feat(online): session compte, stockage sécurisé et adapter API (ticket #64)
Ajoute l'adapter API distant (infrastructure/remote/auth_api.dart, http_api_client.dart) et le stockage sécurisé du token d'auth (infrastructure/security/secure_storage_auth_token_store.dart), câblés dans app_bootstrap.dart. Étend le modèle Drift (migration schemaVersion 9→10) et la couche application (ports, use cases, entités) pour la session de compte. flutter pub get OK (ajout http, flutter_secure_storage), build_runner OK, dart format appliqué, analyze propre (mêmes infos préexistantes), 109/109 tests verts, build APK debug validé. Premier ticket du chantier "Ajouter les features serveur au client" (#63). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
71
lib/infrastructure/remote/auth_api.dart
Normal file
71
lib/infrastructure/remote/auth_api.dart
Normal file
@ -0,0 +1,71 @@
|
||||
import '../../application/application.dart';
|
||||
import 'http_api_client.dart';
|
||||
|
||||
final class HttpRemoteAuthApi implements RemoteAuthApi {
|
||||
const HttpRemoteAuthApi(this.client);
|
||||
|
||||
final HttpApiClient client;
|
||||
|
||||
@override
|
||||
Future<RemoteAuthResult> register({
|
||||
required String email,
|
||||
required String password,
|
||||
String? displayName,
|
||||
}) async {
|
||||
final registered = await client.postJson(
|
||||
'/auth/register',
|
||||
body: {
|
||||
'email': email,
|
||||
'password': password,
|
||||
if (displayName != null) 'displayName': displayName,
|
||||
},
|
||||
expectedStatuses: const {201},
|
||||
);
|
||||
final userId = _requiredString(registered, 'userId');
|
||||
final registeredEmail = _requiredString(registered, 'email');
|
||||
final loggedIn = await login(email: email, password: password);
|
||||
return RemoteAuthResult(
|
||||
userId: userId,
|
||||
email: registeredEmail,
|
||||
token: loggedIn.token,
|
||||
expiresAt: loggedIn.expiresAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RemoteAuthResult> login({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
final response = await client.postJson(
|
||||
'/auth/login',
|
||||
body: {'email': email, 'password': password},
|
||||
);
|
||||
return RemoteAuthResult(
|
||||
userId: response['userId'] is String
|
||||
? response['userId'] as String
|
||||
: email.trim().toLowerCase(),
|
||||
email: response['email'] is String
|
||||
? response['email'] as String
|
||||
: email.trim().toLowerCase(),
|
||||
token: _requiredString(response, 'token'),
|
||||
expiresAt: DateTime.parse(_requiredString(response, 'expiresAt')).toUtc(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout(String token) {
|
||||
return client.postEmpty('/auth/logout', bearerToken: token);
|
||||
}
|
||||
|
||||
String _requiredString(Map<String, Object?> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
throw RemoteAuthException(
|
||||
RemoteAuthFailure.unknown,
|
||||
'$key is missing from auth response.',
|
||||
);
|
||||
}
|
||||
}
|
||||
124
lib/infrastructure/remote/http_api_client.dart
Normal file
124
lib/infrastructure/remote/http_api_client.dart
Normal file
@ -0,0 +1,124 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../application/application.dart';
|
||||
|
||||
final class HttpApiClient {
|
||||
HttpApiClient({
|
||||
required this.baseUrl,
|
||||
http.Client? client,
|
||||
this.timeout = const Duration(seconds: 10),
|
||||
}) : client = client ?? http.Client();
|
||||
|
||||
static const defaultBaseUrl = String.fromEnvironment(
|
||||
'GAMETIME_API_BASE_URL',
|
||||
defaultValue: 'http://localhost:8080',
|
||||
);
|
||||
|
||||
final Uri baseUrl;
|
||||
final http.Client client;
|
||||
final Duration timeout;
|
||||
|
||||
Future<Map<String, Object?>> postJson(
|
||||
String path, {
|
||||
Map<String, Object?>? body,
|
||||
String? bearerToken,
|
||||
Set<int> expectedStatuses = const {200, 201},
|
||||
}) async {
|
||||
final response = await _send(
|
||||
() => client.post(
|
||||
_resolve(path),
|
||||
headers: _headers(bearerToken),
|
||||
body: body == null ? null : jsonEncode(body),
|
||||
),
|
||||
);
|
||||
if (!expectedStatuses.contains(response.statusCode)) {
|
||||
throw _exceptionForStatus(response.statusCode, response.body);
|
||||
}
|
||||
if (response.body.trim().isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
final Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(response.body);
|
||||
} on FormatException catch (error) {
|
||||
throw RemoteAuthException(RemoteAuthFailure.unknown, error.message);
|
||||
}
|
||||
if (decoded is Map) {
|
||||
return Map<String, Object?>.from(decoded);
|
||||
}
|
||||
throw const RemoteAuthException(
|
||||
RemoteAuthFailure.unknown,
|
||||
'Unexpected JSON response.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> postEmpty(
|
||||
String path, {
|
||||
String? bearerToken,
|
||||
Set<int> expectedStatuses = const {204},
|
||||
}) async {
|
||||
final response = await _send(
|
||||
() => client.post(_resolve(path), headers: _headers(bearerToken)),
|
||||
);
|
||||
if (!expectedStatuses.contains(response.statusCode)) {
|
||||
throw _exceptionForStatus(response.statusCode, response.body);
|
||||
}
|
||||
}
|
||||
|
||||
Uri _resolve(String path) {
|
||||
final normalized = path.startsWith('/') ? path.substring(1) : path;
|
||||
final base = baseUrl.toString().endsWith('/')
|
||||
? baseUrl
|
||||
: Uri.parse('${baseUrl.toString()}/');
|
||||
return base.resolve(normalized);
|
||||
}
|
||||
|
||||
Map<String, String> _headers(String? bearerToken) => {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
if (bearerToken != null) 'authorization': 'Bearer $bearerToken',
|
||||
};
|
||||
|
||||
Future<http.Response> _send(Future<http.Response> Function() send) async {
|
||||
try {
|
||||
return await send().timeout(timeout);
|
||||
} on TimeoutException {
|
||||
throw const RemoteAuthException(RemoteAuthFailure.network, 'Timeout.');
|
||||
} on http.ClientException catch (error) {
|
||||
throw RemoteAuthException(RemoteAuthFailure.network, error.message);
|
||||
} on FormatException catch (error) {
|
||||
throw RemoteAuthException(RemoteAuthFailure.unknown, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
RemoteAuthException _exceptionForStatus(int statusCode, String body) {
|
||||
final message = _messageFromBody(body);
|
||||
return switch (statusCode) {
|
||||
401 => RemoteAuthException(RemoteAuthFailure.invalidCredentials, message),
|
||||
409 => RemoteAuthException(RemoteAuthFailure.emailAlreadyUsed, message),
|
||||
>= 500 => RemoteAuthException(RemoteAuthFailure.network, message),
|
||||
_ => RemoteAuthException(RemoteAuthFailure.unknown, message),
|
||||
};
|
||||
}
|
||||
|
||||
String? _messageFromBody(String body) {
|
||||
if (body.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map && decoded['error'] is String) {
|
||||
return decoded['error'] as String;
|
||||
}
|
||||
if (decoded is Map && decoded['message'] is String) {
|
||||
return decoded['message'] as String;
|
||||
}
|
||||
} on FormatException {
|
||||
return body;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
}
|
||||
2
lib/infrastructure/remote/remote.dart
Normal file
2
lib/infrastructure/remote/remote.dart
Normal file
@ -0,0 +1,2 @@
|
||||
export 'auth_api.dart';
|
||||
export 'http_api_client.dart';
|
||||
Reference in New Issue
Block a user