From 31e9ac13f9c4fcdabca4a0200d2b329fa6b6ced8 Mon Sep 17 00:00:00 2001 From: Alban Thevret Date: Sun, 27 Apr 2025 16:36:59 +0200 Subject: [PATCH 1/2] Add ExecuteScript feature to launch Google APIs --- Sources/GoogleDriveClient/Client.swift | 13 ++- Sources/GoogleDriveClient/ExecuteScript.swift | 106 ++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 Sources/GoogleDriveClient/ExecuteScript.swift diff --git a/Sources/GoogleDriveClient/Client.swift b/Sources/GoogleDriveClient/Client.swift index a20b3a5..02ad268 100644 --- a/Sources/GoogleDriveClient/Client.swift +++ b/Sources/GoogleDriveClient/Client.swift @@ -9,7 +9,8 @@ public struct Client: Sendable { getFileData: GetFileData, createFile: CreateFile, updateFileData: UpdateFileData, - deleteFile: DeleteFile + deleteFile: DeleteFile, + executeScript: ExecuteScript ) { self.auth = auth self.getAbout = getAbout @@ -19,6 +20,7 @@ public struct Client: Sendable { self.createFile = createFile self.updateFileData = updateFileData self.deleteFile = deleteFile + self.executeScript = executeScript } public var auth: Auth @@ -29,6 +31,7 @@ public struct Client: Sendable { public var createFile: CreateFile public var updateFileData: UpdateFileData public var deleteFile: DeleteFile + public var executeScript: ExecuteScript } extension Client { @@ -84,6 +87,11 @@ extension Client { keychain: keychain, httpClient: httpClient ) + let executeScript = ExecuteScript.live( + auth: auth, + keychain: keychain, + httpClient: httpClient + ) return Client( auth: auth, getAbout: getAbout, @@ -92,7 +100,8 @@ extension Client { getFileData: getFileData, createFile: createFile, updateFileData: updateFileData, - deleteFile: deleteFile + deleteFile: deleteFile, + executeScript: executeScript ) } } diff --git a/Sources/GoogleDriveClient/ExecuteScript.swift b/Sources/GoogleDriveClient/ExecuteScript.swift new file mode 100644 index 0000000..abe4ef5 --- /dev/null +++ b/Sources/GoogleDriveClient/ExecuteScript.swift @@ -0,0 +1,106 @@ +// +// ExecuteScript.swift +// swift-google-drive-client +// +// Created by Alban THEVRET on 25/04/2025. +// + +import Foundation + +public struct ExecuteScript: Sendable { + public struct Params: Sendable, Equatable { + public init( + scriptId: String, + function: String, + args: [String] = [] + ) { + self.scriptId = scriptId + self.function = function + self.args = args + } + + public var scriptId: String + public var function: String + public var args: [String] + } + + public enum Error: Swift.Error, Sendable, Equatable { + case notAuthorized + case response(statusCode: Int?, data: Data) + } + + public typealias Run = @Sendable (Params) async throws -> Data + + public init(run: @escaping Run) { + self.run = run + } + + public var run: Run + + public func callAsFunction(_ params: Params) async throws -> Data { + try await run(params) + } + + public func callAsFunction( + scriptId: String, + function: String, + args: [String] = [] + ) async throws -> Data { + try await run(.init( + scriptId: scriptId, + function: function, + args: args + )) + } +} + +extension ExecuteScript { + public static func live( + auth: Auth, + keychain: Keychain, + httpClient: HTTPClient + ) -> ExecuteScript { + ExecuteScript { params in + try await auth.refreshToken() + + guard let credentials = await keychain.loadCredentials() else { + throw Error.notAuthorized + } + + let request: URLRequest = { + var components = URLComponents() + components.scheme = "https" + components.host = "script.googleapis.com" + components.path = "/v1/scripts/\(params.scriptId):run" + print("Components: \(components)") + + var request = URLRequest(url: components.url!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue( + "\(credentials.tokenType) \(credentials.accessToken)", + forHTTPHeaderField: "Authorization" + ) + print("Authorization: \(credentials.tokenType) \(credentials.accessToken)") + + let requestBody: [String: Any] = [ + "function": params.function, + "parameters": params.args, + "devMode": false + ] + request.httpBody = try? JSONSerialization.data(withJSONObject: requestBody) + + return request + }() + + let (responseData, response) = try await httpClient.data(for: request) + let statusCode = (response as? HTTPURLResponse)?.statusCode + + guard let statusCode, (200..<300).contains(statusCode) else { + throw Error.response(statusCode: statusCode, data: responseData) + } + + return responseData + } + } +} From 1cf1de852fb53f1561d8dd2c951eec5ef5ab748d Mon Sep 17 00:00:00 2001 From: Alban Thevret Date: Mon, 12 May 2025 11:30:13 +0200 Subject: [PATCH 2/2] Fixed comments from "Add ExecuteScript feature to launch Google APIs" MR #26 - removed print statements, - added unit tests, - raised exception for invalid parameters. --- Sources/GoogleDriveClient/ExecuteScript.swift | 139 +++++++- .../ExecuteScriptTests.swift | 321 ++++++++++++++++++ 2 files changed, 451 insertions(+), 9 deletions(-) create mode 100644 Tests/GoogleDriveClientTests/ExecuteScriptTests.swift diff --git a/Sources/GoogleDriveClient/ExecuteScript.swift b/Sources/GoogleDriveClient/ExecuteScript.swift index abe4ef5..a179229 100644 --- a/Sources/GoogleDriveClient/ExecuteScript.swift +++ b/Sources/GoogleDriveClient/ExecuteScript.swift @@ -26,7 +26,69 @@ public struct ExecuteScript: Sendable { public enum Error: Swift.Error, Sendable, Equatable { case notAuthorized - case response(statusCode: Int?, data: Data) + case invalidParams + case serveurError(statusCode: Int?, data: Data) + case invalidResponse + case scriptExecutionError(code: Int?, message: String?, status: String?) + + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.notAuthorized, .notAuthorized), + (.invalidParams, .invalidParams), + (.invalidResponse, .invalidResponse): + return true + case (.serveurError(let lhsSC, let lhsD), .serveurError(let rhsSC, let rhsD)): + return lhsSC == rhsSC && lhsD == rhsD + case (.scriptExecutionError(let lhsC, let lhsM, let lhsS), + .scriptExecutionError(let rhsC, let rhsM, let rhsS)): + return lhsC == rhsC && lhsM == rhsM && lhsS == rhsS + default: + return false + } + } + + var localizedDescription: String { + switch self { + case .notAuthorized: + return "Not authorized" + case .invalidParams: + return "Invalid parameter" + case .serveurError(let statusCode, _): + return "Serveur error (\(statusCode ?? 0))" + case .invalidResponse: + return "Invalid API response" + case .scriptExecutionError(let code, let message, let status): + return "Script execution error: \(message ?? "nil")(\(code ?? 0)), Status: \(status ?? "nil")" + } + } + } + + // Script Error Structure + struct ErrorData: Decodable { + let code: Int + let message: String + let status: String + let details: [ErrorDetail]? + + struct ErrorDetail: Decodable { + let typeUrl: String? + let scriptStackTraceElements: [StackTraceElement]? + let errorMessage: String? + let errorType: String? + + private enum CodingKeys: String, CodingKey { + // swiftlint:disable:previous nesting + case typeUrl = "@type" + case scriptStackTraceElements + case errorMessage + case errorType + } + + struct StackTraceElement: Decodable { + let function: String + let lineNumber: Int + } + } } public typealias Run = @Sendable (Params) async throws -> Data @@ -55,6 +117,38 @@ public struct ExecuteScript: Sendable { } extension ExecuteScript { + /// Execute the request and process returned data + /// Decode and check generic Apps Scrpt (deployed as Executable API) response + /// Successfull response syntax: + /// `{ + /// ` "done": true, + /// ` "response": { + /// ` "result": + /// ` } + /// `} + /// Error response syntax (returned as an scriptExecutionError) : + /// `{ + /// ` "done": false, + /// ` "error": { + /// ` "code": Int, // returned in .scriptExecutionError + /// ` "message": String, // returned in .scriptExecutionError + /// ` "status": String, // returned in .scriptExecutionError + /// ` "details": [ // not processed nor returned + /// ` { + /// ` "@type": String, + /// ` "errorMessage": String, + /// ` "errorType": String + /// ` "scriptStackTraceElements": [ + /// ` { + /// ` "function": String, + /// ` "lineNumber": Int + /// ` } + /// ` ] + /// ` } + /// ` ] + /// ` } + /// `}` + /// - Returns: The json data returned by the Apps Script function. public static func live( auth: Auth, keychain: Keychain, @@ -67,40 +161,67 @@ extension ExecuteScript { throw Error.notAuthorized } - let request: URLRequest = { + // Prepare http request + let request: URLRequest = try { var components = URLComponents() components.scheme = "https" components.host = "script.googleapis.com" components.path = "/v1/scripts/\(params.scriptId):run" - print("Components: \(components)") - var request = URLRequest(url: components.url!) + guard let url = components.url else { + throw Error.invalidParams + } + var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue( "\(credentials.tokenType) \(credentials.accessToken)", forHTTPHeaderField: "Authorization" ) - print("Authorization: \(credentials.tokenType) \(credentials.accessToken)") let requestBody: [String: Any] = [ "function": params.function, "parameters": params.args, "devMode": false ] - request.httpBody = try? JSONSerialization.data(withJSONObject: requestBody) + request.httpBody = try JSONSerialization.data(withJSONObject: requestBody) return request }() + // Execute http request let (responseData, response) = try await httpClient.data(for: request) - let statusCode = (response as? HTTPURLResponse)?.statusCode + // Check http request status code + let statusCode = (response as? HTTPURLResponse)?.statusCode guard let statusCode, (200..<300).contains(statusCode) else { - throw Error.response(statusCode: statusCode, data: responseData) + throw Error.serveurError(statusCode: statusCode, data: responseData) + } + + // Decode and check response + guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any] else { + throw Error.invalidResponse } - return responseData + // Send back function execution error as exception + if let errorDict = json["error"] as? [String: Any] { + let code = errorDict["code"] as? Int + let message = errorDict["message"] as? String + let status = errorDict["status"] as? String + + throw Error.scriptExecutionError( + code: code, + message: message, + status: status + ) + } + + // Send back function result as json extract ("result") + if let responseDict = json["response"] as? [String: Any] { + return try JSONSerialization.data(withJSONObject: responseDict as Any, options: []) + } else { + throw Error.invalidResponse + } } } } diff --git a/Tests/GoogleDriveClientTests/ExecuteScriptTests.swift b/Tests/GoogleDriveClientTests/ExecuteScriptTests.swift new file mode 100644 index 0000000..ada31cd --- /dev/null +++ b/Tests/GoogleDriveClientTests/ExecuteScriptTests.swift @@ -0,0 +1,321 @@ +// +// ExecuteScriptTests.swift +// swift-google-drive-client +// +// Created by Alban THEVRET on 11/05/2025. +// + +import XCTest +@testable import GoogleDriveClient + +final class ExecuteScriptTests: XCTestCase { + + func testExecuteScript() async throws { + let credentials = Credentials( + accessToken: "access-token-1", + expiresAt: Date(), + refreshToken: "refresh-token-1", + tokenType: "token-type-1" + ) + let httpRequests = ActorIsolated<[URLRequest]>([]) + let didRefreshToken = ActorIsolated(0) + let executeScript = ExecuteScript.live( + auth: { + var auth = Auth.unimplemented() + auth.refreshToken = { + await didRefreshToken.withValue { $0 += 1 } + } + return auth + }(), + keychain: { + var keychain = Keychain.unimplemented() + keychain.loadCredentials = { credentials } + return keychain + }(), + httpClient: .init { request in + await httpRequests.withValue { $0.append(request) } + return ( + """ + { + "done": true, + "response": { + "result": "api response data" + } + } + """.data(using: .utf8)!, + HTTPURLResponse( + url: URL(filePath: "/"), + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + } + ) + let params = ExecuteScript.Params( + scriptId: "abcdefghijklmnopqrstuvwxyz0123456789", + function: "apiFunction", + args: ["arg1", "arg2"] + ) + + // Execute Google API function with parameters + let result = try await executeScript(params) + + // Ensure token was refreshed + await didRefreshToken.withValue { + XCTAssertEqual($0, 1) + } + + // Verify the http request encoding + await httpRequests.withValue { + var urlComponents = URLComponents() + urlComponents.scheme = "https" + urlComponents.host = "script.googleapis.com" + urlComponents.path = "/v1/scripts/abcdefghijklmnopqrstuvwxyz0123456789:run" + + var expectedRequest = URLRequest(url: urlComponents.url!) + expectedRequest.httpMethod = "POST" + expectedRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + expectedRequest.allHTTPHeaderFields = [ + "Authorization": "\(credentials.tokenType) \(credentials.accessToken)" + ] + + let requestBody: [String: Any] = [ + "function": params.function, + "parameters": params.args, + "devMode": false + ] + expectedRequest.httpBody = try? JSONSerialization.data(withJSONObject: requestBody) + + XCTAssertEqual($0, [expectedRequest]) + XCTAssertEqual($0.first?.httpBody, expectedRequest.httpBody) + } + + // Verify request result + XCTAssertEqual( + result, "{\"result\":\"api response data\"}".data(using: .utf8)! + ) + } + + // MARK: DEACTIVATED test - No way to generate the invalidParams error +// func testExecuteScriptInvalidParams() async throws { +// let credentials = Credentials( +// accessToken: "access-token-1", +// expiresAt: Date(), +// refreshToken: "refresh-token-1", +// tokenType: "token-type-1" +// ) +// let httpRequests = ActorIsolated<[URLRequest]>([]) +// let didRefreshToken = ActorIsolated(0) +// let executeScript = ExecuteScript.live( +// auth: { +// var auth = Auth.unimplemented() +// auth.refreshToken = { +// await didRefreshToken.withValue { $0 += 1 } +// } +// return auth +// }(), +// keychain: { +// var keychain = Keychain.unimplemented() +// keychain.loadCredentials = { credentials } +// return keychain +// }(), +// httpClient: .init { request in +// await httpRequests.withValue { $0.append(request) } +// return ( +// """ +// { +// "done": true +// "response": "api response data" +// } +// """.data(using: .utf8)!, +// HTTPURLResponse( +// url: URL(filePath: "/"), +// statusCode: 200, +// httpVersion: nil, +// headerFields: nil +// )! +// ) +// } +// ) +// let params = ExecuteScript.Params( +// scriptId: "\u{000}#fragment%GG", +// function: "apiFunction", +// args: ["arg1", "arg2"] +// ) +// +// // Execute Google API function with parameters +// do { +// _ = try await executeScript(params) +// +// // Ensure an exception was raised +// XCTFail("executeScript should have raised an error") +// } catch let error as ExecuteScript.Error { +// // Ensure invalidParams was raised +// XCTAssertEqual(error, ExecuteScript.Error.invalidParams) +// } catch { +// XCTFail("Unexpected error: \(error)") +// } +// } + + func testExecuteScriptServerError() async throws { + let executeScript = ExecuteScript.live( + auth: { + var auth = Auth.unimplemented() + auth.refreshToken = {} + return auth + }(), + keychain: { + var keychain = Keychain.unimplemented() + keychain.loadCredentials = { + Credentials( + accessToken: "", + expiresAt: Date(), + refreshToken: "", + tokenType: "" + ) + } + return keychain + }(), + httpClient: .init { request in + ( + "Error!!!".data(using: .utf8)!, + HTTPURLResponse( + url: URL(filePath: "/"), + statusCode: 500, + httpVersion: nil, + headerFields: nil + )! + ) + } + ) + let params = ExecuteScript.Params( + scriptId: "abcdefghijklmnopqrstuvwxyz0123456789", + function: "apiFunction", + args: ["arg1", "arg2"] + ) + + do { + // Execute Google API function with parameters + let result = try await executeScript(params) + XCTFail("Expected to throw error, got result: \(result)") + } catch let error as ExecuteScript.Error { + // Ensure invalidParams was raised + XCTAssertEqual( + error, + ExecuteScript.Error.serveurError( + statusCode: 500, + data: "Error!!!".data(using: .utf8)! + ), + "Unexpected ExecuteScript error: \(error)" + ) + } catch { + XCTFail("Unexpected error: \(error)") + } + + } + + func testExecuteScriptExecutionError() async throws { + let credentials = Credentials( + accessToken: "access-token-1", + expiresAt: Date(), + refreshToken: "refresh-token-1", + tokenType: "token-type-1" + ) + let httpRequests = ActorIsolated<[URLRequest]>([]) + let didRefreshToken = ActorIsolated(0) + let executeScript = ExecuteScript.live( + auth: { + var auth = Auth.unimplemented() + auth.refreshToken = { + await didRefreshToken.withValue { $0 += 1 } + } + return auth + }(), + keychain: { + var keychain = Keychain.unimplemented() + keychain.loadCredentials = { credentials } + return keychain + }(), + httpClient: .init { request in + await httpRequests.withValue { $0.append(request) } + return ( + """ + { + "done": true, + "error": { + "code": 123, + "message": "Error Message", + "status": "Status", + "details": [] + } + } + """.data(using: .utf8)!, + HTTPURLResponse( + url: URL(filePath: "/"), + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + } + ) + let params = ExecuteScript.Params( + scriptId: "abcdefghijklmnopqrstuvwxyz0123456789", + function: "apiFunction", + args: ["arg1", "arg2"] + ) + + do { + // Execute Google API function with parameters + let result = try await executeScript(params) + XCTFail("Expected to throw error, got result: \(result)") + } catch let error as ExecuteScript.Error { + // Ensure invalidParams was raised + XCTAssertEqual( + error, + ExecuteScript.Error.scriptExecutionError( + code: 123, + message: "Error Message", + status: "Status"), + "Unexpected ExecuteScript error: \(error)" + ) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testExecuteScriptWhenNotAuthorized() async throws { + let executeScript = ExecuteScript.live( + auth: { + var auth = Auth.unimplemented() + auth.refreshToken = {} + return auth + }(), + keychain: { + var keychain = Keychain.unimplemented() + keychain.loadCredentials = { nil } + return keychain + }(), + httpClient: .unimplemented() + ) + let params = ExecuteScript.Params( + scriptId: "abcdefghijklmnopqrstuvwxyz0123456789", + function: "apiFunction", + args: ["arg1", "arg2"] + ) + + // Execute Google API function with parameters + do { + _ = try await executeScript(params) + + // Ensure an exception was raised + XCTFail("executeScript should have raised an error") + } catch let error as ExecuteScript.Error { + // Ensure invalidParams was raised + XCTAssertEqual(error, ExecuteScript.Error.notAuthorized) + } catch { + XCTFail("Unexpected error: \(error)") + } + } +}