Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# registries

Go library for fetching package metadata from registry APIs. Supports 26 ecosystems with a unified interface. Also provides sub-packages for HTTP client usage (`client/`) and streaming artifact downloads (`fetch/`).
Go library for fetching package metadata from registry APIs. Supports 27 ecosystems with a unified interface. Also provides sub-packages for HTTP client usage (`client/`) and streaming artifact downloads (`fetch/`).

## Installation

Expand Down Expand Up @@ -192,6 +192,45 @@ import _ "github.com/git-pkgs/registries/all"
| Homebrew | `brew` | https://formulae.brew.sh |
| Deno | `deno` | https://apiland.deno.dev |
| Terraform | `terraform` | https://registry.terraform.io |
| Swift | `swift` | URL required |

### Swift registries

Swift lookups require an explicit registry URL and a registry identity:

```go
reg, err := registries.New("swift", "https://registry.example.com", nil)
// Handle err before using reg.
pkg, err := reg.FetchPackage(ctx, "scope.package-name")
versions, err := reg.FetchVersions(ctx, "scope.package-name")
```

Import `registries/all` to register the adapter. Identities are case-insensitive.
Source coordinates such as `github.com/owner/repo` are rejected; the adapter does
not infer registry identities from repository names. Swift source-based PURLs
cannot drive these lookups, and `URLs().PURL` returns an empty string.

`FetchPackage` reads metadata from the release named by the registry's
`latest-version` link, falling back to the highest available stable release and
then the highest prerelease. Pagination and release metadata URLs must stay on
the registry's origin.
`FetchVersions` follows pagination and fetches each available release
for its publication time, source archive URL and SHA256 checksum. A release
reported with a protocol `problem` is returned as `retracted`, with the problem
retained in its metadata. Other fetch or decoding failures return an error rather
than a partial version list.

Release metadata retains fields such as `licenseURL`, `author` and
`repositoryURLs`. A license URL is not converted into an SPDX expression, and an
author is not treated as a maintainer. Original publication time takes precedence
over registry publication time. Archive signatures are retained in artifact
metadata but are not verified.

`FetchDependencies` and `FetchMaintainers` return errors wrapping
`errors.ErrUnsupported`. The registry protocol supplies executable Swift
manifests rather than structured dependency data, and has no maintainer endpoint.
The adapter does not execute Swift or download archives. Authentication can be
provided through the shared client's HTTP transport.

## Types

Expand Down
3 changes: 2 additions & 1 deletion all/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
//
// // Now all ecosystems are available
// ecosystems := registries.SupportedEcosystems()
// // ["brew", "cargo", "clojars", "cocoapods", "composer", "conda", "cpan", "cran", "deno", "dub", "elm", "gem", "golang", "hackage", "haxelib", "helm", "hex", "julia", "luarocks", "maven", "nimble", "npm", "nuget", "pub", "pypi", "terraform"]
// // ["brew", "cargo", "clojars", "cocoapods", "composer", "conda", "cpan", "cran", "deno", "dub", "elm", "gem", "golang", "hackage", "haxelib", "helm", "hex", "julia", "luarocks", "maven", "nimble", "npm", "nuget", "pub", "pypi", "swift", "terraform"]
package all

import (
Expand Down Expand Up @@ -38,5 +38,6 @@ import (
_ "github.com/git-pkgs/registries/internal/pub"
_ "github.com/git-pkgs/registries/internal/pypi"
_ "github.com/git-pkgs/registries/internal/rubygems"
_ "github.com/git-pkgs/registries/internal/swift"
_ "github.com/git-pkgs/registries/internal/terraform"
)
58 changes: 39 additions & 19 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,29 @@ func DefaultClient() *Client {

// GetJSON fetches a URL and decodes the JSON response into v.
func (c *Client) GetJSON(ctx context.Context, url string, v any) error {
body, err := c.GetBody(ctx, url)
_, err := c.GetJSONWithHeaders(ctx, url, nil, v)
return err
}

// GetJSONWithHeaders sends request headers and returns response headers.
// It uses the same retry and rate-limit policy as GetJSON.
func (c *Client) GetJSONWithHeaders(ctx context.Context, url string, headers http.Header, v any) (http.Header, error) {
body, responseHeaders, err := c.getBody(ctx, url, headers)
if err != nil {
return err
return responseHeaders, err
}
return json.Unmarshal(body, v)
return responseHeaders, json.Unmarshal(body, v)
}

// GetBody fetches a URL and returns the response body.
func (c *Client) GetBody(ctx context.Context, url string) ([]byte, error) {
body, _, err := c.getBody(ctx, url, nil)
return body, err
}

func (c *Client) getBody(ctx context.Context, url string, headers http.Header) ([]byte, http.Header, error) {
var lastErr error
var lastHeaders http.Header

for attempt := 0; attempt <= c.MaxRetries; attempt++ {
if attempt > 0 {
Expand All @@ -73,64 +86,71 @@ func (c *Client) GetBody(ctx context.Context, url string) ([]byte, error) {

select {
case <-ctx.Done():
return nil, ctx.Err()
return nil, nil, ctx.Err()
case <-time.After(delay):
}
}

if c.RateLimiter != nil {
if err := c.RateLimiter.Wait(ctx); err != nil {
return nil, err
return nil, nil, err
}
}

body, err := c.doRequest(ctx, url)
body, responseHeaders, err := c.doRequest(ctx, url, headers)
if err == nil {
return body, nil
return body, responseHeaders, nil
}

lastErr = err
lastHeaders = responseHeaders
if errors.Is(err, ErrUpstreamHeld) {
return nil, err
return nil, responseHeaders, err
}
var rateErr *RateLimitError
if errors.As(err, &rateErr) {
return nil, err
return nil, responseHeaders, err
}

var httpErr *HTTPError
if ok := isHTTPError(err, &httpErr); ok {
if httpErr.StatusCode == http.StatusNotFound {
return nil, err
return nil, responseHeaders, err
}
if httpErr.StatusCode == http.StatusTooManyRequests || httpErr.StatusCode >= http.StatusInternalServerError {
continue
}
return nil, err
return nil, responseHeaders, err
}
}

return nil, lastErr
return nil, lastHeaders, lastErr
}

func (c *Client) doRequest(ctx context.Context, url string) ([]byte, error) {
func (c *Client) doRequest(ctx context.Context, url string, headers http.Header) ([]byte, http.Header, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
return nil, nil, err
}

req.Header.Set("User-Agent", c.UserAgent)
req.Header.Set("Accept", "application/json")
for key, values := range headers {
req.Header.Del(key)
for _, value := range values {
req.Header.Add(key, value)
}
}

resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
return nil, nil, err
}
defer func() { _ = resp.Body.Close() }()

body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
if err != nil {
return nil, err
return nil, resp.Header, err
}

if resp.StatusCode >= http.StatusBadRequest {
Expand All @@ -145,13 +165,13 @@ func (c *Client) doRequest(ctx context.Context, url string) ([]byte, error) {
if delay%time.Second != 0 {
seconds++
}
return nil, &RateLimitError{RetryAfter: int(seconds)}
return nil, resp.Header, &RateLimitError{RetryAfter: int(seconds)}
}
}
return nil, httpErr
return nil, resp.Header, httpErr
}

return body, nil
return body, resp.Header, nil
}

func isHTTPError(err error, target **HTTPError) bool {
Expand Down
74 changes: 74 additions & 0 deletions client/headers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package client

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)

func TestGetJSONWithHeaders(t *testing.T) {
attempts := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if r.Header.Get("Accept") != "application/vnd.swift.registry.v1+json" || r.Header.Get("User-Agent") != "registries" {
t.Errorf("headers = %#v", r.Header)
}
if attempts == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Add("Link", `<?page=2>; rel="next"`)
_, _ = fmt.Fprint(w, `{"ok":true}`)
}))
defer server.Close()
c := DefaultClient()
c.MaxRetries = 1
c.BaseDelay = 0
requestHeaders := http.Header{"Accept": {"application/vnd.swift.registry.v1+json"}}
var result struct{ OK bool }
headers, err := c.GetJSONWithHeaders(context.Background(), server.URL, requestHeaders, &result)
if err != nil || !result.OK || attempts != 2 || headers.Get("Link") == "" {
t.Fatalf("result = %#v, headers = %#v, attempts = %d, err = %v", result, headers, attempts, err)
}
if requestHeaders.Get("User-Agent") != "" {
t.Fatal("caller headers mutated")
}
}

func TestGetJSONDefaultHeadersUnchanged(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") != "application/json" {
t.Errorf("Accept = %q", r.Header.Get("Accept"))
}
_, _ = fmt.Fprint(w, `{}`)
}))
defer server.Close()
var response map[string]any
if err := DefaultClient().GetJSON(context.Background(), server.URL, &response); err != nil {
t.Fatal(err)
}
}

func TestGetJSONWithHeadersFailure(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusServiceUnavailable, http.StatusTooManyRequests} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Version", "1")
if status == http.StatusTooManyRequests {
w.Header().Set("Retry-After", "10")
}
w.WriteHeader(status)
}))
defer server.Close()
c := DefaultClient()
c.MaxRetries = 0
var result any
headers, err := c.GetJSONWithHeaders(context.Background(), server.URL, nil, &result)
if err == nil || headers.Get("Content-Version") != "1" {
t.Errorf("headers = %#v, err = %v", headers, err)
}
})
}
}
72 changes: 72 additions & 0 deletions internal/swift/links.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package swift

import (
"fmt"
"mime"
"net/http"
"net/url"
"regexp"
"strings"
)

var linkPattern = regexp.MustCompile(`<([^>]*)>((?:[^",]|"(?:\\.|[^"\\])*")*)`)

func responseLinks(headers http.Header) map[string][]string {
links := make(map[string][]string)
for _, value := range headers.Values("Link") {
for _, match := range linkPattern.FindAllStringSubmatch(value, -1) {
_, params, err := mime.ParseMediaType("link" + strings.TrimSpace(match[2]))
if err != nil {
continue
}
for rel := range strings.FieldsSeq(strings.ToLower(params["rel"])) {
links[rel] = append(links[rel], match[1])
}
}
}
return links
}

func resolveLink(baseURL, reference string) (string, error) {
base, err := url.Parse(baseURL)
if err != nil {
return "", err
}
ref, err := url.Parse(reference)
if err != nil {
return "", err
}
resolved := base.ResolveReference(ref)
if (resolved.Scheme != "http" && resolved.Scheme != "https") || resolved.Host == "" ||
resolved.User != nil || resolved.Fragment != "" {
return "", fmt.Errorf("invalid Swift registry link %q", reference)
}
return resolved.String(), nil
}

func nextPage(page string, links map[string][]string) (string, error) {
next := links["next"]
if len(next) == 0 {
return "", nil
}
if len(next) != 1 {
return "", fmt.Errorf("multiple next-page links in Swift registry response")
}
resolved, err := resolveLink(page, next[0])
if err != nil {
return "", err
}
if !sameOrigin(page, resolved) {
return "", fmt.Errorf("swift registry pagination changed origin")
}
return resolved, nil
}

func sameOrigin(a, b string) bool {
first, err := url.Parse(a)
if err != nil {
return false
}
second, err := url.Parse(b)
return err == nil && first.Scheme == second.Scheme && first.Host == second.Host
}
Loading
Loading