Skip to content

Commit 807708c

Browse files
Merge pull request #3457 from SwiftPackageIndex/custom-collection-page
Add custom collection page, linked from package page
2 parents f75333b + 3e0e3e8 commit 807708c

22 files changed

+763
-27
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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 Plot
17+
import Vapor
18+
19+
20+
enum CustomCollectionsController {
21+
22+
static func query(on database: Database, name: String, page: Int, pageSize: Int) async throws -> Page<Joined3<Package, Repository, Version>> {
23+
try await Joined3<Package, Repository, Version>
24+
.query(on: database, version: .defaultBranch)
25+
.join(CustomCollectionPackage.self, on: \Package.$id == \CustomCollectionPackage.$package.$id)
26+
.join(CustomCollection.self, on: \CustomCollection.$id == \CustomCollectionPackage.$customCollection.$id)
27+
.field(Repository.self, \.$name)
28+
.field(Repository.self, \.$owner)
29+
.field(Repository.self, \.$lastActivityAt)
30+
.field(Repository.self, \.$stars)
31+
.field(Repository.self, \.$summary)
32+
.field(Version.self, \.$packageName)
33+
.filter(CustomCollection.self, \.$name == name)
34+
.sort(Repository.self, \.$name)
35+
.page(page, size: pageSize)
36+
}
37+
38+
struct Query: Codable {
39+
var page: Int
40+
var pageSize: Int
41+
42+
static let defaultPage = 1
43+
static let defaultPageSize = 20
44+
45+
enum CodingKeys: CodingKey {
46+
case page
47+
case pageSize
48+
}
49+
50+
init(from decoder: Decoder) throws {
51+
let container = try decoder.container(keyedBy: CodingKeys.self)
52+
self.page = try container.decodeIfPresent(Int.self, forKey: CodingKeys.page) ?? Self.defaultPage
53+
self.pageSize = try container.decodeIfPresent(Int.self, forKey: CodingKeys.pageSize) ?? Self.defaultPageSize
54+
}
55+
}
56+
57+
@Sendable
58+
static func show(req: Request) async throws -> HTML {
59+
guard let name = req.parameters.get("name") else {
60+
throw Abort(.notFound)
61+
}
62+
let query = try req.query.decode(Query.self)
63+
let page = try await Self.query(on: req.db, name: name, page: query.page, pageSize: query.pageSize)
64+
65+
guard !page.results.isEmpty else {
66+
throw Abort(.notFound)
67+
}
68+
69+
let packageInfo = page.results.compactMap(PackageInfo.init(package:))
70+
71+
let model = CustomCollectionShow.Model(
72+
name: name,
73+
packages: packageInfo,
74+
page: query.page,
75+
hasMoreResults: page.hasMoreResults
76+
)
77+
78+
return CustomCollectionShow.View(path: req.url.path, model: model).document()
79+
}
80+
81+
}

Sources/App/Controllers/KeywordController.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ enum KeywordController {
2222
static func query(on database: Database, keyword: String, page: Int, pageSize: Int) async throws -> Page<Joined3<Package, Repository, Version>> {
2323
try await Joined3<Package, Repository, Version>
2424
.query(on: database, version: .defaultBranch)
25+
.field(Repository.self, \.$name)
26+
.field(Repository.self, \.$owner)
27+
.field(Repository.self, \.$lastActivityAt)
28+
.field(Repository.self, \.$stars)
29+
.field(Repository.self, \.$summary)
30+
.field(Version.self, \.$packageName)
2531
.filter(Repository.self, \.$keywords, .custom("@>"), [keyword])
2632
.sort(\.$score, .descending)
2733
.sort(Repository.self, \.$name)

Sources/App/Controllers/PackageCollectionController.swift

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,40 @@ enum PackageCollectionController {
2020
static func generate(req: Request) async throws -> SignedCollection {
2121
AppMetrics.packageCollectionGetTotal?.inc()
2222

23-
guard let owner = req.parameters.get("owner") else { throw Abort(.notFound) }
23+
guard let collectionType = getCollectionType(req: req) else {
24+
throw Abort(.notFound)
25+
}
2426

2527
do {
26-
return try await SignedCollection.generate(
27-
db: req.db,
28-
filterBy: .author(owner),
29-
authorName: "\(owner) via the Swift Package Index"
30-
)
28+
switch collectionType {
29+
case let .author(owner):
30+
return try await SignedCollection.generate(
31+
db: req.db,
32+
filterBy: .author(owner),
33+
authorName: "\(owner) via the Swift Package Index"
34+
)
35+
case let .custom(name):
36+
return try await SignedCollection.generate(
37+
db: req.db,
38+
filterBy: .customCollection(name),
39+
authorName: "Swift Package Index",
40+
collectionName: name,
41+
overview: "A custom package collection generated by the Swift Package Index"
42+
)
43+
}
3144
} catch PackageCollection.Error.noResults {
3245
throw Abort(.notFound)
3346
}
3447
}
48+
49+
enum CollectionType {
50+
case author(String)
51+
case custom(String)
52+
}
53+
54+
static func getCollectionType(req: Request) -> CollectionType? {
55+
if let owner = req.parameters.get("owner") { return .author(owner) }
56+
if let name = req.parameters.get("name") { return .custom(name) }
57+
return nil
58+
}
3559
}

Sources/App/Core/Extensions/String+ext.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ extension String {
5858
var pathEncoded: Self {
5959
replacingOccurrences(of: "/", with: "-")
6060
}
61+
62+
var urlPathEncoded: Self {
63+
addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? self
64+
}
6165
}
6266

6367

Sources/App/Core/PackageCollection+VersionResult.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ extension PackageCollection.VersionResult {
5555
switch filter {
5656
case let .author(owner):
5757
query.filter(Repository.self, \Repository.$owner, .custom("ilike"), owner)
58+
case let .customCollection(name):
59+
query
60+
.join(CustomCollectionPackage.self, on: \Package.$id == \CustomCollectionPackage.$package.$id)
61+
.join(CustomCollection.self, on: \CustomCollection.$id == \CustomCollectionPackage.$customCollection.$id)
62+
.filter(CustomCollection.self, \.$name == name)
5863
case let .urls(packageURLs):
5964
query.filter(App.Package.self, \.$url ~~ packageURLs)
6065
}

Sources/App/Core/PackageCollection+generate.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ extension PackageCollection {
3434
enum Filter {
3535
case urls([String])
3636
case author(String)
37+
case customCollection(String)
3738
}
3839

3940
enum Error: Swift.Error {
@@ -97,10 +98,14 @@ extension PackageCollection {
9798
return owner
9899
case (.author, .some(let label)):
99100
return label
100-
case (.urls, .some(let label)):
101+
case (.customCollection(let name), .none):
102+
return name
103+
case (.customCollection, .some(let label)):
101104
return label
102105
case (.urls(let urls), .none):
103106
return author(for: urls)
107+
case (.urls, .some(let label)):
108+
return label
104109
}
105110
}
106111

Sources/App/Core/SiteURL.swift

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,14 +113,16 @@ enum SiteURL: Resourceable, Sendable {
113113
case blogPost(_ slug: Parameter<String>)
114114
case buildMonitor
115115
case builds(_ id: Parameter<UUID>)
116+
case collections(_ name: Parameter<String>)
116117
case docs(Docs)
117118
case faq
118119
case home
119120
case images(String)
120121
case javascripts(String)
121122
case keywords(_ keyword: Parameter<String>)
122123
case package(_ owner: Parameter<String>, _ repository: Parameter<String>, PackagePathComponents?)
123-
case packageCollection(_ owner: Parameter<String>)
124+
case packageCollectionAuthor(_ owner: Parameter<String>)
125+
case packageCollectionCustom(_ name: Parameter<String>)
124126
case packageCollections
125127
case privacy
126128
case readyForSwift6
@@ -169,6 +171,12 @@ enum SiteURL: Resourceable, Sendable {
169171
case .buildMonitor:
170172
return "build-monitor"
171173

174+
case let .collections(.value(name)):
175+
return "collections/\(name.urlPathEncoded)"
176+
177+
case .collections(.key):
178+
fatalError("path must not be called with a name parameter")
179+
172180
case let .docs(next):
173181
return "docs/\(next.path)"
174182

@@ -201,10 +209,16 @@ enum SiteURL: Resourceable, Sendable {
201209
case .package:
202210
fatalError("invalid path: \(self)")
203211

204-
case let .packageCollection(.value(owner)):
212+
case let .packageCollectionAuthor(.value(owner)):
205213
return "\(owner)/collection.json"
206214

207-
case .packageCollection(.key):
215+
case .packageCollectionAuthor(.key):
216+
fatalError("invalid path: \(self)")
217+
218+
case let .packageCollectionCustom(.value(name)):
219+
return "collections/\(name.urlPathEncoded)/collection.json"
220+
221+
case .packageCollectionCustom(.key):
208222
fatalError("invalid path: \(self)")
209223

210224
case .packageCollections:
@@ -283,6 +297,12 @@ enum SiteURL: Resourceable, Sendable {
283297
case .builds(.value):
284298
fatalError("pathComponents must not be called with a value parameter")
285299

300+
case .collections(.key):
301+
return ["collections", ":name"]
302+
303+
case .collections(.value):
304+
fatalError("pathComponents must not be called with a value parameter")
305+
286306
case let .docs(next):
287307
return ["docs"] + next.pathComponents
288308

@@ -298,10 +318,16 @@ enum SiteURL: Resourceable, Sendable {
298318
case .package:
299319
fatalError("pathComponents must not be called with a value parameter")
300320

301-
case .packageCollection(.key):
321+
case .packageCollectionAuthor(.key):
302322
return [":owner", "collection.json"]
303323

304-
case .packageCollection(.value):
324+
case .packageCollectionAuthor(.value):
325+
fatalError("pathComponents must not be called with a value parameter")
326+
327+
case .packageCollectionCustom(.key):
328+
return ["collections", ":name", "collection.json"]
329+
330+
case .packageCollectionCustom(.value):
305331
fatalError("pathComponents must not be called with a value parameter")
306332

307333
case .images, .javascripts, .stylesheets:

Sources/App/Views/Author/AuthorShow+View.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ enum AuthorShow {
5959
),
6060
.copyableInputForm(buttonName: "Copy Package Collection URL",
6161
eventName: "Copy Package Collection URL Button",
62-
valueToCopy: SiteURL.packageCollection(.value(model.owner)).absoluteURL()),
62+
valueToCopy: SiteURL.packageCollectionAuthor(.value(model.owner)).absoluteURL()),
6363
.hr(.class("minor")),
6464
.ul(
6565
.id("package-list"),
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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+
extension CustomCollectionShow {
16+
struct Model {
17+
var name: String
18+
var packages: [PackageInfo]
19+
var page: Int
20+
var hasMoreResults: Bool
21+
22+
internal init(name: String,
23+
packages: [PackageInfo],
24+
page: Int,
25+
hasMoreResults: Bool) {
26+
self.name = name
27+
self.packages = packages
28+
self.page = page
29+
self.hasMoreResults = hasMoreResults
30+
}
31+
}
32+
}

0 commit comments

Comments
 (0)