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
5 changes: 5 additions & 0 deletions .changeset/putrecord-swap.md
Original file line number Diff line number Diff line change
@@ -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`.
6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 2 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions Sources/AtprotoClient/Agent/DefaultableRecordKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<R: Atproto.Record>(
_ record: R,
validate: Bool? = nil,
swapCommit: Atproto.CID? = nil,
swap: Atproto.RecordSwap?,
) async throws -> Lexicon.Com.Atproto.Repo.PutRecord<R>.Output
where R.Key: Atproto.DefaultableRecordKey {
try await call(
Lexicon.Com.Atproto.Repo.PutRecord<R>.self,
input: .init(
schema: .init(
repo: .did(did),
rkey: .defaultValue(),
record: record,
validate: validate,
swapCommit: swapCommit,
swap: swap
)
),
)
}

public func deleteRecord<R: Atproto.Record>(
//allows for type inference when clear and explicit defn when not
_: R.Type = R.self,
Expand Down
73 changes: 73 additions & 0 deletions Tests/AtprotoClientTests/PutRecordSwapTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading