Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 7b04709

Browse files
committedDec 13, 2024·
chore: add API errors to SDK
1 parent c070ac7 commit 7b04709

File tree

10 files changed

+173
-100
lines changed

10 files changed

+173
-100
lines changed
 

‎Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,26 @@ import SwiftUI
33
struct PreviewClient: Client {
44
init(url _: URL, token _: String? = nil) {}
55

6-
func user(_: String) async throws -> User {
7-
try await Task.sleep(for: .seconds(1))
8-
return User(
9-
id: UUID(),
10-
username: "admin",
11-
avatar_url: "",
12-
name: "admin",
13-
email: "admin@coder.com",
14-
created_at: Date.now,
15-
updated_at: Date.now,
16-
last_seen_at: Date.now,
17-
status: "active",
18-
login_type: "none",
19-
theme_preference: "dark",
20-
organization_ids: [],
21-
roles: []
22-
)
6+
func user(_: String) async throws(ClientError) -> User {
7+
do {
8+
try await Task.sleep(for: .seconds(1))
9+
return User(
10+
id: UUID(),
11+
username: "admin",
12+
avatar_url: "",
13+
name: "admin",
14+
email: "admin@coder.com",
15+
created_at: Date.now,
16+
updated_at: Date.now,
17+
last_seen_at: Date.now,
18+
status: "active",
19+
login_type: "none",
20+
theme_preference: "dark",
21+
organization_ids: [],
22+
roles: []
23+
)
24+
} catch {
25+
throw ClientError.badResponse
26+
}
2327
}
2428
}

‎Coder Desktop/Coder Desktop/SDK/Client.swift

Lines changed: 97 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Foundation
33

44
protocol Client {
55
init(url: URL, token: String?)
6-
func user(_ ident: String) async throws -> User
6+
func user(_ ident: String) async throws(ClientError) -> User
77
}
88

99
struct CoderClient: Client {
@@ -25,38 +25,122 @@ struct CoderClient: Client {
2525
func request<T: Encodable>(
2626
_ path: String,
2727
method: HTTPMethod,
28-
body: T
29-
) async -> DataResponse<Data, AFError> {
28+
body: T? = nil
29+
) async throws(ClientError) -> HTTPResponse {
3030
let url = self.url.appendingPathComponent(path)
31-
let headers: HTTPHeaders = [Headers.sessionToken: token ?? ""]
32-
return await AF.request(
31+
let headers: HTTPHeaders? = token.map { [Headers.sessionToken: $0] }
32+
let out = await AF.request(
3333
url,
3434
method: method,
3535
parameters: body,
36-
encoder: JSONParameterEncoder.default,
3736
headers: headers
3837
).serializingData().response
38+
guard let response = out.response else {
39+
throw ClientError.noResponse
40+
}
41+
switch out.result {
42+
case .success(let data):
43+
return HTTPResponse(resp: response, data: data, req: out.request)
44+
case .failure:
45+
throw ClientError.badResponse
46+
}
3947
}
4048

4149
func request(
4250
_ path: String,
4351
method: HTTPMethod
44-
) async -> DataResponse<Data, AFError> {
52+
) async throws(ClientError) -> HTTPResponse {
4553
let url = self.url.appendingPathComponent(path)
46-
let headers: HTTPHeaders = [Headers.sessionToken: token ?? ""]
47-
return await AF.request(
54+
let headers: HTTPHeaders? = token.map { [Headers.sessionToken: $0] }
55+
let out = await AF.request(
4856
url,
4957
method: method,
5058
headers: headers
5159
).serializingData().response
60+
guard let response = out.response else {
61+
throw ClientError.noResponse
62+
}
63+
switch out.result {
64+
case .success(let data):
65+
return HTTPResponse(resp: response, data: data, req: out.request)
66+
case .failure:
67+
throw ClientError.badResponse
68+
}
5269
}
70+
71+
func responseAsError(_ resp: HTTPResponse) throws(ClientError) -> APIError {
72+
do {
73+
let body = try CoderClient.decoder.decode(Response.self, from: resp.data)
74+
return APIError(
75+
response: body,
76+
statusCode: resp.resp.statusCode,
77+
method: resp.req?.httpMethod,
78+
url: resp.req?.url
79+
)
80+
} catch {
81+
throw ClientError.badResponse
82+
}
83+
}
84+
85+
enum Headers {
86+
static let sessionToken = "Coder-Session-Token"
87+
}
88+
89+
}
90+
91+
struct HTTPResponse {
92+
let resp: HTTPURLResponse
93+
let data: Data
94+
let req: URLRequest?
95+
}
96+
97+
struct APIError: Decodable {
98+
let response: Response
99+
let statusCode: Int
100+
let method: String?
101+
let url: URL?
102+
103+
var description: String {
104+
var components: [String] = []
105+
if let method = method, let url = url {
106+
components.append("\(method) \(url.absoluteString)")
107+
}
108+
components.append("Unexpected status code \(statusCode):\n\(response.message)")
109+
if let detail = response.detail {
110+
components.append("\tError: \(detail)")
111+
}
112+
if let validations = response.validations, !validations.isEmpty {
113+
let validationMessages = validations.map { "\t\($0.field): \($0.detail)" }
114+
components.append(contentsOf: validationMessages)
115+
}
116+
return components.joined(separator: "\n")
117+
}
118+
}
119+
120+
struct Response: Decodable {
121+
let message: String
122+
let detail: String?
123+
let validations: [ValidationError]?
124+
}
125+
126+
struct ValidationError: Decodable {
127+
let field: String
128+
let detail: String
53129
}
54130

55131
enum ClientError: Error {
56-
case unexpectedStatusCode
132+
case apiError(APIError)
57133
case badResponse
58-
}
134+
case noResponse
59135

60-
enum Headers {
61-
static let sessionToken = "Coder-Session-Token"
136+
var description: String {
137+
switch self {
138+
case .apiError(let error):
139+
return error.description
140+
case .badResponse:
141+
return "Bad response"
142+
case .noResponse:
143+
return "No response"
144+
}
145+
}
62146
}

‎Coder Desktop/Coder Desktop/SDK/User.swift

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import Foundation
22

33
extension CoderClient {
4-
func user(_ ident: String) async throws -> User {
5-
let resp = await request("/api/v2/users/\(ident)", method: .get)
6-
guard let response = resp.response, response.statusCode == 200 else {
7-
throw ClientError.unexpectedStatusCode
4+
func user(_ ident: String) async throws(ClientError) -> User {
5+
let res = try await request("/api/v2/users/\(ident)", method: .get)
6+
guard res.resp.statusCode == 200 else {
7+
let error = try responseAsError(res)
8+
throw ClientError.apiError(error)
89
}
9-
guard let data = resp.data else {
10+
do {
11+
return try CoderClient.decoder.decode(User.self, from: res.data)
12+
} catch {
1013
throw ClientError.badResponse
1114
}
12-
return try CoderClient.decoder.decode(User.self, from: data)
1315
}
1416
}
1517

‎Coder Desktop/Coder Desktop/Views/LoginForm.swift

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -37,29 +37,29 @@ struct LoginForm<C: Client, S: Session>: View {
3737
}
3838
.animation(.easeInOut, value: currentPage)
3939
.onAppear {
40-
loginError = nil
4140
baseAccessURL = session.baseAccessURL?.absoluteString ?? baseAccessURL
4241
sessionToken = ""
43-
}.padding(.top, 35)
44-
VStack(alignment: .center) {
45-
if let loginError {
46-
Text("\(loginError.description)")
47-
.font(.headline)
48-
.foregroundColor(.red)
49-
.multilineTextAlignment(.center)
42+
}.padding(.vertical, 35)
43+
.alert("Error", isPresented: Binding(
44+
get: { loginError != nil },
45+
set: { isPresented in
46+
if !isPresented {
47+
loginError = nil
48+
}
49+
}
50+
)) {
51+
Button("OK", role: .cancel) {}.keyboardShortcut(.defaultAction)
52+
} message: {
53+
Text(loginError?.description ?? "")
5054
}
51-
}
52-
.frame(height: 35)
5355
}.padding()
5456
.frame(width: 450, height: 220)
5557
.disabled(loading)
5658
.onReceive(inspection.notice) { self.inspection.visit(self, $0) } // ViewInspector
5759
}
5860

5961
internal func submit() async {
60-
loginError = nil
6162
guard sessionToken != "" else {
62-
loginError = .invalidToken
6363
return
6464
}
6565
guard let url = URL(string: baseAccessURL), url.scheme == "https" else {
@@ -69,11 +69,10 @@ struct LoginForm<C: Client, S: Session>: View {
6969
loading = true
7070
defer { loading = false}
7171
let client = C(url: url, token: sessionToken)
72-
do {
72+
do throws(ClientError) {
7373
_ = try await client.user("me")
7474
} catch {
75-
loginError = .failedAuth
76-
print("Set error")
75+
loginError = .failedAuth(error)
7776
return
7877
}
7978
session.store(baseAccessURL: url, sessionToken: sessionToken)
@@ -142,7 +141,9 @@ struct LoginForm<C: Client, S: Session>: View {
142141
}
143142

144143
private func next() {
145-
loginError = nil
144+
guard baseAccessURL != "" else {
145+
return
146+
}
146147
guard let url = URL(string: baseAccessURL), url.scheme == "https" else {
147148
loginError = .invalidURL
148149
return
@@ -155,7 +156,6 @@ struct LoginForm<C: Client, S: Session>: View {
155156

156157
private func back() {
157158
withAnimation {
158-
loginError = nil
159159
currentPage = .serverURL
160160
focusedField = .baseAccessURL
161161
}
@@ -164,17 +164,14 @@ struct LoginForm<C: Client, S: Session>: View {
164164

165165
enum LoginError {
166166
case invalidURL
167-
case invalidToken
168-
case failedAuth
167+
case failedAuth(ClientError)
169168

170169
var description: String {
171170
switch self {
172171
case .invalidURL:
173172
return "Invalid URL"
174-
case .invalidToken:
175-
return "Invalid Session Token"
176-
case .failedAuth:
177-
return "Could not authenticate with Coder deployment"
173+
case .failedAuth(let err):
174+
return "Could not authenticate with Coder deployment:\n\(err.description)"
178175
}
179176
}
180177
}

‎Coder Desktop/Coder DesktopTests/AgentsTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ struct AgentsTests {
5656
vpn.state = .connected
5757
vpn.agents = createMockAgents(count: 7)
5858

59-
try await ViewHosting.host(view) { _ in
59+
try await ViewHosting.host(view) {
6060
try await sut.inspection.inspect { view in
6161
var toggle = try view.find(ViewType.Toggle.self)
6262
#expect(try toggle.labelView().text().string() == "Show All")

‎Coder Desktop/Coder DesktopTests/LoginFormTests.swift

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ struct LoginTests {
1818
@Test
1919
@MainActor
2020
func testInitialView() async throws {
21-
try await ViewHosting.host(view) { _ in
21+
try await ViewHosting.host(view) {
2222
try await sut.inspection.inspect { view in
2323
#expect(throws: Never.self) { try view.find(text: "Coder Desktop") }
2424
#expect(throws: Never.self) { try view.find(text: "Server URL") }
@@ -30,19 +30,19 @@ struct LoginTests {
3030
@Test
3131
@MainActor
3232
func testInvalidServerURL() async throws {
33-
try await ViewHosting.host(view) { _ in
33+
try await ViewHosting.host(view) {
3434
try await sut.inspection.inspect { view in
35-
try view.find(ViewType.TextField.self).setInput("")
35+
try view.find(ViewType.TextField.self).setInput("http://")
3636
try view.find(button: "Next").tap()
37-
#expect(throws: Never.self) { try view.find(text: "Invalid URL") }
37+
#expect(throws: Never.self) { try view.find(ViewType.Alert.self) }
3838
}
3939
}
4040
}
4141

4242
@Test
4343
@MainActor
4444
func testValidServerURL() async throws {
45-
try await ViewHosting.host(view) { _ in
45+
try await ViewHosting.host(view) {
4646
try await sut.inspection.inspect { view in
4747
try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
4848
try view.find(button: "Next").tap()
@@ -57,7 +57,7 @@ struct LoginTests {
5757
@Test
5858
@MainActor
5959
func testBackButton() async throws {
60-
try await ViewHosting.host(view) { _ in
60+
try await ViewHosting.host(view) {
6161
try await sut.inspection.inspect { view in
6262
try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
6363
try view.find(button: "Next").tap()
@@ -69,46 +69,32 @@ struct LoginTests {
6969
}
7070
}
7171

72-
@Test
73-
@MainActor
74-
func testInvalidSessionToken() async throws {
75-
try await ViewHosting.host(view) { _ in
76-
try await sut.inspection.inspect { view in
77-
try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
78-
try view.find(button: "Next").tap()
79-
try view.find(ViewType.SecureField.self).setInput("")
80-
try await view.actualView().submit()
81-
#expect(throws: Never.self) { try view.find(text: "Invalid Session Token") }
82-
}
83-
}
84-
}
85-
8672
@Test
8773
@MainActor
8874
func testFailedAuthentication() async throws {
8975
let login = LoginForm<MockErrorClient, MockSession>()
9076

91-
try await ViewHosting.host(login.environmentObject(session)) { _ in
77+
try await ViewHosting.host(login.environmentObject(session)) {
9278
try await login.inspection.inspect { view in
9379
try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
9480
try view.find(button: "Next").tap()
9581
#expect(throws: Never.self) { try view.find(text: "Session Token") }
9682
try view.find(ViewType.SecureField.self).setInput("valid-token")
9783
try await view.actualView().submit()
98-
#expect(throws: Never.self) { try view.find(text: "Could not authenticate with Coder deployment") }
84+
#expect(throws: Never.self) { try view.find(ViewType.Alert.self) }
9985
}
10086
}
10187
}
10288

10389
@Test
10490
@MainActor
10591
func testSuccessfulLogin() async throws {
106-
try await ViewHosting.host(view) { _ in
92+
try await ViewHosting.host(view) {
10793
try await sut.inspection.inspect { view in
10894
try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
10995
try view.find(button: "Next").tap()
11096
try view.find(ViewType.SecureField.self).setInput("valid-token")
111-
try view.find(button: "Sign In").tap()
97+
try await view.actualView().submit()
11298

11399
#expect(session.hasSession)
114100
}

‎Coder Desktop/Coder DesktopTests/Util.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ class MockSession: Session {
4747
struct MockClient: Client {
4848
init(url _: URL, token _: String? = nil) {}
4949

50-
func user(_: String) async throws -> Coder_Desktop.User {
50+
func user(_: String) async throws(ClientError) -> Coder_Desktop.User {
5151
User(
5252
id: UUID(),
5353
username: "admin",
@@ -68,9 +68,9 @@ struct MockClient: Client {
6868

6969
struct MockErrorClient: Client {
7070
init(url: URL, token: String?) {}
71-
func user(_ ident: String) async throws -> Coder_Desktop.User {
71+
func user(_ ident: String) async throws(ClientError) -> Coder_Desktop.User {
7272
throw ClientError.badResponse
7373
}
7474
}
7575

76-
extension Inspection: @retroactive InspectionEmissary { }
76+
extension Inspection: @unchecked Sendable, @retroactive InspectionEmissary { }

‎Coder Desktop/Coder DesktopTests/VPNMenuTests.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ struct VPNMenuTests {
2222
func testVPNLoggedOut() async throws {
2323
session.hasSession = false
2424

25-
try await ViewHosting.host(view) { _ in
25+
try await ViewHosting.host(view) {
2626
try await sut.inspection.inspect { view in
2727
let toggle = try view.find(ViewType.Toggle.self)
2828
#expect(toggle.isDisabled())
@@ -35,7 +35,7 @@ struct VPNMenuTests {
3535
@Test
3636
@MainActor
3737
func testStartStopCalled() async throws {
38-
try await ViewHosting.host(view) { _ in
38+
try await ViewHosting.host(view) {
3939
try await sut.inspection.inspect { view in
4040
var toggle = try view.find(ViewType.Toggle.self)
4141
#expect(try !toggle.isOn())
@@ -62,7 +62,7 @@ struct VPNMenuTests {
6262
func testVPNDisabledWhileConnecting() async throws {
6363
vpn.state = .disabled
6464

65-
try await ViewHosting.host(view) { _ in
65+
try await ViewHosting.host(view) {
6666
try await sut.inspection.inspect { view in
6767
var toggle = try view.find(ViewType.Toggle.self)
6868
#expect(try !toggle.isOn())
@@ -83,7 +83,7 @@ struct VPNMenuTests {
8383
func testVPNDisabledWhileDisconnecting() async throws {
8484
vpn.state = .disabled
8585

86-
try await ViewHosting.host(view) { _ in
86+
try await ViewHosting.host(view) {
8787
try await sut.inspection.inspect { view in
8888
var toggle = try view.find(ViewType.Toggle.self)
8989
#expect(try !toggle.isOn())
@@ -108,7 +108,7 @@ struct VPNMenuTests {
108108
@Test
109109
@MainActor
110110
func testOffWhenFailed() async throws {
111-
try await ViewHosting.host(view) { _ in
111+
try await ViewHosting.host(view) {
112112
try await sut.inspection.inspect { view in
113113
let toggle = try view.find(ViewType.Toggle.self)
114114
#expect(try !toggle.isOn())

‎Coder Desktop/Coder DesktopTests/VPNStateTests.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ struct VPNStateTests {
2020
func testDisabledState() async throws {
2121
vpn.state = .disabled
2222

23-
try await ViewHosting.host(view) { _ in
23+
try await ViewHosting.host(view) {
2424
try await sut.inspection.inspect { view in
2525
#expect(throws: Never.self) {
2626
try view.find(text: "Enable CoderVPN to see agents")
@@ -34,7 +34,7 @@ struct VPNStateTests {
3434
func testConnectingState() async throws {
3535
vpn.state = .connecting
3636

37-
try await ViewHosting.host(view) { _ in
37+
try await ViewHosting.host(view) {
3838
try await sut.inspection.inspect { view in
3939
let progressView = try view.find(ViewType.ProgressView.self)
4040
#expect(try progressView.labelView().text().string() == "Starting CoderVPN...")
@@ -47,7 +47,7 @@ struct VPNStateTests {
4747
func testDisconnectingState() async throws {
4848
vpn.state = .disconnecting
4949

50-
try await ViewHosting.host(view) { _ in
50+
try await ViewHosting.host(view) {
5151
try await sut.inspection.inspect { view in
5252
let progressView = try view.find(ViewType.ProgressView.self)
5353
#expect(try progressView.labelView().text().string() == "Stopping CoderVPN...")
@@ -60,7 +60,7 @@ struct VPNStateTests {
6060
func testFailedState() async throws {
6161
vpn.state = .failed(.exampleError)
6262

63-
try await ViewHosting.host(view.environmentObject(vpn)) { _ in
63+
try await ViewHosting.host(view.environmentObject(vpn)) {
6464
try await sut.inspection.inspect { view in
6565
let text = try view.find(ViewType.Text.self)
6666
#expect(try text.string() == VPNServiceError.exampleError.description)
@@ -73,7 +73,7 @@ struct VPNStateTests {
7373
func testDefaultState() async throws {
7474
vpn.state = .connected
7575

76-
try await ViewHosting.host(view.environmentObject(vpn)) { _ in
76+
try await ViewHosting.host(view.environmentObject(vpn)) {
7777
try await sut.inspection.inspect { view in
7878
#expect(throws: (any Error).self) {
7979
_ = try view.find(ViewType.Text.self)

0 commit comments

Comments
 (0)
Please sign in to comment.