From a0c62208916f76d9df7d2920b4f8a3f4d5423e13 Mon Sep 17 00:00:00 2001 From: Rui Miguel Alonso Date: Tue, 21 Nov 2023 09:57:21 +0100 Subject: [PATCH] feat: api client (#1) --- lib/app/view/app.dart | 8 +- lib/main_development.dart | 15 +- lib/main_production.dart | 15 +- lib/main_staging.dart | 15 +- packages/api_client/.gitignore | 7 + packages/api_client/README.md | 13 + packages/api_client/analysis_options.yaml | 1 + packages/api_client/coverage_badge.svg | 20 + packages/api_client/lib/api_client.dart | 4 + packages/api_client/lib/src/api_client.dart | 188 ++++++++ packages/api_client/pubspec.yaml | 15 + .../api_client/test/src/api_client_test.dart | 431 ++++++++++++++++++ pubspec.lock | 15 + pubspec.yaml | 2 + test/app/view/app_test.dart | 16 +- 15 files changed, 760 insertions(+), 5 deletions(-) create mode 100644 packages/api_client/.gitignore create mode 100644 packages/api_client/README.md create mode 100644 packages/api_client/analysis_options.yaml create mode 100644 packages/api_client/coverage_badge.svg create mode 100644 packages/api_client/lib/api_client.dart create mode 100644 packages/api_client/lib/src/api_client.dart create mode 100644 packages/api_client/pubspec.yaml create mode 100644 packages/api_client/test/src/api_client_test.dart diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 0a1832c..246797c 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -1,9 +1,15 @@ +import 'package:api_client/api_client.dart'; import 'package:dash_ai_search/counter/counter.dart'; import 'package:dash_ai_search/l10n/l10n.dart'; import 'package:flutter/material.dart'; class App extends StatelessWidget { - const App({super.key}); + const App({ + required this.apiClient, + super.key, + }); + + final ApiClient apiClient; @override Widget build(BuildContext context) { diff --git a/lib/main_development.dart b/lib/main_development.dart index 5388e35..268fc3f 100644 --- a/lib/main_development.dart +++ b/lib/main_development.dart @@ -1,6 +1,19 @@ +import 'package:api_client/api_client.dart'; import 'package:dash_ai_search/app/app.dart'; import 'package:dash_ai_search/bootstrap.dart'; void main() { - bootstrap(() => const App()); + bootstrap( + () { + final apiClient = ApiClient( + baseUrl: 'http://localhost:8080', + idTokenStream: const Stream.empty(), + refreshIdToken: () async => Future.value(), + ); + + return App( + apiClient: apiClient, + ); + }, + ); } diff --git a/lib/main_production.dart b/lib/main_production.dart index 5388e35..42aeb07 100644 --- a/lib/main_production.dart +++ b/lib/main_production.dart @@ -1,6 +1,19 @@ +import 'package:api_client/api_client.dart'; import 'package:dash_ai_search/app/app.dart'; import 'package:dash_ai_search/bootstrap.dart'; void main() { - bootstrap(() => const App()); + bootstrap( + () { + final apiClient = ApiClient( + baseUrl: 'http://production', + idTokenStream: const Stream.empty(), + refreshIdToken: () async => Future.value(), + ); + + return App( + apiClient: apiClient, + ); + }, + ); } diff --git a/lib/main_staging.dart b/lib/main_staging.dart index 5388e35..2c70729 100644 --- a/lib/main_staging.dart +++ b/lib/main_staging.dart @@ -1,6 +1,19 @@ +import 'package:api_client/api_client.dart'; import 'package:dash_ai_search/app/app.dart'; import 'package:dash_ai_search/bootstrap.dart'; void main() { - bootstrap(() => const App()); + bootstrap( + () { + final apiClient = ApiClient( + baseUrl: 'http://staging', + idTokenStream: const Stream.empty(), + refreshIdToken: () async => Future.value(), + ); + + return App( + apiClient: apiClient, + ); + }, + ); } diff --git a/packages/api_client/.gitignore b/packages/api_client/.gitignore new file mode 100644 index 0000000..526da15 --- /dev/null +++ b/packages/api_client/.gitignore @@ -0,0 +1,7 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock \ No newline at end of file diff --git a/packages/api_client/README.md b/packages/api_client/README.md new file mode 100644 index 0000000..20c8839 --- /dev/null +++ b/packages/api_client/README.md @@ -0,0 +1,13 @@ +# Api Client + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![Powered by Mason](https://img.shields.io/endpoint?url=https%3A%2F%2Ftinyurl.com%2Fmason-badge)](https://github.com/felangel/mason) +[![License: MIT][license_badge]][license_link] + +Client to access the api + + +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis \ No newline at end of file diff --git a/packages/api_client/analysis_options.yaml b/packages/api_client/analysis_options.yaml new file mode 100644 index 0000000..799268d --- /dev/null +++ b/packages/api_client/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.5.1.0.yaml diff --git a/packages/api_client/coverage_badge.svg b/packages/api_client/coverage_badge.svg new file mode 100644 index 0000000..499e98c --- /dev/null +++ b/packages/api_client/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/packages/api_client/lib/api_client.dart b/packages/api_client/lib/api_client.dart new file mode 100644 index 0000000..c2a514e --- /dev/null +++ b/packages/api_client/lib/api_client.dart @@ -0,0 +1,4 @@ +/// Client to access the api +library api_client; + +export 'src/api_client.dart'; diff --git a/packages/api_client/lib/src/api_client.dart b/packages/api_client/lib/src/api_client.dart new file mode 100644 index 0000000..b901a43 --- /dev/null +++ b/packages/api_client/lib/src/api_client.dart @@ -0,0 +1,188 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +/// {@template api_client_error} +/// Error throw when accessing api failed. +/// +/// Check [cause] and [stackTrace] for specific details. +/// {@endtemplate} +class ApiClientError implements Exception { + /// {@macro api_client_error} + ApiClientError(this.cause, this.stackTrace); + + /// Error cause. + final dynamic cause; + + /// The stack trace of the error. + final StackTrace stackTrace; + + @override + String toString() { + return cause.toString(); + } +} + +/// Definition of a post call used by this client. +typedef PostCall = Future Function( + Uri, { + Object? body, + Map? headers, +}); + +/// Definition of a patch call used by this client. +typedef PatchCall = Future Function( + Uri, { + Object? body, + Map? headers, +}); + +/// Definition of a put call used by this client. +typedef PutCall = Future Function( + Uri, { + Object? body, + Map? headers, +}); + +/// Definition of a get call used by this client. +typedef GetCall = Future Function( + Uri, { + Map? headers, +}); + +/// {@template api_client} +/// Client to access the api +/// {@endtemplate} +class ApiClient { + /// {@macro api_client} + ApiClient({ + required String baseUrl, + required Stream idTokenStream, + required Future Function() refreshIdToken, + PostCall postCall = http.post, + PutCall putCall = http.put, + PatchCall patchCall = http.patch, + GetCall getCall = http.get, + }) : _base = Uri.parse(baseUrl), + _post = postCall, + _put = putCall, + _patch = patchCall, + _get = getCall, + _refreshIdToken = refreshIdToken { + _idTokenSubscription = idTokenStream.listen((idToken) { + _idToken = idToken; + }); + } + + final Uri _base; + final PostCall _post; + final PostCall _put; + final PatchCall _patch; + final GetCall _get; + final Future Function() _refreshIdToken; + + late final StreamSubscription _idTokenSubscription; + String? _idToken; + + Map get _headers => { + if (_idToken != null) 'Authorization': 'Bearer $_idToken', + }; + + Future _handleUnauthorized( + Future Function() sendRequest, + ) async { + final response = await sendRequest(); + + if (response.statusCode == HttpStatus.unauthorized) { + _idToken = await _refreshIdToken(); + return sendRequest(); + } + return response; + } + + /// Dispose of resources used by this client. + Future dispose() async { + await _idTokenSubscription.cancel(); + } + + /// Sends a POST request to the specified [path] with the given [body]. + Future post( + String path, { + Object? body, + Map? queryParameters, + }) async { + return _handleUnauthorized(() async { + final response = await _post( + _base.replace( + path: path, + queryParameters: queryParameters, + ), + body: body, + headers: _headers..addContentTypeJson(), + ); + + return response; + }); + } + + /// Sends a PATCH request to the specified [path] with the given [body]. + Future patch( + String path, { + Object? body, + Map? queryParameters, + }) async { + return _handleUnauthorized(() async { + final response = await _patch( + _base.replace( + path: path, + queryParameters: queryParameters, + ), + body: body, + headers: _headers..addContentTypeJson(), + ); + + return response; + }); + } + + /// Sends a PUT request to the specified [path] with the given [body]. + Future put( + String path, { + Object? body, + }) async { + return _handleUnauthorized(() async { + final response = await _put( + _base.replace(path: path), + body: body, + headers: _headers..addContentTypeJson(), + ); + + return response; + }); + } + + /// Sends a GET request to the specified [path]. + Future get( + String path, { + Map? queryParameters, + }) async { + return _handleUnauthorized(() async { + final response = await _get( + _base.replace( + path: path, + queryParameters: queryParameters, + ), + headers: _headers, + ); + + return response; + }); + } +} + +extension on Map { + void addContentTypeJson() { + addAll({HttpHeaders.contentTypeHeader: ContentType.json.value}); + } +} diff --git a/packages/api_client/pubspec.yaml b/packages/api_client/pubspec.yaml new file mode 100644 index 0000000..da4249e --- /dev/null +++ b/packages/api_client/pubspec.yaml @@ -0,0 +1,15 @@ +name: api_client +description: Client to access the api +version: 0.1.0+1 +publish_to: none + +environment: + sdk: ">=3.0.0 <4.0.0" + +dev_dependencies: + mocktail: ^1.0.0 + test: ^1.19.2 + very_good_analysis: ^5.1.0 + +dependencies: + http: ^1.1.0 diff --git a/packages/api_client/test/src/api_client_test.dart b/packages/api_client/test/src/api_client_test.dart new file mode 100644 index 0000000..69503d7 --- /dev/null +++ b/packages/api_client/test/src/api_client_test.dart @@ -0,0 +1,431 @@ +// ignore_for_file: prefer_const_constructors + +import 'dart:async'; +import 'dart:io'; + +import 'package:api_client/api_client.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockHttpClient extends Mock { + Future get(Uri uri, {Map? headers}); + Future post( + Uri uri, { + Object? body, + Map? headers, + }); + Future patch( + Uri uri, { + Object? body, + Map? headers, + }); + Future put( + Uri uri, { + Object? body, + Map? headers, + }); +} + +void main() { + setUpAll(() { + registerFallbackValue(Uri.parse('http://localhost')); + }); + + group('ApiClient', () { + const baseUrl = 'http://baseurl.com'; + const mockIdToken = 'mockIdToken'; + const mockNewIdToken = 'mockNewIdToken'; + + final testJson = {'data': 'test'}; + final expectedResponse = http.Response(testJson.toString(), 200); + + late ApiClient subject; + late _MockHttpClient httpClient; + late StreamController idTokenStreamController; + + Future Function() refreshIdToken = () async => null; + + setUp(() { + httpClient = _MockHttpClient(); + + when( + () => httpClient.get( + any(), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => expectedResponse); + + when( + () => httpClient.post( + any(), + body: any(named: 'body'), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => expectedResponse); + + when( + () => httpClient.patch( + any(), + body: any(named: 'body'), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => expectedResponse); + + when( + () => httpClient.put( + any(), + body: any(named: 'body'), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => expectedResponse); + + idTokenStreamController = StreamController.broadcast(); + + subject = ApiClient( + baseUrl: baseUrl, + getCall: httpClient.get, + postCall: httpClient.post, + patchCall: httpClient.patch, + putCall: httpClient.put, + idTokenStream: idTokenStreamController.stream, + refreshIdToken: () => refreshIdToken(), + ); + }); + + test('can be instantiated', () { + expect( + ApiClient( + baseUrl: 'http://localhost', + idTokenStream: Stream.empty(), + refreshIdToken: () async => null, + ), + isNotNull, + ); + }); + + group('dispose', () { + test('cancels id token stream subscription', () async { + expect(idTokenStreamController.hasListener, isTrue); + + await subject.dispose(); + + expect(idTokenStreamController.hasListener, isFalse); + }); + }); + + group('get', () { + setUp(() { + when( + () => httpClient.get( + any(), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => expectedResponse); + }); + test('returns the response', () async { + final response = await subject.get('/'); + + expect(response.statusCode, equals(expectedResponse.statusCode)); + expect(response.body, equals(expectedResponse.body)); + }); + + test('sends the request correctly', () async { + await subject.get( + '/path/to/endpoint', + queryParameters: { + 'param1': 'value1', + 'param2': 'value2', + }, + ); + + verify( + () => httpClient.get( + Uri.parse('$baseUrl/path/to/endpoint?param1=value1¶m2=value2'), + headers: {}, + ), + ).called(1); + }); + + test('sends the authentication and app check token', () async { + idTokenStreamController.add(mockIdToken); + await Future.microtask(() {}); + await subject.get('/path/to/endpoint'); + + verify( + () => httpClient.get( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + }, + ), + ).called(1); + }); + + test('refreshes the authentication token when needed', () async { + when( + () => httpClient.get( + any(), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => http.Response('', 401)); + + refreshIdToken = () async => mockNewIdToken; + + idTokenStreamController.add(mockIdToken); + await Future.microtask(() {}); + await subject.get('/path/to/endpoint'); + + verify( + () => httpClient.get( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + }, + ), + ).called(1); + verify( + () => httpClient.get( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockNewIdToken', + }, + ), + ).called(1); + }); + }); + + group('post', () { + test('returns the response', () async { + final response = await subject.post('/'); + + expect(response.statusCode, equals(expectedResponse.statusCode)); + expect(response.body, equals(expectedResponse.body)); + }); + + test('sends the request correctly', () async { + await subject.post( + '/path/to/endpoint', + queryParameters: {'param1': 'value1', 'param2': 'value2'}, + body: 'BODY_CONTENT', + ); + + verify( + () => httpClient.post( + Uri.parse('$baseUrl/path/to/endpoint?param1=value1¶m2=value2'), + body: 'BODY_CONTENT', + headers: {HttpHeaders.contentTypeHeader: ContentType.json.value}, + ), + ).called(1); + }); + + test('sends the authentication and app check token', () async { + idTokenStreamController.add(mockIdToken); + await Future.microtask(() {}); + await subject.post('/path/to/endpoint'); + + verify( + () => httpClient.post( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + }); + + test('refreshes the authentication token when needed', () async { + when( + () => httpClient.post( + any(), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => http.Response('', 401)); + + refreshIdToken = () async => mockNewIdToken; + + idTokenStreamController.add(mockIdToken); + await Future.microtask(() {}); + await subject.post('/path/to/endpoint'); + + verify( + () => httpClient.post( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + verify( + () => httpClient.post( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockNewIdToken', + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + }); + }); + + group('patch', () { + test('returns the response', () async { + final response = await subject.patch('/'); + + expect(response.statusCode, equals(expectedResponse.statusCode)); + expect(response.body, equals(expectedResponse.body)); + }); + + test('sends the request correctly', () async { + await subject.patch( + '/path/to/endpoint', + queryParameters: {'param1': 'value1', 'param2': 'value2'}, + body: 'BODY_CONTENT', + ); + + verify( + () => httpClient.patch( + Uri.parse('$baseUrl/path/to/endpoint?param1=value1¶m2=value2'), + body: 'BODY_CONTENT', + headers: {HttpHeaders.contentTypeHeader: ContentType.json.value}, + ), + ).called(1); + }); + + test('sends the authentication and app check token', () async { + idTokenStreamController.add(mockIdToken); + await Future.microtask(() {}); + await subject.patch('/path/to/endpoint'); + + verify( + () => httpClient.patch( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + }); + + test('refreshes the authentication token when needed', () async { + when( + () => httpClient.patch( + any(), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => http.Response('', 401)); + + refreshIdToken = () async => mockNewIdToken; + + idTokenStreamController.add(mockIdToken); + await Future.microtask(() {}); + await subject.patch('/path/to/endpoint'); + + verify( + () => httpClient.patch( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + verify( + () => httpClient.patch( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockNewIdToken', + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + }); + }); + + group('put', () { + test('returns the response', () async { + final response = await subject.put('/'); + + expect(response.statusCode, equals(expectedResponse.statusCode)); + expect(response.body, equals(expectedResponse.body)); + }); + + test('sends the request correctly', () async { + await subject.put( + '/path/to/endpoint', + body: 'BODY_CONTENT', + ); + + verify( + () => httpClient.put( + Uri.parse('$baseUrl/path/to/endpoint'), + body: 'BODY_CONTENT', + headers: {HttpHeaders.contentTypeHeader: ContentType.json.value}, + ), + ).called(1); + }); + + test('sends the authentication and app check token', () async { + idTokenStreamController.add(mockIdToken); + await Future.microtask(() {}); + await subject.put('/path/to/endpoint'); + + verify( + () => httpClient.put( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + }); + + test('refreshes the authentication token when needed', () async { + when( + () => httpClient.put( + any(), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => http.Response('', 401)); + + refreshIdToken = () async => mockNewIdToken; + + idTokenStreamController.add(mockIdToken); + await Future.microtask(() {}); + await subject.put('/path/to/endpoint'); + + verify( + () => httpClient.put( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + verify( + () => httpClient.put( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockNewIdToken', + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + }); + }); + + group('ApiClientError', () { + test('toString returns the cause', () { + expect( + ApiClientError('Ops', StackTrace.empty).toString(), + equals('Ops'), + ); + }); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 7698475..e929721 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,13 @@ packages: url: "https://pub.dev" source: hosted version: "5.13.0" + api_client: + dependency: "direct main" + description: + path: "/Users/ruialonso/dev/flutter/googleAI/dash_ai_search/packages/api_client" + relative: false + source: path + version: "0.1.0+1" args: dependency: transitive description: @@ -168,6 +175,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + http: + dependency: transitive + description: + name: http + sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + url: "https://pub.dev" + source: hosted + version: "1.1.0" http_multi_server: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3471cbb..b4858a0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,8 @@ environment: sdk: ">=3.1.0 <4.0.0" dependencies: + api_client: + path: packages/api_client bloc: ^8.1.2 flutter: sdk: flutter diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart index 80aa5a6..8052a14 100644 --- a/test/app/view/app_test.dart +++ b/test/app/view/app_test.dart @@ -1,11 +1,25 @@ +import 'package:api_client/api_client.dart'; import 'package:dash_ai_search/app/app.dart'; import 'package:dash_ai_search/counter/counter.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockApiClient extends Mock implements ApiClient {} void main() { group('App', () { + late ApiClient apiClient; + + setUp(() { + apiClient = _MockApiClient(); + }); + testWidgets('renders CounterPage', (tester) async { - await tester.pumpWidget(const App()); + await tester.pumpWidget( + App( + apiClient: apiClient, + ), + ); expect(find.byType(CounterPage), findsOneWidget); }); });