add swift transport support package
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user