| // Copyright (c) 2021, the Dart project authors. Please see the AUTHORS file | |
| // for details. All rights reserved. Use of this source code is governed by a | |
| // BSD-style license that can be found in the LICENSE file. | |
| import 'dart:async'; | |
| import 'dart:convert'; | |
| import 'dart:io'; | |
| import 'package:analysis_server/src/session_logger/session_logger.dart'; | |
| import 'package:analyzer/instrumentation/service.dart'; | |
| import 'package:analyzer/src/util/platform_info.dart'; | |
| import 'package:http/http.dart' as http; | |
| import 'package:meta/meta.dart'; | |
| /// A class for interacting with the Pub API. | |
| /// | |
| /// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md | |
| /// | |
| /// Failed requests will automatically be retried. | |
| class PubApi { | |
| static const packageNameListPath = '/api/package-name-completion-data'; | |
| static const packageInfoPath = '/api/packages'; | |
| /// Maximum number of retries if requests fail. | |
| static const maxFailedRequests = 5; | |
| /// Initial wait period between retries. Doubled for each failure (but restarts | |
| /// from this value for each new request). | |
| static int _failedRetryInitialDelaySeconds = 1; | |
| @visibleForTesting | |
| static set failedRetryInitialDelaySeconds(int value) { | |
| _failedRetryInitialDelaySeconds = value; | |
| } | |
| final InstrumentationService instrumentationService; | |
| final SessionLogger sessionLogger; | |
| final http.Client httpClient; | |
| /// The Base URL for hosted Pub packages, excluding the trailing slash. | |
| /// | |
| /// Returns 'https://pub.dev' if no hosted URL is set or the value | |
| /// is invalid. | |
| final String pubHostedUrl; | |
| final _headers = { | |
| 'Accept': 'application/vnd.pub.v2+json', | |
| 'Accept-Encoding': 'gzip', | |
| 'User-Agent': | |
| 'Dart Analysis Server/${platform.version.split(' ').first}' | |
| ' (+https://github.com/dart-lang/sdk)', | |
| }; | |
| new( | |
| this.instrumentationService, | |
| this.sessionLogger, | |
| http.Client? httpClient, | |
| String? envPubHostedUrl, | |
| ) : httpClient = httpClient != null | |
| ? _NoCloseHttpClient(httpClient) | |
| : http.Client(), | |
| pubHostedUrl = _validPubHostedUrl(envPubHostedUrl); | |
| /// Fetches a list of package names from the Pub API. | |
| /// | |
| /// Failed requests will be retried a number of times. If no successful response | |
| /// is received, will return null. | |
| Future<List<PubApiPackage>?> allPackages() async { | |
| var json = await _getJson('$pubHostedUrl$packageNameListPath'); | |
| if (json == null) { | |
| return null; | |
| } | |
| var packageNames = json['packages']; | |
| return packageNames is List | |
| ? packageNames.map((name) => PubApiPackage(name as String)).toList() | |
| : null; | |
| } | |
| void close() { | |
| httpClient.close(); | |
| } | |
| /// Fetches package details from the Pub API. | |
| /// | |
| /// Failed requests will be retried a number of times. If no successful response | |
| /// is received, will return null. | |
| Future<PubApiPackageDetails?> packageInfo(String packageName) async { | |
| var json = await _getJson('$pubHostedUrl$packageInfoPath/$packageName'); | |
| if (json == null) { | |
| return null; | |
| } | |
| var latest = json['latest'] as Map<String, Object?>?; | |
| if (latest == null) { | |
| return null; | |
| } | |
| var pubspec = latest['pubspec'] as Map<String, Object?>?; | |
| var description = pubspec != null | |
| ? pubspec['description'] as String? | |
| : null; | |
| var version = latest['version'] as String?; | |
| return PubApiPackageDetails(packageName, description, version); | |
| } | |
| /// Calls a pub API and decodes the resulting JSON. | |
| /// | |
| /// Automatically retries the request for specific types of failures after | |
| /// [_failedRetryInitialDelaySeconds] doubling each time. After [maxFailedRequests] | |
| /// requests or upon a 4XX response, will return `null` and not retry. | |
| Future<Map<String, Object?>?> _getJson(String url) async { | |
| var requestCount = 0; | |
| var retryAfterSeconds = _failedRetryInitialDelaySeconds; | |
| while (requestCount++ < maxFailedRequests) { | |
| try { | |
| var response = await httpClient.get(Uri.parse(url), headers: _headers); | |
| if (response.statusCode == 200) { | |
| instrumentationService.logInfo('Pub API request successful for $url'); | |
| sessionLogger.logInfo('Pub API request successful for $url'); | |
| return jsonDecode(response.body) as Map<String, Object?>?; | |
| } else if (response.statusCode >= 400 && response.statusCode < 500) { | |
| // Do not retry 4xx responses. | |
| instrumentationService.logError( | |
| 'Pub API returned ${response.statusCode} ${response.reasonPhrase} ' | |
| 'for $url. Not retrying.', | |
| ); | |
| sessionLogger.logException( | |
| exception: | |
| 'Pub API returned ${response.statusCode} ${response.reasonPhrase} ' | |
| 'for $url. Not retrying.', | |
| ); | |
| return null; | |
| } | |
| instrumentationService.logError( | |
| 'Pub API returned ${response.statusCode} ${response.reasonPhrase} ' | |
| 'for $url on attempt $requestCount', | |
| ); | |
| sessionLogger.logException( | |
| exception: | |
| 'Pub API returned ${response.statusCode} ${response.reasonPhrase} ' | |
| 'for $url on attempt $requestCount', | |
| ); | |
| } catch (e) { | |
| if (e is! IOException && e is! FormatException) { | |
| instrumentationService.logError( | |
| 'Error calling pub API for $url. Not retrying. $e', | |
| ); | |
| sessionLogger.logException( | |
| exception: 'Error calling pub API for $url. Not retrying. $e', | |
| ); | |
| return null; | |
| } | |
| instrumentationService.logError('Error calling pub API for $url: $e'); | |
| sessionLogger.logException( | |
| exception: 'Error calling pub API for $url: $e', | |
| ); | |
| } | |
| if (requestCount >= maxFailedRequests) { | |
| instrumentationService.logInfo( | |
| 'Pub API request failed after $requestCount requests', | |
| ); | |
| sessionLogger.logInfo( | |
| 'Pub API request failed after $requestCount requests', | |
| ); | |
| } else { | |
| // Sleep before the next try. | |
| await Future.delayed(Duration(seconds: retryAfterSeconds)); | |
| retryAfterSeconds *= 2; | |
| } | |
| } | |
| return null; | |
| } | |
| /// Returns a valid Pub base URL from [envPubHostedUrl] if valid, otherwise using | |
| /// the default 'https://pub.dev'. | |
| static String _validPubHostedUrl(String? envPubHostedUrl) { | |
| var validUrl = | |
| envPubHostedUrl != null && | |
| (Uri.tryParse(envPubHostedUrl)?.isAbsolute ?? false) | |
| ? envPubHostedUrl | |
| : 'https://pub.dev'; | |
| // Discard any trailing slashes, as all API paths start with them. | |
| return validUrl.endsWith('/') | |
| ? validUrl.substring(0, validUrl.length - 1) | |
| : validUrl; | |
| } | |
| } | |
| class PubApiPackage { | |
| final String packageName; | |
| new(this.packageName); | |
| } | |
| class PubApiPackageDetails { | |
| final String packageName; | |
| final String? description; | |
| final String? latestVersion; | |
| new(this.packageName, this.description, this.latestVersion); | |
| } | |
| /// A wrapper over a package:http Client that does not pass on calls to [close]. | |
| /// | |
| /// This is used to prevent the server closing a client that may be provided to | |
| /// it (while still allowing it to close any client it creates itself). | |
| class _NoCloseHttpClient extends http.BaseClient { | |
| final http.Client client; | |
| new(this.client); | |
| @override | |
| Future<http.StreamedResponse> send(http.BaseRequest request) => | |
| client.send(request); | |
| } |