From 20d761b361b67151caf8efcfd099448f020ef443 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Sat, 29 Aug 2026 18:10:30 -0300 Subject: [PATCH] [api-upload-refactor] Document API usage, link from README --- Documentation/API.md | 236 +++++++++++++++++++++++++++++++++++++++++++ README.md | 16 +++ 2 files changed, 252 insertions(+) create mode 100644 Documentation/API.md diff --git a/Documentation/API.md b/Documentation/API.md new file mode 100644 index 0000000..71ba541 --- /dev/null +++ b/Documentation/API.md @@ -0,0 +1,236 @@ +# `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`. diff --git a/README.md b/README.md index 50143be..f252ffe 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ This is a repository of essential scripts written in Swift for Loverde Co. used ## Features - [x] Many usefull scripts extensions +- [x] `API` — typed async networking + multipart uploads. See **[Documentation/API.md](Documentation/API.md)** Installation @@ -50,6 +51,21 @@ self.navigationController?.popViewControllerWithHandler { //Do some stuff after pop } ``` +* Networking with `API` + +```swift +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"] +) +``` + +Full guide — requests, uploads, client certificates, error handling, and why it +beats a hand-rolled `URLSession`: **[Documentation/API.md](Documentation/API.md)** + ## Another components > LCESnackBarView - **great way to send feedback to user**