diff --git a/Package.swift b/Package.swift index 82be59d..b3be733 100644 --- a/Package.swift +++ b/Package.swift @@ -26,12 +26,15 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0"..<"4.0.0"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-http-types.git", from: "1.2.0"), ], targets: [ .target( name: "ContainerRegistry", dependencies: [ .product(name: "Crypto", package: "swift-crypto", condition: .when(platforms: [.linux])), + .product(name: "HTTPTypes", package: "swift-http-types"), + .product(name: "HTTPTypesFoundation", package: "swift-http-types"), .target( name: "Basics" // AuthorizationProvider ), diff --git a/Sources/ContainerRegistry/AuthHandler.swift b/Sources/ContainerRegistry/AuthHandler.swift index c1b844e..33f0555 100644 --- a/Sources/ContainerRegistry/AuthHandler.swift +++ b/Sources/ContainerRegistry/AuthHandler.swift @@ -14,10 +14,8 @@ import Basics import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif import RegexBuilder +import HTTPTypes struct BearerTokenResponse: Codable { /// An opaque Bearer token that clients should supply to @@ -130,7 +128,7 @@ public struct AuthHandler { } /// Get locally-configured credentials, such as netrc or username/password, for a request - func localCredentials(for request: URLRequest) -> String? { + func localCredentials(for request: HTTPRequest) -> String? { guard let requestURL = request.url else { return nil } if let netrcEntry = auth?.httpAuthorizationHeader(for: requestURL) { return netrcEntry } @@ -149,7 +147,7 @@ public struct AuthHandler { /// In future it could provide cached responses from previous challenges. /// - Parameter request: The request to authorize. /// - Returns: The request, with an appropriate authorization header added, or nil if no credentials are available. - public func auth(for request: URLRequest) -> URLRequest? { nil } + public func auth(for request: HTTPRequest) -> HTTPRequest? { nil } /// Add authorization to an HTTP rquest in response to a challenge from the server. /// - Parameters: @@ -158,13 +156,13 @@ public struct AuthHandler { /// - client: An HTTP client, used to retrieve tokens if necessary. /// - Returns: The request, with an appropriate authorization header added, or nil if no credentials are available. /// - Throws: If an error occurs while retrieving a credential. - public func auth(for request: URLRequest, withChallenge challenge: String, usingClient client: HTTPClient) - async throws -> URLRequest? + public func auth(for request: HTTPRequest, withChallenge challenge: String, usingClient client: HTTPClient) + async throws -> HTTPRequest? { if challenge.lowercased().starts(with: "basic") { guard let authHeader = localCredentials(for: request) else { return nil } var request = request - request.addValue(authHeader, forHTTPHeaderField: "Authorization") + request.headerFields[.authorization] = authHeader return request } else if challenge.lowercased().starts(with: "bearer") { @@ -176,15 +174,15 @@ public struct AuthHandler { challenge.dropFirst("bearer".count).trimmingCharacters(in: .whitespacesAndNewlines) ) guard let challengeURL = parsedChallenge.url else { return nil } - var req = URLRequest(url: challengeURL) - if let credentials = localCredentials(for: req) { - req.addValue("\(credentials)", forHTTPHeaderField: "Authorization") + var tokenRequest = HTTPRequest(url: challengeURL) + if let credentials = localCredentials(for: tokenRequest) { + tokenRequest.headerFields[.authorization] = credentials } - let (data, _) = try await client.executeRequestThrowing(req, expectingStatus: 200) + let (data, _) = try await client.executeRequestThrowing(tokenRequest, expectingStatus: .ok) let tokenResponse = try JSONDecoder().decode(BearerTokenResponse.self, from: data) var request = request - request.addValue("Bearer \(tokenResponse.token)", forHTTPHeaderField: "Authorization") + request.headerFields[.authorization] = "Bearer \(tokenResponse.token)" return request } else { diff --git a/Sources/ContainerRegistry/Blobs.swift b/Sources/ContainerRegistry/Blobs.swift index cfe0fad..1435c62 100644 --- a/Sources/ContainerRegistry/Blobs.swift +++ b/Sources/ContainerRegistry/Blobs.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import Foundation +import HTTPTypes #if canImport(CryptoKit) import CryptoKit @@ -20,10 +21,6 @@ import CryptoKit import Crypto #endif -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - /// Calculates the digest of a blob of data. /// - Parameter data: Blob of data to digest. /// - Returns: The blob's digest, in the format expected by the distribution protocol. @@ -45,17 +42,20 @@ extension RegistryClient { // Response will include a 'Location' header telling us where to PUT the blob data. let httpResponse = try await executeRequestThrowing( .post(registryURLForPath("/v2/\(repository)/blobs/uploads/")), - expectingStatus: 202, // expected response code for a two-shot upload - decodingErrors: [404] + expectingStatus: .accepted, // expected response code for a "two-shot" upload + decodingErrors: [.notFound] ) - guard let location = httpResponse.response.value(forHTTPHeaderField: "Location") else { + guard let location = httpResponse.response.headerFields[.location] else { throw HTTPClientError.missingResponseHeader("Location") } return URLComponents(string: location) } } +// The spec says that Docker- prefix headers are no longer to be used, but also specifies that the registry digest is returned in this header. +extension HTTPField.Name { static let dockerContentDigest = Self("Docker-Content-Digest")! } + public extension RegistryClient { func blobExists(repository: String, digest: String) async throws -> Bool { precondition(repository.count > 0, "repository must not be an empty string") @@ -67,7 +67,7 @@ public extension RegistryClient { decodingErrors: [404] ) return true - } catch HTTPClientError.unexpectedStatusCode(status: 404, _, _) { return false } + } catch HTTPClientError.unexpectedStatusCode(status: .notFound, _, _) { return false } } /// Fetches an unstructured blob of data from the registry. @@ -141,14 +141,14 @@ public extension RegistryClient { // All blob uploads have Content-Type: application/octet-stream on the wire, even if mediatype is different .put(uploadURL, contentType: "application/octet-stream"), uploading: data, - expectingStatus: 201, - decodingErrors: [400, 404] + expectingStatus: .created, + decodingErrors: [.badRequest, .notFound] ) // The registry could compute a different digest and we should use its value // as the canonical digest for linking blobs. If the registry sends a digest we // should check that it matches our locally-calculated digest. - if let serverDigest = httpResponse.response.value(forHTTPHeaderField: "Docker-Content-Digest") { + if let serverDigest = httpResponse.response.headerFields[.dockerContentDigest] { assert(digest == serverDigest) } return .init(mediaType: mediaType, digest: digest, size: Int64(data.count)) diff --git a/Sources/ContainerRegistry/CheckAPI.swift b/Sources/ContainerRegistry/CheckAPI.swift index d58283b..3ee33c7 100644 --- a/Sources/ContainerRegistry/CheckAPI.swift +++ b/Sources/ContainerRegistry/CheckAPI.swift @@ -20,8 +20,11 @@ public extension RegistryClient { // The registry may require authentication on this endpoint. // See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#determining-support do { - return try await executeRequestThrowing(.get(registryURLForPath("/v2/")), decodingErrors: [401, 404]).data - == EmptyObject() - } catch HTTPClientError.unexpectedStatusCode(status: 404, _, _) { return false } + return try await executeRequestThrowing( + .get(registryURLForPath("/v2/")), + decodingErrors: [.unauthorized, .notFound] + ) + .data == EmptyObject() + } catch HTTPClientError.unexpectedStatusCode(status: .notFound, _, _) { return false } } } diff --git a/Sources/ContainerRegistry/HTTPClient.swift b/Sources/ContainerRegistry/HTTPClient.swift index c855d4e..62b2c4c 100644 --- a/Sources/ContainerRegistry/HTTPClient.swift +++ b/Sources/ContainerRegistry/HTTPClient.swift @@ -16,16 +16,17 @@ import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif +import HTTPTypes +import HTTPTypesFoundation // HEAD does not include a response body so if an error is thrown, data will be nil public enum HTTPClientError: Error { - case nonHTTPResponse(URLResponse) - case unexpectedStatusCode(status: Int, response: HTTPURLResponse, data: Data?) + case unexpectedStatusCode(status: HTTPResponse.Status, response: HTTPResponse, data: Data?) case unexpectedContentType(String) case missingContentType case missingResponseHeader(String) - case authenticationChallenge(challenge: String, request: URLRequest, response: HTTPURLResponse) - case unauthorized(request: URLRequest, response: HTTPURLResponse) + case authenticationChallenge(challenge: String, request: HTTPRequest, response: HTTPResponse) + case unauthorized(request: HTTPRequest, response: HTTPResponse) } /// HTTPClient is an abstract HTTP client interface capable of uploads and downloads. @@ -34,20 +35,64 @@ public protocol HTTPClient { /// - Parameters: /// - request: The HTTP request to execute. /// - expectingStatus: The HTTP status code expected if the request is successful. - /// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPURLResponse. + /// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPResponse. /// - Throws: If the server response is unexpected or indicates that an error occurred. - func executeRequestThrowing(_ request: URLRequest, expectingStatus: Int) async throws -> (Data, HTTPURLResponse) + func executeRequestThrowing(_ request: HTTPRequest, expectingStatus: HTTPResponse.Status) async throws -> ( + Data, HTTPResponse + ) /// Execute an HTTP request uploading a request body. /// - Parameters: /// - request: The HTTP request to execute. /// - uploading: The request body to upload. /// - expectingStatus: The HTTP status code expected if the request is successful. - /// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPURLResponse. + /// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPResponse. /// - Throws: If the server response is unexpected or indicates that an error occurred. - func executeRequestThrowing(_ request: URLRequest, uploading: Data, expectingStatus: Int) async throws -> ( - Data, HTTPURLResponse - ) + func executeRequestThrowing(_ request: HTTPRequest, uploading: Data, expectingStatus: HTTPResponse.Status) + async throws -> (Data, HTTPResponse) +} + +// Wrappers to decode raw responses +public extension HTTPClient { + /// Execute an HTTP request with no request body, decoding the JSON response. + /// - Parameters: + /// - request: The HTTP request to execute. + /// - success: The HTTP status code expected if the request is successful. + /// - decoder: JSONDecoder instance with which to decode the response body. + /// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPResponse. + /// - Throws: If the server response is unexpected or indicates that an error occurred. + func executeRequestThrowing( + _ request: HTTPRequest, + expectingStatus success: HTTPResponse.Status, + decodingWith decoder: JSONDecoder + ) async throws -> (Response, HTTPResponse) { + let (data, httpResponse) = try await executeRequestThrowing(request, expectingStatus: success) + let decoded = try decoder.decode(Response.self, from: data) + return (decoded, httpResponse) + } + + /// Execute an HTTP request uploading a request body, decoding the JSON response. + /// - Parameters: + /// - request: The HTTP request to execute. + /// - payload: The request body to upload. + /// - success: The HTTP status code expected if the request is successful. + /// - decoder: JSONDecoder instance with which to decode the response body. + /// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPResponse. + /// - Throws: If the server response is unexpected or indicates that an error occurred. + func executeRequestThrowing( + _ request: HTTPRequest, + uploading payload: Data, + expectingStatus success: HTTPResponse.Status, + decodingWith decoder: JSONDecoder + ) async throws -> (Response, HTTPResponse) { + let (data, httpResponse) = try await executeRequestThrowing( + request, + uploading: payload, + expectingStatus: success + ) + let decoded = try decoder.decode(Response.self, from: data) + return (decoded, httpResponse) + } } extension URLSession: HTTPClient { @@ -57,62 +102,52 @@ extension URLSession: HTTPClient { /// - response: The response from the registry. /// - responseData: The raw response body data returned by the registry. /// - successfulStatus: The successful HTTP response expected from this request. - /// - Returns: An HTTPURLResponse representing the response, if the response was valid. + /// - Returns: An HTTPResponse representing the response, if the response was valid. /// - Throws: If the server response is unexpected or indicates that an error occurred. func validateAPIResponseThrowing( - request: URLRequest, - response: URLResponse, + request: HTTPRequest, + response: HTTPResponse, responseData: Data, - expectingStatus successfulStatus: Int - ) throws -> HTTPURLResponse { - guard let httpResponse = response as? HTTPURLResponse else { throw HTTPClientError.nonHTTPResponse(response) } - + expectingStatus successfulStatus: HTTPResponse.Status + ) throws -> HTTPResponse { // Convert errors into exceptions - guard httpResponse.statusCode == successfulStatus else { - // If the response includes an authentication challenge the client can try again - if httpResponse.statusCode == 401 { - if let authChallenge = httpResponse.value(forHTTPHeaderField: "WWW-Authenticate") { + guard response.status == successfulStatus else { + // If the response includes an authentication challenge the client can try again, prese + if response.status == .unauthorized { + if let authChallenge = response.headerFields[.wwwAuthenticate] { throw HTTPClientError.authenticationChallenge( challenge: authChallenge.trimmingCharacters(in: .whitespacesAndNewlines), request: request, - response: httpResponse + response: response ) } } // Content-Type should always be set, but there may be registries which don't set it. If it is not present, the HTTP standard allows // clients to guess the content type, or default to `application/octet-stream'. - guard let _ = httpResponse.value(forHTTPHeaderField: "Content-Type") else { + guard let _ = response.headerFields[.contentType] else { throw HTTPClientError.missingResponseHeader("Content-Type") } // A HEAD request has no response body and cannot be decoded - if request.httpMethod == "HEAD" { - throw HTTPClientError.unexpectedStatusCode( - status: httpResponse.statusCode, - response: httpResponse, - data: nil - ) + if request.method == .head { + throw HTTPClientError.unexpectedStatusCode(status: response.status, response: response, data: nil) } - throw HTTPClientError.unexpectedStatusCode( - status: httpResponse.statusCode, - response: httpResponse, - data: responseData - ) + throw HTTPClientError.unexpectedStatusCode(status: response.status, response: response, data: responseData) } - return httpResponse + return response } /// Execute an HTTP request with no request body. /// - Parameters: /// - request: The HTTP request to execute. /// - success: The HTTP status code expected if the request is successful. - /// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPURLResponse. + /// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPResponse. /// - Throws: If the server response is unexpected or indicates that an error occurred. - public func executeRequestThrowing(_ request: URLRequest, expectingStatus success: Int) async throws -> ( - Data, HTTPURLResponse - ) { + public func executeRequestThrowing(_ request: HTTPRequest, expectingStatus success: HTTPResponse.Status) + async throws -> (Data, HTTPResponse) + { let (responseData, urlResponse) = try await data(for: request) let httpResponse = try validateAPIResponseThrowing( request: request, @@ -128,11 +163,13 @@ extension URLSession: HTTPClient { /// - request: The HTTP request to execute. /// - payload: The request body to upload. /// - success: The HTTP status code expected if the request is successful. - /// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPURLResponse. + /// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPResponse. /// - Throws: If the server response is unexpected or indicates that an error occurred. - public func executeRequestThrowing(_ request: URLRequest, uploading payload: Data, expectingStatus success: Int) - async throws -> (Data, HTTPURLResponse) - { + public func executeRequestThrowing( + _ request: HTTPRequest, + uploading payload: Data, + expectingStatus success: HTTPResponse.Status + ) async throws -> (Data, HTTPResponse) { let (responseData, urlResponse) = try await upload(for: request, from: payload) let httpResponse = try validateAPIResponseThrowing( request: request, @@ -144,8 +181,8 @@ extension URLSession: HTTPClient { } } -extension URLRequest { - /// Constructs a URLRequest pre-configured with method, url and content types. +extension HTTPRequest { + /// Constructs a HTTPRequest pre-configured with method, url and content types. /// - Parameters: /// - method: HTTP method to use: "GET", "PUT" etc /// - url: The URL on which to operate. @@ -153,22 +190,22 @@ extension URLRequest { /// - contentType: The content-type of the request's body data, if any. /// - authorization: Authorization credentials for this request. init( - method: String, + method: HTTPRequest.Method, url: URL, accepting: [String] = [], contentType: String? = nil, withAuthorization authorization: String? = nil ) { self.init(url: url) - httpMethod = method - if let contentType { addValue(contentType, forHTTPHeaderField: "Content-Type") } - for acceptContentType in accepting { addValue(acceptContentType, forHTTPHeaderField: "Accept") } + self.method = method + if let contentType { headerFields[.contentType] = contentType } + if accepting.count > 0 { headerFields[values: .accept] = accepting } // The URLSession documentation warns not to do this: // https://developer.apple.com/documentation/foundation/urlsessionconfiguration/1411532-httpadditionalheaders#discussion // However this is the best option when URLSession does not support the server's authentication scheme: // https://developer.apple.com/forums/thread/89811 - if let authorization { addValue(authorization, forHTTPHeaderField: "Authorization") } + if let authorization { headerFields[.authorization] = authorization } } static func get( @@ -176,8 +213,8 @@ extension URLRequest { accepting: [String] = [], contentType: String? = nil, withAuthorization authorization: String? = nil - ) -> URLRequest { - .init(method: "GET", url: url, accepting: accepting, contentType: contentType, withAuthorization: authorization) + ) -> HTTPRequest { + .init(method: .get, url: url, accepting: accepting, contentType: contentType, withAuthorization: authorization) } static func head( @@ -185,14 +222,8 @@ extension URLRequest { accepting: [String] = [], contentType: String? = nil, withAuthorization authorization: String? = nil - ) -> URLRequest { - .init( - method: "HEAD", - url: url, - accepting: accepting, - contentType: contentType, - withAuthorization: authorization - ) + ) -> HTTPRequest { + .init(method: .head, url: url, accepting: accepting, contentType: contentType, withAuthorization: authorization) } static func put( @@ -200,8 +231,8 @@ extension URLRequest { accepting: [String] = [], contentType: String? = nil, withAuthorization authorization: String? = nil - ) -> URLRequest { - .init(method: "PUT", url: url, accepting: accepting, contentType: contentType, withAuthorization: authorization) + ) -> HTTPRequest { + .init(method: .put, url: url, accepting: accepting, contentType: contentType, withAuthorization: authorization) } static func post( @@ -209,13 +240,7 @@ extension URLRequest { accepting: [String] = [], contentType: String? = nil, withAuthorization authorization: String? = nil - ) -> URLRequest { - .init( - method: "POST", - url: url, - accepting: accepting, - contentType: contentType, - withAuthorization: authorization - ) + ) -> HTTPRequest { + .init(method: .post, url: url, accepting: accepting, contentType: contentType, withAuthorization: authorization) } } diff --git a/Sources/ContainerRegistry/Manifests.swift b/Sources/ContainerRegistry/Manifests.swift index 9b0138f..fc73a8f 100644 --- a/Sources/ContainerRegistry/Manifests.swift +++ b/Sources/ContainerRegistry/Manifests.swift @@ -24,11 +24,11 @@ public extension RegistryClient { contentType: "application/vnd.oci.image.manifest.v1+json" ), uploading: manifest, - expectingStatus: 201, - decodingErrors: [404] + expectingStatus: .created, + decodingErrors: [.notFound] ) - guard let location = httpResponse.response.value(forHTTPHeaderField: "Location") else { + guard let location = httpResponse.response.headerFields[.location] else { throw HTTPClientError.missingResponseHeader("Location") } return location @@ -46,7 +46,7 @@ public extension RegistryClient { "application/vnd.docker.distribution.manifest.v2+json", ] ), - decodingErrors: [404] + decodingErrors: [.notFound] ) .data } @@ -63,7 +63,7 @@ public extension RegistryClient { "application/vnd.docker.distribution.manifest.list.v2+json", ] ), - decodingErrors: [404] + decodingErrors: [.notFound] ) .data } diff --git a/Sources/ContainerRegistry/RegistryClient.swift b/Sources/ContainerRegistry/RegistryClient.swift index ad3e80d..63032c4 100644 --- a/Sources/ContainerRegistry/RegistryClient.swift +++ b/Sources/ContainerRegistry/RegistryClient.swift @@ -16,7 +16,7 @@ import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif - +import HTTPTypes import Basics enum RegistryClientError: Error { @@ -138,10 +138,10 @@ extension RegistryClient { /// A plain Data version of this function is required because Data is Decodable and decodes from base64. /// Plain blobs are not encoded in the registry, so trying to decode them will fail. public func executeRequestThrowing( - _ request: URLRequest, - expectingStatus success: Int = 200, - decodingErrors errors: [Int] - ) async throws -> (data: Data, response: HTTPURLResponse) { + _ request: HTTPRequest, + expectingStatus success: HTTPResponse.Status = .ok, + decodingErrors errors: [HTTPResponse.Status] + ) async throws -> (data: Data, response: HTTPResponse) { do { let authenticatedRequest = auth?.auth(for: request) ?? request return try await client.executeRequestThrowing(authenticatedRequest, expectingStatus: success) @@ -167,10 +167,10 @@ extension RegistryClient { /// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPURLResponse. /// - Throws: If the server response is unexpected or indicates that an error occurred. public func executeRequestThrowing( - _ request: URLRequest, - expectingStatus success: Int = 200, - decodingErrors errors: [Int] - ) async throws -> (data: Response, response: HTTPURLResponse) { + _ request: HTTPRequest, + expectingStatus success: HTTPResponse.Status = .ok, + decodingErrors errors: [HTTPResponse.Status] + ) async throws -> (data: Response, response: HTTPResponse) { let (data, httpResponse) = try await executeRequestThrowing( request, expectingStatus: success, @@ -192,11 +192,11 @@ extension RegistryClient { /// A plain Data version of this function is required because Data is Encodable and encodes to base64. /// Accidentally encoding data blobs will cause digests to fail and runtimes to be unable to run the images. public func executeRequestThrowing( - _ request: URLRequest, + _ request: HTTPRequest, uploading payload: Data, - expectingStatus success: Int, - decodingErrors errors: [Int] - ) async throws -> (data: Data, response: HTTPURLResponse) { + expectingStatus success: HTTPResponse.Status, + decodingErrors errors: [HTTPResponse.Status] + ) async throws -> (data: Data, response: HTTPResponse) { do { let authenticatedRequest = auth?.auth(for: request) ?? request return try await client.executeRequestThrowing( @@ -231,11 +231,11 @@ extension RegistryClient { /// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPURLResponse. /// - Throws: If the server response is unexpected or indicates that an error occurred. public func executeRequestThrowing( - _ request: URLRequest, + _ request: HTTPRequest, uploading payload: Body, - expectingStatus success: Int, - decodingErrors errors: [Int] - ) async throws -> (data: Data, response: HTTPURLResponse) { + expectingStatus success: HTTPResponse.Status, + decodingErrors errors: [HTTPResponse.Status] + ) async throws -> (data: Data, response: HTTPResponse) { try await executeRequestThrowing( request, uploading: try encoder.encode(payload), diff --git a/Sources/containertool/Extensions/Errors+CustomStringConvertible.swift b/Sources/containertool/Extensions/Errors+CustomStringConvertible.swift index ae04612..337473a 100644 --- a/Sources/containertool/Extensions/Errors+CustomStringConvertible.swift +++ b/Sources/containertool/Extensions/Errors+CustomStringConvertible.swift @@ -18,7 +18,6 @@ extension HTTPClientError: Swift.CustomStringConvertible { /// A human-readable string representing an underlying HTTP protocol error public var description: String { switch self { - case .nonHTTPResponse: return "Registry response was not valid HTTP" case .unexpectedStatusCode(let status, _, _): return "Registry returned an unexpected HTTP error code: \(status)" case .unexpectedContentType(let contentType):