This commit is contained in:
2026-09-29 15:07:05 +02:00
parent 06fe43bba3
commit 4de305bbf4
12 changed files with 837 additions and 97 deletions

View File

@@ -30,3 +30,18 @@ class IntraUnauthorizedException extends IntraApiException {
class IntraApiErrorException extends IntraApiException {
const IntraApiErrorException(super.message);
}
class IntraRateLimitException extends IntraApiException {
const IntraRateLimitException()
: super(
'Trop de requêtes vers l’API 42. Attends quelques secondes puis réessaie.',
);
}
class IntraForbiddenException extends IntraApiException {
const IntraForbiddenException()
: super(
'Accès refusé (403). Déconnecte-toi puis reconnecte-toi '
'(scopes OAuth : public + projects sur l’app Intra).',
);
}

View File

@@ -36,7 +36,8 @@ class IntraAuthService {
IntraConfig.redirectUri,
clientSecret: IntraConfig.clientSecret,
serviceConfiguration: _serviceConfiguration,
scopes: ['public'],
// `projects` requis pour projects_users ; skills via cursus_users (pas /experiences).
scopes: ['public', 'projects'],
),
);

View File

@@ -0,0 +1,63 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'intra_api_exception.dart';
/// GET API 42 avec gestion 401 / 429 (retry + backoff).
Future<http.Response> intraApiGet(
Uri uri, {
required Map<String, String> headers,
int maxAttempts = 4,
}) async {
for (var attempt = 0; attempt < maxAttempts; attempt++) {
final response = await http.get(uri, headers: headers);
if (response.statusCode == 429) {
if (attempt + 1 >= maxAttempts) {
throw const IntraRateLimitException();
}
await Future<void>.delayed(_retryDelay(response, attempt));
continue;
}
return response;
}
throw const IntraRateLimitException();
}
Duration _retryDelay(http.Response response, int attempt) {
final header = response.headers['retry-after'];
if (header != null) {
final seconds = int.tryParse(header.trim());
if (seconds != null && seconds > 0) {
return Duration(seconds: seconds.clamp(1, 30));
}
}
// 0.8s, 1.6s, 3.2s…
final ms = 800 * (1 << attempt);
return Duration(milliseconds: ms.clamp(800, 8000));
}
void ensureIntraOk(http.Response response) {
if (response.statusCode == 401) {
throw const IntraUnauthorizedException();
}
if (response.statusCode == 403) {
throw const IntraForbiddenException();
}
if (response.statusCode == 429) {
throw const IntraRateLimitException();
}
if (response.statusCode != 200) {
throw IntraApiErrorException(
'Erreur API (${response.statusCode})',
);
}
}
dynamic decodeIntraBody(http.Response response) {
ensureIntraOk(response);
return jsonDecode(response.body);
}

View File

@@ -0,0 +1,250 @@
import 'dart:io';
import 'package:http/http.dart' as http;
import '../config/intra_config.dart';
import 'intra_api_exception.dart';
import 'intra_auth_service.dart';
import 'intra_http.dart';
/// Compétence Intra : niveau entier + pourcentage (partie décimale du level API).
class IntraSkillSummary {
const IntraSkillSummary({
required this.name,
required this.level,
required this.percentage,
});
final String name;
final int level;
final int percentage;
}
/// Projet terminé ou en échec.
class IntraProjectSummary {
const IntraProjectSummary({
required this.name,
required this.status,
this.finalMark,
});
final String name;
final String status;
final int? finalMark;
}
/// Données agrégées pour la vue profil.
class IntraUserProfile {
const IntraUserProfile({
required this.user,
required this.cursusLevel,
required this.skills,
required this.projects,
});
final Map<String, dynamic> user;
final double? cursusLevel;
final List<IntraSkillSummary> skills;
final List<IntraProjectSummary> projects;
}
class _ProfileCacheEntry {
_ProfileCacheEntry(this.profile, this.fetchedAt);
final IntraUserProfile profile;
final DateTime fetchedAt;
}
class IntraProfileService {
IntraProfileService._();
static final IntraProfileService instance = IntraProfileService._();
static const Duration _cacheTtl = Duration(minutes: 2);
static const Duration _betweenRequests = Duration(milliseconds: 450);
final IntraAuthService _auth = IntraAuthService.instance;
final Map<String, _ProfileCacheEntry> _cache = {};
/// Charge profil complet (user, cursus, skills, projets) avec le token existant.
Future<IntraUserProfile> fetchProfile(Map<String, dynamic> summaryUser) async {
if (!_auth.isAuthenticated) {
throw const IntraNotAuthenticatedException();
}
final userId = summaryUser['id'];
if (userId == null) {
throw const IntraApiErrorException('Identifiant utilisateur manquant');
}
final id = userId.toString();
final cached = _cache[id];
if (cached != null &&
DateTime.now().difference(cached.fetchedAt) < _cacheTtl) {
return cached.profile;
}
try {
final login = summaryUser['login']?.toString();
final userPath = login != null && login.isNotEmpty
? '/users/$login'
: '/users/$id';
final userDecoded = await _getJson(userPath);
if (userDecoded is! Map<String, dynamic>) {
throw IntraApiErrorException('Réponse API inattendue ($userPath)');
}
final embeddedCursus = userDecoded['cursus_users'];
final List<dynamic> cursusList;
if (embeddedCursus is List && embeddedCursus.isNotEmpty) {
cursusList = embeddedCursus;
} else {
await Future<void>.delayed(_betweenRequests);
cursusList = await _getJsonList('/users/$id/cursus_users');
}
await Future<void>.delayed(_betweenRequests);
final projectsUsers = await _getJsonList(
'/users/$id/projects_users',
query: {'page[size]': '100'},
);
final primaryCursus = _pickPrimaryCursus(cursusList);
final profile = IntraUserProfile(
user: userDecoded,
cursusLevel: _levelFromCursus(primaryCursus),
skills: _parseSkillsFromCursus(primaryCursus, cursusList),
projects: _parseProjects(projectsUsers),
);
_cache[id] = _ProfileCacheEntry(profile, DateTime.now());
return profile;
} on IntraApiException {
rethrow;
} on SocketException {
throw const IntraNetworkException();
} on http.ClientException {
throw const IntraNetworkException();
}
}
Future<dynamic> _getJson(String path, {Map<String, String>? query}) async {
final uri = Uri.parse('${IntraConfig.apiBaseUrl}$path').replace(
queryParameters: query,
);
final response = await intraApiGet(
uri,
headers: _auth.authHeaders(),
);
return decodeIntraBody(response);
}
Future<List<dynamic>> _getJsonList(
String path, {
Map<String, String>? query,
}) async {
final decoded = await _getJson(path, query: query);
if (decoded is! List) {
throw IntraApiErrorException('Réponse API inattendue ($path)');
}
return decoded;
}
Map<String, dynamic>? _pickPrimaryCursus(List<dynamic> cursusUsers) {
Map<String, dynamic>? fallback;
for (final entry in cursusUsers) {
if (entry is! Map<String, dynamic>) continue;
final cursus = entry['cursus'];
final slug = cursus is Map ? cursus['slug']?.toString() : null;
final name = cursus is Map ? cursus['name']?.toString() : null;
if (slug == '42' || name == '42' || name == '42cursus') {
return entry;
}
fallback ??= entry;
}
return fallback;
}
double? _levelFromCursus(Map<String, dynamic>? cursusUser) {
if (cursusUser == null) return null;
final level = cursusUser['level'];
return level is num ? level.toDouble() : null;
}
List<IntraSkillSummary> _parseSkillsFromCursus(
Map<String, dynamic>? primaryCursus,
List<dynamic> allCursus,
) {
final sources = <Map<String, dynamic>>[];
if (primaryCursus != null) {
sources.add(primaryCursus);
} else {
for (final c in allCursus) {
if (c is Map<String, dynamic>) sources.add(c);
}
}
final byId = <int, IntraSkillSummary>{};
for (final cursusUser in sources) {
final skills = cursusUser['skills'];
if (skills is! List) continue;
for (final skill in skills) {
if (skill is! Map<String, dynamic>) continue;
final idRaw = skill['id'];
if (idRaw is! num) continue;
final skillId = idRaw.toInt();
final name = skill['name']?.toString() ?? 'Skill $skillId';
final levelRaw = skill['level'];
if (levelRaw is! num) continue;
final summary = _skillFromLevel(name, levelRaw.toDouble());
final existing = byId[skillId];
if (existing == null || summary.level > existing.level) {
byId[skillId] = summary;
}
}
}
final list = byId.values.toList();
list.sort((a, b) => b.level.compareTo(a.level));
return list;
}
IntraSkillSummary _skillFromLevel(String name, double levelValue) {
final whole = levelValue.floor();
final fraction = levelValue - whole;
final percentage = (fraction * 100).round().clamp(0, 99);
return IntraSkillSummary(
name: name,
level: whole,
percentage: percentage,
);
}
List<IntraProjectSummary> _parseProjects(List<dynamic> projectsUsers) {
final projects = <IntraProjectSummary>[];
for (final entry in projectsUsers) {
if (entry is! Map<String, dynamic>) continue;
final status = entry['status']?.toString();
if (status != 'finished' && status != 'failed') continue;
final project = entry['project'];
final name = project is Map
? (project['name']?.toString() ?? project['slug']?.toString())
: null;
if (name == null || name.isEmpty) continue;
final mark = entry['final_mark'];
projects.add(
IntraProjectSummary(
name: name,
status: status!,
finalMark: mark is int ? mark : (mark is num ? mark.round() : null),
),
);
}
projects.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return projects;
}
}

View File

@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
import '../config/intra_config.dart';
import 'intra_api_exception.dart';
import 'intra_auth_service.dart';
import 'intra_http.dart';
class IntraUserService {
IntraUserService._();
@@ -28,7 +29,10 @@ class IntraUserService {
);
try {
final response = await http.get(uri, headers: _auth.authHeaders());
final response = await intraApiGet(
uri,
headers: _auth.authHeaders(),
);
return _parseUserListResponse(response, trimmed);
} on IntraApiException {
rethrow;
@@ -43,14 +47,7 @@ class IntraUserService {
http.Response response,
String login,
) {
if (response.statusCode == 401) {
throw const IntraUnauthorizedException();
}
if (response.statusCode != 200) {
throw IntraApiErrorException(
'Erreur API (${response.statusCode})',
);
}
ensureIntraOk(response);
final decoded = jsonDecode(response.body);
if (decoded is! List) {