mount public data from the atmosphere to a virtual filesystem (macos only) pdfs.at
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(atproto): add XRPCClient GET with envelope decoding

+172
+18
Packages/ATProto/Sources/ATProto/Support/AuthTokenProvider.swift
··· 1 + import Foundation 2 + 3 + public protocol AuthTokenProvider: Sendable { 4 + /// Returns auth headers to attach to the given request URL, or nil if no 5 + /// auth is available. DPoP-bound providers will supply both 6 + /// `Authorization` and `DPoP` headers. 7 + func authHeaders(for request: URLRequest) async throws -> [String: String]? 8 + 9 + /// Called when the server rejected the previous token so the provider can 10 + /// refresh before the caller retries. 11 + func reportTokenRejected() async 12 + } 13 + 14 + public struct AnonymousAuthTokenProvider: AuthTokenProvider { 15 + public init() {} 16 + public func authHeaders(for request: URLRequest) async throws -> [String: String]? { nil } 17 + public func reportTokenRejected() async {} 18 + }
+76
Packages/ATProto/Sources/ATProto/XRPC/XRPCClient.swift
··· 1 + import Foundation 2 + 3 + public struct XRPCClient: Sendable { 4 + public let service: URL 5 + let session: URLSession 6 + let auth: any AuthTokenProvider 7 + 8 + public init(service: URL, session: URLSession = .shared, auth: any AuthTokenProvider) { 9 + self.service = service 10 + self.session = session 11 + self.auth = auth 12 + } 13 + 14 + public func get<Output: Decodable>( 15 + nsid: String, 16 + params: [String: String]? 17 + ) async throws -> Output { 18 + let url = try buildURL(nsid: nsid, params: params) 19 + var request = URLRequest(url: url) 20 + request.httpMethod = "GET" 21 + return try await send(request: &request) 22 + } 23 + 24 + public func getBytes(nsid: String, params: [String: String]?) async throws -> (Data, URLResponse) { 25 + let url = try buildURL(nsid: nsid, params: params) 26 + var request = URLRequest(url: url) 27 + request.httpMethod = "GET" 28 + return try await sendRaw(request: &request) 29 + } 30 + 31 + func buildURL(nsid: String, params: [String: String]?) throws -> URL { 32 + var comps = URLComponents(url: service, resolvingAgainstBaseURL: false) 33 + guard comps != nil else { throw ATProtoError.invalidURL(service.absoluteString) } 34 + let basePath = comps!.path.isEmpty || comps!.path == "/" ? "/" : comps!.path + "/" 35 + comps!.path = basePath + "xrpc/\(nsid)" 36 + if let params, !params.isEmpty { 37 + comps!.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) } 38 + } 39 + guard let url = comps!.url else { throw ATProtoError.invalidURL(nsid) } 40 + return url 41 + } 42 + 43 + func send<Output: Decodable>(request: inout URLRequest) async throws -> Output { 44 + let (data, response) = try await sendRaw(request: &request) 45 + return try decode(data: data, response: response) 46 + } 47 + 48 + func sendRaw(request: inout URLRequest) async throws -> (Data, URLResponse) { 49 + if let headers = try await auth.authHeaders(for: request) { 50 + for (k, v) in headers { request.addValue(v, forHTTPHeaderField: k) } 51 + } 52 + do { 53 + let (data, response) = try await session.data(for: request) 54 + guard let http = response as? HTTPURLResponse else { 55 + throw ATProtoError.http(status: 0, body: nil) 56 + } 57 + if !(200..<300).contains(http.statusCode) { 58 + if let envelope = try? JSONDecoder().decode(ATProtoError.XRPCEnvelope.self, from: data) { 59 + throw ATProtoError.xrpc(code: envelope.error, message: envelope.message, status: http.statusCode) 60 + } 61 + throw ATProtoError.http(status: http.statusCode, body: data) 62 + } 63 + return (data, response) 64 + } catch let urlErr as URLError { 65 + throw ATProtoError.network(urlErr) 66 + } 67 + } 68 + 69 + func decode<Output: Decodable>(data: Data, response: URLResponse) throws -> Output { 70 + do { 71 + return try JSONDecoder().decode(Output.self, from: data) 72 + } catch { 73 + throw ATProtoError.decoding("\(error)") 74 + } 75 + } 76 + }
+78
Packages/ATProto/Tests/ATProtoTests/XRPCClientTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import ATProto 4 + 5 + @Suite("XRPCClient", .serialized) 6 + struct XRPCClientTests { 7 + struct Echo: Decodable, Equatable { 8 + let value: String 9 + } 10 + 11 + @Test("GET returns decoded success") 12 + func getSuccess() async throws { 13 + let session = URLProtocolStub.install { request in 14 + #expect(request.url?.absoluteString == "https://bsky.social/xrpc/com.example.echo?value=hi") 15 + #expect(request.httpMethod == "GET") 16 + return .json(#"{"value":"hi"}"#) 17 + } 18 + defer { URLProtocolStub.reset() } 19 + 20 + let client = XRPCClient( 21 + service: URL(string: "https://bsky.social")!, 22 + session: session, 23 + auth: AnonymousAuthTokenProvider() 24 + ) 25 + let result: Echo = try await client.get(nsid: "com.example.echo", params: ["value": "hi"]) 26 + #expect(result == Echo(value: "hi")) 27 + } 28 + 29 + @Test("GET decodes XRPC error envelope on 4xx") 30 + func getXRPCError() async throws { 31 + let session = URLProtocolStub.install { _ in 32 + .json(#"{"error":"InvalidRequest","message":"bad"}"#, status: 400) 33 + } 34 + defer { URLProtocolStub.reset() } 35 + 36 + let client = XRPCClient( 37 + service: URL(string: "https://bsky.social")!, 38 + session: session, 39 + auth: AnonymousAuthTokenProvider() 40 + ) 41 + do { 42 + let _: Echo = try await client.get(nsid: "com.example.echo", params: nil) 43 + Issue.record("expected throw") 44 + } catch let err as ATProtoError { 45 + if case .xrpc(let code, let message, let status) = err { 46 + #expect(code == "InvalidRequest") 47 + #expect(message == "bad") 48 + #expect(status == 400) 49 + } else { 50 + Issue.record("wrong error case: \(err)") 51 + } 52 + } 53 + } 54 + 55 + @Test("GET falls back to .http on non-JSON 5xx body") 56 + func getHttpFallback() async throws { 57 + let session = URLProtocolStub.install { _ in 58 + .text("upstream down", status: 502) 59 + } 60 + defer { URLProtocolStub.reset() } 61 + 62 + let client = XRPCClient( 63 + service: URL(string: "https://bsky.social")!, 64 + session: session, 65 + auth: AnonymousAuthTokenProvider() 66 + ) 67 + do { 68 + let _: Echo = try await client.get(nsid: "com.example.echo", params: nil) 69 + Issue.record("expected throw") 70 + } catch let err as ATProtoError { 71 + if case .http(let status, _) = err { 72 + #expect(status == 502) 73 + } else { 74 + Issue.record("wrong error case: \(err)") 75 + } 76 + } 77 + } 78 + }