Skip to content

About

A network client for Swift projects.

Resources

Stars

1 star

Watchers

1 watching

Forks

Latest commit

 

History

15 Commits

Folders and files

Repository files navigation

SkavokNetworking

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.

Requirements

  • Swift 6
  • Xcode 16 or newer
  • iOS 16 or newer
  • macOS 12 or newer

Swift 5 language mode is not supported.

Installation

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 SkavokNetworking

Quick start

Create 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)

Configuring the client

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.

Describing requests

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)

Header precedence

Headers are merged case-insensitively in this order, with later values winning:

  1. URLSessionConfiguration.httpAdditionalHeaders
  2. Configuration.mandatoryHeaders
  3. Request.headers
  4. The per-call configure closure
  5. Changes made by APIClientDelegate.willSendRequest

If no Accept header is supplied, the client adds application/json.

Request bodies

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")
)
  • .json uses the configured JSON encoder and defaults to application/json.
  • .data sends bytes unchanged and adds a content type only when one is provided.
  • .text encodes UTF-8 and defaults to text/plain; charset=utf-8.
  • An explicit request header always overrides the body default.

Performing requests

Decoded response

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")
)

Response without a body

Use Request<Void> when only status and metadata matter:

let response = try await client.send(
    Request<Void>(path: "sessions/current", method: .delete)
)

Raw data

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.value

Prefer download(for:) for large bodies that should not be accumulated in memory.

Per-request URLRequest configuration

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.

Uploading

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.

Downloading

Downloads use a URLSessionDownloadTask, so the response body is written to disk instead of being held entirely in memory.

Explicit destination

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.

Caller-owned temporary file

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.

Download validation

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.

Responses

Every operation returns Response<Value>, which contains:

  • value: decoded value, raw Data, Void, or downloaded file URL
  • data: original response bytes; empty for file-backed downloads
  • response: the original URLResponse
  • statusCode: HTTP status code when available
  • originalRequest and currentRequest
  • 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
}

Validation and errors

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 configuration
  • requestConstruction: missing or invalid URL information
  • transport: failure reported by the URL loading system, with request context
  • unacceptableResponse: HTTP response rejected by validation
  • downloadFile: failure preserving or moving a downloaded file
  • encoding: request body encoding failure, with request context
  • decoding: 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

Retries are intentionally opt-in at two levels:

  1. maximumRetryCount limits retry attempts after the initial request. Its default is 1.
  2. APIClientDelegate.shouldRetry must return true for each retry. Its default returns false.

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.

Cancellation

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.

Delegates and callback queues

There are two distinct extension points:

  • APIClientDelegate customizes high-level URL construction, request mutation, validation, retry decisions, and per-request encoders/decoders.
  • URLSessionDelegate and URLSessionTaskDelegate expose 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.

Custom APIClientDelegate behavior

Implement only the methods you need; defaults are provided for every requirement:

  • willSendRequest: modify the final request immediately before each attempt
  • makeURLForRequest: replace the default base/relative URL construction
  • validateResponse: validate in-memory responses
  • validateDownloadResponse: validate a downloaded file without loading it completely into memory
  • shouldRetry: approve a retry after a transport failure
  • encoderForRequest and decoderForRequest: override coding for an individual request

The delegate is held strongly by the client and must conform to Sendable.

Cache behavior

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.

Inspecting a URLRequest

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.

Transfer scope and limitations

The library intentionally supports foreground, one-shot transfers:

  • Background URLSessionConfiguration instances are rejected with APIError.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; use download(for:) for large payloads.

About

A network client for Swift projects.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages