From b3a13cd5a2a730da1a21bdaae0af9f450cb4b90c Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 28 Aug 2026 14:54:54 +0200 Subject: [PATCH] Setting up basic API Structure --- ac_web/lib/Api/AcApi.dart | 57 +++++++ ac_web/lib/Api/Api.dart | 18 +++ ac_web/lib/Api/ApiClient.dart | 164 +++++++++++++++++++ ac_web/lib/Api/ApiConfig.dart | 74 +++++++++ ac_web/lib/Api/ApiException.dart | 46 ++++++ ac_web/lib/Api/Dtos/AgentDto.dart | 58 +++++++ ac_web/lib/Api/Dtos/AgentTaskDto.dart | 58 +++++++ ac_web/lib/Api/Dtos/EAgentStatus.dart | 27 ++++ ac_web/lib/Api/Dtos/EAgentType.dart | 29 ++++ ac_web/lib/Api/Dtos/ReportDto.dart | 45 ++++++ ac_web/lib/Api/JsonReader.dart | 138 ++++++++++++++++ ac_web/lib/Api/Services/AgentsService.dart | 15 ++ ac_web/lib/Api/Services/ApiServiceBase.dart | 24 +++ ac_web/lib/Api/Services/ReportsService.dart | 18 +++ ac_web/lib/main.dart | 5 + ac_web/pubspec.lock | 32 ++++ ac_web/pubspec.yaml | 1 + ac_web/test/Api/ApiLayerTest.dart | 165 ++++++++++++++++++++ 18 files changed, 974 insertions(+) create mode 100644 ac_web/lib/Api/AcApi.dart create mode 100644 ac_web/lib/Api/Api.dart create mode 100644 ac_web/lib/Api/ApiClient.dart create mode 100644 ac_web/lib/Api/ApiConfig.dart create mode 100644 ac_web/lib/Api/ApiException.dart create mode 100644 ac_web/lib/Api/Dtos/AgentDto.dart create mode 100644 ac_web/lib/Api/Dtos/AgentTaskDto.dart create mode 100644 ac_web/lib/Api/Dtos/EAgentStatus.dart create mode 100644 ac_web/lib/Api/Dtos/EAgentType.dart create mode 100644 ac_web/lib/Api/Dtos/ReportDto.dart create mode 100644 ac_web/lib/Api/JsonReader.dart create mode 100644 ac_web/lib/Api/Services/AgentsService.dart create mode 100644 ac_web/lib/Api/Services/ApiServiceBase.dart create mode 100644 ac_web/lib/Api/Services/ReportsService.dart create mode 100644 ac_web/test/Api/ApiLayerTest.dart diff --git a/ac_web/lib/Api/AcApi.dart b/ac_web/lib/Api/AcApi.dart new file mode 100644 index 0000000..e2e4599 --- /dev/null +++ b/ac_web/lib/Api/AcApi.dart @@ -0,0 +1,57 @@ +import 'package:ac_web/Api/ApiClient.dart'; +import 'package:ac_web/Api/ApiConfig.dart'; +import 'package:ac_web/Api/Services/AgentsService.dart'; +import 'package:ac_web/Api/Services/ReportsService.dart'; +import 'package:http/http.dart' as http; + +/// Single entry point for the whole Rest API of the Artificial Company backend. +/// +/// Usage: +/// ```dart +/// // once during startup, e.g. in main.dart +/// AcApi.configure(const ApiConfig(host: '192.168.0.10', port: 5000)); +/// +/// // anywhere in the app +/// final agents = await AcApi.instance.agents.getAgents(); +/// ``` +class AcApi { + AcApi({ApiConfig config = ApiConfig.development, http.Client? httpClient}) + : mClient = ApiClient(config: config, httpClient: httpClient); + + final ApiClient mClient; + + static AcApi? _instance; + + /// Globally shared instance. Created lazily with [ApiConfig.development] if + /// [configure] was not called before. + static AcApi get instance => _instance ??= AcApi(); + + /// Replaces the shared instance, e.g. during app startup or in tests. + static AcApi configure(ApiConfig config, {http.Client? httpClient}) { + _instance?.dispose(); + return _instance = AcApi(config: config, httpClient: httpClient); + } + + /// Drops the shared instance. Mainly useful for tests. + static void reset() { + _instance?.dispose(); + _instance = null; + } + + ApiClient get client => mClient; + + ApiConfig get config => mClient.config; + + AgentsService? _agents; + ReportsService? _reports; + + /// `/agents` controller. + AgentsService get agents => _agents ??= AgentsService(mClient); + + /// `/reports` controller. + ReportsService get reports => _reports ??= ReportsService(mClient); + + void dispose() { + mClient.dispose(); + } +} diff --git a/ac_web/lib/Api/Api.dart b/ac_web/lib/Api/Api.dart new file mode 100644 index 0000000..4fa1fad --- /dev/null +++ b/ac_web/lib/Api/Api.dart @@ -0,0 +1,18 @@ +/// Barrel file for the Rest API layer. +/// +/// Import this single file in widgets and pages: +/// `import 'package:ac_web/Api/Api.dart';` +library; + +export 'package:ac_web/Api/AcApi.dart'; +export 'package:ac_web/Api/ApiClient.dart'; +export 'package:ac_web/Api/ApiConfig.dart'; +export 'package:ac_web/Api/ApiException.dart'; +export 'package:ac_web/Api/Dtos/AgentDto.dart'; +export 'package:ac_web/Api/Dtos/AgentTaskDto.dart'; +export 'package:ac_web/Api/Dtos/EAgentStatus.dart'; +export 'package:ac_web/Api/Dtos/EAgentType.dart'; +export 'package:ac_web/Api/Dtos/ReportDto.dart'; +export 'package:ac_web/Api/Services/AgentsService.dart'; +export 'package:ac_web/Api/Services/ApiServiceBase.dart'; +export 'package:ac_web/Api/Services/ReportsService.dart'; diff --git a/ac_web/lib/Api/ApiClient.dart b/ac_web/lib/Api/ApiClient.dart new file mode 100644 index 0000000..bbe0c90 --- /dev/null +++ b/ac_web/lib/Api/ApiClient.dart @@ -0,0 +1,164 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:ac_web/Api/ApiConfig.dart'; +import 'package:ac_web/Api/ApiException.dart'; +import 'package:http/http.dart' as http; + +/// Thin, transport only wrapper around `package:http`. +/// +/// It knows nothing about the concrete controllers of the backend, it only +/// performs requests, maps failures onto [ApiException]s and decodes json. +/// Everything endpoint specific lives in the services under `Api/Services`. +class ApiClient { + ApiClient({ApiConfig config = ApiConfig.development, http.Client? httpClient}) + : mConfig = config, + mHttpClient = httpClient ?? http.Client(), + mOwnsHttpClient = httpClient == null; + + final ApiConfig mConfig; + final http.Client mHttpClient; + final bool mOwnsHttpClient; + + ApiConfig get config => mConfig; + + /// Performs a GET request and returns the decoded json body. + Future getJson(String path, {Map? query, Map? headers}) { + return _send('GET', path, query: query, headers: headers); + } + + /// Performs a GET request and maps a json array onto a list of models. + Future> getList( + String path, + T Function(Map json) fromJson, { + Map? query, + Map? headers, + }) async { + final uri = mConfig.buildUri(path, query); + final body = await _send('GET', path, query: query, headers: headers); + + if (body == null) { + return []; + } + if (body is! List) { + throw ApiParsingException('Expected a json array but got ${body.runtimeType}.', uri: uri); + } + + try { + return body + .whereType() + .map((entry) => fromJson(Map.from(entry))) + .toList(growable: false); + } on ApiException { + rethrow; + } catch (error) { + throw ApiParsingException('Failed to parse list of $T: $error', uri: uri); + } + } + + /// Performs a GET request and maps a json object onto a single model. + Future getObject( + String path, + T Function(Map json) fromJson, { + Map? query, + Map? headers, + }) async { + final uri = mConfig.buildUri(path, query); + final body = await _send('GET', path, query: query, headers: headers); + + if (body is! Map) { + throw ApiParsingException('Expected a json object but got ${body.runtimeType}.', uri: uri); + } + + try { + return fromJson(Map.from(body)); + } on ApiException { + rethrow; + } catch (error) { + throw ApiParsingException('Failed to parse $T: $error', uri: uri); + } + } + + Future postJson( + String path, { + Object? body, + Map? query, + Map? headers, + }) { + return _send('POST', path, body: body, query: query, headers: headers); + } + + Future putJson( + String path, { + Object? body, + Map? query, + Map? headers, + }) { + return _send('PUT', path, body: body, query: query, headers: headers); + } + + Future deleteJson( + String path, { + Object? body, + Map? query, + Map? headers, + }) { + return _send('DELETE', path, body: body, query: query, headers: headers); + } + + void dispose() { + if (mOwnsHttpClient) { + mHttpClient.close(); + } + } + + Future _send( + String method, + String path, { + Object? body, + Map? query, + Map? headers, + }) async { + final uri = mConfig.buildUri(path, query); + + final request = http.Request(method, uri); + request.headers.addAll({ + 'Accept': 'application/json', + ...mConfig.defaultHeaders, + ...?headers, + }); + if (body != null) { + request.headers['Content-Type'] = 'application/json; charset=utf-8'; + request.body = jsonEncode(body); + } + + http.Response response; + try { + final streamed = await mHttpClient.send(request).timeout(mConfig.timeout); + response = await http.Response.fromStream(streamed); + } on TimeoutException { + throw ApiTimeoutException('Request timed out after ${mConfig.timeout.inSeconds}s.', uri: uri); + } catch (error) { + throw ApiNetworkException('Could not reach the api: $error', uri: uri); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiHttpException( + statusCode: response.statusCode, + message: response.reasonPhrase ?? 'Request failed.', + body: response.body, + uri: uri, + ); + } + + if (response.statusCode == 204 || response.body.isEmpty) { + return null; + } + + try { + return jsonDecode(utf8.decode(response.bodyBytes)); + } catch (error) { + throw ApiParsingException('Response was no valid json: $error', uri: uri, body: response.body); + } + } +} diff --git a/ac_web/lib/Api/ApiConfig.dart b/ac_web/lib/Api/ApiConfig.dart new file mode 100644 index 0000000..ca2b528 --- /dev/null +++ b/ac_web/lib/Api/ApiConfig.dart @@ -0,0 +1,74 @@ +/// Central configuration of the backend Rest API (.Net Web API). +/// +/// The API is reached over plain http because it only ever gets called +/// inside of a trusted network. +class ApiConfig { + const ApiConfig({ + this.scheme = 'http', + this.host = 'localhost', + this.port = 5000, + this.basePath = '', + this.timeout = const Duration(seconds: 15), + this.defaultHeaders = const {}, + }); + + final String scheme; + final String host; + final int? port; + + /// Optional prefix in front of every controller route, e.g. `api`. + final String basePath; + + final Duration timeout; + final Map defaultHeaders; + + /// Default configuration used by [AcApi] when nothing else is provided. + static const ApiConfig development = ApiConfig(); + + ApiConfig copyWith({ + String? scheme, + String? host, + int? port, + String? basePath, + Duration? timeout, + Map? defaultHeaders, + }) { + return ApiConfig( + scheme: scheme ?? this.scheme, + host: host ?? this.host, + port: port ?? this.port, + basePath: basePath ?? this.basePath, + timeout: timeout ?? this.timeout, + defaultHeaders: defaultHeaders ?? this.defaultHeaders, + ); + } + + /// Builds an absolute [Uri] for a controller route like `agents`. + Uri buildUri(String path, [Map? queryParameters]) { + final segments = [ + ...basePath.split('/'), + ...path.split('/'), + ].where((segment) => segment.isNotEmpty).toList(); + + Map? query; + if (queryParameters != null && queryParameters.isNotEmpty) { + query = {}; + queryParameters.forEach((key, value) { + if (value != null) { + query![key] = value.toString(); + } + }); + if (query.isEmpty) { + query = null; + } + } + + return Uri( + scheme: scheme, + host: host, + port: port, + pathSegments: segments, + queryParameters: query, + ); + } +} diff --git a/ac_web/lib/Api/ApiException.dart b/ac_web/lib/Api/ApiException.dart new file mode 100644 index 0000000..cd53aeb --- /dev/null +++ b/ac_web/lib/Api/ApiException.dart @@ -0,0 +1,46 @@ +/// Base class for every error the Rest API layer can produce. +sealed class ApiException implements Exception { + const ApiException(this.message, {this.uri}); + + final String message; + final Uri? uri; + + @override + String toString() => '$runtimeType: $message${uri != null ? ' ($uri)' : ''}'; +} + +/// The server answered with a non success status code. +class ApiHttpException extends ApiException { + const ApiHttpException({ + required this.statusCode, + required String message, + this.body, + super.uri, + }) : super(message); + + final int statusCode; + final String? body; + + bool get isClientError => statusCode >= 400 && statusCode < 500; + bool get isServerError => statusCode >= 500; + + @override + String toString() => 'ApiHttpException($statusCode): $message${uri != null ? ' ($uri)' : ''}'; +} + +/// The server could not be reached at all (no route, refused, offline, ...). +class ApiNetworkException extends ApiException { + const ApiNetworkException(super.message, {super.uri}); +} + +/// The request ran longer than the configured timeout. +class ApiTimeoutException extends ApiException { + const ApiTimeoutException(super.message, {super.uri}); +} + +/// The response body could not be parsed into the expected shape. +class ApiParsingException extends ApiException { + const ApiParsingException(super.message, {super.uri, this.body}); + + final String? body; +} diff --git a/ac_web/lib/Api/Dtos/AgentDto.dart b/ac_web/lib/Api/Dtos/AgentDto.dart new file mode 100644 index 0000000..b412028 --- /dev/null +++ b/ac_web/lib/Api/Dtos/AgentDto.dart @@ -0,0 +1,58 @@ +import 'package:ac_web/Api/Dtos/AgentTaskDto.dart'; +import 'package:ac_web/Api/Dtos/EAgentStatus.dart'; +import 'package:ac_web/Api/Dtos/EAgentType.dart'; +import 'package:ac_web/Api/JsonReader.dart'; + +/// Dart counterpart of the backend `AgentData` returned by `GET /agents`. +class AgentDto { + const AgentDto({ + required this.name, + required this.type, + required this.lastSeen, + required this.tasks, + required this.status, + }); + + final String name; + final EAgentType type; + final DateTime lastSeen; + final List tasks; + final EAgentStatus status; + + bool get hasTasks => tasks.isNotEmpty; + + factory AgentDto.fromJson(Map json) { + return AgentDto( + name: JsonReader.requireString(json, 'Name'), + type: JsonReader.enumValue( + json, + 'Type', + EAgentType.values, + (value) => value.name, + EAgentType.unknown, + ), + lastSeen: JsonReader.requireDateTime(json, 'LastSeen'), + tasks: JsonReader.list(json, 'Tasks', AgentTaskDto.fromJson), + status: JsonReader.enumValue( + json, + 'Status', + EAgentStatus.values, + (value) => value.name, + EAgentStatus.unknown, + ), + ); + } + + Map toJson() { + return { + 'Name': name, + 'Type': type.name, + 'LastSeen': lastSeen.toIso8601String(), + 'Tasks': tasks.map((task) => task.toJson()).toList(growable: false), + 'Status': status.name, + }; + } + + @override + String toString() => 'AgentDto(name: $name, type: ${type.name}, status: ${status.name})'; +} diff --git a/ac_web/lib/Api/Dtos/AgentTaskDto.dart b/ac_web/lib/Api/Dtos/AgentTaskDto.dart new file mode 100644 index 0000000..ce5dc7a --- /dev/null +++ b/ac_web/lib/Api/Dtos/AgentTaskDto.dart @@ -0,0 +1,58 @@ +import 'package:ac_web/Api/JsonReader.dart'; + +/// Dart counterpart of the backend `AgentTaskDto`. +/// +/// The exact shape of the backend dto is not final yet, therefore everything +/// except the name is treated as optional. Unknown properties are simply +/// ignored, so this dto keeps working while the api grows. +class AgentTaskDto { + const AgentTaskDto({ + required this.name, + this.id, + this.description, + this.status, + this.progress, + this.createdAt, + this.finishedAt, + }); + + final String name; + final String? id; + final String? description; + final String? status; + + /// Completion between 0 and 100 if the api reports it. + final int? progress; + + final DateTime? createdAt; + final DateTime? finishedAt; + + bool get isFinished => finishedAt != null; + + factory AgentTaskDto.fromJson(Map json) { + return AgentTaskDto( + name: JsonReader.optionalString(json, 'Name') ?? '', + id: JsonReader.optionalString(json, 'Id'), + description: JsonReader.optionalString(json, 'Description'), + status: JsonReader.optionalString(json, 'Status'), + progress: JsonReader.optionalInt(json, 'Progress'), + createdAt: JsonReader.optionalDateTime(json, 'CreatedAt'), + finishedAt: JsonReader.optionalDateTime(json, 'FinishedAt'), + ); + } + + Map toJson() { + return { + 'Name': name, + if (id != null) 'Id': id, + if (description != null) 'Description': description, + if (status != null) 'Status': status, + if (progress != null) 'Progress': progress, + if (createdAt != null) 'CreatedAt': createdAt!.toIso8601String(), + if (finishedAt != null) 'FinishedAt': finishedAt!.toIso8601String(), + }; + } + + @override + String toString() => 'AgentTaskDto(name: $name, status: $status)'; +} diff --git a/ac_web/lib/Api/Dtos/EAgentStatus.dart b/ac_web/lib/Api/Dtos/EAgentStatus.dart new file mode 100644 index 0000000..3bec08a --- /dev/null +++ b/ac_web/lib/Api/Dtos/EAgentStatus.dart @@ -0,0 +1,27 @@ +/// Mirror of the backend `EAgentStatus` enum. +/// +/// See the note in `EAgentType.dart` about ordering and [unknown]. +enum EAgentStatus { + offline, + idle, + working, + error, + unknown; + + bool get isOnline => this == EAgentStatus.idle || this == EAgentStatus.working; + + String get displayName { + switch (this) { + case EAgentStatus.offline: + return 'Offline'; + case EAgentStatus.idle: + return 'Idle'; + case EAgentStatus.working: + return 'Working'; + case EAgentStatus.error: + return 'Error'; + case EAgentStatus.unknown: + return 'Unknown'; + } + } +} diff --git a/ac_web/lib/Api/Dtos/EAgentType.dart b/ac_web/lib/Api/Dtos/EAgentType.dart new file mode 100644 index 0000000..6ca5ca7 --- /dev/null +++ b/ac_web/lib/Api/Dtos/EAgentType.dart @@ -0,0 +1,29 @@ +/// Mirror of the backend `EAgentType` enum. +/// +/// The order of the values has to match the order in the .Net enum, because +/// the api may serialize enums as their numeric value. [unknown] is kept last +/// so that it never shadows a real backend value and acts as fallback for +/// values this client does not know yet. +enum EAgentType { + developer, + tester, + reviewer, + manager, + unknown; + + /// Human readable label, e.g. for chips or table cells. + String get displayName { + switch (this) { + case EAgentType.developer: + return 'Developer'; + case EAgentType.tester: + return 'Tester'; + case EAgentType.reviewer: + return 'Reviewer'; + case EAgentType.manager: + return 'Manager'; + case EAgentType.unknown: + return 'Unknown'; + } + } +} diff --git a/ac_web/lib/Api/Dtos/ReportDto.dart b/ac_web/lib/Api/Dtos/ReportDto.dart new file mode 100644 index 0000000..8605e72 --- /dev/null +++ b/ac_web/lib/Api/Dtos/ReportDto.dart @@ -0,0 +1,45 @@ +import 'package:ac_web/Api/JsonReader.dart'; + +/// Dart counterpart of the backend `ReportDto` returned by `GET /reports`. +class ReportDto { + const ReportDto({ + required this.id, + required this.author, + required this.timestamp, + required this.type, + required this.text, + }); + + final String id; + final String author; + final DateTime timestamp; + + /// Free form report type. The backend sends this as a plain string, so it is + /// intentionally not modelled as an enum here. + final String type; + + final String text; + + factory ReportDto.fromJson(Map json) { + return ReportDto( + id: JsonReader.requireString(json, 'Id'), + author: JsonReader.requireString(json, 'Author'), + timestamp: JsonReader.requireDateTime(json, 'Timestamp'), + type: JsonReader.optionalString(json, 'Type') ?? '', + text: JsonReader.optionalString(json, 'Text') ?? '', + ); + } + + Map toJson() { + return { + 'Id': id, + 'Author': author, + 'Timestamp': timestamp.toIso8601String(), + 'Type': type, + 'Text': text, + }; + } + + @override + String toString() => 'ReportDto(id: $id, author: $author, type: $type)'; +} diff --git a/ac_web/lib/Api/JsonReader.dart b/ac_web/lib/Api/JsonReader.dart new file mode 100644 index 0000000..f0f048f --- /dev/null +++ b/ac_web/lib/Api/JsonReader.dart @@ -0,0 +1,138 @@ +/// Small helper that makes reading .Net json payloads defensive. +/// +/// The backend is still in development, so property names might arrive in +/// PascalCase or camelCase and single properties might be missing. These +/// helpers keep the dto factories short and readable. +class JsonReader { + const JsonReader._(); + + /// Looks a key up, ignoring casing differences between .Net and Dart. + static dynamic value(Map json, String key) { + if (json.containsKey(key)) { + return json[key]; + } + + final lowerKey = key.toLowerCase(); + for (final entry in json.entries) { + if (entry.key.toLowerCase() == lowerKey) { + return entry.value; + } + } + return null; + } + + static String requireString(Map json, String key) { + final raw = value(json, key); + if (raw == null) { + throw FormatException('Missing required property "$key".'); + } + return raw.toString(); + } + + static String? optionalString(Map json, String key) { + final raw = value(json, key); + return raw?.toString(); + } + + static DateTime requireDateTime(Map json, String key) { + final parsed = optionalDateTime(json, key); + if (parsed == null) { + throw FormatException('Missing or invalid date property "$key".'); + } + return parsed; + } + + static DateTime? optionalDateTime(Map json, String key) { + final raw = value(json, key); + if (raw == null) { + return null; + } + if (raw is DateTime) { + return raw; + } + return DateTime.tryParse(raw.toString()); + } + + static bool? optionalBool(Map json, String key) { + final raw = value(json, key); + if (raw == null) { + return null; + } + if (raw is bool) { + return raw; + } + final text = raw.toString().toLowerCase(); + if (text == 'true' || text == '1') { + return true; + } + if (text == 'false' || text == '0') { + return false; + } + return null; + } + + static int? optionalInt(Map json, String key) { + final raw = value(json, key); + if (raw == null) { + return null; + } + if (raw is int) { + return raw; + } + if (raw is num) { + return raw.toInt(); + } + return int.tryParse(raw.toString()); + } + + /// Maps a json array onto a list of models, tolerating a missing property. + static List list( + Map json, + String key, + T Function(Map json) fromJson, + ) { + final raw = value(json, key); + if (raw is! List) { + return []; + } + return raw + .whereType() + .map((entry) => fromJson(Map.from(entry))) + .toList(growable: false); + } + + /// Resolves a .Net enum that can arrive either as its name or as its index. + static T enumValue( + Map json, + String key, + List values, + String Function(T value) nameOf, + T fallback, + ) { + final raw = value(json, key); + if (raw == null) { + return fallback; + } + + if (raw is num) { + final index = raw.toInt(); + if (index >= 0 && index < values.length) { + return values[index]; + } + return fallback; + } + + final text = raw.toString().trim().toLowerCase(); + for (final candidate in values) { + if (nameOf(candidate).toLowerCase() == text) { + return candidate; + } + } + + final index = int.tryParse(text); + if (index != null && index >= 0 && index < values.length) { + return values[index]; + } + return fallback; + } +} diff --git a/ac_web/lib/Api/Services/AgentsService.dart b/ac_web/lib/Api/Services/AgentsService.dart new file mode 100644 index 0000000..4d176c9 --- /dev/null +++ b/ac_web/lib/Api/Services/AgentsService.dart @@ -0,0 +1,15 @@ +import 'package:ac_web/Api/Dtos/AgentDto.dart'; +import 'package:ac_web/Api/Services/ApiServiceBase.dart'; + +/// Access to the backend `AgentsController` (`/agents`). +class AgentsService extends ApiServiceBase { + const AgentsService(super.client); + + @override + String get controllerRoute => 'agents'; + + /// `GET /agents` - all known agents including their current tasks. + Future> getAgents() { + return client.getList(route(), AgentDto.fromJson); + } +} diff --git a/ac_web/lib/Api/Services/ApiServiceBase.dart b/ac_web/lib/Api/Services/ApiServiceBase.dart new file mode 100644 index 0000000..03a36d3 --- /dev/null +++ b/ac_web/lib/Api/Services/ApiServiceBase.dart @@ -0,0 +1,24 @@ +import 'package:ac_web/Api/ApiClient.dart'; + +/// Base class for every service that maps onto one backend controller. +/// +/// Adding support for a new controller means: +/// 1. create the dtos under `Api/Dtos` +/// 2. create a `Service extends ApiServiceBase` with `controllerRoute` +/// 3. expose it as a lazy getter on [AcApi] +abstract class ApiServiceBase { + const ApiServiceBase(this.client); + + final ApiClient client; + + /// Route of the controller without leading slash, e.g. `agents`. + String get controllerRoute; + + /// Combines the controller route with an optional relative sub path. + String route([String? subPath]) { + if (subPath == null || subPath.isEmpty) { + return controllerRoute; + } + return '$controllerRoute/$subPath'; + } +} diff --git a/ac_web/lib/Api/Services/ReportsService.dart b/ac_web/lib/Api/Services/ReportsService.dart new file mode 100644 index 0000000..a70686d --- /dev/null +++ b/ac_web/lib/Api/Services/ReportsService.dart @@ -0,0 +1,18 @@ +import 'package:ac_web/Api/Dtos/ReportDto.dart'; +import 'package:ac_web/Api/Services/ApiServiceBase.dart'; + +/// Access to the backend `ReportsController` (`/reports`). +class ReportsService extends ApiServiceBase { + const ReportsService(super.client); + + @override + String get controllerRoute => 'reports'; + + /// `GET /reports` - all reports, newest first. + Future> getReports() async { + final reports = await client.getList(route(), ReportDto.fromJson); + final sorted = List.of(reports); + sorted.sort((left, right) => right.timestamp.compareTo(left.timestamp)); + return sorted; + } +} diff --git a/ac_web/lib/main.dart b/ac_web/lib/main.dart index 43bcc67..afe0449 100644 --- a/ac_web/lib/main.dart +++ b/ac_web/lib/main.dart @@ -1,7 +1,12 @@ +import 'package:ac_web/Api/Api.dart'; import 'package:ac_web/Layouts/MainApp.dart'; import 'package:flutter/material.dart'; void main() { + // Rest API of the backend. Plain http is fine, we only talk inside of a + // trusted network. + AcApi.configure(const ApiConfig(host: 'localhost', port: 5000)); + runApp(const MainApp()); } diff --git a/ac_web/pubspec.lock b/ac_web/pubspec.lock index 582d01a..0018ada 100644 --- a/ac_web/pubspec.lock +++ b/ac_web/pubspec.lock @@ -75,6 +75,22 @@ packages: description: flutter source: sdk version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" leak_tracker: dependency: transitive description: @@ -192,6 +208,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -208,6 +232,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" sdks: dart: ">=3.12.2 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/ac_web/pubspec.yaml b/ac_web/pubspec.yaml index 836f225..c378035 100644 --- a/ac_web/pubspec.yaml +++ b/ac_web/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + http: ^1.6.0 dev_dependencies: flutter_test: diff --git a/ac_web/test/Api/ApiLayerTest.dart b/ac_web/test/Api/ApiLayerTest.dart new file mode 100644 index 0000000..ee5befe --- /dev/null +++ b/ac_web/test/Api/ApiLayerTest.dart @@ -0,0 +1,165 @@ +import 'dart:convert'; + +import 'package:ac_web/Api/Api.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +const ApiConfig testConfig = ApiConfig(host: 'api.local', port: 5000, basePath: 'api'); + +AcApi apiReturning(String body, {int statusCode = 200, void Function(Uri uri)? onRequest}) { + final client = MockClient((request) async { + onRequest?.call(request.url); + return http.Response(body, statusCode, headers: {'content-type': 'application/json'}); + }); + return AcApi(config: testConfig, httpClient: client); +} + +void main() { + group('ApiConfig', () { + test('builds http uris including base path and query', () { + final uri = testConfig.buildUri('agents', {'onlyOnline': true, 'skip': null}); + + expect(uri.toString(), 'http://api.local:5000/api/agents?onlyOnline=true'); + }); + }); + + group('AgentsService', () { + test('parses agents with enums as names and nested tasks', () async { + Uri? requested; + final api = apiReturning( + jsonEncode([ + { + 'name': 'Ada', + 'type': 'Developer', + 'lastSeen': '2026-08-28T12:00:00Z', + 'status': 'Working', + 'tasks': [ + {'name': 'Implement api layer', 'status': 'InProgress', 'progress': 42}, + ], + }, + ]), + onRequest: (uri) => requested = uri, + ); + + final agents = await api.agents.getAgents(); + + expect(requested.toString(), 'http://api.local:5000/api/agents'); + expect(agents, hasLength(1)); + expect(agents.single.name, 'Ada'); + expect(agents.single.type, EAgentType.developer); + expect(agents.single.status, EAgentStatus.working); + expect(agents.single.lastSeen.isUtc, isTrue); + expect(agents.single.tasks.single.name, 'Implement api layer'); + expect(agents.single.tasks.single.progress, 42); + }); + + test('parses numeric enums and falls back to unknown', () async { + final api = apiReturning( + jsonEncode([ + { + 'Name': 'Numeric', + 'Type': 1, + 'LastSeen': '2026-08-28T12:00:00Z', + 'Status': 2, + 'Tasks': [], + }, + { + 'Name': 'Future', + 'Type': 'SomethingNew', + 'LastSeen': '2026-08-28T12:00:00Z', + 'Status': 'AlsoNew', + 'Tasks': [], + }, + ]), + ); + + final agents = await api.agents.getAgents(); + + expect(agents.first.type, EAgentType.tester); + expect(agents.first.status, EAgentStatus.working); + expect(agents.first.tasks, isEmpty); + expect(agents.last.type, EAgentType.unknown); + expect(agents.last.status, EAgentStatus.unknown); + }); + + test('throws ApiParsingException on a malformed payload', () async { + final api = apiReturning(jsonEncode([{'Type': 'Developer'}])); + + expect(() => api.agents.getAgents(), throwsA(isA())); + }); + }); + + group('ReportsService', () { + test('returns reports sorted newest first', () async { + final api = apiReturning( + jsonEncode([ + { + 'Id': 'older', + 'Author': 'Ada', + 'Timestamp': '2026-08-20T10:00:00Z', + 'Type': 'Info', + 'Text': 'first', + }, + { + 'Id': 'newer', + 'Author': 'Grace', + 'Timestamp': '2026-08-27T10:00:00Z', + 'Type': 'Warning', + 'Text': 'second', + }, + ]), + ); + + final reports = await api.reports.getReports(); + + expect(reports.map((report) => report.id), ['newer', 'older']); + expect(reports.first.author, 'Grace'); + expect(reports.first.type, 'Warning'); + }); + + test('maps error status codes onto ApiHttpException', () async { + final api = apiReturning('{"error":"boom"}', statusCode: 500); + + await expectLater( + api.reports.getReports(), + throwsA(isA().having((error) => error.isServerError, 'isServerError', isTrue)), + ); + }); + + test('maps transport failures onto ApiNetworkException', () async { + final api = AcApi( + config: testConfig, + httpClient: MockClient((request) async => throw http.ClientException('offline')), + ); + + await expectLater(api.reports.getReports(), throwsA(isA())); + }); + + test('treats an empty body as an empty list', () async { + final api = apiReturning('', statusCode: 204); + + expect(await api.reports.getReports(), isEmpty); + }); + }); + + group('Dto round trip', () { + test('agent survives toJson/fromJson', () { + final original = AgentDto( + name: 'Ada', + type: EAgentType.reviewer, + lastSeen: DateTime.utc(2026, 8, 28, 12), + tasks: const [AgentTaskDto(name: 'Review', status: 'Open')], + status: EAgentStatus.idle, + ); + + final restored = AgentDto.fromJson(original.toJson()); + + expect(restored.name, original.name); + expect(restored.type, original.type); + expect(restored.status, original.status); + expect(restored.lastSeen, original.lastSeen); + expect(restored.tasks.single.name, 'Review'); + }); + }); +}