diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index 453199a..8552b0e 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -249,3 +249,7 @@ get_non_separator_tokens_1: await client.index('articles').getNonSeparatorTokens update_non_separator_tokens_1: "await client.index('articles').updateNonSeparatorTokens([\"@\", \"#\"]);" reset_non_separator_tokens_1: await client.index('articles').resetNonSeparatorTokens(); export_post_1: "await client.export(\n ExportQuery(\n url: exportSinkUrl,\n apiKey: 'new_instance_api_key',\n payloadSize: \"100 MiB\",\n ),\n);" +list_dynamic_search_rules_1: "await client.listDynamicSearchRules();" +get_dynamic_search_rule_1: "await client.getDynamicSearchRule('black-friday');" +patch_dynamic_search_rule_1: "await client.updateOrCreateDynamicSearchRule(\n 'black-friday',\n DynamicSearchRule(\n uid: 'black-friday',\n description: 'Black Friday 2025 rules',\n precedence: 10,\n active: true,\n conditions: {\n 'query': {'isEmpty': true},\n 'time': {\n 'start': '2025-11-28T00:00:00Z',\n 'end': '2025-11-28T23:59:59Z',\n },\n },\n actions: >[\n {\n 'selector': {'indexUid': 'products', 'id': '123'},\n 'action': {'type': 'pin', 'position': 1},\n },\n ],\n ),\n);" +delete_dynamic_search_rule_1: "await client.deleteDynamicSearchRule('black-friday');" diff --git a/lib/src/client.dart b/lib/src/client.dart index e2d430a..cbf68a2 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -366,4 +366,68 @@ class MeiliSearchClient { ), ); } + + // + // Dynamic Search Rules endpoints (experimental, Meilisearch v1.50.0+) + // + // These endpoints require the `dynamicSearchRules` experimental feature + // flag to be enabled on the server, e.g.: + // + // await client.http.updateExperimentalFeatures( + // const UpdateExperimentalFeatures(dynamicSearchRules: true), + // ); + // + + /// Lists Dynamic Search Rules. The server exposes this as a POST so the + /// filter expression can travel in the body; pass [params] to paginate + /// or filter, or omit it to list rules with the server's default paging. + @RequiredMeiliServerVersion('1.50.0') + Future> listDynamicSearchRules({ + DynamicSearchRulesQuery? params, + }) async { + final response = await http.postMethod>( + '/dynamic-search-rules', + data: params?.toBody() ?? const {}, + ); + + return Result.fromMapWithType( + response.data!, + (model) => DynamicSearchRule.fromJson(model), + ); + } + + /// Retrieves a single Dynamic Search Rule by its [uid]. + @RequiredMeiliServerVersion('1.50.0') + Future getDynamicSearchRule(String uid) async { + final response = await http.getMethod>( + '/dynamic-search-rules/$uid', + ); + + return DynamicSearchRule.fromJson(response.data!); + } + + /// Creates a new Dynamic Search Rule with the given [uid], or updates + /// the existing one (upsert). Returns the async [Task] the server + /// enqueues to apply the change. + @RequiredMeiliServerVersion('1.50.0') + Future updateOrCreateDynamicSearchRule( + String uid, + DynamicSearchRule rule, + ) { + return _update( + http.patchMethod>( + '/dynamic-search-rules/$uid', + data: rule.toUpsertBody(), + ), + ); + } + + /// Deletes the Dynamic Search Rule identified by [uid]. Returns the + /// async [Task] the server enqueues to apply the deletion. + @RequiredMeiliServerVersion('1.50.0') + Future deleteDynamicSearchRule(String uid) { + return _update( + http.deleteMethod>('/dynamic-search-rules/$uid'), + ); + } } diff --git a/lib/src/query_parameters/_exports.dart b/lib/src/query_parameters/_exports.dart index 13cf739..752aece 100644 --- a/lib/src/query_parameters/_exports.dart +++ b/lib/src/query_parameters/_exports.dart @@ -13,3 +13,4 @@ export 'facet_search_query.dart'; export 'swap_index.dart'; export 'hybrid_search.dart'; export 'export_query.dart'; +export 'dynamic_search_rules_query.dart'; diff --git a/lib/src/query_parameters/dynamic_search_rules_query.dart b/lib/src/query_parameters/dynamic_search_rules_query.dart new file mode 100644 index 0000000..934332b --- /dev/null +++ b/lib/src/query_parameters/dynamic_search_rules_query.dart @@ -0,0 +1,53 @@ +/// The filter object accepted inside a +/// `POST /dynamic-search-rules` body (Meilisearch v1.50.0+). +/// +/// In v1.50.0 the list-endpoint filter changed shape: instead of a bare +/// filter-expression string it takes an object. [query] searches across +/// the rule's description and its `conditions.query.words`; [active] +/// narrows the result to active/paused rules. +class DynamicSearchRulesFilter { + final String? query; + final bool? active; + + const DynamicSearchRulesFilter({this.query, this.active}); + + Map toJson() => { + if (query != null) 'query': query, + if (active != null) 'active': active, + }; +} + +/// Body for `POST /dynamic-search-rules`. +/// +/// The list endpoint is a POST rather than a GET so callers can send an +/// arbitrary filter object in the body. All fields are optional; an +/// empty body lists every rule with the server's default pagination. +class DynamicSearchRulesQuery { + /// Zero-based offset into the rule list. + final int? offset; + + /// Maximum number of rules to return in a single response. + final int? limit; + + /// Optional filter object. See [DynamicSearchRulesFilter]. + final DynamicSearchRulesFilter? filter; + + const DynamicSearchRulesQuery({ + this.offset, + this.limit, + this.filter, + }); + + /// Serializes this query to the JSON body sent to + /// `POST /dynamic-search-rules`. Nulls are omitted so callers only send + /// what they set. An empty filter object is also omitted so an + /// unconfigured [filter] doesn't send `{}` on the wire. + Map toBody() { + final filterMap = filter?.toJson(); + return { + if (offset != null) 'offset': offset, + if (limit != null) 'limit': limit, + if (filterMap != null && filterMap.isNotEmpty) 'filter': filterMap, + }; + } +} diff --git a/lib/src/results/_exports.dart b/lib/src/results/_exports.dart index 9503c21..9d1455c 100644 --- a/lib/src/results/_exports.dart +++ b/lib/src/results/_exports.dart @@ -14,4 +14,5 @@ export 'all_stats.dart'; export 'network.dart'; export 'facet_stat.dart'; export 'document_container.dart'; +export 'dynamic_search_rule.dart'; export 'ranking_rules/_exports.dart'; diff --git a/lib/src/results/dynamic_search_rule.dart b/lib/src/results/dynamic_search_rule.dart new file mode 100644 index 0000000..5b1f382 --- /dev/null +++ b/lib/src/results/dynamic_search_rule.dart @@ -0,0 +1,89 @@ +/// A Dynamic Search Rule as returned by the +/// `GET /dynamic-search-rules/{uid}` and `POST /dynamic-search-rules` +/// endpoints introduced in Meilisearch v1.50.0 (experimental). +/// +/// The rule shape follows the [Meilisearch reference](https://www.meilisearch.com/docs/capabilities/search_rules/overview). +/// `conditions` and `actions` are exposed as raw maps/lists so the SDK +/// keeps working when new sub-fields are added on the server side without a +/// coordinated release. Callers that want strongly typed access can wrap +/// them at their layer. +class DynamicSearchRule { + /// The rule's unique identifier (path parameter for the single-rule + /// endpoints). + final String uid; + + /// Human-readable description. + final String? description; + + /// Ordering key when several rules match a given query. In v1.50.0 this + /// field was renamed from `priority` to `precedence`, and **lower** + /// numeric values apply first: a rule with `precedence: 1` is picked + /// before a rule with `precedence: 5`. + final int? precedence; + + /// Whether the rule is currently active. + final bool? active; + + /// Condition tree that decides when the rule fires (query patterns, + /// time windows, etc.). Exposed as a raw map so consumers see every + /// field the server sends, including ones added after this SDK ships. + final Map? conditions; + + /// Ordered list of actions the rule performs when it fires (typically + /// document pinning). Same raw-map rationale as [conditions]. + final List>? actions; + + /// ISO-8601 timestamp; present when the server returns it. + final DateTime? createdAt; + + /// ISO-8601 timestamp; present when the server returns it. + final DateTime? updatedAt; + + const DynamicSearchRule({ + required this.uid, + this.description, + this.precedence, + this.active, + this.conditions, + this.actions, + this.createdAt, + this.updatedAt, + }); + + factory DynamicSearchRule.fromJson(Map json) { + final actionsRaw = json['actions']; + final createdAtRaw = json['createdAt']; + final updatedAtRaw = json['updatedAt']; + final conditionsRaw = json['conditions']; + return DynamicSearchRule( + uid: json['uid'] as String? ?? '', + description: json['description'] as String?, + precedence: json['precedence'] as int?, + active: json['active'] as bool?, + conditions: conditionsRaw is Map + ? Map.from(conditionsRaw) + : null, + actions: actionsRaw is Iterable + ? actionsRaw + .whereType>() + .map(Map.from) + .toList() + : null, + createdAt: + createdAtRaw is String ? DateTime.tryParse(createdAtRaw) : null, + updatedAt: + updatedAtRaw is String ? DateTime.tryParse(updatedAtRaw) : null, + ); + } + + /// Body used when upserting a rule via + /// `PATCH /dynamic-search-rules/{uid}`. Fields that are null on this + /// instance are omitted so callers can send sparse updates. + Map toUpsertBody() => { + if (description != null) 'description': description, + if (precedence != null) 'precedence': precedence, + if (active != null) 'active': active, + if (conditions != null) 'conditions': conditions, + if (actions != null) 'actions': actions, + }; +} diff --git a/test/dynamic_search_rules_serialization_test.dart b/test/dynamic_search_rules_serialization_test.dart new file mode 100644 index 0000000..cd6c878 --- /dev/null +++ b/test/dynamic_search_rules_serialization_test.dart @@ -0,0 +1,170 @@ +import 'package:meilisearch/meilisearch.dart'; +import 'package:test/test.dart'; + +// Serialization / query-body coverage for the Dynamic Search Rules +// endpoints introduced in Meilisearch v1.50.0. These tests intentionally +// don't touch the network — an experimental server-side flag is required +// to exercise the endpoints against a live Meilisearch, and the API shape +// is what breaks first when the server evolves. + +void main() { + group('DynamicSearchRule.fromJson', () { + test('parses every documented field including nested conditions/actions', + () { + final json = { + 'uid': 'black-friday', + 'description': 'Black Friday 2025 rules', + 'precedence': 10, + 'active': true, + 'conditions': { + 'query': {'isEmpty': true}, + 'time': { + 'start': '2025-11-28T00:00:00Z', + 'end': '2025-11-28T23:59:59Z', + }, + }, + 'actions': [ + { + 'selector': { + 'indexUid': 'products', + 'id': '123', + }, + 'action': { + 'type': 'pin', + 'position': 1, + }, + }, + ], + 'createdAt': '2025-11-01T12:34:56Z', + 'updatedAt': '2025-11-02T10:00:00Z', + }; + + final rule = DynamicSearchRule.fromJson(json); + + expect(rule.uid, 'black-friday'); + expect(rule.description, 'Black Friday 2025 rules'); + expect(rule.precedence, 10); + expect(rule.active, isTrue); + + expect(rule.conditions, isNotNull); + final query = rule.conditions!['query'] as Map; + expect(query['isEmpty'], isTrue); + + expect(rule.actions, isNotNull); + expect(rule.actions!.length, 1); + final firstAction = rule.actions!.first; + final selector = firstAction['selector'] as Map; + expect(selector['indexUid'], 'products'); + final action = firstAction['action'] as Map; + expect(action['type'], 'pin'); + expect(action['position'], 1); + + expect(rule.createdAt, DateTime.parse('2025-11-01T12:34:56Z')); + expect(rule.updatedAt, DateTime.parse('2025-11-02T10:00:00Z')); + }); + + test('handles a sparse response without exploding', () { + final rule = DynamicSearchRule.fromJson( + const {'uid': 'minimal'}, + ); + + expect(rule.uid, 'minimal'); + expect(rule.description, isNull); + expect(rule.precedence, isNull); + expect(rule.active, isNull); + expect(rule.conditions, isNull); + expect(rule.actions, isNull); + expect(rule.createdAt, isNull); + expect(rule.updatedAt, isNull); + }); + + test('accepts an unparseable timestamp by leaving it null (not throwing)', + () { + final rule = DynamicSearchRule.fromJson({ + 'uid': 'x', + 'createdAt': 'not-a-date', + }); + + expect(rule.createdAt, isNull); + }); + }); + + group('DynamicSearchRule.toUpsertBody', () { + test('emits only the fields the caller set (sparse PATCH)', () { + final rule = DynamicSearchRule( + uid: 'ignored-when-upserting', + precedence: 5, + ); + + final body = rule.toUpsertBody(); + + expect(body.keys, unorderedEquals(['precedence'])); + expect(body['precedence'], 5); + }); + + test('serializes conditions and actions verbatim', () { + final rule = DynamicSearchRule( + uid: 'x', + description: 'test', + active: true, + conditions: const { + 'query': {'isEmpty': true}, + }, + actions: const >[ + { + 'selector': {'indexUid': 'i', 'id': '1'}, + 'action': {'type': 'pin', 'position': 1}, + }, + ], + ); + + final body = rule.toUpsertBody(); + + expect(body['description'], 'test'); + expect(body['active'], isTrue); + expect(body['conditions'], isA>()); + expect((body['actions'] as List).length, 1); + }); + }); + + group('DynamicSearchRulesQuery.toBody', () { + test('empty query serializes to an empty body', () { + expect(const DynamicSearchRulesQuery().toBody(), isEmpty); + }); + + test('offset/limit round-trip', () { + const q = DynamicSearchRulesQuery(offset: 20, limit: 5); + expect(q.toBody(), {'offset': 20, 'limit': 5}); + }); + + test('filter object round-trip carries query + active', () { + const q = DynamicSearchRulesQuery( + limit: 10, + filter: DynamicSearchRulesFilter(query: 'black friday', active: true), + ); + expect(q.toBody(), { + 'limit': 10, + 'filter': { + 'query': 'black friday', + 'active': true, + }, + }); + }); + + test('an entirely empty filter object is dropped from the body', () { + const q = DynamicSearchRulesQuery( + filter: DynamicSearchRulesFilter(), + ); + expect(q.toBody(), isEmpty); + }); + + test('sparse filter with only one field only emits that field', () { + const q = DynamicSearchRulesQuery( + filter: DynamicSearchRulesFilter(active: false), + ); + expect(q.toBody(), { + 'filter': {'active': false}, + }); + }); + }); +}