[api-upload-refactor] Document API usage, link from README
This commit is contained in:
236
Documentation/API.md
Normal file
236
Documentation/API.md
Normal file
@@ -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`.
|
||||||
16
README.md
16
README.md
@@ -10,6 +10,7 @@ This is a repository of essential scripts written in Swift for Loverde Co. used
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
- [x] Many usefull scripts extensions
|
- [x] Many usefull scripts extensions
|
||||||
|
- [x] `API` — typed async networking + multipart uploads. See **[Documentation/API.md](Documentation/API.md)**
|
||||||
|
|
||||||
|
|
||||||
Installation
|
Installation
|
||||||
@@ -50,6 +51,21 @@ self.navigationController?.popViewControllerWithHandler {
|
|||||||
//Do some stuff after pop
|
//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
|
## Another components
|
||||||
> LCESnackBarView - **great way to send feedback to user**
|
> LCESnackBarView - **great way to send feedback to user**
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user