Skip to content

Commit

Permalink
refactor(neon_http_client): split into separate interceptor client pa…
Browse files Browse the repository at this point in the history
…ckage

Signed-off-by: Nikolas Rimikis <[email protected]>
  • Loading branch information
Leptopoda committed Sep 9, 2024
1 parent 5f77888 commit e8b46a0
Show file tree
Hide file tree
Showing 36 changed files with 430 additions and 120 deletions.
1 change: 1 addition & 0 deletions packages/interceptor_http_client/LICENSE
6 changes: 6 additions & 0 deletions packages/interceptor_http_client/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
include: package:neon_lints/dart.yaml

custom_lint:
rules:
- avoid_exports: false
- avoid_dart_io: false
10 changes: 10 additions & 0 deletions packages/interceptor_http_client/dart_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
platforms:
- vm
- chrome

define_platforms:
chromium:
name: Chromium
extends: chrome
settings:
executable: chromium
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// A http client that allows to register interceptors for requests and responses.
library;

export 'src/interceptor_http_client.dart';
export 'src/interceptors/interceptors.dart';
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import 'dart:async';

import 'package:built_collection/built_collection.dart';
import 'package:http/http.dart' as http;
import 'package:interceptor_http_client/src/interceptors/interceptors.dart';
import 'package:meta/meta.dart';

/// An exception caused by an error in a [InterceptorHttpClient].
abstract class InterceptorHttpClientException extends http.ClientException {
/// An exception caused by an error in a [InterceptorHttpClient].
InterceptorHttpClientException(super.message, [super.uri]);
}

/// An exception caused by a [HttpInterceptor].
final class InterceptionException extends InterceptorHttpClientException {
/// Creates a new interceptor failure exception.
InterceptionException(super.message, [super.uri]);
}

/// A http client for intercepting requests and responses.
class InterceptorHttpClient with http.BaseClient {
/// Creates a new interceptor http client.
const InterceptorHttpClient({
required http.Client baseClient,
required this.interceptors,
}) : _baseClient = baseClient;

/// The underlying HTTP client.
final http.Client _baseClient;

/// The list of enabled interceptors.
@visibleForTesting
@protected
final BuiltList<HttpInterceptor> interceptors;

@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
var interceptedRequest = request;
for (final interceptor in interceptors) {
if (interceptor.shouldInterceptRequest(interceptedRequest)) {
try {
interceptedRequest = await interceptor.interceptRequest(
request: interceptedRequest,
);
} catch (error, stackTrace) {
if (error is http.ClientException) {
rethrow;
}

Error.throwWithStackTrace(
InterceptionException('Failed to intercept request', request.url),
stackTrace,
);
}
}
}

var interceptedResponse = await _baseClient.send(interceptedRequest);

Uri url;
if (interceptedResponse case http.BaseResponseWithUrl(url: final responseUrl)) {
url = responseUrl;
} else {
url = interceptedRequest.url;
}

for (final interceptor in interceptors) {
if (interceptor.shouldInterceptResponse(interceptedResponse)) {
try {
interceptedResponse = await interceptor.interceptResponse(
response: interceptedResponse,
url: url,
);
} catch (error, stackTrace) {
if (error is http.ClientException) {
rethrow;
}
Error.throwWithStackTrace(
InterceptionException('Failed to intercept response', request.url),
stackTrace,
);
}
}
}

return interceptedResponse;
}

@override
void close() {
_baseClient.close();

super.close();
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import 'dart:async';
import 'dart:io' show Cookie, HttpHeaders;

import 'package:cookie_store/cookie_store.dart';
import 'package:http/http.dart' as http;
import 'package:interceptor_http_client/src/interceptors/interceptors.dart';
import 'package:meta/meta.dart';
import 'package:neon_http_client/src/interceptors/http_interceptor.dart';
import 'package:universal_io/io.dart';

/// A HttpInterceptor to implement cookie persisting interceptors.
///
Expand Down Expand Up @@ -79,13 +79,13 @@ abstract class CookieInterceptor implements HttpInterceptor {
}

/// A HttpInterceptor persisting cookies in the provided [cookieStore].
@internal
final class CookieStoreInterceptor extends CookieInterceptor {
/// Creates a new interceptor persisting cookies.
const CookieStoreInterceptor({
required this.cookieStore,
});

/// The cookie store instance backing this interceptor.
final CookieStore cookieStore;

@override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ abstract interface class HttpInterceptor {
///
/// Exceptions might be thrown during interception.
/// If the exception is an [http.ClientException] it will be thrown as is,
/// otherwise it wrapped as an `InterceptionException`.
/// otherwise it is wrapped as an `InterceptionException`.
FutureOr<http.BaseRequest> interceptRequest({required http.BaseRequest request});

/// Whether this interceptor should intercept response.
Expand All @@ -26,7 +26,7 @@ abstract interface class HttpInterceptor {
///
/// Exceptions might be thrown during interception.
/// If the exception is an [http.ClientException] it will be thrown as is,
/// otherwise it wrapped as an `InterceptionException`.
/// otherwise it is wrapped as an `InterceptionException`.
FutureOr<http.StreamedResponse> interceptResponse({
required http.StreamedResponse response,
required Uri url,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export 'cookie_interceptor.dart';
export 'http_interceptor.dart';
29 changes: 29 additions & 0 deletions packages/interceptor_http_client/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: interceptor_http_client
description: A http client with request and response interceptors.
version: 0.1.0
publish_to: none

environment:
sdk: ^3.0.0

dependencies:
built_collection: ^5.0.0
cookie_store:
git:
url: https://github.com/nextcloud/neon
path: packages/cookie_store
http: ^1.0.0
meta: ^1.0.0

dev_dependencies:
http_client_conformance_tests:
git:
url: https://github.com/dart-lang/http
path: pkgs/http_client_conformance_tests
ref: 76512c4cbf987361421030349fd1946e63e33359
mocktail: ^1.0.4
neon_lints:
git:
url: https://github.com/nextcloud/neon
path: packages/neon_lints
test: ^1.25.8
6 changes: 6 additions & 0 deletions packages/interceptor_http_client/pubspec_overrides.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# melos_managed_dependency_overrides: cookie_store,dynamite_runtime,neon_lints,nextcloud
dependency_overrides:
cookie_store:
path: ../cookie_store
neon_lints:
path: ../neon_lints
44 changes: 44 additions & 0 deletions packages/interceptor_http_client/test/client_conformance_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import 'package:built_collection/built_collection.dart';
import 'package:http/http.dart' as http;
import 'package:http_client_conformance_tests/http_client_conformance_tests.dart';
import 'package:interceptor_http_client/src/interceptor_http_client.dart';
import 'package:test/test.dart';

void main() {
group(
'Interceptor Client VM conformance test',
() {
testAll(
() => InterceptorHttpClient(
baseClient: http.Client(),
interceptors: BuiltList(),
),
canReceiveSetCookieHeaders: true,
canSendCookieHeaders: true,
);
},
onPlatform: const {
'browser': [Skip()],
},
);

group(
'Interceptor Client browser conformance test',
() {
testAll(
() => InterceptorHttpClient(
baseClient: http.Client(),
interceptors: BuiltList(),
),
redirectAlwaysAllowed: true,
canStreamRequestBody: false,
canStreamResponseBody: false,
canWorkInIsolates: false,
supportsMultipartRequest: false,
);
},
onPlatform: const {
'dart-vm': [Skip()],
},
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import 'package:built_collection/built_collection.dart';
import 'package:http/http.dart';
import 'package:http/testing.dart';
import 'package:interceptor_http_client/interceptor_http_client.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';

class _MockInterceptor extends Mock implements HttpInterceptor {}

class _FakeUri extends Fake implements Uri {}

void main() {
final uri = Uri.parse('http://example.com');
Request fakeRequest() {
return Request('PUT', uri);
}

StreamedResponse fakeResponse() {
return StreamedResponse(const Stream.empty(), 200);
}

final mockedClient = MockClient((request) async {
return Response.fromStream(fakeResponse());
});
late InterceptorHttpClient client;

setUpAll(() {
registerFallbackValue(_FakeUri());
registerFallbackValue(fakeResponse());
registerFallbackValue(fakeRequest());
});

tearDown(() {
client.close();
});

group(InterceptorHttpClient, () {
group('interceptors', () {
late HttpInterceptor interceptor;

setUp(() {
interceptor = _MockInterceptor();

client = InterceptorHttpClient(
baseClient: mockedClient,
interceptors: BuiltList([interceptor]),
);
});

test('does not intercept', () async {
when(() => interceptor.shouldInterceptRequest(any())).thenReturn(false);
when(() => interceptor.shouldInterceptResponse(any())).thenReturn(false);

final request = Request('GET', uri);
await client.send(request);

verifyNever(
() => interceptor.interceptRequest(
request: any(named: 'request'),
),
);
verifyNever(
() => interceptor.interceptResponse(
response: any(named: 'response'),
url: any(named: 'url'),
),
);
});

test('does intercept', () async {
when(() => interceptor.shouldInterceptRequest(any())).thenReturn(true);
when(() => interceptor.shouldInterceptResponse(any())).thenReturn(true);
when(
() => interceptor.interceptRequest(request: any(named: 'request')),
).thenReturn(fakeRequest());
when(
() => interceptor.interceptResponse(response: any(named: 'response'), url: any(named: 'url')),
).thenReturn(fakeResponse());

final request = Request('GET', uri);
await client.send(request);

verify(
() => interceptor.interceptRequest(
request: any(named: 'request', that: equals(request)),
),
).called(1);
verify(
() => interceptor.interceptResponse(
response: any(named: 'response'),
url: any(named: 'url', that: equals(uri)),
),
).called(1);
});

test('rethrows errors as InterceptionFailure', () async {
when(() => interceptor.shouldInterceptRequest(any())).thenReturn(true);
when(
() => interceptor.interceptRequest(request: any(named: 'request')),
).thenThrow(StateError('message'));

expect(
client.get(uri),
throwsA(
isA<InterceptionException>().having(
(e) => e.uri,
'uri',
uri,
),
),
);

when(() => interceptor.shouldInterceptRequest(any())).thenReturn(true);
when(() => interceptor.interceptRequest(request: any(named: 'request'))).thenReturn(fakeRequest());
when(() => interceptor.shouldInterceptResponse(any())).thenReturn(true);
when(
() => interceptor.interceptResponse(response: any(named: 'response'), url: any(named: 'url')),
).thenThrow(StateError('message'));

expect(
client.get(uri),
throwsA(
isA<InterceptionException>().having(
(e) => e.uri,
'uri',
uri,
),
),
);
});
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ import 'dart:io';

import 'package:cookie_store/cookie_store.dart';
import 'package:http/http.dart' as http;
import 'package:interceptor_http_client/src/interceptors/cookie_interceptor.dart';
import 'package:mocktail/mocktail.dart';
import 'package:neon_http_client/src/interceptors/cookie_interceptor.dart';
import 'package:test/test.dart';
import 'package:universal_io/io.dart';

class _MockCookieStore extends Mock implements CookieStore {}

Expand Down
Loading

0 comments on commit e8b46a0

Please sign in to comment.