Setting up basic API Structure
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<dynamic> getJson(String path, {Map<String, dynamic>? query, Map<String, String>? headers}) {
|
||||
return _send('GET', path, query: query, headers: headers);
|
||||
}
|
||||
|
||||
/// Performs a GET request and maps a json array onto a list of models.
|
||||
Future<List<T>> getList<T>(
|
||||
String path,
|
||||
T Function(Map<String, dynamic> json) fromJson, {
|
||||
Map<String, dynamic>? query,
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
final uri = mConfig.buildUri(path, query);
|
||||
final body = await _send('GET', path, query: query, headers: headers);
|
||||
|
||||
if (body == null) {
|
||||
return <T>[];
|
||||
}
|
||||
if (body is! List) {
|
||||
throw ApiParsingException('Expected a json array but got ${body.runtimeType}.', uri: uri);
|
||||
}
|
||||
|
||||
try {
|
||||
return body
|
||||
.whereType<Map>()
|
||||
.map((entry) => fromJson(Map<String, dynamic>.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<T> getObject<T>(
|
||||
String path,
|
||||
T Function(Map<String, dynamic> json) fromJson, {
|
||||
Map<String, dynamic>? query,
|
||||
Map<String, String>? 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<String, dynamic>.from(body));
|
||||
} on ApiException {
|
||||
rethrow;
|
||||
} catch (error) {
|
||||
throw ApiParsingException('Failed to parse $T: $error', uri: uri);
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> postJson(
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, dynamic>? query,
|
||||
Map<String, String>? headers,
|
||||
}) {
|
||||
return _send('POST', path, body: body, query: query, headers: headers);
|
||||
}
|
||||
|
||||
Future<dynamic> putJson(
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, dynamic>? query,
|
||||
Map<String, String>? headers,
|
||||
}) {
|
||||
return _send('PUT', path, body: body, query: query, headers: headers);
|
||||
}
|
||||
|
||||
Future<dynamic> deleteJson(
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, dynamic>? query,
|
||||
Map<String, String>? headers,
|
||||
}) {
|
||||
return _send('DELETE', path, body: body, query: query, headers: headers);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (mOwnsHttpClient) {
|
||||
mHttpClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> _send(
|
||||
String method,
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, dynamic>? query,
|
||||
Map<String, String>? 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> 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<String, String>? 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<String, dynamic>? queryParameters]) {
|
||||
final segments = <String>[
|
||||
...basePath.split('/'),
|
||||
...path.split('/'),
|
||||
].where((segment) => segment.isNotEmpty).toList();
|
||||
|
||||
Map<String, String>? query;
|
||||
if (queryParameters != null && queryParameters.isNotEmpty) {
|
||||
query = <String, String>{};
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<AgentTaskDto> tasks;
|
||||
final EAgentStatus status;
|
||||
|
||||
bool get hasTasks => tasks.isNotEmpty;
|
||||
|
||||
factory AgentDto.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'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})';
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'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)';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'Id': id,
|
||||
'Author': author,
|
||||
'Timestamp': timestamp.toIso8601String(),
|
||||
'Type': type,
|
||||
'Text': text,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'ReportDto(id: $id, author: $author, type: $type)';
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic> json, String key) {
|
||||
final raw = value(json, key);
|
||||
if (raw == null) {
|
||||
throw FormatException('Missing required property "$key".');
|
||||
}
|
||||
return raw.toString();
|
||||
}
|
||||
|
||||
static String? optionalString(Map<String, dynamic> json, String key) {
|
||||
final raw = value(json, key);
|
||||
return raw?.toString();
|
||||
}
|
||||
|
||||
static DateTime requireDateTime(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<T> list<T>(
|
||||
Map<String, dynamic> json,
|
||||
String key,
|
||||
T Function(Map<String, dynamic> json) fromJson,
|
||||
) {
|
||||
final raw = value(json, key);
|
||||
if (raw is! List) {
|
||||
return <T>[];
|
||||
}
|
||||
return raw
|
||||
.whereType<Map>()
|
||||
.map((entry) => fromJson(Map<String, dynamic>.from(entry)))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// Resolves a .Net enum that can arrive either as its name or as its index.
|
||||
static T enumValue<T>(
|
||||
Map<String, dynamic> json,
|
||||
String key,
|
||||
List<T> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<List<AgentDto>> getAgents() {
|
||||
return client.getList(route(), AgentDto.fromJson);
|
||||
}
|
||||
}
|
||||
@@ -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 `<Name>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';
|
||||
}
|
||||
}
|
||||
@@ -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<List<ReportDto>> getReports() async {
|
||||
final reports = await client.getList(route(), ReportDto.fromJson);
|
||||
final sorted = List<ReportDto>.of(reports);
|
||||
sorted.sort((left, right) => right.timestamp.compareTo(left.timestamp));
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<ApiParsingException>()));
|
||||
});
|
||||
});
|
||||
|
||||
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<ApiHttpException>().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<ApiNetworkException>()));
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user