summaryrefslogtreecommitdiff
path: root/makima/ios/Sources/Makima/Net/APIClient.swift
blob: 1cb01c92b9373a4a31ff89bec239cd26dd587a4b (plain) (blame)
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
import Foundation

/// Async HTTP client for the Makima API.
///
/// Injects the `x-makima-api-key` header on every request (verified in
/// `src/server/auth.rs` — API keys live in a custom header, not `Authorization`).
///
/// Instances are cheap; one per active `ServerProfile`.
final class APIClient {
    let profile: ServerProfile
    let apiKey: String
    let session: URLSession

    init(profile: ServerProfile, apiKey: String, session: URLSession = .shared) {
        self.profile = profile
        self.apiKey = apiKey
        self.session = session
    }

    // MARK: - Public

    func get<T: Decodable>(_ path: String, as _: T.Type = T.self) async throws -> T {
        try await send(method: "GET", path: path, body: nil as Empty?)
    }

    @discardableResult
    func post<B: Encodable, R: Decodable>(_ path: String, body: B) async throws -> R {
        try await send(method: "POST", path: path, body: body)
    }

    @discardableResult
    func post<R: Decodable>(_ path: String) async throws -> R {
        try await send(method: "POST", path: path, body: nil as Empty?)
    }

    @discardableResult
    func delete<R: Decodable>(_ path: String, as _: R.Type = R.self) async throws -> R {
        try await send(method: "DELETE", path: path, body: nil as Empty?)
    }

    /// Make the request without decoding a response body. Use for endpoints
    /// whose response we don't model yet.
    func send(method: String, path: String) async throws {
        let (_, status) = try await rawSend(method: method, path: path, body: nil as Empty?)
        try mapStatus(status, data: Data())
    }

    // MARK: - Internals

    private struct Empty: Encodable {}

    private func send<B: Encodable, R: Decodable>(method: String, path: String, body: B?) async throws -> R {
        let (data, status) = try await rawSend(method: method, path: path, body: body)
        try mapStatus(status, data: data)

        if R.self == EmptyResponse.self {
            return EmptyResponse() as! R
        }
        do {
            let decoder = JSONDecoder()
            decoder.dateDecodingStrategy = .iso8601
            return try decoder.decode(R.self, from: data)
        } catch {
            throw APIError.decoding(String(describing: error))
        }
    }

    private func rawSend<B: Encodable>(method: String, path: String, body: B?) async throws -> (Data, Int) {
        guard let base = profile.apiBaseURL else {
            throw APIError.invalidURL(profile.baseURLString)
        }

        // Compose URL. `path` may start with `/` — normalise.
        let trimmed = path.hasPrefix("/") ? String(path.dropFirst()) : path
        guard let url = URL(string: trimmed, relativeTo: base)?.absoluteURL else {
            throw APIError.invalidURL(trimmed)
        }

        var request = URLRequest(url: url)
        request.httpMethod = method
        request.setValue(apiKey, forHTTPHeaderField: "x-makima-api-key")
        request.setValue("application/json", forHTTPHeaderField: "accept")
        request.setValue("makima-ios/\(Self.appVersion)", forHTTPHeaderField: "user-agent")

        if let body {
            request.setValue("application/json", forHTTPHeaderField: "content-type")
            let encoder = JSONEncoder()
            encoder.dateEncodingStrategy = .iso8601
            do {
                request.httpBody = try encoder.encode(body)
            } catch {
                throw APIError.decoding("encoding request body: \(error)")
            }
        }

        do {
            let (data, response) = try await session.data(for: request)
            guard let http = response as? HTTPURLResponse else {
                throw APIError.invalidResponse
            }
            return (data, http.statusCode)
        } catch let urlErr as URLError where urlErr.code == .cancelled {
            throw APIError.cancelled
        } catch {
            throw APIError.network(String(describing: error))
        }
    }

    private func mapStatus(_ status: Int, data: Data) throws {
        switch status {
        case 200..<300: return
        case 401, 403:  throw APIError.unauthorized
        case 404:       throw APIError.notFound
        default:
            let msg = (try? JSONDecoder().decode(APIErrorBody.self, from: data))?.message
            throw APIError.http(status: status, message: msg)
        }
    }

    static var appVersion: String {
        (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.0"
    }
}

struct EmptyResponse: Decodable {}