diff --git a/Sources/LatrKit/Library/SavedLibrary.swift b/Sources/LatrKit/Library/SavedLibrary.swift index e4317fa..2db3f17 100644 --- a/Sources/LatrKit/Library/SavedLibrary.swift +++ b/Sources/LatrKit/Library/SavedLibrary.swift @@ -159,26 +159,32 @@ public struct SavedLibrary: Sendable { } public func setState(ofSavedItemWithKey key: String, to state: SavedItemState) async throws { - guard let current = try await savedItem(withKey: key) else { - throw SavedLibraryError.itemNotFound + for attempt in 0 ... 1 { + guard let current = try await savedItem(withKey: key) else { throw SavedLibraryError.itemNotFound } + var next = current.value + next.state = state + do { + _ = try await repository.updateRecord( + in: repositoryDID, collection: .savedItem, withKey: key, + value: next, swapRecord: current.cid + ) + return + } catch RepositoryClientError.conflict where attempt == 0 { continue } + catch RepositoryClientError.conflict { throw SavedLibraryError.conflict } } - - var next = current.value - next.state = state - _ = try await repository.updateRecord( - in: repositoryDID, - collection: .savedItem, - withKey: key, - value: next - ) } public func removeSavedItem(withKey key: String) async throws { - try await repository.deleteRecord( - in: repositoryDID, - collection: .savedItem, - withKey: key - ) + for attempt in 0 ... 1 { + guard let current = try await savedItem(withKey: key) else { throw SavedLibraryError.itemNotFound } + do { + try await repository.deleteRecord( + in: repositoryDID, collection: .savedItem, withKey: key, swapRecord: current.cid + ) + return + } catch RepositoryClientError.conflict where attempt == 0 { continue } + catch RepositoryClientError.conflict { throw SavedLibraryError.conflict } + } } public func removeExternalSave(for url: String, includingWrapper: Bool = false) async throws { diff --git a/Sources/LatrKit/Library/SavedLibraryError.swift b/Sources/LatrKit/Library/SavedLibraryError.swift index 3eb429f..e1f1e17 100644 --- a/Sources/LatrKit/Library/SavedLibraryError.swift +++ b/Sources/LatrKit/Library/SavedLibraryError.swift @@ -3,4 +3,6 @@ import Foundation public enum SavedLibraryError: Error, Sendable { case invalidURL case itemNotFound + case conflict + case invalidStoredRecord(uri: String) } diff --git a/Sources/LatrKit/Models/ExternalSave.swift b/Sources/LatrKit/Models/ExternalSave.swift index 066b199..a702d9e 100644 --- a/Sources/LatrKit/Models/ExternalSave.swift +++ b/Sources/LatrKit/Models/ExternalSave.swift @@ -14,6 +14,7 @@ public struct ExternalSave: Codable, Sendable, Equatable { public var language: String? public var publishedAt: String? public var author: String? + public var unknownFields: [String: JSONValue] enum CodingKeys: String, CodingKey { case type = "$type" @@ -41,7 +42,8 @@ public struct ExternalSave: Codable, Sendable, Equatable { image: String? = nil, language: String? = nil, publishedAt: String? = nil, - author: String? = nil + author: String? = nil, + unknownFields: [String: JSONValue] = [:] ) { self.type = LexiconCollection.external.identifier self.url = url @@ -55,6 +57,33 @@ public struct ExternalSave: Codable, Sendable, Equatable { self.language = language self.publishedAt = publishedAt self.author = author + self.unknownFields = unknownFields + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + type = try c.decode(String.self, forKey: .type); url = try c.decode(String.self, forKey: .url) + normalizedUrl = try c.decode(String.self, forKey: .normalizedUrl); fingerprint = try c.decode(String.self, forKey: .fingerprint) + createdAt = try c.decode(String.self, forKey: .createdAt); title = try c.decodeIfPresent(String.self, forKey: .title) + excerpt = try c.decodeIfPresent(String.self, forKey: .excerpt); site = try c.decodeIfPresent(String.self, forKey: .site) + image = try c.decodeIfPresent(String.self, forKey: .image); language = try c.decodeIfPresent(String.self, forKey: .language) + publishedAt = try c.decodeIfPresent(String.self, forKey: .publishedAt); author = try c.decodeIfPresent(String.self, forKey: .author) + let dynamic = try decoder.container(keyedBy: AnyCodingKey.self) + let known = Set(CodingKeys.allCases.map(\.rawValue)) + unknownFields = try dynamic.allKeys.reduce(into: [:]) { result, key in + if !known.contains(key.stringValue) { result[key.stringValue] = try dynamic.decode(JSONValue.self, forKey: key) } + } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(type, forKey: .type); try c.encode(url, forKey: .url); try c.encode(normalizedUrl, forKey: .normalizedUrl) + try c.encode(fingerprint, forKey: .fingerprint); try c.encode(createdAt, forKey: .createdAt) + try c.encodeIfPresent(title, forKey: .title); try c.encodeIfPresent(excerpt, forKey: .excerpt); try c.encodeIfPresent(site, forKey: .site) + try c.encodeIfPresent(image, forKey: .image); try c.encodeIfPresent(language, forKey: .language) + try c.encodeIfPresent(publishedAt, forKey: .publishedAt); try c.encodeIfPresent(author, forKey: .author) + var dynamic = encoder.container(keyedBy: AnyCodingKey.self) + for (key, value) in unknownFields { try dynamic.encode(value, forKey: AnyCodingKey(stringValue: key)!) } } /// Best available human-readable title for display. @@ -74,3 +103,5 @@ public struct ExternalSave: Codable, Sendable, Equatable { return "Saved link" } } + +extension ExternalSave.CodingKeys: CaseIterable {} diff --git a/Sources/LatrKit/Models/SavedItem.swift b/Sources/LatrKit/Models/SavedItem.swift index f7d1863..53cf7a7 100644 --- a/Sources/LatrKit/Models/SavedItem.swift +++ b/Sources/LatrKit/Models/SavedItem.swift @@ -15,6 +15,7 @@ public struct SavedItem: Codable, Sendable, Equatable { public var previewSite: String? public var previewImage: String? public var previewAuthor: String? + public var unknownFields: [String: JSONValue] enum CodingKeys: String, CodingKey { case type = "$type" @@ -44,7 +45,8 @@ public struct SavedItem: Codable, Sendable, Equatable { previewExcerpt: String? = nil, previewSite: String? = nil, previewImage: String? = nil, - previewAuthor: String? = nil + previewAuthor: String? = nil, + unknownFields: [String: JSONValue] = [:] ) { self.type = LexiconCollection.savedItem.identifier self.subjectUri = subjectUri @@ -59,5 +61,41 @@ public struct SavedItem: Codable, Sendable, Equatable { self.previewSite = previewSite self.previewImage = previewImage self.previewAuthor = previewAuthor + self.unknownFields = unknownFields + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + type = try c.decode(String.self, forKey: .type) + subjectUri = try c.decode(String.self, forKey: .subjectUri) + savedAt = try c.decode(String.self, forKey: .savedAt) + state = try c.decodeIfPresent(SavedItemState.self, forKey: .state) + tags = try c.decodeIfPresent([String].self, forKey: .tags) + note = try c.decodeIfPresent(String.self, forKey: .note) + lastOpenedAt = try c.decodeIfPresent(String.self, forKey: .lastOpenedAt) + linkedWebUrl = try c.decodeIfPresent(String.self, forKey: .linkedWebUrl) + previewTitle = try c.decodeIfPresent(String.self, forKey: .previewTitle) + previewExcerpt = try c.decodeIfPresent(String.self, forKey: .previewExcerpt) + previewSite = try c.decodeIfPresent(String.self, forKey: .previewSite) + previewImage = try c.decodeIfPresent(String.self, forKey: .previewImage) + previewAuthor = try c.decodeIfPresent(String.self, forKey: .previewAuthor) + let dynamic = try decoder.container(keyedBy: AnyCodingKey.self) + let known = Set(CodingKeys.allCases.map(\.rawValue)) + unknownFields = try dynamic.allKeys.reduce(into: [:]) { result, key in + if !known.contains(key.stringValue) { result[key.stringValue] = try dynamic.decode(JSONValue.self, forKey: key) } + } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(type, forKey: .type); try c.encode(subjectUri, forKey: .subjectUri); try c.encode(savedAt, forKey: .savedAt) + try c.encodeIfPresent(state, forKey: .state); try c.encodeIfPresent(tags, forKey: .tags); try c.encodeIfPresent(note, forKey: .note) + try c.encodeIfPresent(lastOpenedAt, forKey: .lastOpenedAt); try c.encodeIfPresent(linkedWebUrl, forKey: .linkedWebUrl) + try c.encodeIfPresent(previewTitle, forKey: .previewTitle); try c.encodeIfPresent(previewExcerpt, forKey: .previewExcerpt) + try c.encodeIfPresent(previewSite, forKey: .previewSite); try c.encodeIfPresent(previewImage, forKey: .previewImage); try c.encodeIfPresent(previewAuthor, forKey: .previewAuthor) + var dynamic = encoder.container(keyedBy: AnyCodingKey.self) + for (key, value) in unknownFields { try dynamic.encode(value, forKey: AnyCodingKey(stringValue: key)!) } } } + +extension SavedItem.CodingKeys: CaseIterable {} diff --git a/Sources/LatrKit/Repository/RepositoryClient.swift b/Sources/LatrKit/Repository/RepositoryClient.swift index 5cbaabf..9447182 100644 --- a/Sources/LatrKit/Repository/RepositoryClient.swift +++ b/Sources/LatrKit/Repository/RepositoryClient.swift @@ -24,12 +24,32 @@ public protocol RepositoryClient: Sendable { in repository: String, collection: LexiconCollection, withKey key: String, - value: some Encodable & Sendable + value: some Encodable & Sendable, + swapRecord: String? ) async throws -> UpdateRecordResponse func deleteRecord( in repository: String, collection: LexiconCollection, - withKey key: String + withKey key: String, + swapRecord: String? ) async throws } + +public extension RepositoryClient { + func updateRecord( + in repository: String, collection: LexiconCollection, withKey key: String, + value: some Encodable & Sendable + ) async throws -> UpdateRecordResponse { + try await updateRecord(in: repository, collection: collection, withKey: key, value: value, swapRecord: nil) + } + + func deleteRecord(in repository: String, collection: LexiconCollection, withKey key: String) async throws { + try await deleteRecord(in: repository, collection: collection, withKey: key, swapRecord: nil) + } +} + +public enum RepositoryClientError: Error, Sendable, Equatable { + case conflict + case invalidStoredRecord(uri: String) +} diff --git a/Sources/LatrKit/Utilities/JSONValue.swift b/Sources/LatrKit/Utilities/JSONValue.swift new file mode 100644 index 0000000..b7af7ad --- /dev/null +++ b/Sources/LatrKit/Utilities/JSONValue.swift @@ -0,0 +1,36 @@ +import Foundation + +public enum JSONValue: Codable, Sendable, Equatable { + case string(String), integer(Int), double(Double), boolean(Bool), object([String: JSONValue]), array([JSONValue]), null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { self = .null } + else if let value = try? container.decode(Bool.self) { self = .boolean(value) } + else if let value = try? container.decode(Int.self) { self = .integer(value) } + else if let value = try? container.decode(Double.self) { self = .double(value) } + else if let value = try? container.decode(String.self) { self = .string(value) } + else if let value = try? container.decode([JSONValue].self) { self = .array(value) } + else { self = .object(try container.decode([String: JSONValue].self)) } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .string(value): try container.encode(value) + case let .integer(value): try container.encode(value) + case let .double(value): try container.encode(value) + case let .boolean(value): try container.encode(value) + case let .object(value): try container.encode(value) + case let .array(value): try container.encode(value) + case .null: try container.encodeNil() + } + } +} + +struct AnyCodingKey: CodingKey { + let stringValue: String + let intValue: Int? = nil + init?(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { return nil } +} diff --git a/Sources/LatrKit/XRPC/LatrXRPCContracts.swift b/Sources/LatrKit/XRPC/LatrXRPCContracts.swift new file mode 100644 index 0000000..0bde3a0 --- /dev/null +++ b/Sources/LatrKit/XRPC/LatrXRPCContracts.swift @@ -0,0 +1,45 @@ +import Foundation + +public struct LatrXRPCErrorBody: Codable, Sendable, Equatable { public let error: String; public let message: String } +public struct LatrListItemsParameters: Codable, Sendable, Equatable { public let limit: Int; public let cursor: String?; public init(limit: Int, cursor: String? = nil) { self.limit = limit; self.cursor = cursor } } +public struct LatrListItemsOutput: Codable, Sendable { public let records: [RepositoryRecord]; public let cursor: String? } +public struct LatrSaveURLInput: Codable, Sendable, Equatable { public let url: String; public init(url: String) { self.url = url } } +public struct LatrSaveSubjectInput: Codable, Sendable, Equatable { public let subjectUri: String; public let linkedWebUrl: String?; public init(subjectUri: String, linkedWebUrl: String? = nil) { self.subjectUri = subjectUri; self.linkedWebUrl = linkedWebUrl } } +public struct LatrSetStateInput: Codable, Sendable, Equatable { public let itemRkey: String; public let state: SavedItemState; public init(itemRkey: String, state: SavedItemState) { self.itemRkey = itemRkey; self.state = state } } +public struct LatrDeleteItemInput: Codable, Sendable, Equatable { public let itemRkey: String; public init(itemRkey: String) { self.itemRkey = itemRkey } } +public struct LatrSimpleOK: Codable, Sendable, Equatable { public let ok: Bool } +public struct LatrSaveResult: Codable, Sendable, Equatable { public let ok: Bool; public let kind: String; public let subjectUri: String?; public let linkedWebUrl: String?; public let storage: String? } + +public enum LatrPayloadValidationError: Error, Sendable, Equatable { case invalidLimit; case invalidURL; case invalidATURI; case emptyRecordKey; case exceedsUTF8Limit(field: String, maximum: Int) } +public enum LatrPayloadValidator { + public static func validate(_ value: LatrListItemsParameters) throws { guard (1 ... 100).contains(value.limit) else { throw LatrPayloadValidationError.invalidLimit } } + public static func validateURL(_ value: String, field: String = "url") throws { + guard value.utf8.count <= 8192 else { throw LatrPayloadValidationError.exceedsUTF8Limit(field: field, maximum: 8192) } + guard let url = URL(string: value), let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { throw LatrPayloadValidationError.invalidURL } + } + public static func validateATURI(_ value: String) throws { guard value.utf8.count <= 8192, value.hasPrefix("at://"), value.split(separator: "/").count >= 5 else { throw LatrPayloadValidationError.invalidATURI } } + public static func validateRecordKey(_ value: String) throws { guard !value.isEmpty else { throw LatrPayloadValidationError.emptyRecordKey } } +} + +public struct LatrXRPCClient: Sendable { + public let transport: any LatrXRPCTransport + public init(transport: any LatrXRPCTransport) { self.transport = transport } + public func listItems(_ parameters: LatrListItemsParameters) async throws -> LatrListItemsOutput { + try LatrPayloadValidator.validate(parameters) + var query = [URLQueryItem(name: "limit", value: String(parameters.limit))] + if let cursor = parameters.cursor { query.append(URLQueryItem(name: "cursor", value: cursor)) } + return try JSONDecoder().decode(LatrListItemsOutput.self, from: try await transport.send(method: .listItems, parameters: query, body: nil)) + } + public func saveURL(_ input: LatrSaveURLInput) async throws -> LatrSaveResult { + try LatrPayloadValidator.validateURL(input.url) + return try await procedure(.saveURL, input, as: LatrSaveResult.self) + } + public func setState(_ input: LatrSetStateInput) async throws -> LatrSimpleOK { + try LatrPayloadValidator.validateRecordKey(input.itemRkey) + return try await procedure(.setState, input, as: LatrSimpleOK.self) + } + private func procedure(_ method: LatrXRPCMethod, _ input: Input, as: Output.Type) async throws -> Output { + let data = try JSONEncoder().encode(input) + return try JSONDecoder().decode(Output.self, from: try await transport.send(method: method, parameters: [], body: data)) + } +} diff --git a/Sources/LatrKit/XRPC/LatrXRPCMethod.swift b/Sources/LatrKit/XRPC/LatrXRPCMethod.swift new file mode 100644 index 0000000..1afd3c3 --- /dev/null +++ b/Sources/LatrKit/XRPC/LatrXRPCMethod.swift @@ -0,0 +1,32 @@ +import Foundation + +public struct LatrXRPCMethod: Hashable, Sendable { + public enum Kind: String, Sendable { case query, procedure } + public let nsid: String + public let kind: Kind + public let requiresApplicationCredential: Bool + public var verb: String { kind == .query ? "GET" : "POST" } + + public static let listItems = Self("link.latr.saved.listItems", .query) + public static let getItem = Self("link.latr.saved.getItem", .query) + public static let saveURL = Self("link.latr.saved.saveUrl", .procedure) + public static let saveSubject = Self("link.latr.saved.saveSubject", .procedure) + public static let setState = Self("link.latr.saved.setState", .procedure) + public static let deleteItem = Self("link.latr.saved.deleteItem", .procedure) + public static let migrateLegacy = Self("link.latr.saved.migrateLegacy", .procedure) + public static let getOpenGraph = Self("link.latr.preview.getOpenGraph", .query) + public static let resolveURL = Self("link.latr.discovery.resolveUrl", .query) + public static let authProbe = Self("link.latr.auth.probe", .query) + public static let listClients = Self("link.latr.developer.listClients", .query, false) + public static let createClient = Self("link.latr.developer.createClient", .procedure, false) + public static let deleteClient = Self("link.latr.developer.deleteClient", .procedure, false) + public static let listKeys = Self("link.latr.developer.listKeys", .query, false) + public static let createKey = Self("link.latr.developer.createKey", .procedure, false) + public static let revokeKey = Self("link.latr.developer.revokeKey", .procedure, false) + public static let getUsage = Self("link.latr.developer.getUsage", .query, false) + public static let all: [Self] = [.listItems, .getItem, .saveURL, .saveSubject, .setState, .deleteItem, .migrateLegacy, .getOpenGraph, .resolveURL, .authProbe, .listClients, .createClient, .deleteClient, .listKeys, .createKey, .revokeKey, .getUsage] + + private init(_ nsid: String, _ kind: Kind, _ requiresApplicationCredential: Bool = true) { + self.nsid = nsid; self.kind = kind; self.requiresApplicationCredential = requiresApplicationCredential + } +} diff --git a/Sources/LatrKit/XRPC/LatrXRPCTransport.swift b/Sources/LatrKit/XRPC/LatrXRPCTransport.swift new file mode 100644 index 0000000..2034343 --- /dev/null +++ b/Sources/LatrKit/XRPC/LatrXRPCTransport.swift @@ -0,0 +1,42 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public typealias LatrXRPCHeaderProvider = @Sendable (LatrXRPCMethod, String, URL) async throws -> [String: String] + +public protocol LatrXRPCTransport: Sendable { + func send(method: LatrXRPCMethod, parameters: [URLQueryItem], body: Data?) async throws -> Data +} + +public enum LatrXRPCTransportError: Error, Sendable, Equatable { + case invalidURL, invalidResponse, http(status: Int, error: String?, message: String?) +} + +public struct URLSessionLatrXRPCTransport: LatrXRPCTransport, Sendable { + public let baseURL: URL + public let session: URLSession + public let headerProvider: LatrXRPCHeaderProvider + + public init(baseURL: URL, session: URLSession = .shared, headerProvider: @escaping LatrXRPCHeaderProvider) { + self.baseURL = baseURL; self.session = session; self.headerProvider = headerProvider + } + + public func send(method: LatrXRPCMethod, parameters: [URLQueryItem] = [], body: Data? = nil) async throws -> Data { + var components = URLComponents(url: baseURL.appending(path: "xrpc/\(method.nsid)"), resolvingAgainstBaseURL: false) + components?.queryItems = parameters.isEmpty ? nil : parameters + guard let url = components?.url else { throw LatrXRPCTransportError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = method.verb + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let body { request.httpBody = body; request.setValue("application/json", forHTTPHeaderField: "Content-Type") } + for (name, value) in try await headerProvider(method, method.verb, url) { request.setValue(value, forHTTPHeaderField: name) } + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw LatrXRPCTransportError.invalidResponse } + guard (200 ... 299).contains(http.statusCode) else { + let decoded = try? JSONDecoder().decode(LatrXRPCErrorBody.self, from: data) + throw LatrXRPCTransportError.http(status: http.statusCode, error: decoded?.error, message: decoded?.message) + } + return data + } +} diff --git a/Tests/LatrKitTests/InMemoryRepository.swift b/Tests/LatrKitTests/InMemoryRepository.swift index e063810..4e1c919 100644 --- a/Tests/LatrKitTests/InMemoryRepository.swift +++ b/Tests/LatrKitTests/InMemoryRepository.swift @@ -57,7 +57,8 @@ final class InMemoryRepository: RepositoryClient, @unchecked Sendable { in repository: String, collection: LexiconCollection, withKey key: String, - value: some Encodable & Sendable + value: some Encodable & Sendable, + swapRecord: String? ) async throws -> UpdateRecordResponse { let uri = "at://\(repository)/\(collection.identifier)/\(key)" let json = try JSONEncoder().encode(value) @@ -68,7 +69,8 @@ final class InMemoryRepository: RepositoryClient, @unchecked Sendable { func deleteRecord( in repository: String, collection: LexiconCollection, - withKey key: String + withKey key: String, + swapRecord: String? ) async throws { store.removeValue(forKey: storeKey(collection: collection, key: key)) } diff --git a/Tests/LatrKitTests/SubjectPreviewResolverTests.swift b/Tests/LatrKitTests/SubjectPreviewResolverTests.swift index 37c818a..d328e5f 100644 --- a/Tests/LatrKitTests/SubjectPreviewResolverTests.swift +++ b/Tests/LatrKitTests/SubjectPreviewResolverTests.swift @@ -132,7 +132,8 @@ private struct MockRepository: RepositoryClient { in repository: String, collection: LexiconCollection, withKey key: String, - value: some Encodable & Sendable + value: some Encodable & Sendable, + swapRecord: String? ) async throws -> UpdateRecordResponse { UpdateRecordResponse(uri: "at://\(repository)/\(collection.identifier)/\(key)") } @@ -140,6 +141,7 @@ private struct MockRepository: RepositoryClient { func deleteRecord( in repository: String, collection: LexiconCollection, - withKey key: String + withKey key: String, + swapRecord: String? ) async throws {} } diff --git a/Tests/LatrKitTests/XRPCContractTests.swift b/Tests/LatrKitTests/XRPCContractTests.swift new file mode 100644 index 0000000..fa3372f --- /dev/null +++ b/Tests/LatrKitTests/XRPCContractTests.swift @@ -0,0 +1,26 @@ +import Foundation +import Testing +@testable import LatrKit + +@Test func xrpcDescriptorsHaveStableVerbsAndCredentialPolicy() { + #expect(LatrXRPCMethod.all.count == 17) + #expect(LatrXRPCMethod.listItems.verb == "GET") + #expect(LatrXRPCMethod.saveURL.verb == "POST") + #expect(!LatrXRPCMethod.listClients.requiresApplicationCredential) +} + +@Test func recordsPreserveUnknownFieldsAcrossMutationRoundTrip() throws { + let data = Data(#"{"$type":"link.latr.saved.item","subjectUri":"at://did:plc:test/app.bsky.feed.post/abc","savedAt":"2026-08-13T00:00:00Z","future":{"enabled":true}}"#.utf8) + var item = try JSONDecoder().decode(SavedItem.self, from: data) + item.state = .archived + let encoded = try JSONEncoder().encode(item) + let object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + #expect((object["future"] as? [String: Bool])?["enabled"] == true) +} + +@Test func validationCountsUTF8Bytes() { + let oversized = String(repeating: "😀", count: 2_049) + #expect(throws: LatrPayloadValidationError.exceedsUTF8Limit(field: "url", maximum: 8192)) { + try LatrPayloadValidator.validateURL(oversized) + } +}