From 2c33fd13943df05a100a9a70a0cf0cbd19022af4 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 5 Apr 2026 10:00:30 +0100 Subject: [PATCH 1/3] Fix Composer minified metadata expansion and namespaced package routing Packagist serves metadata in a minified format where only the first version entry has all fields and subsequent entries inherit from the previous one. The proxy was passing this through without expanding it, which meant cooldown filtering could break the inheritance chain (losing fields like `name`) and `~dev` sentinel markers were silently dropped. The proxy now expands the minified format before filtering and rewriting, ensuring every version entry is self-contained. Web UI and API routes used single-segment chi URL params for package names, which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog` would match the version show route instead of the package show route. All `/package/` and related API routes now use wildcard paths with a `resolvePackageName` helper that tries increasingly longer path prefixes as package names via DB lookup, correctly handling namespaced packages across all endpoints (show, version, browse, compare, vulns). Fixes #61, fixes #62 --- internal/handler/composer.go | 49 +++++++- internal/handler/composer_test.go | 195 ++++++++++++++++++++++++++++++ internal/server/api.go | 127 +++++++++---------- internal/server/api_test.go | 44 ++----- internal/server/browse.go | 147 ++++++++++++---------- internal/server/resolve.go | 41 +++++++ internal/server/resolve_test.go | 120 ++++++++++++++++++ internal/server/server.go | 131 +++++++++++++++----- internal/server/server_test.go | 58 +++++++-- 9 files changed, 716 insertions(+), 196 deletions(-) create mode 100644 internal/server/resolve.go create mode 100644 internal/server/resolve_test.go diff --git a/internal/handler/composer.go b/internal/handler/composer.go index 3b76a9c..b9edbdd 100644 --- a/internal/handler/composer.go +++ b/internal/handler/composer.go @@ -128,7 +128,9 @@ func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.R } // rewriteMetadata rewrites dist URLs in Composer metadata to point at this proxy. -// If cooldown is enabled, versions published too recently are filtered out. +// If the metadata uses the minified Composer v2 format, it is expanded first so +// that every version entry contains all fields. If cooldown is enabled, versions +// published too recently are filtered out. func (h *ComposerHandler) rewriteMetadata(body []byte) ([]byte, error) { var metadata map[string]any if err := json.Unmarshal(body, &metadata); err != nil { @@ -140,18 +142,63 @@ func (h *ComposerHandler) rewriteMetadata(body []byte) ([]byte, error) { return body, nil } + minified := metadata["minified"] == "composer/2.0" + for packageName, versions := range packages { versionList, ok := versions.([]any) if !ok { continue } + if minified { + versionList = expandMinifiedVersions(versionList) + } + packages[packageName] = h.filterAndRewriteVersions(packageName, versionList) } + delete(metadata, "minified") + return json.Marshal(metadata) } +// expandMinifiedVersions expands the Composer v2 minified format where each +// version entry only contains fields that differ from the previous entry. +// The "~dev" sentinel string resets the inheritance chain. +func expandMinifiedVersions(versionList []any) []any { + expanded := make([]any, 0, len(versionList)) + inherited := map[string]any{} + + for _, v := range versionList { + // The "~dev" sentinel resets the inheritance chain for dev versions. + if s, ok := v.(string); ok && s == "~dev" { + inherited = map[string]any{} + continue + } + + vmap, ok := v.(map[string]any) + if !ok { + continue + } + + // Merge inherited fields into a new map, then overlay current fields. + merged := make(map[string]any, len(inherited)+len(vmap)) + for k, val := range inherited { + merged[k] = val + } + for k, val := range vmap { + merged[k] = val + } + + // Update inherited state for next iteration. + inherited = merged + + expanded = append(expanded, merged) + } + + return expanded +} + // filterAndRewriteVersions applies cooldown filtering and rewrites dist URLs // for a single package's version list. func (h *ComposerHandler) filterAndRewriteVersions(packageName string, versionList []any) []any { diff --git a/internal/handler/composer_test.go b/internal/handler/composer_test.go index 567515b..89a4c33 100644 --- a/internal/handler/composer_test.go +++ b/internal/handler/composer_test.go @@ -50,6 +50,201 @@ func TestComposerRewriteMetadata(t *testing.T) { } } +func TestComposerRewriteMetadataExpandsMinified(t *testing.T) { + h := &ComposerHandler{ + proxy: testProxy(), + proxyURL: "http://localhost:8080", + } + + // Minified format: first version has all fields, subsequent versions + // only include fields that changed. The proxy must expand this so every + // version has all fields (including "name"). + input := `{ + "minified": "composer/2.0", + "packages": { + "symfony/console": [ + { + "name": "symfony/console", + "description": "Symfony Console Component", + "version": "6.0.0", + "dist": { + "url": "https://repo.packagist.org/files/symfony/console/6.0.0/abc123.zip", + "type": "zip" + } + }, + { + "version": "5.4.0", + "dist": { + "url": "https://repo.packagist.org/files/symfony/console/5.4.0/def456.zip", + "type": "zip" + } + } + ] + } + }` + + output, err := h.rewriteMetadata([]byte(input)) + if err != nil { + t.Fatalf("rewriteMetadata failed: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(output, &result); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + + // The minified key should be removed from output + if _, ok := result["minified"]; ok { + t.Error("expected minified key to be removed from output") + } + + packages := result["packages"].(map[string]any) + versions := packages["symfony/console"].([]any) + + // Second version should have inherited the "name" and "description" fields + v1 := versions[1].(map[string]any) + if v1["name"] != "symfony/console" { + t.Errorf("second version name = %v, want %q", v1["name"], "symfony/console") + } + if v1["description"] != "Symfony Console Component" { + t.Errorf("second version description = %v, want %q", v1["description"], "Symfony Console Component") + } +} + +func TestComposerRewriteMetadataMinifiedDevReset(t *testing.T) { + h := &ComposerHandler{ + proxy: testProxy(), + proxyURL: "http://localhost:8080", + } + + // The ~dev sentinel resets the inheritance chain for dev versions. + input := `{ + "minified": "composer/2.0", + "packages": { + "symfony/console": [ + { + "name": "symfony/console", + "description": "Symfony Console Component", + "license": ["MIT"], + "version": "6.0.0", + "dist": { + "url": "https://repo.packagist.org/files/symfony/console/6.0.0/abc123.zip", + "type": "zip" + } + }, + "~dev", + { + "name": "symfony/console", + "version": "dev-main", + "dist": { + "url": "https://repo.packagist.org/files/symfony/console/dev-main/xyz789.zip", + "type": "zip" + } + } + ] + } + }` + + output, err := h.rewriteMetadata([]byte(input)) + if err != nil { + t.Fatalf("rewriteMetadata failed: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(output, &result); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + + packages := result["packages"].(map[string]any) + versions := packages["symfony/console"].([]any) + + if len(versions) != 2 { + t.Fatalf("expected 2 versions, got %d", len(versions)) + } + + // Dev version should NOT have inherited "license" or "description" + // from the tagged version (the ~dev sentinel resets inheritance). + devVersion := versions[1].(map[string]any) + if devVersion["version"] != "dev-main" { + t.Errorf("dev version = %v, want %q", devVersion["version"], "dev-main") + } + if _, ok := devVersion["license"]; ok { + t.Error("dev version should not have inherited license field after ~dev reset") + } + if _, ok := devVersion["description"]; ok { + t.Error("dev version should not have inherited description field after ~dev reset") + } +} + +func TestComposerRewriteMetadataCooldownPreservesNames(t *testing.T) { + now := time.Now() + old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339) + veryOld := now.Add(-20 * 24 * time.Hour).Format(time.RFC3339) + recent := now.Add(-1 * time.Hour).Format(time.RFC3339) + + proxy := &Proxy{Logger: slog.Default()} + proxy.Cooldown = &cooldown.Config{Default: "3d"} + + h := &ComposerHandler{ + proxy: proxy, + proxyURL: "http://localhost:8080", + } + + // Minified format where "name" only appears in first version. + // When cooldown filters the first version, remaining versions must + // still have the "name" field after expansion. + input := `{ + "minified": "composer/2.0", + "packages": { + "symfony/console": [ + { + "name": "symfony/console", + "description": "Symfony Console Component", + "version": "7.0.0", + "time": "` + recent + `", + "dist": {"url": "https://repo.packagist.org/7.0.0.zip", "type": "zip"} + }, + { + "version": "6.0.0", + "time": "` + old + `", + "dist": {"url": "https://repo.packagist.org/6.0.0.zip", "type": "zip"} + }, + { + "version": "5.0.0", + "time": "` + veryOld + `", + "dist": {"url": "https://repo.packagist.org/5.0.0.zip", "type": "zip"} + } + ] + } + }` + + output, err := h.rewriteMetadata([]byte(input)) + if err != nil { + t.Fatalf("rewriteMetadata failed: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(output, &result); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + + packages := result["packages"].(map[string]any) + versions := packages["symfony/console"].([]any) + + // v7.0.0 should be filtered by cooldown, leaving v6.0.0 and v5.0.0 + if len(versions) != 2 { + t.Fatalf("expected 2 versions after cooldown, got %d", len(versions)) + } + + // Both remaining versions must have the "name" field + for _, v := range versions { + vmap := v.(map[string]any) + if vmap["name"] != "symfony/console" { + t.Errorf("version %v missing name field, got %v", vmap["version"], vmap["name"]) + } + } +} + func TestComposerRewriteMetadataCooldown(t *testing.T) { now := time.Now() old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339) diff --git a/internal/server/api.go b/internal/server/api.go index e9b91be..e687f6d 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -135,36 +135,59 @@ type BulkResponse struct { Packages map[string]*PackageResponse `json:"packages"` } -// HandleGetPackage handles GET /api/package/{ecosystem}/{name} -// @Summary Get package metadata -// @Description Returns enriched package metadata. URL-encode scoped names (e.g. @scope/name -> %40scope%2Fname). -// @Tags api -// @Produce json -// @Param ecosystem path string true "Ecosystem" -// @Param name path string true "Package name" -// @Success 200 {object} PackageResponse -// @Failure 400 {string} string -// @Failure 404 {string} string -// @Failure 500 {string} string -// @Router /api/package/{ecosystem}/{name} [get] -func (h *APIHandler) HandleGetPackage(w http.ResponseWriter, r *http.Request) { +// HandlePackagePath dispatches /api/package/{ecosystem}/* to the appropriate handler. +// Resolves namespaced package names (Composer vendor/name, npm @scope/name) from the path. +func (h *APIHandler) HandlePackagePath(w http.ResponseWriter, r *http.Request) { ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") + wildcard := chi.URLParam(r, "*") + segments := splitWildcardPath(wildcard) - if ecosystem == "" || name == "" { + if ecosystem == "" || len(segments) == 0 { http.Error(w, "ecosystem and name are required", http.StatusBadRequest) return } - // Handle scoped npm packages (e.g., @scope/name) - if strings.HasPrefix(name, "@") { - // The path is split, so we need to get the rest - rest := chi.URLParam(r, "rest") - if rest != "" { - name = name + "/" + rest + // For the API, we don't have a DB to resolve names, so we use a heuristic: + // the last segment that looks like a version (contains a digit) is the version, + // everything before it is the name. If no version-like segment, it's all name. + // + // With 1 segment: package lookup (name only) + // With 2+ segments: last segment is version, rest is name + // Exception: if this is a namespaced ecosystem and we have exactly 2 segments, + // it could be vendor/name with no version. The enrichment service handles + // both cases (it will try to look up the package either way). + if len(segments) == 1 { + h.getPackage(w, r, ecosystem, segments[0]) + return + } + + // Try the full path as a package name first via enrichment. + // If it resolves, this is a package-only lookup. + fullName := strings.Join(segments, "/") + info, err := h.enrichment.EnrichPackage(r.Context(), ecosystem, fullName) + if err == nil && info != nil { + resp := &PackageResponse{ + Ecosystem: info.Ecosystem, + Name: info.Name, + LatestVersion: info.LatestVersion, + License: info.License, + LicenseCategory: string(h.enrichment.CategorizeLicense(info.License)), + Description: info.Description, + Homepage: info.Homepage, + Repository: info.Repository, + RegistryURL: info.RegistryURL, } + writeJSON(w, resp) + return } + // Otherwise, last segment is the version. + name := strings.Join(segments[:len(segments)-1], "/") + version := segments[len(segments)-1] + h.getVersion(w, r, ecosystem, name, version) +} + +func (h *APIHandler) getPackage(w http.ResponseWriter, r *http.Request, ecosystem, name string) { info, err := h.enrichment.EnrichPackage(r.Context(), ecosystem, name) if err != nil { http.Error(w, "failed to enrich package", http.StatusInternalServerError) @@ -191,28 +214,7 @@ func (h *APIHandler) HandleGetPackage(w http.ResponseWriter, r *http.Request) { writeJSON(w, resp) } -// HandleGetVersion handles GET /api/package/{ecosystem}/{name}/{version} -// @Summary Get version metadata and vulnerabilities -// @Description Returns enriched package+version metadata and vulnerability data. -// @Tags api -// @Produce json -// @Param ecosystem path string true "Ecosystem" -// @Param name path string true "Package name" -// @Param version path string true "Version" -// @Success 200 {object} EnrichmentResponse -// @Failure 400 {string} string -// @Failure 500 {string} string -// @Router /api/package/{ecosystem}/{name}/{version} [get] -func (h *APIHandler) HandleGetVersion(w http.ResponseWriter, r *http.Request) { - ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") - version := chi.URLParam(r, "version") - - if ecosystem == "" || name == "" || version == "" { - http.Error(w, "ecosystem, name, and version are required", http.StatusBadRequest) - return - } - +func (h *APIHandler) getVersion(w http.ResponseWriter, r *http.Request, ecosystem, name, version string) { result, err := h.enrichment.EnrichFull(r.Context(), ecosystem, name, version) if err != nil { http.Error(w, "failed to enrich version", http.StatusInternalServerError) @@ -267,32 +269,31 @@ func (h *APIHandler) HandleGetVersion(w http.ResponseWriter, r *http.Request) { writeJSON(w, resp) } -// HandleGetVulns handles GET /api/vulns/{ecosystem}/{name} -// @Summary Get vulnerabilities for a package or version -// @Description Returns vulnerabilities for a package across versions, or for a specific version if provided. -// @Tags api -// @Produce json -// @Param ecosystem path string true "Ecosystem" -// @Param name path string true "Package name" -// @Param version path string false "Version" -// @Success 200 {object} VulnsResponse -// @Failure 400 {string} string -// @Failure 500 {string} string -// @Router /api/vulns/{ecosystem}/{name} [get] -// @Router /api/vulns/{ecosystem}/{name}/{version} [get] -func (h *APIHandler) HandleGetVulns(w http.ResponseWriter, r *http.Request) { +// HandleVulnsPath dispatches /api/vulns/{ecosystem}/* to the vulns handler. +// Supports both {name} and {name}/{version} paths with namespaced package names. +func (h *APIHandler) HandleVulnsPath(w http.ResponseWriter, r *http.Request) { ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") - version := chi.URLParam(r, "version") + wildcard := chi.URLParam(r, "*") + segments := splitWildcardPath(wildcard) - if ecosystem == "" || name == "" { + if ecosystem == "" || len(segments) == 0 { http.Error(w, "ecosystem and name are required", http.StatusBadRequest) return } - // If no version specified, use "0" to get all vulnerabilities - if version == "" { - version = "0" + // Last segment could be a version. Try full path as name first, + // then split off the last segment as version. + name := strings.Join(segments, "/") + version := "0" + + if len(segments) > 1 { + // Try enrichment with the full path as name. + // If it doesn't resolve, assume last segment is version. + info, err := h.enrichment.EnrichPackage(r.Context(), ecosystem, name) + if err != nil || info == nil { + name = strings.Join(segments[:len(segments)-1], "/") + version = segments[len(segments)-1] + } } vulns, err := h.enrichment.CheckVulnerabilities(r.Context(), ecosystem, name, version) diff --git a/internal/server/api_test.go b/internal/server/api_test.go index 96cce9e..548f324 100644 --- a/internal/server/api_test.go +++ b/internal/server/api_test.go @@ -31,55 +31,37 @@ func TestNewAPIHandler(t *testing.T) { } } -func TestHandleGetPackage_MissingParams(t *testing.T) { +func TestHandlePackagePath_MissingParams(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) svc := enrichment.New(logger) h := NewAPIHandler(svc, nil) - req := httptest.NewRequest("GET", "/api/package//", nil) - req.SetPathValue("ecosystem", "") - req.SetPathValue("name", "") + r := chi.NewRouter() + r.Get("/api/package/{ecosystem}/*", h.HandlePackagePath) + req := httptest.NewRequest("GET", "/api/package//", nil) w := httptest.NewRecorder() - h.HandleGetPackage(w, req) + r.ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + if w.Code != http.StatusBadRequest && w.Code != http.StatusNotFound { + t.Errorf("expected status 400 or 404, got %d", w.Code) } } -func TestHandleGetVersion_MissingParams(t *testing.T) { +func TestHandleVulnsPath_MissingParams(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) svc := enrichment.New(logger) h := NewAPIHandler(svc, nil) - req := httptest.NewRequest("GET", "/api/package///", nil) - req.SetPathValue("ecosystem", "") - req.SetPathValue("name", "") - req.SetPathValue("version", "") - - w := httptest.NewRecorder() - h.HandleGetVersion(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) - } -} - -func TestHandleGetVulns_MissingParams(t *testing.T) { - logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - svc := enrichment.New(logger) - h := NewAPIHandler(svc, nil) + r := chi.NewRouter() + r.Get("/api/vulns/{ecosystem}/*", h.HandleVulnsPath) req := httptest.NewRequest("GET", "/api/vulns//", nil) - req.SetPathValue("ecosystem", "") - req.SetPathValue("name", "") - w := httptest.NewRecorder() - h.HandleGetVulns(w, req) + r.ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + if w.Code != http.StatusBadRequest && w.Code != http.StatusNotFound { + t.Errorf("expected status 400 or 404, got %d", w.Code) } } diff --git a/internal/server/browse.go b/internal/server/browse.go index 372df50..c3ec9f7 100644 --- a/internal/server/browse.go +++ b/internal/server/browse.go @@ -57,10 +57,85 @@ type BrowseFileInfo struct { // @Failure 404 {string} string // @Failure 500 {string} string // @Router /api/browse/{ecosystem}/{name}/{version} [get] -func (s *Server) handleBrowseList(w http.ResponseWriter, r *http.Request) { +// handleBrowsePath dispatches /api/browse/{ecosystem}/* to the appropriate browse handler. +// It resolves namespaced package names by consulting the database. +// +// Supported paths: +// +// {name}/{version} -> browse list +// {name}/{version}/file/{path} -> browse file +func (s *Server) handleBrowsePath(w http.ResponseWriter, r *http.Request) { ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") - version := chi.URLParam(r, "version") + wildcard := chi.URLParam(r, "*") + segments := splitWildcardPath(wildcard) + + if ecosystem == "" || len(segments) < 2 { + http.Error(w, "ecosystem, name, and version required", http.StatusBadRequest) + return + } + + // Check for /file/ in the path for browse file requests. + fileIdx := -1 + for i, seg := range segments { + if seg == "file" && i > 0 { + fileIdx = i + break + } + } + + if fileIdx >= 0 { + // Everything before "file" is name+version, everything after is the file path. + nameVersionSegments := segments[:fileIdx] + filePath := strings.Join(segments[fileIdx+1:], "/") + + name, rest := resolvePackageName(s.db, ecosystem, nameVersionSegments) + if name == "" && len(nameVersionSegments) >= 2 { + name = strings.Join(nameVersionSegments[:len(nameVersionSegments)-1], "/") + rest = nameVersionSegments[len(nameVersionSegments)-1:] + } + if len(rest) != 1 { + http.Error(w, "not found", http.StatusNotFound) + return + } + s.browseFile(w, r, ecosystem, name, rest[0], filePath) + return + } + + // No /file/ segment: this is a browse list. + name, rest := resolvePackageName(s.db, ecosystem, segments) + if name == "" && len(segments) >= 2 { + name = strings.Join(segments[:len(segments)-1], "/") + rest = segments[len(segments)-1:] + } + if len(rest) != 1 { + http.Error(w, "not found", http.StatusNotFound) + return + } + s.browseList(w, r, ecosystem, name, rest[0]) +} + +// handleComparePath dispatches /api/compare/{ecosystem}/* to the compare handler. +// Supported paths: {name}/{fromVersion}/{toVersion} +func (s *Server) handleComparePath(w http.ResponseWriter, r *http.Request) { + ecosystem := chi.URLParam(r, "ecosystem") + wildcard := chi.URLParam(r, "*") + segments := splitWildcardPath(wildcard) + + if ecosystem == "" || len(segments) < 3 { + http.Error(w, "ecosystem, name, fromVersion, and toVersion required", http.StatusBadRequest) + return + } + + // The last two segments are fromVersion and toVersion. + // Everything before that is the package name. + name := strings.Join(segments[:len(segments)-2], "/") + fromVersion := segments[len(segments)-2] + toVersion := segments[len(segments)-1] + + s.compareDiff(w, r, ecosystem, name, fromVersion, toVersion) +} + +func (s *Server) browseList(w http.ResponseWriter, r *http.Request, ecosystem, name, version string) { dirPath := r.URL.Query().Get("path") // Get the artifact for this version @@ -152,13 +227,7 @@ func (s *Server) handleBrowseList(w http.ResponseWriter, r *http.Request) { // @Failure 404 {string} string // @Failure 500 {string} string // @Router /api/browse/{ecosystem}/{name}/{version}/file/{filepath} [get] -func (s *Server) handleBrowseFile(w http.ResponseWriter, r *http.Request) { - ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") - version := chi.URLParam(r, "version") - - // Get the wildcard path - filePath := chi.URLParam(r, "*") +func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, name, version, filePath string) { if filePath == "" { http.Error(w, "file path required", http.StatusBadRequest) return @@ -345,24 +414,7 @@ type BrowseSourceData struct { Version string } -// handleBrowseSource renders the source code browser UI. -// GET /package/{ecosystem}/{name}/{version}/browse -func (s *Server) handleBrowseSource(w http.ResponseWriter, r *http.Request) { - ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") - version := chi.URLParam(r, "version") - - data := BrowseSourceData{ - Ecosystem: ecosystem, - PackageName: name, - Version: version, - } - - if err := s.templates.Render(w, "browse_source", data); err != nil { - s.logger.Error("failed to render browse source page", "error", err) - http.Error(w, "internal server error", http.StatusInternalServerError) - } -} +// handleBrowseSource is now showBrowseSource in server.go, dispatched via handlePackagePath. // handleCompareDiff compares two versions and returns a diff. // GET /api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion} @@ -378,12 +430,7 @@ func (s *Server) handleBrowseSource(w http.ResponseWriter, r *http.Request) { // @Failure 404 {string} string // @Failure 500 {string} string // @Router /api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion} [get] -func (s *Server) handleCompareDiff(w http.ResponseWriter, r *http.Request) { - ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") - fromVersion := chi.URLParam(r, "fromVersion") - toVersion := chi.URLParam(r, "toVersion") - +func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem, name, fromVersion, toVersion string) { // Get artifacts for both versions fromPURL := purl.MakePURLString(ecosystem, name, fromVersion) toPURL := purl.MakePURLString(ecosystem, name, toVersion) @@ -475,34 +522,4 @@ type ComparePageData struct { ToVersion string } -// handleComparePage renders the version comparison UI. -// GET /package/{ecosystem}/{name}/compare/{versions} -// where {versions} is in format "fromVersion...toVersion" -func (s *Server) handleComparePage(w http.ResponseWriter, r *http.Request) { - ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") - versions := chi.URLParam(r, "versions") - - // Parse versions (format: "1.0.0...2.0.0") - const compareVersionParts = 2 - parts := strings.Split(versions, "...") - if len(parts) != compareVersionParts { - http.Error(w, "invalid version format, use: version1...version2", http.StatusBadRequest) - return - } - - fromVersion := parts[0] - toVersion := parts[1] - - data := ComparePageData{ - Ecosystem: ecosystem, - PackageName: name, - FromVersion: fromVersion, - ToVersion: toVersion, - } - - if err := s.templates.Render(w, "compare_versions", data); err != nil { - s.logger.Error("failed to render compare page", "error", err) - http.Error(w, "internal server error", http.StatusInternalServerError) - } -} +// handleComparePage is now showComparePage in server.go, dispatched via handlePackagePath. diff --git a/internal/server/resolve.go b/internal/server/resolve.go new file mode 100644 index 0000000..479ede6 --- /dev/null +++ b/internal/server/resolve.go @@ -0,0 +1,41 @@ +package server + +import ( + "strings" + + "github.com/git-pkgs/proxy/internal/database" +) + +// resolvePackageName determines the package name from a wildcard path by +// checking the database. This handles namespaced packages like Composer's +// vendor/name format where the package name contains a slash. +// +// It tries the full path as a package name first. If not found, it splits +// off the last segment as a non-name suffix (version, action, etc.) and +// tries again, working backwards until a match is found or segments run out. +// +// Returns the package name and the remaining path segments after the name. +// If no package is found, returns empty name and the original segments. +func resolvePackageName(db *database.DB, ecosystem string, segments []string) (name string, rest []string) { + // Try increasingly longer prefixes as the package name. + // Start with the longest possible name (all segments) and work down. + for i := len(segments); i >= 1; i-- { + candidate := strings.Join(segments[:i], "/") + pkg, err := db.GetPackageByEcosystemName(ecosystem, candidate) + if err == nil && pkg != nil { + return candidate, segments[i:] + } + } + + return "", segments +} + +// splitWildcardPath splits a chi wildcard path value into segments, +// trimming any leading/trailing slashes. +func splitWildcardPath(path string) []string { + path = strings.Trim(path, "/") + if path == "" { + return nil + } + return strings.Split(path, "/") +} diff --git a/internal/server/resolve_test.go b/internal/server/resolve_test.go new file mode 100644 index 0000000..427c2cb --- /dev/null +++ b/internal/server/resolve_test.go @@ -0,0 +1,120 @@ +package server + +import ( + "os" + "path/filepath" + "testing" + + "github.com/git-pkgs/proxy/internal/database" +) + +func newTestDB(t *testing.T) (*database.DB, func()) { + t.Helper() + dir, err := os.MkdirTemp("", "resolve-test-*") + if err != nil { + t.Fatal(err) + } + db, err := database.Create(filepath.Join(dir, "test.db")) + if err != nil { + _ = os.RemoveAll(dir) + t.Fatal(err) + } + return db, func() { _ = db.Close(); _ = os.RemoveAll(dir) } +} + +func seedPackage(t *testing.T, db *database.DB, ecosystem, name, purl string) { + t.Helper() + if err := db.UpsertPackage(&database.Package{ + PURL: purl, Ecosystem: ecosystem, Name: name, + }); err != nil { + t.Fatalf("failed to upsert package %s: %v", name, err) + } +} + +func TestResolvePackageName(t *testing.T) { + db, cleanup := newTestDB(t) + defer cleanup() + + seedPackage(t, db, "npm", "lodash", "pkg:npm/lodash") + seedPackage(t, db, "composer", "monolog/monolog", "pkg:composer/monolog/monolog") + seedPackage(t, db, "composer", "symfony/console", "pkg:composer/symfony/console") + + tests := []struct { + name string + ecosystem string + segments []string + wantName string + wantRest []string + }{ + { + name: "simple package", ecosystem: "npm", + segments: []string{"lodash"}, wantName: "lodash", wantRest: nil, + }, + { + name: "simple package with version", ecosystem: "npm", + segments: []string{"lodash", "4.17.21"}, wantName: "lodash", wantRest: []string{"4.17.21"}, + }, + { + name: "namespaced package", ecosystem: "composer", + segments: []string{"monolog", "monolog"}, wantName: "monolog/monolog", wantRest: nil, + }, + { + name: "namespaced package with version", ecosystem: "composer", + segments: []string{"symfony", "console", "6.0.0"}, wantName: "symfony/console", wantRest: []string{"6.0.0"}, + }, + { + name: "namespaced with version and action", ecosystem: "composer", + segments: []string{"symfony", "console", "6.0.0", "browse"}, + wantName: "symfony/console", wantRest: []string{"6.0.0", "browse"}, + }, + { + name: "not found", ecosystem: "npm", + segments: []string{"nonexistent"}, wantName: "", wantRest: []string{"nonexistent"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name, rest := resolvePackageName(db, tt.ecosystem, tt.segments) + if name != tt.wantName { + t.Errorf("name = %q, want %q", name, tt.wantName) + } + if len(rest) != len(tt.wantRest) { + t.Errorf("rest = %v, want %v", rest, tt.wantRest) + } else { + for i := range rest { + if rest[i] != tt.wantRest[i] { + t.Errorf("rest[%d] = %q, want %q", i, rest[i], tt.wantRest[i]) + } + } + } + }) + } +} + +func TestSplitWildcardPath(t *testing.T) { + tests := []struct { + input string + want []string + }{ + {"lodash", []string{"lodash"}}, + {"lodash/4.17.21", []string{"lodash", "4.17.21"}}, + {"monolog/monolog", []string{"monolog", "monolog"}}, + {"symfony/console/6.0.0/browse", []string{"symfony", "console", "6.0.0", "browse"}}, + {"", nil}, + {"/", nil}, + } + + for _, tt := range tests { + got := splitWildcardPath(tt.input) + if len(got) != len(tt.want) { + t.Errorf("splitWildcardPath(%q) = %v, want %v", tt.input, got, tt.want) + continue + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("splitWildcardPath(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index bc3187b..090e786 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -43,6 +43,7 @@ import ( "log/slog" "net/http" "strconv" + "strings" "time" swaggerdoc "github.com/git-pkgs/proxy/docs/swagger" @@ -218,30 +219,22 @@ func (s *Server) Start() error { r.Get("/install", s.handleInstall) r.Get("/search", s.handleSearch) r.Get("/packages", s.handlePackagesList) - r.Get("/package/{ecosystem}/{name}", s.handlePackageShow) - r.Get("/package/{ecosystem}/{name}/{version}", s.handleVersionShow) - r.Get("/package/{ecosystem}/{name}/{version}/browse", s.handleBrowseSource) + r.Get("/package/{ecosystem}/*", s.handlePackagePath) // API endpoints for enrichment data enrichSvc := enrichment.New(s.logger) apiHandler := NewAPIHandler(enrichSvc, s.db) - r.Get("/api/package/{ecosystem}/{name}", apiHandler.HandleGetPackage) - r.Get("/api/package/{ecosystem}/{name}/{version}", apiHandler.HandleGetVersion) - r.Get("/api/vulns/{ecosystem}/{name}", apiHandler.HandleGetVulns) - r.Get("/api/vulns/{ecosystem}/{name}/{version}", apiHandler.HandleGetVulns) + r.Get("/api/package/{ecosystem}/*", apiHandler.HandlePackagePath) + r.Get("/api/vulns/{ecosystem}/*", apiHandler.HandleVulnsPath) r.Post("/api/outdated", apiHandler.HandleOutdated) r.Post("/api/bulk", apiHandler.HandleBulkLookup) r.Get("/api/search", apiHandler.HandleSearch) r.Get("/api/packages", apiHandler.HandlePackagesList) - // Archive browsing endpoints - r.Get("/api/browse/{ecosystem}/{name}/{version}", s.handleBrowseList) - r.Get("/api/browse/{ecosystem}/{name}/{version}/file/*", s.handleBrowseFile) - - // Version comparison endpoints - r.Get("/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}", s.handleCompareDiff) - r.Get("/package/{ecosystem}/{name}/compare/{versions}", s.handleComparePage) + // Archive browsing and comparison endpoints also use wildcard for namespaced packages + r.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath) + r.Get("/api/compare/{ecosystem}/*", s.handleComparePath) s.http = &http.Server{ Addr: s.cfg.Listen, @@ -592,15 +585,71 @@ func (s *Server) handlePackagesList(w http.ResponseWriter, r *http.Request) { } } -func (s *Server) handlePackageShow(w http.ResponseWriter, r *http.Request) { +// handlePackagePath dispatches wildcard package routes to the appropriate handler. +// It resolves namespaced package names (e.g., Composer vendor/name) by consulting +// the database to determine which path segments are part of the package name. +// +// Supported paths: +// +// {name} -> package show +// {name}/{version} -> version show +// {name}/{version}/browse -> browse source +// {name}/compare/{v1}...{v2} -> compare versions +func (s *Server) handlePackagePath(w http.ResponseWriter, r *http.Request) { ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") + wildcard := chi.URLParam(r, "*") + segments := splitWildcardPath(wildcard) - if ecosystem == "" || name == "" { - http.Error(w, "ecosystem and name required", http.StatusBadRequest) + if ecosystem == "" || len(segments) == 0 { + http.Error(w, "ecosystem and package name required", http.StatusBadRequest) return } + // Check for compare route: {name}/compare/{versions} + for i, seg := range segments { + if seg == "compare" && i > 0 && i < len(segments)-1 { + name := strings.Join(segments[:i], "/") + versions := strings.Join(segments[i+1:], "/") + s.showComparePage(w, ecosystem, name, versions) + return + } + } + + // Check for browse suffix + browse := false + if len(segments) > 1 && segments[len(segments)-1] == "browse" { + browse = true + segments = segments[:len(segments)-1] + } + + // Resolve package name from the remaining segments using DB lookup. + name, rest := resolvePackageName(s.db, ecosystem, segments) + + if name == "" { + // No package found in DB. Fall back to heuristic: assume the last + // segment is a version (if present) and everything else is the name. + if len(segments) == 1 { + // Single segment, no DB match: try package show (will 404). + s.showPackage(w, ecosystem, segments[0]) + return + } + name = strings.Join(segments[:len(segments)-1], "/") + rest = segments[len(segments)-1:] + } + + switch { + case len(rest) == 0 && !browse: + s.showPackage(w, ecosystem, name) + case len(rest) == 1 && browse: + s.showBrowseSource(w, ecosystem, name, rest[0]) + case len(rest) == 1: + s.showVersion(w, ecosystem, name, rest[0]) + default: + http.Error(w, "not found", http.StatusNotFound) + } +} + +func (s *Server) showPackage(w http.ResponseWriter, ecosystem, name string) { pkg, err := s.db.GetPackageByEcosystemName(ecosystem, name) if err != nil { s.logger.Error("failed to get package", "error", err, "ecosystem", ecosystem, "name", name) @@ -636,16 +685,7 @@ func (s *Server) handlePackageShow(w http.ResponseWriter, r *http.Request) { } } -func (s *Server) handleVersionShow(w http.ResponseWriter, r *http.Request) { - ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") - version := chi.URLParam(r, "version") - - if ecosystem == "" || name == "" || version == "" { - http.Error(w, "ecosystem, name, and version required", http.StatusBadRequest) - return - } - +func (s *Server) showVersion(w http.ResponseWriter, ecosystem, name, version string) { pkg, err := s.db.GetPackageByEcosystemName(ecosystem, name) if err != nil || pkg == nil { s.logger.Error("failed to get package", "error", err) @@ -675,7 +715,6 @@ func (s *Server) handleVersionShow(w http.ResponseWriter, r *http.Request) { isOutdated := pkg.LatestVersion.Valid && pkg.LatestVersion.String != version - // Check if any artifact is cached hasCached := false for _, art := range artifacts { if art.StoragePath.Valid { @@ -699,6 +738,40 @@ func (s *Server) handleVersionShow(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) showBrowseSource(w http.ResponseWriter, ecosystem, name, version string) { + data := BrowseSourceData{ + Ecosystem: ecosystem, + PackageName: name, + Version: version, + } + + if err := s.templates.Render(w, "browse_source", data); err != nil { + s.logger.Error("failed to render browse source page", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + } +} + +func (s *Server) showComparePage(w http.ResponseWriter, ecosystem, name, versions string) { + const compareVersionParts = 2 + parts := strings.Split(versions, "...") + if len(parts) != compareVersionParts { + http.Error(w, "invalid version format, use: version1...version2", http.StatusBadRequest) + return + } + + data := ComparePageData{ + Ecosystem: ecosystem, + PackageName: name, + FromVersion: parts[0], + ToVersion: parts[1], + } + + if err := s.templates.Render(w, "compare_versions", data); err != nil { + s.logger.Error("failed to render compare page", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + } +} + // handleHealth responds with a simple health check. // @Summary Health check // @Tags meta diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 7e56f2c..09e50ea 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -101,13 +101,9 @@ func newTestServer(t *testing.T) *testServer { r.Get("/openapi.json", s.handleOpenAPIJSON) r.Mount("/static", http.StripPrefix("/static/", staticHandler())) r.Get("/search", s.handleSearch) - r.Get("/package/{ecosystem}/{name}", s.handlePackageShow) - r.Get("/package/{ecosystem}/{name}/{version}", s.handleVersionShow) - r.Get("/package/{ecosystem}/{name}/{version}/browse", s.handleBrowseSource) - r.Get("/api/browse/{ecosystem}/{name}/{version}", s.handleBrowseList) - r.Get("/api/browse/{ecosystem}/{name}/{version}/file/*", s.handleBrowseFile) - r.Get("/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}", s.handleCompareDiff) - r.Get("/package/{ecosystem}/{name}/compare/{versions}", s.handleComparePage) + r.Get("/package/{ecosystem}/*", s.handlePackagePath) + r.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath) + r.Get("/api/compare/{ecosystem}/*", s.handleComparePath) r.Get("/", s.handleRoot) r.Get("/install", s.handleInstall) r.Get("/packages", s.handlePackagesList) @@ -701,6 +697,54 @@ func TestPackageShowPage_WithLicense(t *testing.T) { } } +func TestComposerNamespacedPackageRoutes(t *testing.T) { + ts := newTestServer(t) + defer ts.close() + + // Seed two Composer packages with vendor/name format. + for _, p := range []struct { + purl, name, versionPURL string + }{ + {"pkg:composer/monolog/monolog", "monolog/monolog", "pkg:composer/monolog/monolog@3.0.0"}, + {"pkg:composer/symfony/console", "symfony/console", "pkg:composer/symfony/console@6.0.0"}, + } { + if err := ts.db.UpsertPackage(&database.Package{ + PURL: p.purl, Ecosystem: "composer", Name: p.name, + }); err != nil { + t.Fatalf("failed to upsert package %s: %v", p.name, err) + } + if err := ts.db.UpsertVersion(&database.Version{ + PURL: p.versionPURL, PackagePURL: p.purl, + }); err != nil { + t.Fatalf("failed to upsert version for %s: %v", p.name, err) + } + } + + tests := []struct { + name string + url string + want string + }{ + {"package show", "/package/composer/monolog/monolog", "monolog/monolog"}, + {"version show", "/package/composer/symfony/console/6.0.0", "symfony/console"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", tt.url, nil) + w := httptest.NewRecorder() + ts.handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("GET %s: expected status 200, got %d", tt.url, w.Code) + } + if !strings.Contains(w.Body.String(), tt.want) { + t.Errorf("GET %s: expected body to contain %q", tt.url, tt.want) + } + }) + } +} + func TestSearchPage_WithSeededResults(t *testing.T) { ts := newTestServer(t) defer ts.close() From 48006ff408aae20b0e069e1505c4d198972ef82a Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 5 Apr 2026 11:15:57 +0100 Subject: [PATCH 2/3] Add namespaced package routing tests for all affected ecosystems Verifies the wildcard routing handles slashes in package names for npm (@babel/core), Go modules (github.com/stretchr/testify), OCI images (library/nginx), Conda (conda-forge/numpy), and Conan (zlib/1.2.13@demo/stable). --- internal/server/server_test.go | 59 ++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 09e50ea..f0c65cc 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -745,6 +745,65 @@ func TestComposerNamespacedPackageRoutes(t *testing.T) { } } +func TestNamespacedPackageRoutes(t *testing.T) { + ts := newTestServer(t) + defer ts.close() + + // Seed packages from ecosystems that use slashes in package names. + pkgs := []struct { + purl, ecosystem, name, versionPURL string + }{ + // npm scoped packages + {"pkg:npm/%40babel/core", "npm", "@babel/core", "pkg:npm/%40babel/core@7.24.0"}, + // Go modules (multi-segment paths) + {"pkg:golang/github.com/stretchr/testify", "golang", "github.com/stretchr/testify", "pkg:golang/github.com/stretchr/testify@1.9.0"}, + // OCI/container images + {"pkg:oci/library/nginx", "oci", "library/nginx", "pkg:oci/library/nginx@sha256:abc123"}, + // Conda (channel/name) + {"pkg:conda/conda-forge/numpy", "conda", "conda-forge/numpy", "pkg:conda/conda-forge/numpy@1.26.4"}, + // Conan (name/version@user/channel) + {"pkg:conan/zlib/1.2.13@demo/stable", "conan", "zlib/1.2.13@demo/stable", "pkg:conan/zlib/1.2.13@demo/stable@rev1"}, + } + + for _, p := range pkgs { + if err := ts.db.UpsertPackage(&database.Package{ + PURL: p.purl, Ecosystem: p.ecosystem, Name: p.name, + }); err != nil { + t.Fatalf("failed to upsert package %s: %v", p.name, err) + } + if err := ts.db.UpsertVersion(&database.Version{ + PURL: p.versionPURL, PackagePURL: p.purl, + }); err != nil { + t.Fatalf("failed to upsert version for %s: %v", p.name, err) + } + } + + tests := []struct { + name string + url string + want int + }{ + {"npm scoped package show", "/package/npm/@babel/core", http.StatusOK}, + {"golang module show", "/package/golang/github.com/stretchr/testify", http.StatusOK}, + {"oci image show", "/package/oci/library/nginx", http.StatusOK}, + {"conda package show", "/package/conda/conda-forge/numpy", http.StatusOK}, + {"conan package show", "/package/conan/zlib/1.2.13@demo/stable", http.StatusOK}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", tt.url, nil) + w := httptest.NewRecorder() + ts.handler.ServeHTTP(w, req) + + if w.Code != tt.want { + t.Errorf("GET %s: expected status %d, got %d (body: %s)", + tt.url, tt.want, w.Code, w.Body.String()) + } + }) + } +} + func TestSearchPage_WithSeededResults(t *testing.T) { ts := newTestServer(t) defer ts.close() From 46f826565dec6cdc70ee9cede7dcd61e4d4aaf94 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 5 Apr 2026 16:01:41 +0100 Subject: [PATCH 3/3] Regenerate swagger docs after route refactor The swagger annotations for the old per-endpoint handlers were removed during the wildcard routing refactor. Regenerate to match current state. --- docs/swagger/docs.go | 312 -------------------------------------- docs/swagger/swagger.json | 312 -------------------------------------- 2 files changed, 624 deletions(-) diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 76d835d..fedf889 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -297,115 +297,6 @@ const docTemplate = `{ } } }, - "/api/package/{ecosystem}/{name}": { - "get": { - "description": "Returns enriched package metadata. URL-encode scoped names (e.g. @scope/name -\u003e %40scope%2Fname).", - "produces": [ - "application/json" - ], - "tags": [ - "api" - ], - "summary": "Get package metadata", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.PackageResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, - "/api/package/{ecosystem}/{name}/{version}": { - "get": { - "description": "Returns enriched package+version metadata and vulnerability data.", - "produces": [ - "application/json" - ], - "tags": [ - "api" - ], - "summary": "Get version metadata and vulnerabilities", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Version", - "name": "version", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.EnrichmentResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, "/api/packages": { "get": { "produces": [ @@ -505,108 +396,6 @@ const docTemplate = `{ } } }, - "/api/vulns/{ecosystem}/{name}": { - "get": { - "description": "Returns vulnerabilities for a package across versions, or for a specific version if provided.", - "produces": [ - "application/json" - ], - "tags": [ - "api" - ], - "summary": "Get vulnerabilities for a package or version", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.VulnsResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, - "/api/vulns/{ecosystem}/{name}/{version}": { - "get": { - "description": "Returns vulnerabilities for a package across versions, or for a specific version if provided.", - "produces": [ - "application/json" - ], - "tags": [ - "api" - ], - "summary": "Get vulnerabilities for a package or version", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Version", - "name": "version", - "in": "path" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.VulnsResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, "/health": { "get": { "produces": [ @@ -715,29 +504,6 @@ const docTemplate = `{ } } }, - "server.EnrichmentResponse": { - "type": "object", - "properties": { - "is_outdated": { - "type": "boolean" - }, - "license_category": { - "type": "string" - }, - "package": { - "$ref": "#/definitions/server.PackageResponse" - }, - "version": { - "$ref": "#/definitions/server.VersionResponse" - }, - "vulnerabilities": { - "type": "array", - "items": { - "$ref": "#/definitions/server.VulnResponse" - } - } - } - }, "server.OutdatedPackage": { "type": "object", "properties": { @@ -949,84 +715,6 @@ const docTemplate = `{ "type": "integer" } } - }, - "server.VersionResponse": { - "type": "object", - "properties": { - "ecosystem": { - "type": "string" - }, - "integrity": { - "type": "string" - }, - "is_outdated": { - "type": "boolean" - }, - "license": { - "type": "string" - }, - "name": { - "type": "string" - }, - "published_at": { - "type": "string" - }, - "version": { - "type": "string" - }, - "yanked": { - "type": "boolean" - } - } - }, - "server.VulnResponse": { - "type": "object", - "properties": { - "cvss_score": { - "type": "number" - }, - "fixed_version": { - "type": "string" - }, - "id": { - "type": "string" - }, - "references": { - "type": "array", - "items": { - "type": "string" - } - }, - "severity": { - "type": "string" - }, - "summary": { - "type": "string" - } - } - }, - "server.VulnsResponse": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "ecosystem": { - "type": "string" - }, - "name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "vulnerabilities": { - "type": "array", - "items": { - "$ref": "#/definitions/server.VulnResponse" - } - } - } } } }` diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 8f0edb9..88df1e9 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -290,115 +290,6 @@ } } }, - "/api/package/{ecosystem}/{name}": { - "get": { - "description": "Returns enriched package metadata. URL-encode scoped names (e.g. @scope/name -\u003e %40scope%2Fname).", - "produces": [ - "application/json" - ], - "tags": [ - "api" - ], - "summary": "Get package metadata", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.PackageResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, - "/api/package/{ecosystem}/{name}/{version}": { - "get": { - "description": "Returns enriched package+version metadata and vulnerability data.", - "produces": [ - "application/json" - ], - "tags": [ - "api" - ], - "summary": "Get version metadata and vulnerabilities", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Version", - "name": "version", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.EnrichmentResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, "/api/packages": { "get": { "produces": [ @@ -498,108 +389,6 @@ } } }, - "/api/vulns/{ecosystem}/{name}": { - "get": { - "description": "Returns vulnerabilities for a package across versions, or for a specific version if provided.", - "produces": [ - "application/json" - ], - "tags": [ - "api" - ], - "summary": "Get vulnerabilities for a package or version", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.VulnsResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, - "/api/vulns/{ecosystem}/{name}/{version}": { - "get": { - "description": "Returns vulnerabilities for a package across versions, or for a specific version if provided.", - "produces": [ - "application/json" - ], - "tags": [ - "api" - ], - "summary": "Get vulnerabilities for a package or version", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Version", - "name": "version", - "in": "path" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.VulnsResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, "/health": { "get": { "produces": [ @@ -708,29 +497,6 @@ } } }, - "server.EnrichmentResponse": { - "type": "object", - "properties": { - "is_outdated": { - "type": "boolean" - }, - "license_category": { - "type": "string" - }, - "package": { - "$ref": "#/definitions/server.PackageResponse" - }, - "version": { - "$ref": "#/definitions/server.VersionResponse" - }, - "vulnerabilities": { - "type": "array", - "items": { - "$ref": "#/definitions/server.VulnResponse" - } - } - } - }, "server.OutdatedPackage": { "type": "object", "properties": { @@ -942,84 +708,6 @@ "type": "integer" } } - }, - "server.VersionResponse": { - "type": "object", - "properties": { - "ecosystem": { - "type": "string" - }, - "integrity": { - "type": "string" - }, - "is_outdated": { - "type": "boolean" - }, - "license": { - "type": "string" - }, - "name": { - "type": "string" - }, - "published_at": { - "type": "string" - }, - "version": { - "type": "string" - }, - "yanked": { - "type": "boolean" - } - } - }, - "server.VulnResponse": { - "type": "object", - "properties": { - "cvss_score": { - "type": "number" - }, - "fixed_version": { - "type": "string" - }, - "id": { - "type": "string" - }, - "references": { - "type": "array", - "items": { - "type": "string" - } - }, - "severity": { - "type": "string" - }, - "summary": { - "type": "string" - } - } - }, - "server.VulnsResponse": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "ecosystem": { - "type": "string" - }, - "name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "vulnerabilities": { - "type": "array", - "items": { - "$ref": "#/definitions/server.VulnResponse" - } - } - } } } } \ No newline at end of file