diff --git a/InternetArchiveKit/InternetArchive.swift b/InternetArchiveKit/InternetArchive.swift index 433b2c1..7a2f6e9 100644 --- a/InternetArchiveKit/InternetArchive.swift +++ b/InternetArchiveKit/InternetArchive.swift @@ -259,7 +259,31 @@ public final class InternetArchive: InternetArchiveProtocol, @unchecked Sendable } } - private func makeRequest(url: URL) async -> Result + /** @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( + url: URL, + decoder: ZippyJSONDecoder? = nil + ) async -> Result where T: Decodable { os_log( .info, @@ -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( @@ -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(_ 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 @@ -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( diff --git a/InternetArchiveKit/InternetArchiveProtocols.swift b/InternetArchiveKit/InternetArchiveProtocols.swift index 66c4142..c4eebbe 100644 --- a/InternetArchiveKit/InternetArchiveProtocols.swift +++ b/InternetArchiveKit/InternetArchiveProtocols.swift @@ -177,6 +177,31 @@ public protocol InternetArchiveProtocol { 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 @@ -226,6 +251,7 @@ public protocol InternetArchiveURLGeneratorProtocol { sortFields: [InternetArchiveURLQueryItemProtocol], additionalQueryParams: [URLQueryItem] ) -> URL? + func generateViewsUrl(identifiers: [String]) -> URL? func generateScrapeUrl( query: InternetArchiveURLStringProtocol, fields: [String], @@ -307,6 +333,21 @@ extension InternetArchiveProtocol { } } + /** @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 diff --git a/InternetArchiveKit/InternetArchiveURLGenerator.swift b/InternetArchiveKit/InternetArchiveURLGenerator.swift index 1db9a96..bb6e24d 100644 --- a/InternetArchiveKit/InternetArchiveURLGenerator.swift +++ b/InternetArchiveKit/InternetArchiveURLGenerator.swift @@ -18,9 +18,14 @@ extension InternetArchive { /// 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 } /** @@ -169,6 +174,26 @@ extension InternetArchive { 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 @@ -227,6 +252,7 @@ extension InternetArchive { private let host: String private let scheme: String + private let statsHost: String private let log: OSLog = OSLog( subsystem: logSubsystemId, diff --git a/InternetArchiveKit/Models/ItemViews.swift b/InternetArchiveKit/Models/ItemViews.swift new file mode 100644 index 0000000..5285445 --- /dev/null +++ b/InternetArchiveKit/Models/ItemViews.swift @@ -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 + } + } +} diff --git a/InternetArchiveKitTests/InternetArchiveKitTests.swift b/InternetArchiveKitTests/InternetArchiveKitTests.swift index 13bdfd5..16cf00b 100644 --- a/InternetArchiveKitTests/InternetArchiveKitTests.swift +++ b/InternetArchiveKitTests/InternetArchiveKitTests.swift @@ -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() { diff --git a/InternetArchiveKitTests/ViewsTests.swift b/InternetArchiveKitTests/ViewsTests.swift new file mode 100644 index 0000000..436764d --- /dev/null +++ b/InternetArchiveKitTests/ViewsTests.swift @@ -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) + } +}