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