236 lines
6.9 KiB
Dart
236 lines
6.9 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');
|
|
}
|
|
|
|
/// This app is older than the backoffice accepts; it has to be updated
|
|
/// before it can sync again. The token stays valid.
|
|
class UpdateRequiredException extends ApiException {
|
|
const UpdateRequiredException({super.code, String? message})
|
|
: super(426, message ?? 'App update required');
|
|
}
|
|
|
|
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;
|
|
|
|
/// Sent with every request so the backoffice can refuse an app that is too
|
|
/// old (`426 app_update_required`).
|
|
final String? appVersion;
|
|
|
|
KoolTapApiClient({
|
|
http.Client? client,
|
|
this.baseUrl = kRemoteServerUrl,
|
|
this.appVersion,
|
|
}) : _client = client ?? http.Client();
|
|
|
|
Map<String, String> get _versionHeader => {'X-App-Version': ?appVersion};
|
|
|
|
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/'), headers: _versionHeader)
|
|
.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.addAll(_versionHeader)
|
|
..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'), headers: _versionHeader)
|
|
.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',
|
|
..._versionHeader,
|
|
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 == 426) {
|
|
final error = _error(response);
|
|
throw UpdateRequiredException(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);
|
|
}
|
|
}
|
|
}
|