Merge pull request #1198 from kiwix/1193-change-library-endpoint

Library endpoint changes
This commit is contained in:
Kelson 2025-05-10 20:35:23 +02:00 committed by GitHub
commit ecc9ba1556
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 67 additions and 23 deletions

View File

@ -21,7 +21,7 @@ NS_ASSUME_NONNULL_BEGIN
@interface OPDSParser : NSObject @interface OPDSParser : NSObject
- (nonnull instancetype)init; - (nonnull instancetype)init;
- (BOOL)parseData:(nonnull NSData *)data NS_REFINED_FOR_SWIFT; - (BOOL)parseData:(nonnull NSData *)data using: (nonnull NSString *)urlHost NS_REFINED_FOR_SWIFT;
- (nonnull NSSet *)getZimFileIDs NS_REFINED_FOR_SWIFT; - (nonnull NSSet *)getZimFileIDs NS_REFINED_FOR_SWIFT;
- (nullable ZimFileMetaData *)getZimFileMetaData:(nonnull NSUUID *)identifier NS_REFINED_FOR_SWIFT; - (nullable ZimFileMetaData *)getZimFileMetaData:(nonnull NSUUID *)identifier NS_REFINED_FOR_SWIFT;

View File

@ -39,7 +39,7 @@
return self; return self;
} }
- (BOOL)parseData:(nonnull NSData *)data { - (BOOL)parseData:(nonnull NSData *)data using: (nonnull NSString *)urlHost {
try { try {
NSString *content = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; NSString *content = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
if (content == nil) { if (content == nil) {
@ -47,7 +47,7 @@
} }
std::shared_ptr<kiwix::Manager> manager = std::make_shared<kiwix::Manager>(self.library); std::shared_ptr<kiwix::Manager> manager = std::make_shared<kiwix::Manager>(self.library);
return manager->readOpds([content cStringUsingEncoding:NSUTF8StringEncoding], return manager->readOpds([content cStringUsingEncoding:NSUTF8StringEncoding],
[@"https://library.kiwix.org" cStringUsingEncoding:NSUTF8StringEncoding]); [urlHost cStringUsingEncoding:NSUTF8StringEncoding]);
} catch (std::exception) { } catch (std::exception) {
return false; return false;
} }

View File

@ -15,7 +15,7 @@
protocol Parser { protocol Parser {
var zimFileIDs: Set<UUID> { get } var zimFileIDs: Set<UUID> { get }
func parse(data: Data) throws func parse(data: Data, urlHost: String) throws
func getMetaData(id: UUID) -> ZimFileMetaData? func getMetaData(id: UUID) -> ZimFileMetaData?
} }
@ -24,8 +24,8 @@ extension OPDSParser: Parser {
__getZimFileIDs() as? Set<UUID> ?? Set<UUID>() __getZimFileIDs() as? Set<UUID> ?? Set<UUID>()
} }
func parse(data: Data) throws { func parse(data: Data, urlHost: String) throws {
if !self.__parseData(data) { if !self.__parseData(data, using: urlHost.removingSuffix("/")) {
throw LibraryRefreshError.parse throw LibraryRefreshError.parse
} }
} }
@ -42,7 +42,7 @@ extension OPDSParser: Parser {
struct DeletingParser: Parser { struct DeletingParser: Parser {
let zimFileIDs: Set<UUID> = .init() let zimFileIDs: Set<UUID> = .init()
func parse(data: Data) throws { func parse(data: Data, urlHost: String) throws {
} }
func getMetaData(id: UUID) -> ZimFileMetaData? { func getMetaData(id: UUID) -> ZimFileMetaData? {

View File

@ -21,6 +21,11 @@ extension String {
guard hasPrefix(value) else { return self } guard hasPrefix(value) else { return self }
return String(dropFirst(value.count)) return String(dropFirst(value.count))
} }
func removingSuffix(_ value: String) -> String {
guard hasSuffix(value) else { return self }
return String(dropLast(value.count))
}
func replacingRegex( func replacingRegex(
matching pattern: String, matching pattern: String,

View File

@ -95,4 +95,25 @@ extension URL {
components.scheme = "zim" components.scheme = "zim"
return components.url ?? self return components.url ?? self
} }
/// Remove the defined components one by one if found
/// - Parameter pathComponents: eg: /package/details/more can be defined as: ["package", "details", "more"]
/// - Returns: the modified url
func trim(pathComponents: [String]) -> URL {
var result = self
for component in pathComponents.reversed() where component == result.lastPathComponent {
result = result.deletingLastPathComponent()
}
return result
}
/// Removes everything after ? or &
/// - Returns: the modified URL, or the same if it fails to find the components
func withoutQueryParams() -> URL {
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else {
return self
}
components.queryItems = nil
return components.url ?? self
}
} }

View File

@ -171,8 +171,9 @@ final class LibraryRefreshViewModelTest: XCTestCase {
func testNewZimFileAndProperties() async throws { func testNewZimFileAndProperties() async throws {
let zimFileID = UUID() let zimFileID = UUID()
HTTPTestingURLProtocol.handler = { urlProtocol in HTTPTestingURLProtocol.handler = { urlProtocol in
let responseTestURL = URL(string: "https://response-testing.com/catalog/v2/entries?count=-1")!
let response = HTTPURLResponse( let response = HTTPURLResponse(
url: URL.mock(), url: responseTestURL,
statusCode: 200, httpVersion: nil, headerFields: [:] statusCode: 200, httpVersion: nil, headerFields: [:]
)! )!
let data = self.makeOPDSData(zimFileID: zimFileID).data(using: .utf8)! let data = self.makeOPDSData(zimFileID: zimFileID).data(using: .utf8)!
@ -211,7 +212,7 @@ final class LibraryRefreshViewModelTest: XCTestCase {
XCTAssertNil(zimFile.faviconData) XCTAssertNil(zimFile.faviconData)
XCTAssertEqual( XCTAssertEqual(
zimFile.faviconURL, zimFile.faviconURL,
URL(string: "https://library.kiwix.org/catalog/v2/illustration/1ec90eab-5724-492b-9529-893959520de4/") URL(string: "https://response-testing.com/catalog/v2/illustration/1ec90eab-5724-492b-9529-893959520de4/")
) )
XCTAssertEqual(zimFile.fileDescription, "A selection of the best 50,000 Wikipedia articles") XCTAssertEqual(zimFile.fileDescription, "A selection of the best 50,000 Wikipedia articles")
XCTAssertEqual(zimFile.fileID, zimFileID) XCTAssertEqual(zimFile.fileID, zimFileID)

View File

@ -22,7 +22,7 @@ final class OPDSParserTests: XCTestCase {
XCTExpectFailure("Requires work in dependency to resolve the issue.") XCTExpectFailure("Requires work in dependency to resolve the issue.")
let content = "Invalid OPDS Data" let content = "Invalid OPDS Data"
XCTAssertThrowsError( XCTAssertThrowsError(
try OPDSParser().parse(data: content.data(using: .utf8)!) try OPDSParser().parse(data: content.data(using: .utf8)!, urlHost: "")
) )
} }
@ -31,7 +31,7 @@ final class OPDSParserTests: XCTestCase {
let incompatibleEncodings: [String.Encoding] = [.unicode, .utf16, .utf32] let incompatibleEncodings: [String.Encoding] = [.unicode, .utf16, .utf32]
try incompatibleEncodings.forEach { encoding in try incompatibleEncodings.forEach { encoding in
XCTAssertThrowsError( XCTAssertThrowsError(
try OPDSParser().parse(data: content.data(using: encoding)!), try OPDSParser().parse(data: content.data(using: encoding)!, urlHost: ""),
"parsing with enconding \(encoding.description) should fail" "parsing with enconding \(encoding.description) should fail"
) )
} }
@ -73,10 +73,16 @@ final class OPDSParserTests: XCTestCase {
</entry> </entry>
</feed> </feed>
""" """
// Parse data // Parse data
let responseTestURL = URL(string: "https://resp-test.org/")!
let parser = OPDSParser() let parser = OPDSParser()
XCTAssertNoThrow(try parser.parse(data: content.data(using: .utf8)!)) XCTAssertNoThrow(
try parser.parse(
data: content.data(using: .utf8)!,
urlHost: responseTestURL.absoluteString
)
)
// check one zim file is populated // check one zim file is populated
let zimFileID = UUID(uuidString: "1ec90eab-5724-492b-9529-893959520de4")! let zimFileID = UUID(uuidString: "1ec90eab-5724-492b-9529-893959520de4")!
@ -108,7 +114,7 @@ final class OPDSParserTests: XCTestCase {
) )
XCTAssertEqual( XCTAssertEqual(
metadata.faviconURL, metadata.faviconURL,
URL(string: "https://library.kiwix.org/catalog/v2/illustration/1ec90eab-5724-492b-9529-893959520de4/") URL(string: "https://resp-test.org/catalog/v2/illustration/1ec90eab-5724-492b-9529-893959520de4/")
) )
XCTAssertEqual(metadata.flavor, "maxi") XCTAssertEqual(metadata.flavor, "maxi")
} }

View File

@ -43,5 +43,11 @@ final class URLContentPathTests: XCTestCase {
"widgets.wp.com/likes/master.html?ver=20240530" "widgets.wp.com/likes/master.html?ver=20240530"
]) ])
} }
func test_trimming() {
let inputURL = URL(string: "https://library.kiwix.org/catalog/v2/entries?count=-1")!
let expectedURL = URL(string: "https://library.kiwix.org/")!
XCTAssertEqual(inputURL.withoutQueryParams().trim(pathComponents: ["catalog", "v2", "entries"]), expectedURL)
}
} }

View File

@ -95,7 +95,7 @@ final class LibraryViewModel: ObservableObject {
private var insertionCount = 0 private var insertionCount = 0
private var deletionCount = 0 private var deletionCount = 0
private static let catalogURL = URL(string: "https://library.kiwix.org/catalog/v2/entries?count=-1")! private static let catalogURL = URL(string: "https://opds.library.kiwix.org/v2/entries?count=-1")!
@MainActor @MainActor
init( init(
@ -131,7 +131,7 @@ final class LibraryViewModel: ObservableObject {
process.state = .inProgress process.state = .inProgress
// refresh library // refresh library
guard let data = try await fetchData() else { guard case (var data, let responseURL)? = try await fetchData() else {
// this is the case when we have no new data (304 http) // this is the case when we have no new data (304 http)
// but we still need to refresh the memory only stored // but we still need to refresh the memory only stored
// zimfile categories to languages dictionary // zimfile categories to languages dictionary
@ -156,7 +156,7 @@ final class LibraryViewModel: ObservableObject {
return return
} }
} }
let parser = try await parse(data: data) let parser = try await parse(data: data, urlHost: responseURL)
// delete all old ISO Lang Code entries if needed, by passing in an empty parser // delete all old ISO Lang Code entries if needed, by passing in an empty parser
if defaults[.libraryUsingOldISOLangCodes] { if defaults[.libraryUsingOldISOLangCodes] {
try await process(parser: DeletingParser()) try await process(parser: DeletingParser())
@ -252,7 +252,7 @@ final class LibraryViewModel: ObservableObject {
} }
} }
private func fetchData() async throws -> Data? { private func fetchData() async throws -> (Data, URL)? {
do { do {
var request = URLRequest(url: Self.catalogURL, timeoutInterval: 20) var request = URLRequest(url: Self.catalogURL, timeoutInterval: 20)
request.allHTTPHeaderFields = ["If-None-Match": defaults[.libraryETag]] request.allHTTPHeaderFields = ["If-None-Match": defaults[.libraryETag]]
@ -260,19 +260,20 @@ final class LibraryViewModel: ObservableObject {
guard let response = response as? HTTPURLResponse else { return nil } guard let response = response as? HTTPURLResponse else { return nil }
switch response.statusCode { switch response.statusCode {
case 200: case 200:
let responseURL = response.url ?? Self.catalogURL
if let eTag = response.allHeaderFields["Etag"] as? String { if let eTag = response.allHeaderFields["Etag"] as? String {
defaults[.libraryETag] = eTag defaults[.libraryETag] = eTag
} }
// OK to process further // OK to process further
os_log("Retrieved OPDS Data, size: %llu bytes", log: Log.OPDS, type: .info, data.count) os_log("Retrieved OPDS Data, size: %llu bytes", log: Log.OPDS, type: .info, data.count)
return data return (data, responseURL)
case 304: case 304:
return nil // already downloaded return nil // already downloaded
default: default:
throw LibraryRefreshError.retrieve(description: "HTTP Status \(response.statusCode).") throw LibraryRefreshError.retrieve(description: "HTTP Status \(response.statusCode).")
} }
} catch { } catch {
os_log("Error retrieving OPDS Data: %s", log: Log.OPDS, type: .error) os_log("Error retrieving OPDS Data: %s", log: Log.OPDS, type: .error, error.localizedDescription)
if let error = error as? LibraryRefreshError { if let error = error as? LibraryRefreshError {
throw error throw error
} else { } else {
@ -281,11 +282,15 @@ final class LibraryViewModel: ObservableObject {
} }
} }
private func parse(data: Data) async throws -> OPDSParser { private func parse(data: Data, urlHost: URL) async throws -> OPDSParser {
try await withCheckedThrowingContinuation { continuation in try await withCheckedThrowingContinuation { continuation in
let parser = OPDSParser() let parser = OPDSParser()
do { do {
try parser.parse(data: data) let urlHostString = urlHost
.withoutQueryParams()
.trim(pathComponents: ["catalog", "v2", "entries"])
.absoluteString
try parser.parse(data: data, urlHost: urlHostString)
continuation.resume(returning: parser) continuation.resume(returning: parser)
} catch { } catch {
continuation.resume(throwing: error) continuation.resume(throwing: error)

View File

@ -63,7 +63,7 @@ struct Favicon_Previews: PreviewProvider {
category: .wikipedia, category: .wikipedia,
imageData: nil, imageData: nil,
imageURL: URL( imageURL: URL(
string: "https://library.kiwix.org/meta?name=favicon&content=wikipedia_en_climate_change_maxi_2021-12" string: "https://opds.library.kiwix.org/v2/illustration/e82e6816-a2dc-a7f0-2d15-58d24709db93/?size=48"
)! )!
).frame(width: 200, height: 200).previewLayout(.sizeThatFits) ).frame(width: 200, height: 200).previewLayout(.sizeThatFits)
Favicon( Favicon(