Add Artifact Registry client upstream-list read (monolith/S05 virtual, Step 1)
What
- Adds
ArtifactRegistry::Client#upstream_repositories(slug:, repository_name:, format:), returning a virtual repository's ordered list of upstream associations. - Adds two value objects under
ee/lib/artifact_registry/:UpstreamRepositoryAssociation(readersid,position,upstream_repository) andUpstreamRepositorySummary(readersid,name,format,kind), mirroring the sibling AR value objects and tolerant of unknown fields. formatis a required argument, never inferred, and is guarded against the four formats the client already supports: maven, npm, docker, oci.- A 404 resolves to nil. This covers a missing repository, a non-virtual repository, and a viewer who may not see it, since the contract collapses these into a single status. 401/403 raise an authorization error, 5xx raise an unavailable error, and a malformed success body raises the unexpected-success error.
- EE-only. Ships dark behind the
artifact_registry_uifeature flag. The client itself is not flag-gated; every caller is, and no caller wires the flag on in this MR.
Why a bare Array
This read returns a plain Array rather than the paginated Page object used by the other list reads. The route is unpaginated: ADR-004 caps a virtual repository at 20 upstreams, so Artifact Registry sends no Link header and there is no cursor to page.
Testing
RSpec with WebMock. The client spec covers a positive case for each of the four formats, an ordering case over a 20 element body, the empty-list case, and one case per mapped status (404, 401, 403, 429, 500, 503, and malformed success bodies), plus credential-not-in-URI, the format guard, blank and dot-segment slug/name guards, and path-segment encoding. Two value-object specs cover the documented readers, unknown-field tolerance, absent fields, and nil attributes.
The Artifact Registry REST route (listUpstreamRepositories) is contracted in the OpenAPI spec but not yet implemented in the Artifact Registry service, so this is reviewed and merged against WebMock stubs only and cannot be exercised against a running Artifact Registry yet.
How to validate locally
The AR route is contracted but not yet implemented, so every check runs against stubbed HTTP or pure parsing. Confirmed output follows each step (run on branch 628662-client-upstream-list-read).
1. Value-object specs
bundle exec rspec ee/spec/lib/artifact_registry/upstream_repository_association_spec.rb ee/spec/lib/artifact_registry/upstream_repository_summary_spec.rb...........
Finished in 0.02525 seconds (files took 3.76 seconds to load)
11 examples, 0 failures2. Client spec (the new method)
bundle exec rspec ee/spec/lib/artifact_registry/client_spec.rb -e '#upstream_repositories'............................
Finished in 15.54 seconds (files took 36.54 seconds to load)
28 examples, 0 failures3. Stubbed-client console script
Drives the real method over a Faraday test adapter: a populated maven list, an empty npm list, a 404, and the format guard. Save as /tmp/ar_validate_upstream.rb and run bundle exec rails runner /tmp/ar_validate_upstream.rb.
# frozen_string_literal: true
require 'faraday'
def build_client(status:, body:)
stubs = Faraday::Adapter::Test::Stubs.new do |stub|
stub.get(%r{upstream_repositories\z}) do
[status, { 'Content-Type' => 'application/json' }, body]
end
end
conn = Faraday.new do |c|
c.request :json
c.response :json, content_type: 'application/json'
c.adapter :test, stubs
end
token_exchange = Object.new
def token_exchange.token_for(*) = 'validation-token'
client = ArtifactRegistry::Client.new(
base_url: 'https://ar.example.test', current_user: Object.new, token_exchange: token_exchange
)
client.instance_variable_set(:@connection, conn)
client
end
populated = [
{ 'id' => 'a1', 'position' => 1,
'upstream_repository' => { 'id' => 'b1', 'name' => 'payment-core', 'format' => 'maven', 'kind' => 'hosted' } },
{ 'id' => 'a2', 'position' => 2,
'upstream_repository' => { 'id' => 'b2', 'name' => 'payment-mirror', 'format' => 'maven', 'kind' => 'remote' } }
].to_json
puts "== Populated upstream list (maven) =="
result = build_client(status: 200, body: populated)
.upstream_repositories(slug: 'my-group', repository_name: 'virtual-repo', format: 'maven')
puts "returned class: #{result.class}, responds_to?(:next_cursor): #{result.respond_to?(:next_cursor)}"
result.each do |assoc|
s = assoc.upstream_repository
puts " position=#{assoc.position} assoc_id=#{assoc.id} -> #{s.class}(name=#{s.name}, format=#{s.format}, kind=#{s.kind})"
end
puts "\n== Empty upstream list (npm) =="
empty = build_client(status: 200, body: '[]')
.upstream_repositories(slug: 'my-group', repository_name: 'virtual-repo', format: 'npm')
puts "returned: #{empty.inspect}"
puts "\n== 404 resolves to nil (docker) =="
missing = build_client(status: 404, body: { 'error' => { 'code' => 'not_found' } }.to_json)
.upstream_repositories(slug: 'my-group', repository_name: 'missing', format: 'docker')
puts "returned: #{missing.inspect}"
puts "\n== Format guard raises before any request (oci accepted, gem rejected) =="
begin
build_client(status: 200, body: populated)
.upstream_repositories(slug: 'my-group', repository_name: 'virtual-repo', format: 'gem')
rescue ArgumentError => e
puts "ArgumentError: #{e.message}"
endOutput:
== Populated upstream list (maven) ==
returned class: Array, responds_to?(:next_cursor): false
position=1 assoc_id=a1 -> ArtifactRegistry::UpstreamRepositorySummary(name=payment-core, format=maven, kind=hosted)
position=2 assoc_id=a2 -> ArtifactRegistry::UpstreamRepositorySummary(name=payment-mirror, format=maven, kind=remote)
== Empty upstream list (npm) ==
returned: []
== 404 resolves to nil (docker) ==
returned: nil
== Format guard raises before any request (oci accepted, gem rejected) ==
ArgumentError: format must be one of: maven, npm, docker, ociDependencies / merge gate
This is Step 1 ("Client upstream-list read") of the monolith/S05 virtual repository detail plan. The originating plan MR is gitlab-org/ops/artifact-registry!2534 (merged), which is still open. Per the plan's guardrail, this MR must not merge until that MR merges, so it stays in Draft, blocked on it.
Related to #628475