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}