Files
LCEssentials/Documentation/API.md
2026-08-29 19:36:03 -03:00

7.1 KiB

API — networking for LCEssentials

Part of the reference set: Extensions.md · SwiftUI.md · UIKit.md.

API is an actor that wraps URLSession for JSON REST calls and multipart uploads. One line to send a typed request, decode the response, and get a consistent error — instead of re-writing the same URLRequest / status-code / JSONDecoder boilerplate in every project.

import LCEssentials

struct User: Decodable, Sendable { let id: Int; let name: String }

let user: User = try await API.shared.request(
    url: "https://api.example.com/users/{id}",
    method: .get,
    pathParams: ["id": "42"]
)

Why not hand-rolled URLSession

Hand-rolled URLSession API
Build URLRequest, set method, headers, body, Content-Type, Content-Length every call request(url:method:body:) — headers and content metadata handled
switch on httpResponse.statusCode in every call site, or forget to 2xx decodes, 4xx/5xx throw a populated NSError (.code = HTTP status, failure reason = response body)
JSONDecoder().decode(T.self, from:) + custom error messages each time JSONDecoder.decode with keyed / type-mismatch / missing-value diagnostics baked in
Multipart body assembled by hand with \r\n string concatenation and force-unwrapped .data(using:) MultipartForm builder; body streamed from a temp file, never fully in memory
Large file upload loads the whole file into a Data form.file(_:url:) streams from disk in 64 KB chunks
Retry logic copy-pasted, often unbounded persistConnection: true, bounded by API.maxPersistRetries
Client-certificate (mTLS) needs a custom URLSessionDelegate per project setupCertification(certData:password:)
Progress reporting needs a delegate + KVO wiring upload(..., onProgress:)
@MainActor hops or manual DispatchQueue juggling actor-isolated, Sendable-checked, runs off the main thread
Response types must be Codable even when only decoding T: Decodable & Sendable

API is not a replacement for a full networking stack (no interceptors, caching policy DSL, or automatic token refresh). For simple typed REST it removes the boilerplate and the easy-to-get-wrong parts.


Requests

GET

let items: [Item] = try await API.shared.request(
    url: "https://api.example.com/items",
    method: .get
)

POST / PUT / PATCH with a JSON body

body takes any HTTPBody. jsonBody(_:) wraps an Encodable & Sendable value; Content-Type: application/json; charset=UTF-8 is set for you.

struct CreateUser: Encodable, Sendable { let name: String; let email: String }

let created: User = try await API.shared.request(
    url: "https://api.example.com/users",
    method: .post,
    body: jsonBody(CreateUser(name: "Ana", email: "ana@example.com"))
)

Form-url-encoded body

let token: TokenDTO = try await API.shared.request(
    url: "https://api.example.com/oauth/token",
    method: .post,
    body: .form([
        "grant_type": "password",
        "username": "ana",
        "password": "s3cr3t"          // reserved chars are percent-escaped, never dropped
    ])
)

Raw body (you control the bytes and content type)

body: RawBody(data: protobufData, contentType: "application/x-protobuf")

Path parameters

{name} placeholders in url are filled from pathParams:

url: "https://api.example.com/teams/{team}/members/{member}",
pathParams: ["team": "42", "member": "7"]

Custom headers

Merged over the defaults — your value wins per key, the other defaults stay.

headers: ["Authorization": "Bearer \(accessToken)"]

Plain-text / string responses

When T == String the raw response body is returned without JSON decoding:

let csv: String = try await API.shared.request(url: "\(base)/export.csv", method: .get)

Retry on transient 4xx

let data: Payload = try await API.shared.request(
    url: "\(base)/flaky",
    method: .get,
    persistConnection: true      // retries up to API.maxPersistRetries, then throws
)

Other options

Parameter Default Meaning
debug true Print request/response logs
timeoutInterval 30 Seconds
networkServiceType .default URLRequest.NetworkServiceType

Errors

Non-2xx responses throw an NSError:

do {
    let u: User = try await API.shared.request(url: "\(base)/users/999", method: .get)
} catch let error as NSError {
    error.code                       // HTTP status, e.g. 404
    error.localizedDescription       // from URLError
    error.localizedFailureReason     // pretty-printed response body
}

Transport failures surface as URLError. Malformed success bodies throw DecodingError with a readable message (missing key, type mismatch, …).


Uploads

Build a multipart form

var form = MultipartForm()
form.field("caption", "Sunset")
form.file("thumbnail", data: jpegData, filename: "thumb.jpg")          // in memory
form.file("video", url: localVideoURL)                                  // streamed from disk
  • field(_:_:) — plain text field.
  • file(_:data:filename:mime:) — in-memory blob. MIME guessed from the filename extension unless you pass mime:.
  • file(_:url:filename:mime:) — on-disk file, streamed straight into the body so a large file never becomes fully resident in memory.

Send

let result: UploadResult = try await API.shared.upload(
    url: "https://api.example.com/media",
    form: form
)

The body is serialised to a temp file and always removed afterwards, on success and on throw.

With progress

let result: UploadResult = try await API.shared.upload(
    url: "https://api.example.com/media",
    form: form,
    onProgress: { fraction in
        Task { @MainActor in progressView.progress = Float(fraction) }
    }
)

onProgress is called on an arbitrary queue with a value in 0.0...1.0, then 1.0 once the body has been fully sent. Hop to the main actor yourself before touching UI.


Client certificate (mutual TLS)

let p12 = try Data(contentsOf: certificateURL)
await API.shared.setupCertification(certData: p12, password: "cert-password")
// subsequent requests present the client certificate on TLS challenge

Extending: custom body types

Conform to HTTPBody:

struct CSVBody: HTTPBody {
    let rows: [[String]]
    func encoded() throws -> (data: Data, contentType: String) {
        let text = rows.map { $0.joined(separator: ",") }.joined(separator: "\n")
        return (Data(text.utf8), "text/csv; charset=UTF-8")
    }
}

try await API.shared.request(url: "\(base)/import", method: .post, body: CSVBody(rows: rows))

API needs no change to accept it.


Notes

  • API is an actor. Every call is await; config setters (setupCertification, setPersistConnectionDelay) are await too.
  • Response and body types must be Sendable. Value-type structs already are.
  • The shared instance is API.shared. Tests build isolated instances with API(testConfiguration:) and a stub URLProtocol.