Skip to content

Commit

Permalink
🔀 Swift Concurrency (#23)
Browse files Browse the repository at this point in the history
* ⬆️ Update dependencies

* ✨ Prepare new async GCPAPIService

* ♻️ Make download command async

* ♻️ Make upload command async

* ♻️ Make AuthAPI async

* 🐛 Fix async URLSession extensions

* ⬇️ Downgrade ArgumentParser

* 🔧 Require macOS 12

* 🔧 Use Xcode 13.2.1 on CI
  • Loading branch information
olejnjak authored Mar 19, 2022
1 parent 78a5f16 commit 2183371
Show file tree
Hide file tree
Showing 14 changed files with 246 additions and 78 deletions.
2 changes: 1 addition & 1 deletion .github/xcode-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
13.0
13.2.1
25 changes: 17 additions & 8 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,44 @@
"repositoryURL": "https://github.com/vapor/jwt-kit",
"state": {
"branch": null,
"revision": "ca2edf69a613f3d89354114f56cfd9921d2727d5",
"version": "4.2.6"
"revision": "5f9c44d4c196cc06c3fc601f279169f121d6f62d",
"version": "4.4.0"
}
},
{
"package": "swift-argument-parser",
"repositoryURL": "https://github.com/apple/swift-argument-parser",
"state": {
"branch": null,
"revision": "d2930e8fcf9c33162b9fcc1d522bc975e2d4179b",
"version": "1.0.1"
"revision": "e394bf350e38cb100b6bc4172834770ede1b7232",
"version": "1.0.3"
}
},
{
"package": "swift-crypto",
"repositoryURL": "https://github.com/apple/swift-crypto.git",
"state": {
"branch": null,
"revision": "127d3745c37b5705e4bc8d16c7951c48dcc3332c",
"version": "2.0.0"
"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"
}
},
{
"package": "swift-tools-support-core",
"repositoryURL": "https://github.com/apple/swift-tools-support-core",
"state": {
"branch": null,
"revision": "f9bbd6b80d67408021576adf6247e17c2e957d92",
"version": "0.2.4"
"revision": "b7667f3e266af621e5cc9c77e74cacd8e8c00cb4",
"version": "0.2.5"
}
}
]
Expand Down
4 changes: 2 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:5.4
// swift-tools-version:5.5
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription
Expand All @@ -17,7 +17,7 @@ let package = Package(
),
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", .upToNextMajor(from: "1.0.1")),
.package(url: "https://github.com/apple/swift-argument-parser", .upToNextMinor(from: "1.0.1")),
.package(url: "https://github.com/apple/swift-tools-support-core", .upToNextMajor(from: "0.2.0")),
.package(url: "https://github.com/vapor/jwt-kit", .upToNextMajor(from: "4.2.6")),
],
Expand Down
11 changes: 11 additions & 0 deletions Sources/GCP_Remote/Extensions/SequenceExtensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation

extension Sequence {
func asyncForEach(
_ operation: (Element) async throws -> Void
) async rethrows {
for element in self {
try await operation(element)
}
}
}
36 changes: 36 additions & 0 deletions Sources/GCP_Remote/Extensions/URLSessionExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ struct RequestError: Error {
extension URLSession {
static let torino = URLSession(configuration: .ephemeral)

@available(*, deprecated, renamed: "data(request:)")
func syncDataTask(for request: URLRequest) throws -> (Data?, URLResponse?) {
let semaphore = DispatchSemaphore(value: 0)

Expand Down Expand Up @@ -37,3 +38,38 @@ extension URLSession {
throw RequestError(response: resultResponse, data: resultData)
}
}

// Swift Concurrency API is available from macOS 12,
// to support lower deployment target we need following extensions
@available(macOS, deprecated: 12.0, message: "Use the built-in API instead")
extension URLSession {
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: [:]
)
)
}
}

task.resume()
}
}

func data(url: URL) async throws -> (Data, URLResponse) {
try await data(request: URLRequest(url: url))
}
}
9 changes: 6 additions & 3 deletions Sources/GCP_Remote/Services/AuthAPIService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public protocol AuthAPIServicing {
serviceAccount: ServiceAccount,
validFor interval: TimeInterval,
readOnly: Bool
) throws -> AccessToken
) async throws -> AccessToken
}

/// Service that fetches an access token from further communication
Expand All @@ -34,15 +34,18 @@ public struct AuthAPIService: AuthAPIServicing {
serviceAccount: ServiceAccount,
validFor interval: TimeInterval,
readOnly: Bool
) throws -> AccessToken {
) async throws -> AccessToken {
let claims = self.claims(serviceAccount: serviceAccount, validFor: interval, readOnly: readOnly)
let jwt = try self.jwt(for: serviceAccount, claims: claims)
let requestData = AccessTokenRequest(assertion: jwt)
var request = URLRequest(url: claims.aud)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try encoder.encode(requestData)
return try decoder.decode(AccessToken.self, from: try session.syncDataTask(for: request).0 ?? Data())
return try await decoder.decode(
AccessToken.self,
from: session.data(request: request).0
)
}

// MARK: - Private helpers
Expand Down
97 changes: 97 additions & 0 deletions Sources/GCP_Remote/Services/GCPAPIService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import Foundation

public protocol GCPAPIServicing {
func downloadObject(
_ object: String,
bucket: String,
token: AccessToken
) async throws -> Data

func uploadData(
_ data: Data,
object: String,
bucket: String,
token: AccessToken
) async throws
}

public final class GCPAPIService: GCPAPIServicing {
private enum Action: String {
case download, upload
}

private let session: URLSession

// MARK: - Initializers

public convenience init() {
self.init(session: .torino)
}

init(session: URLSession) {
self.session = session
}

// MARK: - Interface

public func downloadObject(
_ object: String,
bucket: String,
token: AccessToken
) async throws -> Data {
var urlComponents = URLComponents(string: url(
action: .download,
bucket: bucket,
object: object).absoluteString)!
urlComponents.queryItems = [
.init(name: "alt", value: "media"),
]

var request = URLRequest(url: urlComponents.url!)
token.addToRequest(&request)
request.httpMethod = "GET"

return try await session.data(request: request).0
}

public func uploadData(
_ data: Data,
object: String,
bucket: String,
token: AccessToken
) async throws {
var urlComponents = URLComponents(string: url(
action: .upload,
bucket: bucket,
object: nil).absoluteString)!
urlComponents.queryItems = [
.init(name: "uploadType", value: "media"),
.init(name: "name", value: object),
]

var request = URLRequest(url: urlComponents.url!)
token.addToRequest(&request)
request.setValue("application/zip", forHTTPHeaderField: "Content-Type")
request.httpMethod = "POST"
request.httpBody = data

_ = try await session.data(request: request)
}

// MARK: - Private helpers

private func url(
action: Action,
bucket: String,
object: String?
) -> URL {
.init(string: [
"https://storage.googleapis.com",
action.rawValue,
"storage/v1/b",
bucket,
"o",
object,
].compactMap { $0 }.joined(separator: "/"))!
}
}
49 changes: 17 additions & 32 deletions Sources/GCP_Remote/Services/GCPDownloader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import Logger
public typealias DownloadItem = (remotePath: String, localFile: AbsolutePath)

public protocol GCPDownloading {
func download(items: [DownloadItem]) throws
func download(items: [DownloadItem]) async throws
}

public struct GCPDownloader: GCPDownloading {
private let authAPI: AuthAPIServicing
private let session: URLSession
private let gcpAPI: GCPAPIServicing
private let fileSystem: FileSystem
private let logger: Logging
private let config: GCPConfig
Expand All @@ -19,69 +19,54 @@ public struct GCPDownloader: GCPDownloading {

public init(
authAPI: AuthAPIServicing = AuthAPIService(),
fileSystem: FileSystem = localFileSystem,
logger: Logging = Logger.shared,
config: GCPConfig
) {
self.init(
authAPI: authAPI,
session: .torino,
fileSystem: fileSystem,
logger: logger,
config: config
)
}

public init(
authAPI: AuthAPIServicing = AuthAPIService(),
session: URLSession,
gcpAPI: GCPAPIServicing = GCPAPIService(),
fileSystem: FileSystem = localFileSystem,
logger: Logging = Logger.shared,
config: GCPConfig
) {
self.authAPI = authAPI
self.session = session
self.gcpAPI = gcpAPI
self.fileSystem = fileSystem
self.logger = logger
self.config = config
}

// MARK: - Public interface

public func download(items: [DownloadItem]) throws {
public func download(items: [DownloadItem]) async throws {
guard items.count > 0 else {
logger.info("Nothing to download")
return
}

let sa = try loadServiceAccount(path: config.serviceAccountPath)
let token = try authAPI.fetchAccessToken(serviceAccount: sa, validFor: 60, readOnly: false)
let token = try await authAPI.fetchAccessToken(
serviceAccount: sa,
validFor: 60,
readOnly: false
)

items.forEach { remotePath, localPath in
await items.asyncForEach { remotePath, localPath in
let name = localPath.basenameWithoutExt

logger.info("Downloading dependency", name)

let object = remotePath.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!
var urlComponents = URLComponents(string: "https://storage.googleapis.com/download/storage/v1/b/" + config.bucket + "/o/" + object)!
urlComponents.queryItems = [
.init(name: "alt", value: "media"),
]

var request = URLRequest(url: urlComponents.url!)
token.addToRequest(&request)
request.httpMethod = "GET"

do {
let data = try session.syncDataTask(for: request).0
let data = try await gcpAPI.downloadObject(
object,
bucket: config.bucket,
token: token
)

try? fileSystem.createDirectory(
localPath.parentDirectory,
recursive: true
)

do {
try data?.write(to: localPath.asURL)
try data.write(to: localPath.asURL)
logger.info("Successfully downloaded dependency", name)
} catch {
logger.error("Unable to write data for dependency", name)
Expand Down
Loading

0 comments on commit 2183371

Please sign in to comment.