Skip to content

Commit 597b99f

Browse files
authored
[Registry] Make 'Content-Version' optional for archive and manifest download API (#6156)
Motivation: The 'Content-Version' header is required in all registry server responses to indicate API version. However, it shouldn't be required for responses that don't/can't change, such as the download archive and fetch package manifest endpoints. This may also cause problems for registry that have these files hosted elsewhere, because they may not have full control over response headers. Modifications: - Modify registry API spec to make 'Content-Version' optional for the said endpoints - Adjust `RegistryClient` rdar://105415468
1 parent 51e41d1 commit 597b99f

File tree

3 files changed

+236
-14
lines changed

3 files changed

+236
-14
lines changed

Documentation/Registry.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,12 @@ Valid `Accept` header field values are described by the following rules:
183183
accept = "application/vnd.swift.registry" [".v" version] ["+" mediatype]
184184
```
185185

186-
A server MUST set the `Content-Type` and `Content-Version` header fields
187-
with the corresponding content type and API version number of the response.
186+
A server MUST set the `Content-Type` header field
187+
with the corresponding content type of the response.
188+
189+
A server MUST set the `Content-Version` header field
190+
with the API version number of the response, unless
191+
explicitly stated otherwise.
188192

189193
```http
190194
HTTP/1.1 200 OK
@@ -594,6 +598,10 @@ set to `attachment` with a `filename` parameter equal to
594598
the name of the manifest file
595599
(for example, "Package.swift").
596600

601+
A server MAY omit the `Content-Version` header
602+
since the response content (i.e., the manifest) SHOULD NOT
603+
change across different API versions.
604+
597605
It is RECOMMENDED for clients and servers to support
598606
caching as described by [RFC 7234].
599607

@@ -711,6 +719,10 @@ set to `attachment` with a `filename` parameter equal to the name of the package
711719
followed by a hyphen (`-`), the version number, and file extension
712720
(for example, "LinkedList-1.1.1.zip").
713721

722+
A server MAY omit the `Content-Version` header
723+
since the response content (i.e., the source archive) SHOULD NOT
724+
change across different API versions.
725+
714726
It is RECOMMENDED for clients and servers to support
715727
range requests as described by [RFC 7233]
716728
and caching as described by [RFC 7234].
@@ -1379,7 +1391,7 @@ paths:
13791391
schema:
13801392
type: integer
13811393
Content-Version:
1382-
$ref: "#/components/headers/contentVersion"
1394+
$ref: "#/components/headers/optionalContentVersion"
13831395
Link:
13841396
schema:
13851397
type: string
@@ -1426,7 +1438,7 @@ paths:
14261438
schema:
14271439
type: integer
14281440
Content-Version:
1429-
$ref: "#/components/headers/contentVersion"
1441+
$ref: "#/components/headers/optionalContentVersion"
14301442
Digest:
14311443
required: true
14321444
schema:
@@ -1666,7 +1678,13 @@ components:
16661678
schema:
16671679
type: string
16681680
enum:
1669-
- - "1"
1681+
- "1"
1682+
optionalContentVersion:
1683+
required: false
1684+
schema:
1685+
type: string
1686+
enum:
1687+
- "1"
16701688

16711689
```
16721690

Sources/PackageRegistry/RegistryClient.swift

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// This source file is part of the Swift open source project
44
//
5-
// Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
5+
// Copyright (c) 2021-2023 Apple Inc. and the Swift project authors
66
// Licensed under Apache License v2.0 with Runtime Library Exception
77
//
88
// See http://swift.org/LICENSE.txt for license information
@@ -265,7 +265,8 @@ public final class RegistryClient: Cancellable {
265265
try self.checkResponseStatusAndHeaders(
266266
response,
267267
expectedStatusCode: 200,
268-
expectedContentType: .swift
268+
expectedContentType: .swift,
269+
isContentVersionOptional: true
269270
)
270271

271272
guard let data = response.body else {
@@ -443,7 +444,12 @@ public final class RegistryClient: Cancellable {
443444
switch result {
444445
case .success(let response):
445446
do {
446-
try self.checkResponseStatusAndHeaders(response, expectedStatusCode: 200, expectedContentType: .zip)
447+
try self.checkResponseStatusAndHeaders(
448+
response,
449+
expectedStatusCode: 200,
450+
expectedContentType: .zip,
451+
isContentVersionOptional: true
452+
)
447453
} catch {
448454
return completion(.failure(RegistryError.failedDownloadingSourceArchive(error)))
449455
}
@@ -739,20 +745,29 @@ extension RegistryClient {
739745
private func acceptHeader(mediaType: MediaType) -> String {
740746
"application/vnd.swift.registry.v\(self.apiVersion.rawValue)+\(mediaType)"
741747
}
748+
749+
private func checkContentVersion(_ response: HTTPClient.Response, isOptional: Bool) throws {
750+
let contentVersion = response.headers.get("Content-Version").first
751+
if isOptional, contentVersion == nil {
752+
return
753+
}
754+
// Check API version as long as `Content-Version` is set
755+
guard contentVersion == self.apiVersion.rawValue else {
756+
throw RegistryError.invalidContentVersion(expected: self.apiVersion.rawValue, actual: contentVersion)
757+
}
758+
}
742759

743760
private func checkResponseStatusAndHeaders(
744761
_ response: HTTPClient.Response,
745762
expectedStatusCode: Int,
746-
expectedContentType: ContentType
763+
expectedContentType: ContentType,
764+
isContentVersionOptional: Bool = false
747765
) throws {
748766
guard response.statusCode == expectedStatusCode else {
749767
throw RegistryError.invalidResponseStatus(expected: expectedStatusCode, actual: response.statusCode)
750768
}
751769

752-
let contentVersion = response.headers.get("Content-Version").first
753-
guard contentVersion == self.apiVersion.rawValue else {
754-
throw RegistryError.invalidContentVersion(expected: self.apiVersion.rawValue, actual: contentVersion)
755-
}
770+
try checkContentVersion(response, isOptional: isContentVersionOptional)
756771

757772
let contentType = response.headers.get("Content-Type").first
758773
guard contentType?.hasPrefix(expectedContentType.rawValue) == true else {

Tests/PackageRegistryTests/RegistryClientTests.swift

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// This source file is part of the Swift open source project
44
//
5-
// Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
5+
// Copyright (c) 2021-2023 Apple Inc. and the Swift project authors
66
// Licensed under Apache License v2.0 with Runtime Library Exception
77
//
88
// See http://swift.org/LICENSE.txt for license information
@@ -247,6 +247,86 @@ final class RegistryClientTests: XCTestCase {
247247
XCTAssertEqual(parsedToolsVersion, .v4)
248248
}
249249
}
250+
251+
func testGetManifestContent_optionalContentVersion() throws {
252+
let registryURL = "https://packages.example.com"
253+
let identity = PackageIdentity.plain("mona.LinkedList")
254+
let (scope, name) = identity.scopeAndName!
255+
let version = Version("1.1.1")
256+
let manifestURL = URL(string: "\(registryURL)/\(scope)/\(name)/\(version)/Package.swift")!
257+
258+
let handler: HTTPClient.Handler = { request, _, completion in
259+
var components = URLComponents(url: request.url, resolvingAgainstBaseURL: false)!
260+
let toolsVersion = components.queryItems?.first { $0.name == "swift-version" }
261+
.flatMap { ToolsVersion(string: $0.value!) } ?? ToolsVersion.current
262+
// remove query
263+
components.query = nil
264+
let urlWithoutQuery = components.url
265+
switch (request.method, urlWithoutQuery) {
266+
case (.get, manifestURL):
267+
XCTAssertEqual(request.headers.get("Accept").first, "application/vnd.swift.registry.v1+swift")
268+
269+
let data = """
270+
// swift-tools-version:\(toolsVersion)
271+
272+
import PackageDescription
273+
274+
let package = Package()
275+
""".data(using: .utf8)!
276+
277+
completion(.success(.init(
278+
statusCode: 200,
279+
headers: .init([
280+
.init(name: "Content-Length", value: "\(data.count)"),
281+
.init(name: "Content-Type", value: "text/x-swift"),
282+
// Omit `Content-Version` header
283+
]),
284+
body: data
285+
)))
286+
default:
287+
completion(.failure(StringError("method and url should match")))
288+
}
289+
}
290+
291+
var httpClient = HTTPClient(handler: handler)
292+
httpClient.configuration.circuitBreakerStrategy = .none
293+
httpClient.configuration.retryStrategy = .none
294+
295+
var configuration = RegistryConfiguration()
296+
configuration.defaultRegistry = Registry(url: URL(string: registryURL)!)
297+
298+
let registryClient = makeRegistryClient(configuration: configuration, httpClient: httpClient)
299+
300+
do {
301+
let manifest = try registryClient.getManifestContent(
302+
package: identity,
303+
version: version,
304+
customToolsVersion: nil
305+
)
306+
let parsedToolsVersion = try ToolsVersionParser.parse(utf8String: manifest)
307+
XCTAssertEqual(parsedToolsVersion, .current)
308+
}
309+
310+
do {
311+
let manifest = try registryClient.getManifestContent(
312+
package: identity,
313+
version: version,
314+
customToolsVersion: .v5_3
315+
)
316+
let parsedToolsVersion = try ToolsVersionParser.parse(utf8String: manifest)
317+
XCTAssertEqual(parsedToolsVersion, .v5_3)
318+
}
319+
320+
do {
321+
let manifest = try registryClient.getManifestContent(
322+
package: identity,
323+
version: version,
324+
customToolsVersion: .v4
325+
)
326+
let parsedToolsVersion = try ToolsVersionParser.parse(utf8String: manifest)
327+
XCTAssertEqual(parsedToolsVersion, .v4)
328+
}
329+
}
250330

251331
func testFetchSourceArchiveChecksum() throws {
252332
let registryURL = "https://packages.example.com"
@@ -858,6 +938,115 @@ final class RegistryClientTests: XCTestCase {
858938
XCTAssertEqual(registryURL, fingerprint.origin.url?.absoluteString)
859939
XCTAssertEqual(checksum, fingerprint.value)
860940
}
941+
942+
func testDownloadSourceArchive_optionalContentVersion() throws {
943+
let registryURL = "https://packages.example.com"
944+
let identity = PackageIdentity.plain("mona.LinkedList")
945+
let (scope, name) = identity.scopeAndName!
946+
let version = Version("1.1.1")
947+
let downloadURL = URL(string: "\(registryURL)/\(scope)/\(name)/\(version).zip")!
948+
let metadataURL = URL(string: "\(registryURL)/\(scope)/\(name)/\(version)")!
949+
950+
let checksumAlgorithm: HashAlgorithm = SHA256()
951+
let checksum = checksumAlgorithm.hash(emptyZipFile).hexadecimalRepresentation
952+
953+
let handler: HTTPClient.Handler = { request, _, completion in
954+
switch (request.kind, request.method, request.url) {
955+
case (.download(let fileSystem, let path), .get, downloadURL):
956+
XCTAssertEqual(request.headers.get("Accept").first, "application/vnd.swift.registry.v1+zip")
957+
958+
let data = Data(emptyZipFile.contents)
959+
try! fileSystem.writeFileContents(path, data: data)
960+
961+
completion(.success(.init(
962+
statusCode: 200,
963+
headers: .init([
964+
.init(name: "Content-Length", value: "\(data.count)"),
965+
.init(name: "Content-Type", value: "application/zip"),
966+
// Omit `Content-Version` header
967+
.init(name: "Content-Disposition", value: #"attachment; filename="LinkedList-1.1.1.zip""#),
968+
.init(
969+
name: "Digest",
970+
value: "sha-256=bc6c9a5d2f2226cfa1ef4fad8344b10e1cc2e82960f468f70d9ed696d26b3283"
971+
),
972+
]),
973+
body: nil
974+
)))
975+
// `downloadSourceArchive` calls this API to fetch checksum
976+
case (.generic, .get, metadataURL):
977+
XCTAssertEqual(request.headers.get("Accept").first, "application/vnd.swift.registry.v1+json")
978+
979+
let data = """
980+
{
981+
"id": "mona.LinkedList",
982+
"version": "1.1.1",
983+
"resources": [
984+
{
985+
"name": "source-archive",
986+
"type": "application/zip",
987+
"checksum": "\(checksum)"
988+
}
989+
],
990+
"metadata": {
991+
"description": "One thing links to another."
992+
}
993+
}
994+
""".data(using: .utf8)!
995+
996+
completion(.success(.init(
997+
statusCode: 200,
998+
headers: .init([
999+
.init(name: "Content-Length", value: "\(data.count)"),
1000+
.init(name: "Content-Type", value: "application/json"),
1001+
.init(name: "Content-Version", value: "1"),
1002+
]),
1003+
body: data
1004+
)))
1005+
default:
1006+
completion(.failure(StringError("method and url should match")))
1007+
}
1008+
}
1009+
1010+
var httpClient = HTTPClient(handler: handler)
1011+
httpClient.configuration.circuitBreakerStrategy = .none
1012+
httpClient.configuration.retryStrategy = .none
1013+
1014+
var configuration = RegistryConfiguration()
1015+
configuration.defaultRegistry = Registry(url: URL(string: registryURL)!)
1016+
1017+
let fingerprintStorage = MockPackageFingerprintStorage()
1018+
let registryClient = RegistryClient(
1019+
configuration: configuration,
1020+
fingerprintStorage: fingerprintStorage,
1021+
fingerprintCheckingMode: .strict,
1022+
customHTTPClient: httpClient,
1023+
customArchiverProvider: { fileSystem in
1024+
MockArchiver(handler: { _, from, to, callback in
1025+
let data = try fileSystem.readFileContents(from)
1026+
XCTAssertEqual(data, emptyZipFile)
1027+
1028+
let packagePath = to.appending(component: "package")
1029+
try fileSystem.createDirectory(packagePath, recursive: true)
1030+
try fileSystem.writeFileContents(packagePath.appending(component: "Package.swift"), string: "")
1031+
callback(.success(()))
1032+
})
1033+
}
1034+
)
1035+
1036+
let fileSystem = InMemoryFileSystem()
1037+
let path = AbsolutePath(path: "/LinkedList-1.1.1")
1038+
1039+
try registryClient.downloadSourceArchive(
1040+
package: identity,
1041+
version: version,
1042+
fileSystem: fileSystem,
1043+
destinationPath: path,
1044+
checksumAlgorithm: checksumAlgorithm
1045+
)
1046+
1047+
let contents = try fileSystem.getDirectoryContents(path)
1048+
XCTAssertEqual(contents, ["Package.swift"])
1049+
}
8611050

8621051
func testLookupIdentities() throws {
8631052
let registryURL = "https://packages.example.com"

0 commit comments

Comments
 (0)