feat(#404): config-profile import source (catalog + URL, tail-detection)
Device-agnostic source layer (observer/repeater/companion reuse it): - config_catalog.dart: profiles.json manifest model + resilient parse (skips malformed entries, rejects bad JSON / newer manifest_version; absent status = published, unknown status hidden). - config_source_service.dart: resolveSourceUrl tail-detection (.json=catalog, .yaml=profile, trailing-slash -> <dir>/profiles.json), fetchCatalog / fetchProfile over http with a user-facing ConfigSourceException. Default catalog = OffbandMesh/config-profiles raw profiles.json. 16 tests (tail-detection table + MockClient fetch paths + manifest parse). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>pull/455/head
parent
ddb2e20448
commit
9bc8560b65
@ -0,0 +1,150 @@
|
||||
import 'dart:convert';
|
||||
|
||||
// Catalog manifest model (#404): the `profiles.json` a source publishes.
|
||||
//
|
||||
// Schema is documented in the `OffbandMesh/config-profiles` repo (SCHEMA.md).
|
||||
// Parsing is resilient — a single malformed entry is skipped rather than
|
||||
// failing the whole catalog — but a manifest that isn't JSON, or declares a
|
||||
// newer `manifest_version` than we support, is rejected outright.
|
||||
|
||||
/// Highest manifest_version this build understands.
|
||||
const int kCatalogManifestVersion = 1;
|
||||
|
||||
/// Thrown when a manifest cannot be used at all (bad JSON / unsupported version).
|
||||
class ConfigCatalogFormatException implements Exception {
|
||||
const ConfigCatalogFormatException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => 'ConfigCatalogFormatException: $message';
|
||||
}
|
||||
|
||||
enum CatalogStatus {
|
||||
published,
|
||||
retired,
|
||||
|
||||
/// A status string this build doesn't recognize. Treated as not-published so
|
||||
/// an unknown future status never accidentally surfaces in the picker.
|
||||
unknown;
|
||||
|
||||
static CatalogStatus fromWire(String? v) {
|
||||
switch (v) {
|
||||
case 'published':
|
||||
return CatalogStatus.published;
|
||||
case 'retired':
|
||||
return CatalogStatus.retired;
|
||||
default:
|
||||
return CatalogStatus.unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One catalog entry. [name] and [url] are required; the rest are hints.
|
||||
class CatalogEntry {
|
||||
const CatalogEntry({
|
||||
required this.name,
|
||||
required this.url,
|
||||
this.description,
|
||||
this.region,
|
||||
this.schemaVersion,
|
||||
this.status = CatalogStatus.published,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String url;
|
||||
final String? description;
|
||||
final String? region;
|
||||
final int? schemaVersion;
|
||||
final CatalogStatus status;
|
||||
}
|
||||
|
||||
class ConfigCatalog {
|
||||
const ConfigCatalog({
|
||||
required this.manifestVersion,
|
||||
required this.entries,
|
||||
this.skippedEntries = 0,
|
||||
});
|
||||
|
||||
final int manifestVersion;
|
||||
final List<CatalogEntry> entries;
|
||||
|
||||
/// Count of malformed entries skipped during parse — surfaced so a partly-bad
|
||||
/// catalog doesn't look complete (no silent truncation).
|
||||
final int skippedEntries;
|
||||
|
||||
/// Entries the app should offer in the picker.
|
||||
List<CatalogEntry> get published =>
|
||||
entries.where((e) => e.status == CatalogStatus.published).toList();
|
||||
}
|
||||
|
||||
/// Parse a `profiles.json` manifest. Throws [ConfigCatalogFormatException] for a
|
||||
/// manifest that can't be used; skips individual malformed entries.
|
||||
ConfigCatalog parseCatalog(String source) {
|
||||
final dynamic doc;
|
||||
try {
|
||||
doc = jsonDecode(source);
|
||||
} on FormatException catch (e) {
|
||||
throw ConfigCatalogFormatException('Not valid JSON: ${e.message}');
|
||||
}
|
||||
if (doc is! Map) {
|
||||
throw const ConfigCatalogFormatException('manifest must be a JSON object');
|
||||
}
|
||||
|
||||
final version = doc['manifest_version'];
|
||||
if (version is! int) {
|
||||
throw const ConfigCatalogFormatException(
|
||||
'manifest_version is required and must be an integer',
|
||||
);
|
||||
}
|
||||
if (version > kCatalogManifestVersion) {
|
||||
throw ConfigCatalogFormatException(
|
||||
'manifest_version $version is newer than this app supports '
|
||||
'($kCatalogManifestVersion). Update the app.',
|
||||
);
|
||||
}
|
||||
|
||||
final rawProfiles = doc['profiles'];
|
||||
if (rawProfiles != null && rawProfiles is! List) {
|
||||
throw const ConfigCatalogFormatException('"profiles" must be a list');
|
||||
}
|
||||
|
||||
final entries = <CatalogEntry>[];
|
||||
var skipped = 0;
|
||||
for (final raw in (rawProfiles as List? ?? const [])) {
|
||||
if (raw is! Map) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
final name = raw['name'];
|
||||
final url = raw['url'];
|
||||
if (name is! String || name.isEmpty || url is! String || url.isEmpty) {
|
||||
skipped++; // name + url are the minimum an entry must carry
|
||||
continue;
|
||||
}
|
||||
entries.add(
|
||||
CatalogEntry(
|
||||
name: name,
|
||||
url: url,
|
||||
description: raw['description'] is String
|
||||
? raw['description'] as String
|
||||
: null,
|
||||
region: raw['region'] is String ? raw['region'] as String : null,
|
||||
schemaVersion: raw['schema_version'] is int
|
||||
? raw['schema_version'] as int
|
||||
: null,
|
||||
// Absent status = published (author just omitted it). A present-but-
|
||||
// unrecognized status resolves to unknown and is hidden (forward-safe).
|
||||
status: raw.containsKey('status')
|
||||
? CatalogStatus.fromWire(
|
||||
raw['status'] is String ? raw['status'] as String : null,
|
||||
)
|
||||
: CatalogStatus.published,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ConfigCatalog(
|
||||
manifestVersion: version,
|
||||
entries: entries,
|
||||
skippedEntries: skipped,
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../helpers/config_profile_parser.dart';
|
||||
import '../models/config_catalog.dart';
|
||||
import '../models/config_profile.dart';
|
||||
|
||||
/// Fetches config profiles from a remote source (#404): the curated Offband
|
||||
/// catalog, or any region's self-hosted catalog / single profile.
|
||||
///
|
||||
/// Device-agnostic — observer (#139), repeater (#137), and companion (#138)
|
||||
/// share this. Only the apply step differs per device.
|
||||
|
||||
/// Default curated catalog (repo: OffbandMesh/config-profiles).
|
||||
const String kDefaultCatalogUrl =
|
||||
'https://raw.githubusercontent.com/OffbandMesh/config-profiles/main/profiles.json';
|
||||
|
||||
/// What a user-entered URL points at, decided by its tail.
|
||||
enum SourceKind { catalog, profile }
|
||||
|
||||
class ResolvedSource {
|
||||
const ResolvedSource(this.kind, this.url);
|
||||
final SourceKind kind;
|
||||
|
||||
/// The URL to actually fetch (may differ from the input — a directory URL
|
||||
/// resolves to `<dir>/profiles.json`).
|
||||
final String url;
|
||||
}
|
||||
|
||||
/// Thrown on any fetch/decode failure; message is user-facing.
|
||||
class ConfigSourceException implements Exception {
|
||||
const ConfigSourceException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => 'ConfigSourceException: $message';
|
||||
}
|
||||
|
||||
/// Resolve a user-entered URL by its tail (no reliance on HTTP directory
|
||||
/// listing):
|
||||
/// - ends `.json` -> a catalog manifest
|
||||
/// - ends `.yaml` / `.yml` -> a single profile
|
||||
/// - otherwise (trailing `/` or no filename) -> `<url>profiles.json`
|
||||
ResolvedSource resolveSourceUrl(String input) {
|
||||
final trimmed = input.trim();
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) {
|
||||
throw const ConfigSourceException(
|
||||
'Enter a full http(s) URL to a catalog or a .yaml profile',
|
||||
);
|
||||
}
|
||||
|
||||
final lower = trimmed.toLowerCase();
|
||||
if (lower.endsWith('.json')) {
|
||||
return ResolvedSource(SourceKind.catalog, trimmed);
|
||||
}
|
||||
if (lower.endsWith('.yaml') || lower.endsWith('.yml')) {
|
||||
return ResolvedSource(SourceKind.profile, trimmed);
|
||||
}
|
||||
// Directory / bare host: append the conventional manifest filename.
|
||||
final base = trimmed.endsWith('/') ? trimmed : '$trimmed/';
|
||||
return ResolvedSource(SourceKind.catalog, '${base}profiles.json');
|
||||
}
|
||||
|
||||
class ConfigSourceService {
|
||||
ConfigSourceService({http.Client? client})
|
||||
: _client = client ?? http.Client();
|
||||
|
||||
final http.Client _client;
|
||||
|
||||
/// Fetch + parse a catalog manifest.
|
||||
Future<ConfigCatalog> fetchCatalog(String url) async {
|
||||
return parseCatalog(await _get(url));
|
||||
}
|
||||
|
||||
/// Fetch + parse a single profile YAML.
|
||||
Future<ConfigProfile> fetchProfile(String url) async {
|
||||
return parseConfigProfile(await _get(url));
|
||||
}
|
||||
|
||||
Future<String> _get(String url) async {
|
||||
final http.Response resp;
|
||||
try {
|
||||
resp = await _client.get(Uri.parse(url));
|
||||
} catch (e) {
|
||||
throw ConfigSourceException('Could not reach $url: $e');
|
||||
}
|
||||
if (resp.statusCode != 200) {
|
||||
throw ConfigSourceException('$url returned HTTP ${resp.statusCode}');
|
||||
}
|
||||
if (resp.body.isEmpty) {
|
||||
throw ConfigSourceException('$url returned an empty response');
|
||||
}
|
||||
return resp.body;
|
||||
}
|
||||
|
||||
void dispose() => _client.close();
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/models/config_catalog.dart';
|
||||
|
||||
void main() {
|
||||
group('parseCatalog', () {
|
||||
test('parses entries and exposes published only', () {
|
||||
final c = parseCatalog('''
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"profiles": [
|
||||
{"name": "A", "url": "https://x/a.yaml", "status": "published", "region": "IAD", "schema_version": 1},
|
||||
{"name": "B", "url": "https://x/b.yaml", "status": "retired"},
|
||||
{"name": "C", "url": "https://x/c.yaml"}
|
||||
]
|
||||
}
|
||||
''');
|
||||
expect(c.manifestVersion, 1);
|
||||
expect(c.entries.length, 3);
|
||||
// published + status-omitted (C) show; retired (B) hidden
|
||||
expect(c.published.map((e) => e.name), ['A', 'C']);
|
||||
expect(c.entries.first.region, 'IAD');
|
||||
});
|
||||
|
||||
test('skips malformed entries without failing the catalog', () {
|
||||
final c = parseCatalog('''
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"profiles": [
|
||||
{"name": "ok", "url": "https://x/a.yaml"},
|
||||
{"name": "no-url"},
|
||||
{"url": "https://x/no-name.yaml"},
|
||||
"not-an-object"
|
||||
]
|
||||
}
|
||||
''');
|
||||
expect(c.entries.length, 1);
|
||||
expect(c.skippedEntries, 3);
|
||||
});
|
||||
|
||||
test('unrecognized status is hidden', () {
|
||||
final c = parseCatalog(
|
||||
'{"manifest_version":1,"profiles":[{"name":"x","url":"https://x/x.yaml","status":"draft"}]}',
|
||||
);
|
||||
expect(c.entries.single.status, CatalogStatus.unknown);
|
||||
expect(c.published, isEmpty);
|
||||
});
|
||||
|
||||
test('rejects bad JSON', () {
|
||||
expect(
|
||||
() => parseCatalog('{not json'),
|
||||
throwsA(isA<ConfigCatalogFormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('requires manifest_version', () {
|
||||
expect(
|
||||
() => parseCatalog('{"profiles":[]}'),
|
||||
throwsA(isA<ConfigCatalogFormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a newer manifest_version', () {
|
||||
expect(
|
||||
() => parseCatalog('{"manifest_version":999,"profiles":[]}'),
|
||||
throwsA(isA<ConfigCatalogFormatException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:meshcore_open/models/config_profile.dart';
|
||||
import 'package:meshcore_open/services/config_source_service.dart';
|
||||
|
||||
void main() {
|
||||
group('resolveSourceUrl (tail-detection)', () {
|
||||
test('.json -> catalog, unchanged', () {
|
||||
final r = resolveSourceUrl('https://h/dir/profiles.json');
|
||||
expect(r.kind, SourceKind.catalog);
|
||||
expect(r.url, 'https://h/dir/profiles.json');
|
||||
});
|
||||
|
||||
test('.yaml / .yml -> profile, unchanged', () {
|
||||
expect(resolveSourceUrl('https://h/p.yaml').kind, SourceKind.profile);
|
||||
expect(resolveSourceUrl('https://h/p.yml').kind, SourceKind.profile);
|
||||
});
|
||||
|
||||
test('trailing slash -> <dir>/profiles.json', () {
|
||||
final r = resolveSourceUrl('https://h/dir/');
|
||||
expect(r.kind, SourceKind.catalog);
|
||||
expect(r.url, 'https://h/dir/profiles.json');
|
||||
});
|
||||
|
||||
test('bare host / no filename -> profiles.json appended', () {
|
||||
final r = resolveSourceUrl('https://h/dir');
|
||||
expect(r.kind, SourceKind.catalog);
|
||||
expect(r.url, 'https://h/dir/profiles.json');
|
||||
});
|
||||
|
||||
test('case-insensitive extension', () {
|
||||
expect(resolveSourceUrl('https://h/P.YAML').kind, SourceKind.profile);
|
||||
expect(resolveSourceUrl('https://h/M.JSON').kind, SourceKind.catalog);
|
||||
});
|
||||
|
||||
test('rejects non-http input', () {
|
||||
expect(
|
||||
() => resolveSourceUrl('ftp://h/x.yaml'),
|
||||
throwsA(isA<ConfigSourceException>()),
|
||||
);
|
||||
expect(
|
||||
() => resolveSourceUrl('not a url'),
|
||||
throwsA(isA<ConfigSourceException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('ConfigSourceService fetch', () {
|
||||
test('fetchProfile parses a served YAML', () async {
|
||||
final svc = ConfigSourceService(
|
||||
client: MockClient((req) async {
|
||||
return http.Response('schema_version: 1\nregion: IAD\n', 200);
|
||||
}),
|
||||
);
|
||||
final p = await svc.fetchProfile('https://h/p.yaml');
|
||||
expect(p, isA<ConfigProfile>());
|
||||
expect(p.regionIata, 'IAD');
|
||||
});
|
||||
|
||||
test('fetchCatalog parses a served manifest', () async {
|
||||
final svc = ConfigSourceService(
|
||||
client: MockClient((req) async {
|
||||
return http.Response(
|
||||
'{"manifest_version":1,"profiles":[{"name":"A","url":"https://h/a.yaml"}]}',
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
final c = await svc.fetchCatalog('https://h/profiles.json');
|
||||
expect(c.published.single.name, 'A');
|
||||
});
|
||||
|
||||
test('non-200 throws ConfigSourceException', () async {
|
||||
final svc = ConfigSourceService(
|
||||
client: MockClient((req) async => http.Response('nope', 404)),
|
||||
);
|
||||
expect(
|
||||
() => svc.fetchProfile('https://h/missing.yaml'),
|
||||
throwsA(isA<ConfigSourceException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('empty body throws ConfigSourceException', () async {
|
||||
final svc = ConfigSourceService(
|
||||
client: MockClient((req) async => http.Response('', 200)),
|
||||
);
|
||||
expect(
|
||||
() => svc.fetchCatalog('https://h/profiles.json'),
|
||||
throwsA(isA<ConfigSourceException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue