forked from swiftlang/swiftly
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathHTTPClient.swift
181 lines (149 loc) · 6.09 KB
/
HTTPClient.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
import _StringProcessing
import AsyncHTTPClient
import Foundation
import NIO
import NIOFoundationCompat
import NIOHTTP1
public protocol HTTPRequestExecutor {
func execute(_ request: HTTPClientRequest, timeout: TimeAmount) async throws -> HTTPClientResponse
}
/// An `HTTPRequestExecutor` backed by an `HTTPClient`.
internal struct HTTPRequestExecutorImpl: HTTPRequestExecutor {
public func execute(_ request: HTTPClientRequest, timeout: TimeAmount) async throws -> HTTPClientResponse {
try await HTTPClient.shared.execute(request, timeout: timeout)
}
}
private func makeRequest(url: String) -> HTTPClientRequest {
var request = HTTPClientRequest(url: url)
request.headers.add(name: "User-Agent", value: "swiftly/\(SwiftlyCore.version)")
return request
}
/// HTTPClient wrapper used for interfacing with various REST APIs and downloading things.
public struct SwiftlyHTTPClient {
private struct Response {
let status: HTTPResponseStatus
let buffer: ByteBuffer
}
private let executor: HTTPRequestExecutor
/// The GitHub authentication token to use for any requests made to the GitHub API.
public var githubToken: String?
public init(executor: HTTPRequestExecutor? = nil) {
self.executor = executor ?? HTTPRequestExecutorImpl()
}
private func get(url: String, headers: [String: String]) async throws -> Response {
var request = makeRequest(url: url)
for (k, v) in headers {
request.headers.add(name: k, value: v)
}
let response = try await self.executor.execute(request, timeout: .seconds(30))
// if defined, the content-length headers announces the size of the body
let expectedBytes = response.headers.first(name: "content-length").flatMap(Int.init) ?? 1024 * 1024
return Response(status: response.status, buffer: try await response.body.collect(upTo: expectedBytes))
}
/// Decode the provided type `T` from the JSON body of the response from a GET request
/// to the given URL.
public func getFromJSON<T: Decodable>(
url: String,
type: T.Type,
headers: [String: String] = [:]
) async throws -> T {
let response = try await self.get(url: url, headers: headers)
guard case .ok = response.status else {
var message = "received status \"\(response.status)\" when reaching \(url)"
let json = String(buffer: response.buffer)
message += ": \(json)"
throw Error(message: message)
}
return try JSONDecoder().decode(type.self, from: response.buffer)
}
/// Return an array of released Swift versions that match the given filter, up to the provided
/// limit (default unlimited).
///
/// TODO: retrieve these directly from swift.org instead of through GitHub.
public func getReleaseToolchains(
limit: Int? = nil,
filter: ((ToolchainVersion.StableRelease) -> Bool)? = nil
) async throws -> [ToolchainVersion.StableRelease] {
let filterMap = { (gh: GitHubTag) -> ToolchainVersion.StableRelease? in
guard let release = try gh.parseStableRelease() else {
return nil
}
if let filter {
guard filter(release) else {
return nil
}
}
return release
}
return try await self.mapGitHubTags(limit: limit, filterMap: filterMap) { page in
try await self.getReleases(page: page)
}
}
/// Return an array of Swift snapshots that match the given filter, up to the provided
/// limit (default unlimited).
///
/// TODO: retrieve these directly from swift.org instead of through GitHub.
public func getSnapshotToolchains(
limit: Int? = nil,
filter: ((ToolchainVersion.Snapshot) -> Bool)? = nil
) async throws -> [ToolchainVersion.Snapshot] {
let filter = { (gh: GitHubTag) -> ToolchainVersion.Snapshot? in
guard let snapshot = try gh.parseSnapshot() else {
return nil
}
if let filter {
guard filter(snapshot) else {
return nil
}
}
return snapshot
}
return try await self.mapGitHubTags(limit: limit, filterMap: filter) { page in
try await self.getTags(page: page)
}
}
public struct DownloadProgress {
public let receivedBytes: Int
public let totalBytes: Int?
}
public struct DownloadNotFoundError: LocalizedError {
public let url: String
}
public func downloadFile(
url: URL,
to destination: URL,
reportProgress: ((DownloadProgress) -> Void)? = nil
) async throws {
let fileHandle = try FileHandle(forWritingTo: destination)
defer {
try? fileHandle.close()
}
let request = makeRequest(url: url.absoluteString)
let response = try await self.executor.execute(request, timeout: .seconds(30))
switch response.status {
case .ok:
break
case .notFound:
throw SwiftlyHTTPClient.DownloadNotFoundError(url: url.path)
default:
throw Error(message: "Received \(response.status) when trying to download \(url)")
}
// if defined, the content-length headers announces the size of the body
let expectedBytes = response.headers.first(name: "content-length").flatMap(Int.init)
var lastUpdate = Date()
var receivedBytes = 0
for try await buffer in response.body {
receivedBytes += buffer.readableBytes
try fileHandle.write(contentsOf: buffer.readableBytesView)
let now = Date()
if let reportProgress, lastUpdate.distance(to: now) > 0.25 || receivedBytes == expectedBytes {
lastUpdate = now
reportProgress(SwiftlyHTTPClient.DownloadProgress(
receivedBytes: receivedBytes,
totalBytes: expectedBytes
))
}
}
try fileHandle.synchronize()
}
}