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