1 Commits

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

1
.gitignore vendored
View File

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

View File

@@ -1,239 +0,0 @@
# `API` — networking for LCEssentials
Part of the reference set: [Extensions.md](Extensions.md) ·
[SwiftUI.md](SwiftUI.md) · [UIKit.md](UIKit.md).
`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

@@ -1,117 +0,0 @@
# LCEssentials — SwiftUI
SwiftUI components and `View` helpers. Foundation/value-type helpers are in
[Extensions.md](Extensions.md); UIKit-era helpers in [UIKit.md](UIKit.md).
Every section is a collapsible block — click a heading to expand it.
## Contents
- [Navigation](#navigation)
- [View helpers](#view-helpers)
---
## Navigation
<details>
<summary><b>LCENavigationView</b> — customizable navigation bar (iOS 15+)</summary>
A drop-in replacement for the system navigation bar with left/right buttons,
title + subtitle, background colour, and a hide toggle. Configuration methods
return `self`, so they chain. **Per the workspace iOS standards, this component
is mandatory on new SwiftUI screens instead of a hand-rolled bar.**
`@available(iOS 15, *)`, iOS only.
### `init(title: (any View) = Text(""), subTitle: (any View) = Text(""), @ViewBuilder content: () -> Content)`
The `content` closure is everything shown **below** the bar.
```swift
LCENavigationView(title: Text("Profile")) {
ScrollView {
ProfileForm()
}
}
```
### `func setTitle(text: (any View) = Text(""), subTitle: (any View)? = nil) -> LCENavigationView`
Set (or replace) the title and optional subtitle. Passing no `subTitle` hides
the subtitle row.
```swift
LCENavigationView { Content() }
.setTitle(text: Text("Orders"), subTitle: Text("32 open"))
```
### `func setLeftButton(text: Text = Text(""), image: (any View)? = nil, action: @escaping () -> Void) -> LCENavigationView`
Configure the leading button. If the trailing button has no text/image yet, a
transparent placeholder is added on that side so the title stays centred.
```swift
.setLeftButton(text: Text("Back"), image: Image(systemName: "chevron.left")) {
dismiss()
}
```
### `func setRightButton(text: Text = Text(""), image: (any View)? = nil, action: @escaping () -> Void) -> LCENavigationView`
Configure the trailing button (same placeholder behaviour for the leading side).
```swift
.setRightButton(image: Image(systemName: "plus")) {
showingNewItem = true
}
```
### `func hideNavigationView(_ hide: Bool) -> LCENavigationView`
Show or hide the whole bar (the content stays).
```swift
.hideNavigationView(isFullScreenMedia)
```
### `func setNavigationBarBackgroundColor(_ color: Color) -> LCENavigationView`
Bar background colour (extends into the top safe area). Defaults to `.clear`.
```swift
.setNavigationBarBackgroundColor(.blue.opacity(0.1))
```
### Full example
```swift
struct OrdersScreen: View {
@Environment(\.dismiss) private var dismiss
@State private var showingNew = false
var body: some View {
LCENavigationView(title: Text("Orders")) {
OrdersList()
}
.setLeftButton(text: Text("Back"),
image: Image(systemName: "chevron.left")) { dismiss() }
.setRightButton(image: Image(systemName: "plus")) { showingNew = true }
.setNavigationBarBackgroundColor(Color(.systemBackground))
}
}
```
> `LCENavigationState` (the `@Published` backing store) and the reflection-based
> `Text.string` / tag helpers in `View+Ext.swift` are `internal` implementation
> details — not part of the public API.
</details>
---
## View helpers
There are currently no public standalone `View` extensions — the `getTag` /
`extractTag` reflection utilities in `SwiftUI/View+Ext.swift` are `internal` and
exist only to support `LCENavigationView`'s subtitle handling.

View File

@@ -1,815 +0,0 @@
# LCEssentials — UIKit
UIKit-era helpers: programmatic layout & constraints, view/control extensions,
navigation, table/collection helpers, and drop-in components (image picker, image
zoom, snackbar, GIF loading).
Foundation/value-type helpers are in [Extensions.md](Extensions.md); SwiftUI
helpers in [SwiftUI.md](SwiftUI.md).
Every section is a collapsible block — click a heading to expand it.
## Contents
- [Layout & Constraints](#layout--constraints)
- [Views & Controls](#views--controls)
- [Navigation & Controllers](#navigation--controllers)
- [Collections & Tables](#collections--tables)
- [Media & Components](#media--components)
---
## Layout & Constraints
<details>
<summary><b>UIView</b> — programmatic constraints, chainable anchors</summary>
### `enum AnchorType`
The anchor to pin when using `setConstraintsTo`. Cases: `all`, `top`, `bottom`,
`leading`, `trailing`, `left`, `right`, `centerX`, `centerY`, `width`, `heigth`
*(sic)*, `topToBottom`, `bottomToTop`, `leadingToTrailing`, `trailingToLeading`,
plus `…GreaterThanOrEqualTo` / `…LessThanOrEqualTo` variants.
### `@discardableResult func setConstraintsTo(parentView: UIView, anchorType: AnchorType, value: CGFloat, safeArea: Bool = false) -> Self`
Activate one constraint against `parentView`. Remembers `parentView` (in
`viewReference`) so follow-up calls can omit it. `safeArea: true` pins top/bottom
to the safe-area guide (iOS 11+).
```swift
card.addSubview(label)
label.setConstraintsTo(parentView: card, anchorType: .top, value: 12, safeArea: false)
.setConstraints(.leading, 16)
.setConstraints(.trailing, -16)
```
### `@discardableResult func setConstraintsTo(_ parentView:_ anchorType:_ value:_ safeArea: Bool = false) -> Self`
Positional-argument shorthand for the above.
### `@discardableResult func setConstraintsTo(anchorType: AnchorType, value: CGFloat, safeArea: Bool = false) -> UIView`
Reuse the last `parentView` (`viewReference`). **Traps** with `fatalError` if no
`parentView` was set first.
### `@discardableResult func setConstraints(_ anchorType: AnchorType, _ value: CGFloat, _ safeArea: Bool = false) -> UIView`
Positional shorthand for the reuse form — the one you chain.
### `func setConstraints(_ toScrollView: UIScrollView, direction: UICollectionView.ScrollDirection = .vertical)`
Pin `self` as the single content view of a scroll view (edges + matching
width/height, with a low-priority constraint on the scroll axis). Traps if
`self` is itself a `UIScrollView`.
```swift
scrollView.addSubview(content)
content.setConstraints(scrollView, direction: .vertical)
```
### Constraint accessors
`widthConstraint`, `heightConstraint`, `leadingConstraint`, `trailingConstraint`,
`topConstraint`, `bottomConstraint`, `centerXConstraints`, `centerYConstraints`
the first matching `NSLayoutConstraint` found by walking up the hierarchy.
```swift
box.widthConstraint?.constant = 120
UIView.animate(withDuration: 0.2) { self.view.layoutIfNeeded() }
```
### `func findConstraint(attribute: NSLayoutConstraint.Attribute, for view: UIView) -> NSLayoutConstraint?`
Search self and ancestors for a constraint on `attribute` involving `view`.
### `func constraints(on anchor:) -> [NSLayoutConstraint]`
Constraints touching a given `NSLayoutYAxisAnchor` / `XAxisAnchor` / `Dimension` of `self`.
### `@discardableResult func setHeight(size:) -> Self` / `setHeight(min:)` / `setWidth(size:)` / `setWidth(min:)`
Activate a fixed or minimum dimension constraint.
```swift
avatar.setWidth(size: 44).setHeight(size: 44)
```
### `func setWidth(_ toView: UIView? = nil, constant: CGFloat, _ multiplier: CGFloat = 0) -> Self` / `setHeight(...)`
Dimension relative to another view (`multiplier`) or fixed (`multiplier == 0`).
### Frame setters
`setX(x:)`, `setY(y:)`, `setFrameWidth(width:)`, `setFrameHeight(height:)` — mutate
`self.frame` directly (manual layout, not Auto Layout).
</details>
<details>
<summary><b>UIView</b> — hierarchy, appearance, effects</summary>
### `static var className: String`
`String(describing: self)`.
```swift
UIButton.className // "UIButton"
```
### `func addSubview(_ subview:, translatesAutoresizingMaskIntoConstraints: Bool = false)` / `addSubviews(_ subviews: [UIView], …)`
Add a view (or many) and set `translatesAutoresizingMaskIntoConstraints` in one call.
```swift
view.addSubviews([header, body, footer]) // all ready for Auto Layout
```
### `func subviews<T>(ofType _: T.Type) -> [T]`
All descendants of a type, recursively.
```swift
formView.subviews(ofType: UITextField.self)
```
### `func findAView<T>(_ ofType: T.Type) -> T?`
First descendant of a type.
### `var parentViewController: UIViewController?`
Nearest owning view controller (walks the responder chain).
### `var borderColor: UIColor?` / `var borderWidth: CGFloat` / `var cornerRadius: CGFloat`
Layer border/corner shortcuts (`cornerRadius` also sets `masksToBounds`).
```swift
card.cornerRadius = 12
card.borderWidth = 1
card.borderColor = .separator
```
### `func setRadius(top: Bool, bottom: Bool, radius: CGFloat = 8)`
Round only the top and/or bottom corners.
```swift
sheet.setRadius(top: true, bottom: false, radius: 16)
```
### `func applyShadow(color:offSet:radius:opacity:shouldRasterize: Bool = true, rasterizationScaleTo: = UIScreen.main.scale)` / `func removeShadow()`
Layer shadow with rasterisation on by default.
```swift
card.applyShadow(color: .black, offSet: CGSize(width: 0, height: 2),
radius: 6, opacity: 0.15)
```
### `func insertBlurView(style: UIBlurEffect.Style, color: UIColor = .black, alpha: CGFloat = 0.9)`
Insert a full-bleed `UIVisualEffectView` behind the content.
### `func drawCircle(inCoord x:y:with radius:strokeColor: = .red, fillColor: = .gray, isEmpty: Bool = false) -> [String: Any]`
Add a circular `CAShapeLayer`; returns `["path": UIBezierPath, "layer": CAShapeLayer]`.
### `var isRightToLeft: Bool`
Effective RTL layout direction.
### `var screenshot: UIImage?` / `func asImage() -> UIImage`
Render the view to an image (`screenshot` via the legacy context, `asImage` via `UIGraphicsImageRenderer`).
```swift
let png = chartView.asImage().pngData()
```
### `var globalPoint: CGPoint?` / `var globalFrame: CGRect?` / `var absolutePosition: CGRect`
Origin/frame converted to window coordinates.
### `func fadeIn(withDuration: TimeInterval = 1, withDelay: TimeInterval = 0, completionHandler: @escaping (Bool) -> Void)` / `func fadeOut(...)`
Animate `alpha` to 1 / 0. *(Completion-handler API — pre-dates async/await.)*
```swift
overlay.fadeOut { _ in overlay.removeFromSuperview() }
```
### `var viewReference: UIView?`
Scratch reference used internally by the chained `setConstraintsTo` calls.
</details>
<details>
<summary><b>GradientOrientation / EnumBorderSide</b></summary>
### `enum GradientOrientation`
`topRightBottomLeft`, `topLeftBottomRight`, `horizontal`, `vertical` — maps to a
`CAGradientLayer` start/end point pair. Passed to gradient helpers elsewhere in
the package.
### `enum EnumBorderSide`
`top`, `bottom`, `left`, `right` — which edge to draw a single-side border on.
</details>
<details>
<summary><b>UIStackView</b> — convenience init, bulk arranged-subview ops (iOS 9+)</summary>
### `convenience init(arrangedSubviews: [UIView]? = nil, axis: = .vertical, spacing: = 0, alignment: = .fill, distribution: = .fill, layoutMargins: = .zero, isMarginsRelative: Bool = true)`
One-call configured stack view.
```swift
let stack = UIStackView(arrangedSubviews: [title, subtitle],
axis: .vertical, spacing: 4)
```
### `func addArrangedSubviews(_ views: [UIView], translateAutoresizing: Bool = false)`
Add many arranged subviews, setting their autoresizing flag.
### `func removeAllArrangedSubviews(deactivateConstraints: Bool = true)`
Remove and dispose every arranged subview.
### `func removeSubview(view: UIView, deactivateConstraints: Bool = true)`
Remove one arranged subview (optionally deactivating its constraints and removing from the hierarchy).
### `func addSpace(_ size: CGFloat, backgroundColor: UIColor = .clear)`
Insert a fixed-size spacer view sized along the stack's axis.
```swift
stack.addArrangedSubviews([row1, row2])
stack.addSpace(24)
stack.addArrangedSubview(row3)
```
</details>
> `NSLayoutConstraint` helpers (`constraintWithMultiplier`, `matches(view:anchor:)`)
> and `[NSLayoutConstraint].filtered(view:anchor:)` are `internal` — used by the
> `UIView.constraints(on:)` accessors above, not called directly.
## Views & Controls
<details>
<summary><b>UIButton</b> — per-state accessors, all-state setters</summary>
### Per-state properties
`imageForNormal` / `imageForHighlighted` / `imageForSelected` / `imageForDisabled`,
`titleForNormal` / `…Highlighted` / `…Selected` / `…Disabled`,
`titleColorForNormal` / `…Highlighted` / `…Selected` / `…Disabled` — get/set
shortcuts for the matching `UIControl.State` (also `@IBInspectable`).
```swift
button.titleForNormal = "Save"
button.titleColorForDisabled = .tertiaryLabel
```
### `func setTitleForAllStates(_:)` / `func setTitleColorForAllStates(_:)` / `func setImageForAllStates(_:)`
Apply one value to `.normal`, `.selected`, `.highlighted`, `.disabled` at once.
```swift
button.setTitleColorForAllStates(.white)
```
### `func centerTextAndImage(spacing: CGFloat)`
Balance title/image edge insets so text + icon sit centred with `spacing` between them.
```swift
button.centerTextAndImage(spacing: 8)
```
</details>
<details>
<summary><b>UILabel</b></summary>
### `func lineNumbers() -> Int`
Rendered line count at the current width.
```swift
if bodyLabel.lineNumbers() > 3 { showMoreButton() }
```
### `var getEstimatedHeight: CGFloat`
Height the label would need to show its full text/attributed text unclipped.
</details>
<details>
<summary><b>UITextField</b></summary>
### `var placeholderColor: UIColor`
Get/set the placeholder text colour (rebuilds `attributedPlaceholder`; setter is a
no-op if no placeholder text is set).
```swift
field.placeholder = "Email"
field.placeholderColor = .secondaryLabel
```
### `func addPaddingLeft(_ padding: CGFloat)`
Inset the text from the left with an empty spacer view.
### `func addPaddingLeftIcon(_ image: UIImage, padding: CGFloat)`
Left view = an icon plus trailing padding.
```swift
field.addPaddingLeftIcon(UIImage(systemName: "magnifyingglass")!, padding: 8)
```
</details>
<details>
<summary><b>UIImageView</b> (<code>@MainActor</code>)</summary>
### `func changeColorOfImage(_ color: UIColor, image: UIImage?) -> UIImageView`
Set a template-rendered image tinted to `color`; returns `self`.
```swift
iconView.changeColorOfImage(.systemBlue, image: UIImage(named: "star"))
```
### `var encodeToBase64: String?`
JPEG (quality 0.6) of the current image as a Base64 string.
### `func addAspectRatioConstraint()` / `func removeAspectRatioConstraint()`
Add / remove a width-to-height constraint matching the current image's ratio.
```swift
photoView.image = photo
photoView.addAspectRatioConstraint()
```
</details>
<details>
<summary><b>UIImage</b> — recolour, resize, thumbnails, masks, init</summary>
### `func imageWithColor(color: UIColor) -> UIImage` / `func tintImage(color: UIColor) -> UIImage`
Return a copy filled / tinted with `color` (keeps the alpha shape).
```swift
let redIcon = icon.tintImage(color: .systemRed)
```
### `func backgroundColorTransparent(initialColor: UIColor, finalColor: UIColor) -> UIImage?`
Make pixels in a colour range transparent.
### `class func outlinedEllipse(size: CGSize, color: UIColor, lineWidth: CGFloat = 1) -> UIImage?`
Generate a stroked-ellipse image.
```swift
UIImage.outlinedEllipse(size: CGSize(width: 24, height: 24), color: .label)
```
### `func resizeImage(newWidth: CGFloat) -> UIImage`
Scale to `newWidth`, keeping the aspect ratio.
### `func createThumbnail(_ maxPixelSize: UInt) -> UIImage`
Fast down-sampled thumbnail via `CGImageSource`.
```swift
let thumb = fullImage.createThumbnail(200)
```
### `func maskWithAlphaImage(maskImage: UIImage) -> UIImage`
Use another image's alpha as a mask.
### `func isAnimated() -> Bool`
Whether the image has more than one frame.
### `init?(base64String: String, scale: CGFloat = 1)`
Decode a Base64 string to an image.
### `init(view: UIView)` — *`@MainActor`*
Rasterise a view into an image.
```swift
let snapshot = UIImage(view: cardView)
```
</details>
<details>
<summary><b>UIColor</b> — hex, components</summary>
### `convenience init(hex: String)`
Parse `#RGB`, `#RGBA`, `#RRGGBB`, or `#RRGGBBAA` (with or without `#`).
```swift
view.backgroundColor = UIColor(hex: "#1E88E5")
UIColor(hex: "FF0000CC") // red at 80% alpha
```
### `var hexString: String?`
`#RRGGBB` (or `#RRGGBBAA` when alpha < 1).
### `var redValue` / `var greenValue` / `var blueValue` / `var alphaValue`
Individual channel values (`CGFloat`, via `CIColor`).
```swift
UIColor.systemBlue.redValue // 0.01.0
```
</details>
<details>
<summary><b>UIScrollView</b> — snapshot, visible rect, paged scrolling</summary>
### `var snapshot: UIImage?`
Image of the **entire** content size (not just the visible part) — works on
`UITableView` / `UICollectionView` too.
### `var visibleRect: CGRect`
The currently visible content region.
### `var offsetInPage: CGFloat`
Fractional position within the current page height (`0.0``1.0`).
### `func scrollUp(animated:)` / `scrollDown(animated:)` / `scrollLeft(animated:)` / `scrollRight(animated:)`
Move one page (respects `isPagingEnabled`). `animated` defaults to `true`.
```swift
nextButton.onTap = { scrollView.scrollRight() }
```
### `enum orientation`
`horizontal` / `vertical` — helper enum used by scroll utilities.
</details>
<details>
<summary><b>UITapGestureRecognizer</b></summary>
### `func didTapAttributedTextInLabel(label: UILabel, textToTouch: String) -> Bool`
Whether the tap landed on a given substring of a label's attributed text — for
making part of a label tappable.
```swift
@objc func handleTap(_ g: UITapGestureRecognizer) {
if g.didTapAttributedTextInLabel(label: termsLabel, textToTouch: "Terms of Use") {
openTerms()
}
}
```
</details>
## Navigation & Controllers
<details>
<summary><b>UINavigationController</b> — completion-handler push/pop, transparent bar</summary>
### `func pushViewController(_:animated: Bool = true, completion: (() -> Void)? = nil)`
### `func popViewController(animated: Bool = true, _ completion: (() -> Void)? = nil)`
### `func popToViewController(_:animated: Bool = true, _ completion: (() -> Void)? = nil)`
The standard transitions with a completion block (wrapped in a `CATransaction`).
```swift
navigationController?.pushViewController(detail) {
print("detail is on screen")
}
```
### `func pushViewController(_:hidesBottomBar: Bool = false, animated: Bool = true)` — *not tvOS*
Push while setting `hidesBottomBarWhenPushed`.
### `func makeTransparent(withTint tint: UIColor = .white)`
Clear background + shadow, translucent, tinted bar/title.
```swift
navigationController?.makeTransparent(withTint: .label)
```
</details>
<details>
<summary><b>UIViewController</b> — state, instantiation, dismissal, toasts, keyboard, observers</summary>
### `var isVisible: Bool` / `var isLoaded: Bool`
View is loaded **and** in a window. (`isLoaded` is an alias.)
### `var isModal: Bool`
Whether the controller is presented modally (vs. pushed).
### `static var className: String` / `static var identifier: String` / `static var segueID: String`
`"MyVC"`, `"idMyVC"`, `"idSegueMyVC"`.
### `static func instantiate<T>(storyBoard: String, identifier: String? = nil, bundle: Bundle? = …) -> T`
Load a controller from a storyboard by (default) `T.identifier`.
```swift
let vc: ProfileVC = ProfileVC.instantiate(storyBoard: "Main")
```
### `static func instatiate<T>(nibName: String, bundle: Bundle? = nil) -> T` *(sic — "instatiate")*
Load from a nib and force an initial layout pass.
### `func present(viewControllerToPresent:completion: @escaping () -> Void)`
`present(_:animated:)` with a completion block.
### `func closeController(jumpToController: UIViewController? = nil, completion: @escaping () -> Void)`
Dismiss if modal, else pop (to `jumpToController` if given), then call `completion`.
```swift
closeController { self.refreshList() }
```
### `func show(toastWith message:, font: = system 12, toastPosition: ToastPosition, backgroundColor: = .black, textColor: = .white, duration: = 3)`
Show a temporary rounded toast label. `ToastPosition` is `.top` / `.down`
(notch-aware at the top).
```swift
show(toastWith: "Saved", toastPosition: .down)
```
### `func addNotificationObserver(name:selector:)` / `func removeNotificationObserver(name:)` / `func removeNotificationsObserver()`
`NotificationCenter` registration shortcuts (last one removes all).
### `@objc func dismissSystemKeyboard(_ sender: UITapGestureRecognizer)`
Ready-made selector for a tap-to-dismiss-keyboard gesture.
```swift
view.addGestureRecognizer(UITapGestureRecognizer(target: self,
action: #selector(dismissSystemKeyboard(_:))))
```
</details>
<details>
<summary><b>UITabBarController</b> — badges, animated tab switching</summary>
### `func setBadges(badgeValues: [Int], font: UIFont = Helvetica-Light 11)`
Set numeric badges for every tab at once (`0` = no badge). Custom-drawn
(`CustomTabBadge` label), so they position above each tab item.
```swift
tabBarController?.setBadges(badgeValues: [0, 3, 0, 12])
```
### `func addBadge(index:value:color:font:)`
Add a single custom badge.
### `func setSelectedView(atIndex:withAnimation: Bool = false, completion:)`
Select a tab (pops that tab's nav stack to root first).
### `func setSelectedView(withNoPop atIndex:withAnimation: Bool = false, completion:)`
Same, without popping to root.
### `func animateToTab(toIndex: Int)`
Slide-transition between tabs.
### `func changeViewControllerToItem(withViewController:Item:)`
Replace a tab's root controller then switch to that tab.
```swift
tabBarController?.changeViewControllerToItem(withViewController: NewHome(), Item: 0)
```
### `class CustomTabBadge: UILabel`
The badge label type used above (`init(font:)`).
</details>
<details>
<summary><b>UIApplication</b> — environment, app info, open URL</summary>
### `enum Environment` + `static var inferredEnvironment: Environment`
`.debug` / `.testFlight` / `.appStore`, inferred from build config, simulator, and
the provisioning/receipt files.
```swift
if UIApplication.inferredEnvironment == .appStore { enableAnalytics() }
```
### `static var displayName: String?` / `static var buildNumber: String?` / `static var version: String?`
Bundle info values. (`LCEssentials.appVersion` etc. forward to these.)
### `static func openURL(urlStr: String)`
Open a URL string if it can be opened.
```swift
UIApplication.openURL(urlStr: "https://loverde.com.br")
```
</details>
<details>
<summary><b>UIResponder</b></summary>
### `var getParentViewController: UIViewController?`
Walk the responder chain to the owning view controller.
```swift
someView.getParentViewController?.present(alert, animated: true)
```
</details>
<details>
<summary><b>UIDevice</b> — notch metrics, model name</summary>
### `static var topNotch: CGFloat` / `static var bottomNotch: CGFloat` / `static var hasNotch: Bool`
Safe-area top/bottom insets and whether the device has a notch / Dynamic Island.
```swift
let headerY: CGFloat = UIDevice.hasNotch ? 44 : 20
```
### `var modelName: String`
Marketing name from the hardware identifier (`"iPhone 15 Pro"`, `"iPad Air (5th generation)"`, …); falls back to the raw identifier for unknown devices. Reads `SIMULATOR_MODEL_IDENTIFIER` on the simulator.
```swift
UIDevice.current.modelName // "iPhone 16 Pro"
```
</details>
## Collections & Tables
<details>
<summary><b>UITableView</b> — typed dequeue, safe indexing, cell animations</summary>
### `func dequeueReusableCell<T: UITableViewCell>(withClass name: T.Type) -> T` / `…(withClass:for indexPath:) -> T`
Dequeue a cell by its class name as the identifier. **Traps** if the cell isn't registered.
```swift
let cell = tableView.dequeueReusableCell(withClass: OrderCell.self, for: indexPath)
```
### `func dequeueReusableHeaderFooterView<T: UITableViewHeaderFooterView>(withClass name: T.Type) -> T`
Same, for section header/footer views.
### `func dequeueCell<T: UITableViewCell>(indexPath: IndexPath) -> T`
Dequeue using `T.identifier` (`"id" + class name`).
### `func reloadData(_ completion: @escaping () -> Void)`
`reloadData()` with a callback for when layout settles.
### `func isValidIndexPath(_:) -> Bool`
Bounds check against the current section/row counts.
### `func safeScrollToRow(at:at scrollPosition:animated:)`
`scrollToRow` that silently no-ops for an out-of-range index path.
### `func makeMoveUpWithFadeAnimation(rowHeight:duration:delayFactor:) -> UITableViewCellAnimation`
Build a staggered slide-up + fade-in cell animation closure.
```swift
let animator = UITableViewAnimator(
animation: tableView.makeMoveUpWithFadeAnimation(rowHeight: 64, duration: 0.35, delayFactor: 0.03)
)
func tableView(_ t: UITableView, willDisplay cell: UITableViewCell, forRowAt ip: IndexPath) {
animator.animate(cell: cell, at: ip, in: t)
}
```
### `typealias UITableViewCellAnimation = (UITableViewCell, IndexPath, UITableView) -> Void`
### `class UITableViewAnimator`
`init(animation:)` + `animate(cell:at:in:)` — runs a cell animation closure.
### `UITableViewCell.identifier` / `UITableViewCell.prepareDisclosureIndicator()`
`"id" + class name`; and re-tint the disclosure chevron to a template image so it
picks up `tintColor`.
</details>
<details>
<summary><b>UICollectionView</b> — setup, counts, safe indexing, carousel layout</summary>
### `static var identifier: String`
`"id" + class name`.
### `func setupCollectionView(flowLayout: = UICollectionViewFlowLayout(), spacings: = 0, direction: = .horizontal, edgesInset: = .zero, allowMulpleSelection: Bool = false, automaticSize: CGSize? = nil)`
Configure layout spacing/direction/insets, multi-selection, and self-sizing in one call.
```swift
collectionView.setupCollectionView(spacings: 8, direction: .vertical,
edgesInset: .init(top: 12, left: 16, bottom: 12, right: 16))
```
### `func reloadData(_ completion: @escaping () -> Void)`
Reload with a completion callback.
### `func numberOfItems() -> Int`
Total items across all sections.
### `var lastSection: Int` / `var indexPathForLastItem: IndexPath?` / `func indexPathForLastItem(inSection:) -> IndexPath?`
Last section index; index path of the last item overall or in a section.
### `func isValidIndexPath(_:) -> Bool` / `func safeScrollToItem(at:at scrollPosition:animated:)`
Bounds check; scroll that no-ops on an invalid index path.
### `enum CollectionViewFlowLayoutSpacingMode`
`.fixed(spacing:)` / `.overlap(visibleOffset:)` — spacing strategy for the carousel layout below.
### `open class CollectionViewFlowLayout: UICollectionViewFlowLayout`
A centred, paginated "cover-flow" style layout: the centred item is full size,
side items scale and fade. Tunables: `sideItemScale` (0.6), `sideItemAlpha`
(0.6), `sideItemShift` (0), `spacingMode` (`.fixed(40)`).
```swift
let layout = CollectionViewFlowLayout()
layout.itemSize = CGSize(width: 240, height: 320)
layout.sideItemScale = 0.7
layout.spacingMode = .overlap(visibleOffset: 30)
collectionView.collectionViewLayout = layout
```
</details>
## Media & Components
<details>
<summary><b>LCSnackBarView</b> — in-app notification banner</summary>
A chainable banner shown from the top or bottom of a controller. Configure with
`configure(...)` calls, then `present()`.
### `init(style: LCSnackBarViewType = .default, orientation: LCSnackBarOrientation = .top, delegate: LCSnackBarViewDelegate? = nil)`
### Enums
- `LCSnackBarViewType``.default` (rectangular) / `.rounded`
- `LCSnackBarOrientation``.top` / `.bottom`
- `LCSnackBarTimer: CGFloat``.infinity` (0, manual dismiss), `.minimum` (2s), `.medium` (5s), `.maximum` (10s)
### Configuration (each returns `Self`)
| Method | Sets |
|---|---|
| `configure(text: String)` | the message |
| `configure(textColor: UIColor)` | text colour |
| `configure(textFont: UIFont, alignment: NSTextAlignment = .center)` | font + alignment |
| `configure(backgroundColor: UIColor)` | banner background |
| `configure(exibition timer: LCSnackBarTimer)` | how long it stays |
| `configure(imageIconBefore icon: UIImageView, withTintColor: UIColor? = nil)` | leading icon |
### `func present(completion: (() -> Void)? = nil)`
Show on the top-most view controller.
### `weak var delegate: LCSnackBarViewDelegate?`
`snackbar(didStartExibition:)`, `snackbar(didTouchOn:)`, `snackbar(didEndExibition:)` — all optional.
### Full example
```swift
LCSnackBarView(style: .rounded, orientation: .bottom)
.configure(text: "Profile saved")
.configure(backgroundColor: .systemGreen)
.configure(exibition: .minimum)
.present()
```
</details>
<details>
<summary><b>ImagePickerController</b> — camera / photo-library picker with permissions</summary>
Wraps `UIImagePickerController`, handling camera & photo-library authorization
(including the "go to Settings" path) and presenting a source-choice alert.
### `init()`
### `weak var delegate: ImagePickerControllerDelegate?`
### `var isEditable: Bool` — allow in-picker cropping (default `false`)
### `func openImagePicker()`
Check permissions, then present the camera/library choice.
### `protocol ImagePickerControllerDelegate: AnyObject`
`imagePicker(didSelect image: UIImage?)` — the picked (or edited) image, or `nil` on cancel/failure.
```swift
let picker = ImagePickerController()
picker.delegate = self
picker.isEditable = true
present(picker, animated: false) { picker.openImagePicker() }
// ImagePickerControllerDelegate
func imagePicker(didSelect image: UIImage?) {
avatarView.image = image
}
```
</details>
<details>
<summary><b>ImageZoomController</b> — full-screen pinch-zoom / pan viewer</summary>
### `init(_ withImage: UIImage)`
### `var minimumZoomScale: CGFloat` (default `1.0`) / `var maximumZoomScale: CGFloat` (default `6.0`)
### `var addGestureToDismiss: Bool` (default `true`) — drag down to close
### `weak var delegate: ImageZoomControllerDelegate?`
### `func present(completion: (() -> Void)? = nil)` / `func dismiss(completion: (() -> Void)? = nil)`
Present from / dismiss to the top-most controller.
### `@objc protocol ImageZoomControllerDelegate`
`imageZoomController(controller:didZoom:)`, `imageZoomController(controller:didClose:)` — both optional.
```swift
let viewer = ImageZoomController(photo)
viewer.maximumZoomScale = 4
viewer.present()
```
</details>
<details>
<summary><b>GifHelper</b> — animated GIF loading</summary>
### `UIImageView.loadGif(name: String)` / `UIImageView.loadGif(asset: String)` *(iOS 9+)*
Decode a bundled `.gif` (or an asset-catalog data set) off the main thread and
assign the resulting animated `UIImage`.
```swift
bannerView.loadGif(name: "loading") // loading.gif in the bundle
bannerView.loadGif(asset: "confetti") // NSDataAsset "confetti"
```
### `UIImage.gif(data: Data) -> UIImage?` / `gif(url: String) -> UIImage?` / `gif(name: String) -> UIImage?` / `gif(asset: String) -> UIImage?`
Build an animated `UIImage` from GIF bytes / a URL string / a bundled file / an
asset-catalog entry. Frame delays are honoured (via the GCD of per-frame delays).
```swift
let spinner = UIImage.gif(name: "spinner")
imageView.image = spinner
```
</details>

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,4 @@ 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.
Autor: Daniel Arantes Loverde
THE SOFTWARE.

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
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(
name: "LCEssentials",
platforms: [
.iOS(.v15),
.macOS(.v10_15),
.tvOS(.v13),
.watchOS(.v8)
],
products: [
.library(
name: "LCEssentials",
targets: ["LCEssentials"]),
],
dependencies: packageDependencies,
targets: [
.target(
name: "LCEssentials",
dependencies: targetDependencies),
.testTarget(
name: "LCEssentialsTests",
dependencies: ["LCEssentials"]),
name: "LCEssentials"),
]
)

View File

@@ -1,43 +1,41 @@
![](loverde_company_logo_full.png)
# Loverde Co. Essentials
![](loverde_company_logo_full.png)
Loverde Co. Essentials Swift Scripts
----
Essential Swift scripts, extensions, SwiftUI components, and UIKit-era helpers,
shared across Loverde Co. projects. Evolves with Swift, improves every release.
This is a repository of essential scripts written in Swift for Loverde Co. used to save time on re-writing and keeping it on all other projects. So this Cocoapods will evolve with Swift and will improve with every release!
## Requirements
- iOS 15.* or newer, Swift 5.* or newer.
- iOS 15 or newer · Swift 5 or newer
## Features
- [x] Many usefull scripts extensions
## Installation — Swift Package Manager
```swift
Installation
----
#### Swift Package Manager (SPM)
``` swift
dependencies: [
.package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials", .upToNextMajor(from: "2.0.0"))
.package(url: "http://git.loverde.com.br:3000/git/LCEssentials.git", .upToNextMajor(from: "1.0.0"))
]
```
Or add it in Xcode via **File ▸ Add Package Dependencies…** with the URL:
You can also add it via XCode SPM editor with URL:
```
https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials
``` swift
http://git.loverde.com.br:3000/git/LCEssentials.git
```
```swift
import LCEssentials
```
## Usage example
[Read full documentation](LCEssentials_DOCUMENTATION.md)
## Documentation
Author:
----
| Guide | Covers |
| --- | --- |
| **[API.md](Documentation/API.md)** | `API` networking — typed requests, multipart uploads, client certificates, error handling, and why it beats a hand-rolled `URLSession` |
| **[Extensions.md](Documentation/Extensions.md)** | Foundation / value-type / string / collection / numeric / date / crypto extensions and the `LCEssentials` namespace |
| **[SwiftUI.md](Documentation/SwiftUI.md)** | SwiftUI components (`LCENavigationView`) and `View` helpers |
| **[UIKit.md](Documentation/UIKit.md)** | Programmatic layout & constraints, view/control extensions, navigation, tables, and drop-in components (`LCSnackBarView`, image picker/zoom, GIF loading) |
Any question or doubts, please send thru email
---
Daniel Arantes Loverde - <daniel@loverde.com.br>
Daniel Arantes Loverde — <daniel@loverde.com.br>
[<img src="https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" width="28" alt="GitHub">](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)

View File

@@ -5,10 +5,8 @@
// Created by iOSDevCenters on 11/12/15.
// Copyright © 2016 iOSDevCenters. All rights reserved.
//
#if canImport(UIKit)
import UIKit
#endif
#if canImport(UIKit)
#if os(iOS) || os(macOS)
import ImageIO
//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
#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.
public enum httpMethod: String {
/// The POST method.
@@ -37,271 +45,250 @@ public enum httpMethod: String {
case get = "GET"
/// The PUT method.
case put = "PUT"
/// The PATCH method.
case patch = "PATCH"
/// The DELETE method.
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
/// network requests with various configurations, handling different HTTP
/// methods, typed request bodies, multipart uploads, and certificate-based
/// authentication.
/// This struct provides a convenient way to perform network requests with various configurations,
/// including handling different HTTP methods, parameter encoding, and certificate-based authentication.
@available(iOS 13.0.0, *)
public actor API {
/// Client-certificate data (`.p12`) for mutual-TLS, if configured.
private var certData: Data?
/// Password for `certData`, if any.
private var certPassword: String?
/// Session configuration used to build `URLSession`s. `nil` in production
/// (the shared session / a dedicated cert session is used); injected by
/// tests to register a stub `URLProtocol`.
private let sessionConfiguration: URLSessionConfiguration?
@MainActor
public struct API {
private static var certData: Data?
private static var certPassword: String?
/// The default error used when an unexpected issue occurs during a request.
nonisolated static var defaultError: NSError {
NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE,
description: LCEssentials.DEFAULT_ERROR_MSG,
reasonForError: LCEssentials.DEFAULT_ERROR_MSG)
}
static let defaultError = NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE,
description: LCEssentials.DEFAULT_ERROR_MSG,
reasonForError: LCEssentials.DEFAULT_ERROR_MSG)
/// 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.
///
/// By default, it includes "Accept", "Content-Type", and "Accept-Encoding" headers.
nonisolated let defaultHeaders: [String: String] = ["Accept": "application/json",
"Content-Type": "application/json; charset=UTF-8",
"Accept-Encoding": "gzip"]
/// The shared singleton instance of `API`.
public static let shared = API()
private init() {
self.sessionConfiguration = nil
}
/// Test-only. Builds an isolated instance whose `URLSession`s use
/// `configuration` register a stub `URLProtocol` on it. Never call from
/// production code; `shared` state is untouched.
init(testConfiguration configuration: URLSessionConfiguration) {
self.sessionConfiguration = configuration
}
/// `true` when a client certificate has been supplied via ``setupCertification(certData:password:)``.
var hasClientCertificateConfigured: Bool { certData != nil }
/// Overrides ``persistConnectionDelay``.
public func setPersistConnectionDelay(_ seconds: Double) {
persistConnectionDelay = seconds
}
var defaultHeaders: [String: String] = ["Accept": "application/json",
"Content-Type": "application/json; charset=UTF-8",
"Accept-Encoding": "gzip"]
/// 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.
/// The shared singleton instance of the `API` struct.
public static let shared = API()
private init(){}
/// Performs an asynchronous network request and decodes the response into a `Codable` type.
///
/// - Parameters:
/// - url: The URL string. `{name}` placeholders are filled from `pathParams`.
/// - method: The HTTP method (`.get`, `.post`, `.put`, `.delete`, `.patch`).
/// - body: Optional typed request body (``JSONBody``, ``FormURLEncodedBody``,
/// ``RawBody``, or any ``HTTPBody``). Its `Content-Type` and
/// `Content-Length` are set automatically.
/// - pathParams: Values substituted into `{name}` placeholders in `url`.
/// - headers: Custom headers, merged over the defaults (custom wins).
/// - debug: Print request/response debug logs. Defaults to `true`.
/// - timeoutInterval: Request timeout in seconds. Defaults to `30`.
/// - networkServiceType: `URLRequest.NetworkServiceType`. Defaults to `.default`.
/// - persistConnection: Retry (bounded by ``maxPersistRetries``) on a 4xx.
/// - Returns: `T` decoded from the response body, or the raw string when `T == String`.
/// - Throws: `URLError` for transport failures, an `NSError` carrying the HTTP
/// status for non-2xx responses, or `DecodingError` on a malformed body.
public func request<T: Decodable & Sendable>(
url: String,
method: httpMethod,
body: (any HTTPBody)? = nil,
pathParams: [String: String] = [:],
headers: [String: String] = [:],
debug: Bool = true,
timeoutInterval: TimeInterval = 30,
networkServiceType: URLRequest.NetworkServiceType = .default,
persistConnection: Bool = false
) async throws -> T {
let resolvedURL = try makeURL(url, pathParams: pathParams)
var urlRequest = buildRequest(url: resolvedURL, method: method, headers: headers,
timeout: timeoutInterval, serviceType: networkServiceType)
if let body {
try Self.attach(body: body, to: &urlRequest)
/// - url: The URL string for the request.
/// - params: Optional parameters for the request. Can be `[String: Any]` for JSON/form-data, or `Data` for raw body.
/// - method: The HTTP method to use for the request (`.get`, `.post`, `.put`, `.delete`).
/// - headers: Optional custom HTTP headers to be added to the request. These override default headers if there are conflicts.
/// - jsonEncoding: A boolean indicating whether parameters should be JSON encoded. Defaults to `true`.
/// - debug: A boolean indicating whether to print debug logs for the request and response. Defaults to `true`.
/// - timeoutInterval: The timeout interval in seconds for the request. Defaults to `30`.
/// - networkServiceType: The `URLRequest.NetworkServiceType` for the request. Defaults to `.default`.
/// - persistConnection: A boolean indicating whether to persist the connection on certain error codes (e.g., 4xx). Defaults to `false`.
/// - Returns: An instance of the `T` type, decoded from the response data.
/// - Throws: An `Error` if the request fails, including `URLError` for network issues or `DecodingError` for JSON decoding failures.
public func request<T: Codable>(url: String,
params: Any? = nil,
method: httpMethod,
headers: [String: String] = [:],
jsonEncoding: Bool = true,
debug: Bool = true,
timeoutInterval: TimeInterval = 30,
networkServiceType: URLRequest.NetworkServiceType = .default,
persistConnection: Bool = false) async throws -> T {
if let urlReq = URL(string: url.replaceURL(params as? [String: Any] ?? [:] )) {
var request = URLRequest(url: urlReq, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 30)
if method == .post || method == .put || method == .delete {
if let params = params as? [String: Any],
let pathFile = params["file"] as? String,
let fileURL = URL(string: pathFile) {
let boundary = UUID().uuidString
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)!)
}
// Add the file
let fileName = fileURL.lastPathComponent
let mimeType = mimeTypeForPath(path: fileName)
do {
let fileData = try Data(contentsOf: fileURL)
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
body.append(fileData)
body.append("\r\n".data(using: .utf8)!)
} catch {
printError(title: "Upload File", msg: error.localizedDescription)
}
// Finalize the request body
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length")
// Debug logs
printLog(title: "Boundary", msg: boundary)
if let bodyString = String(data: body, encoding: .utf8) {
printLog(title: "Body Content", msg: bodyString)
}
} else if jsonEncoding, let params = params as? [String: Any] {
let requestObject = try JSONSerialization.data(withJSONObject: params)
request.httpBody = requestObject
} else if let params = params as? [String: Any] {
var bodyComponents = URLComponents()
params.forEach({ (key, value) in
bodyComponents.queryItems?.append(URLQueryItem(name: key, value: value as? String))
})
request.httpBody = bodyComponents.query?.data(using: .utf8)
} else if let params = params as? Data {
request.httpBody = params
}
}
request.httpMethod = method.rawValue
request.timeoutInterval = timeoutInterval
request.networkServiceType = networkServiceType
// - Put Default Headers together with user defined params
if !headers.isEmpty {
// - Add it to request
headers.forEach { (key, value) in
request.addValue(value, forHTTPHeaderField: key)
}
}else{
defaultHeaders.forEach { (key, value) in
request.addValue(value, forHTTPHeaderField: key)
}
}
if debug {
API.requestLOG(method: method, request: request)
}
let session = URLSession(
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
}
// - Normal decoding
do {
return try JSONDecoder.decode(data: data)
} catch {
printError(title: "JSONDecoder", msg: error.localizedDescription)
throw error
}
case 400..<500:
// - Debug LOG
if debug {
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
}
if persistConnection {
printError(title: "INTERNET CONNECTION ERROR", msg: "WILL PERSIST")
// Recursive call for persistence
let persist: T = try await self.request(
url: url,
params: params,
method: method,
headers: headers,
jsonEncoding: jsonEncoding,
debug: debug,
timeoutInterval: timeoutInterval,
networkServiceType: networkServiceType,
persistConnection: persistConnection
)
return persist
} else {
if debug {
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
}
let friendlyError = NSError.createErrorWith(code: code, description: error.localizedDescription, reasonForError: data.prettyJson ?? "")
throw friendlyError
}
default:
// - Debug LOG
if debug {
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
}
let friendlyError = NSError.createErrorWith(code: code, description: error.localizedDescription, reasonForError: data.prettyJson ?? "")
throw friendlyError
}
} catch {
throw error
}
}
return try await send(urlRequest, method: method, debug: debug,
persistConnection: persistConnection, retriesLeft: Self.maxPersistRetries)
throw API.defaultError
}
/// Sends a fully-built request, handles the response, and applies bounded
/// `persistConnection` retries.
private func send<T: Decodable & Sendable>(
_ urlRequest: URLRequest,
method: httpMethod,
debug: Bool,
persistConnection: Bool,
retriesLeft: Int
) async throws -> T {
if debug { API.requestLOG(method: method, request: urlRequest) }
let (session, mustInvalidate) = makeSession()
defer { if mustInvalidate { session.finishTasksAndInvalidate() } }
let (data, response) = try await session.data(for: urlRequest)
let code = (response as? HTTPURLResponse)?.statusCode ?? LCEssentials.DEFAULT_ERROR_CODE
switch Self.classify(code: code, data: data, method: method, request: urlRequest, debug: debug) {
case .success:
return try Self.decodeResponse(data)
case .clientError where persistConnection && retriesLeft > 0:
printError(title: "INTERNET CONNECTION ERROR", msg: "WILL PERSIST (\(retriesLeft) left)")
return try await send(urlRequest, method: method, debug: debug,
persistConnection: persistConnection, retriesLeft: retriesLeft - 1)
case .clientError, .otherError:
throw Self.friendlyError(code: code, data: data)
}
}
// MARK: - Request building
func makeURL(_ template: String, pathParams: [String: String]) throws -> URL {
guard let url = URL(string: template.replaceURL(pathParams)) else { throw API.defaultError }
return url
}
func buildRequest(url: URL,
method: httpMethod,
headers: [String: String],
timeout: TimeInterval,
serviceType: URLRequest.NetworkServiceType) -> URLRequest {
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout)
request.httpMethod = method.rawValue
request.timeoutInterval = timeout
request.networkServiceType = serviceType
defaultHeaders.merging(headers) { _, custom in custom }
.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
return request
}
/// Sets the body plus its `Content-Type` and `Content-Length`. The body's own
/// content type wins over any set through `headers`.
private static func attach(body: any HTTPBody, to request: inout URLRequest) throws {
let (data, contentType) = try body.encoded()
request.httpBody = data
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
request.setValue("\(data.count)", forHTTPHeaderField: "Content-Length")
}
// MARK: - Response handling
enum ResponseDisposition { case success, clientError, otherError }
static func classify(code: Int, data: Data, method: httpMethod,
request: URLRequest, debug: Bool) -> ResponseDisposition {
let error = (200..<300).contains(code) ? nil : URLError(URLError.Code(rawValue: code))
if debug {
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
}
switch code {
case 200..<300: return .success
case 400..<500: return .clientError
default: return .otherError
}
}
static func decodeResponse<T: Decodable & Sendable>(_ data: Data) throws -> T {
if T.self == String.self, let string = String(data: data, encoding: .utf8) as? T {
return string
}
do {
return try JSONDecoder.decode(data: data)
} catch {
printError(title: "JSONDecoder", msg: error.localizedDescription)
throw error
}
}
static func friendlyError(code: Int, data: Data) -> NSError {
let urlError = URLError(URLError.Code(rawValue: code))
return NSError.createErrorWith(code: code,
description: urlError.localizedDescription,
reasonForError: data.prettyJson ?? "")
}
/// Sets up client certificate data and an optional password for authentication.
///
/// - Parameters:
/// - 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.
public func setupCertification(certData: Data, password: String = "") {
self.certData = certData
self.certPassword = password
public func setupCertificationRequest(certData: Data, password: String = "") {
API.certData = certData
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)
/// A custom `URLSessionDelegate` handler for managing URL session challenges,
/// 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, *)
private final class URLSessionDelegateHandler: NSObject, URLSessionDelegate, @unchecked Sendable {
private let certData: Data?
private let certPass: String?
@MainActor
private class URLSessionDelegateHandler: NSObject, URLSessionDelegate {
private var certData: Data?
private var certPass: String?
/// Initializes a new `URLSessionDelegateHandler` instance.
///
/// - Parameters:
/// - certData: Optional `Data` for the client certificate.
/// - password: Optional password for the client certificate.
init(certData: Data? = nil, password: String? = nil) {
super.init()
self.certData = certData
self.certPass = password
super.init()
}
/// Handles URL session authentication challenges.
///
/// This method is responsible for providing client certificates for client certificate
@@ -345,30 +332,17 @@ private final class URLSessionDelegateHandler: NSObject, URLSessionDelegate, @un
// Import the .p12 certificate to get the identity
let status = SecPKCS12Import(certData as CFData, options as CFDictionary, &items)
guard status == errSecSuccess,
let items,
CFArrayGetCount(items) > 0,
let itemPtr = CFArrayGetValueAtIndex(items, 0) else {
printError(title: "Certificate", msg: "Failed to import client identity from .p12 (status: \(status))")
if status == errSecSuccess,
let item = (items as? [[String: Any]])?.first,
let identityRef = item[kSecImportItemIdentity as String] as CFTypeRef?,
CFGetTypeID(identityRef) == SecIdentityGetTypeID() {
return (identityRef as! SecIdentity)
} else {
print("Erro ao importar a identidade do certificado: \(status)")
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.
@@ -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

View File

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

View File

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

View File

@@ -21,9 +21,7 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
// MARK: - Methods (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 {
@MainActor
@@ -172,5 +169,3 @@ extension Array where Element == NSLayoutConstraint {
}
}
}
#endif

View File

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

View File

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

View File

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

View File

@@ -21,9 +21,7 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
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 {
let data = imagem.pngData()

View File

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

View File

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

View File

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

View File

@@ -21,9 +21,7 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
#if canImport(CommonCrypto)
import CommonCrypto
#endif
@@ -50,14 +48,10 @@ public extension String {
}
var convertToHTML: NSAttributedString? {
#if canImport(UIKit)
return convertHtmlToAttributedStringWithCSS(font: nil,
csscolor: "",
lineheight: 0,
csstextalign: "")
#else
return convertHtmlToNSAttributedString
#endif
}
/// Check if string is a valid URL.
@@ -652,7 +646,7 @@ public extension String {
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 {
let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font], context: nil)
@@ -763,7 +757,7 @@ public extension String {
}
return self
}
#if canImport(UIKit)
/// Converte String para HTML com CSS.
///
/// - Parameters:
@@ -807,8 +801,7 @@ public extension String {
return nil
}
}
#endif
/// Float value from string (if applicable).
///
/// - Parameter locale: Locale (default is Locale.current)

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,11 +21,9 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
#if canImport(UIKit)
#if os(iOS) || os(macOS)
public extension UIDevice {
static var topNotch: CGFloat {
@@ -66,104 +64,59 @@ public extension UIDevice {
#if os(iOS)
switch identifier {
case "iPod5,1": return "iPod touch (5th generation)"
case "iPod7,1": return "iPod touch (6th generation)"
case "iPod9,1": return "iPod touch (7th generation)"
case "iPhone3,1", "iPhone3,2", "iPhone3,3": return "iPhone 4"
case "iPhone4,1": return "iPhone 4s"
case "iPhone5,1", "iPhone5,2": return "iPhone 5"
case "iPhone5,3", "iPhone5,4": return "iPhone 5c"
case "iPhone6,1", "iPhone6,2": return "iPhone 5s"
case "iPhone7,2": return "iPhone 6"
case "iPhone7,1": return "iPhone 6 Plus"
case "iPhone8,1": return "iPhone 6s"
case "iPhone8,2": return "iPhone 6s Plus"
case "iPhone9,1", "iPhone9,3": return "iPhone 7"
case "iPhone9,2", "iPhone9,4": return "iPhone 7 Plus"
case "iPhone10,1", "iPhone10,4": return "iPhone 8"
case "iPhone10,2", "iPhone10,5": return "iPhone 8 Plus"
case "iPhone10,3", "iPhone10,6": return "iPhone X"
case "iPhone11,2": return "iPhone XS"
case "iPhone11,4", "iPhone11,6": return "iPhone XS Max"
case "iPhone11,8": return "iPhone XR"
case "iPhone12,1": return "iPhone 11"
case "iPhone12,3": return "iPhone 11 Pro"
case "iPhone12,5": return "iPhone 11 Pro Max"
case "iPhone13,1": return "iPhone 12 mini"
case "iPhone13,2": return "iPhone 12"
case "iPhone13,3": return "iPhone 12 Pro"
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 "iPad3,1", "iPad3,2", "iPad3,3": return "iPad (3rd generation)"
case "iPad3,4", "iPad3,5", "iPad3,6": return "iPad (4th generation)"
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 "iPad5,3", "iPad5,4": return "iPad Air 2"
case "iPad11,3", "iPad11,4": return "iPad Air (3rd generation)"
case "iPad13,1", "iPad13,2": return "iPad Air (4th generation)"
case "iPad13,16", "iPad13,17": return "iPad Air (5th generation)"
case "iPad14,8", "iPad14,9": return "iPad Air (11-inch) (M2)"
case "iPad14,10", "iPad14,11": return "iPad Air (13-inch) (M2)"
case "iPad15,3", "iPad15,4": return "iPad Air (11-inch) (M3)"
case "iPad15,5", "iPad15,6": return "iPad Air (13-inch) (M3)"
case "iPad2,5", "iPad2,6", "iPad2,7": return "iPad mini"
case "iPad4,4", "iPad4,5", "iPad4,6": return "iPad mini 2"
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 "iPad7,3", "iPad7,4": return "iPad Pro (10.5-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 "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 "AppleTV6,2": return "Apple TV 4K"
case "AudioAccessory1,1": return "HomePod"
case "AudioAccessory5,1": return "HomePod mini"
default: return identifier
case "iPod5,1": return "iPod touch (5th generation)"
case "iPod7,1": return "iPod touch (6th generation)"
case "iPod9,1": return "iPod touch (7th generation)"
case "iPhone3,1", "iPhone3,2", "iPhone3,3": return "iPhone 4"
case "iPhone4,1": return "iPhone 4s"
case "iPhone5,1", "iPhone5,2": return "iPhone 5"
case "iPhone5,3", "iPhone5,4": return "iPhone 5c"
case "iPhone6,1", "iPhone6,2": return "iPhone 5s"
case "iPhone7,2": return "iPhone 6"
case "iPhone7,1": return "iPhone 6 Plus"
case "iPhone8,1": return "iPhone 6s"
case "iPhone8,2": return "iPhone 6s Plus"
case "iPhone9,1", "iPhone9,3": return "iPhone 7"
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,2", "iPhone10,5": return "iPhone 8 Plus"
case "iPhone10,3", "iPhone10,6": return "iPhone X"
case "iPhone11,2": return "iPhone XS"
case "iPhone11,4", "iPhone11,6": return "iPhone XS Max"
case "iPhone11,8": return "iPhone XR"
case "iPhone12,1": return "iPhone 11"
case "iPhone12,3": return "iPhone 11 Pro"
case "iPhone12,5": return "iPhone 11 Pro Max"
case "iPhone13,1": return "iPhone 12 mini"
case "iPhone13,2": return "iPhone 12"
case "iPhone13,3": return "iPhone 12 Pro"
case "iPhone13,4": return "iPhone 12 Pro Max"
case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4":return "iPad 2"
case "iPad3,1", "iPad3,2", "iPad3,3": return "iPad 3"
case "iPad3,4", "iPad3,5", "iPad3,6": return "iPad 4"
case "iPad4,1", "iPad4,2", "iPad4,3": return "iPad Air"
case "iPad5,3", "iPad5,4": return "iPad Air 2"
case "iPad6,11", "iPad6,12": return "iPad 5"
case "iPad7,5", "iPad7,6": return "iPad 6"
case "iPad7,11", "iPad7,12": return "iPad 7"
case "iPad11,4", "iPad11,5": return "iPad Air (3rd generation)"
case "iPad2,5", "iPad2,6", "iPad2,7": return "iPad Mini"
case "iPad4,4", "iPad4,5", "iPad4,6": return "iPad Mini 2"
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 5"
case "iPad6,3", "iPad6,4": return "iPad Pro (9.7-inch)"
case "iPad6,7", "iPad6,8": return "iPad Pro (12.9-inch)"
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 "AppleTV5,3": return "Apple TV"
case "AppleTV6,2": return "Apple TV 4K"
case "AudioAccessory1,1": return "HomePod"
case "i386", "x86_64": return "Simulator \(identifier)"
default: return identifier
}
#endif
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,11 +19,9 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(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.
@objc public protocol ImageZoomControllerDelegate {
/// Called when the image in the controller is zoomed.

View File

@@ -20,8 +20,6 @@
// THE SOFTWARE.
// MARK: - Framework headers
#if canImport(UIKit)
import Foundation
import UIKit
// MARK: - Protocols
@@ -233,36 +231,53 @@ public extension LCSnackBarView {
/// Handles the `keyboardWillShowNotification` to adjust the snackbar's position.
/// - Parameter notification: The `Notification` object containing keyboard information.
@MainActor
@objc private func keyboardWillShow(_ notification: Notification?) {
guard let info = notification?.userInfo else { return }
systemKeyboardVisible = true
let animationCurveRaw = (info[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue
?? UInt(UIView.AnimationCurve.easeOut.rawValue)
let animationDuration = (info[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue
?? 0.25
let keyboardFrame = (info[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue
?? .zero
let height = keyboardFrame.height
let animationCurve = UIView.AnimationOptions(rawValue: animationCurveRaw << 16)
UIView.animate(
withDuration: animationDuration,
delay: 0,
options: animationCurve
) { [weak self] in
self?.frame.origin.y += height
@objc private func keyboardWillShow(_ notification: Notification?) -> Void {
if let info = notification?.userInfo {
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
// Getting keyboard animation.
if let curve = info[curveUserInfoKey] as? UIView.AnimationOptions {
animationCurve = curve
}
// Getting keyboard animation duration
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,
options: animationCurve,
animations: {
self?.frame.origin.y += height
})
}
}
}
/// Handles the `keyboardWillHideNotification`.
/// - Parameter notification: The `Notification` object.
@MainActor
@objc private func keyboardWillHide(_ notification: Notification?) {
systemKeyboardVisible = false
// keyboard is hidded
@objc private func keyboardWillHide(_ notification: Notification?) -> Void {
DispatchQueue.main.async { [weak self] in
self?.systemKeyboardVisible = false
// keyboard is hidded
}
}
/// Updates the snackbar's style properties, such as width and corner radius, based on `_style`.
@@ -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,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(SwiftUI) && os(iOS)
#if canImport(SwiftUI)
import SwiftUI
/// `LCENavigationState` is an `ObservableObject` that manages the state for `LCENavigationView`.
@@ -47,8 +47,6 @@ class LCENavigationState: ObservableObject {
@Published var title: (any View) = Text("")
/// The subtitle view of the navigation bar.
@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.
@@ -81,7 +79,7 @@ public struct LCENavigationView<Content: View>: View {
/// The body of the `LCENavigationView`.
public var body: some View {
VStack(spacing: 0) {
VStack {
if !state.hideNavigationBar {
NavigationBarView
}
@@ -102,7 +100,7 @@ public struct LCENavigationView<Content: View>: View {
.font(.headline)
.padding()
.background {
state.navigationBarBackgroundColor.ignoresSafeArea(edges: .top)
Color.clear.ignoresSafeArea(edges: .top)
}
}
@@ -219,14 +217,6 @@ public struct LCENavigationView<Content: View>: View {
state.hideNavigationBar = hide
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.

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)
}
}
}