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
+27
View File
@@ -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"]
)
]
)
+10
View File
@@ -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.
@@ -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
}
}
@@ -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)
}
}
+25
View File
@@ -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"
]
}