Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions InternetArchiveKit/InternetArchive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,32 @@ public final class InternetArchive: InternetArchiveProtocol, @unchecked Sendable
return await makeRequest(url: metadataUrl)
}

/** @inheritdoc */
public func simpleLists(
identifier: String
) async -> Result<SimpleListsResponse, Error> {
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,
Expand All @@ -259,7 +285,10 @@ public final class InternetArchive: InternetArchiveProtocol, @unchecked Sendable
}
}

private func makeRequest<T>(url: URL) async -> Result<T, Error>
private func makeRequest<T>(
url: URL,
decoder: ZippyJSONDecoder? = nil
) async -> Result<T, Error>
where T: Decodable {
os_log(
.info,
Expand All @@ -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(
Expand All @@ -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<T>(_ data: Data) throws -> T
private func decodeResponse<T>(_ 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
Expand Down
42 changes: 42 additions & 0 deletions InternetArchiveKit/InternetArchiveProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
- sortFields: The fields by which you want to sort the results as an `InternetArchiveURLQueryItemProtocol` object
- completion: Returns optional `InternetArchive.SearchResponse` and `Error` objects
*/
func search(

Check warning on line 68 in InternetArchiveKit/InternetArchiveProtocols.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Function should have 5 parameters or less: it currently has 6 (function_parameter_count)
query: InternetArchiveURLStringProtocol,
page: Int,
rows: Int,
Expand Down Expand Up @@ -177,6 +177,32 @@
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<InternetArchive.SimpleListsResponse, Error>
*/
func simpleLists(
identifier: String
) async -> Result<InternetArchive.SimpleListsResponse, Error>

/**
Fetch a single item from the Internet Archive

Expand Down Expand Up @@ -218,7 +244,8 @@
func generateItemImageUrl(itemIdentifier: String) -> URL?
func generateMetadataUrl(identifier: String) -> URL?
func generateDownloadUrl(itemIdentifier: String, fileName: String) -> URL?
func generateSimpleListsUrl(identifier: String) -> URL?
func generateSearchUrl(

Check warning on line 248 in InternetArchiveKit/InternetArchiveProtocols.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Function should have 5 parameters or less: it currently has 6 (function_parameter_count)
query: InternetArchiveURLStringProtocol,
page: Int,
rows: Int,
Expand Down Expand Up @@ -307,10 +334,25 @@
}
}

/** @inheritdoc */
public func simpleLists(
identifier: String
) async throws -> InternetArchive.SimpleListsResponse {
let result: Result<InternetArchive.SimpleListsResponse, Error> = 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
{

Check warning on line 355 in InternetArchiveKit/InternetArchiveProtocols.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Opening braces should be preceded by a single space and on the same line as the declaration (opening_brace)
let result: Result<InternetArchive.Item, Error> = await itemDetail(
identifier: identifier
)
Expand Down
15 changes: 15 additions & 0 deletions InternetArchiveKit/InternetArchiveURLGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@
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

Expand All @@ -62,13 +77,13 @@
*/
public func generateDownloadUrl(itemIdentifier: String, fileName: String)
-> URL?
{

Check warning on line 80 in InternetArchiveKit/InternetArchiveURLGenerator.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Opening braces should be preceded by a single space and on the same line as the declaration (opening_brace)
var urlComponents: URLComponents = getBaseUrlComponents()
urlComponents.path = "/download/\(itemIdentifier)/\(fileName)"
return urlComponents.url
}

public func generateSearchUrl(

Check warning on line 86 in InternetArchiveKit/InternetArchiveURLGenerator.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Function should have 5 parameters or less: it currently has 6 (function_parameter_count)
query: InternetArchiveURLStringProtocol,
page: Int,
rows: Int,
Expand All @@ -87,7 +102,7 @@
URLQueryItem(name: "q", value: query.asURLString),
URLQueryItem(name: "output", value: "json"),
URLQueryItem(name: "rows", value: "\(rows)"),
URLQueryItem(name: "page", value: "\(page)"),

Check warning on line 105 in InternetArchiveKit/InternetArchiveURLGenerator.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Collection literals should not have trailing commas (trailing_comma)
]

var urlComponents: URLComponents = getBaseUrlComponents()
Expand Down
47 changes: 47 additions & 0 deletions InternetArchiveKit/Models/SimpleListsResponse.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
4 changes: 4 additions & 0 deletions InternetArchiveKitTests/InternetArchiveKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
108 changes: 108 additions & 0 deletions InternetArchiveKitTests/SimpleListsTests.swift
Original file line number Diff line number Diff line change
@@ -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")
)
}
}
}
Loading