add swift transport support package

This commit is contained in:
2026-04-20 10:55:21 +00:00
commit 9e4c490a22
7 changed files with 438 additions and 0 deletions
@@ -0,0 +1,78 @@
import Foundation
public actor TypedRequestClient {
public let endpoint: URL
private let session: URLSession
private let encoder: JSONEncoder
private let decoder: JSONDecoder
public init(
baseURL: URL,
session: URLSession = .shared,
encoder: JSONEncoder = JSONEncoder(),
decoder: JSONDecoder = JSONDecoder()
) {
self.endpoint = baseURL.appending(path: "typedrequest")
self.session = session
self.encoder = encoder
self.decoder = decoder
}
public func fire<Response: Decodable>(
method: String,
responseType: Response.Type = Response.self
) async throws -> Response {
try await fire(method: method, request: TypedRequestVoid(), responseType: responseType)
}
public func fire<Request: Encodable, Response: Decodable>(
method: String,
request: Request,
responseType: Response.Type = Response.self
) async throws -> Response {
let requestEnvelope = TypedRequestEnvelope(
method: method,
request: request,
response: nil,
correlation: TypedCorrelation(phase: "request")
)
return try await sendWithRetry(method: method, requestEnvelope: requestEnvelope, responseType: responseType)
}
private func sendWithRetry<Request: Encodable, Response: Decodable>(
method: String,
requestEnvelope: TypedRequestEnvelope<Request>,
responseType: Response.Type
) async throws -> Response {
let body = try encoder.encode(requestEnvelope)
var urlRequest = URLRequest(url: endpoint)
urlRequest.httpMethod = "POST"
urlRequest.httpBody = body
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse,
200 ..< 300 ~= httpResponse.statusCode else {
throw TypedRequestError(method: method, message: "Typed request failed at transport level")
}
let typedResponse = try decoder.decode(TypedResponseEnvelope<Response>.self, from: data)
if let error = typedResponse.error {
throw TypedRequestError(method: method, message: error.text)
}
if let retry = typedResponse.retry {
try await Task.sleep(for: .milliseconds(retry.waitForMs))
return try await sendWithRetry(method: method, requestEnvelope: requestEnvelope, responseType: responseType)
}
guard let responsePayload = typedResponse.response else {
throw TypedRequestError(method: method, message: "Typed request did not return a response payload")
}
return responsePayload
}
}
@@ -0,0 +1,142 @@
import Foundation
public enum TypedSocketConnectionStatus: String, Sendable {
case new
case connecting
case connected
case disconnected
case reconnecting
}
public actor TypedSocketClient {
public private(set) var connectionStatus: TypedSocketConnectionStatus = .new
private let serverURL: URL
private let session: URLSession
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private var webSocketTask: URLSessionWebSocketTask?
private var pendingContinuations: [String: CheckedContinuation<Data, Error>] = [:]
public init(
serverURL: URL,
session: URLSession = .shared,
encoder: JSONEncoder = JSONEncoder(),
decoder: JSONDecoder = JSONDecoder()
) {
self.serverURL = serverURL
self.session = session
self.encoder = encoder
self.decoder = decoder
}
public func connect() {
guard webSocketTask == nil else { return }
connectionStatus = .connecting
var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)
components?.scheme = serverURL.scheme == "https" ? "wss" : "ws"
guard let websocketURL = components?.url else {
connectionStatus = .disconnected
return
}
let task = session.webSocketTask(with: websocketURL)
webSocketTask = task
task.resume()
connectionStatus = .connected
Task { [weak self] in
await self?.receiveLoop()
}
}
public func disconnect() async {
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
connectionStatus = .disconnected
for continuation in pendingContinuations.values {
continuation.resume(throwing: TypedRequestError(method: "typedsocket", message: "TypedSocket disconnected"))
}
pendingContinuations.removeAll()
}
public func fire<Response: Decodable>(
method: String,
responseType: Response.Type = Response.self
) async throws -> Response {
try await fire(method: method, request: TypedRequestVoid(), responseType: responseType)
}
public func fire<Request: Encodable, Response: Decodable>(
method: String,
request: Request,
responseType: Response.Type = Response.self
) async throws -> Response {
if webSocketTask == nil {
connect()
}
let correlation = TypedCorrelation(phase: "request")
let envelope = TypedRequestEnvelope(
method: method,
request: request,
response: nil,
correlation: correlation
)
let messageData = try encoder.encode(envelope)
let responseData = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Data, Error>) in
pendingContinuations[correlation.id] = continuation
webSocketTask?.send(.data(messageData)) { [weak self] error in
guard let self else { return }
if let error {
let continuation = self.pendingContinuations.removeValue(forKey: correlation.id)
continuation?.resume(throwing: error)
}
}
}
let responseEnvelope = try decoder.decode(TypedResponseEnvelope<Response>.self, from: responseData)
if let error = responseEnvelope.error {
throw TypedRequestError(method: method, message: error.text)
}
guard let responsePayload = responseEnvelope.response else {
throw TypedRequestError(method: method, message: "TypedSocket did not return a response payload")
}
return responsePayload
}
private func receiveLoop() async {
guard let task = webSocketTask else { return }
do {
let message = try await task.receive()
switch message {
case .data(let data):
try await handleInboundData(data)
case .string(let string):
try await handleInboundData(Data(string.utf8))
@unknown default:
break
}
await receiveLoop()
} catch {
connectionStatus = .disconnected
for continuation in pendingContinuations.values {
continuation.resume(throwing: error)
}
pendingContinuations.removeAll()
webSocketTask = nil
}
}
private func handleInboundData(_ data: Data) async throws {
let correlationEnvelope = try decoder.decode(TypedResponseEnvelope<EmptyJSONValue>.self, from: data)
guard let correlationId = correlationEnvelope.correlation?.id,
let continuation = pendingContinuations.removeValue(forKey: correlationId) else {
return
}
continuation.resume(returning: data)
}
}
@@ -0,0 +1,57 @@
import Foundation
public struct TypedCorrelation: Codable, Hashable, Sendable {
public let id: String
public let phase: String
public init(id: String = UUID().uuidString, phase: String) {
self.id = id
self.phase = phase
}
}
public struct TypedRequestVoid: Codable, Hashable, Sendable {
public init() {}
}
struct TypedRequestEnvelope<Request: Encodable>: Encodable {
let method: String
let request: Request
let response: EmptyJSONValue?
let correlation: TypedCorrelation
}
struct TypedResponseEnvelope<Response: Decodable>: Decodable {
let method: String?
let response: Response?
let error: TypedTransportErrorPayload?
let retry: TypedRetryInstruction?
let correlation: TypedCorrelation?
}
struct TypedTransportErrorPayload: Decodable, Hashable {
let text: String
}
struct TypedRetryInstruction: Decodable, Hashable {
let reason: String?
let waitForMs: Int
}
struct EmptyJSONValue: Codable, Hashable, Sendable {
init() {}
}
public struct TypedRequestError: LocalizedError, Hashable, Sendable {
public let method: String
public let message: String
public init(method: String, message: String) {
self.method = method
self.message = message
}
public var errorDescription: String? {
message
}
}