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
15 changes: 15 additions & 0 deletions .changeset/mock-create-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@germ-network/atprotoclient": minor
---

Serve `com.atproto.repo.createRecord` from MockPDS. Same guards and body handling as `putRecord`, but the PDS mints the record key — a TID, so the key callers recover from `uri` parses as one. A caller-supplied `rkey` is refused with a 400 rather than honored: without the already-exists failure modeled too, honoring it would just be `putRecord` under another name. Production paths that create records (blocking, for one) were previously untestable against the mock: it 400'd on the way in.

Fixes the record `uri` the mock reports. Reads returned `at://did:web:example.com/NSID(rawValue: "app.bsky.graph.block")/<rkey>` — a hardcoded authority, and `collection` interpolated as a struct rather than its `rawValue` — and `putRecord` returned the bare placeholder `"example.com"`. Every uri is now built in one place from the repo's own DID, so create, put and read all name the same record the same way.

Fixes the `cid` that `createRecord` and `putRecord` return: it was the literal `"mock"`, which does not parse as a CID (no `b` prefix, not base32), so feeding it back to anything that takes one failed at the boundary. It is now `Atproto.CID.mock().string`, matching what reads already returned.

Raises the GermConvenience floor to 0.3.0 and aligns the mock's errors with its cleaned-up handling. The mock's generic 400s said `"Invalid Request"` — with a space, matching no atproto error name — so `parse` fell through and every one reached the caller as an opaque `.unrecognized(400 )`. They are `InvalidRequest` now, which is in `defaultErrors`, so consumers get a typed `.xrpcError` they can match on. `getRecord`'s `catch` moved off `HTTPResponseError.unsuccessfulString`, which 0.3.0 no longer throws, onto the type plus its `code` / `bodyString` accessors.

Breaking: `MockRepo.init` takes the repo's `did`. Callers that construct a `MockRepo` directly need updating; `MockPDS.host(did:bskyProfile:)` is unchanged. Anything asserting on the old `uri` or `cid` values, or matching a mock 400 as `.unrecognized`, needs updating too.

The GermConvenience floor is the one to watch downstream: SwiftPM's `from:` is `upToNextMajor` on 0.x, so this drags a consumer's whole graph onto 0.3.0, which is source-breaking for anything that mutates a `BundledHTTPRequest`. oauth4swift ≥ 0.6.0 carries its companion change; first-party mutation sites migrate to `settingHeader(_:for:)`.
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.

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ let package = Package(
),
.package(
url: "https://github.com/germ-network/GermConvenience.git",
from: "0.2.4"
from: "0.3.0"
),
.package(
url: "https://github.com/apple/swift-crypto.git",
Expand Down
32 changes: 32 additions & 0 deletions Sources/AtprotoClientMocks/MockPDS/HTTPDataResponse+Mock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,36 @@ extension HTTPDataResponse {
status: status
)
}

//Every success the mock returns carries the same envelope — 200, JSON content
//type — and five sites were building it by hand. One builder means a change to
//the envelope reaches all of them.
static func mock(json: Data) -> Self {
.init(
data: json,
response: .init(
status: .ok,
headerFields: .init(
[
.init(
name: .contentType,
value: HTTPContentType.json.rawValue
)
]
)
)
)
}

//`Data` is itself Encodable, so an already-encoded body handed to this would
//otherwise be re-encoded as a base64 string. Pass it through instead, the way
//GermConvenience's `Data.decode` special-cases the same type on the way in. An
//`@available(*, unavailable)` overload does NOT close this: the generic is
//still available, so it wins resolution and the call compiles silently.
static func mock(encoding value: some Encodable) throws -> Self {
if let json = value as? Data {
return .mock(json: json)
}
return .mock(json: try JSONEncoder().encode(value))
}
}
158 changes: 110 additions & 48 deletions Sources/AtprotoClientMocks/MockPDS/MockPDS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import AtprotoClient
import AtprotoTypes
import AtprotoTypesMocks
import Foundation
import GermConvenience

Expand All @@ -29,7 +30,7 @@ public actor MockPDS {
throw Errors.didAlreadyHostedHere
}

repos[did] = try .init(bskyProfile: bskyProfile)
repos[did] = try .init(did: did, bskyProfile: bskyProfile)

return .init(did: did, pds: self)
}
Expand Down Expand Up @@ -86,7 +87,7 @@ public actor MockPDS {
case ".well-known":
return try await handleWellKnown(path: .init(pathComponents[2...]))
default:
return try .mock(error: "Invalid Request", status: 400)
return try .mock(error: "InvalidRequest", status: 400)
}

//here is where a directory of types would be handy
Expand All @@ -105,6 +106,15 @@ public actor MockPDS {
return try await listRecords(queryItems: queryItems)
// case Lexicon.Com.Atproto.Sync.GetBlob.nsid:
// break
case Lexicon.Com.Atproto.Repo.CreateRecordNSID.nsid:
guard let authedDid else {
return try .mock(error: "Unauthorized", status: 401)
}

return try await createRecord(
authedDid: authedDid, bodyData: body.tryUnwrap
)

case Lexicon.Com.Atproto.Repo.PutRecordNSID.nsid:
guard let authedDid else {
return try .mock(error: "Unauthorized", status: 401)
Expand All @@ -123,13 +133,13 @@ public actor MockPDS {
authedDid: authedDid, bodyData: body.tryUnwrap
)
default:
return try .mock(error: "Invalid Request", status: 400)
return try .mock(error: "InvalidRequest", status: 400)
}
}

private func handleWellKnown(path: [String]) async throws -> HTTPDataResponse {
guard let component = path.first, path.count == 1 else {
return try .mock(error: "Invalid Request", status: 400)
return try .mock(error: "InvalidRequest", status: 400)
}
switch component {
case "oauth-protected-resource":
Expand All @@ -143,7 +153,7 @@ public actor MockPDS {
response: .init(status: .ok)
)
default:
return try .mock(error: "Invalid Request", status: 400)
return try .mock(error: "InvalidRequest", status: 400)
}
}

Expand Down Expand Up @@ -179,7 +189,7 @@ public actor MockPDS {
}()

guard let repo = try repos[.init(string: repoParam)] else {
return try .mock(error: "Invalid Request", status: 400)
return try .mock(error: "InvalidRequest", status: 400)
}

do {
Expand All @@ -188,11 +198,18 @@ public actor MockPDS {
encodedRkey: encodedRkey,
cid: typedCid
)
} catch HTTPResponseError.unsuccessfulString(let code, let error) {
return .init(
data: try JSONEncoder().encode(
Atproto.XRPC.ErrorResponse(error: error, message: error)),
response: .init(status: .init(code: code))
//GermConvenience 0.3.0 reports every failure as `.unsuccessful` and reads
//the body through `bodyString`; `.unsuccessfulString` is no longer thrown
//from that module. Match the type and use the accessors, so this keeps
//converting whichever case a caller hands us. The body is the message —
//the name stays a code the response parser can recognize.
} catch let failure as HTTPResponseError {
return try .mock(
errorObject: .init(
error: "InvalidRequest",
message: failure.bodyString ?? "Mock Error"
),
status: .init(code: failure.code)
)
}
}
Expand All @@ -207,7 +224,7 @@ public actor MockPDS {
let reverse = queryItems?["reverse"]

guard let repo = try repos[.init(string: repoParam)] else {
return try .mock(error: "Invalid Request", status: 400)
return try .mock(error: "InvalidRequest", status: 400)
}

return try await repo.listRecordsResponse(
Expand All @@ -223,22 +240,90 @@ public actor MockPDS {
let collection: Atproto.NSID
}

//Same guards and body handling as `putRecord`; the difference is the record
//key. Create mints one — a TID, since that is what the record keys the app
//then reads back out of `uri` have to parse as — where put takes one from the
//input.
private func createRecord(
authedDid: Atproto.DID,
bodyData: Data
) async throws -> HTTPDataResponse {
let protoSchema = try JSONDecoder().decode(ProtoSchema.self, from: bodyData)

guard case .did(let did) = protoSchema.repo else {
return try .mock(error: "InvalidRequest", status: 400)
}

guard did == authedDid else {
return try .mock(error: "Unauthorized", status: 401)
}

guard let repo = repos[authedDid] else {
return try .mock(error: "InvalidRequest", status: 400)
}

//hacky, but type-erases the record type
let input = try JSONSerialization.jsonObject(with: bodyData)
let inputDict = try (input as? [String: Any]).tryUnwrap

//The lexicon's optional rkey is deliberately NOT modeled. Honoring it
//without also modeling the already-exists failure would just be putRecord
//wearing create's name, and minting a different key anyway would strand a
//caller that asked for a specific one. Refuse it, loudly: a test that wants
//to choose the key wants `putRecord(_:input:)`.
guard inputDict["rkey"] as? String == nil else {
return try .mock(
errorObject: .init(
error: "InvalidRequest",
message:
"MockPDS mints record keys; use putRecord to choose one"
),
status: .badRequest
)
}
let rkey = Atproto.TID.mock().rawValue

let encodedRecord =
try JSONSerialization
.data(withJSONObject: inputDict["record"].tryUnwrap)

try await repo.createRecord(
collection: protoSchema.collection,
rkey: rkey,
encodedRecord: encodedRecord
)

//unlike put, the caller did not choose the key, so the uri is the only
//way it learns which record it just wrote
return try .mock(
encoding: Lexicon.Com.Atproto.Repo.PutRecordOutput(
uri: repo.recordUri(
collection: protoSchema.collection,
rkey: rkey
),
cid: Atproto.CID.mock().string,
commit: try .mock(),
validationStatus: .valid
)
)
}

private func putRecord(
authedDid: Atproto.DID,
bodyData: Data
) async throws -> HTTPDataResponse {
let protoSchema = try JSONDecoder().decode(ProtoSchema.self, from: bodyData)

guard case .did(let did) = protoSchema.repo else {
return try .mock(error: "Invalid Request", status: 400)
return try .mock(error: "InvalidRequest", status: 400)
}

guard did == authedDid else {
return try .mock(error: "Unauthorized", status: 401)
}

guard let repo = repos[authedDid] else {
return try .mock(error: "Invalid Request", status: 400)
return try .mock(error: "InvalidRequest", status: 400)
}

//hacky, but type-erases the record type
Expand All @@ -256,26 +341,16 @@ public actor MockPDS {
encodedRecord: encodedRecord
)

let returnVal = Lexicon.Com.Atproto.Repo
.PutRecordOutput(
uri: "example.com",
cid: "mock",
return try .mock(
encoding: Lexicon.Com.Atproto.Repo.PutRecordOutput(
uri: repo.recordUri(
collection: protoSchema.collection,
rkey: inputRkey
),
cid: Atproto.CID.mock().string,
commit: try .mock(),
validationStatus: .valid
)
return .init(
data: try JSONEncoder().encode(returnVal),
response: .init(
status: .ok,
headerFields: .init(
[
.init(
name: .contentType,
value: HTTPContentType.json.rawValue
)
]
)
)
)
}

Expand All @@ -294,15 +369,15 @@ public actor MockPDS {
let protoSchema = try JSONDecoder().decode(ProtoSchema.self, from: bodyData)

guard case .did(let did) = protoSchema.repo else {
return try .mock(error: "Invalid Request", status: 400)
return try .mock(error: "InvalidRequest", status: 400)
}

guard did == authedDid else {
return try .mock(error: "Unauthorized", status: 401)
}

guard let repo = repos[authedDid] else {
return try .mock(error: "Invalid Request", status: 400)
return try .mock(error: "InvalidRequest", status: 400)
}

let input = try JSONDecoder().decode(
Expand All @@ -315,26 +390,13 @@ public actor MockPDS {
rkey: input.rkey
)

let returnVal = Lexicon.Com.Atproto.Repo
.DeleteRecordOutput(
return try .mock(
encoding: Lexicon.Com.Atproto.Repo.DeleteRecordOutput(
commit: .init(
cid: .mock(),
rev: .mock()
)
)
return .init(
data: try JSONEncoder().encode(returnVal),
response: .init(
status: .ok,
headerFields: .init(
[
.init(
name: .contentType,
value: HTTPContentType.json.rawValue
)
]
)
)
)
}

Expand Down
Loading
Loading