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
113 changes: 113 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 409 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 @@ -229,6 +229,119 @@
}
}

/** @inheritdoc */
public func upload(
itemIdentifier: String,
fileName: String,
data: Data,
contentType: String? = nil,
metadata: [String: String] = [:],
autoMakeBucket: Bool = true,
queueDerive: Bool = true,
sizeHint: Int? = nil
) async -> Result<Void, Error> {
guard credentials != nil else {
return .failure(InternetArchiveError.missingCredentials)
}
guard
let uploadUrl: URL = urlGenerator.generateUploadUrl(
itemIdentifier: itemIdentifier,
fileName: fileName
)
else {
return .failure(InternetArchiveError.invalidUrl)
}

var request = authorizedRequest(url: uploadUrl)
request.httpMethod = "PUT"
request.httpBody = data
if let contentType = contentType {
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
}
if autoMakeBucket {
request.setValue("1", forHTTPHeaderField: "x-amz-auto-make-bucket")
}
if !queueDerive {
request.setValue("0", forHTTPHeaderField: "x-archive-queue-derive")
}
if let sizeHint = sizeHint {
request.setValue("\(sizeHint)", forHTTPHeaderField: "x-archive-size-hint")
}
for (key, value) in metadata {
// IAS3 turns double hyphens back into underscores in metadata names
let headerName = "x-archive-meta-"
+ key.replacingOccurrences(of: "_", with: "--")
request.setValue(
Self.metadataHeaderValue(value), forHTTPHeaderField: headerName)
}

return await performS3Request(request)
}

/** @inheritdoc */
public func deleteFile(
itemIdentifier: String,
fileName: String,
cascadeDerivatives: Bool = true
) async -> Result<Void, Error> {
guard credentials != nil else {
return .failure(InternetArchiveError.missingCredentials)
}
guard
let uploadUrl: URL = urlGenerator.generateUploadUrl(
itemIdentifier: itemIdentifier,
fileName: fileName
)
else {
return .failure(InternetArchiveError.invalidUrl)
}

var request = authorizedRequest(url: uploadUrl)
request.httpMethod = "DELETE"
if cascadeDerivatives {
request.setValue("1", forHTTPHeaderField: "x-archive-cascade-delete")
}

return await performS3Request(request)
}

private func performS3Request(_ request: URLRequest) async -> Result<Void, Error> {
do {
let (data, response) = try await urlSession.data(for: request)
if let httpResponse = response as? HTTPURLResponse,
!(200..<300).contains(httpResponse.statusCode) {
// IAS3 errors are S3-style XML with a Message element
let body = String(decoding: data, as: UTF8.self)
let message = Self.s3ErrorMessage(from: body)
?? "IAS3 request failed with HTTP \(httpResponse.statusCode)"
return .failure(InternetArchiveError.apiError(message: message))
}
return .success(())
} catch {
return .failure(error)
}
}

/// Extracts the Message element from an S3-style XML error body
static func s3ErrorMessage(from body: String) -> String? {
guard
let start = body.range(of: "<Message>"),
let end = body.range(of: "</Message>"),
start.upperBound <= end.lowerBound
else { return nil }
return String(body[start.upperBound..<end.lowerBound])
}

/// IAS3 metadata header values with characters outside ASCII travel
/// percent-encoded inside a `uri(...)` wrapper
static func metadataHeaderValue(_ value: String) -> String {
guard !value.allSatisfy({ $0.isASCII }) else { return value }
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
let encoded = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
return "uri(\(encoded))"
}

/** @inheritdoc */
public func itemDetail(identifier: String) async -> Result<Item, Error> {
guard
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
126 changes: 126 additions & 0 deletions InternetArchiveKit/InternetArchiveProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,89 @@ public protocol InternetArchiveProtocol {
identifier: String,
completion: @escaping (InternetArchive.Item?, Error?) -> Void
)

/**
Upload a file to an Internet Archive item over IAS3

Requires credentials, and the account needs write access to the item. The
file body is held in memory, so this suits metadata-sized and audio-sized
files rather than multi-gigabyte ones.

- parameters:
- itemIdentifier: The item (bucket) identifier
- fileName: The file name to store
- data: The file contents
- contentType: The MIME type to send
- metadata: Item metadata for `x-archive-meta-*` headers, applied when
the bucket is created
- autoMakeBucket: Create the item if it doesn't exist yet
- queueDerive: Queue a derive task after the upload
- sizeHint: The expected final item size in bytes, for large items
*/
func upload(
itemIdentifier: String,
fileName: String,
data: Data,
contentType: String?,
metadata: [String: String],
autoMakeBucket: Bool,
queueDerive: Bool,
sizeHint: Int?
) async throws

/**
Upload a file to an Internet Archive item over IAS3

- parameters:
- itemIdentifier: The item (bucket) identifier
- fileName: The file name to store
- data: The file contents
- contentType: The MIME type to send
- metadata: Item metadata for `x-archive-meta-*` headers
- autoMakeBucket: Create the item if it doesn't exist yet
- queueDerive: Queue a derive task after the upload
- sizeHint: The expected final item size in bytes
- returns: Result<Void, Error>
*/
func upload(
itemIdentifier: String,
fileName: String,
data: Data,
contentType: String?,
metadata: [String: String],
autoMakeBucket: Bool,
queueDerive: Bool,
sizeHint: Int?
) async -> Result<Void, Error>

/**
Delete a file from an Internet Archive item over IAS3

- parameters:
- itemIdentifier: The item (bucket) identifier
- fileName: The file name to delete
- cascadeDerivatives: Also delete the file's derivatives
*/
func deleteFile(
itemIdentifier: String,
fileName: String,
cascadeDerivatives: Bool
) async throws

/**
Delete a file from an Internet Archive item over IAS3

- parameters:
- itemIdentifier: The item (bucket) identifier
- fileName: The file name to delete
- cascadeDerivatives: Also delete the file's derivatives
- returns: Result<Void, Error>
*/
func deleteFile(
itemIdentifier: String,
fileName: String,
cascadeDerivatives: Bool
) async -> Result<Void, Error>
}

/// A protocol to which the main `InternetArchive.URLGenerator` class conforms
Expand All @@ -258,6 +341,7 @@ public protocol InternetArchiveURLGeneratorProtocol {
sortFields: [InternetArchiveURLQueryItemProtocol],
additionalQueryParams: [URLQueryItem]
) -> URL?
func generateUploadUrl(itemIdentifier: String, fileName: String) -> URL?
func generateScrapeUrl(
query: InternetArchiveURLStringProtocol,
fields: [String],
Expand Down Expand Up @@ -370,4 +454,46 @@ extension InternetArchiveProtocol {
throw failure
}
}

/** @inheritdoc */
public func upload(
itemIdentifier: String,
fileName: String,
data: Data,
contentType: String?,
metadata: [String: String],
autoMakeBucket: Bool,
queueDerive: Bool,
sizeHint: Int?
) async throws {
let result: Result<Void, Error> = await upload(
itemIdentifier: itemIdentifier,
fileName: fileName,
data: data,
contentType: contentType,
metadata: metadata,
autoMakeBucket: autoMakeBucket,
queueDerive: queueDerive,
sizeHint: sizeHint
)
if case .failure(let error) = result {
throw error
}
}

/** @inheritdoc */
public func deleteFile(
itemIdentifier: String,
fileName: String,
cascadeDerivatives: Bool
) async throws {
let result: Result<Void, Error> = await deleteFile(
itemIdentifier: itemIdentifier,
fileName: fileName,
cascadeDerivatives: cascadeDerivatives
)
if case .failure(let error) = result {
throw error
}
}
}
19 changes: 19 additions & 0 deletions InternetArchiveKit/InternetArchiveURLGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,32 @@
*/
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 an IAS3 (`s3.us.archive.org`) upload url for a file

- parameters:
- itemIdentifier: The item (bucket) identifier
- fileName: The file name (key)

- returns: Optional upload `URL`
*/
public func generateUploadUrl(itemIdentifier: String, fileName: String)
-> URL?
{

Check warning on line 82 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 = URLComponents()
urlComponents.scheme = scheme
urlComponents.host = "s3.us.archive.org"
urlComponents.path = "/\(itemIdentifier)/\(fileName)"
return urlComponents.url
}

public func generateSearchUrl(

Check warning on line 90 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 +106,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 109 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
4 changes: 4 additions & 0 deletions InternetArchiveKitTests/InternetArchiveKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ class InternetArchiveKitTests: XCTestCase {
return nil
}

func generateUploadUrl(itemIdentifier: String, fileName: String) -> URL? {
return nil
}

func generateSearchUrl(query: InternetArchiveURLStringProtocol, page: Int, rows: Int, fields: [String], sortFields: [InternetArchiveURLQueryItemProtocol], additionalQueryParams: [URLQueryItem]) -> URL? {
return nil
}
Expand Down
Loading
Loading