Skip to content

Commit fb329e2

Browse files
Merge pull request #3429 from SwiftPackageIndex/custom-package-collections
Custom package collections
2 parents c19a151 + 5efe3f3 commit fb329e2

16 files changed

+835
-18
lines changed

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ let package = Package(
6666
.product(name: "CanonicalPackageURL", package: "CanonicalPackageURL"),
6767
.product(name: "CustomDump", package: "swift-custom-dump"),
6868
.product(name: "Dependencies", package: "swift-dependencies"),
69+
.product(name: "DependenciesMacros", package: "swift-dependencies"),
6970
.product(name: "DependencyResolution", package: "DependencyResolution"),
7071
.product(name: "Fluent", package: "fluent"),
7172
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),

Sources/App/Commands/Reconcile.swift

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
import Dependencies
1516
import Fluent
1617
import Vapor
1718

@@ -24,13 +25,13 @@ struct ReconcileCommand: AsyncCommand {
2425
func run(using context: CommandContext, signature: Signature) async throws {
2526
Current.setLogger(Logger(component: "reconcile"))
2627

27-
Current.logger().info("Reconciling ...")
28+
Current.logger().info("Reconciling...")
2829

2930
do {
3031
try await reconcile(client: context.application.client,
3132
database: context.application.db)
3233
} catch {
33-
Current.logger().error("\(error.localizedDescription)")
34+
Current.logger().error("\(error)")
3435
}
3536

3637
Current.logger().info("done.")
@@ -39,7 +40,7 @@ struct ReconcileCommand: AsyncCommand {
3940
try await AppMetrics.push(client: context.application.client,
4041
jobName: "reconcile")
4142
} catch {
42-
Current.logger().warning("\(error.localizedDescription)")
43+
Current.logger().warning("\(error)")
4344
}
4445
}
4546
}
@@ -48,6 +49,24 @@ struct ReconcileCommand: AsyncCommand {
4849
func reconcile(client: Client, database: Database) async throws {
4950
let start = DispatchTime.now().uptimeNanoseconds
5051
defer { AppMetrics.reconcileDurationSeconds?.time(since: start) }
52+
53+
// reconcile main package list
54+
Current.logger().info("Reconciling main list...")
55+
let fullPackageList = try await reconcileMainPackageList(client: client, database: database)
56+
57+
do { // reconcile custom package collections
58+
Current.logger().info("Reconciling custom collections...")
59+
@Dependency(\.packageListRepository) var packageListRepository
60+
let collections = try await packageListRepository.fetchCustomCollections(client: client)
61+
for collection in collections {
62+
Current.logger().info("Reconciling '\(collection.name)' collection...")
63+
try await reconcileCustomCollection(client: client, database: database, fullPackageList: fullPackageList, collection)
64+
}
65+
}
66+
}
67+
68+
69+
func reconcileMainPackageList(client: Client, database: Database) async throws -> [URL] {
5170
async let sourcePackageList = try Current.fetchPackageList(client)
5271
async let sourcePackageDenyList = try Current.fetchPackageDenyList(client)
5372
async let currentList = try fetchCurrentPackageList(database)
@@ -58,6 +77,8 @@ func reconcile(client: Client, database: Database) async throws {
5877
try await reconcileLists(db: database,
5978
source: packageList,
6079
target: currentList)
80+
81+
return packageList
6182
}
6283

6384

@@ -115,6 +136,7 @@ func reconcileLists(db: Database, source: [URL], target: [URL]) async throws {
115136
}
116137
}
117138

139+
118140
func processPackageDenyList(packageList: [URL], denyList: [URL]) -> [URL] {
119141
// Note: If the implementation of this function ever changes, the `RemoveDenyList`
120142
// command in the Validator will also need updating to match.
@@ -140,3 +162,15 @@ func processPackageDenyList(packageList: [URL], denyList: [URL]) -> [URL] {
140162
.subtracting(Set(denyList.map(CaseInsensitiveURL.init)))
141163
).map(\.url)
142164
}
165+
166+
167+
func reconcileCustomCollection(client: Client, database: Database, fullPackageList: [URL], _ dto: CustomCollection.DTO) async throws {
168+
let collection = try await CustomCollection.findOrCreate(on: database, dto)
169+
170+
// Limit incoming URLs to 50 since this is input outside of our control
171+
@Dependency(\.packageListRepository) var packageListRepository
172+
let incomingURLs = try await packageListRepository.fetchCustomCollection(client: client, url: collection.url)
173+
.prefix(Constants.maxCustomPackageCollectionSize)
174+
175+
try await collection.reconcile(on: database, packageURLs: incomingURLs)
176+
}

Sources/App/Core/Constants.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ enum Constants {
2626

2727
static let packageListUri = URI(string: "https://raw.githubusercontent.com/SwiftPackageIndex/PackageList/main/packages.json")
2828
static let packageDenyListUri = URI(string: "https://raw.githubusercontent.com/SwiftPackageIndex/PackageList/main/denylist.json")
29+
static let customCollectionsUri = URI(string: "https://raw.githubusercontent.com/SwiftPackageIndex/PackageList/main/custom-package-collections.json")
30+
31+
static let maxCustomPackageCollectionSize = 50
2932

3033
// NB: the underlying materialised views also have a limit, this is just an additional
3134
// limit to ensure we don't spill too many rows onto the home page
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright Dave Verwer, Sven A. Schmidt, and other contributors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Dependencies
16+
import DependenciesMacros
17+
import Vapor
18+
19+
20+
@DependencyClient
21+
struct PackageListRepositoryClient {
22+
var fetchCustomCollection: @Sendable (_ client: Client, _ url: URL) async throws -> [URL]
23+
var fetchCustomCollections: @Sendable (_ client: Client) async throws -> [CustomCollection.DTO]
24+
// TODO: move other package list dependencies here
25+
}
26+
27+
28+
extension PackageListRepositoryClient: DependencyKey {
29+
static var liveValue: PackageListRepositoryClient {
30+
.init(
31+
fetchCustomCollection: { client, url in
32+
try await client
33+
.get(URI(string: url.absoluteString))
34+
.content
35+
.decode([URL].self, using: JSONDecoder())
36+
},
37+
fetchCustomCollections: { client in
38+
try await client
39+
.get(Constants.customCollectionsUri)
40+
.content
41+
.decode([CustomCollection.DTO].self, using: JSONDecoder())
42+
}
43+
)
44+
}
45+
}
46+
47+
48+
extension PackageListRepositoryClient: Sendable, TestDependencyKey {
49+
static var testValue: Self { Self() }
50+
}
51+
52+
53+
extension DependencyValues {
54+
var packageListRepository: PackageListRepositoryClient {
55+
get { self[PackageListRepositoryClient.self] }
56+
set { self[PackageListRepositoryClient.self] = newValue }
57+
}
58+
}
59+
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright Dave Verwer, Sven A. Schmidt, and other contributors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Fluent
16+
import SQLKit
17+
18+
19+
struct CreateCustomCollection: AsyncMigration {
20+
func prepare(on database: Database) async throws {
21+
try await database.schema("custom_collections")
22+
23+
// managed fields
24+
.id()
25+
.field("created_at", .datetime)
26+
.field("updated_at", .datetime)
27+
28+
// data fields
29+
.field("name", .string, .required)
30+
.field("description", .string)
31+
.field("badge", .string)
32+
.field("url", .string, .required)
33+
34+
// constraints
35+
.unique(on: "name")
36+
.unique(on: "url")
37+
38+
.create()
39+
}
40+
41+
func revert(on database: Database) async throws {
42+
try await database.schema("custom_collections").delete()
43+
}
44+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright Dave Verwer, Sven A. Schmidt, and other contributors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Fluent
16+
import SQLKit
17+
18+
19+
struct CreateCustomCollectionPackage: AsyncMigration {
20+
func prepare(on database: Database) async throws {
21+
try await database.schema("custom_collections+packages")
22+
23+
// managed fields
24+
.id()
25+
.field("created_at", .datetime)
26+
.field("updated_at", .datetime)
27+
28+
// reference fields
29+
.field("custom_collection_id", .uuid,
30+
.references("custom_collections", "id", onDelete: .cascade), .required)
31+
.field("package_id", .uuid,
32+
.references("packages", "id", onDelete: .cascade), .required)
33+
34+
// constraints
35+
.unique(on: "custom_collection_id", "package_id")
36+
37+
.create()
38+
}
39+
40+
func revert(on database: Database) async throws {
41+
try await database.schema("custom_collections+packages").delete()
42+
}
43+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright Dave Verwer, Sven A. Schmidt, and other contributors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Fluent
16+
import Vapor
17+
18+
19+
final class CustomCollection: @unchecked Sendable, Model, Content {
20+
static let schema = "custom_collections"
21+
22+
typealias Id = UUID
23+
24+
// managed fields
25+
26+
@ID(key: .id)
27+
var id: Id?
28+
29+
@Timestamp(key: "created_at", on: .create)
30+
var createdAt: Date?
31+
32+
// periphery:ignore
33+
@Timestamp(key: "updated_at", on: .update)
34+
var updatedAt: Date?
35+
36+
// data fields
37+
38+
@Field(key: "name")
39+
var name: String
40+
41+
@Field(key: "description")
42+
var description: String?
43+
44+
@Field(key: "badge")
45+
var badge: String?
46+
47+
@Field(key: "url")
48+
var url: URL
49+
50+
// reference fields
51+
@Siblings(through: CustomCollectionPackage.self, from: \.$customCollection, to: \.$package)
52+
var packages: [Package]
53+
54+
init() { }
55+
56+
init(id: Id? = nil, createdAt: Date? = nil, updatedAt: Date? = nil, _ dto: DTO) {
57+
self.id = id
58+
self.createdAt = createdAt
59+
self.updatedAt = updatedAt
60+
self.name = dto.name
61+
self.description = dto.description
62+
self.badge = dto.badge
63+
self.url = dto.url
64+
}
65+
}
66+
67+
68+
extension CustomCollection {
69+
struct DTO: Codable {
70+
var name: String
71+
var description: String?
72+
var badge: String?
73+
var url: URL
74+
}
75+
76+
static func findOrCreate(on database: Database, _ dto: DTO) async throws -> CustomCollection {
77+
if let collection = try await CustomCollection.query(on: database)
78+
.filter(\.$url == dto.url)
79+
.first() {
80+
return collection
81+
} else {
82+
let collection = CustomCollection(dto)
83+
try await collection.save(on: database)
84+
return collection
85+
}
86+
}
87+
88+
func reconcile(on database: Database, packageURLs: some Collection<URL>) async throws {
89+
let incoming: [Package.Id: Package] = .init(
90+
packages: try await Package.query(on: database)
91+
.filter(by: packageURLs)
92+
.all()
93+
)
94+
try await $packages.load(on: database)
95+
let existing: [Package.Id: Package] = .init(packages: packages)
96+
let newIDs = Set(incoming.keys).subtracting(Set(existing.keys))
97+
try await $packages.attach(incoming[newIDs], on: database)
98+
let removedIDs = Set(existing.keys).subtracting(Set(incoming.keys))
99+
try await $packages.detach(existing[removedIDs], on: database)
100+
}
101+
}
102+
103+
104+
private extension [Package.Id: Package] {
105+
init(packages: [Package]) {
106+
self.init(
107+
packages.compactMap({ pkg in pkg.id.map({ ($0, pkg) }) }),
108+
uniquingKeysWith: { (first, second) in first }
109+
)
110+
}
111+
112+
subscript(ids: some Collection<Package.Id>) -> [Package] {
113+
Array(ids.compactMap { self[$0] })
114+
}
115+
}

0 commit comments

Comments
 (0)