From 9e4c490a22f4ff4c1444a25e3bc8a00ed0a765dc Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 20 Apr 2026 10:55:21 +0000 Subject: [PATCH] add swift transport support package --- Package.swift | 27 ++++ README.md | 10 ++ Sources/SwiftSupport/TypedRequestClient.swift | 78 ++++++++++ Sources/SwiftSupport/TypedSocketClient.swift | 142 ++++++++++++++++++ .../SwiftSupport/TypedTransportModels.swift | 57 +++++++ .../TypedRequestClientTests.swift | 99 ++++++++++++ package.json | 25 +++ 7 files changed, 438 insertions(+) create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/SwiftSupport/TypedRequestClient.swift create mode 100644 Sources/SwiftSupport/TypedSocketClient.swift create mode 100644 Sources/SwiftSupport/TypedTransportModels.swift create mode 100644 Tests/SwiftSupportTests/TypedRequestClientTests.swift create mode 100644 package.json diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..cfb6351 --- /dev/null +++ b/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "swiftsupport", + platforms: [ + .iOS(.v17), + .macOS(.v14), + .watchOS(.v10) + ], + products: [ + .library( + name: "SwiftSupport", + targets: ["SwiftSupport"] + ) + ], + targets: [ + .target( + name: "SwiftSupport" + ), + .testTarget( + name: "SwiftSupportTests", + dependencies: ["SwiftSupport"] + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8ee310 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# @api.global/swiftsupport + +Shared Swift support for `api.global` transports. + +Current scope: + +- typedrequest over `POST /typedrequest` +- typedsocket over WebSocket with typedrequest-style envelopes + +This package is designed to be reused by Swift apps that talk to `@api.global/typedrequest` and `@api.global/typedsocket` servers. diff --git a/Sources/SwiftSupport/TypedRequestClient.swift b/Sources/SwiftSupport/TypedRequestClient.swift new file mode 100644 index 0000000..2c01bdc --- /dev/null +++ b/Sources/SwiftSupport/TypedRequestClient.swift @@ -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( + method: String, + responseType: Response.Type = Response.self + ) async throws -> Response { + try await fire(method: method, request: TypedRequestVoid(), responseType: responseType) + } + + public func fire( + 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( + method: String, + requestEnvelope: TypedRequestEnvelope, + 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.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 + } +} diff --git a/Sources/SwiftSupport/TypedSocketClient.swift b/Sources/SwiftSupport/TypedSocketClient.swift new file mode 100644 index 0000000..01d6edd --- /dev/null +++ b/Sources/SwiftSupport/TypedSocketClient.swift @@ -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] = [:] + + 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( + method: String, + responseType: Response.Type = Response.self + ) async throws -> Response { + try await fire(method: method, request: TypedRequestVoid(), responseType: responseType) + } + + public func fire( + 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) 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.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.self, from: data) + guard let correlationId = correlationEnvelope.correlation?.id, + let continuation = pendingContinuations.removeValue(forKey: correlationId) else { + return + } + continuation.resume(returning: data) + } +} diff --git a/Sources/SwiftSupport/TypedTransportModels.swift b/Sources/SwiftSupport/TypedTransportModels.swift new file mode 100644 index 0000000..db70bd9 --- /dev/null +++ b/Sources/SwiftSupport/TypedTransportModels.swift @@ -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: Encodable { + let method: String + let request: Request + let response: EmptyJSONValue? + let correlation: TypedCorrelation +} + +struct TypedResponseEnvelope: 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 + } +} diff --git a/Tests/SwiftSupportTests/TypedRequestClientTests.swift b/Tests/SwiftSupportTests/TypedRequestClientTests.swift new file mode 100644 index 0000000..96e0d52 --- /dev/null +++ b/Tests/SwiftSupportTests/TypedRequestClientTests.swift @@ -0,0 +1,99 @@ +import Foundation +import Testing + +@testable import SwiftSupport + +private final class URLProtocolStub: URLProtocol, @unchecked Sendable { + static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + return + } + + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +private struct EchoRequest: Codable { + let value: String +} + +private struct EchoResponse: Codable, Equatable { + let echoed: String +} + +@Test func typedRequestClientPostsToTypedRequestEndpoint() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + let session = URLSession(configuration: configuration) + + URLProtocolStub.handler = { request in + #expect(request.url?.absoluteString == "https://idp.global/typedrequest") + #expect(request.httpMethod == "POST") + + let data = try #require(request.httpBody) + let body = try JSONSerialization.jsonObject(with: data) as? [String: Any] + #expect(body?["method"] as? String == "echo") + + let responseBody = try JSONEncoder().encode([ + "response": ["echoed": "ok"], + "correlation": ["id": "1", "phase": "response"] + ]) + let response = HTTPURLResponse( + url: try #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, responseBody) + } + + let client = TypedRequestClient(baseURL: URL(string: "https://idp.global")!, session: session) + let response = try await client.fire(method: "echo", request: EchoRequest(value: "hello"), responseType: EchoResponse.self) + + #expect(response == EchoResponse(echoed: "ok")) +} + +@Test func typedRequestClientThrowsServerErrors() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + let session = URLSession(configuration: configuration) + + URLProtocolStub.handler = { request in + let responseBody = try JSONEncoder().encode([ + "error": ["text": "Nope"], + "correlation": ["id": "1", "phase": "response"] + ]) + let response = HTTPURLResponse( + url: try #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, responseBody) + } + + let client = TypedRequestClient(baseURL: URL(string: "https://idp.global")!, session: session) + + await #expect(throws: TypedRequestError.self) { + _ = try await client.fire(method: "echo", request: EchoRequest(value: "hello"), responseType: EchoResponse.self) + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8120b3c --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "@api.global/swiftsupport", + "version": "1.0.0", + "private": false, + "description": "Swift transport support for api.global typedrequest and typedsocket servers.", + "author": "Lossless GmbH", + "license": "MIT", + "type": "module", + "scripts": { + "test": "echo 'Run swift test in a Swift toolchain environment'", + "build": "echo 'Build via SwiftPM/Xcode'" + }, + "files": [ + "Package.swift", + "README.md", + "Sources/**/*", + "Tests/**/*" + ], + "keywords": [ + "Swift", + "typedrequest", + "typedsocket", + "api.global" + ] +}