[api-upload-refactor] Add test target, relax decode constraints, add HTTPBody + MultipartForm
- Package: new LCEssentialsTests target (runs on iOS simulator) - JSONDecoder.decode: T: Codable -> T: Decodable & Sendable (4 overloads); remove try! in decode(fromURL:); honour encoding param in decode(_:using:) - Propagate & Sendable to Data.object, Dictionary.toObjetct, API.request return - LCEHTTPBody: HTTPBody protocol, JSONBody, FormURLEncodedBody, RawBody, factories - LCEMultipartForm: multipart builder, OutputStream-streamed serialize(), in-memory + on-disk file parts, ported mimeType(for:) - Tests: StubURLProtocol harness + 15 tests
This commit is contained in:
116
Sources/LCEssentials/Classes/LCEHTTPBody.swift
Normal file
116
Sources/LCEssentials/Classes/LCEHTTPBody.swift
Normal file
@@ -0,0 +1,116 @@
|
||||
//
|
||||
// Copyright (c) 2020 Loverde Co.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A request payload plus the `Content-Type` it implies.
|
||||
///
|
||||
/// Adopt this to teach `API.request` a new body encoding without changing `API`.
|
||||
public protocol HTTPBody: Sendable {
|
||||
/// - Returns: the encoded bytes and the `Content-Type` header value to send with them.
|
||||
func encoded() throws -> (data: Data, contentType: String)
|
||||
}
|
||||
|
||||
// MARK: - JSON
|
||||
|
||||
/// JSON-encodes an `Encodable` payload.
|
||||
public struct JSONBody<Payload: Encodable & Sendable>: HTTPBody {
|
||||
|
||||
public let payload: Payload
|
||||
private let encoder: @Sendable () -> JSONEncoder
|
||||
|
||||
/// - Parameters:
|
||||
/// - payload: the value to encode.
|
||||
/// - encoderProvider: builds the `JSONEncoder` to use. Defaults to a plain encoder.
|
||||
public init(_ payload: Payload,
|
||||
encoderProvider: @escaping @Sendable () -> JSONEncoder = { JSONEncoder() }) {
|
||||
self.payload = payload
|
||||
self.encoder = encoderProvider
|
||||
}
|
||||
|
||||
public func encoded() throws -> (data: Data, contentType: String) {
|
||||
(try encoder().encode(payload), "application/json; charset=UTF-8")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Form URL Encoded
|
||||
|
||||
/// `application/x-www-form-urlencoded` body. Every value is percent-escaped —
|
||||
/// nothing is silently dropped for containing reserved characters.
|
||||
public struct FormURLEncodedBody: HTTPBody {
|
||||
|
||||
public let fields: [String: String]
|
||||
|
||||
public init(_ fields: [String: String]) {
|
||||
self.fields = fields
|
||||
}
|
||||
|
||||
public func encoded() throws -> (data: Data, contentType: String) {
|
||||
var allowed = CharacterSet.alphanumerics
|
||||
allowed.insert(charactersIn: "-._~") // RFC 3986 unreserved
|
||||
|
||||
let pairs: [String] = fields.map { key, value in
|
||||
let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
|
||||
let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
|
||||
return "\(k)=\(v)"
|
||||
}
|
||||
let body = Data(pairs.joined(separator: "&").utf8)
|
||||
return (body, "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Raw
|
||||
|
||||
/// A pre-encoded body with an explicit content type.
|
||||
public struct RawBody: HTTPBody {
|
||||
|
||||
public let data: Data
|
||||
public let contentType: String
|
||||
|
||||
public init(data: Data, contentType: String) {
|
||||
self.data = data
|
||||
self.contentType = contentType
|
||||
}
|
||||
|
||||
public func encoded() throws -> (data: Data, contentType: String) {
|
||||
(data, contentType)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Factories
|
||||
|
||||
/// JSON body from any `Encodable & Sendable` value.
|
||||
public func jsonBody<T: Encodable & Sendable>(_ value: T) -> JSONBody<T> {
|
||||
JSONBody(value)
|
||||
}
|
||||
|
||||
/// Form-url-encoded body from a string dictionary.
|
||||
public func formBody(_ fields: [String: String]) -> FormURLEncodedBody {
|
||||
FormURLEncodedBody(fields)
|
||||
}
|
||||
|
||||
public extension HTTPBody where Self == FormURLEncodedBody {
|
||||
/// Call-site sugar for `request(body: .form([...]))`.
|
||||
static func form(_ fields: [String: String]) -> FormURLEncodedBody {
|
||||
FormURLEncodedBody(fields)
|
||||
}
|
||||
}
|
||||
190
Sources/LCEssentials/Classes/LCEMultipartForm.swift
Normal file
190
Sources/LCEssentials/Classes/LCEMultipartForm.swift
Normal file
@@ -0,0 +1,190 @@
|
||||
//
|
||||
// Copyright (c) 2020 Loverde Co.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A `multipart/form-data` body builder.
|
||||
///
|
||||
/// Text fields and small in-memory blobs are held as `Data`; on-disk files are
|
||||
/// referenced by `URL` and streamed straight into the serialised body, so a
|
||||
/// large upload never becomes fully resident in memory.
|
||||
public struct MultipartForm: Sendable {
|
||||
|
||||
/// Where a part's content comes from.
|
||||
public enum Source: Sendable {
|
||||
case data(Data)
|
||||
case file(URL)
|
||||
}
|
||||
|
||||
struct Part: Sendable {
|
||||
let name: String
|
||||
let filename: String?
|
||||
let mimeType: String?
|
||||
let source: Source
|
||||
/// `true` → plain form field: no `filename` / `Content-Type` header lines.
|
||||
let isField: Bool
|
||||
}
|
||||
|
||||
/// Error thrown while serialising the body.
|
||||
public enum SerializationError: Error {
|
||||
case cannotCreateTempFile(URL)
|
||||
case cannotOpenOutput(URL)
|
||||
case cannotOpenInput(URL)
|
||||
case writeFailed(underlying: Error?)
|
||||
case readFailed(URL)
|
||||
}
|
||||
|
||||
private(set) var parts: [Part] = []
|
||||
public let boundary: String
|
||||
|
||||
private static let chunkSize = 64 * 1024
|
||||
private static let crlf = "\r\n"
|
||||
|
||||
/// - Parameter boundary: multipart boundary token. Defaults to a random value.
|
||||
public init(boundary: String = "LCEssentials-\(UUID().uuidString)") {
|
||||
self.boundary = boundary
|
||||
}
|
||||
|
||||
// MARK: - Building
|
||||
|
||||
/// Appends a plain text field.
|
||||
public mutating func field(_ name: String, _ value: String) {
|
||||
parts.append(Part(name: name, filename: nil, mimeType: nil,
|
||||
source: .data(Data(value.utf8)), isField: true))
|
||||
}
|
||||
|
||||
/// Appends an in-memory file part.
|
||||
public mutating func file(_ name: String, data: Data, filename: String, mime: String? = nil) {
|
||||
parts.append(Part(name: name, filename: filename,
|
||||
mimeType: mime ?? Self.mimeType(for: filename),
|
||||
source: .data(data), isField: false))
|
||||
}
|
||||
|
||||
/// Appends an on-disk file part. The file is streamed at serialisation time.
|
||||
public mutating func file(_ name: String, url: URL, filename: String? = nil, mime: String? = nil) {
|
||||
let resolvedName = filename ?? url.lastPathComponent
|
||||
parts.append(Part(name: name, filename: resolvedName,
|
||||
mimeType: mime ?? Self.mimeType(for: resolvedName),
|
||||
source: .file(url), isField: false))
|
||||
}
|
||||
|
||||
// MARK: - Serialisation
|
||||
|
||||
/// Writes the whole body to a temporary file.
|
||||
///
|
||||
/// - Returns: the temp file URL (caller must delete it once the upload
|
||||
/// finishes) and the `multipart/form-data; boundary=…` content type.
|
||||
public func serialize() throws -> (fileURL: URL, contentType: String) {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("lce-multipart-\(UUID().uuidString).tmp")
|
||||
|
||||
guard FileManager.default.createFile(atPath: fileURL.path, contents: nil) else {
|
||||
throw SerializationError.cannotCreateTempFile(fileURL)
|
||||
}
|
||||
guard let output = OutputStream(url: fileURL, append: false) else {
|
||||
throw SerializationError.cannotOpenOutput(fileURL)
|
||||
}
|
||||
output.open()
|
||||
defer { output.close() }
|
||||
|
||||
for part in parts {
|
||||
try write(Data(header(for: part).utf8), to: output)
|
||||
switch part.source {
|
||||
case .data(let data):
|
||||
try write(data, to: output)
|
||||
case .file(let url):
|
||||
try stream(fileAt: url, to: output)
|
||||
}
|
||||
try write(Data(Self.crlf.utf8), to: output)
|
||||
}
|
||||
try write(Data("--\(boundary)--\(Self.crlf)".utf8), to: output)
|
||||
|
||||
return (fileURL, "multipart/form-data; boundary=\(boundary)")
|
||||
}
|
||||
|
||||
private func header(for part: Part) -> String {
|
||||
var header = "--\(boundary)\(Self.crlf)"
|
||||
header += "Content-Disposition: form-data; name=\"\(part.name)\""
|
||||
if let filename = part.filename, !part.isField {
|
||||
header += "; filename=\"\(filename)\""
|
||||
}
|
||||
header += Self.crlf
|
||||
if !part.isField, let mime = part.mimeType {
|
||||
header += "Content-Type: \(mime)\(Self.crlf)"
|
||||
}
|
||||
header += Self.crlf
|
||||
return header
|
||||
}
|
||||
|
||||
private func write(_ data: Data, to output: OutputStream) throws {
|
||||
guard !data.isEmpty else { return }
|
||||
var bytesRemaining = data
|
||||
while !bytesRemaining.isEmpty {
|
||||
let written = bytesRemaining.withUnsafeBytes { raw -> Int in
|
||||
guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return -1 }
|
||||
return output.write(base, maxLength: bytesRemaining.count)
|
||||
}
|
||||
guard written > 0 else {
|
||||
throw SerializationError.writeFailed(underlying: output.streamError)
|
||||
}
|
||||
bytesRemaining.removeFirst(written)
|
||||
}
|
||||
}
|
||||
|
||||
private func stream(fileAt url: URL, to output: OutputStream) throws {
|
||||
guard let input = InputStream(url: url) else {
|
||||
throw SerializationError.cannotOpenInput(url)
|
||||
}
|
||||
input.open()
|
||||
defer { input.close() }
|
||||
|
||||
var buffer = [UInt8](repeating: 0, count: Self.chunkSize)
|
||||
while input.hasBytesAvailable {
|
||||
let read = input.read(&buffer, maxLength: buffer.count)
|
||||
if read == 0 { break }
|
||||
guard read > 0 else { throw SerializationError.readFailed(url) }
|
||||
try write(Data(buffer[0..<read]), to: output)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MIME
|
||||
|
||||
/// Best-effort MIME type from a file name's extension.
|
||||
/// Falls back to `application/octet-stream`.
|
||||
public static func mimeType(for path: String) -> String {
|
||||
let ext = (path as NSString).pathExtension.lowercased()
|
||||
return mimeTypes[ext] ?? "application/octet-stream"
|
||||
}
|
||||
|
||||
private static let mimeTypes: [String: String] = [
|
||||
"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "gif": "image/gif",
|
||||
"pdf": "application/pdf", "txt": "text/plain", "html": "text/html", "htm": "text/html",
|
||||
"json": "application/json", "xml": "application/xml", "zip": "application/zip",
|
||||
"mp3": "audio/mpeg", "mp4": "video/mp4", "mov": "video/quicktime",
|
||||
"doc": "application/msword",
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"xls": "application/vnd.ms-excel",
|
||||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"ppt": "application/vnd.ms-powerpoint",
|
||||
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
]
|
||||
}
|
||||
@@ -99,7 +99,7 @@ public struct API {
|
||||
/// - 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,
|
||||
public func request<T: Decodable & Sendable>(url: String,
|
||||
params: Any? = nil,
|
||||
method: httpMethod,
|
||||
headers: [String: String] = [:],
|
||||
|
||||
@@ -112,7 +112,7 @@ public extension Data {
|
||||
#endif
|
||||
}
|
||||
|
||||
func object<T: Codable>() -> T? {
|
||||
func object<T: Codable & Sendable>() -> T? {
|
||||
do {
|
||||
let outPut: T = try JSONDecoder.decode(data: self)
|
||||
return outPut
|
||||
|
||||
@@ -138,7 +138,7 @@ public extension Dictionary {
|
||||
/// - LoverdeCo: Convert Dictonary to Object
|
||||
///
|
||||
/// - returns: Object: Codable/Decodable
|
||||
func toObjetct<T: Codable>() -> T {
|
||||
func toObjetct<T: Codable & Sendable>() -> T {
|
||||
let jsonString = self.convertToJSON
|
||||
let output: T = try! JSONDecoder.decode(jsonString)
|
||||
return output
|
||||
|
||||
@@ -44,8 +44,8 @@ extension JSONDecoder {
|
||||
/// - LoverdeCo: Decode JSON Data to Object
|
||||
///
|
||||
/// - Parameter data: Data
|
||||
/// - returns: Object: Codable/Decodable
|
||||
public static func decode<T: Codable>(data: Data) throws -> T {
|
||||
/// - returns: Object: Decodable & Sendable
|
||||
public static func decode<T: Decodable & Sendable>(data: Data) throws -> T {
|
||||
var error = NSError(domain: "", code: 0)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .useDefaultKeys
|
||||
@@ -70,28 +70,24 @@ extension JSONDecoder {
|
||||
/// - LoverdeCo: Decode JSON String to Object
|
||||
///
|
||||
/// - Parameter json: String
|
||||
/// - 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
|
||||
}
|
||||
/// - 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)
|
||||
}
|
||||
throw error
|
||||
return try decode(data: jsonData)
|
||||
}
|
||||
|
||||
|
||||
/// - LoverdeCo: Decode JSON URL to Object
|
||||
///
|
||||
/// - Parameter url: URL
|
||||
/// - returns: Object: Codable/Decodable
|
||||
public static func decode<T: Codable>(fromURL url: URL) throws -> T {
|
||||
return try decode(data: try! Data(contentsOf: url))
|
||||
/// - returns: Object: Decodable & Sendable
|
||||
public static func decode<T: Decodable & Sendable>(fromURL url: URL) throws -> T {
|
||||
return try decode(data: Data(contentsOf: url))
|
||||
}
|
||||
|
||||
public static func decode<T: Codable>(dictionary: Any) throws -> T {
|
||||
|
||||
public static func decode<T: Decodable & Sendable>(dictionary: Any) throws -> T {
|
||||
do {
|
||||
let json = try JSONSerialization.data(withJSONObject: dictionary)
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
Reference in New Issue
Block a user