diff --git a/app_dart/bin/gae_server.dart b/app_dart/bin/gae_server.dart index 02172929a9..4fe15669a2 100644 --- a/app_dart/bin/gae_server.dart +++ b/app_dart/bin/gae_server.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:io'; +import 'dart:io' as io; import 'package:appengine/appengine.dart'; import 'package:cocoon_server/google_auth_provider.dart'; @@ -20,6 +20,7 @@ import 'package:cocoon_service/src/service/firebase_jwt_validator.dart'; import 'package:cocoon_service/src/service/flags/dynamic_config_updater.dart'; import 'package:cocoon_service/src/service/get_files_changed.dart'; import 'package:cocoon_service/src/service/scheduler/ci_yaml_fetcher.dart'; +import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; Future main() async { @@ -53,6 +54,7 @@ Future main() async { projectId: Config.flutterGcpProjectId, ), initialConfig: dynamicConfig, + httpClient: MappingHttpClient(http.Client()), ); // Start updating the config to loop forever. If this fails, it will log // every ~1 minute. @@ -75,12 +77,16 @@ Future main() async { final buildBucketClient = BuildBucketClient( accessTokenService: AccessTokenService.defaultProvider(config), + httpClient: config.httpClient, ); // Gerrit service class to communicate with GoB. final gerritService = GerritService( config: config, - authClient: await const GoogleAuthProvider().createClient(scopes: []), + authClient: await const GoogleAuthProvider().createClient( + baseClient: config.httpClient, + scopes: [], + ), ); /// LUCI service class to communicate with buildBucket service. @@ -155,10 +161,10 @@ Future main() async { ); return runAppEngine( - (HttpRequest request) async { + (io.HttpRequest request) async { await server(request.toRequest()); }, - onAcceptingConnections: (InternetAddress address, int port) { + onAcceptingConnections: (io.InternetAddress address, int port) { final host = address.isLoopback ? 'localhost' : address.host; print('Serving requests at http://$host:$port/'); }, diff --git a/app_dart/lib/src/request_handling/http_io.dart b/app_dart/lib/src/request_handling/http_io.dart index 6630df7711..92ef77ffd2 100644 --- a/app_dart/lib/src/request_handling/http_io.dart +++ b/app_dart/lib/src/request_handling/http_io.dart @@ -7,8 +7,9 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:cocoon_common/core_extensions.dart'; +import 'package:http/http.dart' as http; -import 'http_utils.dart'; +import 'http_utils.dart' as http_utils; import 'request_handler.dart'; /// Creates a [Request] by wrapping an existing [HttpRequest]. @@ -74,7 +75,7 @@ final class _HttpResponse implements RequestResponse { set statusCode(int value) => _response.statusCode = value; @override - set contentType(MediaType? value) { + set contentType(http_utils.MediaType? value) { if (value != null) { _response.headers.contentType = ContentType( value.type, @@ -99,6 +100,30 @@ final class _HttpResponse implements RequestResponse { @override Future redirect( Uri location, { - int status = HttpStatus.movedTemporarily, + int status = http_utils.HttpStatus.movedTemporarily, }) => _response.redirect(location, status: status); } + +/// An [http.Client] that maps [SocketException] from dart:io to the cocoon +/// [http_utils.SocketException]. +/// +/// This is used to maintain platform neutrality in the core of app_dart. +class MappingHttpClient extends http.BaseClient { + MappingHttpClient(this._inner); + + final http.Client _inner; + + @override + Future send(http.BaseRequest request) async { + try { + return await _inner.send(request); + } on SocketException catch (e) { + throw http_utils.SocketException('$e'); + } on HttpException catch (e) { + throw http_utils.HttpException('$e'); + } + } + + @override + void close() => _inner.close(); +} diff --git a/app_dart/lib/src/service/config.dart b/app_dart/lib/src/service/config.dart index 5cfa400b11..d3bcb2fd99 100644 --- a/app_dart/lib/src/service/config.dart +++ b/app_dart/lib/src/service/config.dart @@ -24,7 +24,12 @@ const String kDefaultBranchName = 'master'; interface class Config extends DynamicallyUpdatedConfig { /// Creates and returns a [Config] instance. - Config(this._cache, this._secrets, {required super.initialConfig}); + Config( + this._cache, + this._secrets, { + required super.initialConfig, + http.Client? httpClient, + }) : _httpClient = httpClient ?? http.Client(); /// When present on a pull request, instructs Cocoon to submit it /// automatically as soon as all the required checks pass. @@ -78,6 +83,10 @@ interface class Config extends DynamicallyUpdatedConfig { final CacheService _cache; final SecretManager _secrets; + final http.Client _httpClient; + + /// The [http.Client] to use for requests. + http.Client get httpClient => _httpClient; /// List of Github presubmit supported repos. /// @@ -453,7 +462,10 @@ interface class Config extends DynamicallyUpdatedConfig { 'api.github.com', 'app/installations/$appInstallation/access_tokens', ); - final response = await http.post(githubAccessTokensUri, headers: headers); + final response = await _httpClient.post( + githubAccessTokensUri, + headers: headers, + ); final jsonBody = jsonDecode(response.body) as Map; if (jsonBody.containsKey('token') == false) { log.warn(response.body); @@ -476,12 +488,16 @@ interface class Config extends DynamicallyUpdatedConfig { } gh.GitHub createGitHubClientWithToken(String token) { - return gh.GitHub(auth: gh.Authentication.withToken(token)); + return gh.GitHub( + auth: gh.Authentication.withToken(token), + client: _httpClient, + ); } Future createGitHubGraphQLClient() async { final httpLink = HttpLink( 'https://api.github.com/graphql', + httpClient: _httpClient, defaultHeaders: { 'Accept': 'application/vnd.github.antiope-preview+json', }, diff --git a/app_dart/test/request_handling/mapping_http_client_test.dart b/app_dart/test/request_handling/mapping_http_client_test.dart new file mode 100644 index 0000000000..00c197b595 --- /dev/null +++ b/app_dart/test/request_handling/mapping_http_client_test.dart @@ -0,0 +1,73 @@ +// Copyright 2026 The Flutter Authors. 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:io' as io; + +import 'package:cocoon_server_test/test_logging.dart'; +import 'package:cocoon_service/src/request_handling/http_io.dart'; +import 'package:cocoon_service/src/request_handling/http_utils.dart' + as cocoon_service; +import 'package:http/http.dart' as http; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'mapping_http_client_test.mocks.dart'; + +@GenerateMocks([http.Client]) +void main() { + useTestLoggerPerTest(); + + group('MappingHttpClient', () { + late MockClient mockClient; + late MappingHttpClient mappingClient; + + setUp(() { + mockClient = MockClient(); + mappingClient = MappingHttpClient(mockClient); + }); + + test('maps io.SocketException to cocoon_service.SocketException', () async { + when( + mockClient.send(any), + ).thenThrow(const io.SocketException('test message')); + + expect( + () => mappingClient.get(Uri.parse('https://example.com')), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'SocketException: test message', + ), + ), + ); + }); + + test('maps io.HttpException to cocoon_service.HttpException', () async { + when( + mockClient.send(any), + ).thenThrow(const io.HttpException('test message')); + + expect( + () => mappingClient.get(Uri.parse('https://example.com')), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'HttpException: test message', + ), + ), + ); + }); + + test('passes through successful responses', () async { + final response = http.StreamedResponse(const Stream.empty(), 200); + when(mockClient.send(any)).thenAnswer((_) async => response); + + final result = await mappingClient.get(Uri.parse('https://example.com')); + expect(result.statusCode, 200); + }); + }); +} diff --git a/app_dart/test/request_handling/mapping_http_client_test.mocks.dart b/app_dart/test/request_handling/mapping_http_client_test.mocks.dart new file mode 100644 index 0000000000..3f451c2d2d --- /dev/null +++ b/app_dart/test/request_handling/mapping_http_client_test.mocks.dart @@ -0,0 +1,220 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in cocoon_service/test/request_handling/mapping_http_client_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; +import 'dart:convert' as _i4; +import 'dart:typed_data' as _i6; + +import 'package:http/http.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i5; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { + _FakeResponse_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeStreamedResponse_1 extends _i1.SmartFake + implements _i2.StreamedResponse { + _FakeStreamedResponse_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [Client]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockClient extends _i1.Mock implements _i2.Client { + MockClient() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future<_i2.Response> head(Uri? url, {Map? headers}) => + (super.noSuchMethod( + Invocation.method(#head, [url], {#headers: headers}), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#head, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> get(Uri? url, {Map? headers}) => + (super.noSuchMethod( + Invocation.method(#get, [url], {#headers: headers}), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#get, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> post( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #post, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #post, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> put( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #put, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #put, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> patch( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #patch, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> delete( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #delete, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future read(Uri? url, {Map? headers}) => + (super.noSuchMethod( + Invocation.method(#read, [url], {#headers: headers}), + returnValue: _i3.Future.value( + _i5.dummyValue( + this, + Invocation.method(#read, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future); + + @override + _i3.Future<_i6.Uint8List> readBytes( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method(#readBytes, [url], {#headers: headers}), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), + ) + as _i3.Future<_i6.Uint8List>); + + @override + _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => + (super.noSuchMethod( + Invocation.method(#send, [request]), + returnValue: _i3.Future<_i2.StreamedResponse>.value( + _FakeStreamedResponse_1( + this, + Invocation.method(#send, [request]), + ), + ), + ) + as _i3.Future<_i2.StreamedResponse>); + + @override + void close() => super.noSuchMethod( + Invocation.method(#close, []), + returnValueForMissingStub: null, + ); +} diff --git a/app_dart/tool/local_server.dart b/app_dart/tool/local_server.dart index 1a5a60879f..ee27f92ca5 100644 --- a/app_dart/tool/local_server.dart +++ b/app_dart/tool/local_server.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:io'; +import 'dart:io' as io; import 'package:appengine/appengine.dart'; import 'package:cocoon_integration_test/testing.dart'; @@ -10,7 +10,6 @@ import 'package:cocoon_server/google_auth_provider.dart'; import 'package:cocoon_server_test/fake_secret_manager.dart'; import 'package:cocoon_service/cocoon_service.dart'; import 'package:cocoon_service/server.dart'; -import 'package:cocoon_service/src/foundation/providers.dart'; import 'package:cocoon_service/src/request_handling/http_io.dart'; import 'package:cocoon_service/src/service/big_query.dart'; @@ -19,6 +18,7 @@ import 'package:cocoon_service/src/service/commit_service.dart'; import 'package:cocoon_service/src/service/firebase_jwt_validator.dart'; import 'package:cocoon_service/src/service/get_files_changed.dart'; import 'package:cocoon_service/src/service/scheduler/ci_yaml_fetcher.dart'; +import 'package:http/http.dart' as http; Future main() async { final cache = CacheService(inMemory: false); @@ -26,6 +26,7 @@ Future main() async { cache, FakeSecretManager(), initialConfig: DynamicConfig.fromJson({}), + httpClient: MappingHttpClient(http.Client()), ); final firestore = FakeFirestoreService(); @@ -51,12 +52,13 @@ Future main() async { final buildBucketClient = BuildBucketClient( accessTokenService: AccessTokenService.defaultProvider(config), + httpClient: config.httpClient, ); // Gerrit service class to communicate with GoB. final gerritService = GerritService( config: config, - authClient: Providers.freshHttpClient(), + authClient: config.httpClient, ); /// LUCI service class to communicate with buildBucket service. @@ -127,10 +129,10 @@ Future main() async { ); return runAppEngine( - (HttpRequest request) async { + (io.HttpRequest request) async { await server(request.toRequest()); }, - onAcceptingConnections: (InternetAddress address, int port) { + onAcceptingConnections: (io.InternetAddress address, int port) { final host = address.isLoopback ? 'localhost' : address.host; print('Serving requests at http://$host:$port/'); }, diff --git a/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart b/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart index 6d83569820..8213e39658 100644 --- a/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart +++ b/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart @@ -9,6 +9,7 @@ import 'package:cocoon_service/src/service/github_service.dart'; import 'package:cocoon_service/src/service/luci_build_service/cipd_version.dart'; import 'package:github/github.dart' as gh; import 'package:graphql/client.dart'; +import 'package:http/http.dart' as http; import 'fake_github_service.dart'; @@ -51,12 +52,14 @@ class FakeConfig implements Config { this.backfillerTargetLimitValue, this.issueAndPRLimitValue, this.githubRequestDelayValue, + this.httpClientValue, DynamicConfig? dynamicConfig, }) : dynamicConfig = dynamicConfig ?? DynamicConfig.fromLocalFileSystem(); gh.GitHub? githubClient; GraphQLClient? githubGraphQLClient; GithubService? githubService; + http.Client? httpClientValue; int? maxTaskRetriesValue; int? maxFilesChangedForSkippingEnginePhaseValue; int? maxLuciTaskRetriesValue; @@ -272,6 +275,9 @@ class FakeConfig implements Config { // TODO: implement flags DynamicConfig get flags => dynamicConfig; + @override + http.Client get httpClient => httpClientValue ??= http.Client(); + @override int minimumPassingTestsToDeflake = 50; }