diff --git a/Sources/PackageRegistry/RegistryClient.swift b/Sources/PackageRegistry/RegistryClient.swift index b19f7225547..320297a0c30 100644 --- a/Sources/PackageRegistry/RegistryClient.swift +++ b/Sources/PackageRegistry/RegistryClient.swift @@ -397,8 +397,8 @@ public final class RegistryClient: AsyncCancellable { timeout: DispatchTimeInterval?, observabilityScope: ObservabilityScope ) async throws -> Serialization.VersionMetadata { - let cacheKey = MetadataCacheKey(registry: registry, package: package) - if let cached = self.metadataCache[cacheKey], cached.expires < .now() { + let cacheKey = MetadataCacheKey(registry: registry, package: package, version: version) + if let cached = self.metadataCache[cacheKey], cached.expires > .now() { return cached.metadata } @@ -1403,21 +1403,9 @@ public final class RegistryClient: AsyncCancellable { } } - private func unwrapRegistry(from package: PackageIdentity) throws -> (PackageIdentity.RegistryIdentity, Registry) { - guard let registryIdentity = package.registry else { - throw RegistryError.invalidPackageIdentity(package) - } - - guard let registry = self.configuration.registry(for: registryIdentity.scope) else { - throw RegistryError.registryNotConfigured(scope: registryIdentity.scope) - } - - return (registryIdentity, registry) - } - // If the registry is available, the function returns, otherwise an error // explaining why the registry is unavailable is thrown. - private func withAvailabilityCheck( + func withAvailabilityCheck( registry: Registry, observabilityScope: ObservabilityScope ) async throws { @@ -1438,7 +1426,7 @@ public final class RegistryClient: AsyncCancellable { } } - if let cached = self.availabilityCache[registry.url], cached.expires < .now() { + if let cached = self.availabilityCache[registry.url], cached.expires > .now() { return try availabilityHandler(cached.status) } @@ -1453,6 +1441,18 @@ public final class RegistryClient: AsyncCancellable { return try availabilityHandler(result) } + private func unwrapRegistry(from package: PackageIdentity) throws -> (PackageIdentity.RegistryIdentity, Registry) { + guard let registryIdentity = package.registry else { + throw RegistryError.invalidPackageIdentity(package) + } + + guard let registry = self.configuration.registry(for: registryIdentity.scope) else { + throw RegistryError.registryNotConfigured(scope: registryIdentity.scope) + } + + return (registryIdentity, registry) + } + private func unexpectedStatusError( _ response: HTTPClientResponse, expectedStatus: [Int] @@ -1495,6 +1495,7 @@ public final class RegistryClient: AsyncCancellable { private struct MetadataCacheKey: Hashable { let registry: Registry let package: PackageIdentity.RegistryIdentity + let version: Version } } diff --git a/Tests/PackageRegistryTests/RegistryClientTests.swift b/Tests/PackageRegistryTests/RegistryClientTests.swift index f47004e81f1..32e65c1f150 100644 --- a/Tests/PackageRegistryTests/RegistryClientTests.swift +++ b/Tests/PackageRegistryTests/RegistryClientTests.swift @@ -10,7 +10,7 @@ // //===----------------------------------------------------------------------===// -import Basics +@testable import Basics import _Concurrency import Foundation import PackageFingerprint @@ -440,6 +440,126 @@ fileprivate var availabilityURL = URL("\(registryURL)/availability") assert(metadataSync) } + @Test func getPackageVersionMetadataInCache() async throws { + let checksumAlgorithm: HashAlgorithm = MockHashAlgorithm() + let expectedChecksums: [Version: String] = [ + Version("1.1.1"): "a2ac54cf25fbc1ad0028f03f0aa4b96833b83bb05a14e510892bb27dea4dc812", + Version("1.1.0"): checksumAlgorithm.hash(emptyZipFile).hexadecimalRepresentation + ] + + let counter = SendableBox(0) + let handler: HTTPClient.Implementation = { request, _ in + await counter.increment() + switch (request.method, request.url) { + case (.get, releasesURL.appending(component: "1.1.1")): + let expectedChecksum = expectedChecksums[Version("1.1.1")]! + #expect(request.headers.get("Accept").first == "application/vnd.swift.registry.v1+json") + + let data = """ + { + "id": "mona.LinkedList", + "version": "1.1.1", + "resources": [ + { + "name": "source-archive", + "type": "application/zip", + "checksum": "\(expectedChecksum)" + } + ], + "metadata": { + "author": { + "name": "J. Appleseed" + }, + "licenseURL": "https://github.com/mona/LinkedList/license", + "readmeURL": "https://github.com/mona/LinkedList/readme", + "repositoryURLs": [ + "https://github.com/mona/LinkedList", + "ssh://git@github.com:mona/LinkedList.git", + "git@github.com:mona/LinkedList.git" + ] + } + } + """.data(using: .utf8)! + + return .init( + statusCode: 200, + headers: .init([ + .init(name: "Content-Length", value: "\(data.count)"), + .init(name: "Content-Type", value: "application/json"), + .init(name: "Content-Version", value: "1"), + ]), + body: data + ) + case (.get, releasesURL.appending(component: "1.1.0")): + let expectedChecksum = expectedChecksums[Version("1.1.0")]! + #expect(request.headers.get("Accept").first == "application/vnd.swift.registry.v1+json") + + let data = """ + { + "id": "mona.LinkedList", + "version": "1.1.0", + "resources": [ + { + "name": "source-archive", + "type": "application/zip", + "checksum": "\(expectedChecksum)", + } + ], + "metadata": { + "author": { + "name": "J. Appleseed" + }, + "licenseURL": "https://github.com/mona/LinkedList/license", + "readmeURL": "https://github.com/mona/LinkedList/readme", + "repositoryURLs": [ + "https://github.com/mona/LinkedList", + "ssh://git@github.com:mona/LinkedList.git", + "git@github.com:mona/LinkedList.git" + ] + } + } + """.data(using: .utf8)! + + return .init( + statusCode: 200, + headers: .init([ + .init(name: "Content-Length", value: "\(data.count)"), + .init(name: "Content-Type", value: "application/json"), + .init(name: "Content-Version", value: "1"), + ]), + body: data + ) + default: + throw StringError("method and url should match") + } + } + + let httpClient = HTTPClient(implementation: handler) + var configuration = RegistryConfiguration() + configuration.defaultRegistry = Registry(url: registryURL, supportsAvailability: false) + + let registryClient = makeRegistryClient(configuration: configuration, httpClient: httpClient) + + var expectedRequestCount = 0 + try await check(version: Version("1.1.1"), expectCached: false) + try await check(version: Version("1.1.0"), expectCached: false) + try await check(version: Version("1.1.1"), expectCached: true) + try await check(version: Version("1.1.0"), expectCached: true) + + func check(version: Version, expectCached: Bool) async throws { + let metadata = try await registryClient.getPackageVersionMetadata(package: identity, version: version) + + if !expectCached { + expectedRequestCount += 1 + } + + let count = await counter.value + #expect(count == expectedRequestCount) + #expect(metadata.author?.name == "J. Appleseed") + #expect(metadata.resources[0].checksum == expectedChecksums[version]!) + } + } + func getPackageVersionMetadata_404() async throws { let serverErrorHandler = ServerErrorHandler( method: .get, @@ -3701,6 +3821,87 @@ fileprivate var availabilityURL = URL("\(registryURL)/availability") try await registryClient.checkAvailability(registry: registry) } } + + @Test func withAvailabilityCheck() async throws { + let handler: HTTPClient.Implementation = { request, _ in + switch (request.method, request.url) { + case (.get, availabilityURL): + return .okay() + default: + throw StringError("method and url should match") + } + } + + let httpClient = HTTPClient(implementation: handler) + let registry = Registry(url: registryURL, supportsAvailability: true) + + let registryClient = makeRegistryClient( + configuration: .init(), + httpClient: httpClient + ) + + try await registryClient.withAvailabilityCheck( + registry: registry, + observabilityScope: ObservabilitySystem.NOOP + ) + } + + @Test func withAvailabilityCheckServerError() async throws { + let handler: HTTPClient.Implementation = { request, _ in + switch (request.method, request.url) { + case (.get, availabilityURL): + return .serverError(reason: "boom") + default: + throw StringError("method and url should match") + } + } + + let httpClient = HTTPClient(implementation: handler) + let registry = Registry(url: registryURL, supportsAvailability: true) + + let registryClient = makeRegistryClient( + configuration: .init(), + httpClient: httpClient + ) + + await #expect(throws: StringError("unknown server error (500)")) { + try await registryClient.withAvailabilityCheck( + registry: registry, + observabilityScope: ObservabilitySystem.NOOP + ) + } + } + + @Test func withAvailabilityCheckInCache() async throws { + let counter = SendableBox(0) + let handler: HTTPClient.Implementation = { request, _ in + await counter.increment() + switch (request.method, request.url) { + case (.get, availabilityURL): + return .okay() + default: + throw StringError("method and url should match") + } + } + + let httpClient = HTTPClient(implementation: handler) + let registry = Registry(url: registryURL, supportsAvailability: true) + + let registryClient = makeRegistryClient( + configuration: .init(), + httpClient: httpClient + ) + + // Request count should not increase after first check + for _ in 0..<5 { + try await registryClient.withAvailabilityCheck( + registry: registry, + observabilityScope: ObservabilitySystem.NOOP + ) + let count = await counter.value + #expect(count == 1) + } + } } // MARK: - Sugar