Skip to content
Merged
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
38 changes: 22 additions & 16 deletions Sources/LatrKit/Library/SavedLibrary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions Sources/LatrKit/Library/SavedLibraryError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ import Foundation
public enum SavedLibraryError: Error, Sendable {
case invalidURL
case itemNotFound
case conflict
case invalidStoredRecord(uri: String)
}
33 changes: 32 additions & 1 deletion Sources/LatrKit/Models/ExternalSave.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -74,3 +103,5 @@ public struct ExternalSave: Codable, Sendable, Equatable {
return "Saved link"
}
}

extension ExternalSave.CodingKeys: CaseIterable {}
40 changes: 39 additions & 1 deletion Sources/LatrKit/Models/SavedItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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 {}
24 changes: 22 additions & 2 deletions Sources/LatrKit/Repository/RepositoryClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
36 changes: 36 additions & 0 deletions Sources/LatrKit/Utilities/JSONValue.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
45 changes: 45 additions & 0 deletions Sources/LatrKit/XRPC/LatrXRPCContracts.swift
Original file line number Diff line number Diff line change
@@ -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<SavedItem>]; 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<Input: Encodable & Sendable, Output: Decodable>(_ 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))
}
}
32 changes: 32 additions & 0 deletions Sources/LatrKit/XRPC/LatrXRPCMethod.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading