diff --git a/README.md b/README.md index 047185d..cb945ed 100644 --- a/README.md +++ b/README.md @@ -208,11 +208,15 @@ type Package struct { Namespace string // @scope for npm, groupId for maven LatestVersion string // latest version (populated by some registries) Metadata map[string]any // registry-specific data + Maintainers []Maintainer // populated when the package endpoint includes them + FundingLinks []string // funding or sponsorship URLs } ``` Some registries (npm, pub, deno, conda) populate `LatestVersion` directly. For others, use `FetchLatestVersionFromPURL`. +`Maintainers` is filled in by npm and hex, whose package endpoints already return maintainer data. Other registries leave it nil; use `FetchMaintainers` for those. `FundingLinks` is filled in from npm `funding`, RubyGems `funding_uri`, Hex links and PyPI `project_urls` whose labels mention funding, sponsoring or donating. + ### Version ```go diff --git a/internal/core/funding.go b/internal/core/funding.go new file mode 100644 index 0000000..9102eb6 --- /dev/null +++ b/internal/core/funding.go @@ -0,0 +1,51 @@ +package core + +import ( + "sort" + "strings" +) + +// fundingLabelWords are substrings that mark a registry link label as a +// funding page, matched case-insensitively ("Funding", "Sponsor", "Donate"). +var fundingLabelWords = []string{"fund", "sponsor", "donat"} + +// IsFundingLabel reports whether a link label from registry metadata names +// a funding page, such as PyPI project_urls or Hex package links. +func IsFundingLabel(label string) bool { + label = strings.ToLower(label) + for _, word := range fundingLabelWords { + if strings.Contains(label, word) { + return true + } + } + return false +} + +// FundingLinksFromLabels returns the URLs in links whose labels name a +// funding page. The result is sorted and deduplicated so output is stable +// across map iteration order. It returns nil when no labels match. +func FundingLinksFromLabels(links map[string]string) []string { + var funding []string + for label, link := range links { + if IsFundingLabel(label) { + funding = AppendFundingLink(funding, link) + } + } + sort.Strings(funding) + return funding +} + +// AppendFundingLink appends link to links after trimming whitespace, +// skipping empty values and links already present. +func AppendFundingLink(links []string, link string) []string { + link = strings.TrimSpace(link) + if link == "" { + return links + } + for _, existing := range links { + if existing == link { + return links + } + } + return append(links, link) +} diff --git a/internal/core/funding_test.go b/internal/core/funding_test.go new file mode 100644 index 0000000..01c39b9 --- /dev/null +++ b/internal/core/funding_test.go @@ -0,0 +1,86 @@ +package core + +import ( + "reflect" + "testing" +) + +func TestIsFundingLabel(t *testing.T) { + tests := []struct { + label string + want bool + }{ + {"Funding", true}, + {"Sponsor", true}, + {"GitHub Sponsors", true}, + {"Donate", true}, + {"donation", true}, + {"Open Collective Fund", true}, + {"Homepage", false}, + {"Source", false}, + {"Changelog", false}, + {"", false}, + } + for _, tt := range tests { + if got := IsFundingLabel(tt.label); got != tt.want { + t.Errorf("IsFundingLabel(%q) = %v, want %v", tt.label, got, tt.want) + } + } +} + +func TestFundingLinksFromLabels(t *testing.T) { + tests := []struct { + name string + links map[string]string + want []string + }{ + { + name: "matches funding labels sorted and deduplicated", + links: map[string]string{ + "Sponsor": "https://github.com/sponsors/b", + "Funding": "https://opencollective.com/a", + "Donate": " https://github.com/sponsors/b ", + "Homepage": "https://example.com", + }, + want: []string{"https://github.com/sponsors/b", "https://opencollective.com/a"}, + }, + { + name: "skips empty values", + links: map[string]string{"Funding": " "}, + want: nil, + }, + { + name: "no funding labels", + links: map[string]string{"Source": "https://github.com/a/b"}, + want: nil, + }, + { + name: "nil map", + links: nil, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := FundingLinksFromLabels(tt.links); !reflect.DeepEqual(got, tt.want) { + t.Errorf("FundingLinksFromLabels() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestAppendFundingLink(t *testing.T) { + var links []string + links = AppendFundingLink(links, "https://a.example") + links = AppendFundingLink(links, "") + links = AppendFundingLink(links, " https://a.example ") + links = AppendFundingLink(links, "https://b.example") + + want := []string{"https://a.example", "https://b.example"} + if !reflect.DeepEqual(links, want) { + t.Errorf("AppendFundingLink() = %#v, want %#v", links, want) + } + if got := AppendFundingLink(nil, ""); got != nil { + t.Errorf("AppendFundingLink(nil, \"\") = %#v, want nil", got) + } +} diff --git a/internal/core/types.go b/internal/core/types.go index 3e35046..170b898 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -14,6 +14,14 @@ type Package struct { Namespace string // @scope for npm, groupId for maven LatestVersion string // latest version if returned by registry Metadata map[string]any // registry-specific data + + // Maintainers is populated when the package endpoint already returns + // maintainer data. It is nil otherwise; use FetchMaintainers instead. + Maintainers []Maintainer + + // FundingLinks lists funding or sponsorship URLs declared for the + // package. It is nil when the registry exposes none. + FundingLinks []string } // Version represents a specific version of a package. diff --git a/internal/hex/hex.go b/internal/hex/hex.go index 7db3167..bc6eb46 100644 --- a/internal/hex/hex.go +++ b/internal/hex/hex.go @@ -129,6 +129,11 @@ func (r *Registry) FetchPackage(ctx context.Context, name string) (*core.Package } } + var maintainers []core.Maintainer + if len(resp.Owners) > 0 { + maintainers = convertOwners(resp.Owners) + } + return &core.Package{ Name: resp.Name, Description: resp.Meta.Description, @@ -139,6 +144,8 @@ func (r *Registry) FetchPackage(ctx context.Context, name string) (*core.Package "downloads": resp.Downloads.All, "links": resp.Meta.Links, }, + Maintainers: maintainers, + FundingLinks: core.FundingLinksFromLabels(resp.Meta.Links), }, nil } @@ -241,16 +248,19 @@ func (r *Registry) FetchMaintainers(ctx context.Context, name string) ([]core.Ma return nil, err } - maintainers := make([]core.Maintainer, len(resp.Owners)) - for i, owner := range resp.Owners { + return convertOwners(resp.Owners), nil +} + +func convertOwners(owners []ownerInfo) []core.Maintainer { + maintainers := make([]core.Maintainer, len(owners)) + for i, owner := range owners { maintainers[i] = core.Maintainer{ UUID: owner.Username, Login: owner.Username, Email: owner.Email, } } - - return maintainers, nil + return maintainers } type URLs struct { diff --git a/internal/hex/package_fields_test.go b/internal/hex/package_fields_test.go new file mode 100644 index 0000000..58703c9 --- /dev/null +++ b/internal/hex/package_fields_test.go @@ -0,0 +1,74 @@ +package hex + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/git-pkgs/registries/internal/core" +) + +func TestFetchPackageMaintainersAndFunding(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := packageResponse{ + Name: "demo", + Meta: metaInfo{ + Links: map[string]string{ + "GitHub": "https://github.com/demo/demo", + "Sponsor": "https://github.com/sponsors/demo", + }, + }, + Owners: []ownerInfo{ + {Username: "alice", Email: "alice@example.com"}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + reg := New(server.URL, core.DefaultClient()) + pkg, err := reg.FetchPackage(context.Background(), "demo") + if err != nil { + t.Fatalf("FetchPackage failed: %v", err) + } + + wantMaintainers := []core.Maintainer{ + {UUID: "alice", Login: "alice", Email: "alice@example.com"}, + } + if !reflect.DeepEqual(pkg.Maintainers, wantMaintainers) { + t.Errorf("Maintainers = %#v, want %#v", pkg.Maintainers, wantMaintainers) + } + + wantFunding := []string{"https://github.com/sponsors/demo"} + if !reflect.DeepEqual(pkg.FundingLinks, wantFunding) { + t.Errorf("FundingLinks = %#v, want %#v", pkg.FundingLinks, wantFunding) + } +} + +func TestFetchPackageWithoutMaintainersOrFunding(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := packageResponse{ + Name: "bare", + Meta: metaInfo{Links: map[string]string{"GitHub": "https://github.com/bare/bare"}}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + reg := New(server.URL, core.DefaultClient()) + pkg, err := reg.FetchPackage(context.Background(), "bare") + if err != nil { + t.Fatalf("FetchPackage failed: %v", err) + } + if pkg.Maintainers != nil { + t.Errorf("Maintainers = %#v, want nil", pkg.Maintainers) + } + if pkg.FundingLinks != nil { + t.Errorf("FundingLinks = %#v, want nil", pkg.FundingLinks) + } +} diff --git a/internal/npm/npm.go b/internal/npm/npm.go index a921161..b59a610 100644 --- a/internal/npm/npm.go +++ b/internal/npm/npm.go @@ -209,6 +209,10 @@ func (r *Registry) FetchPackage(ctx context.Context, name string) (*core.Package "dist-tags": resp.DistTags, "funding": latest.Funding, }, + FundingLinks: extractFundingLinks(latest.Funding), + } + if len(resp.Maintainers) > 0 { + pkg.Maintainers = convertMaintainers(resp.Maintainers) } return pkg, nil @@ -354,16 +358,41 @@ func (r *Registry) FetchMaintainers(ctx context.Context, name string) ([]core.Ma return nil, err } - maintainers := make([]core.Maintainer, len(resp.Maintainers)) - for i, m := range resp.Maintainers { + return convertMaintainers(resp.Maintainers), nil +} + +func convertMaintainers(infos []maintainerInfo) []core.Maintainer { + maintainers := make([]core.Maintainer, len(infos)) + for i, m := range infos { maintainers[i] = core.Maintainer{ UUID: m.Name, Login: m.Name, Email: m.Email, } } + return maintainers +} - return maintainers, nil +// extractFundingLinks normalizes the package.json "funding" field, which may +// be a URL string, an object with a "url" key, or an array mixing both. +// Declaration order is kept since npm treats the first entry as primary. +func extractFundingLinks(v interface{}) []string { + items, ok := v.([]interface{}) + if !ok { + items = []interface{}{v} + } + var links []string + for _, item := range items { + switch f := item.(type) { + case string: + links = core.AppendFundingLink(links, f) + case map[string]interface{}: + if u, ok := f["url"].(string); ok { + links = core.AppendFundingLink(links, u) + } + } + } + return links } func extractString(v interface{}) string { diff --git a/internal/npm/package_fields_test.go b/internal/npm/package_fields_test.go new file mode 100644 index 0000000..baf9481 --- /dev/null +++ b/internal/npm/package_fields_test.go @@ -0,0 +1,117 @@ +package npm + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/git-pkgs/registries/internal/core" +) + +func TestFetchPackageMaintainersAndFunding(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := map[string]interface{}{ + "_id": "demo", + "name": "demo", + "dist-tags": map[string]string{"latest": "1.0.0"}, + "versions": map[string]interface{}{ + "1.0.0": map[string]interface{}{ + "name": "demo", + "version": "1.0.0", + "funding": []interface{}{ + "https://github.com/sponsors/demo", + map[string]string{"type": "opencollective", "url": "https://opencollective.com/demo"}, + }, + }, + }, + "maintainers": []map[string]string{ + {"name": "alice", "email": "alice@example.com"}, + {"name": "bob", "email": "bob@example.com"}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + reg := New(server.URL, core.DefaultClient()) + pkg, err := reg.FetchPackage(context.Background(), "demo") + if err != nil { + t.Fatalf("FetchPackage failed: %v", err) + } + + wantMaintainers := []core.Maintainer{ + {UUID: "alice", Login: "alice", Email: "alice@example.com"}, + {UUID: "bob", Login: "bob", Email: "bob@example.com"}, + } + if !reflect.DeepEqual(pkg.Maintainers, wantMaintainers) { + t.Errorf("Maintainers = %#v, want %#v", pkg.Maintainers, wantMaintainers) + } + + wantFunding := []string{"https://github.com/sponsors/demo", "https://opencollective.com/demo"} + if !reflect.DeepEqual(pkg.FundingLinks, wantFunding) { + t.Errorf("FundingLinks = %#v, want %#v", pkg.FundingLinks, wantFunding) + } +} + +func TestFetchPackageWithoutMaintainersOrFunding(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := map[string]interface{}{ + "_id": "bare", + "name": "bare", + "dist-tags": map[string]string{"latest": "1.0.0"}, + "versions": map[string]interface{}{ + "1.0.0": map[string]interface{}{"name": "bare", "version": "1.0.0"}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + reg := New(server.URL, core.DefaultClient()) + pkg, err := reg.FetchPackage(context.Background(), "bare") + if err != nil { + t.Fatalf("FetchPackage failed: %v", err) + } + if pkg.Maintainers != nil { + t.Errorf("Maintainers = %#v, want nil", pkg.Maintainers) + } + if pkg.FundingLinks != nil { + t.Errorf("FundingLinks = %#v, want nil", pkg.FundingLinks) + } +} + +func TestExtractFundingLinks(t *testing.T) { + tests := []struct { + name string + funding string + want []string + }{ + {"string", `"https://github.com/sponsors/a"`, []string{"https://github.com/sponsors/a"}}, + {"object", `{"type":"patreon","url":"https://patreon.com/a"}`, []string{"https://patreon.com/a"}}, + {"object without url", `{"type":"patreon"}`, nil}, + { + "array keeps order and drops duplicates", + `["https://b.example",{"url":"https://a.example"},"https://b.example",""]`, + []string{"https://b.example", "https://a.example"}, + }, + {"null", `null`, nil}, + {"empty string", `""`, nil}, + {"unexpected type", `42`, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var v interface{} + if err := json.Unmarshal([]byte(tt.funding), &v); err != nil { + t.Fatalf("invalid test JSON: %v", err) + } + if got := extractFundingLinks(v); !reflect.DeepEqual(got, tt.want) { + t.Errorf("extractFundingLinks(%s) = %#v, want %#v", tt.funding, got, tt.want) + } + }) + } +} diff --git a/internal/pypi/package_fields_test.go b/internal/pypi/package_fields_test.go new file mode 100644 index 0000000..4c9516f --- /dev/null +++ b/internal/pypi/package_fields_test.go @@ -0,0 +1,54 @@ +package pypi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/git-pkgs/registries/internal/core" +) + +func TestFetchPackageFundingLinks(t *testing.T) { + tests := []struct { + name string + projectURLs map[string]string + want []string + }{ + { + name: "funding and sponsor entries", + projectURLs: map[string]string{ + "Source": "https://github.com/pallets/flask", + "Funding": "https://palletsprojects.com/donate", + "Sponsor": "https://github.com/sponsors/pallets", + }, + want: []string{"https://github.com/sponsors/pallets", "https://palletsprojects.com/donate"}, + }, + { + name: "no funding entries", + projectURLs: map[string]string{"Source": "https://github.com/pallets/flask"}, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := versionInfoResponse{Info: infoBlock{Name: "flask", Version: "3.0.0", ProjectURLs: tt.projectURLs}} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + reg := New(server.URL, core.DefaultClient()) + pkg, err := reg.FetchPackage(context.Background(), "flask") + if err != nil { + t.Fatalf("FetchPackage failed: %v", err) + } + if !reflect.DeepEqual(pkg.FundingLinks, tt.want) { + t.Errorf("FundingLinks = %#v, want %#v", pkg.FundingLinks, tt.want) + } + }) + } +} diff --git a/internal/pypi/pypi.go b/internal/pypi/pypi.go index 0b2c5ab..128417d 100644 --- a/internal/pypi/pypi.go +++ b/internal/pypi/pypi.go @@ -113,6 +113,7 @@ func (r *Registry) FetchPackage(ctx context.Context, name string) (*core.Package "documentation": resp.Info.ProjectURLs["Documentation"], "normalized_name": normalizeName(resp.Info.Name), }, + FundingLinks: core.FundingLinksFromLabels(resp.Info.ProjectURLs), }, nil } diff --git a/internal/rubygems/package_fields_test.go b/internal/rubygems/package_fields_test.go new file mode 100644 index 0000000..ee5047f --- /dev/null +++ b/internal/rubygems/package_fields_test.go @@ -0,0 +1,45 @@ +package rubygems + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/git-pkgs/registries/internal/core" +) + +func TestFetchPackageFundingLinks(t *testing.T) { + tests := []struct { + name string + fundingURI string + want []string + }{ + {"with funding uri", "https://github.com/sponsors/rails", []string{"https://github.com/sponsors/rails"}}, + {"without funding uri", "", nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := gemResponse{Name: "rails", Version: "7.1.0", FundingURI: tt.fundingURI} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + reg := New(server.URL, core.DefaultClient()) + pkg, err := reg.FetchPackage(context.Background(), "rails") + if err != nil { + t.Fatalf("FetchPackage failed: %v", err) + } + if !reflect.DeepEqual(pkg.FundingLinks, tt.want) { + t.Errorf("FundingLinks = %#v, want %#v", pkg.FundingLinks, tt.want) + } + if pkg.Maintainers != nil { + t.Errorf("Maintainers = %#v, want nil", pkg.Maintainers) + } + }) + } +} diff --git a/internal/rubygems/rubygems.go b/internal/rubygems/rubygems.go index 3515fd9..8bac6f6 100644 --- a/internal/rubygems/rubygems.go +++ b/internal/rubygems/rubygems.go @@ -122,6 +122,7 @@ func (r *Registry) FetchPackage(ctx context.Context, name string) (*core.Package "downloads": resp.Downloads, "funding_uri": resp.FundingURI, }, + FundingLinks: core.AppendFundingLink(nil, resp.FundingURI), }, nil }