Skip to content

chore: add API errors to SDK #12

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Dec 16, 2024
Merged
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 22 additions & 17 deletions Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
107 changes: 93 additions & 14 deletions Coder Desktop/Coder Desktop/SDK/Client.swift
Original file line number Diff line number Diff line change
@@ -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<T: Encodable>(
_ path: String,
method: HTTPMethod,
body: T
) async -> DataResponse<Data, AFError> {
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<Data, AFError> {
) 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)"
}
}
}
15 changes: 8 additions & 7 deletions Coder Desktop/Coder Desktop/SDK/User.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}

43 changes: 20 additions & 23 deletions Coder Desktop/Coder Desktop/Views/LoginForm.swift
Original file line number Diff line number Diff line change
@@ -37,29 +37,29 @@ struct LoginForm<C: Client, S: Session>: 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)
.onReceive(inspection.notice) { self.inspection.visit(self, $0) } // ViewInspector
}

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<C: Client, S: Session>: 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<C: Client, S: Session>: 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<C: Client, S: Session>: View {

private func back() {
withAnimation {
loginError = nil
currentPage = .serverURL
focusedField = .baseAccessURL
}
@@ -164,17 +164,14 @@ struct LoginForm<C: Client, S: Session>: 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)"
}
}
}
2 changes: 1 addition & 1 deletion Coder Desktop/Coder DesktopTests/AgentsTests.swift
Original file line number Diff line number Diff line change
@@ -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")
34 changes: 10 additions & 24 deletions Coder Desktop/Coder DesktopTests/LoginFormTests.swift
Original file line number Diff line number Diff line change
@@ -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,19 +30,19 @@ 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) }
}
}
}

@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,46 +69,32 @@ 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<MockErrorClient, MockSession>()

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) }
}
}
}

@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)
}
8 changes: 4 additions & 4 deletions Coder Desktop/Coder DesktopTests/Util.swift
Original file line number Diff line number Diff line change
@@ -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 { }
10 changes: 5 additions & 5 deletions Coder Desktop/Coder DesktopTests/VPNMenuTests.swift
Original file line number Diff line number Diff line change
@@ -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())
10 changes: 5 additions & 5 deletions Coder Desktop/Coder DesktopTests/VPNStateTests.swift
Original file line number Diff line number Diff line change
@@ -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)