import 'package:actors/actors.dart'; import 'package:http/http.dart' as http; import '../../mews/document.dart'; import '../../mews/parser.dart'; /// Commands accepted by the fetch actor. Network I/O and XML parsing both /// happen in the actor's isolate; results return by message only. sealed class FetchCommand { const FetchCommand(); } /// Fetch [url], sending validators when the page is cached. A 304 parses /// [cachedSource] in place of a body, and a network failure falls back to it, /// so the reader stays online-transparent and works offline. class FetchPage extends FetchCommand { const FetchPage({required this.url, this.etag, this.lastModified, this.cachedSource}); final String url; final String? etag; final String? lastModified; final String? cachedSource; } /// Submit a form's fields to its action URL (spec 4.2). class SubmitForm extends FetchCommand { const SubmitForm({required this.url, required this.method, required this.fields}); final String url; final String method; final Map fields; } sealed class FetchResult { const FetchResult(); } class FetchPageOk extends FetchResult { const FetchPageOk({ required this.page, required this.source, this.etag, this.lastModified, this.notModified = false, }); final MewsPage page; /// The markup the page was parsed from, for the cache. final String source; final String? etag; final String? lastModified; /// True when the page came from the local cache (304 or offline). final bool notModified; } class FetchFormOk extends FetchResult { const FetchFormOk(this.page); final MewsPage page; } class FetchNetworkError extends FetchResult { const FetchNetworkError(); } class FetchHttpStatusError extends FetchResult { const FetchHttpStatusError(this.status); final int status; } class FetchParseError extends FetchResult { const FetchParseError(this.message, this.source); final String message; /// The markup that failed to parse, for the "show page text" option. final String source; } class FetchHandler with Handler { // Identifies the client to servers that vary behavior by user agent, like // the http-to-https redirects some Mews sites run (spec 7.2). static const _userAgent = 'Mews/0.1 (Mews client)'; final http.Client _client = http.Client(); @override Future handle(FetchCommand message) async { return switch (message) { FetchPage(:final url, :final etag, :final lastModified, :final cachedSource) => await _fetchPage(url, etag, lastModified, cachedSource), SubmitForm(:final url, :final method, :final fields) => await _submitForm(url, method, fields), }; } Future _fetchPage( String url, String? etag, String? lastModified, String? cachedSource) async { final headers = {'User-Agent': _userAgent}; if (etag != null) headers['If-None-Match'] = etag; if (lastModified != null) headers['If-Modified-Since'] = lastModified; http.Response response; try { response = await _client .get(Uri.parse(url), headers: headers) .timeout(const Duration(seconds: 20)); } catch (_) { return _fromCacheOrError(url, cachedSource, etag, lastModified); } if (response.statusCode == 304) { return _fromCacheOrError(url, cachedSource, etag, lastModified); } if (response.statusCode != 200) return FetchHttpStatusError(response.statusCode); final newEtag = response.headers['etag']; final newLastModified = response.headers['last-modified']; return _parse(response.body, url, etag: newEtag, lastModified: newLastModified); } /// The page could not be revalidated; use the cached copy when there is one. FetchResult _fromCacheOrError(String url, String? cachedSource, String? etag, String? lastModified) { if (cachedSource == null) return const FetchNetworkError(); return _parse(cachedSource, url, etag: etag, lastModified: lastModified, notModified: true); } FetchResult _parse(String source, String url, {String? etag, String? lastModified, bool notModified = false}) { try { final page = parseMewsPage(source: source, url: url); return FetchPageOk( page: page, source: source, etag: etag, lastModified: lastModified, notModified: notModified); } on MewsParseException catch (e) { return FetchParseError(e.message, source); } } Future _submitForm(String url, String method, Map fields) async { final headers = {'User-Agent': _userAgent}; http.Response response; if (method == 'post') { response = await _client .post(Uri.parse(url), headers: headers, body: fields) .timeout(const Duration(seconds: 20)); } else { response = await _client .get(Uri.parse(url).replace(queryParameters: fields), headers: headers) .timeout(const Duration(seconds: 20)); } if (response.statusCode != 200) return FetchHttpStatusError(response.statusCode); return _parse(response.body, url); } @override Future close() async => _client.close(); }