Skip to content

refactor(CoderSDK): share code between Client and AgentClient #132

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 1 commit into from
Apr 10, 2025
Merged
Show file tree
Hide file tree
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
2 changes: 1 addition & 1 deletion Coder-Desktop/Coder-Desktop/State.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 4 additions & 4 deletions Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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)
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Coder-Desktop/Coder-Desktop/Views/LoginForm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ enum LoginError: Error {
case invalidURL
case outdatedCoderVersion
case missingServerVersion
case failedAuth(ClientError)
case failedAuth(SDKError)

var description: String {
switch self {
Expand Down
4 changes: 2 additions & 2 deletions Coder-Desktop/Coder-DesktopTests/FilePickerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 5 additions & 5 deletions Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
19 changes: 17 additions & 2 deletions Coder-Desktop/CoderSDK/AgentClient.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 4 additions & 4 deletions Coder-Desktop/CoderSDK/AgentLS.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}

Expand Down
208 changes: 129 additions & 79 deletions Coder-Desktop/CoderSDK/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? "<non-utf8 data>")
}
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) ?? "<non-utf8 data>")
}
}

// Wrapper around JSONDecoder.decode that displays useful error messages from `DecodingError`.
func decode<T>(_: 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) ?? "<non-utf8 data>")
) 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
)
}
}

Expand Down Expand Up @@ -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)
Expand All @@ -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) ?? "<non-utf8 data>")
}
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) ?? "<non-utf8 data>")
}
}

// Wrapper around JSONDecoder.decode that displays useful error messages from `DecodingError`.
func decode<T: Decodable>(_: 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) ?? "<non-utf8 data>")
}
}
2 changes: 1 addition & 1 deletion Coder-Desktop/CoderSDK/Deployment.swift
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
Loading
Loading