diff --git a/Documentation/Registry.md b/Documentation/Registry.md index 06663684d81..547a880f8ea 100644 --- a/Documentation/Registry.md +++ b/Documentation/Registry.md @@ -33,6 +33,7 @@ - [5. Normative References](#5-normative-references) - [6. Informative References](#6-informative-references) - [Appendix A - OpenAPI Document](#appendix-a---openapi-document) +- [Appendix B - Package Release Metadata JSON Schema](#appendix-b---package-release-metadata-json-schema) ## 1. Notations @@ -466,18 +467,20 @@ Link: ; rel="latest-version" } } ], - "metadata": { ... } + "metadata": { ... }, + "publishedAt": "2023-02-16T04:00:00.000Z" } ``` -The response body MUST contain a JSON object containing the following fields: +The response body SHOULD contain a JSON object containing the following fields: -| Key | Type | Description | -| ----------- | ------ | ----------------------------------------- | -| `id` | String | The namespaced package identifier. | -| `version` | String | The package release version number. | -| `resources` | Array | The resources available for the release. | -| `metadata` | Object | Additional information about the release. | +| Key | Type | Description | Required | +| ------------- | ------ | ----------------------------------------- | :------: | +| `id` | String | The namespaced package identifier. | ✓ | +| `version` | String | The package release version number. | ✓ | +| `resources` | Array | The resources available for the release. | ✓ | +| `metadata` | Object | Additional information about the release. | ✓ | +| `publishedAt` | String | The [ISO 8601]-formatted datetime string of when the package release was published, as recorded by the registry. See related [`originalPublicationTime`](#appendix-b---package-release-metadata-json-schema) in `metadata`. | | A server SHOULD respond with a `Link` header containing the following entries: @@ -520,7 +523,8 @@ with a given combination of `name` and `type` values. #### 4.2.2. Package release metadata standards -SE-391 defines the [JSON schema] for package release metadata that +[Appendix B](#appendix-b---package-release-metadata-json-schema) +defines the JSON schema for package release metadata that gets submitted as part of the ["create a package release"](#endpoint-6) request. A server MAY allow and/or populate additional metadata by expanding the schema. The `metadata` key in the @@ -1035,7 +1039,7 @@ A client MAY include a multipart section named `metadata` containing additional information about the release. A client SHOULD set a `Content-Type` header with the value `application/json` and a `Content-Length` header with the size of the JSON document in bytes. -The package release metadata MUST be based on the [JSON schema], +The package release metadata MUST be based on the [JSON schema](#appendix-b---package-release-metadata-json-schema), as discussed in [4.2.2](#422-package-release-metadata-standards). ```http @@ -1058,8 +1062,8 @@ Content-Transfer-Encoding: quoted-printable A server MAY allow and/or populate additional metadata for a release. -A server MAY make any properties in the [JSON schema] and additional -metadata it defines required. +A server MAY make any properties in the [JSON schema](#appendix-b---package-release-metadata-json-schema) +and additional metadata it defines required. If a client provides an invalid JSON document, the server SHOULD respond with a status code of @@ -1706,6 +1710,135 @@ components: ``` +## Appendix B - Package Release Metadata JSON Schema + +The `metadata` section of the [create package release request](#46-create-a-package-release) +must be a JSON object of type [`PackageRelease`](#packagerelease-type), as defined in the +JSON schema below. + +
+ +Expand to view JSON schema + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md", + "title": "Package Release Metadata", + "description": "Metadata of a package release.", + "type": "object", + "properties": { + "author": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the author." + }, + "email": { + "type": "string", + "format": "email", + "description": "Email address of the author." + }, + "description": { + "type": "string", + "description": "A description of the author." + }, + "organization": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the organization." + }, + "email": { + "type": "string", + "format": "email", + "description": "Email address of the organization." + }, + "description": { + "type": "string", + "description": "A description of the organization." + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the organization." + }, + }, + "required": ["name"] + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the author." + }, + }, + "required": ["name"] + }, + "description": { + "type": "string", + "description": "A description of the package release." + }, + "licenseURL": { + "type": "string", + "format": "uri", + "description": "URL of the package release's license document." + }, + "originalPublicationTime": { + "type": "string", + "format": "date-time", + "description": "Original publication time of the package release in ISO 8601 format." + }, + "readmeURL": { + "type": "string", + "format": "uri", + "description": "URL of the README specifically for the package release or broadly for the package." + }, + "repositoryURLs": { + "type": "array", + "description": "Code repository URL(s) of the package release.", + "items": { + "type": "string", + "description": "Code repository URL." + } + } + } +} +``` + +
+ +##### `PackageRelease` type + +| Property | Type | Description | Required | +| ------------------------- | :-----------------: | ------------------------------------------------ | :------: | +| `author` | [Author](#author-type) | Author of the package release. | | +| `description` | String | A description of the package release. | | +| `licenseURL` | String | URL of the package release's license document. | | +| `originalPublicationTime` | String | Original publication time of the package release in [ISO 8601] format. This can be set if the package release was previously published elsewhere.
A registry should record the publication time independently and include it as `publishedAt` in the [package release metadata response](#42-fetch-information-about-a-package-release).
In case both `originalPublicationTime` and `publishedAt` are set, `originalPublicationTime` should be used. | | +| `readmeURL` | String | URL of the README specifically for the package release or broadly for the package. | | +| `repositoryURLs` | Array | Code repository URL(s) of the package. It is recommended to include all URL variations (e.g., SSH, HTTPS) for the same repository. This can be an empty array if the package does not have source control representation.
Setting this property is one way through which a registry can obtain repository URL to package identifier mappings for the ["lookup package identifiers registered for a URL" API](https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#45-lookup-package-identifiers-registered-for-a-url). A registry may choose other mechanism(s) for package authors to specify such mappings. | | + +##### `Author` type + +| Property | Type | Description | Required | +| ----------------- | :-----------------: | ------------------------------------------------ | :------: | +| `name` | String | Name of the author. | ✓ | +| `email` | String | Email address of the author. | | +| `description` | String | A description of the author. | | +| `organization` | [Organization](#organization-type) | Organization that the author belongs to. | | +| `url` | String | URL of the author. | | + +##### `Organization` type + +| Property | Type | Description | Required | +| ----------------- | :-----------------: | ------------------------------------------------ | :------: | +| `name` | String | Name of the organization. | ✓ | +| `email` | String | Email address of the organization. | | +| `description` | String | A description of the organization. | | +| `url` | String | URL of the organization. | | + [BCP 13]: https://tools.ietf.org/html/rfc6838 "Media Type Specifications and Registration Procedures" [RFC 2119]: https://tools.ietf.org/html/rfc2119 "Key words for use in RFCs to Indicate Requirement Levels" [RFC 3230]: https://tools.ietf.org/html/rfc5843 "Instance Digests in HTTP" @@ -1745,4 +1878,4 @@ components: [XCFramework]: https://developer.apple.com/videos/play/wwdc2019/416/ "WWDC 2019 Session 416: Binary Frameworks in Swift" [SE-0272]: https://github.com/apple/swift-evolution/blob/master/proposals/0272-swiftpm-binary-dependencies.md "Package Manager Binary Dependencies" [Swift tools version]: https://github.com/apple/swift-package-manager/blob/9b9bed7eaf0f38eeccd0d8ca06ae08f6689d1c3f/Documentation/Usage.md#swift-tools-version-specification "Swift Tools Version Specification" -[JSON schema]: https://github.com/apple/swift-evolution/blob/main/proposals/0391-package-registry-publish.md#package-release-metadata-standards "JSON schema for package release metadata" +[ISO 8601]: https://www.iso.org/iso-8601-date-and-time-format.html "ISO 8601 Date and Time Format" diff --git a/Sources/PackageMetadata/PackageMetadata.swift b/Sources/PackageMetadata/PackageMetadata.swift index 610c0dc6d43..975c815c5b1 100644 --- a/Sources/PackageMetadata/PackageMetadata.swift +++ b/Sources/PackageMetadata/PackageMetadata.swift @@ -17,6 +17,7 @@ import PackageModel import PackageRegistry import SourceControl +import struct Foundation.Date import struct Foundation.URL import struct TSCBasic.AbsolutePath import protocol TSCBasic.FileSystem @@ -70,9 +71,24 @@ public struct Package { public let resources: [Resource] public let author: Author? public let description: String? + public let publishedAt: Date? public let latestVersion: Version? - fileprivate init(identity: PackageIdentity, location: String? = nil, branches: [String] = [], versions: [Version], licenseURL: URL? = nil, readmeURL: URL? = nil, repositoryURLs: [URL]?, resources: [Resource], author: Author?, description: String?, latestVersion: Version? = nil, source: Source) { + fileprivate init( + identity: PackageIdentity, + location: String? = nil, + branches: [String] = [], + versions: [Version], + licenseURL: URL? = nil, + readmeURL: URL? = nil, + repositoryURLs: [URL]?, + resources: [Resource], + author: Author?, + description: String?, + publishedAt: Date?, + latestVersion: Version? = nil, + source: Source + ) { self.identity = identity self.location = location self.branches = branches @@ -83,6 +99,7 @@ public struct Package { self.resources = resources self.author = author self.description = description + self.publishedAt = publishedAt self.latestVersion = latestVersion self.source = source } @@ -100,20 +117,23 @@ public struct PackageSearchClient { observabilityScope: ObservabilityScope ) { self.registryClient = registryClient - self.indexAndCollections = PackageIndexAndCollections(fileSystem: fileSystem, observabilityScope: observabilityScope) + self.indexAndCollections = PackageIndexAndCollections( + fileSystem: fileSystem, + observabilityScope: observabilityScope + ) self.fileSystem = fileSystem self.observabilityScope = observabilityScope } var repositoryProvider: RepositoryProvider { - return GitRepositoryProvider() + GitRepositoryProvider() } // FIXME: This matches the current implementation, but we may want be smarter about it? private func guessReadMeURL(baseURL: URL, defaultBranch: String) -> URL { - return baseURL.appendingPathComponent("raw").appendingPathComponent(defaultBranch).appendingPathComponent("README.md") + baseURL.appendingPathComponent("raw").appendingPathComponent(defaultBranch).appendingPathComponent("README.md") } - + private func guessReadMeURL(alternateLocations: [URL]?) -> URL? { if let alternateURL = alternateLocations?.first { // FIXME: This is pretty crude, we should let the registry metadata provide the value instead. @@ -129,6 +149,7 @@ public struct PackageSearchClient { public let resources: [Package.Resource] public let author: Package.Author? public let description: String? + public let publishedAt: Date? } private func getVersionMetadata( @@ -150,7 +171,8 @@ public struct PackageSearchClient { repositoryURLs: metadata.repositoryURLs, resources: metadata.resources.map { .init($0) }, author: metadata.author.map { .init($0) }, - description: metadata.description + description: metadata.description, + publishedAt: metadata.publishedAt ) }) } @@ -163,24 +185,27 @@ public struct PackageSearchClient { let identity = PackageIdentity.plain(query) // Search the package index and collections for a search term. - let search = { (error: Error?) -> Void in + let search = { (error: Error?) in self.indexAndCollections.findPackages(query) { result in do { let packages = try result.get().items.map { - Package(identity: $0.package.identity, - location: $0.package.location, - versions: $0.package.versions.map { $0.version }, - licenseURL: nil, - readmeURL: $0.package.readmeURL, - repositoryURLs: nil, - resources: [], - author: nil, - description: nil, - latestVersion: nil, // this only makes sense in connection with providing versioned metadata - source: .indexAndCollections(collections: $0.collections, indexes: $0.indexes) + Package( + identity: $0.package.identity, + location: $0.package.location, + versions: $0.package.versions.map(\.version), + licenseURL: nil, + readmeURL: $0.package.readmeURL, + repositoryURLs: nil, + resources: [], + author: nil, + description: nil, + publishedAt: nil, + latestVersion: nil, + // this only makes sense in connection with providing versioned metadata + source: .indexAndCollections(collections: $0.collections, indexes: $0.indexes) ) } - if packages.isEmpty, let error = error { + if packages.isEmpty, let error { // If the search result is empty and we had a previous error, emit it now. return callback(.failure(error)) } else { @@ -196,32 +221,48 @@ public struct PackageSearchClient { // determine the available version tags and branches. If the search term cannot be interpreted // as a URL or there are any errors during the process, we fall back to searching the configured // index or package collections. - let fetchStandalonePackageByURL = { (error: Error?) -> Void in + let fetchStandalonePackageByURL = { (error: Error?) in guard let url = URL(string: query) else { return search(error) } do { - try withTemporaryDirectory(removeTreeOnDeinit: true) { (tempDir: AbsolutePath) -> Void in + try withTemporaryDirectory(removeTreeOnDeinit: true) { (tempDir: AbsolutePath) in let tempPath = tempDir.appending(component: url.lastPathComponent) do { let repositorySpecifier = RepositorySpecifier(url: url) - try self.repositoryProvider.fetch(repository: repositorySpecifier, to: tempPath, progressHandler: nil) - if self.repositoryProvider.isValidDirectory(tempPath), let repository = try self.repositoryProvider.open(repository: repositorySpecifier, at: tempPath) as? GitRepository { + try self.repositoryProvider.fetch( + repository: repositorySpecifier, + to: tempPath, + progressHandler: nil + ) + if self.repositoryProvider.isValidDirectory(tempPath), + let repository = try self.repositoryProvider.open( + repository: repositorySpecifier, + at: tempPath + ) as? GitRepository + { let branches = try repository.getBranches() let versions = try repository.getTags().compactMap { Version($0) } - let package = Package(identity: .init(url: url), - location: url.absoluteString, - branches: branches, - versions: versions, - licenseURL: nil, - readmeURL: self.guessReadMeURL(baseURL: url, defaultBranch: try repository.getDefaultBranch()), - repositoryURLs: nil, - resources: [], - author: nil, - description: nil, - latestVersion: nil, // this only makes sense in connection with providing versioned metadata - source: .sourceControl(url: url)) + let package = Package( + identity: .init(url: url), + location: url.absoluteString, + branches: branches, + versions: versions, + licenseURL: nil, + readmeURL: self.guessReadMeURL( + baseURL: url, + defaultBranch: try repository.getDefaultBranch() + ), + repositoryURLs: nil, + resources: [], + author: nil, + description: nil, + publishedAt: nil, + latestVersion: nil, + // this only makes sense in connection with providing versioned metadata + source: .sourceControl(url: url) + ) return callback(.success([package])) } } catch { @@ -238,7 +279,11 @@ public struct PackageSearchClient { // or the search term does not work as a registry identity, we will fall back on // `fetchStandalonePackageByURL`. if identity.isRegistry { - return self.registryClient.getPackageMetadata(package: identity, observabilityScope: observabilityScope, callbackQueue: DispatchQueue.sharedConcurrent) { result in + return self.registryClient.getPackageMetadata( + package: identity, + observabilityScope: observabilityScope, + callbackQueue: DispatchQueue.sharedConcurrent + ) { result in do { let metadata = try result.get() let versions = metadata.versions.sorted(by: >) @@ -252,6 +297,7 @@ public struct PackageSearchClient { let resources: [Package.Resource] let author: Package.Author? let description: String? + let publishedAt: Date? if case .success(let metadata) = result { licenseURL = metadata.licenseURL readmeURL = metadata.readmeURL @@ -259,6 +305,7 @@ public struct PackageSearchClient { resources = metadata.resources author = metadata.author description = metadata.description + publishedAt = metadata.publishedAt } else { licenseURL = nil readmeURL = self.guessReadMeURL(alternateLocations: metadata.alternateLocations) @@ -266,33 +313,39 @@ public struct PackageSearchClient { resources = [] author = nil description = nil + publishedAt = nil } - return callback(.success([Package(identity: identity, - versions: metadata.versions, - licenseURL: licenseURL, - readmeURL: readmeURL, - repositoryURLs: repositoryURLs, - resources: resources, - author: author, - description: description, - latestVersion: version, - source: .registry(url: metadata.registry.url) - )])) + return callback(.success([Package( + identity: identity, + versions: metadata.versions, + licenseURL: licenseURL, + readmeURL: readmeURL, + repositoryURLs: repositoryURLs, + resources: resources, + author: author, + description: description, + publishedAt: publishedAt, + latestVersion: version, + source: .registry(url: metadata.registry.url) + )])) } } else { let readmeURL: URL? = self.guessReadMeURL(alternateLocations: metadata.alternateLocations) - return callback(.success([Package(identity: identity, - versions: metadata.versions, - licenseURL: nil, - readmeURL: readmeURL, - repositoryURLs: nil, - resources: [], - author: nil, - description: nil, - latestVersion: nil, // this only makes sense in connection with providing versioned metadata - source: .registry(url: metadata.registry.url) - )])) + return callback(.success([Package( + identity: identity, + versions: metadata.versions, + licenseURL: nil, + readmeURL: readmeURL, + repositoryURLs: nil, + resources: [], + author: nil, + description: nil, + publishedAt: nil, + latestVersion: nil, + // this only makes sense in connection with providing versioned metadata + source: .registry(url: metadata.registry.url) + )])) } } catch { return fetchStandalonePackageByURL(error) @@ -310,7 +363,7 @@ public struct PackageSearchClient { callbackQueue: DispatchQueue, completion: @escaping (Result, Error>) -> Void ) { - return registryClient.lookupIdentities( + registryClient.lookupIdentities( scmURL: scmURL, timeout: timeout, observabilityScope: observabilityScope, @@ -326,24 +379,25 @@ public struct PackageSearchClient { callbackQueue: DispatchQueue, completion: @escaping (Result, Error>) -> Void ) { - return registryClient.getPackageMetadata( + registryClient.getPackageMetadata( package: package, timeout: timeout, observabilityScope: observabilityScope, - callbackQueue: callbackQueue) { result in - do { - let metadata = try result.get() - let alternateLocations = metadata.alternateLocations ?? [] - return completion(.success(Set(alternateLocations))) - } catch { - return completion(.failure(error)) - } + callbackQueue: callbackQueue + ) { result in + do { + let metadata = try result.get() + let alternateLocations = metadata.alternateLocations ?? [] + return completion(.success(Set(alternateLocations))) + } catch { + return completion(.failure(error)) } + } } } -fileprivate extension Package.Signing { - init(_ signing: RegistryClient.PackageVersionMetadata.Signing) { +extension Package.Signing { + fileprivate init(_ signing: RegistryClient.PackageVersionMetadata.Signing) { self.init( signatureBase64Encoded: signing.signatureBase64Encoded, signatureFormat: signing.signatureFormat @@ -351,18 +405,19 @@ fileprivate extension Package.Signing { } } -fileprivate extension Package.Resource { - init(_ resource: RegistryClient.PackageVersionMetadata.Resource) { +extension Package.Resource { + fileprivate init(_ resource: RegistryClient.PackageVersionMetadata.Resource) { self.init( name: resource.name, type: resource.type, checksum: resource.checksum, - signing: resource.signing.map { .init($0) }) + signing: resource.signing.map { .init($0) } + ) } } -fileprivate extension Package.Author { - init(_ author: RegistryClient.PackageVersionMetadata.Author) { +extension Package.Author { + fileprivate init(_ author: RegistryClient.PackageVersionMetadata.Author) { self.init( name: author.name, email: author.email, @@ -373,8 +428,8 @@ fileprivate extension Package.Author { } } -fileprivate extension Package.Organization { - init(_ organization: RegistryClient.PackageVersionMetadata.Organization) { +extension Package.Organization { + fileprivate init(_ organization: RegistryClient.PackageVersionMetadata.Organization) { self.init( name: organization.name, email: organization.email, diff --git a/Sources/PackageRegistry/RegistryClient.swift b/Sources/PackageRegistry/RegistryClient.swift index c06ede3bc5e..9bd9e27c1cb 100644 --- a/Sources/PackageRegistry/RegistryClient.swift +++ b/Sources/PackageRegistry/RegistryClient.swift @@ -381,7 +381,8 @@ public final class RegistryClient: Cancellable { url: $0.url.flatMap { URL(string: $0) } ) }, - description: versionMetadata.metadata?.description + description: versionMetadata.metadata?.description, + publishedAt: versionMetadata.metadata?.originalPublicationTime ?? versionMetadata.publishedAt ) } ) @@ -1851,6 +1852,7 @@ extension RegistryClient { public let resources: [Resource] public let author: Author? public let description: String? + public let publishedAt: Date? public var sourceArchive: Resource? { self.resources.first(where: { $0.name == "source-archive" }) @@ -2179,6 +2181,7 @@ extension RegistryClient { public let version: String public let resources: [Resource] public let metadata: AdditionalMetadata? + public let publishedAt: Date? var sourceArchive: Resource? { self.resources.first(where: { $0.name == "source-archive" }) @@ -2188,12 +2191,14 @@ extension RegistryClient { id: String, version: String, resources: [Resource], - metadata: AdditionalMetadata? + metadata: AdditionalMetadata?, + publishedAt: Date? ) { self.id = id self.version = version self.resources = resources self.metadata = metadata + self.publishedAt = publishedAt } public struct Resource: Codable { @@ -2221,19 +2226,22 @@ extension RegistryClient { public let licenseURL: String? public let readmeURL: String? public let repositoryURLs: [String]? + public let originalPublicationTime: Date? public init( author: Author? = nil, description: String, licenseURL: String? = nil, readmeURL: String? = nil, - repositoryURLs: [String]? = nil + repositoryURLs: [String]? = nil, + originalPublicationTime: Date? = nil ) { self.author = author self.description = description self.licenseURL = licenseURL self.readmeURL = readmeURL self.repositoryURLs = repositoryURLs + self.originalPublicationTime = originalPublicationTime } } diff --git a/Sources/SPMTestSupport/MockRegistry.swift b/Sources/SPMTestSupport/MockRegistry.swift index 658d8e67655..2044a234768 100644 --- a/Sources/SPMTestSupport/MockRegistry.swift +++ b/Sources/SPMTestSupport/MockRegistry.swift @@ -239,7 +239,8 @@ public class MockRegistry { metadata: .init( description: "\(packageIdentity) description", readmeURL: "http://\(packageIdentity)/readme" - ) + ), + publishedAt: Date() ) var headers = HTTPClientHeaders() diff --git a/Tests/WorkspaceTests/RegistryPackageContainerTests.swift b/Tests/WorkspaceTests/RegistryPackageContainerTests.swift index fff5cb670dd..2b1ab8378a5 100644 --- a/Tests/WorkspaceTests/RegistryPackageContainerTests.swift +++ b/Tests/WorkspaceTests/RegistryPackageContainerTests.swift @@ -375,7 +375,8 @@ class RegistryPackageContainerTests: XCTestCase { signing: nil ) ], - metadata: .init(description: "") + metadata: .init(description: ""), + publishedAt: nil ) completion(.success( HTTPClientResponse( diff --git a/Tests/WorkspaceTests/WorkspaceTests.swift b/Tests/WorkspaceTests/WorkspaceTests.swift index 9365d9d9770..594c7420b44 100644 --- a/Tests/WorkspaceTests/WorkspaceTests.swift +++ b/Tests/WorkspaceTests/WorkspaceTests.swift @@ -14053,7 +14053,8 @@ final class WorkspaceTests: XCTestCase { description: "package \(identity) description", licenseURL: "/\(identity)/license", readmeURL: "/\(identity)/readme" - ) + ), + publishedAt: nil ) completion(.success( HTTPClientResponse(