Parent directory

APIClient.swift

6307 bytes
  1import Foundation
  2import kaiwariShared
  3
  4struct LogEntry: Decodable, Sendable {
  5    let action: String?
  6    let desc: String?
  7    let romaji: String?
  8    let english: String?
  9    let hasSpeech: Bool
 10    let judge: Judge?
 11    let ask: Ask?
 12}
 13
 14struct Judge: Decodable, Sendable {
 15    let score: Int
 16    let romaji: String?
 17    let feedback: String?
 18}
 19
 20struct Ask: Decodable, Sendable {
 21    let romaji: String?
 22    let translation: String?
 23    let breakdown: String?
 24}
 25
 26struct TurnResponse: Decodable, Sendable {
 27    let action: String?
 28    let desc: String?
 29    let romaji: String?
 30    let english: String?
 31    let hasSpeech: Bool
 32    let judge: Judge?
 33    let ask: Ask?
 34    let location: String?
 35    let talk: String?
 36    let audio: String?
 37    let speakError: String?
 38    let judgeError: String?
 39}
 40
 41extension TurnResponse {
 42    var entry: LogEntry {
 43        LogEntry(
 44            action: action,
 45            desc: desc,
 46            romaji: romaji,
 47            english: english,
 48            hasSpeech: hasSpeech,
 49            judge: judge,
 50            ask: ask
 51        )
 52    }
 53}
 54
 55private struct CreateSessionResponse: Decodable, Sendable {
 56    let id: String
 57    let action: String?
 58    let desc: String?
 59    let romaji: String?
 60    let english: String?
 61    let hasSpeech: Bool
 62    let judge: Judge?
 63    let ask: Ask?
 64    let location: String?
 65    let talk: String?
 66    let audio: String?
 67    let speakError: String?
 68    let judgeError: String?
 69
 70    var turn: TurnResponse {
 71        TurnResponse(
 72            action: action,
 73            desc: desc,
 74            romaji: romaji,
 75            english: english,
 76            hasSpeech: hasSpeech,
 77            judge: judge,
 78            ask: ask,
 79            location: location,
 80            talk: talk,
 81            audio: audio,
 82            speakError: speakError,
 83            judgeError: judgeError
 84        )
 85    }
 86}
 87
 88private struct FlashcardResponse: Decodable, Sendable {
 89    let added: Int
 90}
 91
 92private struct ActionRequest: Encodable {
 93    let action: String
 94}
 95
 96private struct ErrorResponse: Decodable, Sendable {
 97    let error: String
 98}
 99
100// The server caps a single turn at three minutes; leave headroom under it.
101private let turnTimeout: TimeInterval = 200
102
103struct APIClient: Sendable {
104    // Starting a session runs the server's opening turn (the area description),
105    // so it takes as long as any other turn.
106    func createSession(using configuration: ServerConfiguration) async throws -> (id: String, turn: TurnResponse) {
107        let response: CreateSessionResponse = try await request(
108            endpoint: configuration.endpoint(path: "v1/sessions"),
109            method: "POST",
110            timeout: turnTimeout,
111            configuration: configuration
112        )
113        return (response.id, response.turn)
114    }
115
116    func fetchLog(sessionID: String, using configuration: ServerConfiguration) async throws -> [LogEntry] {
117        try await request(
118            endpoint: configuration.endpoint(path: "v1/sessions/\(sessionID)/log"),
119            method: "GET",
120            configuration: configuration
121        )
122    }
123
124    func performAction(sessionID: String, action: String, using configuration: ServerConfiguration) async throws -> TurnResponse {
125        try await request(
126            endpoint: configuration.endpoint(path: "v1/sessions/\(sessionID)/actions"),
127            method: "POST",
128            body: try JSONEncoder().encode(ActionRequest(action: action)),
129            contentType: "application/json",
130            timeout: turnTimeout,
131            configuration: configuration
132        )
133    }
134
135    func addFlashcards(sessionID: String, instructions: String, using configuration: ServerConfiguration) async throws -> Int {
136        let response: FlashcardResponse = try await request(
137            endpoint: configuration.endpoint(path: "v1/sessions/\(sessionID)/actions"),
138            method: "POST",
139            body: try JSONEncoder().encode(ActionRequest(action: "!\(instructions)")),
140            contentType: "application/json",
141            timeout: turnTimeout,
142            configuration: configuration
143        )
144        return response.added
145    }
146
147    func speak(sessionID: String, wav: Data, using configuration: ServerConfiguration) async throws -> TurnResponse {
148        try await request(
149            endpoint: configuration.endpoint(path: "v1/sessions/\(sessionID)/speech"),
150            method: "POST",
151            body: wav,
152            contentType: "audio/wav",
153            timeout: turnTimeout,
154            configuration: configuration
155        )
156    }
157
158    private func request<Response: Decodable>(
159        endpoint: URL,
160        method: String,
161        body: Data? = nil,
162        contentType: String? = nil,
163        timeout: TimeInterval? = nil,
164        configuration: ServerConfiguration
165    ) async throws -> Response {
166        var request = URLRequest(url: endpoint)
167        request.httpMethod = method
168        request.setValue("Bearer \(configuration.token)", forHTTPHeaderField: "Authorization")
169        request.setValue("application/json", forHTTPHeaderField: "Accept")
170        if let contentType {
171            request.setValue(contentType, forHTTPHeaderField: "Content-Type")
172        }
173        if let timeout {
174            request.timeoutInterval = timeout
175        }
176        request.httpBody = body
177
178        let (data, response) = try await URLSession.shared.data(for: request)
179
180        guard let httpResponse = response as? HTTPURLResponse else {
181            throw APIError.invalidResponse
182        }
183
184        guard (200...299).contains(httpResponse.statusCode) else {
185            let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: data)
186            throw APIError.server(statusCode: httpResponse.statusCode, message: errorResponse?.error)
187        }
188
189        do {
190            return try JSONDecoder().decode(Response.self, from: data)
191        } catch {
192            throw APIError.invalidResponse
193        }
194    }
195}
196
197enum APIError: LocalizedError {
198    case invalidResponse
199    case server(statusCode: Int, message: String?)
200
201    var errorDescription: String? {
202        switch self {
203        case .invalidResponse:
204            "The server returned an invalid response."
205        case let .server(statusCode, message):
206            message ?? "The server returned HTTP \(statusCode)."
207        }
208    }
209}