diff --git a/Package.resolved b/Package.resolved index ec070d4..de02880 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0bfda6ffc0ea7c8dd4ce38c35f7b56610491f4cbad85d36c6e9430ad607a2ce2", + "originHash" : "f240ec47277635c7032ca09e503fee60099e25203a917ff5e9500d489e823033", "pins" : [ { "identity" : "jwt-kit", @@ -19,15 +19,6 @@ "revision" : "8df3678a8e21522daebd71346156e1a7fae19d58" } }, - { - "identity" : "oauthenticator", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ChimeHQ/OAuthenticator.git", - "state" : { - "branch" : "main", - "revision" : "0962bcc02e8e5c0fc49771c0d0eff3d33433863e" - } - }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 96ea295..aa2b50a 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,6 @@ let package = Package( name: "CoreATProtocol", platforms: [ .iOS(.v26), - .macOS(.v26), .macCatalyst(.v26), .tvOS(.v26), .watchOS(.v26), diff --git a/Sources/CoreATProtocol/Repo/ATRepoAPI.swift b/Sources/CoreATProtocol/Repo/ATRepoAPI.swift new file mode 100644 index 0000000..3e97a36 --- /dev/null +++ b/Sources/CoreATProtocol/Repo/ATRepoAPI.swift @@ -0,0 +1,74 @@ +// +// ATRepoAPI.swift +// CoreATProtocol +// + +import Foundation +import NetworkingKit + +/// API endpoints for the generic com.atproto.repo.* lexicons. +/// +/// Read endpoints accept an optional explicit `host` so records can be fetched +/// from any identity's PDS (resolved via ``IdentityResolver``), not just the +/// session host. Writes always target the session host. +enum ATRepoAPI: Sendable { + case createRecord(body: Data) + case putRecord(body: Data) + case deleteRecord(body: Data) + case getRecord(repo: String, collection: String, rkey: String, host: String?) + case listRecords(repo: String, collection: String, limit: Int, cursor: String?, host: String?) +} + +extension ATRepoAPI: EndpointType { + public var baseURL: URL? { + get async { + switch self { + case .getRecord(_, _, _, .some(let host)), .listRecords(_, _, _, _, .some(let host)): + return URL(string: host) + default: + guard let host = await ATProtoSession.shared.host else { return nil } + return URL(string: host) + } + } + } + + var path: String { + switch self { + case .createRecord: "/xrpc/com.atproto.repo.createRecord" + case .putRecord: "/xrpc/com.atproto.repo.putRecord" + case .deleteRecord: "/xrpc/com.atproto.repo.deleteRecord" + case .getRecord: "/xrpc/com.atproto.repo.getRecord" + case .listRecords: "/xrpc/com.atproto.repo.listRecords" + } + } + + var httpMethod: HTTPMethod { + switch self { + case .createRecord, .putRecord, .deleteRecord: + return .post + case .getRecord, .listRecords: + return .get + } + } + + var task: HTTPTask { + switch self { + case .createRecord(let body), .putRecord(let body), .deleteRecord(let body): + return .requestParameters(encoding: .jsonDataEncoding(data: body)) + + case .getRecord(let repo, let collection, let rkey, _): + return .requestParameters(encoding: .urlEncoding(parameters: [ + "repo": repo, + "collection": collection, + "rkey": rkey + ])) + + case .listRecords(let repo, let collection, let limit, let cursor, _): + var params: Parameters = ["repo": repo, "collection": collection, "limit": limit] + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + } + } + + var headers: HTTPHeaders? { nil } +} diff --git a/Sources/CoreATProtocol/Repo/ATRepoService.swift b/Sources/CoreATProtocol/Repo/ATRepoService.swift new file mode 100644 index 0000000..51255fd --- /dev/null +++ b/Sources/CoreATProtocol/Repo/ATRepoService.swift @@ -0,0 +1,226 @@ +// +// ATRepoService.swift +// CoreATProtocol +// + +import Foundation +import NetworkingKit + +/// Generic repository record CRUD against com.atproto.repo.* endpoints. +/// +/// This is the shared, lexicon-agnostic record layer: higher-level packages +/// (bskyKit, EffemKit, AvocadoughKit, …) build their typed records on top +/// of it instead of each reimplementing the XRPC calls. +/// +/// Writes are authenticated against the session host (the signed-in user's PDS). +/// Reads default to the session host but accept an explicit `host` to fetch +/// public records from any identity's PDS — pass the `pdsEndpoint` from a +/// ``IdentityResolver/ResolvedIdentity``. Explicit-host reads are performed +/// unauthenticated, since session tokens are bound to the session PDS. +@APActor +public struct ATRepoService: Sendable { + public init() {} + + // MARK: - Writes (session host, authenticated) + + /// Creates a new record in the signed-in user's repository. + public func createRecord( + repo: String, + collection: String, + record: [String: Any], + rkey: String? = nil, + validate: Bool? = nil + ) async throws -> ATCreateRecordResponse { + var body: [String: Any] = [ + "repo": repo, + "collection": collection, + "record": record + ] + if let rkey { body["rkey"] = rkey } + if let validate { body["validate"] = validate } + + let data = try JSONSerialization.data(withJSONObject: body) + return try await executeAuthenticated(.createRecord(body: data)) + } + + /// Writes a record, creating or updating it for the given record key. + public func putRecord( + repo: String, + collection: String, + rkey: String, + record: [String: Any], + validate: Bool? = nil, + swapRecord: String? = nil, + swapCommit: String? = nil + ) async throws -> ATPutRecordResponse { + var body: [String: Any] = [ + "repo": repo, + "collection": collection, + "rkey": rkey, + "record": record + ] + if let validate { body["validate"] = validate } + if let swapRecord { body["swapRecord"] = swapRecord } + if let swapCommit { body["swapCommit"] = swapCommit } + + let data = try JSONSerialization.data(withJSONObject: body) + return try await executeAuthenticated(.putRecord(body: data)) + } + + /// Deletes a record from the signed-in user's repository. + public func deleteRecord( + repo: String, + collection: String, + rkey: String + ) async throws { + let body: [String: Any] = [ + "repo": repo, + "collection": collection, + "rkey": rkey + ] + let data = try JSONSerialization.data(withJSONObject: body) + let _: ATEmptyResponse = try await executeAuthenticated(.deleteRecord(body: data)) + } + + // MARK: - Reads + + /// Gets a single record, decoding its value as `Value`. + /// + /// - Parameter host: PDS to read from. `nil` reads the session host + /// (authenticated); an explicit host is read unauthenticated. + public func getRecord( + repo: String, + collection: String, + rkey: String, + host: String? = nil + ) async throws -> ATRecord { + let endpoint = ATRepoAPI.getRecord(repo: repo, collection: collection, rkey: rkey, host: host) + if host != nil { + return try await executePublic(endpoint) + } + return try await executeAuthenticated(endpoint) + } + + /// Lists records in a collection, decoding each value as `Value`. + /// + /// - Parameter host: PDS to read from. `nil` reads the session host + /// (authenticated); an explicit host is read unauthenticated. + public func listRecords( + repo: String, + collection: String, + limit: Int = 50, + cursor: String? = nil, + host: String? = nil + ) async throws -> ATRecordList { + let endpoint = ATRepoAPI.listRecords(repo: repo, collection: collection, limit: limit, cursor: cursor, host: host) + if host != nil { + return try await executePublic(endpoint) + } + return try await executeAuthenticated(endpoint) + } + + // MARK: - Execution + + private func executeAuthenticated(_ endpoint: ATRepoAPI) async throws -> T { + guard let host = ATProtoSession.shared.host, URL(string: host) != nil else { + throw ATRepoError.sessionHostNotConfigured + } + let delegate = ATProtoSession.shared.routerDelegate + return try await ATRepoRouterCache.authenticated(delegate: delegate).execute(endpoint) + } + + private func executePublic(_ endpoint: ATRepoAPI) async throws -> T { + try await ATRepoRouterCache.public().execute(endpoint) + } +} + +// MARK: - Router Cache + +@NetworkingKitActor +private enum ATRepoRouterCache { + private static var _authenticatedRouter: NetworkRouter? + private static var _publicRouter: NetworkRouter? + + static func authenticated(delegate: NetworkRouterDelegate) -> NetworkRouter { + if let router = _authenticatedRouter { + router.delegate = delegate + return router + } + let router = NetworkRouter(decoder: .atDecoder) + router.delegate = delegate + _authenticatedRouter = router + return router + } + + /// Router with no delegate: no auth headers are attached and no + /// refresh/retry is attempted. Used for public reads of other PDSs. + static func `public`() -> NetworkRouter { + if let router = _publicRouter { + return router + } + let router = NetworkRouter(decoder: .atDecoder) + _publicRouter = router + return router + } +} + +// MARK: - Response Types + +public struct ATCreateRecordResponse: Codable, Sendable { + public let uri: String + public let cid: String +} + +public struct ATPutRecordResponse: Codable, Sendable { + public let uri: String + public let cid: String + public let validationStatus: String? +} + +/// A single record with its value decoded as a caller-supplied type. +public struct ATRecord: Decodable, Sendable { + public let uri: String + public let cid: String? + public let value: Value +} + +public struct ATRecordList: Decodable, Sendable { + public let records: [ATRecord] + public let cursor: String? +} + +struct ATEmptyResponse: Decodable, Sendable {} + +// MARK: - Errors + +public enum ATRepoError: Error, LocalizedError, Sendable { + case sessionHostNotConfigured + + public var errorDescription: String? { + switch self { + case .sessionHostNotConfigured: + return "AT Protocol host is not configured. Call setup(hostURL:accessJWT:refreshJWT:) first." + } + } +} + +// MARK: - Error Introspection + +public extension Error { + /// The AT Protocol error name (e.g. `"RecordNotFound"`) carried in an XRPC + /// error response body, if this error wraps one. + var atProtoErrorName: String? { + let data: Data? + if let networkError = self as? NetworkError, + case .statusCode(_, let d, _) = networkError { + data = d + } else if case .network(let networkError) = self as? AtError, + case .statusCode(_, let d, _) = networkError { + data = d + } else { + data = nil + } + guard let data else { return nil } + return (try? JSONDecoder().decode(ErrorMessage.self, from: data))?.error + } +} diff --git a/Tests/CoreATProtocolTests/ATRepoServiceTests.swift b/Tests/CoreATProtocolTests/ATRepoServiceTests.swift new file mode 100644 index 0000000..380f6dc --- /dev/null +++ b/Tests/CoreATProtocolTests/ATRepoServiceTests.swift @@ -0,0 +1,127 @@ +// +// ATRepoServiceTests.swift +// CoreATProtocol +// + +import Foundation +import Testing +import NetworkingKit +@testable import CoreATProtocol + +private struct StubRecordValue: Decodable, Sendable, Equatable { + let type: String + let note: String + let amount: Int + + enum CodingKeys: String, CodingKey { + case type = "$type" + case note, amount + } +} + +@Suite("ATRepoService models") +struct ATRepoServiceModelTests { + + @Test("ATRecord decodes a getRecord response with a caller-supplied value type") + func decodeRecord() throws { + let json = """ + { + "uri": "at://did:plc:abc123/com.example.test/self", + "cid": "bafyreib2rxk3rh6kzwq", + "value": { + "$type": "com.example.test", + "note": "hello", + "amount": 21 + } + } + """ + let record = try JSONDecoder.atDecoder.decode(ATRecord.self, from: Data(json.utf8)) + + #expect(record.uri == "at://did:plc:abc123/com.example.test/self") + #expect(record.cid == "bafyreib2rxk3rh6kzwq") + #expect(record.value == StubRecordValue(type: "com.example.test", note: "hello", amount: 21)) + } + + @Test("ATRecord decodes when cid is absent") + func decodeRecordWithoutCID() throws { + let json = """ + { + "uri": "at://did:plc:abc123/com.example.test/self", + "value": { "$type": "com.example.test", "note": "x", "amount": 1 } + } + """ + let record = try JSONDecoder.atDecoder.decode(ATRecord.self, from: Data(json.utf8)) + #expect(record.cid == nil) + } + + @Test("ATRecordList decodes records and cursor") + func decodeRecordList() throws { + let json = """ + { + "records": [ + { + "uri": "at://did:plc:abc123/com.example.test/1", + "cid": "bafyone", + "value": { "$type": "com.example.test", "note": "first", "amount": 1 } + }, + { + "uri": "at://did:plc:abc123/com.example.test/2", + "cid": "bafytwo", + "value": { "$type": "com.example.test", "note": "second", "amount": 2 } + } + ], + "cursor": "next-page" + } + """ + let list = try JSONDecoder.atDecoder.decode(ATRecordList.self, from: Data(json.utf8)) + + #expect(list.records.count == 2) + #expect(list.records[1].value.note == "second") + #expect(list.cursor == "next-page") + } + + @Test("ATPutRecordResponse decodes with and without validationStatus") + func decodePutRecordResponse() throws { + let withStatus = """ + { "uri": "at://did:plc:abc/com.example/self", "cid": "bafy", "validationStatus": "unknown" } + """ + let withoutStatus = """ + { "uri": "at://did:plc:abc/com.example/self", "cid": "bafy" } + """ + + let first = try JSONDecoder.atDecoder.decode(ATPutRecordResponse.self, from: Data(withStatus.utf8)) + let second = try JSONDecoder.atDecoder.decode(ATPutRecordResponse.self, from: Data(withoutStatus.utf8)) + + #expect(first.validationStatus == "unknown") + #expect(second.validationStatus == nil) + } +} + +@Suite("AT Protocol error introspection") +struct ATProtoErrorNameTests { + + @Test("atProtoErrorName extracts the XRPC error name from a NetworkError body") + func errorNameFromNetworkError() { + let body = Data(#"{"error":"RecordNotFound","message":"Could not locate record"}"#.utf8) + let error = NetworkError.statusCode(StatusCode(rawValue: 400), data: body, request: nil) + + #expect(error.atProtoErrorName == "RecordNotFound") + } + + @Test("atProtoErrorName extracts the XRPC error name from an AtError-wrapped NetworkError") + func errorNameFromAtError() { + let body = Data(#"{"error":"RecordNotFound","message":"Could not locate record"}"#.utf8) + let error = AtError.network(.statusCode(StatusCode(rawValue: 400), data: body, request: nil)) + + #expect(error.atProtoErrorName == "RecordNotFound") + } + + @Test("atProtoErrorName is nil for non-XRPC errors") + func errorNameForUnrelatedError() { + #expect(IdentityError.invalidHandle.atProtoErrorName == nil) + + let nonJSONBody = Data("plain text".utf8) + let error = NetworkError.statusCode(StatusCode(rawValue: 500), data: nonJSONBody, request: nil) + #expect(error.atProtoErrorName == nil) + } +}