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
52 changes: 52 additions & 0 deletions InternetArchiveKit/InternetArchive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
/// // debugPrint(error)
/// }
/// ```
public final class InternetArchive: InternetArchiveProtocol, @unchecked Sendable {

Check failure on line 39 in InternetArchiveKit/InternetArchive.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Class body should span 350 lines or less excluding comments and whitespace: currently spans 355 lines (type_body_length)
// Safe to share across concurrency domains: every stored property is a `let`
// and requests run through the thread-safe `URLSession`. `@unchecked` is only
// needed because the injected URL generator and JSON decoder aren't `Sendable`.
Expand Down Expand Up @@ -310,6 +310,58 @@
}
}

/** @inheritdoc */
public func changes(
start: ChangesStart? = nil
) async -> Result<ChangesResponse, Error> {
guard let credentials = credentials else {
return .failure(InternetArchiveError.missingCredentials)
}
guard let changesUrl: URL = urlGenerator.generateChangesUrl() else {
return .failure(InternetArchiveError.invalidUrl)
}

var fields: [(String, String)] = [
("access", credentials.accessKey),
("secret", credentials.secretKey),
]
switch start {
case .coldStart:
fields.append(("start_date", "0"))
case .startDate(let date):
fields.append(("start_date", Self.changesDateFormatter.string(from: date)))
case .token(let token):
fields.append(("token", token))
case nil:
break
}

// built by hand rather than through makeRequest: the body carries the
// secret key, so nothing in this path is logged
var request = URLRequest(url: changesUrl)
request.httpMethod = "POST"
request.setValue(
"application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpBody = Self.formEncode(fields)

do {
let (data, _) = try await urlSession.data(for: request)
let results: ChangesResponse = try decodeResponse(data)
return .success(results)
} catch {
return .failure(error)
}
}

/// Formats `ChangesStart.startDate` dates as the API's `YYYYMMDD`
private static let changesDateFormatter: DateFormatter = {
let dateFormatter: DateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.dateFormat = "yyyyMMdd"
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
return dateFormatter
}()

/// The request for `url` with the configured credentials attached, if any
private func authorizedRequest(url: URL) -> URLRequest {
var request = URLRequest(url: url)
Expand Down
6 changes: 6 additions & 0 deletions InternetArchiveKit/InternetArchiveErrors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ extension InternetArchive {
/// `identifier`, if it appears in a scrape sort, to be the last sort field.
/// `message` explains what to fix.
case invalidSortFields(message: String)

/// The request needs credentials and this `InternetArchive` instance was
/// created without them. Pass `Credentials` at init.
case missingCredentials
}
}

Expand All @@ -40,6 +44,8 @@ extension InternetArchive.InternetArchiveError: LocalizedError {
return "Internet Archive API error: \(message)"
case .invalidSortFields(let message):
return "Invalid sort fields: \(message)"
case .missingCredentials:
return "This request requires credentials"
}
}
}
Expand Down
42 changes: 42 additions & 0 deletions InternetArchiveKit/InternetArchiveProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,32 @@ public protocol InternetArchiveProtocol {
password: String
) async -> Result<InternetArchive.Account, Error>

/**
Fetch items changed since a point in the Changes API's feed

Requires credentials. Start with `.coldStart` to enumerate everything,
`.startDate` for changes since a date, or nil for the current head; then
pass each response's `nextToken` back as `.token` to keep reading.

- parameters:
- start: Where to read from, or nil for the current head
- returns: InternetArchive.ChangesResponse
*/
func changes(
start: InternetArchive.ChangesStart?
) async throws -> InternetArchive.ChangesResponse

/**
Fetch items changed since a point in the Changes API's feed

- parameters:
- start: Where to read from, or nil for the current head
- returns: Result<InternetArchive.ChangesResponse, Error>
*/
func changes(
start: InternetArchive.ChangesStart?
) async -> Result<InternetArchive.ChangesResponse, Error>

/**
Fetch a single item from the Internet Archive

Expand Down Expand Up @@ -249,6 +275,7 @@ public protocol InternetArchiveURLGeneratorProtocol {
func generateItemImageUrl(itemIdentifier: String) -> URL?
func generateMetadataUrl(identifier: String) -> URL?
func generateXauthnUrl(operation: String) -> URL?
func generateChangesUrl() -> URL?
func generateDownloadUrl(itemIdentifier: String, fileName: String) -> URL?
func generateSearchUrl(
query: InternetArchiveURLStringProtocol,
Expand Down Expand Up @@ -356,6 +383,21 @@ extension InternetArchiveProtocol {
}
}

/** @inheritdoc */
public func changes(
start: InternetArchive.ChangesStart?
) async throws -> InternetArchive.ChangesResponse {
let result: Result<InternetArchive.ChangesResponse, Error> = await changes(
start: start
)
switch result {
case .success(let success):
return success
case .failure(let error):
throw error
}
}

/** @inheritdoc */
public func itemDetail(identifier: String) async throws
-> InternetArchive.Item
Expand Down
16 changes: 16 additions & 0 deletions InternetArchiveKit/InternetArchiveURLGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,13 @@
*/
public func generateDownloadUrl(itemIdentifier: String, fileName: String)
-> URL?
{

Check warning on line 65 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 71 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 +87,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 90 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 Expand Up @@ -184,6 +184,22 @@
return urlComponents.url
}

/**
Generate a Changes API (`/changes/v1`) url

The Changes API lives on the `be-api.us.archive.org` host. Credentials
and paging parameters travel in the POST body, so the url is static.

- returns: Optional changes `URL`
*/
public func generateChangesUrl() -> URL? {
var urlComponents: URLComponents = URLComponents()
urlComponents.scheme = scheme
urlComponents.host = "be-api.us.archive.org"
urlComponents.path = "/changes/v1"
return urlComponents.url
}

private func getBaseUrlComponents() -> URLComponents {
var urlComponents: URLComponents = URLComponents()
urlComponents.scheme = scheme
Expand Down
66 changes: 66 additions & 0 deletions InternetArchiveKit/Models/ChangesResponse.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// ChangesResponse.swift
// InternetArchiveKit
//
// Created by Jason Buckner on 7/17/26.
// Copyright 漏 2026 Jason Buckner. All rights reserved.
//

import Foundation

extension InternetArchive {
/**
Where a `changes()` request starts reading the change feed.

Pass nil to `changes(start:)` to read from the current head. Use
`.coldStart` to enumerate every item, `.startDate` for changes since a
date, and `.token` to continue from a previous response's `nextToken`.
*/
public enum ChangesStart: Sendable {
/// Enumerate all items from the beginning, then stream changes
case coldStart
/// Changes since the given date (sent as `YYYYMMDD`, UTC)
case startDate(Date)
/// Continue from a previous response's `nextToken`
case token(String)
}

/**
One batch from the Changes API.

Pass `nextToken` back to `changes(start: .token(...))` to fetch the next
batch. `doSleepBeforeReturning` is the API's advice to pause before the
next request.
*/
public struct ChangesResponse: Decodable, Sendable {
/// One changed item
public struct Change: Decodable, Sendable {
public let identifier: String

public init(identifier: String) {
self.identifier = identifier
}
}

/// The items that changed in this batch
public let changes: [Change]
/// The token for the next batch
public let nextToken: String?
/// Roughly how far this batch is from the head of the feed
public let estimatedDistanceFromHead: Int?
/// Whether the API advises pausing before the next request
public let doSleepBeforeReturning: Bool?

public init(
changes: [Change],
nextToken: String? = nil,
estimatedDistanceFromHead: Int? = nil,
doSleepBeforeReturning: Bool? = nil
) {
self.changes = changes
self.nextToken = nextToken
self.estimatedDistanceFromHead = estimatedDistanceFromHead
self.doSleepBeforeReturning = doSleepBeforeReturning
}
}
}
83 changes: 83 additions & 0 deletions InternetArchiveKitTests/ChangesTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//
// ChangesTests.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 ChangesTests: XCTestCase {

func testGenerateChangesUrl() {
let generator = InternetArchive.URLGenerator()
XCTAssertEqual(
generator.generateChangesUrl()?.absoluteString,
"https://be-api.us.archive.org/changes/v1"
)
}

func testChangesRequiresCredentials() async {
let archive = InternetArchive(
urlGenerator: InternetArchive.URLGenerator(),
urlSession: URLSession.mock
)
let result = await archive.changes()
switch result {
case .success:
XCTFail("expected a failure")
case .failure(let error):
XCTAssertEqual(
error as? InternetArchive.InternetArchiveError,
InternetArchive.InternetArchiveError.missingCredentials
)
}
}

func testChangesDecodesBatch() async {
let json = """
{
"changes": [{"identifier": "foo"}, {"identifier": "bar_baz"}],
"next_token": "tok123",
"estimated_distance_from_head": 5,
"do_sleep_before_returning": false
}
"""
let urlGenerator = InternetArchive.URLGenerator()
guard let url = urlGenerator.generateChangesUrl() else {
XCTFail("error generating changes url")
return
}
let endpoint = BasicEndpointMock(
status: 200, url: url, body: Data(json.utf8), headers: nil, error: nil)
URLSession.mockEndpoints = [url: endpoint]

let archive = InternetArchive(
urlGenerator: urlGenerator,
urlSession: URLSession.mock,
credentials: InternetArchive.Credentials(
accessKey: "accessfoo", secretKey: "secretbar")
)
let result = await archive.changes(start: .coldStart)

switch result {
case .success(let response):
XCTAssertEqual(response.changes.map { $0.identifier }, ["foo", "bar_baz"])
XCTAssertEqual(response.nextToken, "tok123")
XCTAssertEqual(response.estimatedDistanceFromHead, 5)
XCTAssertEqual(response.doSleepBeforeReturning, false)
case .failure(let error):
XCTFail("error, \(error.localizedDescription)")
}

let request = endpoint.requests.first
XCTAssertEqual(request?.httpMethod, "POST")
XCTAssertEqual(
request?.value(forHTTPHeaderField: "Content-Type"),
"application/x-www-form-urlencoded"
)
}
}
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 generateChangesUrl() -> URL? {
return nil
}

func generateDownloadUrl(itemIdentifier: String, fileName: String) -> URL? {
return nil
}
Expand Down
Loading