Skip to content

Commit 782fc84

Browse files
authored
Refactor HREF normalization and models (#358)
1 parent 018bac4 commit 782fc84

File tree

165 files changed

+3774
-2340
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

165 files changed

+3774
-2340
lines changed

.github/workflows/checks.yml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,17 @@ on:
77

88
env:
99
platform: ${{ 'iOS Simulator' }}
10-
device: ${{ 'iPhone 12' }}
10+
device: ${{ 'iPhone 15' }}
1111
commit_sha: ${{ github.sha }}
12+
DEVELOPER_DIR: /Applications/Xcode_15.0.1.app/Contents/Developer
1213

1314
jobs:
1415
build:
1516
name: Build
16-
runs-on: macos-12
17+
runs-on: macos-13
1718
if: ${{ !github.event.pull_request.draft }}
1819
env:
1920
scheme: ${{ 'Readium-Package' }}
20-
DEVELOPER_DIR: /Applications/Xcode_13.4.1.app/Contents/Developer
2121

2222
steps:
2323
- name: Checkout
@@ -40,7 +40,7 @@ jobs:
4040
4141
lint:
4242
name: Lint
43-
runs-on: macos-12
43+
runs-on: macos-13
4444
if: ${{ !github.event.pull_request.draft }}
4545
env:
4646
scripts: ${{ 'Sources/Navigator/EPUB/Scripts' }}
@@ -63,7 +63,7 @@ jobs:
6363

6464
int-dev:
6565
name: Integration (Local)
66-
runs-on: macos-12
66+
runs-on: macos-13
6767
if: ${{ !github.event.pull_request.draft }}
6868
defaults:
6969
run:
@@ -84,7 +84,7 @@ jobs:
8484
8585
int-spm:
8686
name: Integration (Swift Package Manager)
87-
runs-on: macos-12
87+
runs-on: macos-13
8888
if: ${{ !github.event.pull_request.draft }}
8989
defaults:
9090
run:
@@ -111,7 +111,7 @@ jobs:
111111
112112
int-carthage:
113113
name: Integration (Carthage)
114-
runs-on: macos-12
114+
runs-on: macos-13
115115
if: ${{ !github.event.pull_request.draft }}
116116
defaults:
117117
run:
@@ -141,7 +141,7 @@ jobs:
141141
int-cocoapods:
142142
name: Integration (CocoaPods)
143143
if: github.event_name == 'push'
144-
runs-on: macos-12
144+
runs-on: macos-13
145145
defaults:
146146
run:
147147
working-directory: TestApp

CHANGELOG.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,21 @@ All notable changes to this project will be documented in this file. Take a look
44

55
**Warning:** Features marked as *alpha* may change or be removed in a future release without notice. Use with caution.
66

7-
<!-- ## [Unreleased] -->
7+
## [Unreleased]
8+
9+
### Changed
10+
11+
* Many APIs now expect one of the new URL types (`RelativeURL`, `AbsoluteURL`, `HTTPURL` and `FileURL`). This is helpful because:
12+
* It validates at compile time that we provide a URL that is supported.
13+
* The API's capabilities are better documented, e.g. a download API could look like this : `download(url: HTTPURL) -> FileURL`.
14+
15+
#### Shared
16+
17+
* `Link` and `Locator`'s `href` are normalized as valid URLs to improve interoperability with the Readium Web toolkits.
18+
* **You MUST migrate your database if you were persisting HREFs and Locators**. Take a look at [the migration guide](Documentation/Migration%20Guide.md) for guidance.
19+
* Links are not resolved to the `self` URL of a manifest anymore. However, you can still normalize the HREFs yourselves by calling `Manifest.normalizeHREFsToSelf()`.
20+
* `Publication.localizedTitle` is now optional, as we cannot guarantee a publication will always have a title.
21+
822

923
## [2.6.1]
1024

Documentation/Migration Guide.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,55 @@
22

33
All migration steps necessary in reading apps to upgrade to major versions of the Swift Readium toolkit will be documented in this file.
44

5+
## Unreleased
6+
7+
### Migration of HREFs and Locators (bookmarks, annotations, etc.)
8+
9+
:warning: This requires a database migration in your application, if you were persisting `Locator` objects.
10+
11+
In Readium v2.x, a `Link` or `Locator`'s `href` could be either:
12+
13+
* a valid absolute URL for a streamed publication, e.g. `https://domain.com/isbn/dir/my%20chapter.html`,
14+
* a percent-decoded path for a local archive such as an EPUB, e.g. `/dir/my chapter.html`.
15+
* Note that it was relative to the root of the archive (`/`).
16+
17+
To improve the interoperability with other Readium toolkits (in particular the Readium Web Toolkits, which only work in a streaming context) **Readium v3 now generates and expects valid URLs** for `Locator` and `Link`'s `href`.
18+
19+
* `https://domain.com/isbn/dir/my%20chapter.html` is left unchanged, as it was already a valid URL.
20+
* `/dir/my chapter.html` becomes the relative URL path `dir/my%20chapter.html`
21+
* We dropped the `/` prefix to avoid issues when resolving to a base URL.
22+
* Special characters are percent-encoded.
23+
24+
**You must migrate the HREFs or Locators stored in your database** when upgrading to Readium 3. To assist you, two helpers are provided: `AnyURL(legacyHREF:)` and `Locator(legacyJSONString:)`.
25+
26+
Here's an example of a [GRDB migration](https://swiftpackageindex.com/groue/grdb.swift/master/documentation/grdb/migrations) that can serve as inspiration:
27+
28+
```swift
29+
migrator.registerMigration("normalizeHREFs") { db in
30+
let normalizedRows: [(id: Int, href: String, locator: String)] =
31+
try Row.fetchAll(db, sql: "SELECT id, href, locator FROM bookmarks")
32+
.compactMap { row in
33+
guard
34+
let normalizedHREF = AnyURL(legacyHREF: row["href"])?.string,
35+
let normalizedLocator = try Locator(legacyJSONString: row["locator"])?.jsonString
36+
else {
37+
return nil
38+
}
39+
return (row["id"], normalizedHREF, normalizedLocator)
40+
}
41+
42+
let updateStmt = try db.makeStatement(sql: "UPDATE bookmarks SET href = :href, locator = :locator WHERE id = :id")
43+
for (id, href, locator) in normalizedRows {
44+
try updateStmt.execute(arguments: [
45+
"id": id,
46+
"href": href
47+
"locator": locator
48+
])
49+
}
50+
}
51+
```
52+
53+
554
## 2.5.0
655

756
In the following migration steps, only the `ReadiumInternal` one is mandatory with 2.5.0.

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ scripts:
2323
.PHONY: test
2424
test:
2525
# To limit to a particular test suite: -only-testing:R2SharedTests
26-
xcodebuild test -scheme "Readium-Package" -destination "platform=iOS Simulator,name=iPhone 12" | xcbeautify -q
26+
xcodebuild test -scheme "Readium-Package" -destination "platform=iOS Simulator,name=iPhone 15" | xcbeautify -q
2727

2828
.PHONY: lint-format
2929
lint-format:

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ A [Test App](TestApp) demonstrates how to integrate the Readium Swift toolkit in
1616

1717
<!-- https://swiftversion.net/ -->
1818

19-
| Readium | iOS | Swift compiler | Xcode |
20-
|-----------|------|----------------|-------|
21-
| `develop` | 11.0 | 5.6.1 | 13.4 |
22-
| 2.5.1 | 11.0 | 5.6.1 | 13.4 |
23-
| 2.5.0 | 10.0 | 5.6.1 | 13.4 |
24-
| 2.4.0 | 10.0 | 5.3.2 | 12.4 |
19+
| Readium | iOS | Swift compiler | Xcode |
20+
|-----------|------|----------------|--------|
21+
| `develop` | 11.0 | 5.9 | 15.0.1 |
22+
| 2.5.1 | 11.0 | 5.6.1 | 13.4 |
23+
| 2.5.0 | 10.0 | 5.6.1 | 13.4 |
24+
| 2.4.0 | 10.0 | 5.3.2 | 12.4 |
2525

2626
## Using Readium
2727

Sources/Adapters/GCDWebServer/GCDHTTPServer.swift

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import UIKit
1212
public enum GCDHTTPServerError: Error {
1313
case failedToStartServer(cause: Error)
1414
case serverNotStarted
15+
case invalidEndpoint(HTTPServerEndpoint)
1516
case nullServerURL
1617
}
1718

@@ -24,14 +25,14 @@ public class GCDHTTPServer: HTTPServer, Loggable {
2425
private let server = GCDWebServer()
2526

2627
/// Mapping between endpoints and their handlers.
27-
private var handlers: [HTTPServerEndpoint: (HTTPServerRequest) -> Resource] = [:]
28+
private var handlers: [HTTPURL: (HTTPServerRequest) -> Resource] = [:]
2829

2930
/// Mapping between endpoints and resource transformers.
30-
private var transformers: [HTTPServerEndpoint: [ResourceTransformer]] = [:]
31+
private var transformers: [HTTPURL: [ResourceTransformer]] = [:]
3132

3233
private enum State {
3334
case stopped
34-
case started(port: UInt, baseURL: URL)
35+
case started(port: UInt, baseURL: HTTPURL)
3536
}
3637

3738
private var state: State = .stopped
@@ -112,12 +113,12 @@ public class GCDHTTPServer: HTTPServer, Loggable {
112113
}
113114

114115
queue.async { [self] in
115-
var path = request.path.removingPrefix("/")
116-
path = path.removingPercentEncoding ?? path
117-
// Remove anchors and query params
118-
let pathWithoutAnchor = path.components(separatedBy: .init(charactersIn: "#?")).first ?? path
116+
guard let url = request.url.httpURL else {
117+
completion(FailureResource(link: Link(href: request.url.absoluteString), error: .notFound(nil)))
118+
return
119+
}
119120

120-
func transform(resource: Resource, at endpoint: HTTPServerEndpoint) -> Resource {
121+
func transform(resource: Resource, at endpoint: HTTPURL) -> Resource {
121122
guard let transformers = transformers[endpoint], !transformers.isEmpty else {
122123
return resource
123124
}
@@ -129,15 +130,15 @@ public class GCDHTTPServer: HTTPServer, Loggable {
129130
}
130131

131132
for (endpoint, handler) in handlers {
132-
if endpoint == pathWithoutAnchor {
133-
let resource = handler(HTTPServerRequest(url: request.url, href: nil))
133+
if endpoint == url.removingQuery().removingFragment() {
134+
let resource = handler(HTTPServerRequest(url: url, href: nil))
134135
completion(transform(resource: resource, at: endpoint))
135136
return
136137

137-
} else if path.hasPrefix(endpoint.addingSuffix("/")) {
138+
} else if let href = endpoint.relativize(url) {
138139
let resource = handler(HTTPServerRequest(
139-
url: request.url,
140-
href: path.removingPrefix(endpoint.removingSuffix("/"))
140+
url: url,
141+
href: href
141142
))
142143
completion(transform(resource: resource, at: endpoint))
143144
return
@@ -150,34 +151,46 @@ public class GCDHTTPServer: HTTPServer, Loggable {
150151

151152
// MARK: HTTPServer
152153

153-
public func serve(at endpoint: HTTPServerEndpoint, handler: @escaping (HTTPServerRequest) -> Resource) throws -> URL {
154+
public func serve(at endpoint: HTTPServerEndpoint, handler: @escaping (HTTPServerRequest) -> Resource) throws -> HTTPURL {
154155
try queue.sync(flags: .barrier) {
155156
if case .stopped = state {
156157
try start()
157158
}
158-
guard case let .started(port: _, baseURL: baseURL) = state else {
159-
throw GCDHTTPServerError.serverNotStarted
160-
}
161159

162-
handlers[endpoint] = handler
163-
164-
return baseURL.appendingPathComponent(endpoint)
160+
let url = try url(for: endpoint)
161+
handlers[url] = handler
162+
return url
165163
}
166164
}
167165

168-
public func transformResources(at endpoint: HTTPServerEndpoint, with transformer: @escaping ResourceTransformer) {
169-
queue.sync(flags: .barrier) {
170-
var trs = transformers[endpoint] ?? []
166+
public func transformResources(at endpoint: HTTPServerEndpoint, with transformer: @escaping ResourceTransformer) throws {
167+
try queue.sync(flags: .barrier) {
168+
let url = try url(for: endpoint)
169+
var trs = transformers[url] ?? []
171170
trs.append(transformer)
172-
transformers[endpoint] = trs
171+
transformers[url] = trs
173172
}
174173
}
175174

176-
public func remove(at endpoint: HTTPServerEndpoint) {
177-
queue.sync(flags: .barrier) {
178-
handlers.removeValue(forKey: endpoint)
179-
transformers.removeValue(forKey: endpoint)
175+
public func remove(at endpoint: HTTPServerEndpoint) throws {
176+
try queue.sync(flags: .barrier) {
177+
let url = try url(for: endpoint)
178+
handlers.removeValue(forKey: url)
179+
transformers.removeValue(forKey: url)
180+
}
181+
}
182+
183+
private func url(for endpoint: HTTPServerEndpoint) throws -> HTTPURL {
184+
guard case let .started(port: _, baseURL: baseURL) = state else {
185+
throw GCDHTTPServerError.serverNotStarted
186+
}
187+
guard
188+
let endpointPath = RelativeURL(string: endpoint.addingSuffix("/")),
189+
let endpointURL = baseURL.resolve(endpointPath)
190+
else {
191+
throw GCDHTTPServerError.invalidEndpoint(endpoint)
180192
}
193+
return endpointURL
181194
}
182195

183196
// MARK: Server lifecycle
@@ -230,7 +243,7 @@ public class GCDHTTPServer: HTTPServer, Loggable {
230243
throw GCDHTTPServerError.failedToStartServer(cause: error)
231244
}
232245

233-
guard let baseURL = server.serverURL else {
246+
guard let baseURL = server.serverURL?.httpURL else {
234247
stop()
235248
throw GCDHTTPServerError.nullServerURL
236249
}

Sources/Internal/Extensions/Array.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ public extension Array where Element: Hashable {
5757
array.removeAll { other in other == element }
5858
return array
5959
}
60+
61+
@inlinable mutating func remove(_ element: Element) {
62+
removeAll { other in other == element }
63+
}
6064
}
6165

6266
public extension Array where Element: Equatable {

Sources/Internal/Extensions/String.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,11 @@ public extension String {
8484
}
8585
return index
8686
}
87+
88+
func orNilIfEmpty() -> String? {
89+
guard !isEmpty else {
90+
return nil
91+
}
92+
return self
93+
}
8794
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//
2+
// Copyright 2023 Readium Foundation. All rights reserved.
3+
// Use of this source code is governed by the BSD-style license
4+
// available in the top-level LICENSE file of the project.
5+
//
6+
7+
import Foundation
8+
9+
public extension URL {
10+
/// Removes the fragment portion of the receiver and returns it.
11+
mutating func removeFragment() -> String? {
12+
var fragment: String?
13+
guard let result = copy({
14+
fragment = $0.fragment
15+
$0.fragment = nil
16+
}) else {
17+
return nil
18+
}
19+
self = result
20+
return fragment
21+
}
22+
23+
/// Creates a copy of the receiver after removing its fragment portion.
24+
func removingFragment() -> URL? {
25+
copy { $0.fragment = nil }
26+
}
27+
28+
/// Creates a copy of the receiver after modifying its components.
29+
func copy(_ changes: (inout URLComponents) -> Void) -> URL? {
30+
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: true) else {
31+
return nil
32+
}
33+
changes(&components)
34+
return components.url
35+
}
36+
}

Sources/LCP/Content Protection/LCPContentProtection.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ final class LCPContentProtection: ContentProtection, Loggable {
3434
?? self.authentication
3535

3636
service.retrieveLicense(
37-
from: file.url,
37+
from: file.file,
3838
authentication: authentication,
3939
allowUserInteraction: allowUserInteraction,
4040
sender: sender

0 commit comments

Comments
 (0)