Setting up basic API Structure

This commit is contained in:
2026-08-28 14:54:54 +02:00
parent 968228cbc3
commit b3a13cd5a2
18 changed files with 974 additions and 0 deletions
+57
View File
@@ -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();
}
}
+18
View File
@@ -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';
+164
View File
@@ -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);
}
}
}
+74
View File
@@ -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,
);
}
}
+46
View File
@@ -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;
}
+58
View File
@@ -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})';
}
+58
View File
@@ -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)';
}
+27
View File
@@ -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';
}
}
}
+29
View File
@@ -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';
}
}
}
+45
View File
@@ -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)';
}
+138
View File
@@ -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;
}
}