api_client.dart 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. import 'dart:convert';
  2. import 'package:http/http.dart' as http;
  3. import '../models/inventory.dart';
  4. List<String> serverUrlCandidates(String address) {
  5. final input = address.trim();
  6. if (input.isEmpty) return const [];
  7. final hasScheme = RegExp(r'^[a-zA-Z][a-zA-Z0-9+.-]*://').hasMatch(input);
  8. final prefixes = hasScheme ? const [''] : const ['https://', 'http://'];
  9. final candidates = <String>[];
  10. for (final prefix in prefixes) {
  11. final uri = Uri.tryParse('$prefix$input');
  12. if (uri == null ||
  13. !{'http', 'https'}.contains(uri.scheme.toLowerCase()) ||
  14. uri.host.isEmpty ||
  15. uri.userInfo.isNotEmpty ||
  16. uri.hasQuery ||
  17. uri.hasFragment) {
  18. continue;
  19. }
  20. final normalized = uri
  21. .replace(path: uri.path.replaceAll(RegExp(r'/+$'), ''))
  22. .toString()
  23. .replaceAll(RegExp(r'/+$'), '');
  24. if (!candidates.contains(normalized)) candidates.add(normalized);
  25. }
  26. return candidates;
  27. }
  28. class ApiStatus {
  29. const ApiStatus({required this.apiVersion, required this.appVersion});
  30. final String apiVersion;
  31. final String appVersion;
  32. factory ApiStatus.fromJson(Map<String, dynamic> json) => ApiStatus(
  33. apiVersion: json['apiVersion']?.toString() ?? '',
  34. appVersion: json['appVersion']?.toString() ?? '',
  35. );
  36. }
  37. class ApiException implements Exception {
  38. ApiException(this.message, {this.statusCode = 0});
  39. final String message;
  40. final int statusCode;
  41. @override
  42. String toString() => message;
  43. }
  44. /// Talks to a RackPeek server's REST API. Reads are the GET endpoints;
  45. /// writes go through `POST /api/inventory` in Merge mode, which never
  46. /// removes anything — partial updates send only the changed fields.
  47. class RackPeekApi {
  48. RackPeekApi({
  49. required this.baseUrl,
  50. required this.apiKey,
  51. http.Client? client,
  52. }) : _client = client ?? http.Client();
  53. final String baseUrl;
  54. final String apiKey;
  55. final http.Client _client;
  56. Map<String, String> get _headers => {'X-Api-Key': apiKey};
  57. Uri _uri(String path, [Map<String, String>? query]) =>
  58. Uri.parse('$baseUrl$path').replace(queryParameters: query);
  59. Future<InventoryDocument> getDocument() async {
  60. final json = await _getJson('/api/inventory');
  61. return InventoryDocument.fromJson(json);
  62. }
  63. Future<ApiStatus> getStatus() async {
  64. final response = await _send(
  65. () =>
  66. _client.get(_uri('/api/status')).timeout(const Duration(seconds: 5)),
  67. );
  68. if (response.statusCode != 200) {
  69. throw ApiException(
  70. 'No RackPeek API found at this address (${response.statusCode}).',
  71. statusCode: response.statusCode,
  72. );
  73. }
  74. try {
  75. final json = jsonDecode(response.body) as Map<String, dynamic>;
  76. final status = ApiStatus.fromJson(json);
  77. if (status.apiVersion.isEmpty) {
  78. throw const FormatException('Missing API version.');
  79. }
  80. return status;
  81. } on FormatException {
  82. throw ApiException('The server returned an invalid API status response.');
  83. } on TypeError {
  84. throw ApiException('The server returned an invalid API status response.');
  85. }
  86. }
  87. Future<Map<String, dynamic>> getSummary() async {
  88. return _getJson('/api/inventory/summary');
  89. }
  90. Future<List<Map<String, dynamic>>> search(
  91. String query, {
  92. int max = 20,
  93. }) async {
  94. final json = await _getJson('/api/inventory/search', {
  95. 'q': query,
  96. 'max': '$max',
  97. });
  98. return (json['matches'] as List<dynamic>? ?? [])
  99. .cast<Map<String, dynamic>>();
  100. }
  101. Future<Map<String, dynamic>> upsert(
  102. Map<String, dynamic> document, {
  103. bool dryRun = false,
  104. }) async {
  105. final body = jsonEncode({
  106. 'json': document,
  107. 'mode': 'Merge',
  108. 'dryRun': dryRun,
  109. });
  110. final response = await _send(
  111. () => _client.post(
  112. _uri('/api/inventory'),
  113. headers: {..._headers, 'Content-Type': 'application/json'},
  114. body: body,
  115. ),
  116. );
  117. return _decode(response);
  118. }
  119. Future<Map<String, dynamic>> _getJson(
  120. String path, [
  121. Map<String, String>? query,
  122. ]) async {
  123. final response = await _send(
  124. () => _client.get(_uri(path, query), headers: _headers),
  125. );
  126. return _decode(response);
  127. }
  128. Future<http.Response> _send(Future<http.Response> Function() send) async {
  129. try {
  130. return await send();
  131. } on Exception catch (e) {
  132. throw ApiException('Cannot reach the server: $e');
  133. }
  134. }
  135. Map<String, dynamic> _decode(http.Response response) {
  136. Map<String, dynamic>? parsed;
  137. try {
  138. final body = jsonDecode(response.body);
  139. if (body is Map<String, dynamic>) parsed = body;
  140. } on FormatException {
  141. // fall through to status-based message
  142. }
  143. if (response.statusCode == 200 && parsed != null) return parsed;
  144. final serverError = parsed?['error'] as String?;
  145. switch (response.statusCode) {
  146. case 401:
  147. throw ApiException(
  148. 'Unauthorized — check the API key.',
  149. statusCode: 401,
  150. );
  151. case 503:
  152. throw ApiException(
  153. 'The server has no API key configured (RPK_API_KEY).',
  154. statusCode: 503,
  155. );
  156. default:
  157. throw ApiException(
  158. serverError ?? 'Request failed (${response.statusCode}).',
  159. statusCode: response.statusCode,
  160. );
  161. }
  162. }
  163. }