diff --git a/Package.resolved b/Package.resolved index ae348866c..9defc6cae 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,14 +1,5 @@ { "pins" : [ - { - "identity" : "multipartformdata", - "kind" : "remoteSourceControl", - "location" : "https://github.com/grdsdev/MultipartFormData", - "state" : { - "revision" : "ed7abea9cfc6c3b5e77a73fe6842c57a372d2017", - "version" : "0.1.0" - } - }, { "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 3e5822151..b47e552ce 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,6 @@ let package = Package( .library(name: "Supabase", targets: ["Supabase", "Functions", "PostgREST", "Auth", "Realtime", "Storage"]), ], dependencies: [ - .package(url: "https://github.com/grdsdev/MultipartFormData", from: "0.1.0"), .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "4.0.0"), .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.1.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), @@ -131,7 +130,6 @@ let package = Package( .target( name: "Storage", dependencies: [ - "MultipartFormData", "Helpers", ] ), diff --git a/Sources/Storage/Helpers.swift b/Sources/Storage/Helpers.swift index 382d7250b..630909fa0 100644 --- a/Sources/Storage/Helpers.swift +++ b/Sources/Storage/Helpers.swift @@ -7,70 +7,6 @@ import Foundation -#if canImport(MobileCoreServices) - import MobileCoreServices -#elseif canImport(CoreServices) - import CoreServices -#endif - -#if canImport(UniformTypeIdentifiers) - import UniformTypeIdentifiers - - func mimeType(forPathExtension pathExtension: String) -> String { - #if swift(>=5.9) - if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, visionOS 1, *) { - return UTType(filenameExtension: pathExtension)?.preferredMIMEType - ?? "application/octet-stream" - } else { - if let id = UTTypeCreatePreferredIdentifierForTag( - kUTTagClassFilenameExtension, pathExtension as CFString, nil - )?.takeRetainedValue(), - let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? - .takeRetainedValue() - { - return contentType as String - } - - return "application/octet-stream" - } - #else - if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) { - return UTType(filenameExtension: pathExtension)?.preferredMIMEType - ?? "application/octet-stream" - } else { - if let id = UTTypeCreatePreferredIdentifierForTag( - kUTTagClassFilenameExtension, pathExtension as CFString, nil - )?.takeRetainedValue(), - let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? - .takeRetainedValue() - { - return contentType as String - } - - return "application/octet-stream" - } - #endif - } -#else - - // MARK: - Private - Mime Type - - func mimeType(forPathExtension pathExtension: String) -> String { - #if canImport(CoreServices) || canImport(MobileCoreServices) - if let id = UTTypeCreatePreferredIdentifierForTag( - kUTTagClassFilenameExtension, pathExtension as CFString, nil - )?.takeRetainedValue(), - let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? - .takeRetainedValue() - { - return contentType as String - } - #endif - - return "application/octet-stream" - } -#endif - extension String { var pathExtension: String { (self as NSString).pathExtension diff --git a/Sources/Storage/MultipartFormData.swift b/Sources/Storage/MultipartFormData.swift new file mode 100644 index 000000000..385824e6f --- /dev/null +++ b/Sources/Storage/MultipartFormData.swift @@ -0,0 +1,689 @@ +// MutlipartFormData extracted from [Alamofire](https://github.com/Alamofire/Alamofire/blob/master/Source/Features/MultipartFormData.swift) for using as standalone. + +// +// MultipartFormData.swift +// +// Copyright (c) 2014-2018 Alamofire Software Foundation (http://alamofire.org/) +// +// 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 +import Helpers + +#if canImport(MobileCoreServices) + import MobileCoreServices +#elseif canImport(CoreServices) + import CoreServices +#endif + +/// Constructs `multipart/form-data` for uploads within an HTTP or HTTPS body. There are currently two ways to encode +/// multipart form data. The first way is to encode the data directly in memory. This is very efficient, but can lead +/// to memory issues if the dataset is too large. The second way is designed for larger datasets and will write all the +/// data to a single file on disk with all the proper boundary segmentation. The second approach MUST be used for +/// larger datasets such as video content, otherwise your app may run out of memory when trying to encode the dataset. +/// +/// For more information on `multipart/form-data` in general, please refer to the RFC-2388 and RFC-2045 specs as well +/// and the w3 form documentation. +/// +/// - https://www.ietf.org/rfc/rfc2388.txt +/// - https://www.ietf.org/rfc/rfc2045.txt +/// - https://www.w3.org/TR/html401/interact/forms.html#h-17.13 +class MultipartFormData { + // MARK: - Helper Types + + enum EncodingCharacters { + static let crlf = "\r\n" + } + + enum BoundaryGenerator { + enum BoundaryType { + case initial, encapsulated, final + } + + static func randomBoundary() -> String { + let first = UInt32.random(in: UInt32.min ... UInt32.max) + let second = UInt32.random(in: UInt32.min ... UInt32.max) + + return String(format: "alamofire.boundary.%08x%08x", first, second) + } + + static func boundaryData(forBoundaryType boundaryType: BoundaryType, boundary: String) -> Data { + let boundaryText = switch boundaryType { + case .initial: + "--\(boundary)\(EncodingCharacters.crlf)" + case .encapsulated: + "\(EncodingCharacters.crlf)--\(boundary)\(EncodingCharacters.crlf)" + case .final: + "\(EncodingCharacters.crlf)--\(boundary)--\(EncodingCharacters.crlf)" + } + + return Data(boundaryText.utf8) + } + } + + class BodyPart { + let headers: HTTPHeaders + let bodyStream: InputStream + let bodyContentLength: UInt64 + var hasInitialBoundary = false + var hasFinalBoundary = false + + init(headers: HTTPHeaders, bodyStream: InputStream, bodyContentLength: UInt64) { + self.headers = headers + self.bodyStream = bodyStream + self.bodyContentLength = bodyContentLength + } + } + + // MARK: - Properties + + /// Default memory threshold used when encoding `MultipartFormData`, in bytes. + static let encodingMemoryThreshold: UInt64 = 10000000 + + /// The `Content-Type` header value containing the boundary used to generate the `multipart/form-data`. + open lazy var contentType: String = "multipart/form-data; boundary=\(self.boundary)" + + /// The content length of all body parts used to generate the `multipart/form-data` not including the boundaries. + var contentLength: UInt64 { bodyParts.reduce(0) { $0 + $1.bodyContentLength } } + + /// The boundary used to separate the body parts in the encoded form data. + let boundary: String + + let fileManager: FileManager + + private var bodyParts: [BodyPart] + private var bodyPartError: MultipartFormDataError? + private let streamBufferSize: Int + + // MARK: - Lifecycle + + /// Creates an instance. + /// + /// - Parameters: + /// - fileManager: `FileManager` to use for file operations, if needed. + /// - boundary: Boundary `String` used to separate body parts. + init(fileManager: FileManager = .default, boundary: String? = nil) { + self.fileManager = fileManager + self.boundary = boundary ?? BoundaryGenerator.randomBoundary() + bodyParts = [] + + // + // The optimal read/write buffer size in bytes for input and output streams is 1024 (1KB). For more + // information, please refer to the following article: + // - https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/Streams/Articles/ReadingInputStreams.html + // + streamBufferSize = 1024 + } + + // MARK: - Body Parts + + /// Creates a body part from the data and appends it to the instance. + /// + /// The body part data will be encoded using the following format: + /// + /// - `Content-Disposition: form-data; name=#{name}; filename=#{filename}` (HTTP Header) + /// - `Content-Type: #{mimeType}` (HTTP Header) + /// - Encoded file data + /// - Multipart form boundary + /// + /// - Parameters: + /// - data: `Data` to encoding into the instance. + /// - name: Name to associate with the `Data` in the `Content-Disposition` HTTP header. + /// - fileName: Filename to associate with the `Data` in the `Content-Disposition` HTTP header. + /// - mimeType: MIME type to associate with the data in the `Content-Type` HTTP header. + func append( + _ data: Data, withName name: String, fileName: String? = nil, mimeType: String? = nil + ) { + let headers = contentHeaders(withName: name, fileName: fileName, mimeType: mimeType) + let stream = InputStream(data: data) + let length = UInt64(data.count) + + append(stream, withLength: length, headers: headers) + } + + /// Creates a body part from the file and appends it to the instance. + /// + /// The body part data will be encoded using the following format: + /// + /// - `Content-Disposition: form-data; name=#{name}; filename=#{generated filename}` (HTTP Header) + /// - `Content-Type: #{generated mimeType}` (HTTP Header) + /// - Encoded file data + /// - Multipart form boundary + /// + /// The filename in the `Content-Disposition` HTTP header is generated from the last path component of the + /// `fileURL`. The `Content-Type` HTTP header MIME type is generated by mapping the `fileURL` extension to the + /// system associated MIME type. + /// + /// - Parameters: + /// - fileURL: `URL` of the file whose content will be encoded into the instance. + /// - name: Name to associate with the file content in the `Content-Disposition` HTTP header. + func append(_ fileURL: URL, withName name: String) { + let fileName = fileURL.lastPathComponent + let pathExtension = fileURL.pathExtension + + if !fileName.isEmpty, !pathExtension.isEmpty { + let mime = MultipartFormData.mimeType(forPathExtension: pathExtension) + append(fileURL, withName: name, fileName: fileName, mimeType: mime) + } else { + setBodyPartError(.bodyPartFilenameInvalid(in: fileURL)) + } + } + + /// Creates a body part from the file and appends it to the instance. + /// + /// The body part data will be encoded using the following format: + /// + /// - Content-Disposition: form-data; name=#{name}; filename=#{filename} (HTTP Header) + /// - Content-Type: #{mimeType} (HTTP Header) + /// - Encoded file data + /// - Multipart form boundary + /// + /// - Parameters: + /// - fileURL: `URL` of the file whose content will be encoded into the instance. + /// - name: Name to associate with the file content in the `Content-Disposition` HTTP header. + /// - fileName: Filename to associate with the file content in the `Content-Disposition` HTTP header. + /// - mimeType: MIME type to associate with the file content in the `Content-Type` HTTP header. + func append(_ fileURL: URL, withName name: String, fileName: String, mimeType: String) { + let headers = contentHeaders(withName: name, fileName: fileName, mimeType: mimeType) + + //============================================================ + // Check 1 - is file URL? + //============================================================ + + guard fileURL.isFileURL else { + setBodyPartError(.bodyPartURLInvalid(url: fileURL)) + return + } + + //============================================================ + // Check 2 - is file URL reachable? + //============================================================ + + #if !(os(Linux) || os(Windows) || os(Android)) + do { + let isReachable = try fileURL.checkPromisedItemIsReachable() + guard isReachable else { + setBodyPartError(.bodyPartFileNotReachable(at: fileURL)) + return + } + } catch { + setBodyPartError(.bodyPartFileNotReachableWithError(atURL: fileURL, error: error)) + return + } + #endif + + //============================================================ + // Check 3 - is file URL a directory? + //============================================================ + + var isDirectory: ObjCBool = false + let path = fileURL.path + + guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory), !isDirectory.boolValue + else { + setBodyPartError(.bodyPartFileIsDirectory(at: fileURL)) + return + } + + //============================================================ + // Check 4 - can the file size be extracted? + //============================================================ + + let bodyContentLength: UInt64 + + do { + guard let fileSize = try fileManager.attributesOfItem(atPath: path)[.size] as? NSNumber else { + setBodyPartError(.bodyPartFileSizeNotAvailable(at: fileURL)) + return + } + + bodyContentLength = fileSize.uint64Value + } catch { + setBodyPartError(.bodyPartFileSizeQueryFailedWithError(forURL: fileURL, error: error)) + return + } + + //============================================================ + // Check 5 - can a stream be created from file URL? + //============================================================ + + guard let stream = InputStream(url: fileURL) else { + setBodyPartError(.bodyPartInputStreamCreationFailed(for: fileURL)) + return + } + + append(stream, withLength: bodyContentLength, headers: headers) + } + + /// Creates a body part from the stream and appends it to the instance. + /// + /// The body part data will be encoded using the following format: + /// + /// - `Content-Disposition: form-data; name=#{name}; filename=#{filename}` (HTTP Header) + /// - `Content-Type: #{mimeType}` (HTTP Header) + /// - Encoded stream data + /// - Multipart form boundary + /// + /// - Parameters: + /// - stream: `InputStream` to encode into the instance. + /// - length: Length, in bytes, of the stream. + /// - name: Name to associate with the stream content in the `Content-Disposition` HTTP header. + /// - fileName: Filename to associate with the stream content in the `Content-Disposition` HTTP header. + /// - mimeType: MIME type to associate with the stream content in the `Content-Type` HTTP header. + func append( + _ stream: InputStream, + withLength length: UInt64, + name: String, + fileName: String, + mimeType: String + ) { + let headers = contentHeaders(withName: name, fileName: fileName, mimeType: mimeType) + append(stream, withLength: length, headers: headers) + } + + /// Creates a body part with the stream, length, and headers and appends it to the instance. + /// + /// The body part data will be encoded using the following format: + /// + /// - HTTP headers + /// - Encoded stream data + /// - Multipart form boundary + /// + /// - Parameters: + /// - stream: `InputStream` to encode into the instance. + /// - length: Length, in bytes, of the stream. + /// - headers: `HTTPHeaders` for the body part. + func append(_ stream: InputStream, withLength length: UInt64, headers: HTTPHeaders) { + let bodyPart = BodyPart(headers: headers, bodyStream: stream, bodyContentLength: length) + bodyParts.append(bodyPart) + } + + // MARK: - Data Encoding + + /// Encodes all appended body parts into a single `Data` value. + /// + /// - Note: This method will load all the appended body parts into memory all at the same time. This method should + /// only be used when the encoded data will have a small memory footprint. For large data cases, please use + /// the `writeEncodedData(to:))` method. + /// + /// - Returns: The encoded `Data`, if encoding is successful. + /// - Throws: An `AFError` if encoding encounters an error. + func encode() throws -> Data { + if let bodyPartError { + throw bodyPartError + } + + var encoded = Data() + + bodyParts.first?.hasInitialBoundary = true + bodyParts.last?.hasFinalBoundary = true + + for bodyPart in bodyParts { + let encodedData = try encode(bodyPart) + encoded.append(encodedData) + } + + return encoded + } + + /// Writes all appended body parts to the given file `URL`. + /// + /// This process is facilitated by reading and writing with input and output streams, respectively. Thus, + /// this approach is very memory efficient and should be used for large body part data. + /// + /// - Parameter fileURL: File `URL` to which to write the form data. + /// - Throws: An `AFError` if encoding encounters an error. + func writeEncodedData(to fileURL: URL) throws { + if let bodyPartError { + throw bodyPartError + } + + if fileManager.fileExists(atPath: fileURL.path) { + throw MultipartFormDataError.outputStreamFileAlreadyExists(at: fileURL) + } else if !fileURL.isFileURL { + throw MultipartFormDataError.outputStreamURLInvalid(url: fileURL) + } + + guard let outputStream = OutputStream(url: fileURL, append: false) else { + throw MultipartFormDataError.outputStreamCreationFailed(for: fileURL) + } + + outputStream.open() + defer { outputStream.close() } + + bodyParts.first?.hasInitialBoundary = true + bodyParts.last?.hasFinalBoundary = true + + for bodyPart in bodyParts { + try write(bodyPart, to: outputStream) + } + } + + // MARK: - Private - Body Part Encoding + + private func encode(_ bodyPart: BodyPart) throws -> Data { + var encoded = Data() + + let initialData = + bodyPart.hasInitialBoundary ? initialBoundaryData() : encapsulatedBoundaryData() + encoded.append(initialData) + + let headerData = encodeHeaders(for: bodyPart) + encoded.append(headerData) + + let bodyStreamData = try encodeBodyStream(for: bodyPart) + encoded.append(bodyStreamData) + + if bodyPart.hasFinalBoundary { + encoded.append(finalBoundaryData()) + } + + return encoded + } + + private func encodeHeaders(for bodyPart: BodyPart) -> Data { + let headerText = + bodyPart.headers.map { "\($0.name): \($0.value)\(EncodingCharacters.crlf)" } + .joined() + + EncodingCharacters.crlf + + return Data(headerText.utf8) + } + + private func encodeBodyStream(for bodyPart: BodyPart) throws -> Data { + let inputStream = bodyPart.bodyStream + inputStream.open() + defer { inputStream.close() } + + var encoded = Data() + + while inputStream.hasBytesAvailable { + var buffer = [UInt8](repeating: 0, count: streamBufferSize) + let bytesRead = inputStream.read(&buffer, maxLength: streamBufferSize) + + if let error = inputStream.streamError { + throw MultipartFormDataError.inputStreamReadFailed(error: error) + } + + if bytesRead > 0 { + encoded.append(buffer, count: bytesRead) + } else { + break + } + } + + guard UInt64(encoded.count) == bodyPart.bodyContentLength else { + let error = MultipartFormDataError.UnexpectedInputStreamLength( + bytesExpected: bodyPart.bodyContentLength, + bytesRead: UInt64(encoded.count) + ) + throw MultipartFormDataError.inputStreamReadFailed(error: error) + } + + return encoded + } + + // MARK: - Private - Writing Body Part to Output Stream + + private func write(_ bodyPart: BodyPart, to outputStream: OutputStream) throws { + try writeInitialBoundaryData(for: bodyPart, to: outputStream) + try writeHeaderData(for: bodyPart, to: outputStream) + try writeBodyStream(for: bodyPart, to: outputStream) + try writeFinalBoundaryData(for: bodyPart, to: outputStream) + } + + private func writeInitialBoundaryData(for bodyPart: BodyPart, to outputStream: OutputStream) + throws + { + let initialData = + bodyPart.hasInitialBoundary ? initialBoundaryData() : encapsulatedBoundaryData() + return try write(initialData, to: outputStream) + } + + private func writeHeaderData(for bodyPart: BodyPart, to outputStream: OutputStream) throws { + let headerData = encodeHeaders(for: bodyPart) + return try write(headerData, to: outputStream) + } + + private func writeBodyStream(for bodyPart: BodyPart, to outputStream: OutputStream) throws { + let inputStream = bodyPart.bodyStream + + inputStream.open() + defer { inputStream.close() } + + var bytesLeftToRead = bodyPart.bodyContentLength + while inputStream.hasBytesAvailable, bytesLeftToRead > 0 { + let bufferSize = min(streamBufferSize, Int(bytesLeftToRead)) + var buffer = [UInt8](repeating: 0, count: bufferSize) + let bytesRead = inputStream.read(&buffer, maxLength: bufferSize) + + if let streamError = inputStream.streamError { + throw MultipartFormDataError.inputStreamReadFailed(error: streamError) + } + + if bytesRead > 0 { + if buffer.count != bytesRead { + buffer = Array(buffer[0 ..< bytesRead]) + } + + try write(&buffer, to: outputStream) + bytesLeftToRead -= UInt64(bytesRead) + } else { + break + } + } + } + + private func writeFinalBoundaryData(for bodyPart: BodyPart, to outputStream: OutputStream) throws { + if bodyPart.hasFinalBoundary { + try write(finalBoundaryData(), to: outputStream) + } + } + + // MARK: - Private - Writing Buffered Data to Output Stream + + private func write(_ data: Data, to outputStream: OutputStream) throws { + var buffer = [UInt8](repeating: 0, count: data.count) + data.copyBytes(to: &buffer, count: data.count) + + return try write(&buffer, to: outputStream) + } + + private func write(_ buffer: inout [UInt8], to outputStream: OutputStream) throws { + var bytesToWrite = buffer.count + + while bytesToWrite > 0, outputStream.hasSpaceAvailable { + let bytesWritten = outputStream.write(buffer, maxLength: bytesToWrite) + + if let error = outputStream.streamError { + throw MultipartFormDataError.outputStreamWriteFailed(error: error) + } + + bytesToWrite -= bytesWritten + + if bytesToWrite > 0 { + buffer = Array(buffer[bytesWritten ..< buffer.count]) + } + } + } + + // MARK: - Private - Content Headers + + private func contentHeaders( + withName name: String, fileName: String? = nil, mimeType: String? = nil + ) -> HTTPHeaders { + var disposition = "form-data; name=\"\(name)\"" + if let fileName { disposition += "; filename=\"\(fileName)\"" } + + var headers: HTTPHeaders = ["Content-Disposition": disposition] + if let mimeType { headers["Content-Type"] = mimeType } + + return headers + } + + // MARK: - Private - Boundary Encoding + + private func initialBoundaryData() -> Data { + BoundaryGenerator.boundaryData(forBoundaryType: .initial, boundary: boundary) + } + + private func encapsulatedBoundaryData() -> Data { + BoundaryGenerator.boundaryData(forBoundaryType: .encapsulated, boundary: boundary) + } + + private func finalBoundaryData() -> Data { + BoundaryGenerator.boundaryData(forBoundaryType: .final, boundary: boundary) + } + + // MARK: - Private - Errors + + private func setBodyPartError(_ error: MultipartFormDataError) { + guard bodyPartError == nil else { return } + bodyPartError = error + } +} + +#if canImport(UniformTypeIdentifiers) + import UniformTypeIdentifiers + + extension MultipartFormData { + // MARK: - Private - Mime Type + + static func mimeType(forPathExtension pathExtension: String) -> String { + #if swift(>=5.9) + if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, visionOS 1, *) { + return UTType(filenameExtension: pathExtension)?.preferredMIMEType + ?? "application/octet-stream" + } else { + if let id = UTTypeCreatePreferredIdentifierForTag( + kUTTagClassFilenameExtension, pathExtension as CFString, nil + )?.takeRetainedValue(), + let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? + .takeRetainedValue() + { + return contentType as String + } + + return "application/octet-stream" + } + #else + if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) { + return UTType(filenameExtension: pathExtension)?.preferredMIMEType + ?? "application/octet-stream" + } else { + if let id = UTTypeCreatePreferredIdentifierForTag( + kUTTagClassFilenameExtension, pathExtension as CFString, nil + )?.takeRetainedValue(), + let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? + .takeRetainedValue() + { + return contentType as String + } + + return "application/octet-stream" + } + #endif + } + } + +#else + + extension MultipartFormData { + // MARK: - Private - Mime Type + + static func mimeType(forPathExtension pathExtension: String) -> String { + #if canImport(CoreServices) || canImport(MobileCoreServices) + if let id = UTTypeCreatePreferredIdentifierForTag( + kUTTagClassFilenameExtension, pathExtension as CFString, nil + )?.takeRetainedValue(), + let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? + .takeRetainedValue() + { + return contentType as String + } + #endif + + return "application/octet-stream" + } + } + +#endif + +enum MultipartFormDataError: Error { + case bodyPartURLInvalid(url: URL) + case bodyPartFilenameInvalid(in: URL) + case bodyPartFileNotReachable(at: URL) + case bodyPartFileNotReachableWithError(atURL: URL, error: any Error) + case bodyPartFileIsDirectory(at: URL) + case bodyPartFileSizeNotAvailable(at: URL) + case bodyPartFileSizeQueryFailedWithError(forURL: URL, error: any Error) + case bodyPartInputStreamCreationFailed(for: URL) + case outputStreamFileAlreadyExists(at: URL) + case outputStreamURLInvalid(url: URL) + case outputStreamCreationFailed(for: URL) + case inputStreamReadFailed(error: any Error) + case outputStreamWriteFailed(error: any Error) + + struct UnexpectedInputStreamLength: Error { + let bytesExpected: UInt64 + let bytesRead: UInt64 + } + + var underlyingError: (any Error)? { + switch self { + case let .bodyPartFileNotReachableWithError(_, error), + let .bodyPartFileSizeQueryFailedWithError(_, error), + let .inputStreamReadFailed(error), + let .outputStreamWriteFailed(error): + error + + case .bodyPartURLInvalid, + .bodyPartFilenameInvalid, + .bodyPartFileNotReachable, + .bodyPartFileIsDirectory, + .bodyPartFileSizeNotAvailable, + .bodyPartInputStreamCreationFailed, + .outputStreamFileAlreadyExists, + .outputStreamURLInvalid, + .outputStreamCreationFailed: + nil + } + } + + var url: URL? { + switch self { + case let .bodyPartURLInvalid(url), + let .bodyPartFilenameInvalid(url), + let .bodyPartFileNotReachable(url), + let .bodyPartFileNotReachableWithError(url, _), + let .bodyPartFileIsDirectory(url), + let .bodyPartFileSizeNotAvailable(url), + let .bodyPartFileSizeQueryFailedWithError(url, _), + let .bodyPartInputStreamCreationFailed(url), + let .outputStreamFileAlreadyExists(url), + let .outputStreamURLInvalid(url), + let .outputStreamCreationFailed(url): + url + + case .inputStreamReadFailed, .outputStreamWriteFailed: + nil + } + } +} diff --git a/Sources/Storage/StorageApi.swift b/Sources/Storage/StorageApi.swift index e2ad1fb5f..a635b1a4f 100644 --- a/Sources/Storage/StorageApi.swift +++ b/Sources/Storage/StorageApi.swift @@ -1,6 +1,5 @@ import Foundation import Helpers -import class MultipartFormData.MultipartFormData #if canImport(FoundationNetworking) import FoundationNetworking diff --git a/Sources/Storage/StorageFileApi.swift b/Sources/Storage/StorageFileApi.swift index 5022a9d1a..0bc43f7e6 100644 --- a/Sources/Storage/StorageFileApi.swift +++ b/Sources/Storage/StorageFileApi.swift @@ -1,6 +1,5 @@ import Foundation import Helpers -import class MultipartFormData.MultipartFormData #if canImport(FoundationNetworking) import FoundationNetworking @@ -115,7 +114,7 @@ public class StorageFileApi: StorageApi, @unchecked Sendable { data, withName: fileName, fileName: fileName, - mimeType: options.contentType ?? mimeType(forPathExtension: path.pathExtension) + mimeType: options.contentType ?? MultipartFormData.mimeType(forPathExtension: path.pathExtension) ) return try await _uploadOrUpdate(method: .post, path: path, formData: formData, options: options) } @@ -150,7 +149,7 @@ public class StorageFileApi: StorageApi, @unchecked Sendable { data, withName: fileName, fileName: fileName, - mimeType: options.contentType ?? mimeType(forPathExtension: path.pathExtension) + mimeType: options.contentType ?? MultipartFormData.mimeType(forPathExtension: path.pathExtension) ) return try await _uploadOrUpdate(method: .put, path: path, formData: formData, options: options) } @@ -585,7 +584,7 @@ public class StorageFileApi: StorageApi, @unchecked Sendable { data, withName: fileName, fileName: fileName, - mimeType: options?.contentType ?? mimeType(forPathExtension: path.pathExtension) + mimeType: options?.contentType ?? MultipartFormData.mimeType(forPathExtension: path.pathExtension) ) return try await _uploadToSignedURL( path: path,