commit 548a50e8b0317ca9fe9e618b8a8c432a87c695fc
parent 03cc7a6486122d7674e1fc32b15989cb20c2dc03
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 02:10:27 -0700
kit: add document interchange contracts
- add reusable document import, export, and share payload models
- validate filenames, media types, public urls, and share text
- normalize unsigned byte counts for inline and staged export sources
- cover document interchange contracts with Swift package tests
Diffstat:
2 files changed, 494 insertions(+), 0 deletions(-)
diff --git a/Sources/RadrootsKit/RadrootsDocumentInterchange.swift b/Sources/RadrootsKit/RadrootsDocumentInterchange.swift
@@ -0,0 +1,328 @@
+import Foundation
+
+public enum RadrootsDocumentContentKind: String, Sendable, Equatable, Hashable, CaseIterable {
+ case json
+ case plainText
+ case url
+ case file
+ case stagedBlob
+}
+
+public enum RadrootsDocumentInterchangeError: Error, Equatable, Sendable {
+ case invalidRequest(String)
+ case notFound(String)
+ case userCancelled(String)
+ case permissionDenied(String)
+ case transientFailure(String)
+ case permanentFailure(String)
+}
+
+extension RadrootsDocumentInterchangeError: LocalizedError {
+ public var errorDescription: String? {
+ switch self {
+ case .invalidRequest(let message):
+ message
+ case .notFound(let message):
+ message
+ case .userCancelled(let message):
+ message
+ case .permissionDenied(let message):
+ message
+ case .transientFailure(let message):
+ message
+ case .permanentFailure(let message):
+ message
+ }
+ }
+}
+
+public struct RadrootsDocumentImportRequest: Sendable, Equatable, Hashable {
+ public let allowedContentKinds: [RadrootsDocumentContentKind]
+ public let allowsMultipleSelection: Bool
+ public let destinationScope: RadrootsFileScope
+
+ public init(
+ allowedContentKinds: [RadrootsDocumentContentKind],
+ allowsMultipleSelection: Bool = false,
+ destinationScope: RadrootsFileScope = .temporary
+ ) throws {
+ let normalizedKinds = try Self.normalizedContentKinds(allowedContentKinds)
+ self.allowedContentKinds = normalizedKinds
+ self.allowsMultipleSelection = allowsMultipleSelection
+ self.destinationScope = destinationScope
+ }
+
+ public static func normalizedContentKinds(
+ _ allowedContentKinds: [RadrootsDocumentContentKind]
+ ) throws -> [RadrootsDocumentContentKind] {
+ var seen = Set<RadrootsDocumentContentKind>()
+ let normalized = allowedContentKinds.filter { kind in
+ if seen.contains(kind) {
+ return false
+ }
+ seen.insert(kind)
+ return true
+ }
+ guard !normalized.isEmpty else {
+ throw RadrootsDocumentInterchangeError.invalidRequest("document import must allow at least one content kind")
+ }
+ return normalized
+ }
+}
+
+public struct RadrootsImportedDocument: Sendable, Equatable, Hashable {
+ public let file: RadrootsFileReference
+ public let originalURL: URL?
+ public let suggestedFilename: String
+ public let mediaType: String?
+ public let sizeBytes: UInt64
+
+ public init(
+ file: RadrootsFileReference,
+ originalURL: URL?,
+ suggestedFilename: String,
+ mediaType: String?,
+ sizeBytes: UInt64
+ ) throws {
+ self.file = file
+ self.originalURL = try Self.normalizedOriginalURL(originalURL)
+ self.suggestedFilename = try RadrootsDocumentInterchangeValidation.normalizedFilename(suggestedFilename)
+ self.mediaType = try RadrootsDocumentInterchangeValidation.normalizedMediaType(mediaType)
+ self.sizeBytes = sizeBytes
+ }
+
+ public static func normalizedOriginalURL(_ originalURL: URL?) throws -> URL? {
+ guard let originalURL else {
+ return nil
+ }
+ guard originalURL.isFileURL else {
+ throw RadrootsDocumentInterchangeError.invalidRequest("imported document original url must be a file url")
+ }
+ return originalURL.standardizedFileURL
+ }
+}
+
+public struct RadrootsDocumentImportResult: Sendable, Equatable, Hashable {
+ public let documents: [RadrootsImportedDocument]
+
+ public init(documents: [RadrootsImportedDocument]) throws {
+ guard !documents.isEmpty else {
+ throw RadrootsDocumentInterchangeError.invalidRequest("document import result cannot be empty")
+ }
+ self.documents = documents
+ }
+}
+
+public enum RadrootsShareItem: Sendable, Equatable, Hashable {
+ case text(String)
+ case url(URL)
+ case file(RadrootsFileReference, suggestedFilename: String?, mediaType: String?, sizeBytes: UInt64?)
+ case stagedBlob(RadrootsStagedBlobReference, suggestedFilename: String?)
+
+ public static func validatedText(_ value: String) throws -> Self {
+ .text(try RadrootsDocumentInterchangeValidation.normalizedPublicText(value, field: "share text"))
+ }
+
+ public static func validatedURL(_ value: URL) throws -> Self {
+ .url(try RadrootsDocumentInterchangeValidation.normalizedPublicURL(value))
+ }
+
+ public static func validatedFile(
+ _ file: RadrootsFileReference,
+ suggestedFilename: String? = nil,
+ mediaType: String? = nil,
+ sizeBytes: UInt64? = nil
+ ) throws -> Self {
+ .file(
+ file,
+ suggestedFilename: try RadrootsDocumentInterchangeValidation.normalizedOptionalFilename(suggestedFilename),
+ mediaType: try RadrootsDocumentInterchangeValidation.normalizedMediaType(mediaType),
+ sizeBytes: sizeBytes
+ )
+ }
+
+ public static func validatedStagedBlob(
+ _ stagedBlob: RadrootsStagedBlobReference,
+ suggestedFilename: String? = nil
+ ) throws -> Self {
+ .stagedBlob(
+ stagedBlob,
+ suggestedFilename: try RadrootsDocumentInterchangeValidation.normalizedOptionalFilename(suggestedFilename)
+ )
+ }
+
+ public var normalized: Self {
+ get throws {
+ switch self {
+ case .text(let text):
+ try Self.validatedText(text)
+ case .url(let url):
+ try Self.validatedURL(url)
+ case .file(let file, let suggestedFilename, let mediaType, let sizeBytes):
+ try Self.validatedFile(file, suggestedFilename: suggestedFilename, mediaType: mediaType, sizeBytes: sizeBytes)
+ case .stagedBlob(let stagedBlob, let suggestedFilename):
+ try Self.validatedStagedBlob(stagedBlob, suggestedFilename: suggestedFilename)
+ }
+ }
+ }
+}
+
+public struct RadrootsShareRequest: Sendable, Equatable, Hashable {
+ public let items: [RadrootsShareItem]
+ public let subject: String?
+
+ public init(items: [RadrootsShareItem], subject: String? = nil) throws {
+ let normalizedItems = try items.map { try $0.normalized }
+ guard !normalizedItems.isEmpty else {
+ throw RadrootsDocumentInterchangeError.invalidRequest("share request must contain at least one item")
+ }
+ self.items = normalizedItems
+ self.subject = try RadrootsDocumentInterchangeValidation.normalizedOptionalPublicText(subject, field: "share subject")
+ }
+}
+
+public struct RadrootsShareResult: Sendable, Equatable, Hashable {
+ public let completed: Bool
+
+ public init(completed: Bool) {
+ self.completed = completed
+ }
+}
+
+public enum RadrootsExportDocumentSource: Sendable, Equatable, Hashable {
+ case inlineData(Data)
+ case file(RadrootsFileReference)
+ case stagedBlob(RadrootsStagedBlobReference)
+}
+
+public struct RadrootsExportDocumentRequest: Sendable, Equatable, Hashable {
+ public let source: RadrootsExportDocumentSource
+ public let suggestedFilename: String
+ public let mediaType: String?
+ public let sizeBytes: UInt64?
+
+ public init(
+ source: RadrootsExportDocumentSource,
+ suggestedFilename: String,
+ mediaType: String?,
+ sizeBytes: UInt64? = nil
+ ) throws {
+ self.source = source
+ self.suggestedFilename = try RadrootsDocumentInterchangeValidation.normalizedFilename(suggestedFilename)
+ self.mediaType = try RadrootsDocumentInterchangeValidation.normalizedMediaType(mediaType)
+ self.sizeBytes = try Self.normalizedSizeBytes(source: source, requestedSizeBytes: sizeBytes)
+ }
+
+ public static func normalizedSizeBytes(
+ source: RadrootsExportDocumentSource,
+ requestedSizeBytes: UInt64?
+ ) throws -> UInt64? {
+ switch source {
+ case .inlineData(let data):
+ let actualSize = UInt64(data.count)
+ if let requestedSizeBytes, requestedSizeBytes != actualSize {
+ throw RadrootsDocumentInterchangeError.invalidRequest("inline export byte count does not match data size")
+ }
+ return actualSize
+ case .file:
+ return requestedSizeBytes
+ case .stagedBlob(let stagedBlob):
+ let actualSize = UInt64(stagedBlob.sizeBytes)
+ if let requestedSizeBytes, requestedSizeBytes != actualSize {
+ throw RadrootsDocumentInterchangeError.invalidRequest("staged blob export byte count does not match reference size")
+ }
+ return actualSize
+ }
+ }
+}
+
+public struct RadrootsExportDocumentResult: Sendable, Equatable, Hashable {
+ public let exportedFilename: String
+ public let mediaType: String?
+ public let sizeBytes: UInt64?
+
+ public init(
+ exportedFilename: String,
+ mediaType: String?,
+ sizeBytes: UInt64?
+ ) throws {
+ self.exportedFilename = try RadrootsDocumentInterchangeValidation.normalizedFilename(exportedFilename)
+ self.mediaType = try RadrootsDocumentInterchangeValidation.normalizedMediaType(mediaType)
+ self.sizeBytes = sizeBytes
+ }
+}
+
+public enum RadrootsDocumentInterchangeValidation {
+ public static func normalizedFilename(_ filename: String) throws -> String {
+ let trimmed = filename.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot be empty")
+ }
+ guard trimmed != "." && trimmed != ".." else {
+ throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot be a path segment")
+ }
+ guard !NSString(string: trimmed).isAbsolutePath else {
+ throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot be absolute")
+ }
+ guard !trimmed.contains("/") && !trimmed.contains("\\") && !trimmed.contains("\0") else {
+ throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot contain path separators")
+ }
+ guard trimmed.rangeOfCharacter(from: .controlCharacters) == nil else {
+ throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot contain control characters")
+ }
+ guard trimmed.utf8.count <= 255 else {
+ throw RadrootsDocumentInterchangeError.invalidRequest("document filename is too long")
+ }
+ return trimmed
+ }
+
+ public static func normalizedOptionalFilename(_ filename: String?) throws -> String? {
+ guard let filename else {
+ return nil
+ }
+ return try normalizedFilename(filename)
+ }
+
+ public static func normalizedMediaType(_ mediaType: String?) throws -> String? {
+ guard let mediaType else {
+ return nil
+ }
+ let trimmed = mediaType.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ throw RadrootsDocumentInterchangeError.invalidRequest("document media type cannot be empty")
+ }
+ guard trimmed.rangeOfCharacter(from: .whitespacesAndNewlines.union(.controlCharacters)) == nil else {
+ throw RadrootsDocumentInterchangeError.invalidRequest("document media type cannot contain whitespace")
+ }
+ let parts = trimmed.split(separator: "/", omittingEmptySubsequences: false)
+ guard parts.count == 2, parts.allSatisfy({ !$0.isEmpty }) else {
+ throw RadrootsDocumentInterchangeError.invalidRequest("document media type must be type/subtype")
+ }
+ return trimmed.lowercased()
+ }
+
+ public static func normalizedPublicText(_ text: String, field: String) throws -> String {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ throw RadrootsDocumentInterchangeError.invalidRequest("\(field) cannot be empty")
+ }
+ return trimmed
+ }
+
+ public static func normalizedOptionalPublicText(_ text: String?, field: String) throws -> String? {
+ guard let text else {
+ return nil
+ }
+ return try normalizedPublicText(text, field: field)
+ }
+
+ public static func normalizedPublicURL(_ url: URL) throws -> URL {
+ guard let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" else {
+ throw RadrootsDocumentInterchangeError.invalidRequest("share url must be http or https")
+ }
+ guard url.host != nil else {
+ throw RadrootsDocumentInterchangeError.invalidRequest("share url must include a host")
+ }
+ return url
+ }
+}
diff --git a/Tests/RadrootsKitTests/RadrootsDocumentInterchangeTests.swift b/Tests/RadrootsKitTests/RadrootsDocumentInterchangeTests.swift
@@ -0,0 +1,166 @@
+import Foundation
+import Testing
+@testable import RadrootsKit
+
+@Test func documentImportRequestNormalizesContentKinds() throws {
+ let request = try RadrootsDocumentImportRequest(
+ allowedContentKinds: [.json, .json, .plainText],
+ allowsMultipleSelection: true,
+ destinationScope: .cache
+ )
+
+ #expect(request.allowedContentKinds == [.json, .plainText])
+ #expect(request.allowsMultipleSelection)
+ #expect(request.destinationScope == .cache)
+
+ #expect(throws: RadrootsDocumentInterchangeError.self) {
+ _ = try RadrootsDocumentImportRequest(allowedContentKinds: [])
+ }
+}
+
+@Test func documentImportResultRejectsEmptyResultsAndUnsafeMetadata() throws {
+ let file = RadrootsFileReference(scope: .temporary, relativePath: "imports/config.json")
+ let document = try RadrootsImportedDocument(
+ file: file,
+ originalURL: URL(fileURLWithPath: "/tmp/config.json"),
+ suggestedFilename: " config.json ",
+ mediaType: " Application/JSON ",
+ sizeBytes: 12
+ )
+
+ #expect(document.originalURL == URL(fileURLWithPath: "/tmp/config.json"))
+ #expect(document.suggestedFilename == "config.json")
+ #expect(document.mediaType == "application/json")
+ #expect(try RadrootsDocumentImportResult(documents: [document]).documents == [document])
+
+ #expect(throws: RadrootsDocumentInterchangeError.self) {
+ _ = try RadrootsDocumentImportResult(documents: [])
+ }
+ #expect(throws: RadrootsDocumentInterchangeError.self) {
+ _ = try RadrootsImportedDocument(
+ file: file,
+ originalURL: URL(string: "https://radroots.org/config.json"),
+ suggestedFilename: "config.json",
+ mediaType: "application/json",
+ sizeBytes: 1
+ )
+ }
+ #expect(throws: RadrootsDocumentInterchangeError.self) {
+ _ = try RadrootsImportedDocument(
+ file: file,
+ originalURL: nil,
+ suggestedFilename: "../config.json",
+ mediaType: "application/json",
+ sizeBytes: 1
+ )
+ }
+}
+
+@Test func shareRequestValidatesPublicItems() throws {
+ let file = RadrootsFileReference(scope: .cache, relativePath: "exports/diagnostics.json")
+ let stagedBlob = try RadrootsStagedBlobReference(
+ blobID: "diagnostics",
+ sizeBytes: 24,
+ mediaType: "application/json",
+ filenameHint: "diagnostics.json"
+ )
+ let request = try RadrootsShareRequest(
+ items: [
+ .text(" public post "),
+ .url(URL(string: "https://radroots.org/posts/1")!),
+ .file(file, suggestedFilename: " diagnostics.json ", mediaType: " Application/JSON ", sizeBytes: 24),
+ .stagedBlob(stagedBlob, suggestedFilename: " staged.json ")
+ ],
+ subject: " Radroots "
+ )
+
+ #expect(request.subject == "Radroots")
+ #expect(request.items.count == 4)
+ #expect(request.items[0] == .text("public post"))
+ #expect(request.items[1] == .url(URL(string: "https://radroots.org/posts/1")!))
+ #expect(request.items[2] == .file(file, suggestedFilename: "diagnostics.json", mediaType: "application/json", sizeBytes: 24))
+ #expect(request.items[3] == .stagedBlob(stagedBlob, suggestedFilename: "staged.json"))
+
+ #expect(throws: RadrootsDocumentInterchangeError.self) {
+ _ = try RadrootsShareRequest(items: [])
+ }
+ #expect(throws: RadrootsDocumentInterchangeError.self) {
+ _ = try RadrootsShareRequest(items: [.text(" ")])
+ }
+ #expect(throws: RadrootsDocumentInterchangeError.self) {
+ _ = try RadrootsShareRequest(items: [.url(URL(string: "file:///tmp/private.txt")!)])
+ }
+}
+
+@Test func exportDocumentRequestValidatesFilenameMediaTypeAndByteCounts() throws {
+ let inlineData = Data(#"{"relay":"wss://radroots.org"}"#.utf8)
+ let inlineRequest = try RadrootsExportDocumentRequest(
+ source: .inlineData(inlineData),
+ suggestedFilename: " relays.json ",
+ mediaType: " Application/JSON "
+ )
+
+ #expect(inlineRequest.suggestedFilename == "relays.json")
+ #expect(inlineRequest.mediaType == "application/json")
+ #expect(inlineRequest.sizeBytes == UInt64(inlineData.count))
+
+ let stagedBlob = try RadrootsStagedBlobReference(blobID: "relay-export", sizeBytes: 64)
+ let stagedRequest = try RadrootsExportDocumentRequest(
+ source: .stagedBlob(stagedBlob),
+ suggestedFilename: "relay-export.json",
+ mediaType: "application/json",
+ sizeBytes: 64
+ )
+ #expect(stagedRequest.sizeBytes == 64)
+
+ #expect(throws: RadrootsDocumentInterchangeError.self) {
+ _ = try RadrootsExportDocumentRequest(
+ source: .inlineData(inlineData),
+ suggestedFilename: "/tmp/relays.json",
+ mediaType: "application/json"
+ )
+ }
+ #expect(throws: RadrootsDocumentInterchangeError.self) {
+ _ = try RadrootsExportDocumentRequest(
+ source: .inlineData(inlineData),
+ suggestedFilename: "relays.json",
+ mediaType: "application json"
+ )
+ }
+ #expect(throws: RadrootsDocumentInterchangeError.self) {
+ _ = try RadrootsExportDocumentRequest(
+ source: .inlineData(inlineData),
+ suggestedFilename: "relays.json",
+ mediaType: "application/json",
+ sizeBytes: 1
+ )
+ }
+ #expect(throws: RadrootsDocumentInterchangeError.self) {
+ _ = try RadrootsExportDocumentRequest(
+ source: .stagedBlob(stagedBlob),
+ suggestedFilename: "relay-export.json",
+ mediaType: "application/json",
+ sizeBytes: 1
+ )
+ }
+}
+
+@Test func exportDocumentResultNormalizesPublicMetadata() throws {
+ let result = try RadrootsExportDocumentResult(
+ exportedFilename: " diagnostics.json ",
+ mediaType: " Application/JSON ",
+ sizeBytes: 99
+ )
+
+ #expect(result.exportedFilename == "diagnostics.json")
+ #expect(result.mediaType == "application/json")
+ #expect(result.sizeBytes == 99)
+
+ #expect(throws: RadrootsDocumentInterchangeError.self) {
+ _ = try RadrootsExportDocumentResult(
+ exportedFilename: "bad/name.json",
+ mediaType: "application/json",
+ sizeBytes: nil
+ )
+ }
+}