diff --git a/.changeset/putrecord-swap.md b/.changeset/putrecord-swap.md new file mode 100644 index 0000000..d486f6d --- /dev/null +++ b/.changeset/putrecord-swap.md @@ -0,0 +1,5 @@ +--- +"@germ-network/atprotoclient": minor +--- + +Add `AuthCallable.putRecord(_:validate:swapCommit:swap:)` taking an `Atproto.RecordSwap?`, so a record can be written only if none exists (`.absent` sends `swapRecord: null`) or only over a specific CID. Requires the AtprotoTypes release that adds `RecordSwap`. diff --git a/Package.resolved b/Package.resolved index 94fb874..cdd0494 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "f042904a973ccd6c2ddd356d9781437690d76280b2cc2ae56c6ba1e5dff0cf5b", + "originHash" : "fb002b0e1e109d1ba8d2d8be2bda715292effc75a7594d92e7ca8102617db45d", "pins" : [ { "identity" : "atprototypes", "kind" : "remoteSourceControl", "location" : "https://github.com/germ-network/AtprotoTypes.git", "state" : { - "revision" : "46fbeeac594d236ed992cdffe547eebb9bce7ae6", - "version" : "0.7.0" + "revision" : "ecb21a26d61befd5cf41927e5a64af9e89bd162e", + "version" : "0.8.0" } }, { diff --git a/Package.swift b/Package.swift index 664844e..26b9c6a 100644 --- a/Package.swift +++ b/Package.swift @@ -17,10 +17,8 @@ let package = Package( dependencies: [ .package( url: "https://github.com/germ-network/AtprotoTypes.git", - // Temporary revision pin to the swift-crypto-5 commit - // (germ-network/AtprotoTypes#69); replace with the released version - // once it cuts. - from: "0.7.0" + // 0.8.0: Atproto.RecordSwap, for putRecord's explicit-null swapRecord. + from: "0.8.0" ), .package( url: "https://github.com/germ-network/GermConvenience.git", diff --git a/Sources/AtprotoClient/Agent/DefaultableRecordKey.swift b/Sources/AtprotoClient/Agent/DefaultableRecordKey.swift index f123b28..fe79619 100644 --- a/Sources/AtprotoClient/Agent/DefaultableRecordKey.swift +++ b/Sources/AtprotoClient/Agent/DefaultableRecordKey.swift @@ -67,6 +67,30 @@ extension Atproto.XRPC.AuthCallable { ) } + /// `swap: .absent` asks the PDS to write only if no record exists at the key (the + /// reference PDS's reading of `swapRecord: null`; see `Atproto.RecordSwap`). + public func putRecord( + _ record: R, + validate: Bool? = nil, + swapCommit: Atproto.CID? = nil, + swap: Atproto.RecordSwap?, + ) async throws -> Lexicon.Com.Atproto.Repo.PutRecord.Output + where R.Key: Atproto.DefaultableRecordKey { + try await call( + Lexicon.Com.Atproto.Repo.PutRecord.self, + input: .init( + schema: .init( + repo: .did(did), + rkey: .defaultValue(), + record: record, + validate: validate, + swapCommit: swapCommit, + swap: swap + ) + ), + ) + } + public func deleteRecord( //allows for type inference when clear and explicit defn when not _: R.Type = R.self, diff --git a/Tests/AtprotoClientTests/PutRecordSwapTests.swift b/Tests/AtprotoClientTests/PutRecordSwapTests.swift new file mode 100644 index 0000000..bef797b --- /dev/null +++ b/Tests/AtprotoClientTests/PutRecordSwapTests.swift @@ -0,0 +1,73 @@ +// +// PutRecordSwapTests.swift +// AtprotoClientTests +// + +import AtprotoClient +import AtprotoTypes +import Foundation +import GermConvenienceHTTP +import HTTPTypes +import Testing + +struct PutRecordSwapTests { + /// Captures the request body, then refuses with `InvalidSwap` so no output needs building. + final class RecordingCallable: Atproto.XRPC.AuthCallable, @unchecked Sendable { + let did: Atproto.DID + private let lock = NSLock() + private var bodies: [Data] = [] + + init(did: Atproto.DID) { self.did = did } + + var lastBody: Data? { lock.withLock { bodies.last } } + + func response(_ requestComponents: XRPCRequestComponents) async throws + -> HTTPDataResponse + { + lock.withLock { bodies.append(requestComponents.body ?? Data()) } + return HTTPDataResponse( + data: Data(#"{"error":"InvalidSwap"}"#.utf8), + response: HTTPResponse(status: .badRequest) + ) + } + } + + static let cidString = "bafyreibmcnkzbxtciyydktbatjlpgh5hollpov4zvpwtaaq353vyzfbxwu" + + private func sentBody( + swap: Atproto.RecordSwap?, + swapCommit: Atproto.CID? = nil + ) async throws -> [String: Any] { + let callable = RecordingCallable( + did: try .init(string: "did:plc:abcdefghijklmnopqrstuvwx") + ) + let profile = Lexicon.App.Bsky.Actor.Profile( + avatar: nil, banner: nil, createdAt: nil, description: nil, + displayName: "x", pronouns: nil, website: nil + ) + _ = try? await callable.putRecord(profile, swapCommit: swapCommit, swap: swap) + let body = try #require(callable.lastBody) + return try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + } + + @Test + func absentSwapSendsNullSwapRecord() async throws { + let body = try await sentBody(swap: .absent) + #expect(body.keys.contains("swapRecord")) + #expect(body["swapRecord"] is NSNull) + } + + @Test + func recordSwapSendsCidSwapRecord() async throws { + let cid = try Atproto.CID(string: Self.cidString) + let body = try await sentBody(swap: .record(cid), swapCommit: cid) + #expect(body["swapRecord"] as? String == cid.string) + #expect(body["swapCommit"] as? String == cid.string) + } + + @Test + func nilSwapOmitsSwapRecord() async throws { + let body = try await sentBody(swap: nil) + #expect(body.keys.contains("swapRecord") == false) + } +}