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
Strycher 2 months ago
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…
Cancel
Save

Powered by TurnKey Linux.