Skip to content

Commit 6d2951a

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 6d2951a

File tree

6 files changed

+177
-72
lines changed

6 files changed

+177
-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: 158 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -115,33 +115,165 @@ 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+
func distributionEndpoint(forRepository repository: String, andEndpoint endpoint: String) -> URL {
129+
self.appendingPathComponent("/v2/\(repository)/\(endpoint)")
126130
}
127131
}
128132

129133
extension RegistryClient {
134+
/// Represents an operation to be executed on the registry.
135+
struct RegistryOperation {
136+
enum Destination {
137+
case subpath(String) // Repository subpath on the registry
138+
case url(URL) // Full destination URL, for example from a Location header returned by the registry
139+
}
140+
141+
var method: HTTPRequest.Method // HTTP method
142+
var repository: String // Repository path on the registry
143+
var destination: Destination // Destination of the operation: can be a subpath or remote URL
144+
var accepting: [String] = [] // Acceptable response types
145+
var contentType: String? = nil // Request data type
146+
147+
func url(relativeTo registry: URL) -> URL {
148+
switch destination {
149+
case .url(let url): return url
150+
case .subpath(let path):
151+
let subpath = registry.distributionEndpoint(forRepository: repository, andEndpoint: path)
152+
return subpath
153+
}
154+
}
155+
156+
// Convenience constructors
157+
static func get(
158+
_ repository: String,
159+
path: String,
160+
actions: [String]? = nil,
161+
accepting: [String] = [],
162+
contentType: String? = nil
163+
) -> RegistryOperation {
164+
.init(
165+
method: .get,
166+
repository: repository,
167+
destination: .subpath(path),
168+
accepting: accepting,
169+
contentType: contentType
170+
)
171+
}
172+
173+
static func get(
174+
_ repository: String,
175+
url: URL,
176+
actions: [String]? = nil,
177+
accepting: [String] = [],
178+
contentType: String? = nil
179+
) -> RegistryOperation {
180+
.init(
181+
method: .get,
182+
repository: repository,
183+
destination: .url(url),
184+
accepting: accepting,
185+
contentType: contentType
186+
)
187+
}
188+
189+
static func head(
190+
_ repository: String,
191+
path: String,
192+
actions: [String]? = nil,
193+
accepting: [String] = [],
194+
contentType: String? = nil
195+
) -> RegistryOperation {
196+
.init(
197+
method: .head,
198+
repository: repository,
199+
destination: .subpath(path),
200+
accepting: accepting,
201+
contentType: contentType
202+
)
203+
}
204+
205+
/// 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
206+
static func put(
207+
_ repository: String,
208+
url: URL,
209+
actions: [String]? = nil,
210+
accepting: [String] = [],
211+
contentType: String? = nil
212+
) -> RegistryOperation {
213+
.init(
214+
method: .put,
215+
repository: repository,
216+
destination: .url(url),
217+
accepting: accepting,
218+
contentType: contentType
219+
)
220+
}
221+
222+
static func put(
223+
_ repository: String,
224+
path: String,
225+
actions: [String]? = nil,
226+
accepting: [String] = [],
227+
contentType: String? = nil
228+
) -> RegistryOperation {
229+
.init(
230+
method: .put,
231+
repository: repository,
232+
destination: .subpath(path),
233+
accepting: accepting,
234+
contentType: contentType
235+
)
236+
}
237+
238+
static func post(
239+
_ repository: String,
240+
path: String,
241+
actions: [String]? = nil,
242+
accepting: [String] = [],
243+
contentType: String? = nil
244+
) -> RegistryOperation {
245+
.init(
246+
method: .post,
247+
repository: repository,
248+
destination: .subpath(path),
249+
accepting: accepting,
250+
contentType: contentType
251+
)
252+
}
253+
}
254+
130255
/// Execute an HTTP request with no request body.
131256
/// - Parameters:
132-
/// - request: The HTTP request to execute.
257+
/// - operation: The Registry operation to execute.
133258
/// - success: The HTTP status code expected if the request is successful.
134259
/// - errors: Expected error codes for which the registry sends structured error messages.
135260
/// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPURLResponse.
136261
/// - Throws: If the server response is unexpected or indicates that an error occurred.
137262
///
138263
/// A plain Data version of this function is required because Data is Decodable and decodes from base64.
139264
/// Plain blobs are not encoded in the registry, so trying to decode them will fail.
140-
public func executeRequestThrowing(
141-
_ request: HTTPRequest,
265+
func executeRequestThrowing(
266+
_ operation: RegistryOperation,
142267
expectingStatus success: HTTPResponse.Status = .ok,
143268
decodingErrors errors: [HTTPResponse.Status]
144269
) async throws -> (data: Data, response: HTTPResponse) {
270+
let request = HTTPRequest(
271+
method: operation.method,
272+
url: operation.url(relativeTo: registryURL),
273+
accepting: operation.accepting,
274+
contentType: operation.contentType
275+
)
276+
145277
do {
146278
let authenticatedRequest = auth?.auth(for: request) ?? request
147279
return try await client.executeRequestThrowing(authenticatedRequest, expectingStatus: success)
@@ -166,8 +298,8 @@ extension RegistryClient {
166298
/// - errors: Expected error codes for which the registry sends structured error messages.
167299
/// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPURLResponse.
168300
/// - Throws: If the server response is unexpected or indicates that an error occurred.
169-
public func executeRequestThrowing<Response: Decodable>(
170-
_ request: HTTPRequest,
301+
func executeRequestThrowing<Response: Decodable>(
302+
_ request: RegistryOperation,
171303
expectingStatus success: HTTPResponse.Status = .ok,
172304
decodingErrors errors: [HTTPResponse.Status]
173305
) async throws -> (data: Response, response: HTTPResponse) {
@@ -182,7 +314,7 @@ extension RegistryClient {
182314

183315
/// Execute an HTTP request uploading a request body.
184316
/// - Parameters:
185-
/// - request: The HTTP request to execute.
317+
/// - operation: The Registry operation to execute.
186318
/// - payload: The request body to upload.
187319
/// - success: The HTTP status code expected if the request is successful.
188320
/// - errors: Expected error codes for which the registry sends structured error messages.
@@ -191,12 +323,19 @@ extension RegistryClient {
191323
///
192324
/// A plain Data version of this function is required because Data is Encodable and encodes to base64.
193325
/// 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,
326+
func executeRequestThrowing(
327+
_ operation: RegistryOperation,
196328
uploading payload: Data,
197329
expectingStatus success: HTTPResponse.Status,
198330
decodingErrors errors: [HTTPResponse.Status]
199331
) async throws -> (data: Data, response: HTTPResponse) {
332+
let request = HTTPRequest(
333+
method: operation.method,
334+
url: operation.url(relativeTo: registryURL),
335+
accepting: operation.accepting,
336+
contentType: operation.contentType
337+
)
338+
200339
do {
201340
let authenticatedRequest = auth?.auth(for: request) ?? request
202341
return try await client.executeRequestThrowing(
@@ -224,20 +363,20 @@ extension RegistryClient {
224363

225364
/// Execute an HTTP request uploading a Codable request body.
226365
/// - Parameters:
227-
/// - request: The HTTP request to execute.
366+
/// - operation: The Registry operation to execute.
228367
/// - payload: The request body to upload.
229368
/// - success: The HTTP status code expected if the request is successful.
230369
/// - errors: Expected error codes for which the registry sends structured error messages.
231370
/// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPURLResponse.
232371
/// - Throws: If the server response is unexpected or indicates that an error occurred.
233-
public func executeRequestThrowing<Body: Encodable>(
234-
_ request: HTTPRequest,
372+
func executeRequestThrowing<Body: Encodable>(
373+
_ operation: RegistryOperation,
235374
uploading payload: Body,
236375
expectingStatus success: HTTPResponse.Status,
237376
decodingErrors errors: [HTTPResponse.Status]
238377
) async throws -> (data: Data, response: HTTPResponse) {
239378
try await executeRequestThrowing(
240-
request,
379+
operation,
241380
uploading: try encoder.encode(payload),
242381
expectingStatus: success,
243382
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)