feat: add alot
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user