diff --git a/templates/swift/Sources/Client.swift.twig b/templates/swift/Sources/Client.swift.twig index c18c8f079..6475cef24 100644 --- a/templates/swift/Sources/Client.swift.twig +++ b/templates/swift/Sources/Client.swift.twig @@ -12,6 +12,7 @@ let CRLF = "\r\n" open class Client { // MARK: Properties + public static var chunkSize = 5 * 1024 * 1024 // 5MB open var endPoint = "{{spec.endpoint}}" @@ -33,6 +34,8 @@ open class Client { private static let boundaryChars = "abcdefghijklmnopqrstuvwxyz1234567890" + private static let boundary = randomBoundary() + private static var eventLoopGroupProvider = HTTPClient.EventLoopGroupProvider.createNew @@ -279,7 +282,7 @@ open class Client { with params: [String: Any?] ) throws { if request.headers["content-type"][0] == "multipart/form-data" { - buildMultipart(&request, with: params) + buildMultipart(&request, with: params, chunked: !request.headers["content-range"].isEmpty) } else { try buildJSON(&request, with: params) } @@ -342,6 +345,14 @@ open class Client { default: var message = "" + if response.body == nil { + completion(.failure({{ spec.title | caseUcfirst }}Error( + message: "Unknown error with status code \(response.status.code)", + code: Int(response.status.code) + ))) + return + } + do { let dict = try JSONSerialization .jsonObject(with: response.body!) as? [String: Any] @@ -352,7 +363,7 @@ open class Client { message = response.body!.readString(length: response.body!.readableBytes)! } - let error = AppwriteError( + let error = {{ spec.title | caseUcfirst }}Error( message: message, code: Int(response.status.code) ) @@ -363,7 +374,77 @@ open class Client { } } - private func randomBoundary() -> String { + func chunkedUpload( + path: String, + headers: inout [String: String], + params: inout [String: Any?], + paramName: String, + convert: (([String: Any]) -> T)? = nil, + onProgress: ((Double) -> Void)? = nil, + completion: ((Result) -> Void)? = nil + ) { + let file = params[paramName] as! File + let size = file.buffer.readableBytes + + if size < Client.chunkSize { + call( + method: "POST", + path: path, + headers: headers, + params: params, + convert: convert, + completion: completion + ) + return + } + + var input = file.buffer + var offset = 0 + var result = [String:Any]() + let group = DispatchGroup() + + while offset < size { + let slice = input.readSlice(length: Client.chunkSize) + ?? input.readSlice(length: size - offset) + + params[paramName] = File( + name: file.name, + buffer: slice! + ) + + headers["content-range"] = "bytes \(offset)-\(min((offset + Client.chunkSize) - 1, size))/\(size)" + + group.enter() + + call( + method: "POST", + path: path, + headers: headers, + params: params, + convert: { return $0 } + ) { response in + switch response { + case let .success(map): + result = map + group.leave() + case let .failure(error): + completion?(.failure(error)) + return + } + } + + group.wait() + + offset += Client.chunkSize + headers["x-{{ spec.title | caseLower }}-id"] = result["$id"] as? String + onProgress?(Double(min(offset, size))/Double(size) * 100.0) + } + + completion?(.success(convert!(result))) + } + + + private static func randomBoundary() -> String { var string = "" for _ in 0..<16 { string.append(Client.boundaryChars.randomElement()!) @@ -382,11 +463,12 @@ open class Client { private func buildMultipart( _ request: inout HTTPClient.Request, - with params: [String: Any?] = [:] + with params: [String: Any?] = [:], + chunked: Bool = false ) { func addPart(name: String, value: Any) { bodyBuffer.writeString(DASHDASH) - bodyBuffer.writeString(boundary) + bodyBuffer.writeString(Client.boundary) bodyBuffer.writeString(CRLF) bodyBuffer.writeString("Content-Disposition: form-data; name=\"\(name)\"") @@ -408,7 +490,6 @@ open class Client { bodyBuffer.writeString(CRLF) } - let boundary = randomBoundary() var bodyBuffer = ByteBuffer() for (key, value) in params { @@ -427,13 +508,15 @@ open class Client { } bodyBuffer.writeString(DASHDASH) - bodyBuffer.writeString(boundary) + bodyBuffer.writeString(Client.boundary) bodyBuffer.writeString(DASHDASH) bodyBuffer.writeString(CRLF) request.headers.remove(name: "content-type") + if !chunked { request.headers.add(name: "Content-Length", value: bodyBuffer.readableBytes.description) - request.headers.add(name: "Content-Type", value: "multipart/form-data;boundary=\"\(boundary)\"") + } + request.headers.add(name: "Content-Type", value: "multipart/form-data;boundary=\"\(Client.boundary)\"") request.body = .byteBuffer(bodyBuffer) } diff --git a/templates/swift/Sources/Services/Service.swift.twig b/templates/swift/Sources/Services/Service.swift.twig index fd92b28cd..7be93d35d 100644 --- a/templates/swift/Sources/Services/Service.swift.twig +++ b/templates/swift/Sources/Services/Service.swift.twig @@ -31,6 +31,9 @@ open class {{ service.name | caseUcfirst }}: Service { {% for parameter in method.parameters.all %} {{ parameter.name | caseCamel | escapeKeyword }}: {{ parameter.type | typeName | raw }}{% if not parameter.required %}? = nil{% endif %}, {% endfor %} +{% if 'multipart/form-data' in method.consumes %} + onProgress: ((Double) -> Void)? = nil, +{% endif %} completion: ((Result<{{ _self.resultType(spec, method) }}, {{ spec.title | caseUcfirst}}Error>) -> Void)? = nil ) { {% if method.parameters.path %} var{% else %} let{% endif %} path: String = "{{ method.path }}" @@ -39,11 +42,12 @@ open class {{ service.name | caseUcfirst }}: Service { path = path.replacingOccurrences( of: "{{ '{' }}{{ parameter.name | caseCamel }}{{ '}' }}", with: {% if method.parameters.path %}{{ parameter.name | caseCamel | escapeKeyword }}{% else %}""{% endif %} + ) {% endfor %} {% if method.parameters.query or method.parameters.body or method.parameters.formData or _self.methodNeedsSecurityParameters(method) %} - let params: [String: Any?] = [ + {% if 'multipart/form-data' in method.consumes %}var{% else %}let{% endif %} params: [String: Any?] = [ {% else %} let params: [String: Any?] = [:] {% endif %} @@ -82,7 +86,7 @@ open class {{ service.name | caseUcfirst }}: Service { completion: completion ) {% else %} - let headers: [String: String] = [ + {% if 'multipart/form-data' in method.consumes %}var{% else %}let{% endif %} headers: [String: String] = [ {{ method.headers|map((header, key) => " \"#{key}\": \"#{header}\"")|join(',\n')|raw }} ] @@ -96,6 +100,25 @@ open class {{ service.name | caseUcfirst }}: Service { } {% endif %} +{% if 'multipart/form-data' in method.consumes %} +{% for parameter in method.parameters.all %} +{% if parameter.type == 'file' %} + let paramName = "{{ parameter.name }}" + +{% endif %} +{% endfor %} + client.chunkedUpload( + path: path, + headers: &headers, + params: ¶ms, + paramName: paramName, +{% if method.responseModel %} + convert: convert, +{% endif %} + onProgress: onProgress, + completion: completion + ) +{% else %} client.call( method: "{{ method.method | caseUpper }}", path: path, @@ -106,6 +129,7 @@ open class {{ service.name | caseUcfirst }}: Service { {% endif %} completion: completion ) +{% endif %} {% endif %} } diff --git a/tests/SDKTest.php b/tests/SDKTest.php index 4a47cf7ec..1d20f3f7b 100644 --- a/tests/SDKTest.php +++ b/tests/SDKTest.php @@ -163,12 +163,13 @@ class SDKTest extends TestCase 'cp tests/languages/swift-server/Tests.swift tests/sdks/swift-server/Tests/AppwriteTests/Tests.swift', ], 'envs' => [ - 'swift-5.5' => 'docker run --rm -v $(pwd):/app -w /app/tests/sdks/swift-server swift:5.5 swift test', + 'swift-5.5' => 'docker run --rm -v $(pwd):/app -w /app/tests/sdks/swift-server swiftarm/swift:5.5.2-focal-multi-arch swift test', ], 'expectedOutput' => [ ...FOO_RESPONSES, ...BAR_RESPONSES, ...GENERAL_RESPONSES, + 'POST:/v1/mock/tests/general/upload:passed', // large file upload ...EXCEPTION_RESPONSES, ], ], @@ -180,12 +181,13 @@ class SDKTest extends TestCase 'cp tests/languages/swift-client/Tests.swift tests/sdks/swift-client/Tests/AppwriteTests/Tests.swift', ], 'envs' => [ - 'swift-5.5' => 'docker run --rm -v $(pwd):/app -w /app/tests/sdks/swift-client swift:5.5 swift test', + 'swift-5.5' => 'docker run --rm -v $(pwd):/app -w /app/tests/sdks/swift-client swiftarm/swift:5.5.2-focal-multi-arch swift test', ], 'expectedOutput' => [ ...FOO_RESPONSES, ...BAR_RESPONSES, ...GENERAL_RESPONSES, + 'POST:/v1/mock/tests/general/upload:passed', // large file upload ...EXCEPTION_RESPONSES, ...REALTIME_RESPONSES, ], diff --git a/tests/languages/swift-client/Tests.swift b/tests/languages/swift-client/Tests.swift index b3e92803c..578a5d4e1 100644 --- a/tests/languages/swift-client/Tests.swift +++ b/tests/languages/swift-client/Tests.swift @@ -33,18 +33,17 @@ class Tests: XCTestCase { var realtimeResponse = "Realtime failed!" let expectation = XCTestExpectation(description: "realtime server") - realtime.subscribe(channels: ["tests"]) { message in + _ = realtime.subscribe(channels: ["tests"]) { message in realtimeResponse = message.payload!["response"] as! String expectation.fulfill() } - // Foo Tests group.enter() foo.get(x: "string", y: 123, z: ["string in array"]) { result in switch result { - case .failure(let error): print( error.message) - case .success(let mock): print( mock.result) + case .failure(let error): print(error.message) + case .success(let mock): print(mock.result) } group.leave() } @@ -52,8 +51,8 @@ class Tests: XCTestCase { group.enter() foo.post(x: "string", y: 123, z: ["string in array"]) { result in switch result { - case .failure(let error): print( error.message) - case .success(let mock): print( mock.result) + case .failure(let error): print(error.message) + case .success(let mock): print(mock.result) } group.leave() } @@ -61,8 +60,8 @@ class Tests: XCTestCase { group.enter() foo.put(x: "string", y: 123, z: ["string in array"]) { result in switch result { - case .failure(let error): print( error.message) - case .success(let mock): print( mock.result) + case .failure(let error): print(error.message) + case .success(let mock): print(mock.result) } group.leave() } @@ -70,8 +69,8 @@ class Tests: XCTestCase { group.enter() foo.patch(x: "string", y: 123, z: ["string in array"]) { result in switch result { - case .failure(let error): print( error.message) - case .success(let mock): print( mock.result) + case .failure(let error): print(error.message) + case .success(let mock): print(mock.result) } group.leave() } @@ -79,8 +78,8 @@ class Tests: XCTestCase { group.enter() foo.delete(x: "string", y: 123, z: ["string in array"]) { result in switch result { - case .failure(let error): print( error.message) - case .success(let mock): print( mock.result) + case .failure(let error): print(error.message) + case .success(let mock): print(mock.result) } group.leave() } @@ -90,8 +89,8 @@ class Tests: XCTestCase { group.enter() bar.get(xrequired: "string", xdefault: 123, z: ["string in array"]) { result in switch result { - case .failure(let error): print( error.message) - case .success(let mock): print( mock.result) + case .failure(let error): print(error.message) + case .success(let mock): print(mock.result) } group.leave() } @@ -99,8 +98,8 @@ class Tests: XCTestCase { group.enter() bar.post(xrequired: "string", xdefault: 123, z: ["string in array"]) { result in switch result { - case .failure(let error): print( error.message) - case .success(let mock): print( mock.result) + case .failure(let error): print(error.message) + case .success(let mock): print(mock.result) } group.leave() } @@ -108,8 +107,8 @@ class Tests: XCTestCase { group.enter() bar.put(xrequired: "string", xdefault: 123, z: ["string in array"]) { result in switch result { - case .failure(let error): print( error.message) - case .success(let mock): print( mock.result) + case .failure(let error): print(error.message) + case .success(let mock): print(mock.result) } group.leave() } @@ -117,8 +116,8 @@ class Tests: XCTestCase { group.enter() bar.patch(xrequired: "string", xdefault: 123, z: ["string in array"]) { result in switch result { - case .failure(let error): print( error.message) - case .success(let mock): print( mock.result) + case .failure(let error): print(error.message) + case .success(let mock): print(mock.result) } group.leave() } @@ -126,8 +125,8 @@ class Tests: XCTestCase { group.enter() bar.delete(xrequired: "string", xdefault: 123, z: ["string in array"]) { result in switch result { - case .failure(let error): print( error.message) - case .success(let mock): print( mock.result) + case .failure(let error): print(error.message) + case .success(let mock): print(mock.result) } group.leave() } @@ -137,31 +136,41 @@ class Tests: XCTestCase { group.enter() general.redirect() { result in switch result { - case .failure(let error): print( error.message) - case .success(let mock): print( (mock as! [String: Any])["result"] as! String) + case .failure(let error): print(error.message) + case .success(let mock): print((mock as! [String: Any])["result"] as! String) } group.leave() } group.wait() group.enter() - - let url = URL(fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/../../resources/file.png") - let buffer = ByteBuffer(data: try! Data(contentsOf: url)) - let file = File(name: "file.png", buffer: buffer) - general.upload(x: "string", y: 123, z: ["string in array"], file: file) { result in + var url = URL(fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/../../resources/file.png") + var buffer = ByteBuffer(data: try! Data(contentsOf: url)) + var file = File(name: "file.png", buffer: buffer) + general.upload(x: "string", y: 123, z: ["string in array"], file: file, onProgress: nil) { result in switch result { - case .failure(let error): print( error.message) - case .success(let mock): print( mock.result) + case .failure(let error): print(error.message) + case .success(let mock): print(mock.result) + } + group.leave() + } + group.wait() + group.enter() + url = URL(fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/../../resources/large_file.mp4") + buffer = ByteBuffer(data: try! Data(contentsOf: url)) + file = File(name: "large_file.mp4", buffer: buffer) + general.upload(x: "string", y: 123, z: ["string in array"], file: file, onProgress: nil) { result in + switch result { + case .failure(let error): print(error.message) + case .success(let mock): print(mock.result) } group.leave() } group.wait() - group.enter() general.error400() { result in switch result { - case .failure(let error): print( error.message) - case .success(let error): print( error.message) + case .failure(let error): print(error.message) + case .success(let error): print(error.message) } group.leave() } @@ -169,8 +178,8 @@ class Tests: XCTestCase { group.enter() general.error500() { result in switch result { - case .failure(let error): print( error.message) - case .success(let error): print( error.message) + case .failure(let error): print(error.message) + case .success(let error): print(error.message) } group.leave() } @@ -178,8 +187,8 @@ class Tests: XCTestCase { group.enter() general.error502() { result in switch result { - case .failure(let error): print( error.message) - case .success(let error): print( (error as! Error).message) + case .failure(let error): print(error.message) + case .success(let error): print((error as! Error).message) } group.leave() } @@ -188,5 +197,4 @@ class Tests: XCTestCase { wait(for: [expectation], timeout: 10.0) print( realtimeResponse) } - } diff --git a/tests/languages/swift-server/Tests.swift b/tests/languages/swift-server/Tests.swift index 1a395b1ff..b81971ae0 100644 --- a/tests/languages/swift-server/Tests.swift +++ b/tests/languages/swift-server/Tests.swift @@ -21,7 +21,6 @@ class Tests: XCTestCase { let group = DispatchGroup() let client = Client() - .setEndpointRealtime("wss://demo.appwrite.io/v1") .setProject("console") .addHeader(key: "Origin", value: "http://localhost") .setSelfSigned() @@ -135,11 +134,22 @@ class Tests: XCTestCase { } group.wait() group.enter() - - let url = URL(fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/../../resources/file.png") - let buffer = ByteBuffer(data: try! Data(contentsOf: url)) - let file = File(name: "file.png", buffer: buffer) - general.upload(x: "string", y: 123, z: ["string in array"], file: file) { result in + var url = URL(fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/../../resources/file.png") + var buffer = ByteBuffer(data: try! Data(contentsOf: url)) + var file = File(name: "file.png", buffer: buffer) + general.upload(x: "string", y: 123, z: ["string in array"], file: file, onProgress: nil) { result in + switch result { + case .failure(let error): print(error.message) + case .success(let mock): print(mock.result) + } + group.leave() + } + group.wait() + group.enter() + url = URL(fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/../../resources/large_file.mp4") + buffer = ByteBuffer(data: try! Data(contentsOf: url)) + file = File(name: "large_file.mp4", buffer: buffer) + general.upload(x: "string", y: 123, z: ["string in array"], file: file, onProgress: nil) { result in switch result { case .failure(let error): print(error.message) case .success(let mock): print(mock.result) @@ -147,7 +157,6 @@ class Tests: XCTestCase { group.leave() } group.wait() - group.enter() general.error400() { result in switch result { diff --git a/tests/resources/large_file.mp4 b/tests/resources/large_file.mp4 new file mode 100644 index 000000000..5a275ab90 Binary files /dev/null and b/tests/resources/large_file.mp4 differ