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.

refactor(atproto): XRPCClient review polish — trailing-slash URLs, drop inout, sort query params

+45 -13
+28 -13
Packages/ATProto/Sources/ATProto/XRPC/XRPCClient.swift
··· 18 18 let url = try buildURL(nsid: nsid, params: params) 19 19 var request = URLRequest(url: url) 20 20 request.httpMethod = "GET" 21 - return try await send(request: &request) 21 + return try await send(request) 22 22 } 23 23 24 24 public func getBytes(nsid: String, params: [String: String]?) async throws -> (Data, URLResponse) { 25 25 let url = try buildURL(nsid: nsid, params: params) 26 26 var request = URLRequest(url: url) 27 27 request.httpMethod = "GET" 28 - return try await sendRaw(request: &request) 28 + return try await sendRaw(request) 29 29 } 30 30 31 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)" 32 + guard var comps = URLComponents(url: service, resolvingAgainstBaseURL: false) else { 33 + throw ATProtoError.invalidURL(service.absoluteString) 34 + } 35 + let raw = comps.path 36 + let basePath: String 37 + if raw.isEmpty || raw == "/" { 38 + basePath = "/" 39 + } else if raw.hasSuffix("/") { 40 + basePath = raw 41 + } else { 42 + basePath = raw + "/" 43 + } 44 + comps.path = basePath + "xrpc/\(nsid)" 36 45 if let params, !params.isEmpty { 37 - comps!.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) } 46 + // Sort keys for deterministic query order — eases debugging + test assertions. 47 + comps.queryItems = params.sorted { $0.key < $1.key } 48 + .map { URLQueryItem(name: $0.key, value: $0.value) } 38 49 } 39 - guard let url = comps!.url else { throw ATProtoError.invalidURL(nsid) } 50 + guard let url = comps.url else { throw ATProtoError.invalidURL(nsid) } 40 51 return url 41 52 } 42 53 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) 54 + func send<Output: Decodable>(_ request: URLRequest) async throws -> Output { 55 + let (data, response) = try await sendRaw(request) 56 + return try decode(data) 46 57 } 47 58 48 - func sendRaw(request: inout URLRequest) async throws -> (Data, URLResponse) { 59 + func sendRaw(_ request: URLRequest) async throws -> (Data, URLResponse) { 60 + var request = request 49 61 if let headers = try await auth.authHeaders(for: request) { 50 62 for (k, v) in headers { request.addValue(v, forHTTPHeaderField: k) } 51 63 } ··· 55 67 throw ATProtoError.http(status: 0, body: nil) 56 68 } 57 69 if !(200..<300).contains(http.statusCode) { 70 + // Envelope decode is a best-effort XRPC disambiguation. False positives 71 + // (e.g. CDN/proxy JSON error pages) are benign because .xrpc and .http 72 + // map to the same errno for the same status code. 58 73 if let envelope = try? JSONDecoder().decode(ATProtoError.XRPCEnvelope.self, from: data) { 59 74 throw ATProtoError.xrpc(code: envelope.error, message: envelope.message, status: http.statusCode) 60 75 } ··· 66 81 } 67 82 } 68 83 69 - func decode<Output: Decodable>(data: Data, response: URLResponse) throws -> Output { 84 + func decode<Output: Decodable>(_ data: Data) throws -> Output { 70 85 do { 71 86 return try JSONDecoder().decode(Output.self, from: data) 72 87 } catch {
+17
Packages/ATProto/Tests/ATProtoTests/XRPCClientTests.swift
··· 75 75 } 76 76 } 77 77 } 78 + 79 + @Test("GET respects a PDS service URL with a path prefix") 80 + func getWithPathPrefix() async throws { 81 + let session = URLProtocolStub.install { request in 82 + #expect(request.url?.absoluteString == "https://self.hosted.pds/api/xrpc/com.example.echo") 83 + return .json(#"{"value":"ok"}"#) 84 + } 85 + defer { URLProtocolStub.reset() } 86 + 87 + let client = XRPCClient( 88 + service: URL(string: "https://self.hosted.pds/api/")!, 89 + session: session, 90 + auth: AnonymousAuthTokenProvider() 91 + ) 92 + let result: Echo = try await client.get(nsid: "com.example.echo", params: nil) 93 + #expect(result == Echo(value: "ok")) 94 + } 78 95 }