diff --git a/InternetArchiveKit/InternetArchive.swift b/InternetArchiveKit/InternetArchive.swift index 433b2c1..61fcbe7 100644 --- a/InternetArchiveKit/InternetArchive.swift +++ b/InternetArchiveKit/InternetArchive.swift @@ -241,6 +241,32 @@ public final class InternetArchive: InternetArchiveProtocol, @unchecked Sendable return await makeRequest(url: metadataUrl) } + /** @inheritdoc */ + public func simpleLists( + identifier: String + ) async -> Result { + guard + let simpleListsUrl: URL = urlGenerator.generateSimpleListsUrl( + identifier: identifier + ) + else { + os_log( + .error, + log: log, + "simpleLists error generating url, identifier: %{public}@", + identifier + ) + return .failure(InternetArchiveError.invalidUrl) + } + + return await makeRequest(url: simpleListsUrl, decoder: simpleListsDecoder) + } + + // simple lists responses are keyed by raw list names and parent + // identifiers, and convertFromSnakeCase would mangle ones containing + // underscores, so they decode with explicit CodingKeys instead + private let simpleListsDecoder: ZippyJSONDecoder = ZippyJSONDecoder() + /** @inheritdoc */ public func itemDetail( identifier: String, @@ -259,7 +285,10 @@ public final class InternetArchive: InternetArchiveProtocol, @unchecked Sendable } } - private func makeRequest(url: URL) async -> Result + private func makeRequest( + url: URL, + decoder: ZippyJSONDecoder? = nil + ) async -> Result where T: Decodable { os_log( .info, @@ -279,7 +308,7 @@ public final class InternetArchive: InternetArchiveProtocol, @unchecked Sendable timeElapsed, url.absoluteString ) - let results: T = try decodeResponse(data) + let results: T = try decodeResponse(data, decoder: decoder ?? jsonDecoder) return .success(results) } catch { os_log( @@ -296,10 +325,10 @@ public final class InternetArchive: InternetArchiveProtocol, @unchecked Sendable /// HTTP-200 error envelope (`{"error": "…"}`), surface the API's /// message as `InternetArchiveError.apiError` instead of the /// shape-mismatch decoding error it would otherwise cause. - private func decodeResponse(_ data: Data) throws -> T + private func decodeResponse(_ data: Data, decoder: ZippyJSONDecoder) throws -> T where T: Decodable { do { - return try jsonDecoder.decode(T.self, from: data) + return try decoder.decode(T.self, from: data) } catch { if let envelope = try? jsonDecoder.decode( APIErrorEnvelope.self, from: data diff --git a/InternetArchiveKit/InternetArchiveProtocols.swift b/InternetArchiveKit/InternetArchiveProtocols.swift index 66c4142..baece3e 100644 --- a/InternetArchiveKit/InternetArchiveProtocols.swift +++ b/InternetArchiveKit/InternetArchiveProtocols.swift @@ -177,6 +177,32 @@ public protocol InternetArchiveProtocol { completion: @escaping (Int?, Error?) -> Void ) + /** + Fetch an item's simple list memberships + + Returns the lists the item belongs to, keyed by list name and then by + parent item identifier. Items with no list memberships get an API error + back from archive.org, surfaced as `InternetArchiveError.apiError`. + + - parameters: + - identifier: The item identifier + - returns: InternetArchive.SimpleListsResponse + */ + func simpleLists( + identifier: String + ) async throws -> InternetArchive.SimpleListsResponse + + /** + Fetch an item's simple list memberships + + - parameters: + - identifier: The item identifier + - returns: Result + */ + func simpleLists( + identifier: String + ) async -> Result + /** Fetch a single item from the Internet Archive @@ -218,6 +244,7 @@ public protocol InternetArchiveURLGeneratorProtocol { func generateItemImageUrl(itemIdentifier: String) -> URL? func generateMetadataUrl(identifier: String) -> URL? func generateDownloadUrl(itemIdentifier: String, fileName: String) -> URL? + func generateSimpleListsUrl(identifier: String) -> URL? func generateSearchUrl( query: InternetArchiveURLStringProtocol, page: Int, @@ -307,6 +334,21 @@ extension InternetArchiveProtocol { } } + /** @inheritdoc */ + public func simpleLists( + identifier: String + ) async throws -> InternetArchive.SimpleListsResponse { + let result: Result = await simpleLists( + identifier: identifier + ) + switch result { + case .success(let success): + return success + case .failure(let error): + throw error + } + } + /** @inheritdoc */ public func itemDetail(identifier: String) async throws -> InternetArchive.Item diff --git a/InternetArchiveKit/InternetArchiveURLGenerator.swift b/InternetArchiveKit/InternetArchiveURLGenerator.swift index 1db9a96..d04b591 100644 --- a/InternetArchiveKit/InternetArchiveURLGenerator.swift +++ b/InternetArchiveKit/InternetArchiveURLGenerator.swift @@ -51,6 +51,21 @@ extension InternetArchive { return urlComponents.url } + /** + Generate the simple lists url for an Internet Archive item + (`/metadata/{identifier}/simplelists`) + + - parameters: + - identifier: The item identifier + + - returns: Optional simple lists `URL` + */ + public func generateSimpleListsUrl(identifier: String) -> URL? { + var urlComponents: URLComponents = getBaseUrlComponents() + urlComponents.path = "/metadata/\(identifier)/simplelists" + return urlComponents.url + } + /** Generate the download url for an Internet Archive file diff --git a/InternetArchiveKit/Models/SimpleListsResponse.swift b/InternetArchiveKit/Models/SimpleListsResponse.swift new file mode 100644 index 0000000..d310b7a --- /dev/null +++ b/InternetArchiveKit/Models/SimpleListsResponse.swift @@ -0,0 +1,47 @@ +// +// SimpleListsResponse.swift +// InternetArchiveKit +// +// Created by Jason Buckner on 7/17/26. +// Copyright © 2026 Jason Buckner. All rights reserved. +// + +import Foundation + +extension InternetArchive { + /** + An item's simple list memberships, from + `/metadata/{identifier}/simplelists`. + + `result` is keyed by list name, then by parent item identifier. To go the + other way (all members of a list), use `search()` with a + `simplelists__{list-name}:{parent-item}` query clause. + */ + public struct SimpleListsResponse: Decodable, Sendable { + /// list name → parent item identifier → membership + public let result: [String: [String: SimpleListMembership]] + + public init(result: [String: [String: SimpleListMembership]]) { + self.result = result + } + } + + /** + One membership entry in a simple list. + + The API also serves a free-form `notes` blob per membership; it has no + documented shape, so it isn't modeled here. + */ + public struct SimpleListMembership: Decodable, Sendable { + /// When the membership last changed, e.g. `2020-04-14 08:27:01.453137` + public let sysLastChanged: String? + + enum CodingKeys: String, CodingKey { + case sysLastChanged = "sys_last_changed" + } + + public init(sysLastChanged: String?) { + self.sysLastChanged = sysLastChanged + } + } +} diff --git a/InternetArchiveKitTests/InternetArchiveKitTests.swift b/InternetArchiveKitTests/InternetArchiveKitTests.swift index 13bdfd5..188edf3 100644 --- a/InternetArchiveKitTests/InternetArchiveKitTests.swift +++ b/InternetArchiveKitTests/InternetArchiveKitTests.swift @@ -32,6 +32,10 @@ class InternetArchiveKitTests: XCTestCase { return nil } + func generateSimpleListsUrl(identifier: String) -> URL? { + return nil + } + func generateSearchUrl(query: InternetArchiveURLStringProtocol, page: Int, rows: Int, fields: [String], sortFields: [InternetArchiveURLQueryItemProtocol], additionalQueryParams: [URLQueryItem]) -> URL? { return nil } diff --git a/InternetArchiveKitTests/SimpleListsTests.swift b/InternetArchiveKitTests/SimpleListsTests.swift new file mode 100644 index 0000000..bf4554a --- /dev/null +++ b/InternetArchiveKitTests/SimpleListsTests.swift @@ -0,0 +1,108 @@ +// +// SimpleListsTests.swift +// InternetArchiveKitTests +// +// Created by Jason Buckner on 7/17/26. +// Copyright © 2026 Jason Buckner. All rights reserved. +// + +import XCTest +import URLSessionMock +@testable import InternetArchiveKit + +class SimpleListsTests: XCTestCase { + + func testGenerateSimpleListsUrl() { + let generator = InternetArchive.URLGenerator() + XCTAssertEqual( + generator.generateSimpleListsUrl(identifier: "foo")?.absoluteString, + "https://archive.org/metadata/foo/simplelists" + ) + } + + // list names and parent identifiers can contain underscores, so simple + // lists responses decode without the snake-case key strategy + func testSimpleListsDecodingPreservesKeys() async { + let json: String = """ + { + "result": { + "holdings": { + "library_of_atlantis": { + "notes": {"isbn": ["123"]}, + "sys_changed_by": {"source": "mdapi"}, + "sys_last_changed": "2020-04-14 08:27:01.453137" + } + } + } + } + """ + guard let data: Data = json.data(using: .utf8) else { + XCTFail("error encoding json to data") + return + } + + let urlGenerator = InternetArchive.URLGenerator() + guard let url = urlGenerator.generateSimpleListsUrl(identifier: "child_item") else { + XCTFail("error generating simple lists url") + return + } + + let endpoint = BasicEndpointMock( + status: 200, url: url, body: data, headers: nil, error: nil) + URLSession.mockEndpoints = [url: endpoint] + + let archive = InternetArchive( + urlGenerator: urlGenerator, urlSession: URLSession.mock) + let result = await archive.simpleLists(identifier: "child_item") + + switch result { + case .success(let response): + let membership = response.result["holdings"]?["library_of_atlantis"] + XCTAssertNotNil(membership) + XCTAssertEqual(membership?.sysLastChanged, "2020-04-14 08:27:01.453137") + case .failure(let error): + XCTFail("error, \(error.localizedDescription)") + } + } + + func testSimpleListsLive() async throws { + // the example item from archive.org's simple lists docs + let response: InternetArchive.SimpleListsResponse = + try await InternetArchive().simpleLists(identifier: "isbn_9780920303122") + XCTAssertNotNil(response.result["holdings"]) + } + + func testSimpleListsItemWithoutListsSurfacesApiError() async { + let json: String = """ + {"error": "Couldn't get 'simplelists' for item foo"} + """ + guard let data: Data = json.data(using: .utf8) else { + XCTFail("error encoding json to data") + return + } + + let urlGenerator = InternetArchive.URLGenerator() + guard let url = urlGenerator.generateSimpleListsUrl(identifier: "foo") else { + XCTFail("error generating simple lists url") + return + } + let endpoint = BasicEndpointMock( + status: 200, url: url, body: data, headers: nil, error: nil) + URLSession.mockEndpoints = [url: endpoint] + + let archive = InternetArchive( + urlGenerator: urlGenerator, urlSession: URLSession.mock) + let result = await archive.simpleLists(identifier: "foo") + + switch result { + case .success: + XCTFail("expected a failure") + case .failure(let error): + XCTAssertEqual( + error as? InternetArchive.InternetArchiveError, + InternetArchive.InternetArchiveError.apiError( + message: "Couldn't get 'simplelists' for item foo") + ) + } + } +}