Repository navigation
Expand file tree
/
Copy pathStorageAPI.swift
More file actions
212 lines (188 loc) · 8.67 KB
/
Copy pathStorageAPI.swift
File metadata and controls
212 lines (188 loc) · 8.67 KB
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
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
import Foundation
import HTTPTypes
import IssueReporting
import Logging
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// Internal implementation detail shared by ``StorageClient``, ``StorageBucket``, and the
/// Vectors trio (``StorageVectorsClient``, ``VectorBucketClient``, ``VectorIndexClient``).
///
/// Holds the ``StorageClient/Configuration``, the ``StorageRequests`` builder for its base URL,
/// and sends requests through the shared `HTTPClient`. Each of the types above holds a
/// ``StorageAPI`` value and delegates to it rather than inheriting from it.
struct StorageAPI: Sendable {
/// The apex domains the storage hostname rewrite applies to, each carrying a leading dot so the
/// match has to land on a hostname-label boundary.
///
/// Without the dot, any host merely *ending* in the apex matches: a caller-owned domain like
/// `not-supabase.co` would be rewritten to `not-storage.supabase.co`, pointing requests at a
/// domain the caller does not control. The bare apex `supabase.co` is excluded for the same
/// reason — it is not a project host.
private static let legacySupabaseHostSuffixes = [".supabase.co", ".supabase.in", ".supabase.red"]
/// Reports whether `host` is a Supabase project host that has not already been pointed at
/// storage.
private static func isLegacySupabaseHost(_ host: String) -> Bool {
!host.contains("storage.supabase.")
&& legacySupabaseHostSuffixes.contains(where: host.hasSuffix)
}
/// `url` with `project-ref.supabase.co` rewritten to `project-ref.storage.supabase.co`; any
/// other host is returned as is.
static func storageHost(_ url: URL) throws -> URL {
guard url.hasSchemeAndHost,
var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let host = components.host, !host.isEmpty
else {
throw StorageError(
kind: .invalidConfiguration,
message: "StorageClient configured with a URL that has no scheme or host: \(url)")
}
guard isLegacySupabaseHost(host) else { return url }
components.host = host.replacingOccurrences(of: ".supabase.", with: ".storage.supabase.")
guard let rewritten = components.url else {
throw StorageError(
kind: .invalidConfiguration,
message: "Rewriting the storage host produced an invalid URL: \(components)")
}
return rewritten
}
private(set) var configuration: StorageClient.Configuration
/// Builds the request for each route under ``configuration``'s URL.
let requests: StorageRequests
/// Sent unless the configured headers set the same name.
static let defaultHeaders: HTTPFields = [.xClientInfo: clientInfo(library: "storage-swift")]
/// Creates a ``StorageAPI`` with the given configuration.
///
/// - Parameter configuration: The configuration that controls the endpoint URL, authentication
/// headers, and transport.
/// - Throws: ``StorageError`` with kind ``StorageError/Kind-swift.struct/invalidConfiguration``
/// if the configuration's URL has no scheme, no host, or cannot be split into components.
init(configuration: StorageClient.Configuration) throws {
var configuration = configuration
configuration.logger = configuration.logger.redactingSecrets()
configuration.logger[metadataKey: "system"] = "storage"
if configuration.usesNewHostname == true {
configuration.url = try Self.storageHost(configuration.url)
}
self.configuration = configuration
self.requests = try StorageRequests(url: configuration.url)
}
/// Returns a copy with `value` merged into ``configuration``'s headers, included in all requests
/// made by the returned instance.
///
/// - Parameters:
/// - name: The header field.
/// - value: The value of the header field.
/// - Returns: The copy with the header set.
func settingHeader(name: HTTPField.Name, value: String) -> Self {
var copy = self
copy.configuration.headers[name] = value
return copy
}
/// Sends `head` with the client's default headers and returns the response body.
@discardableResult
func execute(_ head: HTTPRequest, body: Data? = nil) async throws -> Data {
try await execute(StorageRequest(head: head, body: body.map { HTTPBody($0) }))
}
/// Sends `request` with the client's default headers and returns the response body.
///
/// Only `GET`, `HEAD` and ``StorageRequest/replayable`` requests are retried, and only when
/// ``StorageClient/Configuration/retryEnabled`` is `true`.
@discardableResult
func execute(_ request: StorageRequest) async throws -> Data {
guard let body = try await stream(request).body else { return Data() }
return try await Data(collecting: body, upTo: .max)
}
/// Sends `request` with the client's default headers and returns as soon as a 2xx head
/// arrives, with the body still streaming. A non-2xx response throws its ``StorageError``
/// before returning. A network failure while the body streams throws the same errors.
func stream(_ request: StorageRequest) async throws -> (head: HTTPResponse, body: HTTPBody?) {
var policy = configuration.retryEnabled ? RetryPolicy.default : .disabled
if request.replayable { policy.retryableMethods.insert(request.head.method) }
let retry = RetryRequestInterceptor(policy: policy, clock: configuration.clock)
let http = HTTPClient(
configuration: configuration.http,
retrying: retry,
appending: [LoggerInterceptor(logger: configuration.logger)]
)
var head = request.head
head.headerFields = Self.defaultHeaders
.merging(with: configuration.headers)
.merging(with: head.headerFields)
let response: HTTPResponse
let body: HTTPBody?
do {
(response, body) = try await http.stream(head, body: request.body)
} catch {
throw Self.transportError(error)
}
guard (200..<300).contains(response.status.code) else {
// `HTTPClient` has already buffered and capped this non-2xx body.
let data = if let body { try await Data(collecting: body, upTo: .max) } else { Data() }
throw Self.serverError(response, body: data)
}
return (response, body?.mapError(Self.transportError))
}
/// Relabels the network layer's own failures as `.transport` and a token provider's failure as
/// `.accessToken`. `CancellationError`, and anything else thrown by user code that runs inside
/// the exchange (a custom `ClientTransport` or middleware), pass through as themselves.
private static func transportError(_ error: any Error) -> any Error {
if let failure = error as? AccessTokenFailure {
return StorageError(
kind: .accessToken,
message: AccessTokenFailure.message,
underlyingError: failure.underlyingError)
}
guard let urlError = error as? URLError else { return error }
return StorageError(
kind: .transport, message: urlError.localizedDescription, underlyingError: urlError)
}
/// The ``StorageError`` for a non-2xx response: the decoded JSON body when there is one, the
/// text of a plain-text body (the tus routes answer that way), or the status alone.
static func serverError(_ response: HTTPResponse, body: Data) -> StorageError {
let httpResponse = HTTPErrorResponse(response, body: body)
if let decoded = try? JSONDecoder.storage.decode(StorageError.Body.self, from: body) {
return StorageError(
kind: .server,
message: decoded.serverError.message,
code: decoded.code,
serverError: decoded.serverError,
response: httpResponse
)
}
let status = response.status.code
if response.headerFields[.contentType]?.lowercased().hasPrefix("text/plain") == true,
let text = String(data: body, encoding: .utf8)?.trimmingCharacters(
in: .whitespacesAndNewlines),
!text.isEmpty
{
return StorageError(
kind: .server,
message: text,
serverError: .init(statusCode: status, message: text),
response: httpResponse
)
}
return StorageError(
kind: .server,
message: "Unexpected response with status code \(status).",
response: httpResponse
)
}
}
extension Data {
/// Shadows `Data.decoded(as:decoder:)` from Helpers inside the Storage module so every
/// existing decode call site throws ``StorageError`` with kind `.decoding` instead of a bare
/// `DecodingError`. Same-module declarations win over imported ones with the same signature.
func decoded<T: Decodable>(as _: T.Type = T.self, decoder: JSONDecoder = .storage) throws -> T {
do {
return try decoder.decode(T.self, from: self)
} catch {
throw StorageError(
kind: .decoding,
message: "Failed to decode the Storage response as \(T.self).",
underlyingError: error
)
}
}
}