# `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. ```swift 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 ```swift 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. ```swift 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 ```swift 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) ```swift body: RawBody(data: protobufData, contentType: "application/x-protobuf") ``` ### Path parameters `{name}` placeholders in `url` are filled from `pathParams`: ```swift 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. ```swift headers: ["Authorization": "Bearer \(accessToken)"] ``` ### Plain-text / string responses When `T == String` the raw response body is returned without JSON decoding: ```swift let csv: String = try await API.shared.request(url: "\(base)/export.csv", method: .get) ``` ### Retry on transient 4xx ```swift 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`: ```swift 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 ```swift 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 ```swift 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+) ```swift 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) ```swift 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`: ```swift 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`.