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 (readers id, position, upstream_repository) and UpstreamRepositorySummary (readers id, name, format, kind), mirroring the sibling AR value objects and tolerant of unknown fields.
  • format is 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_ui feature 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 failures

2. 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 failures

3. 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}"
end

Output:

== 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, oci

Dependencies / 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

🤖 Generated with Claude Code

Edited by Fiona McCawley

Merge request reports

Loading
Loading