diff --git a/clients/algoliasearch-client-dart/packages/chopper_requester/.gitignore b/clients/algoliasearch-client-dart/packages/chopper_requester/.gitignore new file mode 100644 index 0000000000..3a85790408 --- /dev/null +++ b/clients/algoliasearch-client-dart/packages/chopper_requester/.gitignore @@ -0,0 +1,3 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ diff --git a/clients/algoliasearch-client-dart/packages/chopper_requester/README.md b/clients/algoliasearch-client-dart/packages/chopper_requester/README.md new file mode 100644 index 0000000000..573efd3c04 --- /dev/null +++ b/clients/algoliasearch-client-dart/packages/chopper_requester/README.md @@ -0,0 +1,143 @@ + +

+ + Algolia for Dart + +

+ + +

+ Latest version + Publisher +

+ +

+ Algolia API Chopper Requester +

+ +

+ Use the Chopper Requester to send requests to the Algolia API with the Algolia Search Client for Dart. This package provides a custom Requester for the Algolia Search Client, allowing users to send requests to the Algolia API using the Chopper HTTP client. +

+ +## 💡 Installation + +Add Chopper Requester 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, + /// A custom JSON converter to use for serializing and deserializing JSON + JsonConverter? converter, +}); +``` + +### 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)), + ), + ); + ``` + +#### Parsing JSON in the background using Isolates + +Parsing JSON in the background is a good idea if you don't want to block the main thread. +Please see the [Chopper documentation](https://hadrien-lejard.gitbook.io/chopper/faq#decoding-json-using-isolates) on Decoding JSON using Isolate worker pools. + +## License + +Chopper Requester for Algolia Search Client is an open-sourced software licensed under the [MIT license](LICENSE). diff --git a/clients/algoliasearch-client-dart/packages/chopper_requester/example/example.dart b/clients/algoliasearch-client-dart/packages/chopper_requester/example/example.dart new file mode 100644 index 0000000000..155f7c2679 --- /dev/null +++ b/clients/algoliasearch-client-dart/packages/chopper_requester/example/example.dart @@ -0,0 +1,55 @@ +import 'dart:io' show HttpClient; + +import 'package:algolia_chopper_requester/algolia_chopper_requester.dart'; +import 'package:algolia_client_core/algolia_client_core.dart'; +import 'package:http/http.dart' as http show Client; +import 'package:http/io_client.dart' show IOClient; + +void main() async { + const String appId = 'latency'; + const String apiKey = '6be0576ff61c053d5f9a3225e2a90f76'; + + // Create a custom http [Client] with a connection timeout of 30 seconds + final http.Client client = IOClient( + HttpClient()..connectionTimeout = const Duration(seconds: 30), + ); + + // Creating an instance of the RetryStrategy with necessary parameters. + // This will retry the failed requests with a backoff strategy. + final RetryStrategy requester = RetryStrategy.create( + segment: AgentSegment(value: 'CustomClient'), + appId: appId, + apiKey: apiKey, + defaultHosts: () => [ + Host(url: 'latency-dsn.algolia.net'), + Host(url: 'latency-1.algolianet.com'), + ], + options: ClientOptions( + requester: ChopperRequester( + appId: appId, + apiKey: apiKey, + // Optionally, pass a custom http [Client] to the ChopperRequester. + // NOTE: The [Client] must be manually disposed of after use. + client: client, + ), + ), + ); + + // Executing a GET request on the '/1/indexes/instant_search' endpoint. + final Map response = await requester.execute( + request: ApiRequest( + method: RequestMethod.get, + path: '/1/indexes/instant_search', + queryParams: {'query': 'a', 'hitsPerPage': '5'}, + ), + ); + + // Printing json response. + print(response); + + // Dispose of the requester to clean up its resources. + requester.dispose(); + + // Disposing a custom [Client] has to be done manually. + client.close(); +} diff --git a/clients/algoliasearch-client-dart/packages/chopper_requester/lib/algolia_chopper_requester.dart b/clients/algoliasearch-client-dart/packages/chopper_requester/lib/algolia_chopper_requester.dart new file mode 100644 index 0000000000..193b31ba8f --- /dev/null +++ b/clients/algoliasearch-client-dart/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/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/agent_interceptor.dart b/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/agent_interceptor.dart new file mode 100644 index 0000000000..e2649535a5 --- /dev/null +++ b/clients/algoliasearch-client-dart/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/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/auth_interceptor.dart b/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/auth_interceptor.dart new file mode 100644 index 0000000000..fdd2018ad0 --- /dev/null +++ b/clients/algoliasearch-client-dart/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 String apiKey, + }) : _apiKey = apiKey; + + @override + FutureOr> intercept(Chain chain) => + chain.proceed( + applyHeaders( + chain.request, + { + 'x-algolia-application-id': appId, + 'x-algolia-api-key': _apiKey, + }, + ), + ); +} diff --git a/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/chopper_requester.dart b/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/chopper_requester.dart new file mode 100644 index 0000000000..9b1aa704eb --- /dev/null +++ b/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/chopper_requester.dart @@ -0,0 +1,136 @@ +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. + AuthInterceptor _authInterceptor; + late 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, + JsonConverter? converter, + }) : _authInterceptor = AuthInterceptor( + appId: appId, + apiKey: apiKey, + ) { + _client = ChopperClient( + client: client, + converter: converter ?? const JsonConverter(), + interceptors: [ + _authInterceptor, + 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.toUpperCase(), + requestUri(request), + Uri(), + body: request.body, + 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!), + }; + } + + /// Constructs the request URI from the [request] details. + Uri requestUri(HttpRequest request) => Uri( + scheme: request.host.scheme, + host: request.host.url, + port: request.host.port, + path: request.path, + query: request.queryParameters.isNotEmpty + ? request.queryParameters.entries + .map((e) => "${e.key}=${e.value}") + .join("&") + : null, + ); + + @override + void close() => _client.dispose(); + + @override + void setClientApiKey(String apiKey) { + _authInterceptor = AuthInterceptor( + appId: _authInterceptor.appId, + apiKey: apiKey, + ); + } + + @override + Duration? get connectTimeout => options.timeout; + + @override + void setConnectTimeout(Duration connectTimeout) { + // Chopper does not support setting connect timeout directly. + } +} diff --git a/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/platform/platform.dart b/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/platform/platform.dart new file mode 100644 index 0000000000..ed22645843 --- /dev/null +++ b/clients/algoliasearch-client-dart/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/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/platform/platform_io.dart b/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/platform/platform_io.dart new file mode 100644 index 0000000000..f57e393edc --- /dev/null +++ b/clients/algoliasearch-client-dart/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/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/platform/platform_stub.dart b/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/platform/platform_stub.dart new file mode 100644 index 0000000000..5d425c5b68 --- /dev/null +++ b/clients/algoliasearch-client-dart/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/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/platform/platform_web.dart b/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/platform/platform_web.dart new file mode 100644 index 0000000000..85ed15c42e --- /dev/null +++ b/clients/algoliasearch-client-dart/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/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/version.dart b/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/version.dart new file mode 100644 index 0000000000..b507f011e6 --- /dev/null +++ b/clients/algoliasearch-client-dart/packages/chopper_requester/lib/src/version.dart @@ -0,0 +1,2 @@ +/// Current package version +const packageVersion = '1.17.0'; diff --git a/clients/algoliasearch-client-dart/packages/chopper_requester/pubspec.yaml b/clients/algoliasearch-client-dart/packages/chopper_requester/pubspec.yaml new file mode 100644 index 0000000000..cff2f6badf --- /dev/null +++ b/clients/algoliasearch-client-dart/packages/chopper_requester/pubspec.yaml @@ -0,0 +1,21 @@ +name: algolia_chopper_requester +description: Chopper Requester for Algolia Search Client +version: 1.17.0 +topics: + - search + - discovery + - http + - client + +environment: + sdk: ^3.0.0 + +dependencies: + algolia_client_core: ^1.17.0 + chopper: ^8.0.1+1 + http: ^1.1.0 + logging: ^1.2.0 + +dev_dependencies: + lints: ^4.0.0 + test: ^1.25.7 diff --git a/clients/algoliasearch-client-dart/packages/chopper_requester/test/version_test.dart b/clients/algoliasearch-client-dart/packages/chopper_requester/test/version_test.dart new file mode 100644 index 0000000000..f2b4c5fdbb --- /dev/null +++ b/clients/algoliasearch-client-dart/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/clients/algoliasearch-client-dart/packages/client_core/lib/algolia_client_core.dart b/clients/algoliasearch-client-dart/packages/client_core/lib/algolia_client_core.dart index 9cc1adc0a0..0f24cfcc07 100644 --- a/clients/algoliasearch-client-dart/packages/client_core/lib/algolia_client_core.dart +++ b/clients/algoliasearch-client-dart/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'; diff --git a/generators/src/main/java/com/algolia/codegen/AlgoliaDartGenerator.java b/generators/src/main/java/com/algolia/codegen/AlgoliaDartGenerator.java index 04a88154f8..83a73764cd 100644 --- a/generators/src/main/java/com/algolia/codegen/AlgoliaDartGenerator.java +++ b/generators/src/main/java/com/algolia/codegen/AlgoliaDartGenerator.java @@ -104,6 +104,7 @@ public void processOpts() { supportingFiles.add(new SupportingFile("version.mustache", srcFolder, "version.dart")); supportingFiles.add(new SupportingFile("LICENSE", "", "LICENSE")); supportingFiles.add(new SupportingFile("LICENSE", "../client_core/", "LICENSE")); + supportingFiles.add(new SupportingFile("LICENSE", "../chopper_requester/", "LICENSE")); Helpers.addCommonSupportingFiles(supportingFiles, "../../");