southkin/fullyrestful
Most networking libraries make you split one API across multiple places:
β¨ What makes it different?
Most networking libraries make you split one API across multiple places:
- endpoint path
- request model
- response model
- request-building logic
FullyRESTful was built for the opposite approach:
- one API
- one Swift type
- request and response defined together
That means the shape your backend teammate sends you can stay almost κ·Έλλ‘ in code.
This library lets you define APIs like this:
let responseModel = try await MyAPI().request(param: .init(...)).model...instead of writing all this:
var request = URLRequest(url: URL(string: "...")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(MyAPI.Request(...))
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw URLError(.badServerResponse)
}
let responseModel = try JSONDecoder().decode(MyAPI.Response.self, from: data)π€ Why FullyRESTful?
- You want each API to own its
RequestandResponsetogether. - You do not want endpoint specs scattered across files.
- You want to turn backend API guides into Swift types with minimal glue code.
- You want REST and WebSocket to feel consistent.
- You want a small abstraction over
URLSession, not a giant framework.
FullyRESTful is a Swift library supporting both RESTful APIs and WebSocket connections in a simple, declarative, and composable way.
π¨ FullyRESTful 3.0.0
Breaking Changes in WebSocket Support
The internal WebSocket system has been completely restructured. If you were using WebSocket features in version 2.x, please update your usage accordingly.
π Features
- Declarative API Definitions
Define REST or WebSocket APIs in a single Swift structure.
- RESTful API Support
Simple request/response modeling with JSON encoding/decoding.
- Multipart Upload Support
Upload files and form data with minimal configuration.
- Modern WebSocket Support
Connect once and share the connection via Combine.
- Modular Design
Use only the features you need.
π¦ Installation
Swift Package Manager
dependencies: [
.package(url: "https://github.com/southkin/FullyRESTful.git", .upToNextMajor(from: "3.0.0"))
]π RESTful API Usage
Define a Server
let myServer = ServerInfo(domain: "https://api.example.com", defaultHeader: [:])Define an API
struct MyAPI: APIITEM {
var server = myServer
struct Request: Codable {
let param1: String?
let param2: [Int]
let param3: [String: Float]
}
struct Response: Codable {
let result1: [String]
let result2: [Int]?
let result3: [String: Float]?
}
var requestModel = Request.self
var responseModel = Response.self
var method: HTTPMethod = .POST
var path: String = "/myapi/path"
}Request
let data = try? await MyAPI().getData(param: .init(param1: "example", param2: [1, 2, 3], param3: ["key": 1.23])).data
let model = try await MyAPI().request(
param: .init(param1: "example", param2: [1, 2, 3], param3: ["key": 1.23])
).modelβ Empty Response
For endpoints like DELETE /resource/:id that return 204 No Content:
struct DeleteUserAPI: APIITEM {
var server = myServer
struct Request: Codable {}
var requestModel = Request.self
var responseModel = EmptyResponse.self
var method: HTTPMethod = .DELETE
var path: String = "/users/1"
}
let response = try await DeleteUserAPI().request(param: .init())
print(response.rawResponse.statusCode)π Plain Text Response
If the server returns text/plain, use String as the response model:
struct HealthCheckAPI: APIITEM {
var server = myServer
struct Request: Codable {}
var requestModel = Request.self
var responseModel = String.self
var method: HTTPMethod = .GET
var path: String = "/health"
}
let text = try await HealthCheckAPI().request(param: .init()).modelπ― Custom Decoder
If the backend needs custom date decoding or key strategies:
struct EventAPI: APIITEM {
var server = myServer
struct Request: Codable {}
struct Response: Codable {
let createdAt: Date
}
var requestModel = Request.self
var responseModel = Response.self
var method: HTTPMethod = .GET
var path: String = "/event"
var decoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}
}π Multipart Upload
Define an API with File Upload
struct MyUploadAPI: APIITEM, MultipartUpload {
var server = myServer
struct Request: Codable {
let title: String
let image: MultipartItem
}
struct Response: Codable {
let uploadedURL: String
}
var requestModel = Request.self
var responseModel = Response.self
var method: HTTPMethod = .POST
var path: String = "/upload"
}π WebSocket Usage
WebSockets in FullyRESTful are:
- shared by connection identity (URL, headers, session, and heartbeat policy)
- available as both a backwards-compatible Combine publisher and async streams
- able to report open, message, close code/reason, and transport failures
- configurable for transport ping and application-level heartbeat messages
- capable of sending
.text,.binary, or.codable(...)payloads
Define a WebSocket
struct EchoSocket: WebSocketITEM {
var server = ServerInfo(domain: "wss://echo.websocket.org", defaultHeader: [:])
var path: String = ""
// Existing behavior remains: transport ping every 10 seconds by default.
var pingInterval: TimeInterval { 5 }
}server.defaultHeader is applied to the WebSocket upgrade request. Override webSocketHeader when a socket needs different authentication headers.
Async Messages and Errors
messages() throws receive, ping, heartbeat, and abnormal-close failures. A normal close finishes the stream without throwing.
do {
for try await message in EchoSocket().messages() {
print("π₯", message)
}
} catch let failure as WebSocketConnectionFailure {
print(failure.kind, failure.message, failure.closeInfo as Any)
}Cancelling the consuming task removes that subscriber. A shared connection is cancelled only after its final message/event subscriber leaves.
Connection Events
for await event in EchoSocket().connectionEvents() {
switch event {
case .connecting:
print("connecting")
case .connected:
print("WebSocket handshake completed")
case .messageReceived(let message):
print("received", message)
case .closed(let info):
print("closed", info.code, info.reason as Any)
case .failed(let failure):
print("failed", failure)
}
}Reconnection is intentionally caller-owned: observe .closed or .failed, apply your retry/backoff policy, and create a new stream when appropriate.
Existing Combine API
The original API and failure type are unchanged:
let socket = EchoSocket()
socket.listen()
.sink { message in
print("π₯", message)
}
.store(in: &cancellables)Because listen() still has Failure == Never, it completes when the underlying connection fails. Use messages() or connectionEvents() when the error or close reason is required.
Send Text Message
try await socket.send(.text("Hello WebSocket!"))Send a Codable Message
struct ChatMessage: Codable {
let type: String
let content: String
}
let message = ChatMessage(type: "chat", content: "Hello from Codable!")
try await socket.send(.codable(message))Decode Received Messages
socket.listen()
.decode(ChatMessage.self)
.sink { decoded in
print("π© Decoded:", decoded)
}
.store(in: &cancellables)Custom Heartbeats
KuCoin-style JSON heartbeat:
struct KuCoinSocket: WebSocketITEM {
var server: ServerInfo
var path = "/endpoint"
var heartbeatPolicy: WebSocketHeartbeatPolicy {
.init(
identifier: "kucoin-json-v1",
interval: 10,
tick: .message {
.text(#"{"id":"\(UUID().uuidString)","type":"ping"}"#)
}
)
}
}KIS-style server PINGPONG automatic response:
var heartbeatPolicy: WebSocketHeartbeatPolicy {
.init(identifier: "kis-pingpong-v1") { message in
message == .text("PINGPONG") ? .text("PONG") : nil
}
}Use a stable, behavior-specific identifier. It is part of the connection identity, so sockets with different heartbeat policies are never shared.
Connection Identity and Custom Sessions
Connections share only when URL, case-insensitive header names (with exact values), webSocketSessionIdentifier, heartbeat identifier, and optional webSocketConnectionIdentifier match. Custom WebSocketSessioning implementations should expose a stable identifier that distinguishes session configuration, cookie/auth storage, or delegate behavior:
struct SignedSession: WebSocketSessioning {
let webSocketSessionIdentifier = "signed-session-v2"
func webSocketTask(with request: URLRequest) -> WebSocketTasking {
// Return your task adapter.
}
}π§ͺ Debugging
Enable cURL log output for debugging:
struct MyAPI: APIITEM {
var curlLog = true
}You can also inject a custom URLSession for tests or controlled environments:
struct MyAPI: APIITEM {
var server = myServer
var session: URLSession = .shared
}β οΈ Error Handling
FullyRESTful throws FullyRESTfulError for the common transport and decoding cases.
Typical cases:
badURL: invalidserver.domain + pathhttpError(statusCode:data:): non-success HTTP response with raw body preservedemptyResponseBody: body was empty but yourResponseModelcould not decode from itunsupportedContentType: response was not JSON or textinvalidWebSocketState: send attempted before a valid socket state existed
Example:
do {
let response = try await MyAPI().request(param: .init(...))
print(response.model)
} catch let error as FullyRESTfulError {
switch error {
case .httpError(let statusCode, let data):
print("status:", statusCode)
print("body:", String(data: data, encoding: .utf8) ?? "")
default:
print(error.localizedDescription)
}
}π License
MIT License
Package Metadata
Repository: southkin/fullyrestful
Default branch: main
README: README.md