diff --git a/packages/chopper_requester/.gitignore b/packages/chopper_requester/.gitignore new file mode 100644 index 00000000..3a857904 --- /dev/null +++ b/packages/chopper_requester/.gitignore @@ -0,0 +1,3 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ diff --git a/packages/chopper_requester/CHANGELOG.md b/packages/chopper_requester/CHANGELOG.md new file mode 100644 index 00000000..effe43c8 --- /dev/null +++ b/packages/chopper_requester/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/chopper_requester/LICENSE b/packages/chopper_requester/LICENSE new file mode 100644 index 00000000..02b32ea5 --- /dev/null +++ b/packages/chopper_requester/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Klemen Tusar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/chopper_requester/README.md b/packages/chopper_requester/README.md new file mode 100644 index 00000000..18850d19 --- /dev/null +++ b/packages/chopper_requester/README.md @@ -0,0 +1,117 @@ +# Chopper Requester for Algolia Search Client + +## 💡 Installation + +Add Algolia Client Core as a dependency in your project directly from pub.dev: + +#### For Dart projects: + +```shell +dart pub add algolia_chopper_requester +``` + +#### For Flutter projects: + +```shell +flutter pub add algolia_chopper_requester +``` + +### Basic Usage + +```dart +final String appId = 'latency'; +final String apiKey = '6be0576ff61c053d5f9a3225e2a90f76'; + +final SearchClient _client = SearchClient( + appId: appId, + apiKey: apiKey, + options: ClientOptions( + requester: ChopperRequester( + appId: appId, + apiKey: apiKey, + ) + ), +); + +Future search(String query) => _client.searchIndex( + request: SearchForHits( + indexName: 'flutter', + query: query, + hitsPerPage: 5, + ), + ); +``` + +You can configure the `ChopperRequester` with the following parameters: + +### Configuration + +```dart +final requester = ChopperRequester({ + /// Your Algolia Application ID + required String appId, + /// Your Algolia Search-Only API Key + required String apiKey, + /// Additional headers to send with the request + Map? headers, + /// The segments to include in the `User-Agent` header + Iterable? clientSegments, + /// The logger to use for debugging + Logger? logger, + /// The Chopper Interceptors to use for modifying the request + Iterable? interceptors, + /// The HTTP client to use for sending requests + Client? client +}); +``` + +### Advanced Usage + +To set the connect timeout one has to do that directly on the `Client`, i.e. + +```dart +final requester = ChopperRequester( + appId: appId, + apiKey: apiKey, + client: http.IOClient( + HttpClient()..connectionTimeout = const Duration(seconds: 60), + ), +); +``` + +### Custom Interceptors + +For interceptors please see the [Chopper documentation](https://hadrien-lejard.gitbook.io/chopper/interceptors). + +### Custom Clients + +Via the `client` option users can use platform specific HTTP clients such: +- [cronet_http](https://pub.dev/packages/cronet_http) on Android + ```dart + final requester = ChopperRequester( + appId: appId, + apiKey: apiKey, + client: CronetClient.fromCronetEngine( + CronetEngine.build( + cacheMode: CacheMode.memory, + cacheMaxSize: 50 * 1024 * 1024, + ), + closeEngine: true, + ), + ); + ``` +- [cupertino_http](https://pub.dev/packages/cupertino_http) on iOS/macOS + ```dart + final requester = ChopperRequester( + appId: appId, + apiKey: apiKey, + client: CupertinoClient.fromSessionConfiguration( + (URLSessionConfiguration.defaultSessionConfiguration() + ..timeoutIntervalForRequest = const Duration(seconds: 30)), + ), + ); + ``` + +## License + +Chopper Requester for Algolia Search Client is an open-sourced software licensed under the [MIT license](LICENSE). diff --git a/packages/chopper_requester/analysis_options.yaml b/packages/chopper_requester/analysis_options.yaml new file mode 100644 index 00000000..572dd239 --- /dev/null +++ b/packages/chopper_requester/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/packages/chopper_requester/lib/algolia_chopper_requester.dart b/packages/chopper_requester/lib/algolia_chopper_requester.dart new file mode 100644 index 00000000..193b31ba --- /dev/null +++ b/packages/chopper_requester/lib/algolia_chopper_requester.dart @@ -0,0 +1,4 @@ +library algolia_chopper_requester; + +export 'package:chopper/chopper.dart' show Interceptor; +export 'src/chopper_requester.dart'; diff --git a/packages/chopper_requester/lib/src/agent_interceptor.dart b/packages/chopper_requester/lib/src/agent_interceptor.dart new file mode 100644 index 00000000..e2649535 --- /dev/null +++ b/packages/chopper_requester/lib/src/agent_interceptor.dart @@ -0,0 +1,21 @@ +import 'dart:async' show FutureOr; + +import 'package:algolia_client_core/algolia_client_core.dart' show AlgoliaAgent; +import 'package:chopper/chopper.dart'; +import 'package:algolia_chopper_requester/src/platform/platform.dart'; + +/// Interceptor that attaches the Algolia agent to outgoing requests. +/// +/// This interceptor modifies the query parameters of each request to include the +/// formatted representation of the Algolia agent. +class AgentInterceptor implements Interceptor { + /// The Algolia agent to be attached to outgoing requests. + final AlgoliaAgent agent; + + /// Constructs an [AgentInterceptor] with the provided Algolia agent. + const AgentInterceptor({required this.agent}); + + @override + FutureOr> intercept(Chain chain) => + chain.proceed(Platform.algoliaAgent(chain, agent.formatted())); +} diff --git a/packages/chopper_requester/lib/src/auth_interceptor.dart b/packages/chopper_requester/lib/src/auth_interceptor.dart new file mode 100644 index 00000000..d7ffa351 --- /dev/null +++ b/packages/chopper_requester/lib/src/auth_interceptor.dart @@ -0,0 +1,33 @@ +import 'dart:async' show FutureOr; + +import 'package:chopper/chopper.dart'; + +/// Interceptor that attaches the application id and API key to outgoing requests. +/// +/// This interceptor modifies the headers of each request to include the +/// application id and API key for Algolia authentication. +class AuthInterceptor implements Interceptor { + /// The application id used for Algolia authentication. + final String appId; + + /// The API key used for Algolia authentication. + final String apiKey; + + /// Constructs an [AuthInterceptor] with the provided application id and API key. + const AuthInterceptor({ + required this.appId, + required this.apiKey, + }); + + @override + FutureOr> intercept(Chain chain) => + chain.proceed( + applyHeaders( + chain.request, + { + 'x-algolia-application-id': appId, + 'x-algolia-api-key': apiKey, + }, + ), + ); +} diff --git a/packages/chopper_requester/lib/src/chopper_requester.dart b/packages/chopper_requester/lib/src/chopper_requester.dart new file mode 100644 index 00000000..bdb06929 --- /dev/null +++ b/packages/chopper_requester/lib/src/chopper_requester.dart @@ -0,0 +1,109 @@ +import 'dart:async' show TimeoutException; + +import 'package:algolia_client_core/algolia_client_core.dart'; +import 'package:chopper/chopper.dart'; +import 'package:algolia_chopper_requester/src/agent_interceptor.dart'; +import 'package:algolia_chopper_requester/src/auth_interceptor.dart'; +import 'package:algolia_chopper_requester/src/platform/platform.dart'; +import 'package:algolia_chopper_requester/src/version.dart'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart' show Logger; + +/// A [Requester] implementation using the Chopper library. +/// +/// This class sends HTTP requests using the Chopper library and handles +/// response conversion and error handling. +class ChopperRequester implements Requester { + /// The underlying Chopper client. + final ChopperClient _client; + + /// Constructs a [ChopperClient] with the given [appId] and [apiKey]. + ChopperRequester({ + required String appId, + required String apiKey, + Map? headers, + Iterable? clientSegments, + Logger? logger, + Iterable? interceptors, + http.Client? client, + }) : _client = ChopperClient( + client: client, + converter: JsonConverter(), + interceptors: [ + AuthInterceptor( + appId: appId, + apiKey: apiKey, + ), + AgentInterceptor( + agent: AlgoliaAgent(packageVersion) + ..addAll([ + ...?clientSegments, + ...Platform.agentSegments(), + ]), + ), + if (logger != null) + HttpLoggingInterceptor( + level: Level.body, + onlyErrors: false, + logger: logger, + ), + ...?interceptors, + ], + ); + + @override + Future perform(HttpRequest request) async { + try { + final Response> response = await execute(request); + + if (response.isSuccessful) { + return HttpResponse( + response.statusCode, + response.body, + ); + } else { + throw AlgoliaApiException( + response.statusCode, + response.error ?? response.body, + ); + } + } on TimeoutException catch (e) { + throw AlgoliaTimeoutException(e); + } on http.ClientException catch (e) { + throw AlgoliaIOException(e); + } + } + + /// Executes the [request] and returns the response as an [HttpResponse]. + Future>> execute(HttpRequest request) async { + final Request chopperRequest = Request( + request.method, + Uri( + scheme: request.host.scheme, + host: request.host.url, + port: request.host.port, + path: request.path, + ), + _client.baseUrl, + body: request.body, + parameters: request.queryParameters, + headers: { + for (final MapEntry entry + in request.headers?.entries ?? const {}) + entry.key: entry.value.toString(), + if (request.body != null) 'content-type': 'application/json', + }, + ); + + return switch (options.timeout) { + null => await _client + .send, Map>(chopperRequest), + _ => await _client + .send, Map>(chopperRequest) + .timeout(options.timeout!), + }; + } + + @override + void close() => _client.dispose(); +} diff --git a/packages/chopper_requester/lib/src/platform/platform.dart b/packages/chopper_requester/lib/src/platform/platform.dart new file mode 100644 index 00000000..ed226458 --- /dev/null +++ b/packages/chopper_requester/lib/src/platform/platform.dart @@ -0,0 +1,17 @@ +import 'package:algolia_client_core/algolia_client_core.dart'; +import 'package:chopper/chopper.dart'; + +import 'platform_stub.dart' + if (dart.library.html) 'platform_web.dart' + if (dart.library.io) 'platform_io.dart'; + +final class Platform { + /// Get [AgentSegment]s for the current platform. + static Iterable agentSegments() => platformAgentSegments(); + + /// Set Algolia Agent as User-Agent or as query param depending on the platform. + static Request algoliaAgent(Chain chain, String agent) => + platformAlgoliaAgent(chain, agent); + + Platform._(); +} diff --git a/packages/chopper_requester/lib/src/platform/platform_io.dart b/packages/chopper_requester/lib/src/platform/platform_io.dart new file mode 100644 index 00000000..f57e393e --- /dev/null +++ b/packages/chopper_requester/lib/src/platform/platform_io.dart @@ -0,0 +1,20 @@ +import 'dart:io' as io; + +import 'package:algolia_client_core/algolia_client_core.dart'; +import 'package:chopper/chopper.dart'; + +/// [AgentSegment]s for native platforms. +Iterable platformAgentSegments() => [ + AgentSegment( + value: 'Dart', + version: io.Platform.version, + ), + AgentSegment( + value: io.Platform.operatingSystem, + version: io.Platform.operatingSystemVersion, + ), + ]; + +/// [AlgoliaAgent] for native platforms as user-agent. +Request platformAlgoliaAgent(Chain chain, String agent) => + applyHeader(chain.request, "user-agent", agent); diff --git a/packages/chopper_requester/lib/src/platform/platform_stub.dart b/packages/chopper_requester/lib/src/platform/platform_stub.dart new file mode 100644 index 00000000..5d425c5b --- /dev/null +++ b/packages/chopper_requester/lib/src/platform/platform_stub.dart @@ -0,0 +1,11 @@ +import 'package:algolia_client_core/algolia_client_core.dart'; +import 'package:chopper/chopper.dart'; + +/// [AgentSegment]s for unsupported platforms. +Iterable platformAgentSegments() => const []; + +/// [AlgoliaAgent] for unsupported platforms. +Request platformAlgoliaAgent(Chain chain, String agent) { + // NO-OP. + return chain.request; +} diff --git a/packages/chopper_requester/lib/src/platform/platform_web.dart b/packages/chopper_requester/lib/src/platform/platform_web.dart new file mode 100644 index 00000000..85ed15c4 --- /dev/null +++ b/packages/chopper_requester/lib/src/platform/platform_web.dart @@ -0,0 +1,20 @@ +import 'dart:html' as web; + +import 'package:algolia_client_core/algolia_client_core.dart'; +import 'package:chopper/chopper.dart'; + +/// [AgentSegment]s for web platforms. +Iterable platformAgentSegments() => [ + AgentSegment( + value: 'Platform', + version: 'Web ${web.window.navigator.platform}', + ), + ]; + +Request platformAlgoliaAgent(Chain chain, String agent) => + chain.request.copyWith( + parameters: { + ...chain.request.parameters, + 'X-Algolia-Agent': agent, + }, + ); diff --git a/packages/chopper_requester/lib/src/version.dart b/packages/chopper_requester/lib/src/version.dart new file mode 100644 index 00000000..824ebd3b --- /dev/null +++ b/packages/chopper_requester/lib/src/version.dart @@ -0,0 +1,2 @@ +/// Current package version +const packageVersion = '1.0.0'; diff --git a/packages/chopper_requester/pubspec.yaml b/packages/chopper_requester/pubspec.yaml new file mode 100644 index 00000000..4963b54f --- /dev/null +++ b/packages/chopper_requester/pubspec.yaml @@ -0,0 +1,22 @@ +name: algolia_chopper_requester +description: Chopper Requester for Algolia Search Client +version: 1.0.0 +topics: + - search + - discovery + - http + - client + +environment: + sdk: ^3.0.0 + +dependencies: + algolia_client_core: ^1.15.1 + chopper: ^8.0.1+1 + http: ^1.1.0 + json_annotation: ^4.8.1 + logging: ^1.2.0 + +dev_dependencies: + lints: ^4.0.0 + test: ^1.25.7 diff --git a/packages/chopper_requester/test/version_test.dart b/packages/chopper_requester/test/version_test.dart new file mode 100644 index 00000000..f2b4c5fd --- /dev/null +++ b/packages/chopper_requester/test/version_test.dart @@ -0,0 +1,18 @@ +import 'dart:io'; + +import 'package:algolia_client_core/src/version.dart'; +import 'package:test/test.dart'; + +void main() { + if (Directory.current.path.endsWith('/test')) { + Directory.current = Directory.current.parent; + } + test('package version matches pubspec', () { + final pubspecPath = '${Directory.current.path}/pubspec.yaml'; + final pubspec = File(pubspecPath).readAsStringSync(); + final regex = RegExp('version:s*(.*)'); + final match = regex.firstMatch(pubspec); + expect(match, isNotNull); + expect(packageVersion, match?.group(1)?.trim()); + }); +} diff --git a/packages/client_core/lib/algolia_client_core.dart b/packages/client_core/lib/algolia_client_core.dart index 530dd6f3..de415d28 100644 --- a/packages/client_core/lib/algolia_client_core.dart +++ b/packages/client_core/lib/algolia_client_core.dart @@ -10,6 +10,7 @@ export 'src/api_client.dart'; export 'src/config/agent_segment.dart'; export 'src/config/client_options.dart'; export 'src/config/host.dart'; +export 'src/transport/algolia_agent.dart'; export 'src/transport/api_request.dart'; export 'src/transport/request_options.dart'; export 'src/transport/requester.dart';