diff --git a/flutter_client/lib/api/client.dart b/flutter_client/lib/api/client.dart new file mode 100644 index 00000000..6dee5465 --- /dev/null +++ b/flutter_client/lib/api/client.dart @@ -0,0 +1,30 @@ +import 'package:dio/dio.dart'; + +typedef TokenResolver = Future Function(); + +class ApiClient { + /// Builds a dio instance pinned to [baseUrl] with a Bearer-auth + /// interceptor that pulls the current token from [tokenResolver] + /// on every request. Server already supports cookie OR bearer + /// (internal/auth/session.go); we use bearer to skip cookie-jar. + static Dio buildDio({ + required String baseUrl, + required TokenResolver tokenResolver, + }) { + final d = Dio(BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 8), + receiveTimeout: const Duration(seconds: 30), + contentType: Headers.jsonContentType, + responseType: ResponseType.json, + )); + d.interceptors.add(InterceptorsWrapper(onRequest: (opts, handler) async { + final t = await tokenResolver(); + if (t != null && t.isNotEmpty) { + opts.headers['Authorization'] = 'Bearer $t'; + } + handler.next(opts); + })); + return d; + } +} diff --git a/flutter_client/lib/api/error_copy.dart b/flutter_client/lib/api/error_copy.dart new file mode 100644 index 00000000..7ff65bba --- /dev/null +++ b/flutter_client/lib/api/error_copy.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; +import 'package:flutter/services.dart' show rootBundle; + +class ErrorCopy { + ErrorCopy._(this._table); + final Map _table; + + static ErrorCopy? _instance; + + static Future load() async { + if (_instance != null) return _instance!; + final raw = await rootBundle.loadString('assets/error-copy.json'); + final m = (jsonDecode(raw) as Map).cast().map( + (k, v) => MapEntry(k, v.toString()), + ); + _instance = ErrorCopy._(m); + return _instance!; + } + + String forCode(String code) => + _table[code] ?? _table['unknown'] ?? 'Something went wrong.'; +} diff --git a/flutter_client/lib/api/errors.dart b/flutter_client/lib/api/errors.dart new file mode 100644 index 00000000..7283c350 --- /dev/null +++ b/flutter_client/lib/api/errors.dart @@ -0,0 +1,33 @@ +import 'package:dio/dio.dart'; + +class ApiError implements Exception { + ApiError({required this.code, required this.message, required this.status}); + + final String code; + final String message; + final int status; + + factory ApiError.fromDio(DioException e) { + if (e.type == DioExceptionType.connectionError || + e.type == DioExceptionType.connectionTimeout) { + return ApiError(code: 'connection_refused', message: 'Connection refused', status: 0); + } + final response = e.response; + final data = response?.data; + if (data is Map) { + final field = data['error']; + if (field is String) { + return ApiError(code: field, message: field, status: response?.statusCode ?? 0); + } + if (field is Map) { + final code = field['code']?.toString() ?? 'unknown'; + final msg = field['message']?.toString() ?? code; + return ApiError(code: code, message: msg, status: response?.statusCode ?? 0); + } + } + final status = response?.statusCode ?? 0; + if (status == 401) return ApiError(code: 'unauthenticated', message: 'unauthenticated', status: status); + if (status == 404) return ApiError(code: 'not_found', message: 'not_found', status: status); + return ApiError(code: 'unknown', message: e.message ?? 'unknown', status: status); + } +} diff --git a/flutter_client/test/api/client_test.dart b/flutter_client/test/api/client_test.dart new file mode 100644 index 00000000..6e738df4 --- /dev/null +++ b/flutter_client/test/api/client_test.dart @@ -0,0 +1,42 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/api/client.dart'; + +void main() { + test('client attaches Bearer token from token resolver', () async { + final d = ApiClient.buildDio( + baseUrl: 'http://example/', + tokenResolver: () async => 'tok-123', + ); + Map? captured; + d.interceptors.add( + InterceptorsWrapper(onRequest: (opts, h) { + captured = Map.from(opts.headers); + h.reject(DioException(requestOptions: opts, type: DioExceptionType.cancel)); + }), + ); + try { + await d.get('/whatever'); + } catch (_) {} + expect(captured?['Authorization'], 'Bearer tok-123'); + }); + + test('client omits Authorization when resolver returns null', () async { + final d = ApiClient.buildDio( + baseUrl: 'http://example/', + tokenResolver: () async => null, + ); + Map? captured; + d.interceptors.add( + InterceptorsWrapper(onRequest: (opts, h) { + captured = Map.from(opts.headers); + h.reject(DioException(requestOptions: opts, type: DioExceptionType.cancel)); + }), + ); + try { + await d.get('/whatever'); + } catch (_) {} + expect(captured?.containsKey('Authorization'), isFalse); + }); +} diff --git a/flutter_client/test/api/errors_test.dart b/flutter_client/test/api/errors_test.dart new file mode 100644 index 00000000..605802d8 --- /dev/null +++ b/flutter_client/test/api/errors_test.dart @@ -0,0 +1,57 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/api/errors.dart'; + +void main() { + RequestOptions opts() => RequestOptions(path: '/x'); + + group('ApiError.fromDio', () { + test('flat envelope: {error: "code"}', () { + final d = DioException( + requestOptions: opts(), + response: Response( + requestOptions: opts(), + statusCode: 503, + data: {'error': 'lidarr_unreachable'}, + ), + ); + final e = ApiError.fromDio(d); + expect(e.code, 'lidarr_unreachable'); + expect(e.status, 503); + }); + + test('nested envelope: {error: {code, message}}', () { + final d = DioException( + requestOptions: opts(), + response: Response( + requestOptions: opts(), + statusCode: 400, + data: {'error': {'code': 'bad_request', 'message': 'invalid JSON body'}}, + ), + ); + final e = ApiError.fromDio(d); + expect(e.code, 'bad_request'); + expect(e.message, 'invalid JSON body'); + }); + + test('connection refused: code is connection_refused', () { + final d = DioException( + requestOptions: opts(), + type: DioExceptionType.connectionError, + error: 'Connection refused', + ); + final e = ApiError.fromDio(d); + expect(e.code, 'connection_refused'); + }); + + test('401 with no envelope falls back to unauthenticated', () { + final d = DioException( + requestOptions: opts(), + response: Response(requestOptions: opts(), statusCode: 401), + ); + final e = ApiError.fromDio(d); + expect(e.code, 'unauthenticated'); + }); + }); +}