From 59c37fffd6af72e71409865de8e057deed6919d6 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Fri, 17 Jul 2026 08:28:02 -0700 Subject: [PATCH] Add Reviews API write support submitReview(), deleteReview(), and myReview() cover the reviews.php endpoint with the LOW header. A user has one review per item, so a submit overwrites and reports reviewUpdated. The read side reuses the existing Review model. Closes #83 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015u2FT1T8yYQsEztyKg6UGf --- InternetArchiveKit/InternetArchive.swift | 88 +++++++++ .../InternetArchiveErrors.swift | 6 + .../InternetArchiveProtocols.swift | 134 ++++++++++++++ .../InternetArchiveURLGenerator.swift | 17 ++ InternetArchiveKit/Models/ReviewWrite.swift | 54 ++++++ .../InternetArchiveKitTests.swift | 4 + .../ReviewWriteTests.swift | 167 ++++++++++++++++++ 7 files changed, 470 insertions(+) create mode 100644 InternetArchiveKit/Models/ReviewWrite.swift create mode 100644 InternetArchiveKitTests/ReviewWriteTests.swift diff --git a/InternetArchiveKit/InternetArchive.swift b/InternetArchiveKit/InternetArchive.swift index ed64690..9fe4941 100644 --- a/InternetArchiveKit/InternetArchive.swift +++ b/InternetArchiveKit/InternetArchive.swift @@ -124,6 +124,94 @@ public final class InternetArchive: InternetArchiveProtocol, @unchecked Sendable } } + /** @inheritdoc */ + public func submitReview( + identifier: String, + title: String, + body: String, + stars: Int? = nil + ) async -> Result { + await reviewWriteRequest(identifier: identifier, method: "POST") { + try JSONEncoder().encode( + ReviewSubmission(title: title, body: body, stars: stars)) + } + } + + /** @inheritdoc */ + public func deleteReview( + identifier: String + ) async -> Result { + await reviewWriteRequest(identifier: identifier, method: "DELETE", body: nil) + } + + /** @inheritdoc */ + public func myReview( + identifier: String + ) async -> Result { + guard credentials != nil else { + return .failure(InternetArchiveError.missingCredentials) + } + guard + let reviewsUrl: URL = urlGenerator.generateReviewsUrl( + identifier: identifier + ) + else { + return .failure(InternetArchiveError.invalidUrl) + } + + let result: Result = 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 { + 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, diff --git a/InternetArchiveKit/InternetArchiveErrors.swift b/InternetArchiveKit/InternetArchiveErrors.swift index 8ebe5af..a37cba4 100644 --- a/InternetArchiveKit/InternetArchiveErrors.swift +++ b/InternetArchiveKit/InternetArchiveErrors.swift @@ -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 } } @@ -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" } } } diff --git a/InternetArchiveKit/InternetArchiveProtocols.swift b/InternetArchiveKit/InternetArchiveProtocols.swift index 868c0b9..99baf03 100644 --- a/InternetArchiveKit/InternetArchiveProtocols.swift +++ b/InternetArchiveKit/InternetArchiveProtocols.swift @@ -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 + */ + func submitReview( + identifier: String, + title: String, + body: String, + stars: Int? + ) async -> Result + + /** + 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 + */ + func deleteReview( + identifier: String + ) async -> Result + + /** + 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 + */ + func myReview( + identifier: String + ) async -> Result + /** Scrape the Internet Archive @@ -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? @@ -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 = 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 = 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 = await myReview( + identifier: identifier + ) + switch result { + case .success(let success): + return success + case .failure(let error): + throw error + } + } + /** @inheritdoc */ public func scrape( query: InternetArchiveURLStringProtocol, diff --git a/InternetArchiveKit/InternetArchiveURLGenerator.swift b/InternetArchiveKit/InternetArchiveURLGenerator.swift index edd9c94..f9feceb 100644 --- a/InternetArchiveKit/InternetArchiveURLGenerator.swift +++ b/InternetArchiveKit/InternetArchiveURLGenerator.swift @@ -68,6 +68,23 @@ extension InternetArchive { 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( query: InternetArchiveURLStringProtocol, page: Int, diff --git a/InternetArchiveKit/Models/ReviewWrite.swift b/InternetArchiveKit/Models/ReviewWrite.swift new file mode 100644 index 0000000..123aaff --- /dev/null +++ b/InternetArchiveKit/Models/ReviewWrite.swift @@ -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? + } +} diff --git a/InternetArchiveKitTests/InternetArchiveKitTests.swift b/InternetArchiveKitTests/InternetArchiveKitTests.swift index f089b81..4119969 100644 --- a/InternetArchiveKitTests/InternetArchiveKitTests.swift +++ b/InternetArchiveKitTests/InternetArchiveKitTests.swift @@ -24,6 +24,10 @@ class InternetArchiveKitTests: XCTestCase { return nil } + func generateReviewsUrl(identifier: String) -> URL? { + return nil + } + func generateMetadataUrl(identifier: String) -> URL? { return nil } diff --git a/InternetArchiveKitTests/ReviewWriteTests.swift b/InternetArchiveKitTests/ReviewWriteTests.swift new file mode 100644 index 0000000..d11afe4 --- /dev/null +++ b/InternetArchiveKitTests/ReviewWriteTests.swift @@ -0,0 +1,167 @@ +// +// ReviewWriteTests.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 ReviewWriteTests: XCTestCase { + + private let credentials = InternetArchive.Credentials( + accessKey: "accessfoo", secretKey: "secretbar") + + private func mockedArchive( + identifier: String, body: String + ) -> (archive: InternetArchive, endpoint: BasicEndpointMock)? { + let urlGenerator = InternetArchive.URLGenerator() + guard let url = urlGenerator.generateReviewsUrl(identifier: identifier) else { + return nil + } + let endpoint = BasicEndpointMock( + status: 200, url: url, body: Data(body.utf8), headers: nil, error: nil) + URLSession.mockEndpoints = [url: endpoint] + let archive = InternetArchive( + urlGenerator: urlGenerator, + urlSession: URLSession.mock, + credentials: credentials + ) + return (archive, endpoint) + } + + func testGenerateReviewsUrl() { + let generator = InternetArchive.URLGenerator() + XCTAssertEqual( + generator.generateReviewsUrl(identifier: "foo")?.absoluteString, + "https://archive.org/services/reviews.php?identifier=foo" + ) + } + + func testSubmitReviewRequiresCredentials() async { + let archive = InternetArchive( + urlGenerator: InternetArchive.URLGenerator(), + urlSession: URLSession.mock + ) + let result = await archive.submitReview( + identifier: "foo", title: "Great show", body: "Stellar sound") + switch result { + case .success: + XCTFail("expected a failure") + case .failure(let error): + XCTAssertEqual( + error as? InternetArchive.InternetArchiveError, + InternetArchive.InternetArchiveError.missingCredentials + ) + } + } + + func testSubmitReviewSuccess() async { + let json = """ + {"success": true, "value": {"task_id": 1234, "review_updated": true}} + """ + guard let (archive, endpoint) = mockedArchive(identifier: "foo", body: json) else { + XCTFail("error generating reviews url") + return + } + + let result = await archive.submitReview( + identifier: "foo", title: "Great show", body: "Stellar sound", stars: 5) + + switch result { + case .success(let write): + XCTAssertEqual(write.taskId, 1234) + XCTAssertEqual(write.reviewUpdated, true) + case .failure(let error): + XCTFail("error, \(error.localizedDescription)") + } + + let request = endpoint.requests.first + XCTAssertEqual(request?.httpMethod, "POST") + XCTAssertEqual( + request?.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertEqual( + request?.value(forHTTPHeaderField: "Authorization"), + "LOW accessfoo:secretbar" + ) + } + + func testDeleteReviewSuccess() async { + let json = """ + {"success": true, "value": {"task_id": 5678}} + """ + guard let (archive, endpoint) = mockedArchive(identifier: "foo", body: json) else { + XCTFail("error generating reviews url") + return + } + + let result = await archive.deleteReview(identifier: "foo") + + switch result { + case .success(let write): + XCTAssertEqual(write.taskId, 5678) + case .failure(let error): + XCTFail("error, \(error.localizedDescription)") + } + + XCTAssertEqual(endpoint.requests.first?.httpMethod, "DELETE") + } + + func testMyReviewSuccess() async { + let json = """ + { + "success": true, + "value": { + "reviewbody": "Stellar sound", + "reviewtitle": "Great show", + "reviewer": "foo", + "reviewer_itemname": "@foo", + "reviewdate": "2017-12-11 18:56:28", + "createdate": "2017-12-11 18:56:28", + "stars": "5" + } + } + """ + guard let (archive, _) = mockedArchive(identifier: "foo", body: json) else { + XCTFail("error generating reviews url") + return + } + + let result = await archive.myReview(identifier: "foo") + + switch result { + case .success(let review): + XCTAssertEqual(review.reviewtitle, "Great show") + XCTAssertEqual(review.reviewerItemname, "@foo") + XCTAssertEqual(review.stars?.value, 5) + case .failure(let error): + XCTFail("error, \(error.localizedDescription)") + } + } + + func testSubmitReviewFailureSurfacesError() async { + let json = """ + {"success": false, "error": "item not found"} + """ + guard let (archive, _) = mockedArchive(identifier: "foo", body: json) else { + XCTFail("error generating reviews url") + return + } + + let result = await archive.submitReview( + identifier: "foo", title: "x", body: "y") + + switch result { + case .success: + XCTFail("expected a failure") + case .failure(let error): + XCTAssertEqual( + error as? InternetArchive.InternetArchiveError, + InternetArchive.InternetArchiveError.apiError(message: "item not found") + ) + } + } +}