From cf7b5e6211422723a6ff55e992c37c3354091a84 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 10 Apr 2025 14:02:21 +1000 Subject: [PATCH] refactor(CoderSDK): share code between Client and AgentClient --- Coder-Desktop/Coder-Desktop/State.swift | 2 +- .../Views/FileSync/FilePicker.swift | 8 +- .../Coder-Desktop/Views/LoginForm.swift | 2 +- .../Coder-DesktopTests/FilePickerTests.swift | 4 +- .../Coder-DesktopTests/LoginFormTests.swift | 10 +- Coder-Desktop/CoderSDK/AgentClient.swift | 19 +- Coder-Desktop/CoderSDK/AgentLS.swift | 8 +- Coder-Desktop/CoderSDK/Client.swift | 208 +++++++++++------- Coder-Desktop/CoderSDK/Deployment.swift | 2 +- Coder-Desktop/CoderSDK/User.swift | 2 +- .../CoderSDKTests/CoderSDKTests.swift | 4 +- 11 files changed, 167 insertions(+), 102 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/State.swift b/Coder-Desktop/Coder-Desktop/State.swift index 39389540..aea2fe99 100644 --- a/Coder-Desktop/Coder-Desktop/State.swift +++ b/Coder-Desktop/Coder-Desktop/State.swift @@ -122,7 +122,7 @@ class AppState: ObservableObject { let client = Client(url: baseAccessURL!, token: sessionToken!) do { _ = try await client.user("me") - } catch let ClientError.api(apiErr) { + } catch let SDKError.api(apiErr) { // Expired token if apiErr.statusCode == 401 { clearSession() diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift index 4ee31a62..032a0c3b 100644 --- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift +++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift @@ -72,7 +72,7 @@ struct FilePicker: View { class FilePickerModel: ObservableObject { @Published var rootEntries: [FilePickerEntryModel] = [] @Published var rootIsLoading: Bool = false - @Published var error: ClientError? + @Published var error: SDKError? // It's important that `AgentClient` is a reference type (class) // as we were having performance issues with a struct (unless it was a binding). @@ -87,7 +87,7 @@ class FilePickerModel: ObservableObject { rootIsLoading = true Task { defer { rootIsLoading = false } - do throws(ClientError) { + do throws(SDKError) { rootEntries = try await client .listAgentDirectory(.init(path: [], relativity: .root)) .toModels(client: client) @@ -149,7 +149,7 @@ class FilePickerEntryModel: Identifiable, Hashable, ObservableObject { @Published var entries: [FilePickerEntryModel]? @Published var isLoading = false - @Published var error: ClientError? + @Published var error: SDKError? @Published private var innerIsExpanded = false var isExpanded: Bool { get { innerIsExpanded } @@ -193,7 +193,7 @@ class FilePickerEntryModel: Identifiable, Hashable, ObservableObject { innerIsExpanded = true } } - do throws(ClientError) { + do throws(SDKError) { entries = try await client .listAgentDirectory(.init(path: path, relativity: .root)) .toModels(client: client) diff --git a/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift b/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift index 8b3d3a48..d2880dda 100644 --- a/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift +++ b/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift @@ -207,7 +207,7 @@ enum LoginError: Error { case invalidURL case outdatedCoderVersion case missingServerVersion - case failedAuth(ClientError) + case failedAuth(SDKError) var description: String { switch self { diff --git a/Coder-Desktop/Coder-DesktopTests/FilePickerTests.swift b/Coder-Desktop/Coder-DesktopTests/FilePickerTests.swift index d361581e..7fde3334 100644 --- a/Coder-Desktop/Coder-DesktopTests/FilePickerTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/FilePickerTests.swift @@ -60,7 +60,7 @@ struct FilePickerTests { try Mock( url: url.appendingPathComponent("/api/v0/list-directory"), statusCode: 200, - data: [.post: Client.encoder.encode(mockResponse)] + data: [.post: CoderSDK.encoder.encode(mockResponse)] ).register() try await ViewHosting.host(view) { @@ -88,7 +88,7 @@ struct FilePickerTests { try Mock( url: url.appendingPathComponent("/api/v0/list-directory"), statusCode: 200, - data: [.post: Client.encoder.encode(mockResponse)] + data: [.post: CoderSDK.encoder.encode(mockResponse)] ).register() try await ViewHosting.host(view) { diff --git a/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift b/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift index 26f5883d..24ab1f0f 100644 --- a/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift @@ -79,7 +79,7 @@ struct LoginTests { try Mock( url: url.appendingPathComponent("/api/v2/buildinfo"), statusCode: 200, - data: [.get: Client.encoder.encode(buildInfo)] + data: [.get: CoderSDK.encoder.encode(buildInfo)] ).register() Mock(url: url.appendingPathComponent("/api/v2/users/me"), statusCode: 401, data: [.get: Data()]).register() @@ -104,13 +104,13 @@ struct LoginTests { try Mock( url: url.appendingPathComponent("/api/v2/buildinfo"), statusCode: 200, - data: [.get: Client.encoder.encode(buildInfo)] + data: [.get: CoderSDK.encoder.encode(buildInfo)] ).register() try Mock( url: url.appendingPathComponent("/api/v2/users/me"), statusCode: 200, - data: [.get: Client.encoder.encode(User(id: UUID(), username: "username"))] + data: [.get: CoderSDK.encoder.encode(User(id: UUID(), username: "username"))] ).register() try await ViewHosting.host(view) { @@ -140,13 +140,13 @@ struct LoginTests { try Mock( url: url.appendingPathComponent("/api/v2/users/me"), statusCode: 200, - data: [.get: Client.encoder.encode(user)] + data: [.get: CoderSDK.encoder.encode(user)] ).register() try Mock( url: url.appendingPathComponent("/api/v2/buildinfo"), statusCode: 200, - data: [.get: Client.encoder.encode(buildInfo)] + data: [.get: CoderSDK.encoder.encode(buildInfo)] ).register() try await ViewHosting.host(view) { diff --git a/Coder-Desktop/CoderSDK/AgentClient.swift b/Coder-Desktop/CoderSDK/AgentClient.swift index ecdd3d43..4debe383 100644 --- a/Coder-Desktop/CoderSDK/AgentClient.swift +++ b/Coder-Desktop/CoderSDK/AgentClient.swift @@ -1,7 +1,22 @@ public final class AgentClient: Sendable { - let client: Client + let agentURL: URL public init(agentHost: String) { - client = Client(url: URL(string: "http://\(agentHost):4")!) + agentURL = URL(string: "http://\(agentHost):4")! + } + + func request( + _ path: String, + method: HTTPMethod + ) async throws(SDKError) -> HTTPResponse { + try await CoderSDK.request(baseURL: agentURL, path: path, method: method) + } + + func request( + _ path: String, + method: HTTPMethod, + body: some Encodable & Sendable + ) async throws(SDKError) -> HTTPResponse { + try await CoderSDK.request(baseURL: agentURL, path: path, method: method, body: body) } } diff --git a/Coder-Desktop/CoderSDK/AgentLS.swift b/Coder-Desktop/CoderSDK/AgentLS.swift index 7110f405..0d9a2bc3 100644 --- a/Coder-Desktop/CoderSDK/AgentLS.swift +++ b/Coder-Desktop/CoderSDK/AgentLS.swift @@ -1,10 +1,10 @@ public extension AgentClient { - func listAgentDirectory(_ req: LSRequest) async throws(ClientError) -> LSResponse { - let res = try await client.request("/api/v0/list-directory", method: .post, body: req) + func listAgentDirectory(_ req: LSRequest) async throws(SDKError) -> LSResponse { + let res = try await request("/api/v0/list-directory", method: .post, body: req) guard res.resp.statusCode == 200 else { - throw client.responseAsError(res) + throw responseAsError(res) } - return try client.decode(LSResponse.self, from: res.data) + return try decode(LSResponse.self, from: res.data) } } diff --git a/Coder-Desktop/CoderSDK/Client.swift b/Coder-Desktop/CoderSDK/Client.swift index 98e1c8a9..991cdf60 100644 --- a/Coder-Desktop/CoderSDK/Client.swift +++ b/Coder-Desktop/CoderSDK/Client.swift @@ -11,95 +11,38 @@ public struct Client: Sendable { self.headers = headers } - static let decoder: JSONDecoder = { - var dec = JSONDecoder() - dec.dateDecodingStrategy = .iso8601withOptionalFractionalSeconds - return dec - }() - - static let encoder: JSONEncoder = { - var enc = JSONEncoder() - enc.dateEncodingStrategy = .iso8601withFractionalSeconds - return enc - }() - - private func doRequest( - path: String, - method: HTTPMethod, - body: Data? = nil - ) async throws(ClientError) -> HTTPResponse { - let url = url.appendingPathComponent(path) - var req = URLRequest(url: url) - if let token { req.addValue(token, forHTTPHeaderField: Headers.sessionToken) } - req.httpMethod = method.rawValue - for header in headers { - req.addValue(header.value, forHTTPHeaderField: header.name) - } - req.httpBody = body - let data: Data - let resp: URLResponse - do { - (data, resp) = try await URLSession.shared.data(for: req) - } catch { - throw .network(error) - } - guard let httpResponse = resp as? HTTPURLResponse else { - throw .unexpectedResponse(String(data: data, encoding: .utf8) ?? "") - } - return HTTPResponse(resp: httpResponse, data: data, req: req) - } - func request( _ path: String, method: HTTPMethod, body: some Encodable & Sendable - ) async throws(ClientError) -> HTTPResponse { - let encodedBody: Data? - do { - encodedBody = try Client.encoder.encode(body) - } catch { - throw .encodeFailure(error) + ) async throws(SDKError) -> HTTPResponse { + var headers = headers + if let token { + headers += [.init(name: Headers.sessionToken, value: token)] } - return try await doRequest(path: path, method: method, body: encodedBody) + return try await CoderSDK.request( + baseURL: url, + path: path, + method: method, + headers: headers, + body: body + ) } func request( _ path: String, method: HTTPMethod - ) async throws(ClientError) -> HTTPResponse { - try await doRequest(path: path, method: method) - } - - func responseAsError(_ resp: HTTPResponse) -> ClientError { - do { - let body = try decode(Response.self, from: resp.data) - let out = APIError( - response: body, - statusCode: resp.resp.statusCode, - method: resp.req.httpMethod!, - url: resp.req.url! - ) - return .api(out) - } catch { - return .unexpectedResponse(String(data: resp.data, encoding: .utf8) ?? "") - } - } - - // Wrapper around JSONDecoder.decode that displays useful error messages from `DecodingError`. - func decode(_: T.Type, from data: Data) throws(ClientError) -> T where T: Decodable { - do { - return try Client.decoder.decode(T.self, from: data) - } catch let DecodingError.keyNotFound(_, context) { - throw .unexpectedResponse("Key not found: \(context.debugDescription)") - } catch let DecodingError.valueNotFound(_, context) { - throw .unexpectedResponse("Value not found: \(context.debugDescription)") - } catch let DecodingError.typeMismatch(_, context) { - throw .unexpectedResponse("Type mismatch: \(context.debugDescription)") - } catch let DecodingError.dataCorrupted(context) { - throw .unexpectedResponse("Data corrupted: \(context.debugDescription)") - } catch { - throw .unexpectedResponse(String(data: data.prefix(1024), encoding: .utf8) ?? "") + ) async throws(SDKError) -> HTTPResponse { + var headers = headers + if let token { + headers += [.init(name: Headers.sessionToken, value: token)] } + return try await CoderSDK.request( + baseURL: url, + path: path, + method: method, + headers: headers + ) } } @@ -133,7 +76,7 @@ public struct FieldValidation: Decodable, Sendable { let detail: String } -public enum ClientError: Error { +public enum SDKError: Error { case api(APIError) case network(any Error) case unexpectedResponse(String) @@ -154,3 +97,110 @@ public enum ClientError: Error { public var localizedDescription: String { description } } + +let decoder: JSONDecoder = { + var dec = JSONDecoder() + dec.dateDecodingStrategy = .iso8601withOptionalFractionalSeconds + return dec +}() + +let encoder: JSONEncoder = { + var enc = JSONEncoder() + enc.dateEncodingStrategy = .iso8601withFractionalSeconds + return enc +}() + +func doRequest( + baseURL: URL, + path: String, + method: HTTPMethod, + headers: [HTTPHeader] = [], + body: Data? = nil +) async throws(SDKError) -> HTTPResponse { + let url = baseURL.appendingPathComponent(path) + var req = URLRequest(url: url) + req.httpMethod = method.rawValue + for header in headers { + req.addValue(header.value, forHTTPHeaderField: header.name) + } + req.httpBody = body + let data: Data + let resp: URLResponse + do { + (data, resp) = try await URLSession.shared.data(for: req) + } catch { + throw .network(error) + } + guard let httpResponse = resp as? HTTPURLResponse else { + throw .unexpectedResponse(String(data: data, encoding: .utf8) ?? "") + } + return HTTPResponse(resp: httpResponse, data: data, req: req) +} + +func request( + baseURL: URL, + path: String, + method: HTTPMethod, + headers: [HTTPHeader] = [], + body: some Encodable & Sendable +) async throws(SDKError) -> HTTPResponse { + let encodedBody: Data + do { + encodedBody = try encoder.encode(body) + } catch { + throw .encodeFailure(error) + } + return try await doRequest( + baseURL: baseURL, + path: path, + method: method, + headers: headers, + body: encodedBody + ) +} + +func request( + baseURL: URL, + path: String, + method: HTTPMethod, + headers: [HTTPHeader] = [] +) async throws(SDKError) -> HTTPResponse { + try await doRequest( + baseURL: baseURL, + path: path, + method: method, + headers: headers + ) +} + +func responseAsError(_ resp: HTTPResponse) -> SDKError { + do { + let body = try decode(Response.self, from: resp.data) + let out = APIError( + response: body, + statusCode: resp.resp.statusCode, + method: resp.req.httpMethod!, + url: resp.req.url! + ) + return .api(out) + } catch { + return .unexpectedResponse(String(data: resp.data, encoding: .utf8) ?? "") + } +} + +// Wrapper around JSONDecoder.decode that displays useful error messages from `DecodingError`. +func decode(_: T.Type, from data: Data) throws(SDKError) -> T { + do { + return try decoder.decode(T.self, from: data) + } catch let DecodingError.keyNotFound(_, context) { + throw .unexpectedResponse("Key not found: \(context.debugDescription)") + } catch let DecodingError.valueNotFound(_, context) { + throw .unexpectedResponse("Value not found: \(context.debugDescription)") + } catch let DecodingError.typeMismatch(_, context) { + throw .unexpectedResponse("Type mismatch: \(context.debugDescription)") + } catch let DecodingError.dataCorrupted(context) { + throw .unexpectedResponse("Data corrupted: \(context.debugDescription)") + } catch { + throw .unexpectedResponse(String(data: data.prefix(1024), encoding: .utf8) ?? "") + } +} diff --git a/Coder-Desktop/CoderSDK/Deployment.swift b/Coder-Desktop/CoderSDK/Deployment.swift index 8357a7eb..b88029f1 100644 --- a/Coder-Desktop/CoderSDK/Deployment.swift +++ b/Coder-Desktop/CoderSDK/Deployment.swift @@ -1,7 +1,7 @@ import Foundation public extension Client { - func buildInfo() async throws(ClientError) -> BuildInfoResponse { + func buildInfo() async throws(SDKError) -> BuildInfoResponse { let res = try await request("/api/v2/buildinfo", method: .get) guard res.resp.statusCode == 200 else { throw responseAsError(res) diff --git a/Coder-Desktop/CoderSDK/User.swift b/Coder-Desktop/CoderSDK/User.swift index ca1bbf7d..5b1efc42 100644 --- a/Coder-Desktop/CoderSDK/User.swift +++ b/Coder-Desktop/CoderSDK/User.swift @@ -1,7 +1,7 @@ import Foundation public extension Client { - func user(_ ident: String) async throws(ClientError) -> User { + func user(_ ident: String) async throws(SDKError) -> User { let res = try await request("/api/v2/users/\(ident)", method: .get) guard res.resp.statusCode == 200 else { throw responseAsError(res) diff --git a/Coder-Desktop/CoderSDKTests/CoderSDKTests.swift b/Coder-Desktop/CoderSDKTests/CoderSDKTests.swift index e7675b75..ba4194c5 100644 --- a/Coder-Desktop/CoderSDKTests/CoderSDKTests.swift +++ b/Coder-Desktop/CoderSDKTests/CoderSDKTests.swift @@ -19,7 +19,7 @@ struct CoderSDKTests { url: url.appending(path: "api/v2/users/johndoe"), contentType: .json, statusCode: 200, - data: [.get: Client.encoder.encode(user)] + data: [.get: CoderSDK.encoder.encode(user)] ) var correctHeaders = false mock.onRequestHandler = OnRequestHandler { req in @@ -45,7 +45,7 @@ struct CoderSDKTests { url: url.appending(path: "api/v2/buildinfo"), contentType: .json, statusCode: 200, - data: [.get: Client.encoder.encode(buildInfo)] + data: [.get: CoderSDK.encoder.encode(buildInfo)] ).register() let retBuildInfo = try await client.buildInfo()