From 636eef0eff00e451de6d5d426e6a6785b90b44e2 Mon Sep 17 00:00:00 2001 From: Raj Date: Fri, 27 Feb 2026 17:17:57 -0800 Subject: [PATCH] Add catalog listing to RegistryClient (#564) Adds a `catalog(prefix:)` method to `RegistryClient` that implements `GET /v2/_catalog` with pagination and prefix-based filtering. --- .../Client/RegistryClient+Catalog.swift | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 Sources/ContainerizationOCI/Client/RegistryClient+Catalog.swift diff --git a/Sources/ContainerizationOCI/Client/RegistryClient+Catalog.swift b/Sources/ContainerizationOCI/Client/RegistryClient+Catalog.swift new file mode 100644 index 00000000..38cbee9e --- /dev/null +++ b/Sources/ContainerizationOCI/Client/RegistryClient+Catalog.swift @@ -0,0 +1,85 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import AsyncHTTPClient +import ContainerizationError +import Foundation +import NIOFoundationCompat + +private struct CatalogResponse: Sendable, Decodable { + let repositories: [String] +} + +extension RegistryClient { + /// List repositories in the registry. + /// + /// Implements GET /v2/_catalog from the OCI Distribution Spec with pagination. + /// When prefix is provided, pagination skips ahead to the relevant portion of + /// the lexically-sorted catalog and stops once results move past the prefix. + /// + /// - Parameter prefix: Optional prefix to filter repository names. Must be at least + /// two characters long to enable the skip-ahead optimization; shorter values are + /// treated as no prefix. + /// - Returns: An array of repository names matching the prefix (or all repositories + /// if no prefix is given). + public func catalog(prefix: String? = nil) async throws -> [String] { + let effectivePrefix = prefix.flatMap { $0.count >= 2 ? $0 : nil } + + var allRepos: [String] = [] + // When a prefix is provided, skip ahead in the lexically-sorted catalog + // by setting last to one position before the prefix. The OCI spec + // returns entries that sort after last, so dropping the last character + // of the prefix positions the cursor just before matching entries. + var last: String? = effectivePrefix.map { String($0.dropLast()) } + let pageSize = 100 + + while true { + var components = base + components.path = "/v2/_catalog" + var queryItems = [URLQueryItem(name: "n", value: String(pageSize))] + if let last { + queryItems.append(URLQueryItem(name: "last", value: last)) + } + components.queryItems = queryItems + + let repos: [String] = try await request(components: components) { response in + guard response.status == .ok else { + let url = components.url?.absoluteString ?? "unknown" + let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString + throw Error.invalidStatus(url: url, response.status, reason: reason) + } + + let buffer = try await response.body.collect(upTo: self.bufferSize) + return try JSONDecoder().decode(CatalogResponse.self, from: buffer).repositories + } + + if let effectivePrefix { + let matching = repos.filter { $0.hasPrefix(effectivePrefix) } + allRepos.append(contentsOf: matching) + if let lastRepo = repos.last, !lastRepo.hasPrefix(effectivePrefix) && lastRepo > effectivePrefix { + break + } + } else { + allRepos.append(contentsOf: repos) + } + + if repos.count < pageSize { break } + last = repos.last + } + + return allRepos + } +}