Skip to content

Commit b338777

Browse files
chore: add API errors to SDK (#12)
1 parent eb61235 commit b338777

File tree

10 files changed

+169
-101
lines changed

10 files changed

+169
-101
lines changed

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"originHash" : "42dc2e0a0e0417a7f4f62b3e875c9559038beef7d2265073dd4fc81f2e11ee13",
2+
"originHash" : "aa8dd97dc6e28dedc4a5c45c435467a247486474bf3c1caf5e67085d52325132",
33
"pins" : [
44
{
55
"identity" : "alamofire",
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,29 @@
11
import SwiftUI
2+
import Alamofire
23

34
struct PreviewClient: Client {
45
init(url _: URL, token _: String? = nil) {}
56

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-
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-
)
7+
func user(_: String) async throws(ClientError) -> User {
8+
do {
9+
try await Task.sleep(for: .seconds(1))
10+
return User(
11+
id: UUID(),
12+
username: "admin",
13+
avatar_url: "",
14+
name: "admin",
15+
16+
created_at: Date.now,
17+
updated_at: Date.now,
18+
last_seen_at: Date.now,
19+
status: "active",
20+
login_type: "none",
21+
theme_preference: "dark",
22+
organization_ids: [],
23+
roles: []
24+
)
25+
} catch {
26+
throw ClientError.reqError(AFError.explicitlyCancelled)
27+
}
2328
}
2429
}

Coder Desktop/Coder Desktop/SDK/Client.swift

+93-14
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,117 @@ 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+
switch out.result {
39+
case .success(let data):
40+
return HTTPResponse(resp: out.response!, data: data, req: out.request)
41+
case .failure(let error):
42+
throw ClientError.reqError(error)
43+
}
3944
}
4045

4146
func request(
4247
_ path: String,
4348
method: HTTPMethod
44-
) async -> DataResponse<Data, AFError> {
49+
) async throws(ClientError) -> HTTPResponse {
4550
let url = self.url.appendingPathComponent(path)
46-
let headers: HTTPHeaders = [Headers.sessionToken: token ?? ""]
47-
return await AF.request(
51+
let headers: HTTPHeaders? = token.map { [Headers.sessionToken: $0] }
52+
let out = await AF.request(
4853
url,
4954
method: method,
5055
headers: headers
5156
).serializingData().response
57+
switch out.result {
58+
case .success(let data):
59+
return HTTPResponse(resp: out.response!, data: data, req: out.request)
60+
case .failure(let error):
61+
throw ClientError.reqError(error)
62+
}
5263
}
64+
65+
func responseAsError(_ resp: HTTPResponse) -> ClientError {
66+
do {
67+
let body = try CoderClient.decoder.decode(Response.self, from: resp.data)
68+
let out = APIError(
69+
response: body,
70+
statusCode: resp.resp.statusCode,
71+
method: resp.req?.httpMethod,
72+
url: resp.req?.url
73+
)
74+
return ClientError.apiError(out)
75+
} catch {
76+
return ClientError.unexpectedResponse(resp.data[...1024])
77+
}
78+
}
79+
80+
enum Headers {
81+
static let sessionToken = "Coder-Session-Token"
82+
}
83+
5384
}
5485

55-
enum ClientError: Error {
56-
case unexpectedStatusCode
57-
case badResponse
86+
struct HTTPResponse {
87+
let resp: HTTPURLResponse
88+
let data: Data
89+
let req: URLRequest?
5890
}
5991

60-
enum Headers {
61-
static let sessionToken = "Coder-Session-Token"
92+
struct APIError: Decodable {
93+
let response: Response
94+
let statusCode: Int
95+
let method: String?
96+
let url: URL?
97+
98+
var description: String {
99+
var components: [String] = []
100+
if let method = method, let url = url {
101+
components.append("\(method) \(url.absoluteString)")
102+
}
103+
components.append("Unexpected status code \(statusCode):\n\(response.message)")
104+
if let detail = response.detail {
105+
components.append("\tError: \(detail)")
106+
}
107+
if let validations = response.validations, !validations.isEmpty {
108+
let validationMessages = validations.map { "\t\($0.field): \($0.detail)" }
109+
components.append(contentsOf: validationMessages)
110+
}
111+
return components.joined(separator: "\n")
112+
}
113+
}
114+
115+
struct Response: Decodable {
116+
let message: String
117+
let detail: String?
118+
let validations: [ValidationError]?
119+
}
120+
121+
struct ValidationError: Decodable {
122+
let field: String
123+
let detail: String
124+
}
125+
126+
enum ClientError: Error {
127+
case apiError(APIError)
128+
case reqError(AFError)
129+
case unexpectedResponse(Data)
130+
131+
var description: String {
132+
switch self {
133+
case .apiError(let error):
134+
return error.description
135+
case .reqError(let error):
136+
return error.localizedDescription
137+
case .unexpectedResponse(let data):
138+
return "Unexpected response: \(data)"
139+
}
140+
}
62141
}

Coder Desktop/Coder Desktop/SDK/User.swift

+8-7
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
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+
throw responseAsError(res)
88
}
9-
guard let data = resp.data else {
10-
throw ClientError.badResponse
9+
do {
10+
return try CoderClient.decoder.decode(User.self, from: res.data)
11+
} catch {
12+
throw ClientError.unexpectedResponse(res.data[...1024])
1113
}
12-
return try CoderClient.decoder.decode(User.self, from: data)
1314
}
1415
}
1516

Coder Desktop/Coder Desktop/Views/LoginForm.swift

+20-23
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

+1-1
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")

0 commit comments

Comments
 (0)