Skip to content

Commit efaeb85

Browse files
authored
feat(mirror): support mirroring Composer v2 registries (#151)
1 parent e3a2651 commit efaeb85

4 files changed

Lines changed: 289 additions & 0 deletions

File tree

app/Domains/Mirror/Services/RegistryClient/RegistryClientFactory.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,19 @@ protected static function createClient(
8080
array $rootMetadata,
8181
Mirror $mirror,
8282
): RegistryClientInterface {
83+
// Composer v2 format: a metadata-url template points to per-package
84+
// metadata files. Prioritized over the v1 formats below.
85+
$metadataUrl = $rootMetadata['metadata-url'] ?? null;
86+
87+
if (is_string($metadataUrl) && $metadataUrl !== '') {
88+
return new V2RegistryClient(
89+
httpClient: $httpClient,
90+
baseUrl: $mirror->url,
91+
metadataUrlTemplate: $metadataUrl,
92+
availablePackages: static::resolveAvailablePackages($rootMetadata, $mirror->url),
93+
);
94+
}
95+
8396
// Inline format: packages key contains all package data directly
8497
$packages = $rootMetadata['packages'] ?? null;
8598

@@ -101,6 +114,32 @@ protected static function createClient(
101114
);
102115
}
103116

117+
/**
118+
* Resolve the list of mirrorable package names from a Composer v2 root.
119+
*
120+
* Only registries that advertise their packages via "available-packages"
121+
* (optionally narrowed by "available-package-patterns") can be enumerated.
122+
* Registries that expose packages solely through a "list" endpoint — such
123+
* as Packagist — are not supported, as eagerly mirroring them is impractical.
124+
*
125+
* @param array<string, mixed> $rootMetadata
126+
* @return array<int, string>
127+
*/
128+
protected static function resolveAvailablePackages(array $rootMetadata, string $baseUrl): array
129+
{
130+
$availablePackages = $rootMetadata['available-packages'] ?? null;
131+
132+
if (is_array($availablePackages)) {
133+
return array_values(array_filter($availablePackages, 'is_string'));
134+
}
135+
136+
throw new MirrorSyncException(
137+
"Unsupported Composer v2 registry at {$baseUrl}: it does not advertise an ".
138+
'"available-packages" list. Registries that only expose a "list" endpoint '.
139+
'(such as Packagist) cannot be mirrored.'
140+
);
141+
}
142+
104143
/**
105144
* Fetch package data from include files referenced in packages.json.
106145
*
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
namespace App\Domains\Mirror\Services\RegistryClient;
4+
5+
use App\Domains\Mirror\Contracts\Interfaces\RegistryClientInterface;
6+
use Composer\MetadataMinifier\MetadataMinifier;
7+
use Illuminate\Http\Client\PendingRequest;
8+
use Illuminate\Support\Str;
9+
10+
class V2RegistryClient implements RegistryClientInterface
11+
{
12+
/**
13+
* @param array<int, string> $availablePackages
14+
*/
15+
public function __construct(
16+
protected PendingRequest $httpClient,
17+
protected string $baseUrl,
18+
protected string $metadataUrlTemplate,
19+
protected array $availablePackages,
20+
) {}
21+
22+
public function getAvailablePackages(): array
23+
{
24+
return $this->availablePackages;
25+
}
26+
27+
public function getPackageVersions(string $packageName): array
28+
{
29+
$versions = $this->fetchVersions($this->metadataUrl($packageName));
30+
31+
// Dev versions live in a separate "~dev" metadata file. A missing file
32+
// is expected for packages without dev branches, so 404s are ignored.
33+
$devVersions = $this->fetchVersions($this->metadataUrl($packageName.'~dev'));
34+
35+
return array_merge($versions, $devVersions);
36+
}
37+
38+
public function validateConnection(): bool
39+
{
40+
return true;
41+
}
42+
43+
public function downloadDist(string $url, string $outputPath): bool
44+
{
45+
$response = $this->httpClient->withOptions([
46+
'sink' => $outputPath,
47+
])->get($url);
48+
49+
return $response->successful();
50+
}
51+
52+
/**
53+
* Fetch and expand the minified version metadata at the given URL.
54+
*
55+
* @return array<string, array<string, mixed>> version => composer.json data
56+
*/
57+
protected function fetchVersions(string $url): array
58+
{
59+
$response = $this->httpClient->get($url);
60+
61+
if (! $response->successful()) {
62+
return [];
63+
}
64+
65+
$data = $response->json();
66+
67+
if (! is_array($data) || ! isset($data['packages']) || ! is_array($data['packages'])) {
68+
return [];
69+
}
70+
71+
$versions = [];
72+
73+
foreach ($data['packages'] as $minifiedVersions) {
74+
if (! is_array($minifiedVersions)) {
75+
continue;
76+
}
77+
78+
foreach (MetadataMinifier::expand($minifiedVersions) as $composerJson) {
79+
$version = (string) ($composerJson['version'] ?? '');
80+
81+
if ($version === '') {
82+
continue;
83+
}
84+
85+
$versions[$version] = $composerJson;
86+
}
87+
}
88+
89+
return $versions;
90+
}
91+
92+
/**
93+
* Build the metadata URL for a package by resolving the metadata-url
94+
* template against the registry base URL.
95+
*/
96+
protected function metadataUrl(string $packageName): string
97+
{
98+
$path = str_replace('%package%', $packageName, $this->metadataUrlTemplate);
99+
100+
if (Str::startsWith($path, ['http://', 'https://'])) {
101+
return $path;
102+
}
103+
104+
return Str::finish($this->baseUrl, '/').ltrim($path, '/');
105+
}
106+
}

tests/Feature/Domains/Mirror/SyncMirrorJobTest.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use App\Models\Organization;
1111
use App\Models\Package;
1212
use App\Models\PackageVersion;
13+
use Composer\MetadataMinifier\MetadataMinifier;
1314
use Illuminate\Support\Facades\Bus;
1415
use Illuminate\Support\Facades\Http;
1516

@@ -194,6 +195,33 @@
194195
expect($syncLog->versions_skipped)->toBe(1);
195196
});
196197

198+
it('supports the Composer v2 metadata format', function () {
199+
Bus::fake(SyncMirrorVersionJob::class);
200+
201+
Http::fake([
202+
'satis.example.com/packages.json' => Http::response([
203+
'metadata-url' => '/p2/%package%.json',
204+
'available-packages' => ['vendor/pkg'],
205+
]),
206+
'satis.example.com/p2/vendor/pkg.json' => Http::response([
207+
'minified' => 'composer/2.0',
208+
'packages' => [
209+
'vendor/pkg' => MetadataMinifier::minify([
210+
['name' => 'vendor/pkg', 'version' => '2.0.0', 'version_normalized' => '2.0.0.0', 'dist' => ['reference' => 'def']],
211+
['name' => 'vendor/pkg', 'version' => '1.0.0', 'version_normalized' => '1.0.0.0', 'dist' => ['reference' => 'abc']],
212+
]),
213+
],
214+
]),
215+
'satis.example.com/p2/vendor/pkg~dev.json' => Http::response('Not Found', 404),
216+
]);
217+
218+
SyncMirrorJob::dispatchSync($this->mirror);
219+
220+
$syncLog = MirrorSyncLog::where('mirror_uuid', $this->mirror->uuid)->latest()->first();
221+
expect($syncLog->details['packages_found'])->toBe(1);
222+
expect($syncLog->details['versions_found'])->toBe(2);
223+
});
224+
197225
it('supports includes format in packages.json', function () {
198226
Bus::fake(SyncMirrorVersionJob::class);
199227

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
3+
use App\Domains\Mirror\Exceptions\MirrorSyncException;
4+
use App\Domains\Mirror\Services\RegistryClient\InlineRegistryClient;
5+
use App\Domains\Mirror\Services\RegistryClient\RegistryClientFactory;
6+
use App\Domains\Mirror\Services\RegistryClient\V2RegistryClient;
7+
use App\Models\Mirror;
8+
use App\Models\Organization;
9+
use Composer\MetadataMinifier\MetadataMinifier;
10+
use Illuminate\Support\Facades\Http;
11+
12+
beforeEach(function () {
13+
$this->organization = Organization::factory()->create();
14+
$this->mirror = Mirror::factory()->create([
15+
'organization_uuid' => $this->organization->uuid,
16+
'url' => 'https://registry.example.com',
17+
]);
18+
});
19+
20+
/**
21+
* Build a minified p2 metadata body, exactly as Pricore itself serves it.
22+
*
23+
* @param array<int, array<string, mixed>> $versions
24+
*/
25+
function fakeMetadataResponse(string $packageName, array $versions): array
26+
{
27+
return [
28+
'minified' => 'composer/2.0',
29+
'packages' => [
30+
$packageName => MetadataMinifier::minify($versions),
31+
],
32+
];
33+
}
34+
35+
it('detects the Composer v2 format and prioritizes it over inline packages', function () {
36+
Http::fake([
37+
'registry.example.com/packages.json' => Http::response([
38+
'metadata-url' => '/p2/%package%.json',
39+
'available-packages' => ['vendor/pkg'],
40+
// A stray v1 "packages" key must not take precedence.
41+
'packages' => ['vendor/legacy' => []],
42+
]),
43+
]);
44+
45+
$client = RegistryClientFactory::make($this->mirror);
46+
47+
expect($client)->toBeInstanceOf(V2RegistryClient::class);
48+
expect($client->getAvailablePackages())->toBe(['vendor/pkg']);
49+
});
50+
51+
it('falls back to the inline v1 client when no metadata-url is present', function () {
52+
Http::fake([
53+
'registry.example.com/packages.json' => Http::response([
54+
'packages' => [
55+
'vendor/pkg' => [
56+
'1.0.0' => ['name' => 'vendor/pkg', 'version' => '1.0.0'],
57+
],
58+
],
59+
]),
60+
]);
61+
62+
expect(RegistryClientFactory::make($this->mirror))
63+
->toBeInstanceOf(InlineRegistryClient::class);
64+
});
65+
66+
it('fetches and expands both stable and dev versions', function () {
67+
Http::fake([
68+
'registry.example.com/packages.json' => Http::response([
69+
'metadata-url' => '/p2/%package%.json',
70+
'available-packages' => ['vendor/pkg'],
71+
]),
72+
'registry.example.com/p2/vendor/pkg.json' => Http::response(fakeMetadataResponse('vendor/pkg', [
73+
['name' => 'vendor/pkg', 'version' => '2.0.0', 'version_normalized' => '2.0.0.0', 'dist' => ['reference' => 'ref-2', 'url' => 'https://registry.example.com/d/2.zip']],
74+
['name' => 'vendor/pkg', 'version' => '1.0.0', 'version_normalized' => '1.0.0.0', 'dist' => ['reference' => 'ref-1', 'url' => 'https://registry.example.com/d/1.zip']],
75+
])),
76+
'registry.example.com/p2/vendor/pkg~dev.json' => Http::response(fakeMetadataResponse('vendor/pkg', [
77+
['name' => 'vendor/pkg', 'version' => 'dev-main', 'version_normalized' => 'dev-main', 'dist' => ['reference' => 'ref-dev']],
78+
])),
79+
]);
80+
81+
$versions = RegistryClientFactory::make($this->mirror)->getPackageVersions('vendor/pkg');
82+
83+
expect(array_keys($versions))->toEqualCanonicalizing(['1.0.0', '2.0.0', 'dev-main']);
84+
// Expansion must restore each version's own dist reference (not leak the diff).
85+
expect($versions['2.0.0']['dist']['reference'])->toBe('ref-2');
86+
expect($versions['1.0.0']['dist']['reference'])->toBe('ref-1');
87+
expect($versions['dev-main']['dist']['reference'])->toBe('ref-dev');
88+
});
89+
90+
it('ignores a missing dev metadata file', function () {
91+
Http::fake([
92+
'registry.example.com/packages.json' => Http::response([
93+
'metadata-url' => '/p2/%package%.json',
94+
'available-packages' => ['vendor/pkg'],
95+
]),
96+
'registry.example.com/p2/vendor/pkg.json' => Http::response(fakeMetadataResponse('vendor/pkg', [
97+
['name' => 'vendor/pkg', 'version' => '1.0.0', 'version_normalized' => '1.0.0.0', 'dist' => ['reference' => 'ref-1']],
98+
])),
99+
'registry.example.com/p2/vendor/pkg~dev.json' => Http::response('Not Found', 404),
100+
]);
101+
102+
$versions = RegistryClientFactory::make($this->mirror)->getPackageVersions('vendor/pkg');
103+
104+
expect(array_keys($versions))->toBe(['1.0.0']);
105+
});
106+
107+
it('throws when a v2 registry does not advertise available-packages', function () {
108+
Http::fake([
109+
'registry.example.com/packages.json' => Http::response([
110+
'metadata-url' => '/p2/%package%.json',
111+
'list' => '/packages/list.json',
112+
]),
113+
]);
114+
115+
RegistryClientFactory::make($this->mirror);
116+
})->throws(MirrorSyncException::class, 'available-packages');

0 commit comments

Comments
 (0)