7.1 KiB
API — networking for LCEssentials
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:) (iOS 15+) |
@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 passmime:.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. Works on iOS 13+.
With progress (iOS 15+)
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
APIis anactor. Every call isawait; config setters (setupCertification,setPersistConnectionDelay) areawaittoo.- Response and body types must be
Sendable. Value-type structs already are. - The shared instance is
API.shared. Tests build isolated instances withAPI(testConfiguration:)and a stubURLProtocol.