diff --git a/Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cb887ce..065b859 100644 --- a/Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "42dc2e0a0e0417a7f4f62b3e875c9559038beef7d2265073dd4fc81f2e11ee13", + "originHash" : "aa8dd97dc6e28dedc4a5c45c435467a247486474bf3c1caf5e67085d52325132", "pins" : [ { "identity" : "alamofire", diff --git a/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift b/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift index e21e8c6..336df0c 100644 --- a/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift +++ b/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift @@ -1,24 +1,29 @@ import SwiftUI +import Alamofire struct PreviewClient: Client { init(url _: URL, token _: String? = nil) {} - func user(_: String) async throws -> User { - try await Task.sleep(for: .seconds(1)) - return User( - id: UUID(), - username: "admin", - avatar_url: "", - name: "admin", - email: "admin@coder.com", - created_at: Date.now, - updated_at: Date.now, - last_seen_at: Date.now, - status: "active", - login_type: "none", - theme_preference: "dark", - organization_ids: [], - roles: [] - ) + func user(_: String) async throws(ClientError) -> User { + do { + try await Task.sleep(for: .seconds(1)) + return User( + id: UUID(), + username: "admin", + avatar_url: "", + name: "admin", + email: "admin@coder.com", + created_at: Date.now, + updated_at: Date.now, + last_seen_at: Date.now, + status: "active", + login_type: "none", + theme_preference: "dark", + organization_ids: [], + roles: [] + ) + } catch { + throw ClientError.reqError(AFError.explicitlyCancelled) + } } } diff --git a/Coder Desktop/Coder Desktop/SDK/Client.swift b/Coder Desktop/Coder Desktop/SDK/Client.swift index 0e32e91..8f5e2c0 100644 --- a/Coder Desktop/Coder Desktop/SDK/Client.swift +++ b/Coder Desktop/Coder Desktop/SDK/Client.swift @@ -3,7 +3,7 @@ import Foundation protocol Client { init(url: URL, token: String?) - func user(_ ident: String) async throws -> User + func user(_ ident: String) async throws(ClientError) -> User } struct CoderClient: Client { @@ -25,38 +25,117 @@ struct CoderClient: Client { func request( _ path: String, method: HTTPMethod, - body: T - ) async -> DataResponse { + body: T? = nil + ) async throws(ClientError) -> HTTPResponse { let url = self.url.appendingPathComponent(path) - let headers: HTTPHeaders = [Headers.sessionToken: token ?? ""] - return await AF.request( + let headers: HTTPHeaders? = token.map { [Headers.sessionToken: $0] } + let out = await AF.request( url, method: method, parameters: body, - encoder: JSONParameterEncoder.default, headers: headers ).serializingData().response + switch out.result { + case .success(let data): + return HTTPResponse(resp: out.response!, data: data, req: out.request) + case .failure(let error): + throw ClientError.reqError(error) + } } func request( _ path: String, method: HTTPMethod - ) async -> DataResponse { + ) async throws(ClientError) -> HTTPResponse { let url = self.url.appendingPathComponent(path) - let headers: HTTPHeaders = [Headers.sessionToken: token ?? ""] - return await AF.request( + let headers: HTTPHeaders? = token.map { [Headers.sessionToken: $0] } + let out = await AF.request( url, method: method, headers: headers ).serializingData().response + switch out.result { + case .success(let data): + return HTTPResponse(resp: out.response!, data: data, req: out.request) + case .failure(let error): + throw ClientError.reqError(error) + } } + + func responseAsError(_ resp: HTTPResponse) -> ClientError { + do { + let body = try CoderClient.decoder.decode(Response.self, from: resp.data) + let out = APIError( + response: body, + statusCode: resp.resp.statusCode, + method: resp.req?.httpMethod, + url: resp.req?.url + ) + return ClientError.apiError(out) + } catch { + return ClientError.unexpectedResponse(resp.data[...1024]) + } + } + + enum Headers { + static let sessionToken = "Coder-Session-Token" + } + } -enum ClientError: Error { - case unexpectedStatusCode - case badResponse +struct HTTPResponse { + let resp: HTTPURLResponse + let data: Data + let req: URLRequest? } -enum Headers { - static let sessionToken = "Coder-Session-Token" +struct APIError: Decodable { + let response: Response + let statusCode: Int + let method: String? + let url: URL? + + var description: String { + var components: [String] = [] + if let method = method, let url = url { + components.append("\(method) \(url.absoluteString)") + } + components.append("Unexpected status code \(statusCode):\n\(response.message)") + if let detail = response.detail { + components.append("\tError: \(detail)") + } + if let validations = response.validations, !validations.isEmpty { + let validationMessages = validations.map { "\t\($0.field): \($0.detail)" } + components.append(contentsOf: validationMessages) + } + return components.joined(separator: "\n") + } +} + +struct Response: Decodable { + let message: String + let detail: String? + let validations: [ValidationError]? +} + +struct ValidationError: Decodable { + let field: String + let detail: String +} + +enum ClientError: Error { + case apiError(APIError) + case reqError(AFError) + case unexpectedResponse(Data) + + var description: String { + switch self { + case .apiError(let error): + return error.description + case .reqError(let error): + return error.localizedDescription + case .unexpectedResponse(let data): + return "Unexpected response: \(data)" + } + } } diff --git a/Coder Desktop/Coder Desktop/SDK/User.swift b/Coder Desktop/Coder Desktop/SDK/User.swift index 4ca26eb..f9f20fa 100644 --- a/Coder Desktop/Coder Desktop/SDK/User.swift +++ b/Coder Desktop/Coder Desktop/SDK/User.swift @@ -1,15 +1,16 @@ import Foundation extension CoderClient { - func user(_ ident: String) async throws -> User { - let resp = await request("/api/v2/users/\(ident)", method: .get) - guard let response = resp.response, response.statusCode == 200 else { - throw ClientError.unexpectedStatusCode + func user(_ ident: String) async throws(ClientError) -> User { + let res = try await request("/api/v2/users/\(ident)", method: .get) + guard res.resp.statusCode == 200 else { + throw responseAsError(res) } - guard let data = resp.data else { - throw ClientError.badResponse + do { + return try CoderClient.decoder.decode(User.self, from: res.data) + } catch { + throw ClientError.unexpectedResponse(res.data[...1024]) } - return try CoderClient.decoder.decode(User.self, from: data) } } diff --git a/Coder Desktop/Coder Desktop/Views/LoginForm.swift b/Coder Desktop/Coder Desktop/Views/LoginForm.swift index 2888d0b..c4fe153 100644 --- a/Coder Desktop/Coder Desktop/Views/LoginForm.swift +++ b/Coder Desktop/Coder Desktop/Views/LoginForm.swift @@ -37,19 +37,21 @@ struct LoginForm: View { } .animation(.easeInOut, value: currentPage) .onAppear { - loginError = nil baseAccessURL = session.baseAccessURL?.absoluteString ?? baseAccessURL sessionToken = "" - }.padding(.top, 35) - VStack(alignment: .center) { - if let loginError { - Text("\(loginError.description)") - .font(.headline) - .foregroundColor(.red) - .multilineTextAlignment(.center) + }.padding(.vertical, 35) + .alert("Error", isPresented: Binding( + get: { loginError != nil }, + set: { isPresented in + if !isPresented { + loginError = nil + } + } + )) { + Button("OK", role: .cancel) {}.keyboardShortcut(.defaultAction) + } message: { + Text(loginError?.description ?? "") } - } - .frame(height: 35) }.padding() .frame(width: 450, height: 220) .disabled(loading) @@ -57,9 +59,7 @@ struct LoginForm: View { } internal func submit() async { - loginError = nil guard sessionToken != "" else { - loginError = .invalidToken return } guard let url = URL(string: baseAccessURL), url.scheme == "https" else { @@ -69,11 +69,10 @@ struct LoginForm: View { loading = true defer { loading = false} let client = C(url: url, token: sessionToken) - do { + do throws(ClientError) { _ = try await client.user("me") } catch { - loginError = .failedAuth - print("Set error") + loginError = .failedAuth(error) return } session.store(baseAccessURL: url, sessionToken: sessionToken) @@ -142,7 +141,9 @@ struct LoginForm: View { } private func next() { - loginError = nil + guard baseAccessURL != "" else { + return + } guard let url = URL(string: baseAccessURL), url.scheme == "https" else { loginError = .invalidURL return @@ -155,7 +156,6 @@ struct LoginForm: View { private func back() { withAnimation { - loginError = nil currentPage = .serverURL focusedField = .baseAccessURL } @@ -164,17 +164,14 @@ struct LoginForm: View { enum LoginError { case invalidURL - case invalidToken - case failedAuth + case failedAuth(ClientError) var description: String { switch self { case .invalidURL: return "Invalid URL" - case .invalidToken: - return "Invalid Session Token" - case .failedAuth: - return "Could not authenticate with Coder deployment" + case .failedAuth(let err): + return "Could not authenticate with Coder deployment:\n\(err.description)" } } } diff --git a/Coder Desktop/Coder DesktopTests/AgentsTests.swift b/Coder Desktop/Coder DesktopTests/AgentsTests.swift index e6c679a..2563362 100644 --- a/Coder Desktop/Coder DesktopTests/AgentsTests.swift +++ b/Coder Desktop/Coder DesktopTests/AgentsTests.swift @@ -56,7 +56,7 @@ struct AgentsTests { vpn.state = .connected vpn.agents = createMockAgents(count: 7) - try await ViewHosting.host(view) { _ in + try await ViewHosting.host(view) { try await sut.inspection.inspect { view in var toggle = try view.find(ViewType.Toggle.self) #expect(try toggle.labelView().text().string() == "Show All") diff --git a/Coder Desktop/Coder DesktopTests/LoginFormTests.swift b/Coder Desktop/Coder DesktopTests/LoginFormTests.swift index 3f3b547..3bdbf6a 100644 --- a/Coder Desktop/Coder DesktopTests/LoginFormTests.swift +++ b/Coder Desktop/Coder DesktopTests/LoginFormTests.swift @@ -18,7 +18,7 @@ struct LoginTests { @Test @MainActor func testInitialView() async throws { - try await ViewHosting.host(view) { _ in + try await ViewHosting.host(view) { try await sut.inspection.inspect { view in #expect(throws: Never.self) { try view.find(text: "Coder Desktop") } #expect(throws: Never.self) { try view.find(text: "Server URL") } @@ -30,11 +30,11 @@ struct LoginTests { @Test @MainActor func testInvalidServerURL() async throws { - try await ViewHosting.host(view) { _ in + try await ViewHosting.host(view) { try await sut.inspection.inspect { view in - try view.find(ViewType.TextField.self).setInput("") + try view.find(ViewType.TextField.self).setInput("http://") try view.find(button: "Next").tap() - #expect(throws: Never.self) { try view.find(text: "Invalid URL") } + #expect(throws: Never.self) { try view.find(ViewType.Alert.self) } } } } @@ -42,7 +42,7 @@ struct LoginTests { @Test @MainActor func testValidServerURL() async throws { - try await ViewHosting.host(view) { _ in + try await ViewHosting.host(view) { try await sut.inspection.inspect { view in try view.find(ViewType.TextField.self).setInput("https://coder.example.com") try view.find(button: "Next").tap() @@ -57,7 +57,7 @@ struct LoginTests { @Test @MainActor func testBackButton() async throws { - try await ViewHosting.host(view) { _ in + try await ViewHosting.host(view) { try await sut.inspection.inspect { view in try view.find(ViewType.TextField.self).setInput("https://coder.example.com") try view.find(button: "Next").tap() @@ -69,33 +69,19 @@ struct LoginTests { } } - @Test - @MainActor - func testInvalidSessionToken() async throws { - try await ViewHosting.host(view) { _ in - try await sut.inspection.inspect { view in - try view.find(ViewType.TextField.self).setInput("https://coder.example.com") - try view.find(button: "Next").tap() - try view.find(ViewType.SecureField.self).setInput("") - try await view.actualView().submit() - #expect(throws: Never.self) { try view.find(text: "Invalid Session Token") } - } - } - } - @Test @MainActor func testFailedAuthentication() async throws { let login = LoginForm() - try await ViewHosting.host(login.environmentObject(session)) { _ in + try await ViewHosting.host(login.environmentObject(session)) { try await login.inspection.inspect { view in try view.find(ViewType.TextField.self).setInput("https://coder.example.com") try view.find(button: "Next").tap() #expect(throws: Never.self) { try view.find(text: "Session Token") } try view.find(ViewType.SecureField.self).setInput("valid-token") try await view.actualView().submit() - #expect(throws: Never.self) { try view.find(text: "Could not authenticate with Coder deployment") } + #expect(throws: Never.self) { try view.find(ViewType.Alert.self) } } } } @@ -103,12 +89,12 @@ struct LoginTests { @Test @MainActor func testSuccessfulLogin() async throws { - try await ViewHosting.host(view) { _ in + try await ViewHosting.host(view) { try await sut.inspection.inspect { view in try view.find(ViewType.TextField.self).setInput("https://coder.example.com") try view.find(button: "Next").tap() try view.find(ViewType.SecureField.self).setInput("valid-token") - try view.find(button: "Sign In").tap() + try await view.actualView().submit() #expect(session.hasSession) } diff --git a/Coder Desktop/Coder DesktopTests/Util.swift b/Coder Desktop/Coder DesktopTests/Util.swift index cab9a8f..f902dce 100644 --- a/Coder Desktop/Coder DesktopTests/Util.swift +++ b/Coder Desktop/Coder DesktopTests/Util.swift @@ -47,7 +47,7 @@ class MockSession: Session { struct MockClient: Client { init(url _: URL, token _: String? = nil) {} - func user(_: String) async throws -> Coder_Desktop.User { + func user(_: String) async throws(ClientError) -> Coder_Desktop.User { User( id: UUID(), username: "admin", @@ -68,9 +68,9 @@ struct MockClient: Client { struct MockErrorClient: Client { init(url: URL, token: String?) {} - func user(_ ident: String) async throws -> Coder_Desktop.User { - throw ClientError.badResponse + func user(_ ident: String) async throws(ClientError) -> Coder_Desktop.User { + throw ClientError.reqError(.explicitlyCancelled) } } -extension Inspection: @retroactive InspectionEmissary { } +extension Inspection: @unchecked Sendable, @retroactive InspectionEmissary { } diff --git a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift index f7d482c..fb975e0 100644 --- a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift +++ b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift @@ -22,7 +22,7 @@ struct VPNMenuTests { func testVPNLoggedOut() async throws { session.hasSession = false - try await ViewHosting.host(view) { _ in + try await ViewHosting.host(view) { try await sut.inspection.inspect { view in let toggle = try view.find(ViewType.Toggle.self) #expect(toggle.isDisabled()) @@ -35,7 +35,7 @@ struct VPNMenuTests { @Test @MainActor func testStartStopCalled() async throws { - try await ViewHosting.host(view) { _ in + try await ViewHosting.host(view) { try await sut.inspection.inspect { view in var toggle = try view.find(ViewType.Toggle.self) #expect(try !toggle.isOn()) @@ -62,7 +62,7 @@ struct VPNMenuTests { func testVPNDisabledWhileConnecting() async throws { vpn.state = .disabled - try await ViewHosting.host(view) { _ in + try await ViewHosting.host(view) { try await sut.inspection.inspect { view in var toggle = try view.find(ViewType.Toggle.self) #expect(try !toggle.isOn()) @@ -83,7 +83,7 @@ struct VPNMenuTests { func testVPNDisabledWhileDisconnecting() async throws { vpn.state = .disabled - try await ViewHosting.host(view) { _ in + try await ViewHosting.host(view) { try await sut.inspection.inspect { view in var toggle = try view.find(ViewType.Toggle.self) #expect(try !toggle.isOn()) @@ -108,7 +108,7 @@ struct VPNMenuTests { @Test @MainActor func testOffWhenFailed() async throws { - try await ViewHosting.host(view) { _ in + try await ViewHosting.host(view) { try await sut.inspection.inspect { view in let toggle = try view.find(ViewType.Toggle.self) #expect(try !toggle.isOn()) diff --git a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift index 5a2c960..c5a167e 100644 --- a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift +++ b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift @@ -20,7 +20,7 @@ struct VPNStateTests { func testDisabledState() async throws { vpn.state = .disabled - try await ViewHosting.host(view) { _ in + try await ViewHosting.host(view) { try await sut.inspection.inspect { view in #expect(throws: Never.self) { try view.find(text: "Enable CoderVPN to see agents") @@ -34,7 +34,7 @@ struct VPNStateTests { func testConnectingState() async throws { vpn.state = .connecting - try await ViewHosting.host(view) { _ in + try await ViewHosting.host(view) { try await sut.inspection.inspect { view in let progressView = try view.find(ViewType.ProgressView.self) #expect(try progressView.labelView().text().string() == "Starting CoderVPN...") @@ -47,7 +47,7 @@ struct VPNStateTests { func testDisconnectingState() async throws { vpn.state = .disconnecting - try await ViewHosting.host(view) { _ in + try await ViewHosting.host(view) { try await sut.inspection.inspect { view in let progressView = try view.find(ViewType.ProgressView.self) #expect(try progressView.labelView().text().string() == "Stopping CoderVPN...") @@ -60,7 +60,7 @@ struct VPNStateTests { func testFailedState() async throws { vpn.state = .failed(.exampleError) - try await ViewHosting.host(view.environmentObject(vpn)) { _ in + try await ViewHosting.host(view.environmentObject(vpn)) { try await sut.inspection.inspect { view in let text = try view.find(ViewType.Text.self) #expect(try text.string() == VPNServiceError.exampleError.description) @@ -73,7 +73,7 @@ struct VPNStateTests { func testDefaultState() async throws { vpn.state = .connected - try await ViewHosting.host(view.environmentObject(vpn)) { _ in + try await ViewHosting.host(view.environmentObject(vpn)) { try await sut.inspection.inspect { view in #expect(throws: (any Error).self) { _ = try view.find(ViewType.Text.self)