import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:http/http.dart'; import 'package:logging/logging.dart'; import 'package:result_monad/result_monad.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../globals.dart'; import '../../models/exec_error.dart'; import '../../models/networking/paged_response.dart'; import '../globals_services.dart'; import '../rp_provider_extension.dart'; import '../settings_services.dart'; part 'network_services.g.dart'; http.Response requestTimeout() => http.Response('Client side timeout', 408); class NetworkRequest { final Uri url; final Map? body; final Map headers; final Duration? timeout; NetworkRequest( this.url, { this.body, this.headers = const {}, this.timeout, }); @override String toString() { return 'NetworkRequest{url: $url, body: $body, headers: $headers, timeout: $timeout}'; } @override bool operator ==(Object other) => identical(this, other) || other is NetworkRequest && runtimeType == other.runtimeType && url == other.url && mapEquals(body, other.body) && mapEquals(headers, other.headers) && timeout == other.timeout; @override int get hashCode => url.hashCode ^ Object.hashAll(body?.entries.toList() ?? []) ^ Object.hashAll(headers.entries.toList()) ^ timeout.hashCode; } final _logger = Logger('NetworkServicesProviders'); extension _DebounceAndCancelExtension on Ref { /// Wait for [duration] (defaults to 500ms), and then return a [http.Client] /// which can be used to make a request. /// /// That client will automatically be closed when the provider is disposed. Future getDebouncedHttpClient([Duration? duration]) async { // First, we handle debouncing. var didDispose = false; onDispose(() => didDispose = true); // We delay multiple requests in a row within a specified interval or 0.5 s await Future.delayed(duration ?? const Duration(milliseconds: 500)); // If the provider was disposed during the delay, it means that the user // refreshed again. We throw an exception to cancel the request. // It is safe to use an exception here, as it will be caught by Riverpod. if (didDispose) { throw Exception('Cancelled'); } // We now create the client and close it when the provider is disposed. final client = http.Client(); onDispose(client.close); // Finally, we return the client to allow our provider to make the request. return client; } } @riverpod Map userAgentHeader(Ref ref) { final userAgent = ref.watch(userAgentProvider); return { 'user-agent': userAgent, }; } @riverpod Future> httpGetRawBytes( Ref ref, NetworkRequest request, ) async { _logger.fine('GET Raw Bytes: ${request.url}'); //TODO determine if this timeout needs to be the timeout for the call or how long it sticks around for after it is first executed final timeoutSetting = ref.read(friendicaApiTimeoutSettingProvider); final timeout = request.timeout ?? timeoutSetting; ref.cacheFor(timeout); final requestHeaders = Map.from(request.headers); if (usePhpDebugging) { requestHeaders['Cookie'] = 'XDEBUG_SESSION=PHPSTORM;path=/'; } try { final client = await ref.getDebouncedHttpClient(); final httpRequest = client.get( request.url, headers: requestHeaders, ); final response = await httpRequest.timeout( timeout, onTimeout: requestTimeout, ); if (response.statusCode != 200) { return Result.error(_buildErrorFromResponse(response)); } return Result.ok(response.bodyBytes); } catch (e) { return Result.error( ExecError(type: ErrorType.localError, message: e.toString())); } } @riverpod Future, ExecError>> httpGet( Ref ref, NetworkRequest request, ) async { _logger.fine('GET: ${request.url}'); //TODO determine if this timeout needs to be the timeout for the call or how long it sticks around for after it is first executed final timeoutSetting = ref.read(friendicaApiTimeoutSettingProvider); final timeout = request.timeout ?? timeoutSetting; ref.cacheFor(timeout); final requestHeaders = Map.from(request.headers); if (usePhpDebugging) { requestHeaders['Cookie'] = 'XDEBUG_SESSION=PHPSTORM;path=/'; } try { final client = await ref.getDebouncedHttpClient(); final httpRequest = client.get( request.url, headers: requestHeaders, ); final response = await httpRequest.timeout( timeout, onTimeout: requestTimeout, ); if (response.statusCode != 200) { return Result.error(_buildErrorFromResponse(response)); } return PagedResponse.fromLinkHeader( response.headers['link'], utf8.decode(response.bodyBytes), ); } catch (e) { return Result.error( ExecError(type: ErrorType.localError, message: e.toString())); } } @riverpod Future> httpPost( Ref ref, NetworkRequest request, ) async { _logger.fine('POST: ${request.url} \n Body: ${request.body}'); final timeoutSetting = ref.read(friendicaApiTimeoutSettingProvider); final timeout = request.timeout ?? timeoutSetting; ref.cacheFor(timeout); final requestHeaders = Map.from(request.headers); if (usePhpDebugging) { requestHeaders['Cookie'] = 'XDEBUG_SESSION=PHPSTORM;path=/'; } try { final client = await ref.getDebouncedHttpClient(); final httpRequest = client.post( request.url, headers: requestHeaders, body: jsonEncode(request.body), ); final response = await httpRequest.timeout( timeout, onTimeout: requestTimeout, ); if (response.statusCode != 200) { return Result.error(_buildErrorFromResponse(response)); } return Result.ok(utf8.decode(response.bodyBytes)); } catch (e) { return Result.error( ExecError(type: ErrorType.localError, message: e.toString())); } } @riverpod Future> httpPut( Ref ref, NetworkRequest request, ) async { _logger.fine('PUT: ${request.url} \n Body: ${request.body}'); final timeoutSetting = ref.read(friendicaApiTimeoutSettingProvider); final timeout = request.timeout ?? timeoutSetting; ref.cacheFor(timeout); final requestHeaders = Map.from(request.headers); if (usePhpDebugging) { requestHeaders['Cookie'] = 'XDEBUG_SESSION=PHPSTORM;path=/'; } try { final client = await ref.getDebouncedHttpClient(); final httpRequest = client.put( request.url, headers: requestHeaders, body: jsonEncode(request.body), ); final response = await httpRequest.timeout( timeout, onTimeout: requestTimeout, ); if (response.statusCode != 200) { return Result.error(_buildErrorFromResponse(response)); } return Result.ok(utf8.decode(response.bodyBytes)); } catch (e) { return Result.error( ExecError(type: ErrorType.localError, message: e.toString())); } } @riverpod Future> httpDelete( Ref ref, NetworkRequest request, ) async { _logger.fine('DELETE: ${request.url}'); final timeoutSetting = ref.read(friendicaApiTimeoutSettingProvider); final timeout = request.timeout ?? timeoutSetting; ref.cacheFor(timeout); final requestHeaders = Map.from(request.headers); if (usePhpDebugging) { requestHeaders['Cookie'] = 'XDEBUG_SESSION=PHPSTORM;path=/'; } try { final client = await ref.getDebouncedHttpClient(); final httpRequest = client.delete( request.url, headers: requestHeaders, body: jsonEncode(request.body), ); final response = await httpRequest.timeout( timeout, onTimeout: requestTimeout, ); if (response.statusCode != 200) { return Result.error(_buildErrorFromResponse(response)); } return Result.ok(utf8.decode(response.bodyBytes)); } catch (e) { return Result.error( ExecError(type: ErrorType.localError, message: e.toString())); } } ExecError _buildErrorFromResponse(Response response) { final type = switch (response.statusCode) { 401 => ErrorType.authentication, 404 => ErrorType.notFound, 408 => ErrorType.timeoutError, _ => ErrorType.serverError, }; final msg = '${response.statusCode}: ${response.reasonPhrase}'; return ExecError(type: type, message: msg); }