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 @@ -259,7 +259,31 @@ public final class InternetArchive: InternetArchiveProtocol, @unchecked Sendable
}
}

private func makeRequest<T>(url: URL) async -> Result<T, Error>
/** @inheritdoc */
public func views(
identifiers: [String]
) async -> Result<[String: ItemViews], Error> {
guard
let viewsUrl: URL = urlGenerator.generateViewsUrl(
identifiers: identifiers
)
else {
os_log(
.error,
log: log,
"Error generating views url, identifiers: %{public}@",
identifiers.joined(separator: ",")
)
return .failure(InternetArchiveError.invalidUrl)
}

return await makeRequest(url: viewsUrl, decoder: viewsDecoder)
}

private func makeRequest<T>(
url: URL,
decoder: ZippyJSONDecoder? = nil
) async -> Result<T, Error>
where T: Decodable {
os_log(
.info,
Expand All @@ -279,7 +303,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 +320,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 All @@ -310,6 +334,11 @@ public final class InternetArchive: InternetArchiveProtocol, @unchecked Sendable
}
}

// the Views service response is keyed by raw identifiers, and
// convertFromSnakeCase would mangle identifiers containing underscores,
// so views requests decode with ItemViews' explicit CodingKeys instead
private let viewsDecoder: ZippyJSONDecoder = ZippyJSONDecoder()

private let urlSession: URLSession

private let log: OSLog = OSLog(
Expand Down
41 changes: 41 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,31 @@
completion: @escaping (Int?, Error?) -> Void
)

/**
Fetch view counts for items from the Views Data Service

Returns per-item view stats (all time, last 30 days, last 7 days) keyed by
identifier.

- parameters:
- identifiers: The item identifiers to fetch view counts for
- returns: [String: InternetArchive.ItemViews]
*/
func views(
identifiers: [String]
) async throws -> [String: InternetArchive.ItemViews]

/**
Fetch view counts for items from the Views Data Service

- parameters:
- identifiers: The item identifiers to fetch view counts for
- returns: Result<[String: InternetArchive.ItemViews], Error>
*/
func views(
identifiers: [String]
) async -> Result<[String: InternetArchive.ItemViews], Error>

/**
Fetch a single item from the Internet Archive

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

Check warning on line 246 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 All @@ -226,6 +251,7 @@
sortFields: [InternetArchiveURLQueryItemProtocol],
additionalQueryParams: [URLQueryItem]
) -> URL?
func generateViewsUrl(identifiers: [String]) -> URL?
func generateScrapeUrl(
query: InternetArchiveURLStringProtocol,
fields: [String],
Expand Down Expand Up @@ -307,10 +333,25 @@
}
}

/** @inheritdoc */
public func views(
identifiers: [String]
) async throws -> [String: InternetArchive.ItemViews] {
let result: Result<[String: InternetArchive.ItemViews], Error> = await views(
identifiers: identifiers
)
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 354 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
28 changes: 27 additions & 1 deletion InternetArchiveKit/InternetArchiveURLGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@
/// single clause should chunk against it.
public static let recommendedMaxQueryLength: Int = 1_800

public init(host: String = "archive.org", scheme: String = "https") {
public init(
host: String = "archive.org",
scheme: String = "https",
statsHost: String = "be-api.us.archive.org"
) {
self.host = host
self.scheme = scheme
self.statsHost = statsHost
}

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

Check warning on line 70 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 76 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 +92,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 95 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 @@ -169,6 +174,26 @@
return urlComponents.url
}

/**
Generate a Views Data Service (`/views/v1/short`) url

The Views service lives on its own host (`statsHost`), not the main
archive.org host.

- parameters:
- identifiers: The item identifiers to fetch view counts for

- returns: Optional views `URL`
*/
public func generateViewsUrl(identifiers: [String]) -> URL? {
guard !identifiers.isEmpty else { return nil }
var urlComponents: URLComponents = URLComponents()
urlComponents.scheme = scheme
urlComponents.host = statsHost
urlComponents.path = "/views/v1/short/\(identifiers.joined(separator: ","))"
return urlComponents.url
}

private func getBaseUrlComponents() -> URLComponents {
var urlComponents: URLComponents = URLComponents()
urlComponents.scheme = scheme
Expand Down Expand Up @@ -227,6 +252,7 @@

private let host: String
private let scheme: String
private let statsHost: String

private let log: OSLog = OSLog(
subsystem: logSubsystemId,
Expand Down
41 changes: 41 additions & 0 deletions InternetArchiveKit/Models/ItemViews.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// ItemViews.swift
// InternetArchiveKit
//
// Created by Jason Buckner on 7/17/26.
// Copyright © 2026 Jason Buckner. All rights reserved.
//

import Foundation

extension InternetArchive {
/**
View counts for a single item, from the Views Data Service.

This will be returned from a `views()` request, keyed by item identifier.
*/
public struct ItemViews: Decodable, Sendable {
/// Whether the service has view data for the item
public let haveData: Bool
/// Views across all time
public let allTime: Int
/// Views in the last 30 days
public let last30Day: Int
/// Views in the last 7 days
public let last7Day: Int

enum CodingKeys: String, CodingKey {
case haveData = "have_data"
case allTime = "all_time"
case last30Day = "last_30day"
case last7Day = "last_7day"
}

public init(haveData: Bool, allTime: Int, last30Day: Int, last7Day: Int) {
self.haveData = haveData
self.allTime = allTime
self.last30Day = last30Day
self.last7Day = last7Day
}
}
}
4 changes: 4 additions & 0 deletions InternetArchiveKitTests/InternetArchiveKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ class InternetArchiveKitTests: XCTestCase {
func generateScrapeUrl(query: InternetArchiveURLStringProtocol, fields: [String], sortFields: [InternetArchiveURLQueryItemProtocol], pagination: InternetArchive.ScrapePagination?, additionalQueryParams: [URLQueryItem]) -> URL? {
return nil
}

func generateViewsUrl(identifiers: [String]) -> URL? {
return nil
}
}

func testBadSearchUrl() {
Expand Down
69 changes: 69 additions & 0 deletions InternetArchiveKitTests/ViewsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// ViewsTests.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 ViewsTests: XCTestCase {

func testGenerateViewsUrl() {
let generator = InternetArchive.URLGenerator()
XCTAssertEqual(
generator.generateViewsUrl(identifiers: ["foo", "bar_baz"])?.absoluteString,
"https://be-api.us.archive.org/views/v1/short/foo,bar_baz"
)
XCTAssertNil(generator.generateViewsUrl(identifiers: []))
}

// identifiers can contain underscores, so views responses decode without
// the snake-case key strategy that would mangle them
func testViewsDecodingPreservesIdentifierKeys() async {
let json: String = """
{"foo_bar": {"have_data": true, "all_time": 10, "last_30day": 2, "last_7day": 1}}
"""
guard let data: Data = json.data(using: .utf8) else {
XCTFail("error encoding json to data")
return
}

let urlGenerator = InternetArchive.URLGenerator()
guard let url = urlGenerator.generateViewsUrl(identifiers: ["foo_bar"]) else {
XCTFail("error generating views 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.views(identifiers: ["foo_bar"])
switch result {
case .success(let views):
XCTAssertEqual(views["foo_bar"]?.haveData, true)
XCTAssertEqual(views["foo_bar"]?.allTime, 10)
XCTAssertEqual(views["foo_bar"]?.last30Day, 2)
XCTAssertEqual(views["foo_bar"]?.last7Day, 1)
case .failure(let error):
XCTFail("error, \(error.localizedDescription)")
}
}

func testViewsLive() async throws {
let views: [String: InternetArchive.ItemViews] = try await InternetArchive().views(
identifiers: ["gd73-06-10.sbd.hollister.174.sbeok.shnf"])
guard let itemViews = views["gd73-06-10.sbd.hollister.174.sbeok.shnf"] else {
XCTFail("no views entry for the requested identifier")
return
}
XCTAssertTrue(itemViews.haveData)
XCTAssertTrue(itemViews.allTime > 0)
}
}
Loading