10 Commits

Author SHA1 Message Date
Daniel Arantes Loverde
757d81615b [api-upload-refactor] API networking refactor (v2.0.0)
Typed request bodies (HTTPBody), first-class MultipartForm uploads, API as an
actor, Decodable & Sendable responses, bounded persistConnection retries, zero
force operations, deployment target iOS 15. Breaking change - see
Documentation/API.md and decisions/2026-08-29-api-typed-body-upload.md.
2026-08-29 19:00:33 -03:00
Daniel Arantes Loverde
6acec0ff3a [api-upload-refactor] Set deployment target to iOS 15, simplify upload
- Package.swift: .iOS(.v13) -> .iOS(.v15), .watchOS(.v6) -> .v8
  (README already stated iOS 15; watchOS 8 needed for URLSession.upload(for:fromFile:))
- drop the iOS-13 uploadTask continuation bridge; both upload overloads now
  share one runUpload using session.upload(for:fromFile:delegate:)
- remove now-redundant @available(iOS 15) from the progress overload and test
- Documentation/API.md: drop iOS-version caveats
2026-08-29 18:51:49 -03:00
Daniel Arantes Loverde
20d761b361 [api-upload-refactor] Document API usage, link from README 2026-08-29 18:10:30 -03:00
Daniel Arantes Loverde
fb2e417104 [api-upload-refactor] Add iOS15 upload progress overload; toObjetct throws
- upload(..., onProgress: @Sendable (Double) -> Void) @available(iOS 15.0, *)
  via URLSession.upload(for:fromFile:delegate:) + UploadProgressDelegate;
  emits 1.0 on completion. Base upload stays iOS 13+
- move all upload code to LCEssentials+API+Upload.swift (buildUploadRequest /
  finishUpload / performUpload shared); API file 400 lines. Shared request
  helpers private -> internal
- Dictionary.toObjetct: try! -> throws
- APIUploadTests: +progress contract test (final 1.0, monotonic, decodes)
2026-08-29 17:46:41 -03:00
Daniel Arantes Loverde
3c64f364be [api-upload-refactor] Add multipart upload(), split logging to its own file
- upload<T>(url:method:form:pathParams:headers:debug:timeoutInterval:networkServiceType:)
  serialises MultipartForm to a temp file, streams it via uploadTask(with:fromFile:)
  bridged to async (works on iOS 13+, unlike URLSession.upload(for:fromFile:))
- temp body file always removed via defer (success and throw)
- move requestLOG/responseLOG to LCEssentials+API+Logging.swift; extract
  logResponseError helper; keeps API file at 473 lines
- APIUploadTests: 4 cases
- progress-reporting overload deferred (no stub seam for didSendBodyData)
2026-08-29 17:16:16 -03:00
Daniel Arantes Loverde
bcd358cb09 [api-upload-refactor] Replace request surface: typed body, pathParams, bounded retry
- request(): drop params: Any? and jsonEncoding:; add body: (any HTTPBody)?
  and pathParams: [String: String]
- remove legacy magic-"file" multipart branch (-> upload() in T6) and the
  now-unused mimeTypeForPath
- decompose into makeURL / buildRequest / attach / send / classify /
  decodeResponse / friendlyError (each < 30 lines)
- headers now merge over defaults (custom wins per key) instead of replacing
- fix unbounded persistConnection recursion on permanent 4xx: cap at
  maxPersistRetries (3)
- APIRequestTests: 7 cases over StubURLProtocol
2026-08-29 17:12:52 -03:00
Daniel Arantes Loverde
9581e49ce0 [api-upload-refactor] Convert API to an actor, drop @MainActor
- API: @MainActor struct -> actor. certData/certPassword/persistConnectionDelay
  move from static var (data race) to actor-isolated instance state
- setupCertificationRequest -> setupCertification; add setPersistConnectionDelay
- add internal init(testConfiguration:) seam; makeSession() helper picks
  test / cert-delegate / shared session
- URLSessionDelegateHandler: final + @unchecked Sendable, immutable let certs,
  no @MainActor; challenge logic unchanged
- getIdentity(): remove as! SecIdentity, extract via typed Unmanaged bridging
- delete unused Result enum and defaultParams
- APIActorTests: actor conformance, delay round-trip, per-instance cert state
2026-08-29 17:09:09 -03:00
Daniel Arantes Loverde
e89935a69c [api-upload-refactor] Add test target, relax decode constraints, add HTTPBody + MultipartForm
- Package: new LCEssentialsTests target (runs on iOS simulator)
- JSONDecoder.decode: T: Codable -> T: Decodable & Sendable (4 overloads);
  remove try! in decode(fromURL:); honour encoding param in decode(_:using:)
- Propagate & Sendable to Data.object, Dictionary.toObjetct, API.request return
- LCEHTTPBody: HTTPBody protocol, JSONBody, FormURLEncodedBody, RawBody, factories
- LCEMultipartForm: multipart builder, OutputStream-streamed serialize(),
  in-memory + on-disk file parts, ported mimeType(for:)
- Tests: StubURLProtocol harness + 15 tests
2026-08-29 16:59:08 -03:00
db696b5a34 Merge pull request '[lececryptokit-1.0.4] Bump LCECryptoKitBinary to the release that actually contains the pepper-refactor methods' (#10) from chore/deps/lececryptokit-1.0.4 into main
Reviewed-on: #10
2026-08-22 11:27:43 -03:00
Daniel Arantes Loverde
4e5130a01c [lececryptokit-1.0.4] Bump LCECryptoKitBinary to the release that actually contains the pepper-refactor methods 2026-08-22 11:26:38 -03:00
21 changed files with 1740 additions and 412 deletions

236
Documentation/API.md Normal file
View 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:)` |
| `@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.
### With progress
```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`.

View File

@@ -1,13 +1,13 @@
{ {
"originHash" : "967609fc5437e21be07c4206e2daefc73c3c7522601e64540f0dd5f115b19a6d", "originHash" : "6e3fcf8724d6b7d2d0bbffc25b4ec3b9d8b65a6ef53d157498d5447fd26cf5db",
"pins" : [ "pins" : [
{ {
"identity" : "lcecryptokitbinary", "identity" : "lcecryptokitbinary",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://60c260c85d3a2fe840411b0ff98f521b5eca3c56@git.loverde.com.br/Loverde-Company-LTDA/LCECryptoKitBinary.git", "location" : "https://60c260c85d3a2fe840411b0ff98f521b5eca3c56@git.loverde.com.br/Loverde-Company-LTDA/LCECryptoKitBinary.git",
"state" : { "state" : {
"revision" : "2c5c47cebef40a8adc5557d071a35be405c05e30", "revision" : "11f66b1b9785b4965e027c2fa4742f61e8453403",
"version" : "1.0.3" "version" : "1.0.4"
} }
} }
], ],

View File

@@ -11,7 +11,7 @@ let cryptoPackageURL = isLocalDevelopment
let packageDependencies: [Package.Dependency] = enableCryptoBinary let packageDependencies: [Package.Dependency] = enableCryptoBinary
? [ ? [
.package(url: cryptoPackageURL, exact: "1.0.3") .package(url: cryptoPackageURL, exact: "1.0.4")
] ]
: [] : []
@@ -24,10 +24,10 @@ let targetDependencies: [Target.Dependency] = enableCryptoBinary
let package = Package( let package = Package(
name: "LCEssentials", name: "LCEssentials",
platforms: [ platforms: [
.iOS(.v13), .iOS(.v15),
.macOS(.v10_15), .macOS(.v10_15),
.tvOS(.v13), .tvOS(.v13),
.watchOS(.v6) .watchOS(.v8)
], ],
products: [ products: [
.library( .library(
@@ -39,5 +39,8 @@ let package = Package(
.target( .target(
name: "LCEssentials", name: "LCEssentials",
dependencies: targetDependencies), dependencies: targetDependencies),
.testTarget(
name: "LCEssentialsTests",
dependencies: ["LCEssentials"]),
] ]
) )

View File

@@ -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**

View File

@@ -0,0 +1,116 @@
//
// Copyright (c) 2020 Loverde Co.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import Foundation
/// A request payload plus the `Content-Type` it implies.
///
/// Adopt this to teach `API.request` a new body encoding without changing `API`.
public protocol HTTPBody: Sendable {
/// - Returns: the encoded bytes and the `Content-Type` header value to send with them.
func encoded() throws -> (data: Data, contentType: String)
}
// MARK: - JSON
/// JSON-encodes an `Encodable` payload.
public struct JSONBody<Payload: Encodable & Sendable>: HTTPBody {
public let payload: Payload
private let encoder: @Sendable () -> JSONEncoder
/// - Parameters:
/// - payload: the value to encode.
/// - encoderProvider: builds the `JSONEncoder` to use. Defaults to a plain encoder.
public init(_ payload: Payload,
encoderProvider: @escaping @Sendable () -> JSONEncoder = { JSONEncoder() }) {
self.payload = payload
self.encoder = encoderProvider
}
public func encoded() throws -> (data: Data, contentType: String) {
(try encoder().encode(payload), "application/json; charset=UTF-8")
}
}
// MARK: - Form URL Encoded
/// `application/x-www-form-urlencoded` body. Every value is percent-escaped
/// nothing is silently dropped for containing reserved characters.
public struct FormURLEncodedBody: HTTPBody {
public let fields: [String: String]
public init(_ fields: [String: String]) {
self.fields = fields
}
public func encoded() throws -> (data: Data, contentType: String) {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~") // RFC 3986 unreserved
let pairs: [String] = fields.map { key, value in
let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
return "\(k)=\(v)"
}
let body = Data(pairs.joined(separator: "&").utf8)
return (body, "application/x-www-form-urlencoded; charset=UTF-8")
}
}
// MARK: - Raw
/// A pre-encoded body with an explicit content type.
public struct RawBody: HTTPBody {
public let data: Data
public let contentType: String
public init(data: Data, contentType: String) {
self.data = data
self.contentType = contentType
}
public func encoded() throws -> (data: Data, contentType: String) {
(data, contentType)
}
}
// MARK: - Factories
/// JSON body from any `Encodable & Sendable` value.
public func jsonBody<T: Encodable & Sendable>(_ value: T) -> JSONBody<T> {
JSONBody(value)
}
/// Form-url-encoded body from a string dictionary.
public func formBody(_ fields: [String: String]) -> FormURLEncodedBody {
FormURLEncodedBody(fields)
}
public extension HTTPBody where Self == FormURLEncodedBody {
/// Call-site sugar for `request(body: .form([...]))`.
static func form(_ fields: [String: String]) -> FormURLEncodedBody {
FormURLEncodedBody(fields)
}
}

View File

@@ -0,0 +1,190 @@
//
// Copyright (c) 2020 Loverde Co.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import Foundation
/// A `multipart/form-data` body builder.
///
/// Text fields and small in-memory blobs are held as `Data`; on-disk files are
/// referenced by `URL` and streamed straight into the serialised body, so a
/// large upload never becomes fully resident in memory.
public struct MultipartForm: Sendable {
/// Where a part's content comes from.
public enum Source: Sendable {
case data(Data)
case file(URL)
}
struct Part: Sendable {
let name: String
let filename: String?
let mimeType: String?
let source: Source
/// `true` plain form field: no `filename` / `Content-Type` header lines.
let isField: Bool
}
/// Error thrown while serialising the body.
public enum SerializationError: Error {
case cannotCreateTempFile(URL)
case cannotOpenOutput(URL)
case cannotOpenInput(URL)
case writeFailed(underlying: Error?)
case readFailed(URL)
}
private(set) var parts: [Part] = []
public let boundary: String
private static let chunkSize = 64 * 1024
private static let crlf = "\r\n"
/// - Parameter boundary: multipart boundary token. Defaults to a random value.
public init(boundary: String = "LCEssentials-\(UUID().uuidString)") {
self.boundary = boundary
}
// MARK: - Building
/// Appends a plain text field.
public mutating func field(_ name: String, _ value: String) {
parts.append(Part(name: name, filename: nil, mimeType: nil,
source: .data(Data(value.utf8)), isField: true))
}
/// Appends an in-memory file part.
public mutating func file(_ name: String, data: Data, filename: String, mime: String? = nil) {
parts.append(Part(name: name, filename: filename,
mimeType: mime ?? Self.mimeType(for: filename),
source: .data(data), isField: false))
}
/// Appends an on-disk file part. The file is streamed at serialisation time.
public mutating func file(_ name: String, url: URL, filename: String? = nil, mime: String? = nil) {
let resolvedName = filename ?? url.lastPathComponent
parts.append(Part(name: name, filename: resolvedName,
mimeType: mime ?? Self.mimeType(for: resolvedName),
source: .file(url), isField: false))
}
// MARK: - Serialisation
/// Writes the whole body to a temporary file.
///
/// - Returns: the temp file URL (caller must delete it once the upload
/// finishes) and the `multipart/form-data; boundary=` content type.
public func serialize() throws -> (fileURL: URL, contentType: String) {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("lce-multipart-\(UUID().uuidString).tmp")
guard FileManager.default.createFile(atPath: fileURL.path, contents: nil) else {
throw SerializationError.cannotCreateTempFile(fileURL)
}
guard let output = OutputStream(url: fileURL, append: false) else {
throw SerializationError.cannotOpenOutput(fileURL)
}
output.open()
defer { output.close() }
for part in parts {
try write(Data(header(for: part).utf8), to: output)
switch part.source {
case .data(let data):
try write(data, to: output)
case .file(let url):
try stream(fileAt: url, to: output)
}
try write(Data(Self.crlf.utf8), to: output)
}
try write(Data("--\(boundary)--\(Self.crlf)".utf8), to: output)
return (fileURL, "multipart/form-data; boundary=\(boundary)")
}
private func header(for part: Part) -> String {
var header = "--\(boundary)\(Self.crlf)"
header += "Content-Disposition: form-data; name=\"\(part.name)\""
if let filename = part.filename, !part.isField {
header += "; filename=\"\(filename)\""
}
header += Self.crlf
if !part.isField, let mime = part.mimeType {
header += "Content-Type: \(mime)\(Self.crlf)"
}
header += Self.crlf
return header
}
private func write(_ data: Data, to output: OutputStream) throws {
guard !data.isEmpty else { return }
var bytesRemaining = data
while !bytesRemaining.isEmpty {
let written = bytesRemaining.withUnsafeBytes { raw -> Int in
guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return -1 }
return output.write(base, maxLength: bytesRemaining.count)
}
guard written > 0 else {
throw SerializationError.writeFailed(underlying: output.streamError)
}
bytesRemaining.removeFirst(written)
}
}
private func stream(fileAt url: URL, to output: OutputStream) throws {
guard let input = InputStream(url: url) else {
throw SerializationError.cannotOpenInput(url)
}
input.open()
defer { input.close() }
var buffer = [UInt8](repeating: 0, count: Self.chunkSize)
while input.hasBytesAvailable {
let read = input.read(&buffer, maxLength: buffer.count)
if read == 0 { break }
guard read > 0 else { throw SerializationError.readFailed(url) }
try write(Data(buffer[0..<read]), to: output)
}
}
// MARK: - MIME
/// Best-effort MIME type from a file name's extension.
/// Falls back to `application/octet-stream`.
public static func mimeType(for path: String) -> String {
let ext = (path as NSString).pathExtension.lowercased()
return mimeTypes[ext] ?? "application/octet-stream"
}
private static let mimeTypes: [String: String] = [
"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "gif": "image/gif",
"pdf": "application/pdf", "txt": "text/plain", "html": "text/html", "htm": "text/html",
"json": "application/json", "xml": "application/xml", "zip": "application/zip",
"mp3": "audio/mpeg", "mp4": "video/mp4", "mov": "video/quicktime",
"doc": "application/msword",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xls": "application/vnd.ms-excel",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"ppt": "application/vnd.ms-powerpoint",
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
]
}

View File

@@ -0,0 +1,94 @@
//
// Copyright (c) 2020 Loverde Co.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import Foundation
#if os(iOS) || os(watchOS)
@available(iOS 13.0.0, *)
extension API {
/// Logs details of an outgoing network request for debugging purposes.
static func requestLOG(method: httpMethod, request: URLRequest) {
print("\n<========================= 🟠 INTERNET CONNECTION - REQUEST =========================>")
printLog(title: "DATE AND TIME", msg: Date().debugDescription)
printLog(title: "METHOD", msg: method.rawValue)
printLog(title: "REQUEST", msg: String(describing: request))
printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "")
if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson {
printLog(title: "PARAMETERS", msg: prettyJson)
} else if let dataBody = request.httpBody {
printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-")
}
print("<======================================================================================>")
}
/// Logs details of an incoming network response for debugging purposes.
static func responseLOG(method: httpMethod, request: URLRequest, data: Data?, statusCode: Int, error: Error?) {
let icon = error != nil ? "🔴" : "🟢"
print("\n<========================= \(icon) INTERNET CONNECTION - RESPONSE =========================>")
printLog(title: "DATE AND TIME", msg: Date().debugDescription)
printLog(title: "METHOD", msg: method.rawValue)
printLog(title: "REQUEST", msg: String(describing: request))
printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "")
if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson {
printLog(title: "PARAMETERS", msg: prettyJson)
} else if let dataBody = request.httpBody {
printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-")
}
printLog(title: "STATUS CODE", msg: String(describing: statusCode))
if let dataResponse = data, let prettyJson = dataResponse.prettyJson {
printLog(title: "RESPONSE", msg: prettyJson)
} else {
printLog(title: "RESPONSE", msg: String(data: data ?? Data(), encoding: .utf8) ?? "-")
}
logResponseError(error, data: data, statusCode: statusCode)
print("<======================================================================================>")
}
private static func logResponseError(_ error: Error?, data: Data?, statusCode: Int) {
if let error {
switch error.statusCode {
case NSURLErrorTimedOut:
printError(title: "RESPONSE ERROR TIMEOUT", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorNotConnectedToInternet:
printError(title: "RESPONSE ERROR NO INTERNET", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorNetworkConnectionLost:
printError(title: "RESPONSE ERROR CONNECTION LOST", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorCancelledReasonUserForceQuitApplication:
printError(title: "RESPONSE ERROR APP QUIT", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorCancelledReasonBackgroundUpdatesDisabled:
printError(title: "RESPONSE ERROR BG DISABLED", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorBackgroundSessionWasDisconnected:
printError(title: "RESPONSE ERROR BG SESSION DISCONNECTED", msg: "DESCRICAO: \(error.localizedDescription)")
default:
printError(title: "GENERAL", msg: error.localizedDescription)
}
} else if let data, statusCode != 200 {
if let jsonString = String(data: data, encoding: .utf8) {
printError(title: "JSON STATUS CODE \(statusCode)", msg: jsonString)
} else {
printError(title: "DATA STATUS CODE \(statusCode)", msg: data.debugDescription)
}
}
}
}
#endif

View File

@@ -0,0 +1,179 @@
//
// Copyright (c) 2020 Loverde Co.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import Foundation
#if os(iOS) || os(watchOS)
@available(iOS 13.0.0, *)
public extension API {
/// Uploads a `multipart/form-data` body and decodes the JSON response.
///
/// The body is serialised to a temporary file and streamed from disk, so a
/// large file never becomes fully resident in memory. The temp file is
/// always removed before returning, on success and on throw.
///
/// - Parameters:
/// - url: The URL string. `{name}` placeholders are filled from `pathParams`.
/// - method: The HTTP method. Defaults to `.post`.
/// - form: The multipart body (see ``MultipartForm``).
/// - pathParams: Values substituted into `{name}` placeholders in `url`.
/// - headers: Custom headers, merged over the defaults (custom wins). The
/// `Content-Type` is always set to the multipart type.
/// - debug: Print request/response debug logs. Defaults to `true`.
/// - timeoutInterval: Request timeout in seconds. Defaults to `120`.
/// - networkServiceType: `URLRequest.NetworkServiceType`. Defaults to `.default`.
/// - Returns: `T` decoded from the response body.
/// - Throws: `URLError` for transport failures, an `NSError` carrying the HTTP
/// status for non-2xx responses, or `DecodingError` on a malformed body.
func upload<T: Decodable & Sendable>(
url: String,
method: httpMethod = .post,
form: MultipartForm,
pathParams: [String: String] = [:],
headers: [String: String] = [:],
debug: Bool = true,
timeoutInterval: TimeInterval = 120,
networkServiceType: URLRequest.NetworkServiceType = .default
) async throws -> T {
try await runUpload(url: url, method: method, form: form, pathParams: pathParams,
headers: headers, debug: debug, timeout: timeoutInterval,
serviceType: networkServiceType, progressDelegate: nil)
}
/// Multipart upload that reports progress.
///
/// Same as ``upload(url:method:form:pathParams:headers:debug:timeoutInterval:networkServiceType:)``
/// but calls `onProgress` with a fraction in `0.0...1.0` as bytes are sent,
/// then `1.0` once the body has been fully transmitted.
///
/// - Parameter onProgress: invoked on an arbitrary queue; hop to the main
/// actor yourself before touching UI.
func upload<T: Decodable & Sendable>(
url: String,
method: httpMethod = .post,
form: MultipartForm,
pathParams: [String: String] = [:],
headers: [String: String] = [:],
debug: Bool = true,
timeoutInterval: TimeInterval = 120,
networkServiceType: URLRequest.NetworkServiceType = .default,
onProgress: @escaping @Sendable (Double) -> Void
) async throws -> T {
let result: T = try await runUpload(url: url, method: method, form: form,
pathParams: pathParams, headers: headers, debug: debug,
timeout: timeoutInterval, serviceType: networkServiceType,
progressDelegate: UploadProgressDelegate(onProgress: onProgress))
onProgress(1.0)
return result
}
}
// MARK: - Shared internals
@available(iOS 13.0.0, *)
extension API {
private func runUpload<T: Decodable & Sendable>(
url: String,
method: httpMethod,
form: MultipartForm,
pathParams: [String: String],
headers: [String: String],
debug: Bool,
timeout: TimeInterval,
serviceType: URLRequest.NetworkServiceType,
progressDelegate: UploadProgressDelegate?
) async throws -> T {
let prepared = try buildUploadRequest(url: url, method: method, form: form,
pathParams: pathParams, headers: headers,
timeout: timeout, serviceType: serviceType)
defer { try? FileManager.default.removeItem(at: prepared.bodyFile) }
if debug { API.requestLOG(method: method, request: prepared.request) }
let (session, mustInvalidate) = makeSession()
defer { if mustInvalidate { session.finishTasksAndInvalidate() } }
let (data, response) = try await session.upload(for: prepared.request,
fromFile: prepared.bodyFile,
delegate: progressDelegate)
return try API.finishUpload(data: data, response: response,
method: method, request: prepared.request, debug: debug)
}
private func buildUploadRequest(
url: String,
method: httpMethod,
form: MultipartForm,
pathParams: [String: String],
headers: [String: String],
timeout: TimeInterval,
serviceType: URLRequest.NetworkServiceType
) throws -> (request: URLRequest, bodyFile: URL) {
let resolvedURL = try makeURL(url, pathParams: pathParams)
var request = buildRequest(url: resolvedURL, method: method, headers: headers,
timeout: timeout, serviceType: serviceType)
let serialized = try form.serialize()
request.setValue(serialized.contentType, forHTTPHeaderField: "Content-Type")
return (request, serialized.fileURL)
}
fileprivate static func finishUpload<T: Decodable & Sendable>(
data: Data,
response: URLResponse,
method: httpMethod,
request: URLRequest,
debug: Bool
) throws -> T {
let code = (response as? HTTPURLResponse)?.statusCode ?? LCEssentials.DEFAULT_ERROR_CODE
switch classify(code: code, data: data, method: method, request: request, debug: debug) {
case .success:
return try decodeResponse(data)
case .clientError, .otherError:
throw friendlyError(code: code, data: data)
}
}
}
/// Forwards `URLSession` upload progress to a `@Sendable` closure. Immutable
/// after `init`, safe to hand to `URLSession` as a task delegate.
@available(iOS 13.0.0, *)
private final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
private let onProgress: @Sendable (Double) -> Void
init(onProgress: @escaping @Sendable (Double) -> Void) {
self.onProgress = onProgress
super.init()
}
func urlSession(_ session: URLSession,
task: URLSessionTask,
didSendBodyData bytesSent: Int64,
totalBytesSent: Int64,
totalBytesExpectedToSend: Int64) {
guard totalBytesExpectedToSend > 0 else { return }
let fraction = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
onProgress(min(max(fraction, 0), 1))
}
}
#endif

View File

@@ -29,14 +29,6 @@ import Security
import UIKit import UIKit
#endif #endif
/// A generic `Result` enumeration to represent either a success `Value` or a failure `Error`.
public enum Result<Value, Error: Swift.Error> {
/// Indicates a successful operation with an associated `Value`.
case success(Value)
/// Indicates a failed operation with an associated `Error`.
case failure(Error)
}
/// Enumeration defining common HTTP methods. /// Enumeration defining common HTTP methods.
public enum httpMethod: String { public enum httpMethod: String {
/// The POST method. /// The POST method.
@@ -51,240 +43,200 @@ public enum httpMethod: String {
case delete = "DELETE" case delete = "DELETE"
} }
/// Loverde Co.: API generic struct for simple requests. /// Loverde Co.: API entry point for simple requests.
/// ///
/// This struct provides a convenient way to perform network requests with various configurations, /// An `actor`, so callers are never forced onto the main thread. It performs
/// including handling different HTTP methods, parameter encoding, and certificate-based authentication. /// network requests with various configurations, handling different HTTP
/// methods, typed request bodies, multipart uploads, and certificate-based
/// authentication.
@available(iOS 13.0.0, *) @available(iOS 13.0.0, *)
@MainActor public actor API {
public struct API {
private static var certData: Data? /// Client-certificate data (`.p12`) for mutual-TLS, if configured.
private static var certPassword: String? private var certData: Data?
/// Password for `certData`, if any.
private var certPassword: String?
/// Session configuration used to build `URLSession`s. `nil` in production
/// (the shared session / a dedicated cert session is used); injected by
/// tests to register a stub `URLProtocol`.
private let sessionConfiguration: URLSessionConfiguration?
/// The default error used when an unexpected issue occurs during a request. /// The default error used when an unexpected issue occurs during a request.
static let defaultError = NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE, nonisolated static var defaultError: NSError {
description: LCEssentials.DEFAULT_ERROR_MSG, NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE,
reasonForError: LCEssentials.DEFAULT_ERROR_MSG) description: LCEssentials.DEFAULT_ERROR_MSG,
reasonForError: LCEssentials.DEFAULT_ERROR_MSG)
}
/// The delay in seconds before retrying a persistent connection request. /// The delay in seconds before retrying a persistent connection request.
public static var persistConnectionDelay: Double = 3 public private(set) var persistConnectionDelay: Double = 3
/// Default parameters that will be included in all requests unless explicitly overridden.
public static var defaultParams: [String:Any] = [String: Any]()
/// Default HTTP headers for requests. /// Default HTTP headers for requests.
/// ///
/// By default, it includes "Accept", "Content-Type", and "Accept-Encoding" headers. /// By default, it includes "Accept", "Content-Type", and "Accept-Encoding" headers.
var defaultHeaders: [String: String] = ["Accept": "application/json", nonisolated let defaultHeaders: [String: String] = ["Accept": "application/json",
"Content-Type": "application/json; charset=UTF-8", "Content-Type": "application/json; charset=UTF-8",
"Accept-Encoding": "gzip"] "Accept-Encoding": "gzip"]
/// The shared singleton instance of the `API` struct. /// The shared singleton instance of `API`.
public static let shared = API() public static let shared = API()
private init(){} private init() {
self.sessionConfiguration = nil
}
/// Performs an asynchronous network request and decodes the response into a `Codable` type. /// Test-only. Builds an isolated instance whose `URLSession`s use
/// `configuration` register a stub `URLProtocol` on it. Never call from
/// production code; `shared` state is untouched.
init(testConfiguration configuration: URLSessionConfiguration) {
self.sessionConfiguration = configuration
}
/// `true` when a client certificate has been supplied via ``setupCertification(certData:password:)``.
var hasClientCertificateConfigured: Bool { certData != nil }
/// Overrides ``persistConnectionDelay``.
public func setPersistConnectionDelay(_ seconds: Double) {
persistConnectionDelay = seconds
}
/// Maximum number of extra attempts made when `persistConnection` is set and
/// the server keeps returning a 4xx. Bounds what was previously an unbounded
/// recursion on a permanent client error.
public static let maxPersistRetries = 3
/// Performs an asynchronous network request and decodes the JSON response.
/// ///
/// - Parameters: /// - Parameters:
/// - url: The URL string for the request. /// - url: The URL string. `{name}` placeholders are filled from `pathParams`.
/// - params: Optional parameters for the request. Can be `[String: Any]` for JSON/form-data, or `Data` for raw body. /// - method: The HTTP method (`.get`, `.post`, `.put`, `.delete`, `.patch`).
/// - method: The HTTP method to use for the request (`.get`, `.post`, `.put`, `.delete`,`.patch` ). /// - body: Optional typed request body (``JSONBody``, ``FormURLEncodedBody``,
/// - headers: Optional custom HTTP headers to be added to the request. These override default headers if there are conflicts. /// ``RawBody``, or any ``HTTPBody``). Its `Content-Type` and
/// - jsonEncoding: A boolean indicating whether parameters should be JSON encoded. Defaults to `true`. /// `Content-Length` are set automatically.
/// - debug: A boolean indicating whether to print debug logs for the request and response. Defaults to `true`. /// - pathParams: Values substituted into `{name}` placeholders in `url`.
/// - timeoutInterval: The timeout interval in seconds for the request. Defaults to `30`. /// - headers: Custom headers, merged over the defaults (custom wins).
/// - networkServiceType: The `URLRequest.NetworkServiceType` for the request. Defaults to `.default`. /// - debug: Print request/response debug logs. Defaults to `true`.
/// - persistConnection: A boolean indicating whether to persist the connection on certain error codes (e.g., 4xx). Defaults to `false`. /// - timeoutInterval: Request timeout in seconds. Defaults to `30`.
/// - Returns: An instance of the `T` type, decoded from the response data. /// - networkServiceType: `URLRequest.NetworkServiceType`. Defaults to `.default`.
/// - Throws: An `Error` if the request fails, including `URLError` for network issues or `DecodingError` for JSON decoding failures. /// - persistConnection: Retry (bounded by ``maxPersistRetries``) on a 4xx.
public func request<T: Codable>(url: String, /// - Returns: `T` decoded from the response body, or the raw string when `T == String`.
params: Any? = nil, /// - Throws: `URLError` for transport failures, an `NSError` carrying the HTTP
method: httpMethod, /// status for non-2xx responses, or `DecodingError` on a malformed body.
headers: [String: String] = [:], public func request<T: Decodable & Sendable>(
jsonEncoding: Bool = true, url: String,
debug: Bool = true, method: httpMethod,
timeoutInterval: TimeInterval = 30, body: (any HTTPBody)? = nil,
networkServiceType: URLRequest.NetworkServiceType = .default, pathParams: [String: String] = [:],
persistConnection: Bool = false) async throws -> T { headers: [String: String] = [:],
debug: Bool = true,
if let urlReq = URL(string: url.replaceURL(params as? [String: Any] ?? [:] )) { timeoutInterval: TimeInterval = 30,
var request = URLRequest(url: urlReq, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeoutInterval) networkServiceType: URLRequest.NetworkServiceType = .default,
if method == .post || method == .put || method == .delete || method == .patch { persistConnection: Bool = false
if let params = params as? [String: Any], ) async throws -> T {
let pathFile = params["file"] as? String, let resolvedURL = try makeURL(url, pathParams: pathParams)
let fileURL = URL(string: pathFile) { var urlRequest = buildRequest(url: resolvedURL, method: method, headers: headers,
let boundary = UUID().uuidString timeout: timeoutInterval, serviceType: networkServiceType)
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") if let body {
try Self.attach(body: body, to: &urlRequest)
var body = Data()
// Add additional fields (if any)
for (key, value) in params where key != "file" {
let stringValue = "\(value)"
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
body.append("\(stringValue)\r\n".data(using: .utf8)!)
}
// Add the file
let fileName = fileURL.lastPathComponent
let mimeType = mimeTypeForPath(path: fileName)
printInfo(title: "Body size before", msg: "\(body.count) bytes")
let fileData: Data
do {
fileData = try Data(contentsOf: fileURL)
printInfo(title: "Body size after", msg: "\(body.count) bytes")
} catch {
printError(title: "Upload File", msg: error.localizedDescription)
throw error
}
do {
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
let fileDataCopy = Data(fileData)
body.append(fileDataCopy)
let dataUTF8 = "\r\n".data(using: .utf8)!
body.append(dataUTF8)
printInfo(title: "Body size after", msg: "\(body.count) bytes")
}
// Finalize the request body
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length")
// Debug logs
printLog(title: "Boundary", msg: boundary)
if let bodyString = String(data: body, encoding: .utf8) {
printLog(title: "Body Content", msg: bodyString)
}
} else if jsonEncoding, let params = params as? [String: Any] {
let requestObject = try JSONSerialization.data(withJSONObject: params)
request.httpBody = requestObject
} else if let params = params as? [String: Any] {
var bodyComponents = URLComponents()
params.forEach({ (key, value) in
bodyComponents.queryItems?.append(URLQueryItem(name: key, value: value as? String))
})
request.httpBody = bodyComponents.query?.data(using: .utf8)
} else if let params = params as? Data {
request.httpBody = params
}
}
request.httpMethod = method.rawValue
request.timeoutInterval = timeoutInterval
request.networkServiceType = networkServiceType
// - Put Default Headers together with user defined params
if !headers.isEmpty {
// - Add it to request
headers.forEach { (key, value) in
request.addValue(value, forHTTPHeaderField: key)
}
}else{
defaultHeaders.forEach { (key, value) in
request.addValue(value, forHTTPHeaderField: key)
}
}
if debug {
API.requestLOG(method: method, request: request)
}
// Only spin up a dedicated session (with its own @MainActor
// delegate hop for every TLS/auth challenge) when client
// certificate auth is actually configured. Creating one of
// these per request unconditionally and never invalidating
// it could stall the async challenge callback waiting on an
// already-busy MainActor, hanging the request indefinitely with
// no timeout or error ever surfacing. The common case (no
// client cert) uses the shared session, which has none of this
// risk and is what URLSession is designed to be reused as.
let usesCertSession = API.certData != nil
let session: URLSession = usesCertSession
? URLSession(
configuration: .default,
delegate: URLSessionDelegateHandler(
certData: API.certData,
password: API.certPassword
),
delegateQueue: nil
)
: URLSession.shared
defer {
if usesCertSession {
session.finishTasksAndInvalidate()
}
}
do {
let (data, response) = try await session.data(for: request)
var code: Int = LCEssentials.DEFAULT_ERROR_CODE
let httpResponse = response as? HTTPURLResponse ?? HTTPURLResponse()
code = httpResponse.statusCode
let error = URLError(URLError.Code(rawValue: code))
switch code {
case 200..<300:
// - Debug LOG
if debug {
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: nil)
}
// - Check if is JSON result and try decode it
if let string = data.string as? T, T.self == String.self {
return string
}
// - Normal decoding
do {
return try JSONDecoder.decode(data: data)
} catch {
printError(title: "JSONDecoder", msg: error.localizedDescription)
throw error
}
case 400..<500:
// - Debug LOG
if debug {
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
}
if persistConnection {
printError(title: "INTERNET CONNECTION ERROR", msg: "WILL PERSIST")
// Recursive call for persistence
let persist: T = try await self.request(
url: url,
params: params,
method: method,
headers: headers,
jsonEncoding: jsonEncoding,
debug: debug,
timeoutInterval: timeoutInterval,
networkServiceType: networkServiceType,
persistConnection: persistConnection
)
return persist
} else {
if debug {
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
}
let friendlyError = NSError.createErrorWith(code: code, description: error.localizedDescription, reasonForError: data.prettyJson ?? "")
throw friendlyError
}
default:
// - Debug LOG
if debug {
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
}
let friendlyError = NSError.createErrorWith(code: code, description: error.localizedDescription, reasonForError: data.prettyJson ?? "")
throw friendlyError
}
} catch {
throw error
}
} }
throw API.defaultError return try await send(urlRequest, method: method, debug: debug,
persistConnection: persistConnection, retriesLeft: Self.maxPersistRetries)
}
/// Sends a fully-built request, handles the response, and applies bounded
/// `persistConnection` retries.
private func send<T: Decodable & Sendable>(
_ urlRequest: URLRequest,
method: httpMethod,
debug: Bool,
persistConnection: Bool,
retriesLeft: Int
) async throws -> T {
if debug { API.requestLOG(method: method, request: urlRequest) }
let (session, mustInvalidate) = makeSession()
defer { if mustInvalidate { session.finishTasksAndInvalidate() } }
let (data, response) = try await session.data(for: urlRequest)
let code = (response as? HTTPURLResponse)?.statusCode ?? LCEssentials.DEFAULT_ERROR_CODE
switch Self.classify(code: code, data: data, method: method, request: urlRequest, debug: debug) {
case .success:
return try Self.decodeResponse(data)
case .clientError where persistConnection && retriesLeft > 0:
printError(title: "INTERNET CONNECTION ERROR", msg: "WILL PERSIST (\(retriesLeft) left)")
return try await send(urlRequest, method: method, debug: debug,
persistConnection: persistConnection, retriesLeft: retriesLeft - 1)
case .clientError, .otherError:
throw Self.friendlyError(code: code, data: data)
}
}
// MARK: - Request building
func makeURL(_ template: String, pathParams: [String: String]) throws -> URL {
guard let url = URL(string: template.replaceURL(pathParams)) else { throw API.defaultError }
return url
}
func buildRequest(url: URL,
method: httpMethod,
headers: [String: String],
timeout: TimeInterval,
serviceType: URLRequest.NetworkServiceType) -> URLRequest {
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout)
request.httpMethod = method.rawValue
request.timeoutInterval = timeout
request.networkServiceType = serviceType
defaultHeaders.merging(headers) { _, custom in custom }
.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
return request
}
/// Sets the body plus its `Content-Type` and `Content-Length`. The body's own
/// content type wins over any set through `headers`.
private static func attach(body: any HTTPBody, to request: inout URLRequest) throws {
let (data, contentType) = try body.encoded()
request.httpBody = data
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
request.setValue("\(data.count)", forHTTPHeaderField: "Content-Length")
}
// MARK: - Response handling
enum ResponseDisposition { case success, clientError, otherError }
static func classify(code: Int, data: Data, method: httpMethod,
request: URLRequest, debug: Bool) -> ResponseDisposition {
let error = (200..<300).contains(code) ? nil : URLError(URLError.Code(rawValue: code))
if debug {
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
}
switch code {
case 200..<300: return .success
case 400..<500: return .clientError
default: return .otherError
}
}
static func decodeResponse<T: Decodable & Sendable>(_ data: Data) throws -> T {
if T.self == String.self, let string = String(data: data, encoding: .utf8) as? T {
return string
}
do {
return try JSONDecoder.decode(data: data)
} catch {
printError(title: "JSONDecoder", msg: error.localizedDescription)
throw error
}
}
static func friendlyError(code: Int, data: Data) -> NSError {
let urlError = URLError(URLError.Code(rawValue: code))
return NSError.createErrorWith(code: code,
description: urlError.localizedDescription,
reasonForError: data.prettyJson ?? "")
} }
/// Sets up client certificate data and an optional password for authentication. /// Sets up client certificate data and an optional password for authentication.
@@ -292,21 +244,52 @@ public struct API {
/// - Parameters: /// - Parameters:
/// - certData: The `Data` representation of the client certificate (e.g., a .p12 file). /// - certData: The `Data` representation of the client certificate (e.g., a .p12 file).
/// - password: The password for the certificate, if required. Defaults to an empty string. /// - password: The password for the certificate, if required. Defaults to an empty string.
public func setupCertificationRequest(certData: Data, password: String = "") { public func setupCertification(certData: Data, password: String = "") {
API.certData = certData self.certData = certData
API.certPassword = password self.certPassword = password
} }
/// Builds the `URLSession` for one request.
///
/// - A test configuration (if injected) always wins, carrying the cert
/// delegate when one is configured.
/// - Otherwise a dedicated, delegate-backed session is created only when a
/// client certificate is configured, and must be invalidated afterwards.
/// - The common no-cert case reuses `URLSession.shared`.
///
/// - Returns: the session and whether the caller must invalidate it.
func makeSession() -> (session: URLSession, mustInvalidate: Bool) {
#if canImport(Security)
let delegate: URLSessionDelegateHandler? = certData != nil
? URLSessionDelegateHandler(certData: certData, password: certPassword)
: nil
#else
let delegate: URLSessionDelegate? = nil
#endif
if let configuration = sessionConfiguration {
return (URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil), true)
}
if delegate != nil {
return (URLSession(configuration: .default, delegate: delegate, delegateQueue: nil), true)
}
return (URLSession.shared, false)
}
} }
#if canImport(Security) #if canImport(Security)
/// A custom `URLSessionDelegate` handler for managing URL session challenges, /// A custom `URLSessionDelegate` handler for managing URL session challenges,
/// particularly for client and server trust authentication. /// particularly for client and server trust authentication.
///
/// Immutable after `init`, so it is safe to hand to `URLSession` and have its
/// challenge callback invoked on any thread. The auth logic touches only the
/// Security framework, which is thread-safe.
@available(iOS 13.0.0, *) @available(iOS 13.0.0, *)
@MainActor private final class URLSessionDelegateHandler: NSObject, URLSessionDelegate, @unchecked Sendable {
private class URLSessionDelegateHandler: NSObject, URLSessionDelegate {
private var certData: Data? private let certData: Data?
private var certPass: String? private let certPass: String?
/// Initializes a new `URLSessionDelegateHandler` instance. /// Initializes a new `URLSessionDelegateHandler` instance.
/// ///
@@ -314,9 +297,9 @@ private class URLSessionDelegateHandler: NSObject, URLSessionDelegate {
/// - certData: Optional `Data` for the client certificate. /// - certData: Optional `Data` for the client certificate.
/// - password: Optional password for the client certificate. /// - password: Optional password for the client certificate.
init(certData: Data? = nil, password: String? = nil) { init(certData: Data? = nil, password: String? = nil) {
super.init()
self.certData = certData self.certData = certData
self.certPass = password self.certPass = password
super.init()
} }
/// Handles URL session authentication challenges. /// Handles URL session authentication challenges.
@@ -363,16 +346,29 @@ private class URLSessionDelegateHandler: NSObject, URLSessionDelegate {
// Import the .p12 certificate to get the identity // Import the .p12 certificate to get the identity
let status = SecPKCS12Import(certData as CFData, options as CFDictionary, &items) let status = SecPKCS12Import(certData as CFData, options as CFDictionary, &items)
if status == errSecSuccess, guard status == errSecSuccess,
let item = (items as? [[String: Any]])?.first, let items,
let identityRef = item[kSecImportItemIdentity as String] as CFTypeRef?, CFArrayGetCount(items) > 0,
CFGetTypeID(identityRef) == SecIdentityGetTypeID() { let itemPtr = CFArrayGetValueAtIndex(items, 0) else {
return (identityRef as! SecIdentity) printError(title: "Certificate", msg: "Failed to import client identity from .p12 (status: \(status))")
} else {
print("Erro ao importar a identidade do certificado: \(status)")
return nil return nil
} }
// Read the identity straight out of the CF result via typed `Unmanaged`
// bridging no `as!`. `SecPKCS12Import` boxes a CoreFoundation ref that
// Swift will not let us downcast without a force operation.
let itemDict = Unmanaged<CFDictionary>.fromOpaque(itemPtr).takeUnretainedValue()
let identityKey = Unmanaged.passUnretained(kSecImportItemIdentity).toOpaque()
guard let identityPtr = CFDictionaryGetValue(itemDict, identityKey) else {
printError(title: "Certificate", msg: "Imported .p12 contained no client identity")
return nil
}
let identity = Unmanaged<SecIdentity>.fromOpaque(identityPtr).takeUnretainedValue()
guard CFGetTypeID(identity) == SecIdentityGetTypeID() else {
printError(title: "Certificate", msg: "Imported .p12 entry is not a SecIdentity")
return nil
}
return identity
} }
/// Checks if the provided data is a valid PKCS#12 (P12) certificate with the given password. /// Checks if the provided data is a valid PKCS#12 (P12) certificate with the given password.
@@ -401,133 +397,4 @@ extension Error {
} }
} }
@available(iOS 13.0.0, *)
extension API {
/// Logs details of an outgoing network request for debugging purposes.
///
/// - Parameters:
/// - method: The HTTP method of the request.
/// - request: The `URLRequest` object.
fileprivate static func requestLOG(method: httpMethod, request: URLRequest) {
print("\n<========================= 🟠 INTERNET CONNECTION - REQUEST =========================>")
printLog(title: "DATE AND TIME", msg: Date().debugDescription)
printLog(title: "METHOD", msg: method.rawValue)
printLog(title: "REQUEST", msg: String(describing: request))
printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "")
//
if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson {
printLog(title: "PARAMETERS", msg: prettyJson)
} else if let dataBody = request.httpBody {
printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-")
}
//
print("<======================================================================================>")
}
/// Logs details of an incoming network response for debugging purposes.
///
/// - Parameters:
/// - method: The HTTP method of the original request.
/// - request: The `URLRequest` object that generated this response.
/// - data: The data received in the response.
/// - statusCode: The HTTP status code of the response.
/// - error: An optional `Error` object if the request failed.
fileprivate static func responseLOG(method: httpMethod, request: URLRequest, data: Data?, statusCode: Int, error: Error?) {
///
let icon = error != nil ? "🔴" : "🟢"
print("\n<========================= \(icon) INTERNET CONNECTION - RESPONSE =========================>")
printLog(title: "DATE AND TIME", msg: Date().debugDescription)
printLog(title: "METHOD", msg: method.rawValue)
printLog(title: "REQUEST", msg: String(describing: request))
printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "")
//
if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson {
printLog(title: "PARAMETERS", msg: prettyJson)
} else if let dataBody = request.httpBody {
printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-")
}
//
printLog(title: "STATUS CODE", msg: String(describing: statusCode))
//
if let dataResponse = data, let prettyJson = dataResponse.prettyJson {
printLog(title: "RESPONSE", msg: prettyJson)
} else {
printLog(title: "RESPONSE", msg: String(data: data ?? Data(), encoding: .utf8) ?? "-")
}
//
if let error = error {
switch error.statusCode {
case NSURLErrorTimedOut:
printError(title: "RESPONSE ERROR TIMEOUT", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorNotConnectedToInternet:
printError(title: "RESPONSE ERROR NO INTERNET", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorNetworkConnectionLost:
printError(title: "RESPONSE ERROR CONNECTION LOST", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorCancelledReasonUserForceQuitApplication:
printError(title: "RESPONSE ERROR APP QUIT", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorCancelledReasonBackgroundUpdatesDisabled:
printError(title: "RESPONSE ERROR BG DISABLED", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorBackgroundSessionWasDisconnected:
printError(title: "RESPONSE ERROR BG SESSION DISCONNECTED", msg: "DESCRICAO: \(error.localizedDescription)")
default:
printError(title: "GENERAL", msg: error.localizedDescription)
}
}else if let data = data, statusCode != 200 {
// - Check if is JSON result
if let jsonString = String(data: data, encoding: .utf8) {
printError(title: "JSON STATUS CODE \(statusCode)", msg: jsonString)
}else{
printError(title: "DATA STATUS CODE \(statusCode)", msg: data.debugDescription)
}
}
//
print("<======================================================================================>")
}
/// Determines the MIME type for a given file path based on its extension.
///
/// - Parameter path: The file path string.
/// - Returns: A string representing the MIME type. Defaults to "application/octet-stream" if the type is unknown.
func mimeTypeForPath(path: String) -> String {
let url = URL(fileURLWithPath: path)
let pathExtension = url.pathExtension.lowercased()
// Dictionary of common extensions and MIME types
let mimeTypes: [String: String] = [
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
"gif": "image/gif",
"pdf": "application/pdf",
"txt": "text/plain",
"html": "text/html",
"htm": "text/html",
"json": "application/json",
"xml": "application/xml",
"zip": "application/zip",
"mp3": "audio/mpeg",
"mp4": "video/mp4",
"mov": "video/quicktime",
"doc": "application/msword",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xls": "application/vnd.ms-excel",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"ppt": "application/vnd.ms-powerpoint",
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
]
// Returns the corresponding MIME type for the extension, or "application/octet-stream" as default
return mimeTypes[pathExtension] ?? "application/octet-stream"
}
}
#endif #endif

View File

@@ -112,7 +112,7 @@ public extension Data {
#endif #endif
} }
func object<T: Codable>() -> T? { func object<T: Codable & Sendable>() -> T? {
do { do {
let outPut: T = try JSONDecoder.decode(data: self) let outPut: T = try JSONDecoder.decode(data: self)
return outPut return outPut

View File

@@ -137,11 +137,10 @@ public extension Dictionary {
/// - LoverdeCo: Convert Dictonary to Object /// - LoverdeCo: Convert Dictonary to Object
/// ///
/// - returns: Object: Codable/Decodable /// - returns: Object: Decodable & Sendable
func toObjetct<T: Codable>() -> T { /// - throws: `DecodingError` when the dictionary does not match `T`.
let jsonString = self.convertToJSON func toObjetct<T: Codable & Sendable>() throws -> T {
let output: T = try! JSONDecoder.decode(jsonString) try JSONDecoder.decode(self.convertToJSON)
return output
} }
/// Check if key exists in dictionary. /// Check if key exists in dictionary.

View File

@@ -44,8 +44,8 @@ extension JSONDecoder {
/// - LoverdeCo: Decode JSON Data to Object /// - LoverdeCo: Decode JSON Data to Object
/// ///
/// - Parameter data: Data /// - Parameter data: Data
/// - returns: Object: Codable/Decodable /// - returns: Object: Decodable & Sendable
public static func decode<T: Codable>(data: Data) throws -> T { public static func decode<T: Decodable & Sendable>(data: Data) throws -> T {
var error = NSError(domain: "", code: 0) var error = NSError(domain: "", code: 0)
let decoder = JSONDecoder() let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .useDefaultKeys decoder.keyDecodingStrategy = .useDefaultKeys
@@ -70,28 +70,24 @@ extension JSONDecoder {
/// - LoverdeCo: Decode JSON String to Object /// - LoverdeCo: Decode JSON String to Object
/// ///
/// - Parameter json: String /// - Parameter json: String
/// - returns: Object: Codable/Decodable /// - returns: Object: Decodable & Sendable
public static func decode<T: Codable>(_ json: String, using encoding: String.Encoding = .utf8) throws -> T { public static func decode<T: Decodable & Sendable>(_ json: String, using encoding: String.Encoding = .utf8) throws -> T {
var error = NSError() guard let jsonData = json.data(using: encoding) else {
if let jsonData = json.data(using: .utf8) { let msg = "Could not convert string to \(encoding) data for \(T.self)"
do { throw NSError.createErrorWith(code: 0, description: msg, reasonForError: msg)
return try decode(data: jsonData)
} catch {
throw error
}
} }
throw error return try decode(data: jsonData)
} }
/// - LoverdeCo: Decode JSON URL to Object /// - LoverdeCo: Decode JSON URL to Object
/// ///
/// - Parameter url: URL /// - Parameter url: URL
/// - returns: Object: Codable/Decodable /// - returns: Object: Decodable & Sendable
public static func decode<T: Codable>(fromURL url: URL) throws -> T { public static func decode<T: Decodable & Sendable>(fromURL url: URL) throws -> T {
return try decode(data: try! Data(contentsOf: url)) return try decode(data: Data(contentsOf: url))
} }
public static func decode<T: Codable>(dictionary: Any) throws -> T { public static func decode<T: Decodable & Sendable>(dictionary: Any) throws -> T {
do { do {
let json = try JSONSerialization.data(withJSONObject: dictionary) let json = try JSONSerialization.data(withJSONObject: dictionary)
let decoder = JSONDecoder() let decoder = JSONDecoder()

View File

@@ -0,0 +1,26 @@
import XCTest
@testable import LCEssentials
final class APIActorTests: XCTestCase {
func testAPIIsAnActorNotMainActorBound() async {
let isActor = (API.shared as Any) is any Actor
XCTAssertTrue(isActor, "API must be an actor so callers are not forced onto the main thread")
}
func testPersistConnectionDelayRoundTrips() async {
let api = API(testConfiguration: .ephemeral)
await api.setPersistConnectionDelay(9)
let value = await api.persistConnectionDelay
XCTAssertEqual(value, 9)
}
func testIsolatedTestInstanceDoesNotTouchSharedCertState() async {
let api = API(testConfiguration: .ephemeral)
await api.setupCertification(certData: Data([0x01, 0x02]), password: "pw")
let sharedHasCert = await API.shared.hasClientCertificateConfigured
let instanceHasCert = await api.hasClientCertificateConfigured
XCTAssertFalse(sharedHasCert)
XCTAssertTrue(instanceHasCert)
}
}

View File

@@ -0,0 +1,122 @@
import XCTest
@testable import LCEssentials
private struct Echo: Decodable, Sendable, Equatable {
let id: Int
let name: String
}
private struct CreateDTO: Encodable, Sendable {
let name: String
}
final class APIRequestTests: XCTestCase {
private var api: API!
override func setUp() {
super.setUp()
let cfg = URLSessionConfiguration.ephemeral
cfg.protocolClasses = [StubURLProtocol.self]
api = API(testConfiguration: cfg)
}
override func tearDown() {
StubURLProtocol.reset()
api = nil
super.tearDown()
}
func testJSONBodyRequestSendsEncodedBodyAndDecodesResponse() async throws {
var stub = StubURLProtocol.Stub()
stub.statusCode = 200
stub.body = Data(#"{"id":10,"name":"x"}"#.utf8)
StubURLProtocol.setStub(stub)
let result: Echo = try await api.request(
url: "https://api.example.com/users",
method: .post,
body: jsonBody(CreateDTO(name: "x"))
)
XCTAssertEqual(result, Echo(id: 10, name: "x"))
let sent = StubURLProtocol.capturedRequests.first
XCTAssertEqual(sent?.httpMethod, "POST")
XCTAssertEqual(sent?.value(forHTTPHeaderField: "Content-Type"), "application/json; charset=UTF-8")
XCTAssertEqual(StubURLProtocol.lastCapturedBody, Data(#"{"name":"x"}"#.utf8))
}
func testCustomHeaderOverridesDefault() async throws {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"id":1,"name":"a"}"#.utf8)))
let _: Echo = try await api.request(
url: "https://api.example.com/x",
method: .get,
headers: ["Accept": "application/xml"]
)
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.value(forHTTPHeaderField: "Accept"),
"application/xml")
}
func testPathParamsSubstitution() async throws {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"id":42,"name":"a"}"#.utf8)))
let _: Echo = try await api.request(
url: "https://api.example.com/users/{id}/posts",
method: .get,
pathParams: ["id": "42"]
)
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.url?.absoluteString,
"https://api.example.com/users/42/posts")
}
func testStringResponsePassthroughSkipsJSONDecoding() async throws {
StubURLProtocol.setStub(.init(statusCode: 200,
headers: ["Content-Type": "text/plain"],
body: Data("plain hello".utf8)))
let text: String = try await api.request(url: "https://api.example.com/ping", method: .get)
XCTAssertEqual(text, "plain hello")
}
func testClientErrorThrowsNSErrorWithStatusAndBody() async {
StubURLProtocol.setStub(.init(statusCode: 422,
body: Data(#"{"error":"invalid"}"#.utf8)))
do {
let _: Echo = try await api.request(url: "https://api.example.com/x", method: .post,
body: jsonBody(CreateDTO(name: "")))
XCTFail("expected throw")
} catch let error as NSError {
XCTAssertEqual(error.code, 422)
XCTAssertTrue(error.localizedFailureReason?.contains("invalid") ?? false,
"reason: \(error.localizedFailureReason ?? "nil")")
}
}
func testPersistConnectionRetriesButIsBoundedOnPermanentClientError() async {
StubURLProtocol.setStub(.init(statusCode: 400, body: Data(#"{"error":"nope"}"#.utf8)))
do {
let _: Echo = try await api.request(url: "https://api.example.com/x",
method: .get, persistConnection: true)
XCTFail("expected throw after retries exhausted")
} catch let error as NSError {
XCTAssertEqual(error.code, 400)
} catch {
XCTFail("unexpected error type: \(error)")
}
// retried, but did NOT loop forever
XCTAssertGreaterThanOrEqual(StubURLProtocol.requestCount, 2)
XCTAssertLessThanOrEqual(StubURLProtocol.requestCount, API.maxPersistRetries + 1)
}
func testNoPersistConnectionDoesNotRetry() async {
StubURLProtocol.setStub(.init(statusCode: 400, body: Data(#"{"error":"nope"}"#.utf8)))
let _: Echo? = try? await api.request(url: "https://api.example.com/x", method: .get)
XCTAssertEqual(StubURLProtocol.requestCount, 1)
}
}

View File

@@ -0,0 +1,123 @@
import XCTest
@testable import LCEssentials
private struct UploadEcho: Decodable, Sendable, Equatable {
let ok: Bool
}
/// Thread-safe sink for progress callbacks (invoked on an arbitrary queue).
private final class ProgressBox: @unchecked Sendable {
private let lock = NSLock()
private var storage: [Double] = []
func record(_ value: Double) { lock.lock(); storage.append(value); lock.unlock() }
var values: [Double] { lock.lock(); defer { lock.unlock() }; return storage }
}
final class APIUploadTests: XCTestCase {
private var api: API!
override func setUp() {
super.setUp()
let cfg = URLSessionConfiguration.ephemeral
cfg.protocolClasses = [StubURLProtocol.self]
api = API(testConfiguration: cfg)
}
override func tearDown() {
StubURLProtocol.reset()
api = nil
super.tearDown()
}
private func tempCountInDir(_ dir: URL) -> Int {
(try? FileManager.default.contentsOfDirectory(atPath: dir.path).filter {
$0.hasPrefix("lce-multipart-")
}.count) ?? -1
}
func testUploadSendsMultipartBodyAndDecodesResponse() async throws {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))
var form = MultipartForm()
form.field("caption", "hi")
form.file("photo", data: Data([0x89, 0x50, 0x4E, 0x47]), filename: "p.png")
let expectedBody = try Data(contentsOf: form.serialize().fileURL)
let result: UploadEcho = try await api.upload(
url: "https://api.example.com/media",
form: form
)
XCTAssertEqual(result, UploadEcho(ok: true))
let sent = StubURLProtocol.capturedRequests.first
XCTAssertEqual(sent?.httpMethod, "POST")
XCTAssertTrue(sent?.value(forHTTPHeaderField: "Content-Type")?
.hasPrefix("multipart/form-data; boundary=") ?? false)
// body framing matches a fresh serialize() (boundary differs per form
// instance, so compare structure, not bytes, by re-serialising the SAME form)
XCTAssertEqual(StubURLProtocol.lastCapturedBody?.count, expectedBody.count)
}
func testTempBodyFileRemovedAfterSuccess() async throws {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))
let dir = FileManager.default.temporaryDirectory
let before = tempCountInDir(dir)
var form = MultipartForm()
form.file("f", data: Data("x".utf8), filename: "a.txt")
let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form)
XCTAssertEqual(tempCountInDir(dir), before)
}
func testTempBodyFileRemovedAfterThrow() async {
StubURLProtocol.setStub(.init(statusCode: 500, body: Data(#"{"error":"boom"}"#.utf8)))
let dir = FileManager.default.temporaryDirectory
let before = tempCountInDir(dir)
var form = MultipartForm()
form.file("f", data: Data("x".utf8), filename: "a.txt")
do {
let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form)
XCTFail("expected throw")
} catch {
// expected
}
XCTAssertEqual(tempCountInDir(dir), before)
}
func testProgressOverloadDeliversFinalCompletionAndDecodes() async throws {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))
var form = MultipartForm()
form.file("f", data: Data(repeating: 0x41, count: 4096), filename: "a.bin")
let progressBox = ProgressBox()
let result: UploadEcho = try await api.upload(
url: "https://api.example.com/x",
form: form,
onProgress: { progressBox.record($0) }
)
XCTAssertEqual(result, UploadEcho(ok: true))
let values = progressBox.values
XCTAssertEqual(values.last, 1.0, "final progress must be 1.0")
XCTAssertTrue(values.allSatisfy { $0 >= 0 && $0 <= 1 })
XCTAssertEqual(values, values.sorted(), "progress must be monotonic non-decreasing")
}
func testUploadServerErrorThrowsWithStatus() async {
StubURLProtocol.setStub(.init(statusCode: 413, body: Data(#"{"error":"too big"}"#.utf8)))
var form = MultipartForm()
form.file("f", data: Data("x".utf8), filename: "a.txt")
do {
let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form)
XCTFail("expected throw")
} catch let error as NSError {
XCTAssertEqual(error.code, 413)
} catch {
XCTFail("wrong error: \(error)")
}
}
}

View File

@@ -0,0 +1,58 @@
import XCTest
@testable import LCEssentials
private struct SamplePayload: Encodable, Sendable {
let name: String
let age: Int
}
final class HTTPBodyTests: XCTestCase {
// MARK: - JSONBody
func testJSONBodyEncodesPayloadAndContentType() throws {
let payload = SamplePayload(name: "loverde", age: 3)
let (data, contentType) = try JSONBody(payload).encoded()
XCTAssertEqual(contentType, "application/json; charset=UTF-8")
let decoded = try JSONDecoder().decode([String: AnyDecodable].self, from: data)
XCTAssertEqual(decoded["name"]?.value as? String, "loverde")
XCTAssertEqual(decoded["age"]?.value as? Int, 3)
}
func testJSONBodyFactory() throws {
let (data, _) = try jsonBody(SamplePayload(name: "x", age: 1)).encoded()
XCTAssertFalse(data.isEmpty)
}
// MARK: - FormURLEncodedBody
func testFormURLEncodedEncodesPairsAndPercentEscapes() throws {
let (data, contentType) = try FormURLEncodedBody(["a": "1", "b": "two words"]).encoded()
XCTAssertEqual(contentType, "application/x-www-form-urlencoded; charset=UTF-8")
let pairs = Set(String(data: data, encoding: .utf8)!.split(separator: "&").map(String.init))
XCTAssertEqual(pairs, ["a=1", "b=two%20words"])
}
func testFormURLEncodedEscapesReservedCharactersInsteadOfDropping() throws {
let (data, _) = try FormURLEncodedBody(["q": "a&b=c"]).encoded()
let body = String(data: data, encoding: .utf8)!
XCTAssertEqual(body, "q=a%26b%3Dc")
}
}
/// Minimal type-erased decoder for asserting JSON shape in tests.
struct AnyDecodable: Decodable {
let value: Any
init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if let i = try? c.decode(Int.self) { value = i }
else if let s = try? c.decode(String.self) { value = s }
else if let b = try? c.decode(Bool.self) { value = b }
else if let d = try? c.decode(Double.self) { value = d }
else { value = "" }
}
}

View File

@@ -0,0 +1,26 @@
import XCTest
@testable import LCEssentials
private struct OnlyDecodable: Decodable, Sendable, Equatable {
let id: Int
let name: String
}
final class JSONDecoderDecodeTests: XCTestCase {
func testDecodesTypeThatIsDecodableAndSendableButNotEncodable() throws {
let data = Data(#"{"id":7,"name":"loverde"}"#.utf8)
let value: OnlyDecodable = try JSONDecoder.decode(data: data)
XCTAssertEqual(value, OnlyDecodable(id: 7, name: "loverde"))
}
func testDecodeFromStringOverload() throws {
let value: OnlyDecodable = try JSONDecoder.decode(#"{"id":1,"name":"a"}"#)
XCTAssertEqual(value, OnlyDecodable(id: 1, name: "a"))
}
func testDecodeFromURLThrowsInsteadOfCrashingOnMissingFile() {
let missing = URL(fileURLWithPath: "/tmp/does-not-exist-\(UUID().uuidString).json")
XCTAssertThrowsError(try JSONDecoder.decode(fromURL: missing) as OnlyDecodable)
}
}

View File

@@ -0,0 +1,106 @@
import XCTest
@testable import LCEssentials
final class MultipartFormTests: XCTestCase {
private func readSerialized(_ form: MultipartForm) throws -> (body: Data, contentType: String, url: URL) {
let result = try form.serialize()
let data = try Data(contentsOf: result.fileURL)
return (data, result.contentType, result.fileURL)
}
private func addCleanup(_ url: URL) {
addTeardownBlock { try? FileManager.default.removeItem(at: url) }
}
// MARK: - Fields
func testFieldPartHasNoContentTypeLine() throws {
var form = MultipartForm()
form.field("caption", "hello world")
let (body, contentType, url) = try readSerialized(form)
addCleanup(url)
let text = String(data: body, encoding: .utf8)!
XCTAssertTrue(contentType.hasPrefix("multipart/form-data; boundary="))
let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1])
XCTAssertEqual(text, [
"--\(boundary)\r\n",
"Content-Disposition: form-data; name=\"caption\"\r\n",
"\r\n",
"hello world\r\n",
"--\(boundary)--\r\n"
].joined())
}
// MARK: - In-memory data file
func testDataFilePartCarriesFilenameAndMimeAndRawBytes() throws {
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0xFF])
var form = MultipartForm()
form.file("photo", data: png, filename: "p.png")
let (body, contentType, url) = try readSerialized(form)
addCleanup(url)
let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1])
let text = String(data: body, encoding: .isoLatin1)!
XCTAssertTrue(text.contains("Content-Disposition: form-data; name=\"photo\"; filename=\"p.png\"\r\n"))
XCTAssertTrue(text.contains("Content-Type: image/png\r\n"))
XCTAssertTrue(text.hasSuffix("--\(boundary)--\r\n"))
// raw bytes appear verbatim between the blank line and the trailing CRLF
let marker = Data("\r\n\r\n".utf8)
let range = body.range(of: marker)!
let afterHeader = body[range.upperBound...]
XCTAssertTrue(afterHeader.starts(with: png))
}
func testExplicitMimeOverridesGuess() throws {
var form = MultipartForm()
form.file("f", data: Data("x".utf8), filename: "a.png", mime: "application/octet-stream")
let (body, _, url) = try readSerialized(form)
addCleanup(url)
XCTAssertTrue(String(data: body, encoding: .utf8)!.contains("Content-Type: application/octet-stream\r\n"))
}
// MARK: - Disk file, streamed
func testDiskFilePartIsStreamedAndContentMatches() throws {
let big = Data((0..<(2 * 1024 * 1024)).map { UInt8($0 % 251) })
let src = FileManager.default.temporaryDirectory.appendingPathComponent("src-\(UUID().uuidString).bin")
try big.write(to: src)
addCleanup(src)
var form = MultipartForm()
form.field("kind", "raw")
form.file("doc", url: src)
let (body, contentType, out) = try readSerialized(form)
addCleanup(out)
let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1])
// header uses the source filename
XCTAssertTrue(String(data: body.prefix(400), encoding: .isoLatin1)!
.contains("filename=\"\(src.lastPathComponent)\""))
// the 2 MB payload is present verbatim
let marker = Data("Content-Type: application/octet-stream\r\n\r\n".utf8)
let r = body.range(of: marker)!
let payload = body[r.upperBound..<(body.index(r.upperBound, offsetBy: big.count))]
XCTAssertEqual(Data(payload), big)
XCTAssertTrue(String(data: body.suffix(boundary.count + 8), encoding: .utf8)!
.hasSuffix("--\(boundary)--\r\n"))
}
// MARK: - MIME lookup
func testMimeTypeLookup() {
XCTAssertEqual(MultipartForm.mimeType(for: "a.jpg"), "image/jpeg")
XCTAssertEqual(MultipartForm.mimeType(for: "a.PDF"), "application/pdf")
XCTAssertEqual(MultipartForm.mimeType(for: "a.unknownext"), "application/octet-stream")
}
}

View File

@@ -0,0 +1,8 @@
import XCTest
@testable import LCEssentials
final class SmokeTests: XCTestCase {
func testTargetBuildsAndRuns() {
XCTAssertTrue(true)
}
}

View File

@@ -0,0 +1,116 @@
import Foundation
/// Test double for `URLProtocol`. Intercepts every request on a `URLSession`
/// configured with it, records the outgoing `URLRequest`, and replays a canned
/// response or error supplied by the test.
///
/// Register via:
/// ```
/// let cfg = URLSessionConfiguration.ephemeral
/// cfg.protocolClasses = [StubURLProtocol.self]
/// let session = URLSession(configuration: cfg)
/// ```
final class StubURLProtocol: URLProtocol, @unchecked Sendable {
struct Stub {
var statusCode: Int = 200
var headers: [String: String] = ["Content-Type": "application/json"]
var body: Data = Data()
var error: Error?
/// Bytes reported through `URLSession`'s upload progress, in order.
var uploadProgressChunks: [Int] = []
}
// MARK: - Test-facing state (guarded)
private static let lock = NSLock()
// Access is serialised through `lock`; the unsafe opt-out is the documented
// pattern for lock-guarded mutable statics under strict concurrency.
nonisolated(unsafe) private static var _stub = Stub()
nonisolated(unsafe) private static var _capturedRequests: [URLRequest] = []
nonisolated(unsafe) private static var _capturedBodies: [Data] = []
static func setStub(_ stub: Stub) {
lock.lock(); defer { lock.unlock() }
_stub = stub
_capturedRequests = []
_capturedBodies = []
}
static func reset() { setStub(Stub()) }
static var capturedRequests: [URLRequest] {
lock.lock(); defer { lock.unlock() }
return _capturedRequests
}
/// Body of the last intercepted request. `URLProtocol` strips `httpBody` for
/// stream bodies, so this reads `httpBodyStream` when needed.
static var lastCapturedBody: Data? {
lock.lock(); defer { lock.unlock() }
return _capturedBodies.last
}
static var requestCount: Int {
lock.lock(); defer { lock.unlock() }
return _capturedRequests.count
}
private static func currentStub() -> Stub {
lock.lock(); defer { lock.unlock() }
return _stub
}
private static func record(_ request: URLRequest, body: Data) {
lock.lock(); defer { lock.unlock() }
_capturedRequests.append(request)
_capturedBodies.append(body)
}
// MARK: - URLProtocol
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let stub = Self.currentStub()
Self.record(request, body: Self.bodyData(from: request))
guard let client = client else { return }
if let error = stub.error {
client.urlProtocol(self, didFailWithError: error)
return
}
let url = request.url ?? URL(string: "https://stub.invalid")!
let response = HTTPURLResponse(url: url,
statusCode: stub.statusCode,
httpVersion: "HTTP/1.1",
headerFields: stub.headers)!
client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client.urlProtocol(self, didLoad: stub.body)
client.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
// MARK: - Body extraction
private static func bodyData(from request: URLRequest) -> Data {
if let body = request.httpBody { return body }
guard let stream = request.httpBodyStream else { return Data() }
stream.open()
defer { stream.close() }
var data = Data()
let bufferSize = 64 * 1024
var buffer = [UInt8](repeating: 0, count: bufferSize)
while stream.hasBytesAvailable {
let read = stream.read(&buffer, maxLength: bufferSize)
if read <= 0 { break }
data.append(buffer, count: read)
}
return data
}
}

View File

@@ -0,0 +1,47 @@
import XCTest
final class StubURLProtocolTests: XCTestCase {
private func makeSession() -> URLSession {
let cfg = URLSessionConfiguration.ephemeral
cfg.protocolClasses = [StubURLProtocol.self]
return URLSession(configuration: cfg)
}
override func tearDown() {
StubURLProtocol.reset()
super.tearDown()
}
func testReplaysCannedResponseAndCapturesRequest() async throws {
var stub = StubURLProtocol.Stub()
stub.statusCode = 201
stub.body = Data(#"{"ok":true}"#.utf8)
StubURLProtocol.setStub(stub)
var request = URLRequest(url: URL(string: "https://example.com/things")!)
request.httpMethod = "POST"
request.httpBody = Data(#"{"name":"x"}"#.utf8)
let (data, response) = try await makeSession().data(for: request)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 201)
XCTAssertEqual(String(data: data, encoding: .utf8), #"{"ok":true}"#)
XCTAssertEqual(StubURLProtocol.requestCount, 1)
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.httpMethod, "POST")
XCTAssertEqual(StubURLProtocol.lastCapturedBody, Data(#"{"name":"x"}"#.utf8))
}
func testReplaysError() async {
var stub = StubURLProtocol.Stub()
stub.error = URLError(.notConnectedToInternet)
StubURLProtocol.setStub(stub)
do {
_ = try await makeSession().data(for: URLRequest(url: URL(string: "https://example.com")!))
XCTFail("expected throw")
} catch {
XCTAssertEqual((error as? URLError)?.code, .notConnectedToInternet)
}
}
}