Skip to content

[5.8][Registry] Make 'Content-Version' optional for archive and manifest download API #6156

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions Documentation/Registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,12 @@ Valid `Accept` header field values are described by the following rules:
accept = "application/vnd.swift.registry" [".v" version] ["+" mediatype]
```

A server MUST set the `Content-Type` and `Content-Version` header fields
with the corresponding content type and API version number of the response.
A server MUST set the `Content-Type` header field
with the corresponding content type of the response.

A server MUST set the `Content-Version` header field
with the API version number of the response, unless
explicitly stated otherwise.

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

A server MAY omit the `Content-Version` header
since the response content (i.e., the manifest) SHOULD NOT
change across different API versions.

It is RECOMMENDED for clients and servers to support
caching as described by [RFC 7234].

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

A server MAY omit the `Content-Version` header
since the response content (i.e., the source archive) SHOULD NOT
change across different API versions.

It is RECOMMENDED for clients and servers to support
range requests as described by [RFC 7233]
and caching as described by [RFC 7234].
Expand Down Expand Up @@ -1379,7 +1391,7 @@ paths:
schema:
type: integer
Content-Version:
$ref: "#/components/headers/contentVersion"
$ref: "#/components/headers/optionalContentVersion"
Link:
schema:
type: string
Expand Down Expand Up @@ -1426,7 +1438,7 @@ paths:
schema:
type: integer
Content-Version:
$ref: "#/components/headers/contentVersion"
$ref: "#/components/headers/optionalContentVersion"
Digest:
required: true
schema:
Expand Down Expand Up @@ -1666,7 +1678,13 @@ components:
schema:
type: string
enum:
- - "1"
- "1"
optionalContentVersion:
required: false
schema:
type: string
enum:
- "1"

```

Expand Down
31 changes: 23 additions & 8 deletions Sources/PackageRegistry/RegistryClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// This source file is part of the Swift open source project
//
// Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
// Copyright (c) 2021-2023 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See http://swift.org/LICENSE.txt for license information
Expand Down Expand Up @@ -265,7 +265,8 @@ public final class RegistryClient: Cancellable {
try self.checkResponseStatusAndHeaders(
response,
expectedStatusCode: 200,
expectedContentType: .swift
expectedContentType: .swift,
isContentVersionOptional: true
)

guard let data = response.body else {
Expand Down Expand Up @@ -443,7 +444,12 @@ public final class RegistryClient: Cancellable {
switch result {
case .success(let response):
do {
try self.checkResponseStatusAndHeaders(response, expectedStatusCode: 200, expectedContentType: .zip)
try self.checkResponseStatusAndHeaders(
response,
expectedStatusCode: 200,
expectedContentType: .zip,
isContentVersionOptional: true
)
} catch {
return completion(.failure(RegistryError.failedDownloadingSourceArchive(error)))
}
Expand Down Expand Up @@ -739,20 +745,29 @@ extension RegistryClient {
private func acceptHeader(mediaType: MediaType) -> String {
"application/vnd.swift.registry.v\(self.apiVersion.rawValue)+\(mediaType)"
}

private func checkContentVersion(_ response: HTTPClient.Response, isOptional: Bool) throws {
let contentVersion = response.headers.get("Content-Version").first
if isOptional, contentVersion == nil {
return
}
// Check API version as long as `Content-Version` is set
guard contentVersion == self.apiVersion.rawValue else {
throw RegistryError.invalidContentVersion(expected: self.apiVersion.rawValue, actual: contentVersion)
}
}

private func checkResponseStatusAndHeaders(
_ response: HTTPClient.Response,
expectedStatusCode: Int,
expectedContentType: ContentType
expectedContentType: ContentType,
isContentVersionOptional: Bool = false
) throws {
guard response.statusCode == expectedStatusCode else {
throw RegistryError.invalidResponseStatus(expected: expectedStatusCode, actual: response.statusCode)
}

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

let contentType = response.headers.get("Content-Type").first
guard contentType?.hasPrefix(expectedContentType.rawValue) == true else {
Expand Down
191 changes: 190 additions & 1 deletion Tests/PackageRegistryTests/RegistryClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// This source file is part of the Swift open source project
//
// Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
// Copyright (c) 2021-2023 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See http://swift.org/LICENSE.txt for license information
Expand Down Expand Up @@ -247,6 +247,86 @@ final class RegistryClientTests: XCTestCase {
XCTAssertEqual(parsedToolsVersion, .v4)
}
}

func testGetManifestContent_optionalContentVersion() throws {
let registryURL = "https://packages.example.com"
let identity = PackageIdentity.plain("mona.LinkedList")
let (scope, name) = identity.scopeAndName!
let version = Version("1.1.1")
let manifestURL = URL(string: "\(registryURL)/\(scope)/\(name)/\(version)/Package.swift")!

let handler: HTTPClient.Handler = { request, _, completion in
var components = URLComponents(url: request.url, resolvingAgainstBaseURL: false)!
let toolsVersion = components.queryItems?.first { $0.name == "swift-version" }
.flatMap { ToolsVersion(string: $0.value!) } ?? ToolsVersion.current
// remove query
components.query = nil
let urlWithoutQuery = components.url
switch (request.method, urlWithoutQuery) {
case (.get, manifestURL):
XCTAssertEqual(request.headers.get("Accept").first, "application/vnd.swift.registry.v1+swift")

let data = """
// swift-tools-version:\(toolsVersion)

import PackageDescription

let package = Package()
""".data(using: .utf8)!

completion(.success(.init(
statusCode: 200,
headers: .init([
.init(name: "Content-Length", value: "\(data.count)"),
.init(name: "Content-Type", value: "text/x-swift"),
// Omit `Content-Version` header
]),
body: data
)))
default:
completion(.failure(StringError("method and url should match")))
}
}

var httpClient = HTTPClient(handler: handler)
httpClient.configuration.circuitBreakerStrategy = .none
httpClient.configuration.retryStrategy = .none

var configuration = RegistryConfiguration()
configuration.defaultRegistry = Registry(url: URL(string: registryURL)!)

let registryClient = makeRegistryClient(configuration: configuration, httpClient: httpClient)

do {
let manifest = try registryClient.getManifestContent(
package: identity,
version: version,
customToolsVersion: nil
)
let parsedToolsVersion = try ToolsVersionParser.parse(utf8String: manifest)
XCTAssertEqual(parsedToolsVersion, .current)
}

do {
let manifest = try registryClient.getManifestContent(
package: identity,
version: version,
customToolsVersion: .v5_3
)
let parsedToolsVersion = try ToolsVersionParser.parse(utf8String: manifest)
XCTAssertEqual(parsedToolsVersion, .v5_3)
}

do {
let manifest = try registryClient.getManifestContent(
package: identity,
version: version,
customToolsVersion: .v4
)
let parsedToolsVersion = try ToolsVersionParser.parse(utf8String: manifest)
XCTAssertEqual(parsedToolsVersion, .v4)
}
}

func testFetchSourceArchiveChecksum() throws {
let registryURL = "https://packages.example.com"
Expand Down Expand Up @@ -858,6 +938,115 @@ final class RegistryClientTests: XCTestCase {
XCTAssertEqual(registryURL, fingerprint.origin.url?.absoluteString)
XCTAssertEqual(checksum, fingerprint.value)
}

func testDownloadSourceArchive_optionalContentVersion() throws {
let registryURL = "https://packages.example.com"
let identity = PackageIdentity.plain("mona.LinkedList")
let (scope, name) = identity.scopeAndName!
let version = Version("1.1.1")
let downloadURL = URL(string: "\(registryURL)/\(scope)/\(name)/\(version).zip")!
let metadataURL = URL(string: "\(registryURL)/\(scope)/\(name)/\(version)")!

let checksumAlgorithm: HashAlgorithm = SHA256()
let checksum = checksumAlgorithm.hash(emptyZipFile).hexadecimalRepresentation

let handler: HTTPClient.Handler = { request, _, completion in
switch (request.kind, request.method, request.url) {
case (.download(let fileSystem, let path), .get, downloadURL):
XCTAssertEqual(request.headers.get("Accept").first, "application/vnd.swift.registry.v1+zip")

let data = Data(emptyZipFile.contents)
try! fileSystem.writeFileContents(path, data: data)

completion(.success(.init(
statusCode: 200,
headers: .init([
.init(name: "Content-Length", value: "\(data.count)"),
.init(name: "Content-Type", value: "application/zip"),
// Omit `Content-Version` header
.init(name: "Content-Disposition", value: #"attachment; filename="LinkedList-1.1.1.zip""#),
.init(
name: "Digest",
value: "sha-256=bc6c9a5d2f2226cfa1ef4fad8344b10e1cc2e82960f468f70d9ed696d26b3283"
),
]),
body: nil
)))
// `downloadSourceArchive` calls this API to fetch checksum
case (.generic, .get, metadataURL):
XCTAssertEqual(request.headers.get("Accept").first, "application/vnd.swift.registry.v1+json")

let data = """
{
"id": "mona.LinkedList",
"version": "1.1.1",
"resources": [
{
"name": "source-archive",
"type": "application/zip",
"checksum": "\(checksum)"
}
],
"metadata": {
"description": "One thing links to another."
}
}
""".data(using: .utf8)!

completion(.success(.init(
statusCode: 200,
headers: .init([
.init(name: "Content-Length", value: "\(data.count)"),
.init(name: "Content-Type", value: "application/json"),
.init(name: "Content-Version", value: "1"),
]),
body: data
)))
default:
completion(.failure(StringError("method and url should match")))
}
}

var httpClient = HTTPClient(handler: handler)
httpClient.configuration.circuitBreakerStrategy = .none
httpClient.configuration.retryStrategy = .none

var configuration = RegistryConfiguration()
configuration.defaultRegistry = Registry(url: URL(string: registryURL)!)

let fingerprintStorage = MockPackageFingerprintStorage()
let registryClient = RegistryClient(
configuration: configuration,
fingerprintStorage: fingerprintStorage,
fingerprintCheckingMode: .strict,
customHTTPClient: httpClient,
customArchiverProvider: { fileSystem in
MockArchiver(handler: { _, from, to, callback in
let data = try fileSystem.readFileContents(from)
XCTAssertEqual(data, emptyZipFile)

let packagePath = to.appending(component: "package")
try fileSystem.createDirectory(packagePath, recursive: true)
try fileSystem.writeFileContents(packagePath.appending(component: "Package.swift"), string: "")
callback(.success(()))
})
}
)

let fileSystem = InMemoryFileSystem()
let path = AbsolutePath(path: "/LinkedList-1.1.1")

try registryClient.downloadSourceArchive(
package: identity,
version: version,
fileSystem: fileSystem,
destinationPath: path,
checksumAlgorithm: checksumAlgorithm
)

let contents = try fileSystem.getDirectoryContents(path)
XCTAssertEqual(contents, ["Package.swift"])
}

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