1 Commits

Author SHA1 Message Date
Daniel Arantes Loverde
27b6b45ee9 Complete documentation 2025-10-23 13:26:03 -03:00
56 changed files with 3704 additions and 2218 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,5 @@
.DS_Store .DS_Store
/.build /.build
/build
/Packages /Packages
xcuserdata/ xcuserdata/
DerivedData/ DerivedData/

View File

@@ -1,236 +0,0 @@
# `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`.

File diff suppressed because it is too large Load Diff

View File

@@ -22,5 +22,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.
Autor: Daniel Arantes Loverde

View File

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

View File

@@ -1,46 +1,16 @@
// swift-tools-version: 6.0 // swift-tools-version: 6.0
import PackageDescription import PackageDescription
import Foundation
let isLocalDevelopment = false //FileManager.default.fileExists(atPath: "../LCECryptoKit/PrivateLib/LCECryptoKitBinary")
let enableCryptoBinary = ProcessInfo.processInfo.environment["LCE_ENABLE_CRYPTO_BINARY"] != "0"
let cryptoPackageURL = isLocalDevelopment
? "../LCECryptoKit/PrivateLib/LCECryptoKitBinary"
: "https://60c260c85d3a2fe840411b0ff98f521b5eca3c56@git.loverde.com.br/Loverde-Company-LTDA/LCECryptoKitBinary.git"
let packageDependencies: [Package.Dependency] = enableCryptoBinary
? [
.package(url: cryptoPackageURL, exact: "1.0.4")
]
: []
let targetDependencies: [Target.Dependency] = enableCryptoBinary
? [
.product(name: "LCECryptoKit", package: "lcecryptokitbinary")
]
: []
let package = Package( let package = Package(
name: "LCEssentials", name: "LCEssentials",
platforms: [
.iOS(.v15),
.macOS(.v10_15),
.tvOS(.v13),
.watchOS(.v8)
],
products: [ products: [
.library( .library(
name: "LCEssentials", name: "LCEssentials",
targets: ["LCEssentials"]), targets: ["LCEssentials"]),
], ],
dependencies: packageDependencies,
targets: [ targets: [
.target( .target(
name: "LCEssentials", name: "LCEssentials"),
dependencies: targetDependencies),
.testTarget(
name: "LCEssentialsTests",
dependencies: ["LCEssentials"]),
] ]
) )

View File

@@ -10,7 +10,6 @@ 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
@@ -18,62 +17,21 @@ Installation
#### Swift Package Manager (SPM) #### Swift Package Manager (SPM)
``` swift ``` swift
dependencies: [ dependencies: [
.package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials", .upToNextMajor(from: "1.0.0")) .package(url: "http://git.loverde.com.br:3000/git/LCEssentials.git", .upToNextMajor(from: "1.0.0"))
] ]
``` ```
You can also add it via XCode SPM editor with URL: You can also add it via XCode SPM editor with URL:
``` swift ``` swift
https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials http://git.loverde.com.br:3000/git/LCEssentials.git
``` ```
## Usage example ## Usage example
[Read full documentation](LCEssentials_DOCUMENTATION.md)
* Background Trhead Author:
----
```swift
LCEssentials.backgroundThread(delay: 0.6, background: {
//Do something im background
}) {
//When finish, update UI
}
```
* NavigationController with Completion Handler
```swift
self.navigationController?.popViewControllerWithHandler(completion: {
//Do some stuff after pop
})
//or more simple
self.navigationController?.popViewControllerWithHandler {
//Do some stuff after pop
}
```
* Networking with `API`
```swift
struct User: Decodable, Sendable { let id: Int; let name: String }
let user: User = try await API.shared.request(
url: "https://api.example.com/users/{id}",
method: .get,
pathParams: ["id": "42"]
)
```
Full guide — requests, uploads, client certificates, error handling, and why it
beats a hand-rolled `URLSession`: **[Documentation/API.md](Documentation/API.md)**
## Another components
> LCESnackBarView - **great way to send feedback to user**
And then import `LCEssentials ` wherever you import UIKit or SwiftUI
``` swift
import LCEssentials
```
Any question or doubts, please send thru email Any question or doubts, please send thru email
@@ -81,5 +39,3 @@ Daniel Arantes Loverde - <daniel@loverde.com.br>
[![Alt text](https://loverde.com.br/_signature/loverde_github_mail.gif "My Resume")](https://github.com/loverde-co/resume/) [![Alt text](https://loverde.com.br/_signature/loverde_github_mail.gif "My Resume")](https://github.com/loverde-co/resume/)
[![Alt text](https://loverde.com.br/_signature/loverde_github_mail.gif "Loverde Co. Github")](https://github.com/loverde-co) [![Alt text](https://loverde.com.br/_signature/loverde_github_mail.gif "Loverde Co. Github")](https://github.com/loverde-co)
Autor: Daniel Arantes Loverde

View File

@@ -5,10 +5,8 @@
// Created by iOSDevCenters on 11/12/15. // Created by iOSDevCenters on 11/12/15.
// Copyright © 2016 iOSDevCenters. All rights reserved. // Copyright © 2016 iOSDevCenters. All rights reserved.
// //
#if canImport(UIKit)
import UIKit import UIKit
#endif #if os(iOS) || os(macOS)
#if canImport(UIKit)
import ImageIO import ImageIO
//let jeremyGif = UIImage.gifWithName("jeremy") //let jeremyGif = UIImage.gifWithName("jeremy")

View File

@@ -1,140 +0,0 @@
//
// Copyright (c) 2025 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 canImport(LCECryptoKit)
import LCECryptoKit
public final class LCECryptoKitManager {
private let hashKey: String
public init() {
self.hashKey = ""
}
public init(privateKey: String){
self.hashKey = privateKey
}
public static func generateKey() -> String {
LCECryptoKit.generateRandomAESKeyString()
}
public func encodeTP(email: String, password: String) -> String? {
return LCECryptoKit.encodeSeed(email: email, password: password)
}
public func decodeOTP(_ otpHash: String) -> String? {
return LCECryptoKit.decodeSeed(otpKey: otpHash)
}
// MARK: Need hashKey to decode
public func encodeOTPWithKey(email: String, password: String) -> String? {
return LCECryptoKit.encodeSeed(email: email, password: password, hashKey: self.hashKey)
}
public func decodeOTPWithKey(_ otpHash: String) -> Bool {
LCECryptoKit.decodeSeed(otpKey: otpHash, hashKey: self.hashKey)
}
// MARK: - Salted/Iterated/Peppered Login (atomenta-cryptokit-pepper-refactor-sdd.md)
public static func generateSalt() -> String {
LCECryptoKit.generateSalt()
}
public static func computeClientHash(email: String, password: String, salt: String) -> String {
LCECryptoKit.computeClientHash(email: email, password: password, salt: salt)
}
public static func computeLoginBearerToken(userId: String, clientHash: String) -> String? {
LCECryptoKit.computeLoginBearerToken(userId: userId, clientHash: clientHash)
}
public static func otpEncode(_ plainText: String) -> String? {
LCECryptoKit.otpEncode(plainText)
}
public static func otpDecode(_ otpEncoded: String) -> String? {
LCECryptoKit.otpDecode(otpEncoded)
}
}
#else
public final class LCECryptoKitManager {
private let hashKey: String
public init() {
self.hashKey = ""
}
public init(privateKey: String){
self.hashKey = privateKey
}
public static func generateKey() -> String {
""
}
public func encodeTP(email: String, password: String) -> String? {
nil
}
public func decodeOTP(_ otpHash: String) -> String? {
nil
}
public func encodeOTPWithKey(email: String, password: String) -> String? {
nil
}
public func decodeOTPWithKey(_ otpHash: String) -> Bool {
false
}
// MARK: - Salted/Iterated/Peppered Login (atomenta-cryptokit-pepper-refactor-sdd.md)
public static func generateSalt() -> String {
""
}
public static func computeClientHash(email: String, password: String, salt: String) -> String {
""
}
public static func computeLoginBearerToken(userId: String, clientHash: String) -> String? {
nil
}
public static func otpEncode(_ plainText: String) -> String? {
nil
}
public static func otpDecode(_ otpEncoded: String) -> String? {
nil
}
}
#endif

View File

@@ -1,116 +0,0 @@
//
// 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

@@ -1,190 +0,0 @@
//
// 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

@@ -1,94 +0,0 @@
//
// 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

@@ -1,179 +0,0 @@
//
// 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,6 +29,14 @@ 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.
@@ -37,206 +45,216 @@ public enum httpMethod: String {
case get = "GET" case get = "GET"
/// The PUT method. /// The PUT method.
case put = "PUT" case put = "PUT"
/// The PATCH method.
case patch = "PATCH"
/// The DELETE method. /// The DELETE method.
case delete = "DELETE" case delete = "DELETE"
} }
/// Loverde Co.: API entry point for simple requests. /// Loverde Co.: API generic struct for simple requests.
/// ///
/// An `actor`, so callers are never forced onto the main thread. It performs /// This struct provides a convenient way to perform network requests with various configurations,
/// network requests with various configurations, handling different HTTP /// including handling different HTTP methods, parameter encoding, and certificate-based authentication.
/// methods, typed request bodies, multipart uploads, and certificate-based
/// authentication.
@available(iOS 13.0.0, *) @available(iOS 13.0.0, *)
public actor API { @MainActor
public struct API {
/// Client-certificate data (`.p12`) for mutual-TLS, if configured. private static var certData: Data?
private var certData: Data? private static var certPassword: String?
/// 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.
nonisolated static var defaultError: NSError { static let defaultError = NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE,
NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE,
description: LCEssentials.DEFAULT_ERROR_MSG, description: LCEssentials.DEFAULT_ERROR_MSG,
reasonForError: 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 private(set) var persistConnectionDelay: Double = 3 public static 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.
nonisolated let defaultHeaders: [String: String] = ["Accept": "application/json", var 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 `API`. /// The shared singleton instance of the `API` struct.
public static let shared = API() public static let shared = API()
private init() { private init(){}
self.sessionConfiguration = nil
}
/// Test-only. Builds an isolated instance whose `URLSession`s use /// Performs an asynchronous network request and decodes the response into a `Codable` type.
/// `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. `{name}` placeholders are filled from `pathParams`. /// - url: The URL string for the request.
/// - method: The HTTP method (`.get`, `.post`, `.put`, `.delete`, `.patch`). /// - params: Optional parameters for the request. Can be `[String: Any]` for JSON/form-data, or `Data` for raw body.
/// - body: Optional typed request body (``JSONBody``, ``FormURLEncodedBody``, /// - method: The HTTP method to use for the request (`.get`, `.post`, `.put`, `.delete`).
/// ``RawBody``, or any ``HTTPBody``). Its `Content-Type` and /// - headers: Optional custom HTTP headers to be added to the request. These override default headers if there are conflicts.
/// `Content-Length` are set automatically. /// - jsonEncoding: A boolean indicating whether parameters should be JSON encoded. Defaults to `true`.
/// - pathParams: Values substituted into `{name}` placeholders in `url`. /// - debug: A boolean indicating whether to print debug logs for the request and response. Defaults to `true`.
/// - headers: Custom headers, merged over the defaults (custom wins). /// - timeoutInterval: The timeout interval in seconds for the request. Defaults to `30`.
/// - debug: Print request/response debug logs. Defaults to `true`. /// - networkServiceType: The `URLRequest.NetworkServiceType` for the request. Defaults to `.default`.
/// - timeoutInterval: Request timeout in seconds. Defaults to `30`. /// - persistConnection: A boolean indicating whether to persist the connection on certain error codes (e.g., 4xx). Defaults to `false`.
/// - networkServiceType: `URLRequest.NetworkServiceType`. Defaults to `.default`. /// - Returns: An instance of the `T` type, decoded from the response data.
/// - persistConnection: Retry (bounded by ``maxPersistRetries``) on a 4xx. /// - Throws: An `Error` if the request fails, including `URLError` for network issues or `DecodingError` for JSON decoding failures.
/// - Returns: `T` decoded from the response body, or the raw string when `T == String`. public func request<T: Codable>(url: String,
/// - Throws: `URLError` for transport failures, an `NSError` carrying the HTTP params: Any? = nil,
/// status for non-2xx responses, or `DecodingError` on a malformed body.
public func request<T: Decodable & Sendable>(
url: String,
method: httpMethod, method: httpMethod,
body: (any HTTPBody)? = nil,
pathParams: [String: String] = [:],
headers: [String: String] = [:], headers: [String: String] = [:],
jsonEncoding: Bool = true,
debug: Bool = true, debug: Bool = true,
timeoutInterval: TimeInterval = 30, timeoutInterval: TimeInterval = 30,
networkServiceType: URLRequest.NetworkServiceType = .default, networkServiceType: URLRequest.NetworkServiceType = .default,
persistConnection: Bool = false persistConnection: Bool = false) async throws -> T {
) async throws -> T {
let resolvedURL = try makeURL(url, pathParams: pathParams) if let urlReq = URL(string: url.replaceURL(params as? [String: Any] ?? [:] )) {
var urlRequest = buildRequest(url: resolvedURL, method: method, headers: headers, var request = URLRequest(url: urlReq, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 30)
timeout: timeoutInterval, serviceType: networkServiceType) if method == .post || method == .put || method == .delete {
if let body { if let params = params as? [String: Any],
try Self.attach(body: body, to: &urlRequest) let pathFile = params["file"] as? String,
} let fileURL = URL(string: pathFile) {
return try await send(urlRequest, method: method, debug: debug, let boundary = UUID().uuidString
persistConnection: persistConnection, retriesLeft: Self.maxPersistRetries) request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
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)!)
} }
/// Sends a fully-built request, handles the response, and applies bounded // Add the file
/// `persistConnection` retries. let fileName = fileURL.lastPathComponent
private func send<T: Decodable & Sendable>( let mimeType = mimeTypeForPath(path: fileName)
_ urlRequest: URLRequest, do {
method: httpMethod, let fileData = try Data(contentsOf: fileURL)
debug: Bool, body.append("--\(boundary)\r\n".data(using: .utf8)!)
persistConnection: Bool, body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
retriesLeft: Int body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
) async throws -> T { body.append(fileData)
if debug { API.requestLOG(method: method, request: urlRequest) } body.append("\r\n".data(using: .utf8)!)
} catch {
let (session, mustInvalidate) = makeSession() printError(title: "Upload File", msg: error.localizedDescription)
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 // Finalize the request body
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
func makeURL(_ template: String, pathParams: [String: String]) throws -> URL { request.httpBody = body
guard let url = URL(string: template.replaceURL(pathParams)) else { throw API.defaultError } request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length")
return url // 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
}
} }
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.httpMethod = method.rawValue
request.timeoutInterval = timeout request.timeoutInterval = timeoutInterval
request.networkServiceType = serviceType request.networkServiceType = networkServiceType
defaultHeaders.merging(headers) { _, custom in custom }
.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) } // - Put Default Headers together with user defined params
return request 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)
} }
/// 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 { if debug {
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error) API.requestLOG(method: method, request: request)
}
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 { let session = URLSession(
if T.self == String.self, let string = String(data: data, encoding: .utf8) as? T { configuration: .default,
delegate: URLSessionDelegateHandler(
certData: API.certData,
password: API.certPassword
),
delegateQueue: nil
)
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 return string
} }
// - Normal decoding
do { do {
return try JSONDecoder.decode(data: data) return try JSONDecoder.decode(data: data)
} catch { } catch {
printError(title: "JSONDecoder", msg: error.localizedDescription) printError(title: "JSONDecoder", msg: error.localizedDescription)
throw error throw error
} }
case 400..<500:
// - Debug LOG
if debug {
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
} }
if persistConnection {
static func friendlyError(code: Int, data: Data) -> NSError { printError(title: "INTERNET CONNECTION ERROR", msg: "WILL PERSIST")
let urlError = URLError(URLError.Code(rawValue: code)) // Recursive call for persistence
return NSError.createErrorWith(code: code, let persist: T = try await self.request(
description: urlError.localizedDescription, url: url,
reasonForError: data.prettyJson ?? "") 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
} }
/// Sets up client certificate data and an optional password for authentication. /// Sets up client certificate data and an optional password for authentication.
@@ -244,52 +262,21 @@ public actor 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 setupCertification(certData: Data, password: String = "") { public func setupCertificationRequest(certData: Data, password: String = "") {
self.certData = certData API.certData = certData
self.certPassword = password API.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, *)
private final class URLSessionDelegateHandler: NSObject, URLSessionDelegate, @unchecked Sendable { @MainActor
private class URLSessionDelegateHandler: NSObject, URLSessionDelegate {
private let certData: Data? private var certData: Data?
private let certPass: String? private var certPass: String?
/// Initializes a new `URLSessionDelegateHandler` instance. /// Initializes a new `URLSessionDelegateHandler` instance.
/// ///
@@ -297,9 +284,9 @@ private final class URLSessionDelegateHandler: NSObject, URLSessionDelegate, @un
/// - 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.
@@ -346,29 +333,16 @@ private final class URLSessionDelegateHandler: NSObject, URLSessionDelegate, @un
// 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)
guard status == errSecSuccess, if status == errSecSuccess,
let items, let item = (items as? [[String: Any]])?.first,
CFArrayGetCount(items) > 0, let identityRef = item[kSecImportItemIdentity as String] as CFTypeRef?,
let itemPtr = CFArrayGetValueAtIndex(items, 0) else { CFGetTypeID(identityRef) == SecIdentityGetTypeID() {
printError(title: "Certificate", msg: "Failed to import client identity from .p12 (status: \(status))") return (identityRef as! SecIdentity)
} 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.
@@ -397,4 +371,133 @@ 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

@@ -21,12 +21,8 @@
import Foundation import Foundation
#if canImport(UIKit)
import UIKit import UIKit
#endif
#if canImport(AVFoundation)
import AVFoundation import AVFoundation
#endif
#if os(watchOS) #if os(watchOS)
import WatchKit import WatchKit
#endif #endif
@@ -159,7 +155,7 @@ public struct LCEssentials {
diskPath: "file_cache" diskPath: "file_cache"
) )
#if canImport(UIKit) #if os(iOS) || os(macOS)
/// Extract the file name from the file path /// Extract the file name from the file path
/// ///
/// - Parameter filePath: Full file path in bundle /// - Parameter filePath: Full file path in bundle
@@ -270,11 +266,7 @@ public struct LCEssentials {
/// - LoverdeCo: Check if app is running in debug mode. /// - LoverdeCo: Check if app is running in debug mode.
@MainActor @MainActor
public static var isInDebuggingMode: Bool { public static var isInDebuggingMode: Bool {
#if canImport(UIKit)
return UIApplication.inferredEnvironment == .debug return UIApplication.inferredEnvironment == .debug
#else
return false
#endif
} }
#if !os(macOS) #if !os(macOS)
@@ -424,7 +416,7 @@ public struct LCEssentials {
// MARK: - Methods // MARK: - Methods
public extension LCEssentials { public extension LCEssentials {
#if canImport(UIKit) #if os(iOS) || os(macOS)
/// - LoverdeCo: Share link with message /// - LoverdeCo: Share link with message
/// ///
/// - Parameters: /// - Parameters:

View File

@@ -20,9 +20,7 @@
// THE SOFTWARE. // THE SOFTWARE.
import Foundation import Foundation
#if canImport(UIKit)
import UIKit import UIKit
#endif
@objc public protocol LCESingletonDelegate: AnyObject { @objc public protocol LCESingletonDelegate: AnyObject {
@objc optional func singleton(object: Any?, withData: Any) @objc optional func singleton(object: Any?, withData: Any)

View File

@@ -21,9 +21,7 @@
import Foundation import Foundation
#if canImport(UIKit)
import UIKit import UIKit
#endif
// MARK: - Methods (Equatable) // MARK: - Methods (Equatable)
public extension Array where Element: Equatable { public extension Array where Element: Equatable {
@@ -150,7 +148,6 @@ public extension Array where Element: Equatable {
} }
} }
#if canImport(UIKit)
extension Array where Element == NSLayoutConstraint { extension Array where Element == NSLayoutConstraint {
@MainActor @MainActor
@@ -172,5 +169,3 @@ extension Array where Element == NSLayoutConstraint {
} }
} }
} }
#endif

View File

@@ -21,12 +21,8 @@
import Foundation import Foundation
#if canImport(CryptoKit)
import CryptoKit import CryptoKit
#endif
#if canImport(CommonCrypto)
import CommonCrypto import CommonCrypto
#endif
public extension Data { public extension Data {
@@ -75,25 +71,17 @@ public extension Data {
@available(iOS 13.0, *) @available(iOS 13.0, *)
func HMACSHA512(key: Data) -> Data { func HMACSHA512(key: Data) -> Data {
#if canImport(CryptoKit)
var hmac = HMAC<SHA512>.init(key: SymmetricKey(data: key)) var hmac = HMAC<SHA512>.init(key: SymmetricKey(data: key))
hmac.update(data: self) hmac.update(data: self)
return Data(hmac.finalize()) return Data(hmac.finalize())
#else
return Data()
#endif
} }
func SHA512() -> Data { func SHA512() -> Data {
#if canImport(CommonCrypto)
var digest = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH)) var digest = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH))
self.withUnsafeBytes { self.withUnsafeBytes {
_ = CC_SHA512($0.baseAddress, CC_LONG(self.count), &digest) _ = CC_SHA512($0.baseAddress, CC_LONG(self.count), &digest)
} }
return Data(digest) return Data(digest)
#else
return Data()
#endif
} }
func XOR(with other: Data) -> Data { func XOR(with other: Data) -> Data {
@@ -101,18 +89,14 @@ public extension Data {
} }
func SHA256() -> Data { func SHA256() -> Data {
#if canImport(CommonCrypto)
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
self.withUnsafeBytes { self.withUnsafeBytes {
_ = CC_SHA256($0.baseAddress, CC_LONG(self.count), &digest) _ = CC_SHA256($0.baseAddress, CC_LONG(self.count), &digest)
} }
return Data(digest) return Data(digest)
#else
return Data()
#endif
} }
func object<T: Codable & Sendable>() -> T? { func object<T: Codable>() -> T? {
do { do {
let outPut: T = try JSONDecoder.decode(data: self) let outPut: T = try JSONDecoder.decode(data: self)
return outPut return outPut
@@ -140,13 +124,9 @@ public extension Data {
///print("md5Hex: \(md5Hex)") ///print("md5Hex: \(md5Hex)")
@available(iOS 13.0, *) @available(iOS 13.0, *)
static func MD5(string: String) -> Data { static func MD5(string: String) -> Data {
#if canImport(CryptoKit)
let messageData = string.data(using: .utf8)! let messageData = string.data(using: .utf8)!
let digestData = Insecure.MD5.hash (data: messageData) let digestData = Insecure.MD5.hash (data: messageData)
let digestHex = String(digestData.map { String(format: "%02hhx", $0) }.joined().prefix(32)) let digestHex = String(digestData.map { String(format: "%02hhx", $0) }.joined().prefix(32))
return Data(digestHex.utf8) return Data(digestHex.utf8)
#else
return Data()
#endif
} }
} }

View File

@@ -21,9 +21,7 @@
import Foundation import Foundation
#if canImport(UIKit)
import UIKit import UIKit
#endif
public extension Dictionary { public extension Dictionary {
@@ -137,10 +135,11 @@ public extension Dictionary {
/// - LoverdeCo: Convert Dictonary to Object /// - LoverdeCo: Convert Dictonary to Object
/// ///
/// - returns: Object: Decodable & Sendable /// - returns: Object: Codable/Decodable
/// - throws: `DecodingError` when the dictionary does not match `T`. func toObjetct<T: Codable>() -> T {
func toObjetct<T: Codable & Sendable>() throws -> T { let jsonString = self.convertToJSON
try JSONDecoder.decode(self.convertToJSON) let output: T = try! JSONDecoder.decode(jsonString)
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: Decodable & Sendable /// - returns: Object: Codable/Decodable
public static func decode<T: Decodable & Sendable>(data: Data) throws -> T { public static func decode<T: Codable>(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,24 +70,28 @@ extension JSONDecoder {
/// - LoverdeCo: Decode JSON String to Object /// - LoverdeCo: Decode JSON String to Object
/// ///
/// - Parameter json: String /// - Parameter json: String
/// - returns: Object: Decodable & Sendable /// - returns: Object: Codable/Decodable
public static func decode<T: Decodable & Sendable>(_ json: String, using encoding: String.Encoding = .utf8) throws -> T { public static func decode<T: Codable>(_ json: String, using encoding: String.Encoding = .utf8) throws -> T {
guard let jsonData = json.data(using: encoding) else { var error = NSError()
let msg = "Could not convert string to \(encoding) data for \(T.self)" if let jsonData = json.data(using: .utf8) {
throw NSError.createErrorWith(code: 0, description: msg, reasonForError: msg) do {
}
return try decode(data: jsonData) return try decode(data: jsonData)
} catch {
throw error
}
}
throw error
} }
/// - LoverdeCo: Decode JSON URL to Object /// - LoverdeCo: Decode JSON URL to Object
/// ///
/// - Parameter url: URL /// - Parameter url: URL
/// - returns: Object: Decodable & Sendable /// - returns: Object: Codable/Decodable
public static func decode<T: Decodable & Sendable>(fromURL url: URL) throws -> T { public static func decode<T: Codable>(fromURL url: URL) throws -> T {
return try decode(data: Data(contentsOf: url)) return try decode(data: try! Data(contentsOf: url))
} }
public static func decode<T: Decodable & Sendable>(dictionary: Any) throws -> T { public static func decode<T: Codable>(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

@@ -21,9 +21,7 @@
import Foundation import Foundation
#if canImport(UIKit)
import UIKit import UIKit
#endif
public extension FileManager { public extension FileManager {
@@ -56,7 +54,7 @@ public extension FileManager {
} }
} }
#if canImport(UIKit) #if os(iOS) || os(macOS)
func saveImageToDirectory( _ imageWithPath : String, imagem : UIImage ) -> Bool { func saveImageToDirectory( _ imageWithPath : String, imagem : UIImage ) -> Bool {
let data = imagem.pngData() let data = imagem.pngData()

View File

@@ -21,11 +21,8 @@
import Foundation import Foundation
#if canImport(UIKit)
import UIKit import UIKit
#endif
#if canImport(UIKit)
extension NSLayoutConstraint { extension NSLayoutConstraint {
func constraintWithMultiplier(_ multiplier: CGFloat) -> NSLayoutConstraint { func constraintWithMultiplier(_ multiplier: CGFloat) -> NSLayoutConstraint {
@@ -75,4 +72,3 @@ extension NSLayoutConstraint {
return false return false
} }
} }
#endif

View File

@@ -21,10 +21,8 @@
import Foundation import Foundation
#if canImport(UIKit) #if os(iOS) || os(macOS)
#if canImport(UIKit)
import UIKit import UIKit
#endif
public extension NSMutableAttributedString { public extension NSMutableAttributedString {
@discardableResult func customize(_ text: String, @discardableResult func customize(_ text: String,

View File

@@ -20,9 +20,7 @@
// THE SOFTWARE. // THE SOFTWARE.
#if canImport(UIKit)
import UIKit import UIKit
#endif
public extension Optional { public extension Optional {
/// Get self of default value (if self is nil). /// Get self of default value (if self is nil).

View File

@@ -21,9 +21,7 @@
import Foundation import Foundation
#if canImport(UIKit)
import UIKit import UIKit
#endif
#if canImport(CommonCrypto) #if canImport(CommonCrypto)
import CommonCrypto import CommonCrypto
#endif #endif
@@ -50,14 +48,10 @@ public extension String {
} }
var convertToHTML: NSAttributedString? { var convertToHTML: NSAttributedString? {
#if canImport(UIKit)
return convertHtmlToAttributedStringWithCSS(font: nil, return convertHtmlToAttributedStringWithCSS(font: nil,
csscolor: "", csscolor: "",
lineheight: 0, lineheight: 0,
csstextalign: "") csstextalign: "")
#else
return convertHtmlToNSAttributedString
#endif
} }
/// Check if string is a valid URL. /// Check if string is a valid URL.
@@ -652,7 +646,7 @@ public extension String {
return strDate?.date(withCurrFormatt: newFormatt, localeIdentifier: localeIdentifier, timeZone: timeZone) return strDate?.date(withCurrFormatt: newFormatt, localeIdentifier: localeIdentifier, timeZone: timeZone)
} }
#if canImport(UIKit) #if os(iOS) || os(macOS)
func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat { func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat {
let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude) let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font], context: nil) let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font], context: nil)
@@ -763,7 +757,7 @@ public extension String {
} }
return self return self
} }
#if canImport(UIKit)
/// Converte String para HTML com CSS. /// Converte String para HTML com CSS.
/// ///
/// - Parameters: /// - Parameters:
@@ -807,7 +801,6 @@ public extension String {
return nil return nil
} }
} }
#endif
/// Float value from string (if applicable). /// Float value from string (if applicable).
/// ///

View File

@@ -19,10 +19,8 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE. // THE SOFTWARE.
#if canImport(UIKit) #if canImport(UIKit) && os(iOS) || os(macOS)
#if canImport(UIKit)
import UIKit import UIKit
#endif
public extension UIApplication { public extension UIApplication {
/// Application running environment. /// Application running environment.

View File

@@ -21,11 +21,9 @@
import Foundation import Foundation
#if canImport(UIKit)
import UIKit import UIKit
#endif
#if canImport(UIKit) #if os(iOS) || os(macOS)
// MARK: - Properties // MARK: - Properties
public extension UIButton { public extension UIButton {

View File

@@ -21,10 +21,8 @@
import Foundation import Foundation
#if canImport(UIKit) #if os(iOS) || os(macOS)
#if canImport(UIKit)
import UIKit import UIKit
#endif
public extension UICollectionView { public extension UICollectionView {

View File

@@ -21,11 +21,9 @@
import Foundation import Foundation
#if canImport(UIKit)
import UIKit import UIKit
#endif
#if canImport(UIKit) #if os(iOS) || os(macOS)
public extension UIColor { public extension UIColor {
var redValue: CGFloat{ return CIColor(color: self).red } var redValue: CGFloat{ return CIColor(color: self).red }

View File

@@ -21,11 +21,9 @@
import Foundation import Foundation
#if canImport(UIKit)
import UIKit import UIKit
#endif
#if canImport(UIKit) #if os(iOS) || os(macOS)
public extension UIDevice { public extension UIDevice {
static var topNotch: CGFloat { static var topNotch: CGFloat {
@@ -80,6 +78,7 @@ public extension UIDevice {
case "iPhone8,2": return "iPhone 6s Plus" case "iPhone8,2": return "iPhone 6s Plus"
case "iPhone9,1", "iPhone9,3": return "iPhone 7" case "iPhone9,1", "iPhone9,3": return "iPhone 7"
case "iPhone9,2", "iPhone9,4": return "iPhone 7 Plus" case "iPhone9,2", "iPhone9,4": return "iPhone 7 Plus"
case "iPhone8,4", "iPhone12,8": return "iPhone SE"
case "iPhone10,1", "iPhone10,4": return "iPhone 8" case "iPhone10,1", "iPhone10,4": return "iPhone 8"
case "iPhone10,2", "iPhone10,5": return "iPhone 8 Plus" case "iPhone10,2", "iPhone10,5": return "iPhone 8 Plus"
case "iPhone10,3", "iPhone10,6": return "iPhone X" case "iPhone10,3", "iPhone10,6": return "iPhone X"
@@ -93,76 +92,30 @@ public extension UIDevice {
case "iPhone13,2": return "iPhone 12" case "iPhone13,2": return "iPhone 12"
case "iPhone13,3": return "iPhone 12 Pro" case "iPhone13,3": return "iPhone 12 Pro"
case "iPhone13,4": return "iPhone 12 Pro Max" case "iPhone13,4": return "iPhone 12 Pro Max"
case "iPhone14,4": return "iPhone 13 mini"
case "iPhone14,5": return "iPhone 13"
case "iPhone14,2": return "iPhone 13 Pro"
case "iPhone14,3": return "iPhone 13 Pro Max"
case "iPhone14,7": return "iPhone 14"
case "iPhone14,8": return "iPhone 14 Plus"
case "iPhone15,2": return "iPhone 14 Pro"
case "iPhone15,3": return "iPhone 14 Pro Max"
case "iPhone15,4": return "iPhone 15"
case "iPhone15,5": return "iPhone 15 Plus"
case "iPhone16,1": return "iPhone 15 Pro"
case "iPhone16,2": return "iPhone 15 Pro Max"
case "iPhone17,3": return "iPhone 16"
case "iPhone17,4": return "iPhone 16 Plus"
case "iPhone17,1": return "iPhone 16 Pro"
case "iPhone17,2": return "iPhone 16 Pro Max"
case "iPhone17,5": return "iPhone 16e"
case "iPhone18,3": return "iPhone 17"
case "iPhone18,4": return "iPhone Air"
case "iPhone18,1": return "iPhone 17 Pro"
case "iPhone18,2": return "iPhone 17 Pro Max"
case "iPhone8,4": return "iPhone SE"
case "iPhone12,8": return "iPhone SE (2nd generation)"
case "iPhone14,6": return "iPhone SE (3rd generation)"
case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4":return "iPad 2" case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4":return "iPad 2"
case "iPad3,1", "iPad3,2", "iPad3,3": return "iPad (3rd generation)" case "iPad3,1", "iPad3,2", "iPad3,3": return "iPad 3"
case "iPad3,4", "iPad3,5", "iPad3,6": return "iPad (4th generation)" case "iPad3,4", "iPad3,5", "iPad3,6": return "iPad 4"
case "iPad6,11", "iPad6,12": return "iPad (5th generation)"
case "iPad7,5", "iPad7,6": return "iPad (6th generation)"
case "iPad7,11", "iPad7,12": return "iPad (7th generation)"
case "iPad11,6", "iPad11,7": return "iPad (8th generation)"
case "iPad12,1", "iPad12,2": return "iPad (9th generation)"
case "iPad13,18", "iPad13,19": return "iPad (10th generation)"
case "iPad15,7", "iPad15,8": return "iPad (11th generation)"
case "iPad4,1", "iPad4,2", "iPad4,3": return "iPad Air" case "iPad4,1", "iPad4,2", "iPad4,3": return "iPad Air"
case "iPad5,3", "iPad5,4": return "iPad Air 2" case "iPad5,3", "iPad5,4": return "iPad Air 2"
case "iPad11,3", "iPad11,4": return "iPad Air (3rd generation)" case "iPad6,11", "iPad6,12": return "iPad 5"
case "iPad13,1", "iPad13,2": return "iPad Air (4th generation)" case "iPad7,5", "iPad7,6": return "iPad 6"
case "iPad13,16", "iPad13,17": return "iPad Air (5th generation)" case "iPad7,11", "iPad7,12": return "iPad 7"
case "iPad14,8", "iPad14,9": return "iPad Air (11-inch) (M2)" case "iPad11,4", "iPad11,5": return "iPad Air (3rd generation)"
case "iPad14,10", "iPad14,11": return "iPad Air (13-inch) (M2)" case "iPad2,5", "iPad2,6", "iPad2,7": return "iPad Mini"
case "iPad15,3", "iPad15,4": return "iPad Air (11-inch) (M3)" case "iPad4,4", "iPad4,5", "iPad4,6": return "iPad Mini 2"
case "iPad15,5", "iPad15,6": return "iPad Air (13-inch) (M3)" case "iPad4,7", "iPad4,8", "iPad4,9": return "iPad Mini 3"
case "iPad2,5", "iPad2,6", "iPad2,7": return "iPad mini" case "iPad5,1", "iPad5,2": return "iPad Mini 4"
case "iPad4,4", "iPad4,5", "iPad4,6": return "iPad mini 2" case "iPad11,1", "iPad11,2": return "iPad Mini 5"
case "iPad4,7", "iPad4,8", "iPad4,9": return "iPad mini 3"
case "iPad5,1", "iPad5,2": return "iPad mini 4"
case "iPad11,1", "iPad11,2": return "iPad mini (5th generation)"
case "iPad14,1", "iPad14,2": return "iPad mini (6th generation)"
case "iPad16,1", "iPad16,2": return "iPad mini (A17 Pro)"
case "iPad6,3", "iPad6,4": return "iPad Pro (9.7-inch)" case "iPad6,3", "iPad6,4": return "iPad Pro (9.7-inch)"
case "iPad7,3", "iPad7,4": return "iPad Pro (10.5-inch)" case "iPad6,7", "iPad6,8": return "iPad Pro (12.9-inch)"
case "iPad8,1", "iPad8,2", "iPad8,3", "iPad8,4": return "iPad Pro (11-inch) (1st generation)"
case "iPad8,9", "iPad8,10": return "iPad Pro (11-inch) (2nd generation)"
case "iPad13,4", "iPad13,5", "iPad13,6", "iPad13,7": return "iPad Pro (11-inch) (3rd generation)"
case "iPad14,3", "iPad14,4": return "iPad Pro (11-inch) (4th generation)"
case "iPad16,3", "iPad16,4": return "iPad Pro (11-inch) (M4)"
case "iPad17,1", "iPad17,2": return "iPad Pro (11-inch) (M5)"
case "iPad6,7", "iPad6,8": return "iPad Pro (12.9-inch) (1st generation)"
case "iPad7,1", "iPad7,2": return "iPad Pro (12.9-inch) (2nd generation)" case "iPad7,1", "iPad7,2": return "iPad Pro (12.9-inch) (2nd generation)"
case "iPad7,3", "iPad7,4": return "iPad Pro (10.5-inch)"
case "iPad8,1", "iPad8,2", "iPad8,3", "iPad8,4":return "iPad Pro (11-inch)"
case "iPad8,5", "iPad8,6", "iPad8,7", "iPad8,8":return "iPad Pro (12.9-inch) (3rd generation)" case "iPad8,5", "iPad8,6", "iPad8,7", "iPad8,8":return "iPad Pro (12.9-inch) (3rd generation)"
case "iPad8,11", "iPad8,12": return "iPad Pro (12.9-inch) (4th generation)"
case "iPad13,8", "iPad13,9", "iPad13,10", "iPad13,11":return "iPad Pro (12.9-inch) (5th generation)"
case "iPad14,5", "iPad14,6": return "iPad Pro (12.9-inch) (6th generation)"
case "iPad16,5", "iPad16,6": return "iPad Pro (13-inch) (M4)"
case "iPad17,3", "iPad17,4": return "iPad Pro (13-inch) (M5)"
case "AppleTV5,3": return "Apple TV" case "AppleTV5,3": return "Apple TV"
case "AppleTV6,2": return "Apple TV 4K" case "AppleTV6,2": return "Apple TV 4K"
case "AudioAccessory1,1": return "HomePod" case "AudioAccessory1,1": return "HomePod"
case "AudioAccessory5,1": return "HomePod mini" case "i386", "x86_64": return "Simulator \(identifier)"
default: return identifier default: return identifier
} }
#endif #endif

View File

@@ -21,11 +21,9 @@
import Foundation import Foundation
#if canImport(UIKit)
import UIKit import UIKit
#endif
#if canImport(UIKit) #if os(iOS) || os(macOS)
public extension UIImage { public extension UIImage {
//Extension Required by RoundedButton to create UIImage from UIColor //Extension Required by RoundedButton to create UIImage from UIColor
func imageWithColor(color: UIColor) -> UIImage { func imageWithColor(color: UIColor) -> UIImage {

View File

@@ -21,11 +21,9 @@
import Foundation import Foundation
#if canImport(UIKit)
import UIKit import UIKit
#endif
#if canImport(UIKit) #if os(iOS) || os(macOS)
@MainActor @MainActor
public extension UIImageView { public extension UIImageView {

View File

@@ -21,11 +21,9 @@
import Foundation import Foundation
#if canImport(UIKit)
import UIKit import UIKit
#endif
#if canImport(UIKit) #if os(iOS) || os(macOS)
public extension UILabel { public extension UILabel {
func lineNumbers() -> Int{ func lineNumbers() -> Int{

View File

@@ -21,10 +21,8 @@
import Foundation import Foundation
#if canImport(UIKit) #if os(iOS) || os(macOS)
#if canImport(UIKit)
import UIKit import UIKit
#endif
import QuartzCore import QuartzCore
public extension UINavigationController { public extension UINavigationController {

View File

@@ -21,10 +21,8 @@
import Foundation import Foundation
#if canImport(UIKit) #if os(iOS) || os(macOS)
#if canImport(UIKit)
import UIKit import UIKit
#endif
public extension UIResponder { public extension UIResponder {

View File

@@ -21,9 +21,7 @@
#if canImport(UIKit) && !os(watchOS) #if canImport(UIKit) && !os(watchOS)
#if canImport(UIKit)
import UIKit import UIKit
#endif
// MARK: - Methods // MARK: - Methods
public extension UIScrollView { public extension UIScrollView {

View File

@@ -21,9 +21,7 @@
import Foundation import Foundation
#if canImport(UIKit)
import UIKit import UIKit
#endif
#if os(iOS) #if os(iOS)
// MARK: - Initializers // MARK: - Initializers

View File

@@ -21,10 +21,8 @@
import Foundation import Foundation
#if canImport(UIKit) #if os(iOS) || os(macOS)
#if canImport(UIKit)
import UIKit import UIKit
#endif
public class CustomTabBadge: UILabel { public class CustomTabBadge: UILabel {

View File

@@ -21,11 +21,9 @@
import Foundation import Foundation
#if canImport(UIKit)
import UIKit import UIKit
#endif
#if canImport(UIKit) #if os(iOS) || os(macOS)
//MARK: - UITableView Animation Cell //MARK: - UITableView Animation Cell
public typealias UITableViewCellAnimation = (UITableViewCell, IndexPath, UITableView) -> Void public typealias UITableViewCellAnimation = (UITableViewCell, IndexPath, UITableView) -> Void

View File

@@ -21,10 +21,8 @@
import Foundation import Foundation
#if canImport(UIKit) #if canImport(UIKit) && os(iOS) || os(macOS)
#if canImport(UIKit)
import UIKit import UIKit
#endif
public extension UITapGestureRecognizer { public extension UITapGestureRecognizer {

View File

@@ -21,10 +21,8 @@
import Foundation import Foundation
#if canImport(UIKit) #if os(iOS) || os(macOS)
#if canImport(UIKit)
import UIKit import UIKit
#endif
public extension UITextField { public extension UITextField {

View File

@@ -21,10 +21,8 @@
import Foundation import Foundation
#if canImport(UIKit) #if os(iOS) || os(macOS)
#if canImport(UIKit)
import UIKit import UIKit
#endif
typealias GradientPoints = (startPoint: CGPoint, endPoint: CGPoint) typealias GradientPoints = (startPoint: CGPoint, endPoint: CGPoint)

View File

@@ -22,10 +22,8 @@
import Foundation import Foundation
#if canImport(UIKit) #if os(iOS) || os(macOS)
#if canImport(UIKit)
import UIKit import UIKit
#endif
import QuartzCore import QuartzCore
public enum ToastPosition { public enum ToastPosition {

View File

@@ -20,10 +20,8 @@
// THE SOFTWARE. // THE SOFTWARE.
#if canImport(UIKit) #if os(iOS) || os(macOS)
#if canImport(UIKit)
import UIKit import UIKit
#endif
import AVFoundation import AVFoundation
import Photos import Photos

View File

@@ -19,11 +19,9 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE. // THE SOFTWARE.
#if canImport(UIKit)
import UIKit import UIKit
#endif
#if canImport(UIKit) #if os(iOS) || os(macOS)
/// A protocol for delegates of `ImageZoomController` to provide callbacks for zoom and close events. /// A protocol for delegates of `ImageZoomController` to provide callbacks for zoom and close events.
@objc public protocol ImageZoomControllerDelegate { @objc public protocol ImageZoomControllerDelegate {
/// Called when the image in the controller is zoomed. /// Called when the image in the controller is zoomed.

View File

@@ -20,8 +20,6 @@
// THE SOFTWARE. // THE SOFTWARE.
// MARK: - Framework headers // MARK: - Framework headers
#if canImport(UIKit)
import Foundation
import UIKit import UIKit
// MARK: - Protocols // MARK: - Protocols
@@ -233,37 +231,54 @@ public extension LCSnackBarView {
/// Handles the `keyboardWillShowNotification` to adjust the snackbar's position. /// Handles the `keyboardWillShowNotification` to adjust the snackbar's position.
/// - Parameter notification: The `Notification` object containing keyboard information. /// - Parameter notification: The `Notification` object containing keyboard information.
@MainActor @objc private func keyboardWillShow(_ notification: Notification?) -> Void {
@objc private func keyboardWillShow(_ notification: Notification?) {
guard let info = notification?.userInfo else { return } if let info = notification?.userInfo {
systemKeyboardVisible = true systemKeyboardVisible = true
//
let curveUserInfoKey = UIResponder.keyboardAnimationCurveUserInfoKey
let durationUserInfoKey = UIResponder.keyboardAnimationDurationUserInfoKey
let frameEndUserInfoKey = UIResponder.keyboardFrameEndUserInfoKey
//
var animationCurve: UIView.AnimationOptions = .curveEaseOut
var animationDuration: TimeInterval = 0.25
var height:CGFloat = 0.0
let animationCurveRaw = (info[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue // Getting keyboard animation.
?? UInt(UIView.AnimationCurve.easeOut.rawValue) if let curve = info[curveUserInfoKey] as? UIView.AnimationOptions {
let animationDuration = (info[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue animationCurve = curve
?? 0.25 }
let keyboardFrame = (info[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue
?? .zero
let height = keyboardFrame.height
let animationCurve = UIView.AnimationOptions(rawValue: animationCurveRaw << 16)
UIView.animate( // Getting keyboard animation duration
withDuration: animationDuration, if let duration = info[durationUserInfoKey] as? TimeInterval {
animationDuration = duration
}
// Getting UIKeyboardSize.
if let kbFrame = info[frameEndUserInfoKey] as? CGRect {
height = kbFrame.size.height
}
DispatchQueue.main.async { [weak self] in
UIView.animate(withDuration: animationDuration,
delay: 0, delay: 0,
options: animationCurve options: animationCurve,
) { [weak self] in animations: {
self?.frame.origin.y += height self?.frame.origin.y += height
})
}
} }
} }
/// Handles the `keyboardWillHideNotification`. /// Handles the `keyboardWillHideNotification`.
/// - Parameter notification: The `Notification` object. /// - Parameter notification: The `Notification` object.
@MainActor @objc private func keyboardWillHide(_ notification: Notification?) -> Void {
@objc private func keyboardWillHide(_ notification: Notification?) { DispatchQueue.main.async { [weak self] in
systemKeyboardVisible = false self?.systemKeyboardVisible = false
// keyboard is hidded // keyboard is hidded
} }
}
/// Updates the snackbar's style properties, such as width and corner radius, based on `_style`. /// Updates the snackbar's style properties, such as width and corner radius, based on `_style`.
private func updateStyle() { private func updateStyle() {
@@ -497,5 +512,3 @@ public extension LCSnackBarView {
} }
} }
} }
#endif

View File

@@ -18,7 +18,7 @@
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // 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 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE. // THE SOFTWARE.
#if canImport(SwiftUI) && os(iOS) #if canImport(SwiftUI)
import SwiftUI import SwiftUI
/// `LCENavigationState` is an `ObservableObject` that manages the state for `LCENavigationView`. /// `LCENavigationState` is an `ObservableObject` that manages the state for `LCENavigationView`.
@@ -47,8 +47,6 @@ class LCENavigationState: ObservableObject {
@Published var title: (any View) = Text("") @Published var title: (any View) = Text("")
/// The subtitle view of the navigation bar. /// The subtitle view of the navigation bar.
@Published var subTitle: (any View) = Text("") @Published var subTitle: (any View) = Text("")
/// The background color of the navigation bar.
@Published var navigationBarBackgroundColor: Color = .clear
} }
/// `LCENavigationView` is a SwiftUI `View` that provides a customizable navigation bar. /// `LCENavigationView` is a SwiftUI `View` that provides a customizable navigation bar.
@@ -81,7 +79,7 @@ public struct LCENavigationView<Content: View>: View {
/// The body of the `LCENavigationView`. /// The body of the `LCENavigationView`.
public var body: some View { public var body: some View {
VStack(spacing: 0) { VStack {
if !state.hideNavigationBar { if !state.hideNavigationBar {
NavigationBarView NavigationBarView
} }
@@ -102,7 +100,7 @@ public struct LCENavigationView<Content: View>: View {
.font(.headline) .font(.headline)
.padding() .padding()
.background { .background {
state.navigationBarBackgroundColor.ignoresSafeArea(edges: .top) Color.clear.ignoresSafeArea(edges: .top)
} }
} }
@@ -219,14 +217,6 @@ public struct LCENavigationView<Content: View>: View {
state.hideNavigationBar = hide state.hideNavigationBar = hide
return self return self
} }
/// Sets the background color for the navigation bar.
/// - Parameter color: The color to use as the navigation bar background.
/// - Returns: The `LCENavigationView` instance for chaining.
public func setNavigationBarBackgroundColor(_ color: Color) -> LCENavigationView {
state.navigationBarBackgroundColor = color
return self
}
} }
/// Extension to `FormatStyle` to format any value as a string. /// Extension to `FormatStyle` to format any value as a string.

View File

@@ -1,26 +0,0 @@
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

@@ -1,122 +0,0 @@
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

@@ -1,123 +0,0 @@
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

@@ -1,58 +0,0 @@
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

@@ -1,26 +0,0 @@
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

@@ -1,106 +0,0 @@
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

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

View File

@@ -1,116 +0,0 @@
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

@@ -1,47 +0,0 @@
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)
}
}
}