Skip to content

Commit 1c775a3

Browse files
committed
ContainerRegistry: Add a struct to represent a registry operation
Refactor RegistryClient, adding a struct which holds all the necessary information about a requested operation on the registry. This 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.
1 parent e9f2f2c commit 1c775a3

File tree

6 files changed

+164
-73
lines changed

6 files changed

+164
-73
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.appendingPathComponent("/v2/")),
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: 8 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,8 @@ 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.appending(path: "/v2/\(repository)/manifests/\(manifest.digest)").absoluteString
4041
}
4142

4243
func getManifest(repository: String, reference: String) async throws -> ImageManifest {
@@ -46,7 +47,8 @@ public extension RegistryClient {
4647

4748
return try await executeRequestThrowing(
4849
.get(
49-
registryURLForPath("/v2/\(repository)/manifests/\(reference)"),
50+
repository,
51+
path: "manifests/\(reference)",
5052
accepting: [
5153
"application/vnd.oci.image.manifest.v1+json",
5254
"application/vnd.docker.distribution.manifest.v2+json",
@@ -63,7 +65,8 @@ public extension RegistryClient {
6365

6466
return try await executeRequestThrowing(
6567
.get(
66-
registryURLForPath("/v2/\(repository)/manifests/\(reference)"),
68+
repository,
69+
path: "manifests/\(reference)",
6770
accepting: [
6871
"application/vnd.oci.image.index.v1+json",
6972
"application/vnd.docker.distribution.manifest.list.v2+json",

Sources/ContainerRegistry/RegistryClient.swift

Lines changed: 146 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -115,33 +115,152 @@ public struct RegistryClient {
115115
let urlsession = URLSession(configuration: .ephemeral)
116116
try await self.init(registry: registryURL, client: urlsession, auth: auth)
117117
}
118+
}
119+
120+
extension RegistryClient {
121+
/// Represents an operation to be executed on the registry.
122+
struct RegistryOperation {
123+
enum Destination {
124+
case subpath(String) // Repository subpath on the registry
125+
case url(URL) // Full destination URL, for example from a Location header returned by the registry
126+
}
127+
128+
var method: HTTPRequest.Method // HTTP method
129+
var repository: String // Repository path on the registry
130+
var destination: Destination // Destination of the operation: can be a subpath or remote URL
131+
var accepting: [String] = [] // Acceptable response types
132+
var contentType: String? = nil // Request data type
118133

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)
134+
func url(relativeTo registry: URL) -> URL {
135+
switch destination {
136+
case .url(let url): return url
137+
case .subpath(let path):
138+
let subpath = registry.appendingPathComponent("/v2/\(repository)/\(path)")
139+
return subpath
140+
}
141+
}
142+
143+
// Convenience constructors
144+
static func get(
145+
_ repository: String,
146+
path: String,
147+
actions: [String]? = nil,
148+
accepting: [String] = [],
149+
contentType: String? = nil
150+
) -> RegistryOperation {
151+
.init(
152+
method: .get,
153+
repository: repository,
154+
destination: .subpath(path),
155+
accepting: accepting,
156+
contentType: contentType
157+
)
158+
}
159+
160+
static func get(
161+
_ repository: String,
162+
url: URL,
163+
actions: [String]? = nil,
164+
accepting: [String] = [],
165+
contentType: String? = nil
166+
) -> RegistryOperation {
167+
.init(
168+
method: .get,
169+
repository: repository,
170+
destination: .url(url),
171+
accepting: accepting,
172+
contentType: contentType
173+
)
174+
}
175+
176+
static func head(
177+
_ repository: String,
178+
path: String,
179+
actions: [String]? = nil,
180+
accepting: [String] = [],
181+
contentType: String? = nil
182+
) -> RegistryOperation {
183+
.init(
184+
method: .head,
185+
repository: repository,
186+
destination: .subpath(path),
187+
accepting: accepting,
188+
contentType: contentType
189+
)
190+
}
191+
192+
/// 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
193+
static func put(
194+
_ repository: String,
195+
url: URL,
196+
actions: [String]? = nil,
197+
accepting: [String] = [],
198+
contentType: String? = nil
199+
) -> RegistryOperation {
200+
.init(
201+
method: .put,
202+
repository: repository,
203+
destination: .url(url),
204+
accepting: accepting,
205+
contentType: contentType
206+
)
207+
}
208+
209+
static func put(
210+
_ repository: String,
211+
path: String,
212+
actions: [String]? = nil,
213+
accepting: [String] = [],
214+
contentType: String? = nil
215+
) -> RegistryOperation {
216+
.init(
217+
method: .put,
218+
repository: repository,
219+
destination: .subpath(path),
220+
accepting: accepting,
221+
contentType: contentType
222+
)
223+
}
224+
225+
static func post(
226+
_ repository: String,
227+
path: String,
228+
actions: [String]? = nil,
229+
accepting: [String] = [],
230+
contentType: String? = nil
231+
) -> RegistryOperation {
232+
.init(
233+
method: .post,
234+
repository: repository,
235+
destination: .subpath(path),
236+
accepting: accepting,
237+
contentType: contentType
238+
)
124239
}
125-
return url
126240
}
127-
}
128241

129-
extension RegistryClient {
130242
/// Execute an HTTP request with no request body.
131243
/// - Parameters:
132-
/// - request: The HTTP request to execute.
244+
/// - operation: The Registry operation to execute.
133245
/// - success: The HTTP status code expected if the request is successful.
134246
/// - errors: Expected error codes for which the registry sends structured error messages.
135247
/// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPURLResponse.
136248
/// - Throws: If the server response is unexpected or indicates that an error occurred.
137249
///
138250
/// A plain Data version of this function is required because Data is Decodable and decodes from base64.
139251
/// Plain blobs are not encoded in the registry, so trying to decode them will fail.
140-
public func executeRequestThrowing(
141-
_ request: HTTPRequest,
252+
func executeRequestThrowing(
253+
_ operation: RegistryOperation,
142254
expectingStatus success: HTTPResponse.Status = .ok,
143255
decodingErrors errors: [HTTPResponse.Status]
144256
) async throws -> (data: Data, response: HTTPResponse) {
257+
let request = HTTPRequest(
258+
method: operation.method,
259+
url: operation.url(relativeTo: registryURL),
260+
accepting: operation.accepting,
261+
contentType: operation.contentType
262+
)
263+
145264
do {
146265
let authenticatedRequest = auth?.auth(for: request) ?? request
147266
return try await client.executeRequestThrowing(authenticatedRequest, expectingStatus: success)
@@ -166,8 +285,8 @@ extension RegistryClient {
166285
/// - errors: Expected error codes for which the registry sends structured error messages.
167286
/// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPURLResponse.
168287
/// - Throws: If the server response is unexpected or indicates that an error occurred.
169-
public func executeRequestThrowing<Response: Decodable>(
170-
_ request: HTTPRequest,
288+
func executeRequestThrowing<Response: Decodable>(
289+
_ request: RegistryOperation,
171290
expectingStatus success: HTTPResponse.Status = .ok,
172291
decodingErrors errors: [HTTPResponse.Status]
173292
) async throws -> (data: Response, response: HTTPResponse) {
@@ -182,7 +301,7 @@ extension RegistryClient {
182301

183302
/// Execute an HTTP request uploading a request body.
184303
/// - Parameters:
185-
/// - request: The HTTP request to execute.
304+
/// - operation: The Registry operation to execute.
186305
/// - payload: The request body to upload.
187306
/// - success: The HTTP status code expected if the request is successful.
188307
/// - errors: Expected error codes for which the registry sends structured error messages.
@@ -191,12 +310,19 @@ extension RegistryClient {
191310
///
192311
/// A plain Data version of this function is required because Data is Encodable and encodes to base64.
193312
/// 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,
313+
func executeRequestThrowing(
314+
_ operation: RegistryOperation,
196315
uploading payload: Data,
197316
expectingStatus success: HTTPResponse.Status,
198317
decodingErrors errors: [HTTPResponse.Status]
199318
) async throws -> (data: Data, response: HTTPResponse) {
319+
let request = HTTPRequest(
320+
method: operation.method,
321+
url: operation.url(relativeTo: registryURL),
322+
accepting: operation.accepting,
323+
contentType: operation.contentType
324+
)
325+
200326
do {
201327
let authenticatedRequest = auth?.auth(for: request) ?? request
202328
return try await client.executeRequestThrowing(
@@ -224,20 +350,20 @@ extension RegistryClient {
224350

225351
/// Execute an HTTP request uploading a Codable request body.
226352
/// - Parameters:
227-
/// - request: The HTTP request to execute.
353+
/// - operation: The Registry operation to execute.
228354
/// - payload: The request body to upload.
229355
/// - success: The HTTP status code expected if the request is successful.
230356
/// - errors: Expected error codes for which the registry sends structured error messages.
231357
/// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPURLResponse.
232358
/// - Throws: If the server response is unexpected or indicates that an error occurred.
233-
public func executeRequestThrowing<Body: Encodable>(
234-
_ request: HTTPRequest,
359+
func executeRequestThrowing<Body: Encodable>(
360+
_ operation: RegistryOperation,
235361
uploading payload: Body,
236362
expectingStatus success: HTTPResponse.Status,
237363
decodingErrors errors: [HTTPResponse.Status]
238364
) async throws -> (data: Data, response: HTTPResponse) {
239365
try await executeRequestThrowing(
240-
request,
366+
operation,
241367
uploading: try encoder.encode(payload),
242368
expectingStatus: success,
243369
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)