Files
kooltab/lib/services/kooltap_api_client.dart
T

213 lines
6.1 KiB
Dart

import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/remote_command.dart';
import '../models/venue.dart';
import '../utils/app_config.dart';
class ApiException implements Exception {
final int statusCode;
/// Machine-readable reason from the server, e.g. `invalid_registration_code`.
final String? code;
final String message;
const ApiException(this.statusCode, this.message, {this.code});
@override
String toString() =>
'HTTP $statusCode${code == null ? '' : ' ($code)'}: $message';
}
/// The device token was revoked or is invalid.
class UnauthorizedException extends ApiException {
const UnauthorizedException({super.code, String? message})
: super(401, message ?? 'Unauthorized');
}
class RateLimitedException extends ApiException {
final Duration retryAfter;
const RateLimitedException(this.retryAfter) : super(429, 'Rate limited');
}
class DeviceCredentials {
final String deviceId;
final String token;
const DeviceCredentials({required this.deviceId, required this.token});
}
class KoolTapApiClient {
final http.Client _client;
final String baseUrl;
KoolTapApiClient({http.Client? client, this.baseUrl = kRemoteServerUrl})
: _client = client ?? http.Client();
static const _timeout = Duration(seconds: 15);
static const _defaultRetryAfter = Duration(seconds: 60);
Future<List<Venue>> getVenues() async {
final response = await _client
.get(Uri.parse('$baseUrl/venues/'))
.timeout(_timeout);
final data = _decode(response) as List<dynamic>;
return [
for (final venue in data) Venue.fromJson(venue as Map<String, dynamic>),
];
}
Future<DeviceCredentials> registerDevice({
required String venueId,
required String deviceName,
required String registrationCode,
}) async {
final data =
await _post('/devices/register', {
'venueId': venueId,
'name': deviceName,
'registrationCode': registrationCode,
})
as Map<String, dynamic>;
return DeviceCredentials(
deviceId: data['deviceId'] as String,
token: data['token'] as String,
);
}
Future<SyncResponse> sync({
required String token,
required SyncRequest request,
}) async {
final data = await _post(
'/devices/${request.deviceId}/sync',
request.toJson(),
token: token,
);
return SyncResponse.fromJson(data as Map<String, dynamic>? ?? {});
}
Future<void> ackCommand({
required String token,
required String deviceId,
required CommandAck ack,
}) async {
await _post(
'/devices/$deviceId/commands/${ack.commandId}/ack',
ack.toJson(),
token: token,
);
}
/// Uploads a picture taken on this tablet; the server crops and stores it
/// and returns its id, which a `product_updated` event then links.
Future<String> uploadImage({
required String token,
required String deviceId,
required List<int> bytes,
required String filename,
}) async {
final request =
http.MultipartRequest(
'POST',
Uri.parse('$baseUrl/devices/$deviceId/images'),
)
..headers['Authorization'] = 'Bearer $token'
..files.add(
http.MultipartFile.fromBytes('file', bytes, filename: filename),
);
// Uploading a photo over a slow connection takes longer than a sync
final response = await http.Response.fromStream(
await _client.send(request).timeout(_timeout * 4),
);
final data = _decode(response, authenticated: true) as Map<String, dynamic>;
return data['imageId'] as String;
}
/// A product picture (public: pictures never change at their address).
Future<List<int>> downloadImage(String imageId) async {
final response = await _client
.get(Uri.parse('$baseUrl/images/$imageId'))
.timeout(_timeout * 2);
if (response.statusCode != 200) {
throw ApiException(response.statusCode, 'Image $imageId not available');
}
return response.bodyBytes;
}
Future<Object?> _post(
String path,
Map<String, dynamic> body, {
String? token,
}) async {
final response = await _client
.post(
Uri.parse('$baseUrl$path'),
headers: {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
},
body: jsonEncode(body),
)
.timeout(_timeout);
return _decode(response, authenticated: token != null);
}
/// A 401 only means the device token is no longer valid when the request
/// used one; while pairing it means the registration code was rejected.
Object? _decode(http.Response response, {bool authenticated = false}) {
final status = response.statusCode;
if (status == 401 && authenticated) {
final error = _error(response);
throw UnauthorizedException(code: error.code, message: error.message);
}
if (status == 429) {
final seconds = int.tryParse(response.headers['retry-after'] ?? '');
throw RateLimitedException(
seconds == null ? _defaultRetryAfter : Duration(seconds: seconds),
);
}
if (status < 200 || status >= 300) {
final error = _error(response);
throw ApiException(status, error.message ?? '', code: error.code);
}
if (response.body.isEmpty) return null;
return jsonDecode(response.body);
}
/// The server reports errors as `{"error": {"code": ..., "message": ...}}`;
/// anything else (e.g. a proxy's HTML error page) falls back to the reason
/// phrase.
({String? code, String? message}) _error(http.Response response) {
final fallback = response.reasonPhrase;
try {
final error =
(jsonDecode(response.body) as Map<String, dynamic>)['error'];
if (error is! Map<String, dynamic>) {
return (code: null, message: fallback);
}
return (
code: error['code'] as String?,
message: error['message'] as String? ?? fallback,
);
} on FormatException {
return (code: null, message: fallback);
} on TypeError {
return (code: null, message: fallback);
}
}
}