diff --git a/Package.resolved b/Package.resolved index 5521264..b1224e8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/vapor/jwt-kit", "state": { "branch": null, - "revision": "5f9c44d4c196cc06c3fc601f279169f121d6f62d", - "version": "4.4.0" + "revision": "ca2edf69a613f3d89354114f56cfd9921d2727d5", + "version": "4.2.6" } }, { @@ -15,8 +15,8 @@ "repositoryURL": "https://github.com/apple/swift-argument-parser", "state": { "branch": null, - "revision": "e394bf350e38cb100b6bc4172834770ede1b7232", - "version": "1.0.3" + "revision": "d2930e8fcf9c33162b9fcc1d522bc975e2d4179b", + "version": "1.0.1" } }, { @@ -24,17 +24,8 @@ "repositoryURL": "https://github.com/apple/swift-crypto.git", "state": { "branch": null, - "revision": "a8911e0fadc25aef1071d582355bd1037a176060", - "version": "2.0.4" - } - }, - { - "package": "swift-system", - "repositoryURL": "https://github.com/apple/swift-system.git", - "state": { - "branch": null, - "revision": "836bc4557b74fe6d2660218d56e3ce96aff76574", - "version": "1.1.1" + "revision": "127d3745c37b5705e4bc8d16c7951c48dcc3332c", + "version": "2.0.0" } }, { @@ -42,8 +33,8 @@ "repositoryURL": "https://github.com/apple/swift-tools-support-core", "state": { "branch": null, - "revision": "b7667f3e266af621e5cc9c77e74cacd8e8c00cb4", - "version": "0.2.5" + "revision": "f9bbd6b80d67408021576adf6247e17c2e957d92", + "version": "0.2.4" } } ] diff --git a/Sources/GCP_Remote/Extensions/URLSessionExtensions.swift b/Sources/GCP_Remote/Extensions/URLSessionExtensions.swift index 44c2116..f317515 100644 --- a/Sources/GCP_Remote/Extensions/URLSessionExtensions.swift +++ b/Sources/GCP_Remote/Extensions/URLSessionExtensions.swift @@ -43,33 +43,55 @@ extension URLSession { // to support lower deployment target we need following extensions @available(macOS, deprecated: 12.0, message: "Use the built-in API instead") extension URLSession { + @discardableResult func data(request: URLRequest) async throws -> (Data, URLResponse) { try await withUnsafeThrowingContinuation { continuation in - let task = self.dataTask(with: request) { data, response, error in - guard let data = data, let response = response else { - let error = error ?? URLError(.badServerResponse) - return continuation.resume(throwing: error) - } - - if let httpResponse = (response as? HTTPURLResponse), - (200...299).contains(httpResponse.statusCode) { - continuation.resume(returning: (data, response)) - } else { - continuation.resume( - throwing: - URLError( - .cannotParseResponse, - userInfo: [:] - ) - ) - } - } + let task = self.dataTask( + with: request, + completionHandler: Self.taskCompletion(continuation: continuation) + ) task.resume() } } + @discardableResult func data(url: URL) async throws -> (Data, URLResponse) { try await data(request: URLRequest(url: url)) } + + @discardableResult + func upload(request: URLRequest, fromFile file: URL) async throws -> (Data, URLResponse) { + try await withUnsafeThrowingContinuation { continuation in + let task = self.uploadTask( + with: request, + fromFile: file, + completionHandler: Self.taskCompletion(continuation: continuation) + ) + + task.resume() + } + } + + private static func taskCompletion(continuation: UnsafeContinuation<(Data, URLResponse), Error>) -> (Data?, URLResponse?, Error?) -> () { + { data, response, error in + guard let data = data, let response = response else { + let error = error ?? URLError(.badServerResponse) + return continuation.resume(throwing: error) + } + + if let httpResponse = (response as? HTTPURLResponse), + (200...299).contains(httpResponse.statusCode) { + continuation.resume(returning: (data, response)) + } else { + continuation.resume( + throwing: + URLError( + .cannotParseResponse, + userInfo: [:] + ) + ) + } + } + } } diff --git a/Sources/GCP_Remote/Model/Metadata.swift b/Sources/GCP_Remote/Model/Metadata.swift new file mode 100644 index 0000000..96f8234 --- /dev/null +++ b/Sources/GCP_Remote/Model/Metadata.swift @@ -0,0 +1,7 @@ +import Foundation + +public struct Metadata: Codable { + public let crc32c: String + public let etag: String + public let md5Hash: String +} diff --git a/Sources/GCP_Remote/Services/GCPAPIService.swift b/Sources/GCP_Remote/Services/GCPAPIService.swift index 711ad92..a6d8e0e 100644 --- a/Sources/GCP_Remote/Services/GCPAPIService.swift +++ b/Sources/GCP_Remote/Services/GCPAPIService.swift @@ -7,17 +7,23 @@ public protocol GCPAPIServicing { token: AccessToken ) async throws -> Data - func uploadData( - _ data: Data, + func upload( + file: URL, object: String, bucket: String, token: AccessToken ) async throws + + func metadata( + object: String, + bucket: String, + token: AccessToken + ) async throws -> Metadata } public final class GCPAPIService: GCPAPIServicing { private enum Action: String { - case download, upload + case download, upload, get = "" } private let session: URLSession @@ -54,8 +60,8 @@ public final class GCPAPIService: GCPAPIServicing { return try await session.data(request: request).0 } - public func uploadData( - _ data: Data, + public func upload( + file: URL, object: String, bucket: String, token: AccessToken @@ -73,9 +79,26 @@ public final class GCPAPIService: GCPAPIServicing { token.addToRequest(&request) request.setValue("application/zip", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" - request.httpBody = data - _ = try await session.data(request: request) + try await session.upload(request: request, fromFile: file) + } + + public func metadata( + object: String, + bucket: String, + token: AccessToken + ) async throws -> Metadata { + var request = URLRequest(url: url( + action: .get, + bucket: bucket, + object: object + )) + token.addToRequest(&request) + request.httpMethod = "GET" + return try await JSONDecoder().decode( + Metadata.self, + from: session.data(request: request).0 + ) } // MARK: - Private helpers @@ -91,7 +114,9 @@ public final class GCPAPIService: GCPAPIServicing { "storage/v1/b", bucket, "o", - object, - ].compactMap { $0 }.joined(separator: "/"))! + object?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), + ].compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: "/"))! } } diff --git a/Sources/GCP_Remote/Services/GCPUploader.swift b/Sources/GCP_Remote/Services/GCPUploader.swift index e94603a..8d3f43a 100644 --- a/Sources/GCP_Remote/Services/GCPUploader.swift +++ b/Sources/GCP_Remote/Services/GCPUploader.swift @@ -1,6 +1,7 @@ import Foundation import TSCBasic import Logger +import CryptoKit public typealias UploadItem = (localFile: AbsolutePath, remotePath: String) @@ -48,22 +49,26 @@ public struct GCPUploader: GCPUploading { logger.info("Uploading dependency", name) - var urlComponents = URLComponents(string: "https://storage.googleapis.com/upload/storage/v1/b/" + config.bucket + "/o")! - urlComponents.queryItems = [ - .init(name: "uploadType", value: "media"), - .init(name: "name", value: remotePath), - ] - - var request = URLRequest(url: urlComponents.url!) - token.addToRequest(&request) - request.setValue("application/zip", forHTTPHeaderField: "Content-Type") - request.httpMethod = "POST" - do { - request.httpBody = try Data(contentsOf: localPath.asURL) + let existingMetadata = try? await gcpAPI.metadata( + object: remotePath, + bucket: config.bucket, + token: token + ) + + if let currentMD5 = existingMetadata?.md5Hash, + let data = try? Data(contentsOf: localPath.asURL) { + let md5 = Data(Insecure.MD5.hash(data: data)) + .base64EncodedString() + + if currentMD5 == md5 { + logger.info("Dependency " + name + " has not changed, skipping upload") + return + } + } - try await gcpAPI.uploadData( - try Data(contentsOf: localPath.asURL), + try await gcpAPI.upload( + file: localPath.asURL, object: remotePath, bucket: config.bucket, token: token @@ -72,6 +77,7 @@ public struct GCPUploader: GCPUploading { } catch { logger.info("Unable to upload dependency", name) logger.error(error.localizedDescription) + throw error } } }