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
88 changes: 88 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 391 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 @@ -124,6 +124,94 @@
}
}

/** @inheritdoc */
public func submitReview(
identifier: String,
title: String,
body: String,
stars: Int? = nil
) async -> Result<ReviewWriteResult, Error> {
await reviewWriteRequest(identifier: identifier, method: "POST") {
try JSONEncoder().encode(
ReviewSubmission(title: title, body: body, stars: stars))
}
}

/** @inheritdoc */
public func deleteReview(
identifier: String
) async -> Result<ReviewWriteResult, Error> {
await reviewWriteRequest(identifier: identifier, method: "DELETE", body: nil)
}

/** @inheritdoc */
public func myReview(
identifier: String
) async -> Result<Review, Error> {
guard credentials != nil else {
return .failure(InternetArchiveError.missingCredentials)
}
guard
let reviewsUrl: URL = urlGenerator.generateReviewsUrl(
identifier: identifier
)
else {
return .failure(InternetArchiveError.invalidUrl)
}

let result: Result<ReviewReadEnvelope, Error> = await makeRequest(
url: reviewsUrl)
switch result {
case .success(let envelope):
guard envelope.success == true, let review = envelope.value else {
let message = envelope.error ?? "no review found"
return .failure(InternetArchiveError.apiError(message: message))
}
return .success(review)
case .failure(let error):
return .failure(error)
}
}

private func reviewWriteRequest(
identifier: String,
method: String,
body: (() throws -> Data)? = nil
) async -> Result<ReviewWriteResult, Error> {
guard credentials != nil else {
return .failure(InternetArchiveError.missingCredentials)
}
guard
let reviewsUrl: URL = urlGenerator.generateReviewsUrl(
identifier: identifier
)
else {
return .failure(InternetArchiveError.invalidUrl)
}

var request = authorizedRequest(url: reviewsUrl)
request.httpMethod = method
do {
if let body = body {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try body()
}
let (data, _) = try await urlSession.data(for: request)
let envelope: ReviewWriteEnvelope = try decodeResponse(data)
guard envelope.success == true else {
let message = envelope.error ?? "review write failed"
return .failure(InternetArchiveError.apiError(message: message))
}
return .success(
ReviewWriteResult(
taskId: envelope.value?.taskId,
reviewUpdated: envelope.value?.reviewUpdated
))
} catch {
return .failure(error)
}
}

/** @inheritdoc */
public func scrape(
query: InternetArchiveURLStringProtocol,
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
134 changes: 134 additions & 0 deletions InternetArchiveKit/InternetArchiveProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,88 @@ public protocol InternetArchiveProtocol {
completion: @escaping (InternetArchive.SearchResponse?, Error?) -> Void
)

/**
Create or update the signed-in account's review on an item

Requires credentials. A user has one review per item, so submitting again
overwrites the existing review; the result's `reviewUpdated` reports which
happened.

- parameters:
- identifier: The item identifier
- title: The review title
- body: The review body
- stars: The star rating, 0 to 5
- returns: InternetArchive.ReviewWriteResult
*/
func submitReview(
identifier: String,
title: String,
body: String,
stars: Int?
) async throws -> InternetArchive.ReviewWriteResult

/**
Create or update the signed-in account's review on an item

- parameters:
- identifier: The item identifier
- title: The review title
- body: The review body
- stars: The star rating, 0 to 5
- returns: Result<InternetArchive.ReviewWriteResult, Error>
*/
func submitReview(
identifier: String,
title: String,
body: String,
stars: Int?
) async -> Result<InternetArchive.ReviewWriteResult, Error>

/**
Delete the signed-in account's review from an item

- parameters:
- identifier: The item identifier
- returns: InternetArchive.ReviewWriteResult
*/
func deleteReview(
identifier: String
) async throws -> InternetArchive.ReviewWriteResult

/**
Delete the signed-in account's review from an item

- parameters:
- identifier: The item identifier
- returns: Result<InternetArchive.ReviewWriteResult, Error>
*/
func deleteReview(
identifier: String
) async -> Result<InternetArchive.ReviewWriteResult, Error>

/**
Fetch the signed-in account's review on an item

- parameters:
- identifier: The item identifier
- returns: InternetArchive.Review
*/
func myReview(
identifier: String
) async throws -> InternetArchive.Review

/**
Fetch the signed-in account's review on an item

- parameters:
- identifier: The item identifier
- returns: Result<InternetArchive.Review, Error>
*/
func myReview(
identifier: String
) async -> Result<InternetArchive.Review, Error>

/**
Scrape the Internet Archive

Expand Down Expand Up @@ -247,6 +329,7 @@ public protocol InternetArchiveProtocol {
/// A protocol to which the main `InternetArchive.URLGenerator` class conforms
public protocol InternetArchiveURLGeneratorProtocol {
func generateItemImageUrl(itemIdentifier: String) -> URL?
func generateReviewsUrl(identifier: String) -> URL?
func generateMetadataUrl(identifier: String) -> URL?
func generateXauthnUrl(operation: String) -> URL?
func generateDownloadUrl(itemIdentifier: String, fileName: String) -> URL?
Expand Down Expand Up @@ -304,6 +387,57 @@ extension InternetArchiveProtocol {
}
}

/** @inheritdoc */
public func submitReview(
identifier: String,
title: String,
body: String,
stars: Int?
) async throws -> InternetArchive.ReviewWriteResult {
let result: Result<InternetArchive.ReviewWriteResult, Error> = await submitReview(
identifier: identifier,
title: title,
body: body,
stars: stars
)
switch result {
case .success(let success):
return success
case .failure(let error):
throw error
}
}

/** @inheritdoc */
public func deleteReview(
identifier: String
) async throws -> InternetArchive.ReviewWriteResult {
let result: Result<InternetArchive.ReviewWriteResult, Error> = await deleteReview(
identifier: identifier
)
switch result {
case .success(let success):
return success
case .failure(let error):
throw error
}
}

/** @inheritdoc */
public func myReview(
identifier: String
) async throws -> InternetArchive.Review {
let result: Result<InternetArchive.Review, Error> = await myReview(
identifier: identifier
)
switch result {
case .success(let success):
return success
case .failure(let error):
throw error
}
}

/** @inheritdoc */
public func scrape(
query: InternetArchiveURLStringProtocol,
Expand Down
17 changes: 17 additions & 0 deletions InternetArchiveKit/InternetArchiveURLGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,30 @@
*/
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
}

/**
Generate a Reviews API (`/services/reviews.php`) url

- parameters:
- identifier: The item identifier

- returns: Optional reviews `URL`
*/
public func generateReviewsUrl(identifier: String) -> URL? {
var urlComponents: URLComponents = getBaseUrlComponents()
urlComponents.path = "/services/reviews.php"
urlComponents.queryItems = [
URLQueryItem(name: "identifier", value: identifier)
]
return urlComponents.url
}

public func generateSearchUrl(

Check warning on line 88 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 +104,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 107 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
54 changes: 54 additions & 0 deletions InternetArchiveKit/Models/ReviewWrite.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// ReviewWrite.swift
// InternetArchiveKit
//
// Created by Jason Buckner on 7/17/26.
// Copyright © 2026 Jason Buckner. All rights reserved.
//

import Foundation

extension InternetArchive {
/**
The result of a successful `submitReview()` or `deleteReview()` call.

The write is queued as a catalog task; `taskId` points at it.
`reviewUpdated` is true when a submit overwrote an existing review.
*/
public struct ReviewWriteResult: Sendable {
public let taskId: Int?
public let reviewUpdated: Bool?

public init(taskId: Int?, reviewUpdated: Bool?) {
self.taskId = taskId
self.reviewUpdated = reviewUpdated
}
}

/// The POST body for `submitReview()`
struct ReviewSubmission: Encodable {
let title: String
let body: String
let stars: Int?
}

/// The review write response envelope, e.g.
/// `{"success": true, "value": {"task_id": 123, "review_updated": false}}`.
struct ReviewWriteEnvelope: Decodable {
struct Value: Decodable {
let taskId: Int?
let reviewUpdated: Bool?
}
let success: Bool?
let value: Value?
let error: String?
}

/// The review read response envelope; `value` is the caller's own review in
/// the same shape reviews take on the metadata response.
struct ReviewReadEnvelope: Decodable {
let success: Bool?
let value: Review?
let error: String?
}
}
4 changes: 4 additions & 0 deletions InternetArchiveKitTests/InternetArchiveKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ class InternetArchiveKitTests: XCTestCase {
return nil
}

func generateReviewsUrl(identifier: String) -> URL? {
return nil
}

func generateMetadataUrl(identifier: String) -> URL? {
return nil
}
Expand Down
Loading
Loading