Add gems remote type for RubyGems #142

Merged
benvin merged 5 commits from benvin/remote-gems into benvin/remote-head-route 2026-10-17 13:19:01 +11:00
12 changed files with 143 additions and 1 deletions
+1
View File
@@ -33,6 +33,7 @@ API: `http://localhost:8000` | Frontend: `http://localhost:5173`
| `terraform` | `*/versions` | `*/download/*/*` |
| `goproxy` | `@v/list`, `@latest` | `.info`, `.mod`, `.zip` |
| `cargo` | sparse index files (`config.json` synthesized) | `crates/*/*.crate` |
| `gems` | compact index (`versions`, `info/*`, `names`), `*specs.4.8.gz` | `gems/*.gem`, `quick/Marshal.4.8/*.gemspec.rz` |
| `nuget` | `index.json` (service index), registration and flat container indexes | `.nupkg`, `.nuspec` |
| `github_rpm` | `repodata/*` (synthesized) | `.rpm` (redirected) |
+1 -1
View File
@@ -23,7 +23,7 @@ already-running stack.
- **Repository lifecycle** — add / change / delete for remote, local and virtual repos.
- **Caching** — one immutable artifact per remote package type (generic, docker,
helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo, nuget) proxied through the
helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo, gems, nuget) proxied through the
mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
verified against the origin fixture.
- **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/`
+1
View File
@@ -30,6 +30,7 @@ func TestCachingPerProvider(t *testing.T) {
{"puppet", "puppet-releases/author-mod-1.0.0.tar.gz", "puppet-releases/author-mod-1.0.0.tar.gz"},
{"goproxy", "goproxy/example.com/mod/@v/v1.0.0.zip", "goproxy/example.com/mod/@v/v1.0.0.zip"},
{"cargo", "crates/mycrate/mycrate-1.0.0.crate", "crates/mycrate/mycrate-1.0.0.crate"},
{"gems", "gems/mygem-1.0.0.gem", "gems/mygem-1.0.0.gem"},
{"nuget", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg"},
{"terraform", "hashicorp/aws/download/pkg.zip", "v1/providers/hashicorp/aws/download/pkg.zip"},
{"docker", "library/testimg/blobs/blobdata", "v2/library/testimg/blobs/blobdata"},
+1
View File
@@ -0,0 +1 @@
mygem-1.0.0 e2e fixture
+62
View File
@@ -0,0 +1,62 @@
// Package gems proxies a RubyGems source: mutable compact index (versions,
// info/*, names) and legacy specs indexes, plus immutable .gem and
// quick/Marshal.4.8 gemspec downloads.
package gems
import (
"context"
"net/http"
"strings"
"git.unkin.net/unkin/artifactapi/internal/auth"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func init() {
provider.Register(&Provider{})
}
type Provider struct{}
func (p *Provider) Type() models.PackageType { return models.PackageGems }
func (p *Provider) Classify(path string) provider.Mutability {
if isGem(path) || isQuickSpec(path) {
return provider.Immutable
}
return provider.Mutable
}
func (p *Provider) ContentType(path string) string {
path = strings.TrimLeft(path, "/")
if path == "versions" || path == "names" || strings.HasPrefix(path, "info/") {
return "text/plain; charset=utf-8"
}
if strings.HasPrefix(path, "api/v1/") && strings.HasSuffix(path, ".json") {
return "application/json"
}
return "application/octet-stream"
}
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
}
func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte, error) {
return nil, nil
}
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
return auth.BasicHeaders(remote), nil
}
func isGem(path string) bool {
path = strings.TrimLeft(path, "/")
return strings.HasPrefix(path, "gems/") && strings.HasSuffix(path, ".gem")
}
func isQuickSpec(path string) bool {
path = strings.TrimLeft(path, "/")
return strings.HasPrefix(path, "quick/Marshal.4.8/") && strings.HasSuffix(path, ".gemspec.rz")
}
+61
View File
@@ -0,0 +1,61 @@
package gems
import (
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func TestClassify(t *testing.T) {
p := &Provider{}
tests := []struct {
path string
want provider.Mutability
}{
{"gems/rack-3.1.8.gem", provider.Immutable},
{"/gems/nokogiri-1.16.7-x86_64-linux.gem", provider.Immutable},
{"quick/Marshal.4.8/rack-3.1.8.gemspec.rz", provider.Immutable},
{"versions", provider.Mutable},
{"names", provider.Mutable},
{"info/rack", provider.Mutable},
{"info/gems", provider.Mutable},
{"specs.4.8.gz", provider.Mutable},
{"latest_specs.4.8.gz", provider.Mutable},
{"prerelease_specs.4.8.gz", provider.Mutable},
{"api/v1/dependencies", provider.Mutable},
{"quick/Marshal.4.8/", provider.Mutable},
}
for _, tt := range tests {
if got := p.Classify(tt.path); got != tt.want {
t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}
func TestContentType(t *testing.T) {
p := &Provider{}
tests := map[string]string{
"versions": "text/plain; charset=utf-8",
"info/rack": "text/plain; charset=utf-8",
"names": "text/plain; charset=utf-8",
"gems/rack-3.1.8.gem": "application/octet-stream",
"specs.4.8.gz": "application/octet-stream",
"api/v1/gems/rack.json": "application/json",
"api/v1/dependencies.json": "application/json",
"api/v1/dependencies": "application/octet-stream",
}
for path, want := range tests {
if got := p.ContentType(path); got != want {
t.Errorf("ContentType(%q) = %q, want %q", path, got, want)
}
}
}
func TestUpstreamURL(t *testing.T) {
p := &Provider{}
r := models.Remote{BaseURL: "https://rubygems.org/"}
if got := p.UpstreamURL(r, "/info/rack"); got != "https://rubygems.org/info/rack" {
t.Errorf("UpstreamURL = %q", got)
}
}
+1
View File
@@ -25,6 +25,7 @@ import (
_ "git.unkin.net/unkin/artifactapi/internal/provider/cargo"
"git.unkin.net/unkin/artifactapi/internal/provider/deb"
_ "git.unkin.net/unkin/artifactapi/internal/provider/docker"
_ "git.unkin.net/unkin/artifactapi/internal/provider/gems"
_ "git.unkin.net/unkin/artifactapi/internal/provider/generic"
_ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy"
_ "git.unkin.net/unkin/artifactapi/internal/provider/helm"
+2
View File
@@ -17,6 +17,7 @@ const (
PackageTerraform PackageType = "terraform"
PackageGoProxy PackageType = "goproxy"
PackageCargo PackageType = "cargo"
PackageGems PackageType = "gems"
PackageNuGet PackageType = "nuget"
PackageGitHubRPM PackageType = "github_rpm"
PackageGitHubDeb PackageType = "github_deb"
@@ -36,6 +37,7 @@ var validPackageTypes = map[PackageType]bool{
PackageTerraform: true,
PackageGoProxy: true,
PackageCargo: true,
PackageGems: true,
PackageNuGet: true,
PackageGitHubRPM: true,
PackageGitHubDeb: true,
+1
View File
@@ -19,6 +19,7 @@ func TestPackageTypeValid(t *testing.T) {
models.PackageTerraform,
models.PackageGoProxy,
models.PackageCargo,
models.PackageGems,
models.PackageNuGet,
models.PackageGitHubRPM,
models.PackageGitHubDeb,
+10
View File
@@ -243,6 +243,16 @@ index = "sparse+${proxy}/"`,
},
];
case 'gems':
return [
{
title: 'Use this remote as the RubyGems source',
language: 'bash',
code: `gem sources --clear-all --add ${proxy}/
bundle config mirror.https://rubygems.org ${proxy}`,
},
];
case 'nuget':
return [
{
+1
View File
@@ -18,6 +18,7 @@ const typeColors: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'default'
terraform: 'blue',
goproxy: 'green',
cargo: 'yellow',
gems: 'red',
nuget: 'blue',
};
+1
View File
@@ -18,6 +18,7 @@ const typeColors: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'default'
terraform: 'blue',
goproxy: 'green',
cargo: 'yellow',
gems: 'red',
nuget: 'blue',
};