SkavokNetworking is a lightweight, Swift 6 networking library built on URLSession, actors,
and structured concurrency. It provides typed requests and responses, explicit body encoding,
foreground uploads and downloads, HTTP validation, controlled retries, cancellation propagation,
and contextual errors without introducing a third-party dependency.
- Swift 6
- Xcode 16 or newer
- iOS 16 or newer
- macOS 12 or newer
Swift 5 language mode is not supported.
Add the package in Xcode using File → Add Package Dependencies and this URL:
https://github.com/Livsy90/SkavokNetworking.git
Then add SkavokNetworking to the target dependencies and import it:
import Foundation
import SkavokNetworkingCreate one client for an API and reuse it:
let client = APIClient(baseURL: URL(string: "https://api.example.com/v1")!) {
$0.mandatoryHeaders = [
"Authorization": "Bearer token"
]
}Define a Decodable & Sendable response model and send a typed request:
struct User: Decodable, Sendable {
let id: Int
let name: String
}
let response = try await client.send(
Request<User>(path: "users/42")
)
print(response.value.name)
print(response.statusCode as Any)APIClient.Configuration controls behavior shared by all requests from a client:
let sessionConfiguration = URLSessionConfiguration.default
sessionConfiguration.timeoutIntervalForRequest = 30
let client = APIClient(baseURL: URL(string: "https://api.example.com/v1")!) {
$0.sessionConfiguration = sessionConfiguration
$0.mandatoryHeaders = ["X-Client": "MyApp"]
$0.maximumRetryCount = 2
$0.maximumDownloadErrorBodySize = 64 * 1024
$0.decoderFactory = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}
$0.encoderFactory = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return encoder
}
}The configuration passed into the client is copied. A new encoder or decoder is created for every JSON body, so concurrent requests do not share mutable formatter state.
The generic parameter of Request<Response> describes the expected response type. It does not
change the outgoing request:
let request = Request<User>(
path: "users/42",
method: .get,
query: [
("include", "profile"),
("fields", "id,name")
],
headers: ["X-Request-ID": "request-42"],
id: "load-user-42",
cachePolicy: .reloadRevalidatingCacheData
)path can be relative to the client's baseURL or be an absolute URL. Relative paths are appended
to the base path, so a base URL ending in /v1 and users/42 produce /v1/users/42. Query items
already present in the URL are preserved and request query items are appended in their original
order, including duplicate keys.
Use withResponse(_:) when the same request description needs another response representation:
let rawRequest = request.withResponse(Data.self)Headers are merged case-insensitively in this order, with later values winning:
URLSessionConfiguration.httpAdditionalHeadersConfiguration.mandatoryHeadersRequest.headers- The per-call
configureclosure - Changes made by
APIClientDelegate.willSendRequest
If no Accept header is supplied, the client adds application/json.
HTTPBody makes body encoding and content type explicit:
struct CreateEvent: Encodable, Sendable {
let name: String
}
let json = Request<Void>(
path: "events",
method: .post,
body: .json(CreateEvent(name: "Launch"))
)
let bytes = Request<Void>(
path: "files",
method: .post,
body: .data(binaryData, contentType: "application/octet-stream")
)
let text = Request<Void>(
path: "notes",
method: .post,
body: .text("Hello")
).jsonuses the configured JSON encoder and defaults toapplication/json..datasends bytes unchanged and adds a content type only when one is provided..textencodes UTF-8 and defaults totext/plain; charset=utf-8.- An explicit request header always overrides the body default.
Use send(_:) when the response is JSON, Data, String, or an optional decodable value:
let user: Response<User> = try await client.send(
Request<User>(path: "users/42")
)An empty body can be decoded as an optional value:
let response: Response<User?> = try await client.send(
Request<User?>(path: "users/current")
)Use Request<Void> when only status and metadata matter:
let response = try await client.send(
Request<Void>(path: "sessions/current", method: .delete)
)Use data(for:) to skip decoding and receive the complete response body in memory:
let response = try await client.data(
for: Request<Void>(path: "images/avatar")
)
let bytes: Data = response.valuePrefer download(for:) for large bodies that should not be accumulated in memory.
Every transfer method has a full overload with a configure closure:
let response = try await client.send(
Request<User>(path: "users/42"),
delegate: nil
) { urlRequest in
urlRequest.timeoutInterval = 10
urlRequest.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
}This closure runs after the library constructs the URLRequest and before retry attempts begin.
APIClientDelegate.willSendRequest runs immediately before each attempt.
Upload in-memory bytes with upload(for:from:):
let response = try await client.upload(
for: Request<User>(path: "avatars", method: .post),
from: imageData
)Upload an existing file with upload(for:fromFile:):
let response = try await client.upload(
for: Request<Void>(path: "imports", method: .post),
fromFile: archiveURL
)The response can be decoded or ignored with Request<Void>. Cancelling the calling Swift task
cancels the underlying upload task.
Downloads use a URLSessionDownloadTask, so the response body is written to disk instead of being
held entirely in memory.
let documentsDirectory = try FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let destination = documentsDirectory.appendingPathComponent("archive.zip")
let response = try await client.download(
for: Request<Void>(path: "files/archive.zip"),
destination: destination
)
print(response.location)Missing parent directories are created. An existing destination is never overwritten: the call
fails before starting the network request with a destinationAlreadyExists DownloadFileError.
Omit destination to receive a unique, stable temporary file:
let response = try await client.download(
for: Request<Void>(path: "files/archive.zip")
)
defer {
try? FileManager.default.removeItem(at: response.location)
}
// Read, import, or move response.location.Ownership of this file is transferred to the caller. Move it to permanent storage or delete it when it is no longer needed.
Downloads use the same URL construction, request mutation, retry, cancellation, and HTTP validation
pipeline as other operations. When validation rejects an HTTP response, the downloaded file is
removed. Only a bounded prefix is copied into HTTPError.data; the default limit is 1 MiB and can
be changed using maximumDownloadErrorBodySize. Check HTTPError.isBodyTruncated before assuming
that the error body is complete.
Every operation returns Response<Value>, which contains:
value: decoded value, rawData,Void, or downloaded file URLdata: original response bytes; empty for file-backed downloadsresponse: the originalURLResponsestatusCode: HTTP status code when availableoriginalRequestandcurrentRequest- completed
URLSessionTask - collected
URLSessionTaskMetrics, when delivered by the session
Use map(_:) to transform the value while preserving the metadata:
let name = try response.map { user in
user.name
}By default, HTTP status codes in 200..<300 are accepted. Other HTTP responses produce
APIError.unacceptableResponse(HTTPError).
do {
let response = try await client.send(Request<User>(path: "users/42"))
print(response.value)
} catch let APIError.unacceptableResponse(error) {
print(error.statusCode)
print(error.headers)
print(String(data: error.data, encoding: .utf8) as Any)
} catch let APIError.transport(error) {
print(error.requestID as Any)
print(error.underlyingError)
} catch is CancellationError {
// The caller cancelled the operation.
} catch {
print(error.localizedDescription)
}APIError separates failure stages:
configuration: an unsupported client/session configurationrequestConstruction: missing or invalid URL informationtransport: failure reported by the URL loading system, with request contextunacceptableResponse: HTTP response rejected by validationdownloadFile: failure preserving or moving a downloaded fileencoding: request body encoding failure, with request contextdecoding: response decoding failure, including received data and status
Cancellation is preserved as CancellationError or URLError.cancelled; it is not wrapped in
APIError.transport and is never retried.
Retries are intentionally opt-in at two levels:
maximumRetryCountlimits retry attempts after the initial request. Its default is1.APIClientDelegate.shouldRetrymust returntruefor each retry. Its default returnsfalse.
Only transport failures are considered. Configuration, request construction, request mutation, HTTP validation, filesystem, encoding, decoding, and cancellation failures are not retried.
struct RetryDelegate: APIClientDelegate {
func client(
_ client: APIClient,
shouldRetry task: URLSessionTask,
error: Error,
attempts: Int
) async throws -> Bool {
guard let error = error as? URLError else { return false }
return attempts < 3 && [.timedOut, .networkConnectionLost].contains(error.code)
}
}
let client = APIClient(baseURL: URL(string: "https://api.example.com")!) {
$0.delegate = RetryDelegate()
$0.maximumRetryCount = 2
}The delegate receives the number of completed attempts, including the failed attempt. No delay or
backoff is added automatically; implement it inside shouldRetry when required.
All transfers participate in structured concurrency:
let task = Task {
try await client.download(for: Request<Void>(path: "large-file.zip"))
}
task.cancel()Cancellation is checked before an underlying task is created, between retry attempts, and after a
download finishes but before the file is returned. Cancelling the Swift task cancels its current
URLSessionTask and cleans up any client-owned downloaded file.
There are two distinct extension points:
APIClientDelegatecustomizes high-level URL construction, request mutation, validation, retry decisions, and per-request encoders/decoders.URLSessionDelegateandURLSessionTaskDelegateexpose supported URL loading callbacks such as authentication, redirection, upload progress, connectivity waiting, and metrics.
Set a session-wide delegate during configuration:
let client = APIClient(baseURL: baseURL) {
$0.sessionDelegate = sessionDelegate
$0.sessionDelegateUnderlyingQueue = callbackQueue
}Or pass a task-specific URLSessionTaskDelegate to a full transfer overload. Task-specific handling
takes precedence for callbacks that use a completion handler. The client wraps the configured
underlying queue in a private serial OperationQueue, preserving callback ordering.
Response disposition, response-body delivery, download progress, and temporary-file handling remain owned by the client. Data- and download-specific delegate callbacks are therefore not part of the public contract; this prevents a delegate from changing the task type or invalidating async result semantics.
Implement only the methods you need; defaults are provided for every requirement:
willSendRequest: modify the final request immediately before each attemptmakeURLForRequest: replace the default base/relative URL constructionvalidateResponse: validate in-memory responsesvalidateDownloadResponse: validate a downloaded file without loading it completely into memoryshouldRetry: approve a retry after a transport failureencoderForRequestanddecoderForRequest: override coding for an individual request
The delegate is held strongly by the client and must conform to Sendable.
When .default uses URLCache.shared, the client replaces it with a private cache of the same
capacity. This prevents one client's clearCache() call from clearing unrelated cached responses:
await client.clearCache()If a custom cache is assigned explicitly to sessionConfiguration.urlCache, it is retained. Calling
clearCache() then affects every client that intentionally shares that cache.
Use makeURLRequest(for:) to inspect or test request construction without starting a transfer:
let urlRequest = try await client.makeURLRequest(
for: Request<Void>(path: "health")
)
print(urlRequest.url as Any)The method applies URL construction, query items, headers, cache policy, and body encoding. It does
not call willSendRequest, because that hook belongs to an actual transfer attempt.
The library intentionally supports foreground, one-shot transfers:
- Background
URLSessionConfigurationinstances are rejected withAPIError.configuration(.backgroundSessionUnsupported). - Background restoration needs a separate lifecycle-oriented API and is not emulated.
- Download resume data is not exposed.
- Download progress callbacks are not currently part of the contract.
- Raw
data(for:)responses are accumulated in memory; usedownload(for:)for large payloads.