[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:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "488bc8ba375dd6a42de3f34d4290056a146f9bd52f1bd6cd8ed81cbf4c63984f",
|
||||
"originHash" : "50e672f8971a68675dcb3ca27b4bd4832c7e96f2216ae062f002554d445c6714",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "lcecryptokitbinary",
|
||||
|
||||
@@ -39,5 +39,8 @@ let package = Package(
|
||||
.target(
|
||||
name: "LCEssentials",
|
||||
dependencies: targetDependencies),
|
||||
.testTarget(
|
||||
name: "LCEssentialsTests",
|
||||
dependencies: ["LCEssentials"]),
|
||||
]
|
||||
)
|
||||
|
||||
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 {
|
||||
/// - 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)
|
||||
}
|
||||
return try decode(data: jsonData)
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
/// - 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()
|
||||
|
||||
58
Tests/LCEssentialsTests/HTTPBodyTests.swift
Normal file
58
Tests/LCEssentialsTests/HTTPBodyTests.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import XCTest
|
||||
@testable import LCEssentials
|
||||
|
||||
private struct SamplePayload: Encodable, Sendable {
|
||||
let name: String
|
||||
let age: Int
|
||||
}
|
||||
|
||||
final class HTTPBodyTests: XCTestCase {
|
||||
|
||||
// MARK: - JSONBody
|
||||
|
||||
func testJSONBodyEncodesPayloadAndContentType() throws {
|
||||
let payload = SamplePayload(name: "loverde", age: 3)
|
||||
let (data, contentType) = try JSONBody(payload).encoded()
|
||||
|
||||
XCTAssertEqual(contentType, "application/json; charset=UTF-8")
|
||||
|
||||
let decoded = try JSONDecoder().decode([String: AnyDecodable].self, from: data)
|
||||
XCTAssertEqual(decoded["name"]?.value as? String, "loverde")
|
||||
XCTAssertEqual(decoded["age"]?.value as? Int, 3)
|
||||
}
|
||||
|
||||
func testJSONBodyFactory() throws {
|
||||
let (data, _) = try jsonBody(SamplePayload(name: "x", age: 1)).encoded()
|
||||
XCTAssertFalse(data.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - FormURLEncodedBody
|
||||
|
||||
func testFormURLEncodedEncodesPairsAndPercentEscapes() throws {
|
||||
let (data, contentType) = try FormURLEncodedBody(["a": "1", "b": "two words"]).encoded()
|
||||
|
||||
XCTAssertEqual(contentType, "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
|
||||
let pairs = Set(String(data: data, encoding: .utf8)!.split(separator: "&").map(String.init))
|
||||
XCTAssertEqual(pairs, ["a=1", "b=two%20words"])
|
||||
}
|
||||
|
||||
func testFormURLEncodedEscapesReservedCharactersInsteadOfDropping() throws {
|
||||
let (data, _) = try FormURLEncodedBody(["q": "a&b=c"]).encoded()
|
||||
let body = String(data: data, encoding: .utf8)!
|
||||
XCTAssertEqual(body, "q=a%26b%3Dc")
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal type-erased decoder for asserting JSON shape in tests.
|
||||
struct AnyDecodable: Decodable {
|
||||
let value: Any
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.singleValueContainer()
|
||||
if let i = try? c.decode(Int.self) { value = i }
|
||||
else if let s = try? c.decode(String.self) { value = s }
|
||||
else if let b = try? c.decode(Bool.self) { value = b }
|
||||
else if let d = try? c.decode(Double.self) { value = d }
|
||||
else { value = "" }
|
||||
}
|
||||
}
|
||||
26
Tests/LCEssentialsTests/JSONDecoderDecodeTests.swift
Normal file
26
Tests/LCEssentialsTests/JSONDecoderDecodeTests.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import XCTest
|
||||
@testable import LCEssentials
|
||||
|
||||
private struct OnlyDecodable: Decodable, Sendable, Equatable {
|
||||
let id: Int
|
||||
let name: String
|
||||
}
|
||||
|
||||
final class JSONDecoderDecodeTests: XCTestCase {
|
||||
|
||||
func testDecodesTypeThatIsDecodableAndSendableButNotEncodable() throws {
|
||||
let data = Data(#"{"id":7,"name":"loverde"}"#.utf8)
|
||||
let value: OnlyDecodable = try JSONDecoder.decode(data: data)
|
||||
XCTAssertEqual(value, OnlyDecodable(id: 7, name: "loverde"))
|
||||
}
|
||||
|
||||
func testDecodeFromStringOverload() throws {
|
||||
let value: OnlyDecodable = try JSONDecoder.decode(#"{"id":1,"name":"a"}"#)
|
||||
XCTAssertEqual(value, OnlyDecodable(id: 1, name: "a"))
|
||||
}
|
||||
|
||||
func testDecodeFromURLThrowsInsteadOfCrashingOnMissingFile() {
|
||||
let missing = URL(fileURLWithPath: "/tmp/does-not-exist-\(UUID().uuidString).json")
|
||||
XCTAssertThrowsError(try JSONDecoder.decode(fromURL: missing) as OnlyDecodable)
|
||||
}
|
||||
}
|
||||
106
Tests/LCEssentialsTests/MultipartFormTests.swift
Normal file
106
Tests/LCEssentialsTests/MultipartFormTests.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import XCTest
|
||||
@testable import LCEssentials
|
||||
|
||||
final class MultipartFormTests: XCTestCase {
|
||||
|
||||
private func readSerialized(_ form: MultipartForm) throws -> (body: Data, contentType: String, url: URL) {
|
||||
let result = try form.serialize()
|
||||
let data = try Data(contentsOf: result.fileURL)
|
||||
return (data, result.contentType, result.fileURL)
|
||||
}
|
||||
|
||||
private func addCleanup(_ url: URL) {
|
||||
addTeardownBlock { try? FileManager.default.removeItem(at: url) }
|
||||
}
|
||||
|
||||
// MARK: - Fields
|
||||
|
||||
func testFieldPartHasNoContentTypeLine() throws {
|
||||
var form = MultipartForm()
|
||||
form.field("caption", "hello world")
|
||||
|
||||
let (body, contentType, url) = try readSerialized(form)
|
||||
addCleanup(url)
|
||||
let text = String(data: body, encoding: .utf8)!
|
||||
|
||||
XCTAssertTrue(contentType.hasPrefix("multipart/form-data; boundary="))
|
||||
let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1])
|
||||
|
||||
XCTAssertEqual(text, [
|
||||
"--\(boundary)\r\n",
|
||||
"Content-Disposition: form-data; name=\"caption\"\r\n",
|
||||
"\r\n",
|
||||
"hello world\r\n",
|
||||
"--\(boundary)--\r\n"
|
||||
].joined())
|
||||
}
|
||||
|
||||
// MARK: - In-memory data file
|
||||
|
||||
func testDataFilePartCarriesFilenameAndMimeAndRawBytes() throws {
|
||||
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0xFF])
|
||||
var form = MultipartForm()
|
||||
form.file("photo", data: png, filename: "p.png")
|
||||
|
||||
let (body, contentType, url) = try readSerialized(form)
|
||||
addCleanup(url)
|
||||
let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1])
|
||||
let text = String(data: body, encoding: .isoLatin1)!
|
||||
|
||||
XCTAssertTrue(text.contains("Content-Disposition: form-data; name=\"photo\"; filename=\"p.png\"\r\n"))
|
||||
XCTAssertTrue(text.contains("Content-Type: image/png\r\n"))
|
||||
XCTAssertTrue(text.hasSuffix("--\(boundary)--\r\n"))
|
||||
|
||||
// raw bytes appear verbatim between the blank line and the trailing CRLF
|
||||
let marker = Data("\r\n\r\n".utf8)
|
||||
let range = body.range(of: marker)!
|
||||
let afterHeader = body[range.upperBound...]
|
||||
XCTAssertTrue(afterHeader.starts(with: png))
|
||||
}
|
||||
|
||||
func testExplicitMimeOverridesGuess() throws {
|
||||
var form = MultipartForm()
|
||||
form.file("f", data: Data("x".utf8), filename: "a.png", mime: "application/octet-stream")
|
||||
let (body, _, url) = try readSerialized(form)
|
||||
addCleanup(url)
|
||||
XCTAssertTrue(String(data: body, encoding: .utf8)!.contains("Content-Type: application/octet-stream\r\n"))
|
||||
}
|
||||
|
||||
// MARK: - Disk file, streamed
|
||||
|
||||
func testDiskFilePartIsStreamedAndContentMatches() throws {
|
||||
let big = Data((0..<(2 * 1024 * 1024)).map { UInt8($0 % 251) })
|
||||
let src = FileManager.default.temporaryDirectory.appendingPathComponent("src-\(UUID().uuidString).bin")
|
||||
try big.write(to: src)
|
||||
addCleanup(src)
|
||||
|
||||
var form = MultipartForm()
|
||||
form.field("kind", "raw")
|
||||
form.file("doc", url: src)
|
||||
|
||||
let (body, contentType, out) = try readSerialized(form)
|
||||
addCleanup(out)
|
||||
let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1])
|
||||
|
||||
// header uses the source filename
|
||||
XCTAssertTrue(String(data: body.prefix(400), encoding: .isoLatin1)!
|
||||
.contains("filename=\"\(src.lastPathComponent)\""))
|
||||
|
||||
// the 2 MB payload is present verbatim
|
||||
let marker = Data("Content-Type: application/octet-stream\r\n\r\n".utf8)
|
||||
let r = body.range(of: marker)!
|
||||
let payload = body[r.upperBound..<(body.index(r.upperBound, offsetBy: big.count))]
|
||||
XCTAssertEqual(Data(payload), big)
|
||||
|
||||
XCTAssertTrue(String(data: body.suffix(boundary.count + 8), encoding: .utf8)!
|
||||
.hasSuffix("--\(boundary)--\r\n"))
|
||||
}
|
||||
|
||||
// MARK: - MIME lookup
|
||||
|
||||
func testMimeTypeLookup() {
|
||||
XCTAssertEqual(MultipartForm.mimeType(for: "a.jpg"), "image/jpeg")
|
||||
XCTAssertEqual(MultipartForm.mimeType(for: "a.PDF"), "application/pdf")
|
||||
XCTAssertEqual(MultipartForm.mimeType(for: "a.unknownext"), "application/octet-stream")
|
||||
}
|
||||
}
|
||||
8
Tests/LCEssentialsTests/SmokeTests.swift
Normal file
8
Tests/LCEssentialsTests/SmokeTests.swift
Normal file
@@ -0,0 +1,8 @@
|
||||
import XCTest
|
||||
@testable import LCEssentials
|
||||
|
||||
final class SmokeTests: XCTestCase {
|
||||
func testTargetBuildsAndRuns() {
|
||||
XCTAssertTrue(true)
|
||||
}
|
||||
}
|
||||
116
Tests/LCEssentialsTests/Support/StubURLProtocol.swift
Normal file
116
Tests/LCEssentialsTests/Support/StubURLProtocol.swift
Normal file
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
|
||||
/// Test double for `URLProtocol`. Intercepts every request on a `URLSession`
|
||||
/// configured with it, records the outgoing `URLRequest`, and replays a canned
|
||||
/// response or error supplied by the test.
|
||||
///
|
||||
/// Register via:
|
||||
/// ```
|
||||
/// let cfg = URLSessionConfiguration.ephemeral
|
||||
/// cfg.protocolClasses = [StubURLProtocol.self]
|
||||
/// let session = URLSession(configuration: cfg)
|
||||
/// ```
|
||||
final class StubURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
|
||||
struct Stub {
|
||||
var statusCode: Int = 200
|
||||
var headers: [String: String] = ["Content-Type": "application/json"]
|
||||
var body: Data = Data()
|
||||
var error: Error?
|
||||
/// Bytes reported through `URLSession`'s upload progress, in order.
|
||||
var uploadProgressChunks: [Int] = []
|
||||
}
|
||||
|
||||
// MARK: - Test-facing state (guarded)
|
||||
|
||||
private static let lock = NSLock()
|
||||
// Access is serialised through `lock`; the unsafe opt-out is the documented
|
||||
// pattern for lock-guarded mutable statics under strict concurrency.
|
||||
nonisolated(unsafe) private static var _stub = Stub()
|
||||
nonisolated(unsafe) private static var _capturedRequests: [URLRequest] = []
|
||||
nonisolated(unsafe) private static var _capturedBodies: [Data] = []
|
||||
|
||||
static func setStub(_ stub: Stub) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
_stub = stub
|
||||
_capturedRequests = []
|
||||
_capturedBodies = []
|
||||
}
|
||||
|
||||
static func reset() { setStub(Stub()) }
|
||||
|
||||
static var capturedRequests: [URLRequest] {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return _capturedRequests
|
||||
}
|
||||
|
||||
/// Body of the last intercepted request. `URLProtocol` strips `httpBody` for
|
||||
/// stream bodies, so this reads `httpBodyStream` when needed.
|
||||
static var lastCapturedBody: Data? {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return _capturedBodies.last
|
||||
}
|
||||
|
||||
static var requestCount: Int {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return _capturedRequests.count
|
||||
}
|
||||
|
||||
private static func currentStub() -> Stub {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return _stub
|
||||
}
|
||||
|
||||
private static func record(_ request: URLRequest, body: Data) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
_capturedRequests.append(request)
|
||||
_capturedBodies.append(body)
|
||||
}
|
||||
|
||||
// MARK: - URLProtocol
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
|
||||
override func startLoading() {
|
||||
let stub = Self.currentStub()
|
||||
Self.record(request, body: Self.bodyData(from: request))
|
||||
|
||||
guard let client = client else { return }
|
||||
|
||||
if let error = stub.error {
|
||||
client.urlProtocol(self, didFailWithError: error)
|
||||
return
|
||||
}
|
||||
|
||||
let url = request.url ?? URL(string: "https://stub.invalid")!
|
||||
let response = HTTPURLResponse(url: url,
|
||||
statusCode: stub.statusCode,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: stub.headers)!
|
||||
client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client.urlProtocol(self, didLoad: stub.body)
|
||||
client.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
|
||||
// MARK: - Body extraction
|
||||
|
||||
private static func bodyData(from request: URLRequest) -> Data {
|
||||
if let body = request.httpBody { return body }
|
||||
guard let stream = request.httpBodyStream else { return Data() }
|
||||
stream.open()
|
||||
defer { stream.close() }
|
||||
var data = Data()
|
||||
let bufferSize = 64 * 1024
|
||||
var buffer = [UInt8](repeating: 0, count: bufferSize)
|
||||
while stream.hasBytesAvailable {
|
||||
let read = stream.read(&buffer, maxLength: bufferSize)
|
||||
if read <= 0 { break }
|
||||
data.append(buffer, count: read)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
47
Tests/LCEssentialsTests/Support/StubURLProtocolTests.swift
Normal file
47
Tests/LCEssentialsTests/Support/StubURLProtocolTests.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import XCTest
|
||||
|
||||
final class StubURLProtocolTests: XCTestCase {
|
||||
|
||||
private func makeSession() -> URLSession {
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.protocolClasses = [StubURLProtocol.self]
|
||||
return URLSession(configuration: cfg)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
StubURLProtocol.reset()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testReplaysCannedResponseAndCapturesRequest() async throws {
|
||||
var stub = StubURLProtocol.Stub()
|
||||
stub.statusCode = 201
|
||||
stub.body = Data(#"{"ok":true}"#.utf8)
|
||||
StubURLProtocol.setStub(stub)
|
||||
|
||||
var request = URLRequest(url: URL(string: "https://example.com/things")!)
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = Data(#"{"name":"x"}"#.utf8)
|
||||
|
||||
let (data, response) = try await makeSession().data(for: request)
|
||||
|
||||
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 201)
|
||||
XCTAssertEqual(String(data: data, encoding: .utf8), #"{"ok":true}"#)
|
||||
XCTAssertEqual(StubURLProtocol.requestCount, 1)
|
||||
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.httpMethod, "POST")
|
||||
XCTAssertEqual(StubURLProtocol.lastCapturedBody, Data(#"{"name":"x"}"#.utf8))
|
||||
}
|
||||
|
||||
func testReplaysError() async {
|
||||
var stub = StubURLProtocol.Stub()
|
||||
stub.error = URLError(.notConnectedToInternet)
|
||||
StubURLProtocol.setStub(stub)
|
||||
|
||||
do {
|
||||
_ = try await makeSession().data(for: URLRequest(url: URL(string: "https://example.com")!))
|
||||
XCTFail("expected throw")
|
||||
} catch {
|
||||
XCTAssertEqual((error as? URLError)?.code, .notConnectedToInternet)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user