main
swift 156 lines 5.88 KB
Raw
1 import Foundation
2
3 #if canImport(FoundationNetworking)
4 // URLSession lives in FoundationNetworking on non-Apple Swift toolchains.
5 import FoundationNetworking
6 #endif
7
8 /// Thin async HTTP layer shared by ``AuthCoreClient`` and ``OIDC``.
9 ///
10 /// Faithfully reproduces the response handling of the Rust SDK's
11 /// `smbcloud_network::network::{request, request_login}`: 2xx decodes the
12 /// payload, anything else is mapped to a ``SmbCloudError``.
13 struct HTTPTransport: Sendable {
14 let session: URLSession
15
16 init(session: URLSession = .shared) {
17 self.session = session
18 }
19
20 private func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
21 let data: Data
22 let response: URLResponse
23 do {
24 (data, response) = try await session.data(for: request)
25 } catch {
26 throw SmbCloudError(code: .networkError, message: error.localizedDescription)
27 }
28 guard let http = response as? HTTPURLResponse else {
29 throw SmbCloudError(code: .networkError)
30 }
31 return (data, http)
32 }
33
34 /// Decodes a 2xx JSON body as `T`; otherwise throws the parsed API error.
35 func requestJSON<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
36 let (data, http) = try await send(request)
37 guard (200..<300).contains(http.statusCode) else {
38 throw Self.apiError(from: data, statusCode: http.statusCode)
39 }
40 do {
41 return try smbCloudDecoder.decode(T.self, from: data)
42 } catch {
43 throw SmbCloudError(code: .parseError, message: error.localizedDescription)
44 }
45 }
46
47 /// Succeeds on any 2xx; otherwise throws the parsed API error.
48 func requestVoid(_ request: URLRequest) async throws {
49 let (data, http) = try await send(request)
50 guard (200..<300).contains(http.statusCode) else {
51 throw Self.apiError(from: data, statusCode: http.statusCode)
52 }
53 }
54
55 /// Mirrors `request_login`: maps the sign-in response to an ``AccountStatus``.
56 func requestLogin(_ request: URLRequest) async throws -> AccountStatus {
57 let (data, http) = try await send(request)
58
59 switch http.statusCode {
60 case 200:
61 if let auth = http.value(forHTTPHeaderField: "Authorization"), !auth.isEmpty {
62 return .ready(accessToken: auth)
63 }
64 // 200 without a token: parse a status code from the body if present.
65 if let parsed = try? smbCloudDecoder.decode(APIErrorBody.self, from: data) {
66 switch parsed.errorCode {
67 case SmbCloudErrorCode.emailNotVerified.rawValue:
68 return .incomplete(status: .emailUnverified)
69 case SmbCloudErrorCode.passwordNotSet.rawValue:
70 return .incomplete(status: .passwordNotSet)
71 default:
72 return .notFound
73 }
74 }
75 return .notFound
76 case 404:
77 return .notFound
78 case 422:
79 if let parsed = try? smbCloudDecoder.decode(APIErrorBody.self, from: data),
80 let code = parsed.errorCode,
81 let account = AccountErrorCode(rawValue: code)
82 {
83 return .incomplete(status: account)
84 }
85 throw Self.apiError(from: data, statusCode: 422)
86 default:
87 throw Self.apiError(from: data, statusCode: http.statusCode)
88 }
89 }
90
91 /// Builds a ``SmbCloudError`` from a non-2xx response body.
92 static func apiError(from data: Data, statusCode: Int) -> SmbCloudError {
93 if let parsed = try? smbCloudDecoder.decode(APIErrorBody.self, from: data) {
94 let code = parsed.errorCode.flatMap(SmbCloudErrorCode.init(rawValue:))
95 ?? statusError(statusCode)
96 return SmbCloudError(code: code, message: parsed.message ?? code.defaultMessage)
97 }
98 let code = statusError(statusCode)
99 let body = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
100 return SmbCloudError(code: code, message: (body?.isEmpty == false ? body : nil) ?? code.defaultMessage)
101 }
102
103 private static func statusError(_ statusCode: Int) -> SmbCloudErrorCode {
104 switch statusCode {
105 case 401: return .unauthorized
106 case 404: return .emailNotFound
107 case 400, 422: return .invalidParams
108 default: return .networkError
109 }
110 }
111 }
112
113 /// `{ "error_code": <int>, "message": "…" }` — the API's error envelope.
114 private struct APIErrorBody: Decodable {
115 let errorCode: Int?
116 let message: String?
117
118 enum CodingKeys: String, CodingKey {
119 case errorCode = "error_code"
120 case message
121 }
122 }
123
124 /// Shared decoder: snake_case is handled per-type via explicit `CodingKeys`,
125 /// and dates use a tolerant ISO-8601 parser (with/without fractional seconds).
126 let smbCloudDecoder: JSONDecoder = {
127 let decoder = JSONDecoder()
128 decoder.dateDecodingStrategy = .custom { decoder in
129 let container = try decoder.singleValueContainer()
130 let raw = try container.decode(String.self)
131 if let date = SmbCloudDateParser.date(from: raw) {
132 return date
133 }
134 throw DecodingError.dataCorruptedError(
135 in: container, debugDescription: "Unrecognized date format: \(raw)"
136 )
137 }
138 return decoder
139 }()
140
141 enum SmbCloudDateParser {
142 nonisolated(unsafe) private static let withFraction: ISO8601DateFormatter = {
143 let f = ISO8601DateFormatter()
144 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
145 return f
146 }()
147 nonisolated(unsafe) private static let plain: ISO8601DateFormatter = {
148 let f = ISO8601DateFormatter()
149 f.formatOptions = [.withInternetDateTime]
150 return f
151 }()
152
153 static func date(from string: String) -> Date? {
154 withFraction.date(from: string) ?? plain.date(from: string)
155 }
156 }