Skip to content

Commit 483ad76

Browse files
authored
ContainerRegistry: Add a struct to represent a registry operation (#29)
### Motivation `RegistryClient.executeRequestThrowing` currently takes an `HTTPRequest` struct parameter and executes it using the underlying `HTTPClient`. It augments `HTTPClient` by handling authentication challenges and decodes JSON error responses, but the use of `HTTPRequest` means that the caller is responsible for details such as generating the full distribution endpoint URL. Giving `RegistryClient` its own abstract operation type frees the caller from dealing with these internal details. The struct can also be passed easily anywhere the details of the operation are required, making future improvements easier. For instance, if an operation causes an error, the original operation struct could be attached to the error making all its details available to the error handler. ### Modifications Add a struct which holds all the necessary information about a requested operation on the registry, and refactor `RegistryClient` to use it. ### Result No functional change. This refactoring makes the separation of RegistryClient and HTTPClient's responsibilities clearer, and allows us to centralise the generation of registry URLs. In the future it will make further improvements easier, such as changing how authentication is handled and providing more detailed error messages. ### Test Plan Automated tests continue to pass; also tested manually.
1 parent e9f2f2c commit 483ad76

File tree

6 files changed

+178
-72
lines changed

6 files changed

+178
-72
lines changed

Sources/ContainerRegistry/Blobs.swift

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ extension RegistryClient {
4141
// - Do not include the digest.
4242
// Response will include a 'Location' header telling us where to PUT the blob data.
4343
let httpResponse = try await executeRequestThrowing(
44-
.post(registryURLForPath("/v2/\(repository)/blobs/uploads/")),
44+
.post(repository, path: "blobs/uploads/"),
4545
expectingStatus: .accepted, // expected response code for a "two-shot" upload
4646
decodingErrors: [.notFound]
4747
)
@@ -63,7 +63,7 @@ public extension RegistryClient {
6363

6464
do {
6565
let _ = try await executeRequestThrowing(
66-
.head(registryURLForPath("/v2/\(repository)/blobs/\(digest)")),
66+
.head(repository, path: "blobs/\(digest)"),
6767
decodingErrors: [.notFound]
6868
)
6969
return true
@@ -82,7 +82,7 @@ public extension RegistryClient {
8282
precondition(digest.count > 0, "digest must not be an empty string")
8383

8484
return try await executeRequestThrowing(
85-
.get(registryURLForPath("/v2/\(repository)/blobs/\(digest)"), accepting: ["application/octet-stream"]),
85+
.get(repository, path: "blobs/\(digest)", accepting: ["application/octet-stream"]),
8686
decodingErrors: [.notFound]
8787
)
8888
.data
@@ -105,7 +105,7 @@ public extension RegistryClient {
105105
precondition(digest.count > 0, "digest must not be an empty string")
106106

107107
return try await executeRequestThrowing(
108-
.get(registryURLForPath("/v2/\(repository)/blobs/\(digest)"), accepting: ["application/octet-stream"]),
108+
.get(repository, path: "blobs/\(digest)", accepting: ["application/octet-stream"]),
109109
decodingErrors: [.notFound]
110110
)
111111
.data
@@ -136,10 +136,9 @@ public extension RegistryClient {
136136
let digest = digest(of: data)
137137
location.queryItems = (location.queryItems ?? []) + [URLQueryItem(name: "digest", value: "\(digest.utf8)")]
138138
guard let uploadURL = location.url else { throw RegistryClientError.invalidUploadLocation("\(location)") }
139-
140139
let httpResponse = try await executeRequestThrowing(
141140
// All blob uploads have Content-Type: application/octet-stream on the wire, even if mediatype is different
142-
.put(uploadURL, contentType: "application/octet-stream"),
141+
.put(repository, url: uploadURL, contentType: "application/octet-stream"),
143142
uploading: data,
144143
expectingStatus: .created,
145144
decodingErrors: [.badRequest, .notFound]

Sources/ContainerRegistry/CheckAPI.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ public extension RegistryClient {
2222
// but this is not required and some do not.
2323
// The registry may require authentication on this endpoint.
2424
do {
25+
// Using the bare HTTP client because this is the only endpoint which does not include a repository path
2526
let _ = try await executeRequestThrowing(
26-
.get(registryURLForPath("/v2/")),
27+
.get("", url: registryURL.distributionEndpoint),
28+
expectingStatus: .ok,
2729
decodingErrors: [.unauthorized, .notFound]
2830
)
2931
return true

Sources/ContainerRegistry/HTTPClient.swift

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -159,40 +159,4 @@ extension HTTPRequest {
159159
// https://developer.apple.com/forums/thread/89811
160160
if let authorization { headerFields[.authorization] = authorization }
161161
}
162-
163-
static func get(
164-
_ url: URL,
165-
accepting: [String] = [],
166-
contentType: String? = nil,
167-
withAuthorization authorization: String? = nil
168-
) -> HTTPRequest {
169-
.init(method: .get, url: url, accepting: accepting, contentType: contentType, withAuthorization: authorization)
170-
}
171-
172-
static func head(
173-
_ url: URL,
174-
accepting: [String] = [],
175-
contentType: String? = nil,
176-
withAuthorization authorization: String? = nil
177-
) -> HTTPRequest {
178-
.init(method: .head, url: url, accepting: accepting, contentType: contentType, withAuthorization: authorization)
179-
}
180-
181-
static func put(
182-
_ url: URL,
183-
accepting: [String] = [],
184-
contentType: String? = nil,
185-
withAuthorization authorization: String? = nil
186-
) -> HTTPRequest {
187-
.init(method: .put, url: url, accepting: accepting, contentType: contentType, withAuthorization: authorization)
188-
}
189-
190-
static func post(
191-
_ url: URL,
192-
accepting: [String] = [],
193-
contentType: String? = nil,
194-
withAuthorization authorization: String? = nil
195-
) -> HTTPRequest {
196-
.init(method: .post, url: url, accepting: accepting, contentType: contentType, withAuthorization: authorization)
197-
}
198162
}

Sources/ContainerRegistry/Manifests.swift

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ public extension RegistryClient {
2121
let httpResponse = try await executeRequestThrowing(
2222
// All blob uploads have Content-Type: application/octet-stream on the wire, even if mediatype is different
2323
.put(
24-
registryURLForPath("/v2/\(repository)/manifests/\(reference)"),
24+
repository,
25+
path: "manifests/\(reference)",
2526
contentType: manifest.mediaType ?? "application/vnd.oci.image.manifest.v1+json"
2627
),
2728
uploading: manifest,
@@ -35,8 +36,9 @@ public extension RegistryClient {
3536
// ECR does not set this header at all.
3637
// If the header is not present, create a suitable value.
3738
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests
38-
return try httpResponse.response.headerFields[.location]
39-
?? registryURLForPath("/v2/\(repository)/manifests/\(manifest.digest)").absoluteString
39+
return httpResponse.response.headerFields[.location]
40+
?? registryURL.distributionEndpoint(forRepository: repository, andEndpoint: "manifests/\(manifest.digest)")
41+
.absoluteString
4042
}
4143

4244
func getManifest(repository: String, reference: String) async throws -> ImageManifest {
@@ -46,7 +48,8 @@ public extension RegistryClient {
4648

4749
return try await executeRequestThrowing(
4850
.get(
49-
registryURLForPath("/v2/\(repository)/manifests/\(reference)"),
51+
repository,
52+
path: "manifests/\(reference)",
5053
accepting: [
5154
"application/vnd.oci.image.manifest.v1+json",
5255
"application/vnd.docker.distribution.manifest.v2+json",
@@ -63,7 +66,8 @@ public extension RegistryClient {
6366

6467
return try await executeRequestThrowing(
6568
.get(
66-
registryURLForPath("/v2/\(repository)/manifests/\(reference)"),
69+
repository,
70+
path: "manifests/\(reference)",
6771
accepting: [
6872
"application/vnd.oci.image.index.v1+json",
6973
"application/vnd.docker.distribution.manifest.list.v2+json",

Sources/ContainerRegistry/RegistryClient.swift

Lines changed: 159 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -115,33 +115,166 @@ public struct RegistryClient {
115115
let urlsession = URLSession(configuration: .ephemeral)
116116
try await self.init(registry: registryURL, client: urlsession, auth: auth)
117117
}
118+
}
118119

119-
func registryURLForPath(_ path: String) throws -> URL {
120-
var components = URLComponents()
121-
components.path = path
122-
guard let url = components.url(relativeTo: registryURL) else {
123-
throw RegistryClientError.invalidRegistryPath(path)
124-
}
125-
return url
120+
extension URL {
121+
/// The base distribution endpoint URL
122+
var distributionEndpoint: URL { self.appendingPathComponent("/v2/") }
123+
124+
/// The URL for a particular endpoint relating to a particular repository
125+
/// - Parameters:
126+
/// - repository: The name of the repository. May include path separators.
127+
/// - endpoint: The distribution endpoint e.g. "tags/list"
128+
/// - Returns: A fully-qualified URL for the endpoint.
129+
func distributionEndpoint(forRepository repository: String, andEndpoint endpoint: String) -> URL {
130+
self.appendingPathComponent("/v2/\(repository)/\(endpoint)")
126131
}
127132
}
128133

129134
extension RegistryClient {
135+
/// Represents an operation to be executed on the registry.
136+
struct RegistryOperation {
137+
enum Destination {
138+
case subpath(String) // Repository subpath on the registry
139+
case url(URL) // Full destination URL, for example from a Location header returned by the registry
140+
}
141+
142+
var method: HTTPRequest.Method // HTTP method
143+
var repository: String // Repository path on the registry
144+
var destination: Destination // Destination of the operation: can be a subpath or remote URL
145+
var accepting: [String] = [] // Acceptable response types
146+
var contentType: String? = nil // Request data type
147+
148+
func url(relativeTo registry: URL) -> URL {
149+
switch destination {
150+
case .url(let url): return url
151+
case .subpath(let path):
152+
let subpath = registry.distributionEndpoint(forRepository: repository, andEndpoint: path)
153+
return subpath
154+
}
155+
}
156+
157+
// Convenience constructors
158+
static func get(
159+
_ repository: String,
160+
path: String,
161+
actions: [String]? = nil,
162+
accepting: [String] = [],
163+
contentType: String? = nil
164+
) -> RegistryOperation {
165+
.init(
166+
method: .get,
167+
repository: repository,
168+
destination: .subpath(path),
169+
accepting: accepting,
170+
contentType: contentType
171+
)
172+
}
173+
174+
static func get(
175+
_ repository: String,
176+
url: URL,
177+
actions: [String]? = nil,
178+
accepting: [String] = [],
179+
contentType: String? = nil
180+
) -> RegistryOperation {
181+
.init(
182+
method: .get,
183+
repository: repository,
184+
destination: .url(url),
185+
accepting: accepting,
186+
contentType: contentType
187+
)
188+
}
189+
190+
static func head(
191+
_ repository: String,
192+
path: String,
193+
actions: [String]? = nil,
194+
accepting: [String] = [],
195+
contentType: String? = nil
196+
) -> RegistryOperation {
197+
.init(
198+
method: .head,
199+
repository: repository,
200+
destination: .subpath(path),
201+
accepting: accepting,
202+
contentType: contentType
203+
)
204+
}
205+
206+
/// This handles the 'put' case where the registry gives us a location URL which we must not alter, aside from adding the digest to it
207+
static func put(
208+
_ repository: String,
209+
url: URL,
210+
actions: [String]? = nil,
211+
accepting: [String] = [],
212+
contentType: String? = nil
213+
) -> RegistryOperation {
214+
.init(
215+
method: .put,
216+
repository: repository,
217+
destination: .url(url),
218+
accepting: accepting,
219+
contentType: contentType
220+
)
221+
}
222+
223+
static func put(
224+
_ repository: String,
225+
path: String,
226+
actions: [String]? = nil,
227+
accepting: [String] = [],
228+
contentType: String? = nil
229+
) -> RegistryOperation {
230+
.init(
231+
method: .put,
232+
repository: repository,
233+
destination: .subpath(path),
234+
accepting: accepting,
235+
contentType: contentType
236+
)
237+
}
238+
239+
static func post(
240+
_ repository: String,
241+
path: String,
242+
actions: [String]? = nil,
243+
accepting: [String] = [],
244+
contentType: String? = nil
245+
) -> RegistryOperation {
246+
.init(
247+
method: .post,
248+
repository: repository,
249+
destination: .subpath(path),
250+
accepting: accepting,
251+
contentType: contentType
252+
)
253+
}
254+
}
255+
130256
/// Execute an HTTP request with no request body.
131257
/// - Parameters:
132-
/// - request: The HTTP request to execute.
258+
/// - operation: The Registry operation to execute.
133259
/// - success: The HTTP status code expected if the request is successful.
134260
/// - errors: Expected error codes for which the registry sends structured error messages.
135261
/// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPURLResponse.
136262
/// - Throws: If the server response is unexpected or indicates that an error occurred.
137263
///
138264
/// A plain Data version of this function is required because Data is Decodable and decodes from base64.
139265
/// Plain blobs are not encoded in the registry, so trying to decode them will fail.
140-
public func executeRequestThrowing(
141-
_ request: HTTPRequest,
266+
func executeRequestThrowing(
267+
_ operation: RegistryOperation,
142268
expectingStatus success: HTTPResponse.Status = .ok,
143269
decodingErrors errors: [HTTPResponse.Status]
144270
) async throws -> (data: Data, response: HTTPResponse) {
271+
let request = HTTPRequest(
272+
method: operation.method,
273+
url: operation.url(relativeTo: registryURL),
274+
accepting: operation.accepting,
275+
contentType: operation.contentType
276+
)
277+
145278
do {
146279
let authenticatedRequest = auth?.auth(for: request) ?? request
147280
return try await client.executeRequestThrowing(authenticatedRequest, expectingStatus: success)
@@ -166,8 +299,8 @@ extension RegistryClient {
166299
/// - errors: Expected error codes for which the registry sends structured error messages.
167300
/// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPURLResponse.
168301
/// - Throws: If the server response is unexpected or indicates that an error occurred.
169-
public func executeRequestThrowing<Response: Decodable>(
170-
_ request: HTTPRequest,
302+
func executeRequestThrowing<Response: Decodable>(
303+
_ request: RegistryOperation,
171304
expectingStatus success: HTTPResponse.Status = .ok,
172305
decodingErrors errors: [HTTPResponse.Status]
173306
) async throws -> (data: Response, response: HTTPResponse) {
@@ -182,7 +315,7 @@ extension RegistryClient {
182315

183316
/// Execute an HTTP request uploading a request body.
184317
/// - Parameters:
185-
/// - request: The HTTP request to execute.
318+
/// - operation: The Registry operation to execute.
186319
/// - payload: The request body to upload.
187320
/// - success: The HTTP status code expected if the request is successful.
188321
/// - errors: Expected error codes for which the registry sends structured error messages.
@@ -191,12 +324,19 @@ extension RegistryClient {
191324
///
192325
/// A plain Data version of this function is required because Data is Encodable and encodes to base64.
193326
/// Accidentally encoding data blobs will cause digests to fail and runtimes to be unable to run the images.
194-
public func executeRequestThrowing(
195-
_ request: HTTPRequest,
327+
func executeRequestThrowing(
328+
_ operation: RegistryOperation,
196329
uploading payload: Data,
197330
expectingStatus success: HTTPResponse.Status,
198331
decodingErrors errors: [HTTPResponse.Status]
199332
) async throws -> (data: Data, response: HTTPResponse) {
333+
let request = HTTPRequest(
334+
method: operation.method,
335+
url: operation.url(relativeTo: registryURL),
336+
accepting: operation.accepting,
337+
contentType: operation.contentType
338+
)
339+
200340
do {
201341
let authenticatedRequest = auth?.auth(for: request) ?? request
202342
return try await client.executeRequestThrowing(
@@ -224,20 +364,20 @@ extension RegistryClient {
224364

225365
/// Execute an HTTP request uploading a Codable request body.
226366
/// - Parameters:
227-
/// - request: The HTTP request to execute.
367+
/// - operation: The Registry operation to execute.
228368
/// - payload: The request body to upload.
229369
/// - success: The HTTP status code expected if the request is successful.
230370
/// - errors: Expected error codes for which the registry sends structured error messages.
231371
/// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPURLResponse.
232372
/// - Throws: If the server response is unexpected or indicates that an error occurred.
233-
public func executeRequestThrowing<Body: Encodable>(
234-
_ request: HTTPRequest,
373+
func executeRequestThrowing<Body: Encodable>(
374+
_ operation: RegistryOperation,
235375
uploading payload: Body,
236376
expectingStatus success: HTTPResponse.Status,
237377
decodingErrors errors: [HTTPResponse.Status]
238378
) async throws -> (data: Data, response: HTTPResponse) {
239379
try await executeRequestThrowing(
240-
request,
380+
operation,
241381
uploading: try encoder.encode(payload),
242382
expectingStatus: success,
243383
decodingErrors: errors

Sources/ContainerRegistry/Tags.swift

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,7 @@ public extension RegistryClient {
1616
func getTags(repository: String) async throws -> Tags {
1717
// See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#listing-tags
1818
precondition(repository.count > 0, "repository must not be an empty string")
19-
return try await executeRequestThrowing(
20-
.get(registryURLForPath("/v2/\(repository)/tags/list")),
21-
decodingErrors: [.notFound]
22-
)
23-
.data
19+
20+
return try await executeRequestThrowing(.get(repository, path: "tags/list"), decodingErrors: [.notFound]).data
2421
}
2522
}

0 commit comments

Comments
 (0)