Draft: AR namespace statistics: Client method and value object

What does this MR do and why?

Adds a client method and value object for namespace statistics. Nothing calls it yet — the GraphQL statistics field is a separate task.

ArtifactRegistry::Client#namespace_statistics(slug:) issues GET /api/v1/:slug/statistics and returns an ArtifactRegistry::NamespaceStatistics with repositories_count and deduplicated_size_bytes.

Design decisions

  • Two fields exposed, not four. The response carries components_count and downloads_count too, but neither has a consumer yet. Adding them later is additive — the value object reads keys off the parsed body, so no existing code changes.
  • Negative deduplicated_size_bytes passes through. The contract declares no minimum, so underflow is possible until reconciliation. Clamping in the client would hide drift from the consumer (the header renders negatives as zero per S04).
  • Missing required fields raise UnavailableError. Both fields are required in the contract; an absent key signals something between the monolith and AR is rewriting the response. This differs from #version_statistics, whose single figure is nullable.
  • 404 returns nil with logging. Follows the existing #repositories pattern. A 501 raises UnavailableError (unlike #version_statistics) because this route is fully implemented, not a placeholder.
  • Auth uses user_request. The per-user token is sent as Authorization: Bearer, matching every other /api/v1 read.

Files changed

  • ee/lib/artifact_registry/client.rb: 1 public method, 1 path builder, 1 contract predicate (+21)
  • ee/lib/artifact_registry/namespace_statistics.rb: new value object (+17)
  • ee/spec/lib/artifact_registry/client_spec.rb: 24 new examples (+201)
  • ee/spec/lib/artifact_registry/namespace_statistics_spec.rb: new spec, 8 examples (+77)

Total: 316 insertions, 0 deletions, 4 files.

Specs cover: request path, auth token handling, response parsing (full four-field body, two exposed fields), error mapping (401/403AuthorizationError, 404nil, 429/500/503/501UnavailableError, 400ApiError), and edge cases (missing fields, array body, 204, blank slugs, percent-encoding).

This ships behind the artifact_registry_ui feature flag (disabled). No changelog, no database change, no user-visible change. All files under ee/.

References

Screenshots or screen recordings

Not applicable — client-layer change with no UI or GraphQL surface.

How to set up and validate locally

  1. Run the specs:

    bundle exec rspec ee/spec/lib/artifact_registry/client_spec.rb
    bundle exec rspec ee/spec/lib/artifact_registry/namespace_statistics_spec.rb

    Confirmed: 869 examples, 0 failures (client) and 8 examples, 0 failures (value object). Filtering the client spec to -e '#namespace_statistics' gives 24 examples, 0 failures.

  2. Run RuboCop:

    bundle exec rubocop ee/lib/artifact_registry/client.rb ee/lib/artifact_registry/namespace_statistics.rb ee/spec/lib/artifact_registry/client_spec.rb ee/spec/lib/artifact_registry/namespace_statistics_spec.rb

    Confirmed: 4 files inspected, no offenses detected.

  3. Drive the method against a running Artifact Registry.

Environment

A local Artifact Registry built from origin/main on http://localhost:8080, verifying the per-user JWT the GDK mints.

  • GDK organization: default, user: root
  • Artifact Registry namespace slug: gdk-local-test
  • That namespace holds 4 repositories (one docker, one maven, one hosted npm, one remote npm)

The monolith needs this under development: in config/gitlab.yml. gdk reconfigure overwrites the block, so re-add it after reconfiguring:

development:
  artifact_registry:
    api_url: http://localhost:8080

Artifact Registry needs this, pointing keys_url at Workhorse on :3333 (:3000 is the HTTP router and 404s the JWKS path), while issuer.url stays :3000 because that is the iss claim it checks:

auth:
  token_exchange:
    issuer:
      url: http://gdk.test:3000
      keys_url: http://gdk.test:3333/oauth/discovery/keys
    expected_audiences:
      - gitlab-artifact-registry

Driven with bundle exec rails runner, resolving the slug through ArtifactRegistry::NamespaceMapping and building the client with organization.artifact_registry_client(current_user: user).

Raw response

What Artifact Registry actually returned for GET /api/v1/gdk-local-test/statistics (HTTP 200):

{
  "repositories_count": 4,
  "deduplicated_size_bytes": 4809,
  "components_count": 3,
  "downloads_count": 0
}

That confirms the four-field contract body. ArtifactRegistry::NamespaceStatistics reads only the two fields with a consumer today.

Scenarios

# Scenario Result
1 namespace_statistics(slug: 'gdk-local-test') ArtifactRegistry::NamespaceStatistics with repositories_count = 4, deduplicated_size_bytes = 4809; does not respond to components_count or downloads_count
2 Cross-check against #repositories 4 nodes returned, matching repositories_count
3 Unknown slug no-such-namespace nil, plus exactly one error-tracking event: ArtifactRegistry::Client::ApiError carrying {slug: "no-such-namespace", status: 404}
4 Blank slug ' ' ArgumentError: slug is required, no HTTP call made
5 slug: nil same ArgumentError
6 Slug needing percent-encoding, 'a b/c' encoded, Artifact Registry answered 404, method returned nil
7 No current_user (service client) ArgumentError: current_user is required for a per-user request
8 User outside the organization no token minted, blank-credential guard raised ArtifactRegistry::Client::AuthorizationError: No Artifact Registry credential was obtained
Full captured output
org=default user=root slug=gdk-local-test api_url=http://localhost:8080

--- raw AR response body (what the contract actually sends) ---
HTTP 200
{
  "repositories_count": 4,
  "deduplicated_size_bytes": 4809,
  "components_count": 3,
  "downloads_count": 0
}

--- 1. happy path: namespace_statistics(slug:) ---
class                   = ArtifactRegistry::NamespaceStatistics
repositories_count      = 4
deduplicated_size_bytes = 4809
does NOT expose components_count/downloads_count: true

--- 2. cross-check repositories_count against #repositories ---
repositories page size  = 4
statistics count        = 4

--- 3. unknown slug -> nil (404 mapped, one error-tracking event) ---
result  = nil
events  = 1
  ArtifactRegistry::Client::ApiError: {:slug=>"no-such-namespace", :status=>404}

--- 4. blank slug -> guarded before any HTTP call ---
RAISED ArgumentError: slug is required

--- 5. nil slug -> guarded ---
RAISED ArgumentError: slug is required

--- 6. slug needing percent-encoding -> encoded, AR answers 404 -> nil ---
result = nil

--- 7. no current_user -> service client refuses to make a user request ---
RAISED ArgumentError: current_user is required for a per-user request

--- 8. non-member user -> no token minted, blank-credential guard ---
outsider=petrina_koepp member=false
RAISED ArtifactRegistry::Client::AuthorizationError: No Artifact Registry credential was obtained

DONE

Not reproducible against a live service

Three of the design decisions above stay covered by specs only, because a healthy Artifact Registry cannot produce the input:

  • Missing required field to UnavailableError. Artifact Registry always sends both repositories_count and deduplicated_size_bytes.
  • 501 to UnavailableError. The route is fully implemented, so it never answers 501.
  • Negative deduplicated_size_bytes pass-through. The local namespace has no accounting underflow.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Fiona McCawley

Merge request reports

Loading
Loading