From d71e23857fe811d18008443d5f13f49566811931 Mon Sep 17 00:00:00 2001 From: Kumar Ujjawal Date: Mon, 22 Sep 2025 01:13:13 +0530 Subject: [PATCH 01/11] Add sharding support for Network methods --- src/client.rs | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 ++ src/network.rs | 37 ++++++++++++++++++++++ src/search.rs | 5 +++ src/tasks.rs | 62 +++++++++++++++++++++++++++++++++++-- 5 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 src/network.rs diff --git a/src/client.rs b/src/client.rs index f9dfe98c..5e02abaa 100644 --- a/src/client.rs +++ b/src/client.rs @@ -8,6 +8,7 @@ use crate::{ errors::*, indexes::*, key::{Key, KeyBuilder, KeyUpdater, KeysQuery, KeysResults}, + network::{NetworkState, NetworkUpdate}, request::*, search::*, task_info::TaskInfo, @@ -1148,6 +1149,46 @@ impl Client { crate::tenant_tokens::generate_tenant_token(api_key_uid, search_rules, api_key, expires_at) } + /// Get the current network state (/network) + pub async fn get_network_state(&self) -> Result { + self.http_client + .request::<(), (), NetworkState>( + &format!("{}/network", self.host), + Method::Get { query: () }, + 200, + ) + .await + } + + /// Partially update the network state (/network) + pub async fn update_network_state(&self, body: &NetworkUpdate) -> Result { + self.http_client + .request::<(), &NetworkUpdate, NetworkState>( + &format!("{}/network", self.host), + Method::Patch { query: (), body }, + 200, + ) + .await + } + + /// Convenience: set sharding=true/false + pub async fn set_sharding(&self, enabled: bool) -> Result { + let update = NetworkUpdate { + sharding: Some(enabled), + ..NetworkUpdate::default() + }; + self.update_network_state(&update).await + } + + /// Convenience: set self to a remote name + pub async fn set_self_remote(&self, name: &str) -> Result { + let update = NetworkUpdate { + self_name: Some(name.to_string()), + ..NetworkUpdate::default() + }; + self.update_network_state(&update).await + } + fn sleep_backend(&self) -> SleepBackend { SleepBackend::infer(self.http_client.is_tokio()) } @@ -1207,6 +1248,49 @@ pub struct Version { #[cfg(test)] mod tests { + use super::*; + use mockito::Matcher; + + #[tokio::test] + async fn test_network_update_and_deserialize_remotes() { + let mut s = mockito::Server::new_async().await; + let base = s.url(); + + let response_body = serde_json::json!({ + "remotes": { + "ms-00": { + "url": "http://ms-00", + "searchApiKey": "SEARCH", + "writeApiKey": "WRITE" + } + }, + "self": "ms-00", + "sharding": true + }) + .to_string(); + + let _m = s + .mock("PATCH", "/network") + .match_body(Matcher::Regex( + r#"\{.*"sharding"\s*:\s*true.*\}"#.to_string(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(response_body) + .create_async() + .await; + + let client = Client::new(base, None::).unwrap(); + let updated = client + .set_sharding(true) + .await + .expect("update_network_state failed"); + assert_eq!(updated.sharding, Some(true)); + let remotes = updated.remotes.expect("remotes should be present"); + let ms00 = remotes.get("ms-00").expect("ms-00 should exist"); + assert_eq!(ms00.write_api_key.as_deref(), Some("WRITE")); + } + use big_s::S; use time::OffsetDateTime; diff --git a/src/lib.rs b/src/lib.rs index e4c8d5ac..2ba31fa2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -244,6 +244,8 @@ pub mod features; pub mod indexes; /// Module containing the [`Key`](key::Key) struct. pub mod key; +/// Module for Network configuration API (sharding/remotes). +pub mod network; pub mod request; /// Module related to search queries and results. pub mod search; diff --git a/src/network.rs b/src/network.rs new file mode 100644 index 00000000..9f7857e0 --- /dev/null +++ b/src/network.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteConfig { + pub url: String, + #[serde(rename = "searchApiKey")] + pub search_api_key: String, + #[serde(rename = "writeApiKey", skip_serializing_if = "Option::is_none")] + // present in responses since 1.19 + pub write_api_key: Option, +} + +pub type RemotesMap = HashMap; + +/// Full network state returned by GET /network +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkState { + pub remotes: Option, + #[serde(rename = "self")] + pub self_name: Option, + pub sharding: Option, +} + +/// Partial update body for PATCH /network +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub remotes: Option, + #[serde(rename = "self", skip_serializing_if = "Option::is_none")] + pub self_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sharding: Option, +} diff --git a/src/search.rs b/src/search.rs index 084a0d3f..53e2b872 100644 --- a/src/search.rs +++ b/src/search.rs @@ -415,8 +415,12 @@ pub struct SearchQuery<'a, Http: HttpClient> { #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct QueryFederationOptions { + /// Weight multiplier for this query when merging federated results #[serde(skip_serializing_if = "Option::is_none")] pub weight: Option, + /// Remote instance name to target when sharding; corresponds to a key in network.remotes + #[serde(skip_serializing_if = "Option::is_none")] + pub remote: Option, } #[allow(missing_docs)] @@ -766,6 +770,7 @@ impl<'a, 'b, Http: HttpClient> MultiSearchQuery<'a, 'b, Http> { search_query, QueryFederationOptions { weight: Some(weight), + remote: None, }, ) } diff --git a/src/tasks.rs b/src/tasks.rs index 9977eae4..080ef8a8 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{Map, Value}; use std::time::Duration; use time::OffsetDateTime; @@ -157,6 +158,9 @@ pub struct SucceededTask { pub canceled_by: Option, pub index_uid: Option, pub error: Option, + /// Remotes object returned by the server for this task (present since Meilisearch 1.19) + #[serde(skip_serializing_if = "Option::is_none")] + pub remotes: Option>, #[serde(flatten)] pub update_type: TaskType, pub uid: u32, @@ -174,6 +178,9 @@ pub struct EnqueuedTask { #[serde(with = "time::serde::rfc3339")] pub enqueued_at: OffsetDateTime, pub index_uid: Option, + /// Remotes object returned by the server for this enqueued task + #[serde(skip_serializing_if = "Option::is_none")] + pub remotes: Option>, #[serde(flatten)] pub update_type: TaskType, pub uid: u32, @@ -193,6 +200,9 @@ pub struct ProcessingTask { #[serde(with = "time::serde::rfc3339")] pub started_at: OffsetDateTime, pub index_uid: Option, + /// Remotes object returned by the server for this processing task + #[serde(skip_serializing_if = "Option::is_none")] + pub remotes: Option>, #[serde(flatten)] pub update_type: TaskType, pub uid: u32, @@ -738,6 +748,55 @@ impl<'a, Http: HttpClient> TasksQuery<'a, TasksPaginationFilters, Http> { #[cfg(test)] mod test { + use super::*; + + #[test] + fn test_deserialize_enqueued_task_with_remotes() { + let json = r#"{ + "enqueuedAt": "2022-02-03T13:02:38.369634Z", + "indexUid": "movies", + "status": "enqueued", + "type": "indexUpdate", + "uid": 12, + "remotes": { "ms-00": { "status": "ok" } } +}"#; + let task: Task = serde_json::from_str(json).unwrap(); + match task { + Task::Enqueued { content } => { + let remotes = content.remotes.expect("remotes should be present"); + assert!(remotes.contains_key("ms-00")); + } + _ => panic!("expected enqueued task"), + } + } + + #[test] + fn test_deserialize_processing_task_with_remotes() { + let json = r#"{ + "details": { + "indexedDocuments": null, + "receivedDocuments": 10 + }, + "duration": null, + "enqueuedAt": "2022-02-03T15:17:02.801341Z", + "finishedAt": null, + "indexUid": "movies", + "startedAt": "2022-02-03T15:17:02.812338Z", + "status": "processing", + "type": "documentAdditionOrUpdate", + "uid": 14, + "remotes": { "ms-00": { "status": "ok" } } +}"#; + let task: Task = serde_json::from_str(json).unwrap(); + match task { + Task::Processing { content } => { + let remotes = content.remotes.expect("remotes should be present"); + assert!(remotes.contains_key("ms-00")); + } + _ => panic!("expected processing task"), + } + } + use super::*; use crate::{ client::*, @@ -782,8 +841,7 @@ mod test { enqueued_at, index_uid: Some(index_uid), update_type: TaskType::DocumentAdditionOrUpdate { details: None }, - uid: 12, - } + uid: 12, .. } } if enqueued_at == datetime && index_uid == "meili")); From ca562a0599e50165a08eb8192a40c1eace255794 Mon Sep 17 00:00:00 2001 From: Kumar Ujjawal Date: Mon, 22 Sep 2025 09:56:12 +0530 Subject: [PATCH 02/11] fixed formatting --- src/tasks.rs | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/tasks.rs b/src/tasks.rs index 080ef8a8..08a05d96 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -753,13 +753,13 @@ mod test { #[test] fn test_deserialize_enqueued_task_with_remotes() { let json = r#"{ - "enqueuedAt": "2022-02-03T13:02:38.369634Z", - "indexUid": "movies", - "status": "enqueued", - "type": "indexUpdate", - "uid": 12, - "remotes": { "ms-00": { "status": "ok" } } -}"#; + "enqueuedAt": "2022-02-03T13:02:38.369634Z", + "indexUid": "movies", + "status": "enqueued", + "type": "indexUpdate", + "uid": 12, + "remotes": { "ms-00": { "status": "ok" } } + }"#; let task: Task = serde_json::from_str(json).unwrap(); match task { Task::Enqueued { content } => { @@ -773,20 +773,20 @@ mod test { #[test] fn test_deserialize_processing_task_with_remotes() { let json = r#"{ - "details": { - "indexedDocuments": null, - "receivedDocuments": 10 - }, - "duration": null, - "enqueuedAt": "2022-02-03T15:17:02.801341Z", - "finishedAt": null, - "indexUid": "movies", - "startedAt": "2022-02-03T15:17:02.812338Z", - "status": "processing", - "type": "documentAdditionOrUpdate", - "uid": 14, - "remotes": { "ms-00": { "status": "ok" } } -}"#; + "details": { + "indexedDocuments": null, + "receivedDocuments": 10 + }, + "duration": null, + "enqueuedAt": "2022-02-03T15:17:02.801341Z", + "finishedAt": null, + "indexUid": "movies", + "startedAt": "2022-02-03T15:17:02.812338Z", + "status": "processing", + "type": "documentAdditionOrUpdate", + "uid": 14, + "remotes": { "ms-00": { "status": "ok" } } + }"#; let task: Task = serde_json::from_str(json).unwrap(); match task { Task::Processing { content } => { From 910f9e9d5c753d0c1d89f6cdbff0b89c0ae5709a Mon Sep 17 00:00:00 2001 From: meili-bot <74670311+meili-bot@users.noreply.github.com> Date: Mon, 22 Sep 2025 13:24:49 -0300 Subject: [PATCH 03/11] Update dependabot and release template configuration (#710) * Update .github/dependabot.yml * Update .github/release-draft-template.yml --- .github/dependabot.yml | 2 -- .github/release-draft-template.yml | 5 +---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3095f9b5..85c785ed 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,6 @@ updates: schedule: interval: "monthly" labels: - - 'skip-changelog' - 'dependencies' rebase-strategy: disabled @@ -16,6 +15,5 @@ updates: time: "04:00" open-pull-requests-limit: 10 labels: - - skip-changelog - dependencies rebase-strategy: disabled diff --git a/.github/release-draft-template.yml b/.github/release-draft-template.yml index daada503..8732900e 100644 --- a/.github/release-draft-template.yml +++ b/.github/release-draft-template.yml @@ -18,6 +18,7 @@ categories: label: 'security' - title: '⚙️ Maintenance/misc' label: + - 'dependencies' - 'maintenance' - 'documentation' template: | @@ -27,10 +28,6 @@ template: | no-changes-template: 'Changes are coming soon 😎' sort-direction: 'ascending' replacers: - - search: '/(?:and )?@dependabot-preview(?:\[bot\])?,?/g' - replace: '' - - search: '/(?:and )?@dependabot(?:\[bot\])?,?/g' - replace: '' - search: '/(?:and )?@bors(?:\[bot\])?,?/g' replace: '' - search: '/(?:and )?@meili-bot(?:\[bot\])?,?/g' From 59c2da18b5732dbb332145f77a03869f23b841fb Mon Sep 17 00:00:00 2001 From: Kumar Ujjawal Date: Wed, 24 Sep 2025 00:43:46 +0530 Subject: [PATCH 04/11] removed Debug --- src/network.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/network.rs b/src/network.rs index 9f7857e0..d221f781 100644 --- a/src/network.rs +++ b/src/network.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RemoteConfig { pub url: String, @@ -15,7 +15,7 @@ pub struct RemoteConfig { pub type RemotesMap = HashMap; /// Full network state returned by GET /network -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NetworkState { pub remotes: Option, @@ -25,7 +25,7 @@ pub struct NetworkState { } /// Partial update body for PATCH /network -#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[derive(Default, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NetworkUpdate { #[serde(skip_serializing_if = "Option::is_none")] From d27dbed003edc8f1471b4c1d474bde72ed55aa62 Mon Sep 17 00:00:00 2001 From: Kumar Ujjawal Date: Wed, 24 Sep 2025 00:51:33 +0530 Subject: [PATCH 05/11] nit --- src/tasks.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tasks.rs b/src/tasks.rs index 08a05d96..c06b21d7 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -748,7 +748,6 @@ impl<'a, Http: HttpClient> TasksQuery<'a, TasksPaginationFilters, Http> { #[cfg(test)] mod test { - use super::*; #[test] fn test_deserialize_enqueued_task_with_remotes() { From 0329ee4844fb21e835a265909ae670e2d3b42ee5 Mon Sep 17 00:00:00 2001 From: Kumar Ujjawal Date: Fri, 26 Sep 2025 18:23:31 +0530 Subject: [PATCH 06/11] Add support for sorting on the documents API --- src/documents.rs | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/documents.rs b/src/documents.rs index e166d4cc..63a36caa 100644 --- a/src/documents.rs +++ b/src/documents.rs @@ -190,6 +190,12 @@ pub struct DocumentsQuery<'a, Http: HttpClient> { #[serde(skip_serializing_if = "Option::is_none")] pub fields: Option>, + /// Attributes used to sort the returned documents. + /// + /// Available since v1.16 of Meilisearch. + #[serde(skip_serializing_if = "Option::is_none")] + pub sort: Option>, + /// Filters to apply. /// /// Available since v1.2 of Meilisearch @@ -206,6 +212,7 @@ impl<'a, Http: HttpClient> DocumentsQuery<'a, Http> { offset: None, limit: None, fields: None, + sort: None, filter: None, } } @@ -277,6 +284,31 @@ impl<'a, Http: HttpClient> DocumentsQuery<'a, Http> { self } + /// Specify the sort order of the returned documents. + /// + /// # Example + /// + /// ``` + /// # use meilisearch_sdk::{client::*, indexes::*, documents::*}; + /// # + /// # let MEILISEARCH_URL = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700"); + /// # let MEILISEARCH_API_KEY = option_env!("MEILISEARCH_API_KEY").unwrap_or("masterKey"); + /// # + /// # let client = Client::new(MEILISEARCH_URL, Some(MEILISEARCH_API_KEY)).unwrap(); + /// let index = client.index("documents_with_sort"); + /// + /// let mut documents_query = DocumentsQuery::new(&index); + /// + /// documents_query.with_sort(["release_date:desc"]); + /// ``` + pub fn with_sort( + &mut self, + sort: impl IntoIterator, + ) -> &mut DocumentsQuery<'a, Http> { + self.sort = Some(sort.into_iter().collect()); + self + } + pub fn with_filter<'b>(&'b mut self, filter: &'a str) -> &'b mut DocumentsQuery<'a, Http> { self.filter = Some(filter); self @@ -538,6 +570,33 @@ mod tests { Ok(()) } + #[meilisearch_test] + async fn test_get_documents_with_sort(client: Client, index: Index) -> Result<(), Error> { + setup_test_index(&client, &index).await?; + + index + .set_sortable_attributes(["id"]) + .await? + .wait_for_completion(&client, None, None) + .await?; + + let documents = DocumentsQuery::new(&index) + .with_sort(["id:desc"]) + .execute::() + .await?; + + assert_eq!( + documents.results.first().and_then(|document| document.id), + Some(3) + ); + assert_eq!( + documents.results.last().and_then(|document| document.id), + Some(0) + ); + + Ok(()) + } + #[meilisearch_test] async fn test_get_documents_with_error_hint() -> Result<(), Error> { let meilisearch_url = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700"); From de376662ad8ced641317dd5b7e41c51955b555dd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 04:03:51 +0000 Subject: [PATCH 07/11] Update thiserror requirement from 1.0.51 to 2.0.17 --- updated-dependencies: - dependency-name: thiserror dependency-version: 2.0.17 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- examples/web_app_graphql/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/web_app_graphql/Cargo.toml b/examples/web_app_graphql/Cargo.toml index 4c90ea56..a94189db 100644 --- a/examples/web_app_graphql/Cargo.toml +++ b/examples/web_app_graphql/Cargo.toml @@ -22,5 +22,5 @@ log = "0.4.20" meilisearch-sdk = "0.24.3" serde = { version = "1.0.192", features = ["derive"] } serde_json = "1.0.108" -thiserror = "1.0.51" +thiserror = "2.0.17" validator = { version = "0.20.0", features = ["derive"] } From a3277e4daaaeccb0b07a00340b1f36d616d72ab2 Mon Sep 17 00:00:00 2001 From: Kumar Ujjawal Date: Wed, 15 Oct 2025 23:11:17 +0530 Subject: [PATCH 08/11] chore: Bump jsonwebtoken crate to 10.0.0 --- Cargo.toml | 2 +- src/tenant_tokens.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 9bbead8e..76907589 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ futures-channel = "0.3.31" futures-util = { version = "0.3.31", default-features = false, features = ["io"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -jsonwebtoken = { version = "9.3.1", default-features = false } +jsonwebtoken = { version = "10.0.0", default-features = false, features = ["aws_lc_rs"]} tokio = { version = "1.38", optional = true, features = ["time"] } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/src/tenant_tokens.rs b/src/tenant_tokens.rs index 24052d41..b7c6dea9 100644 --- a/src/tenant_tokens.rs +++ b/src/tenant_tokens.rs @@ -6,6 +6,7 @@ use time::OffsetDateTime; #[cfg(not(target_arch = "wasm32"))] use uuid::Uuid; +#[cfg_attr(test, derive(Clone))] #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct TenantTokenClaim { From db9f85eed7f26ec2655d1a2965e70af445c63d65 Mon Sep 17 00:00:00 2001 From: Kumar Ujjawal Date: Sat, 18 Oct 2025 13:10:55 +0530 Subject: [PATCH 09/11] feat: Add support for webhook API --- .code-samples.meilisearch.yaml | 19 +++ Cargo.toml | 4 +- src/client.rs | 163 ++++++++++++++++++++++ src/lib.rs | 2 + src/webhooks.rs | 241 +++++++++++++++++++++++++++++++++ 5 files changed, 427 insertions(+), 2 deletions(-) create mode 100644 src/webhooks.rs diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index 48c194a2..96f53fc2 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -1956,3 +1956,22 @@ update_embedders_1: |- .set_embedders(&embedders) .await .unwrap(); +webhooks_get_1: |- + let webhooks = client.get_webhooks().await.unwrap(); +webhooks_get_single_1: |- + let webhook = client.get_webhook("WEBHOOK_UUID").await.unwrap(); +webhooks_post_1: |- + let mut payload = WebhookCreate::new("WEBHOOK_TARGET_URL"); + payload + .insert_header("authorization", "SECURITY_KEY") + .insert_header("referer", "https://example.com"); + let webhook = client.create_webhook(&payload).await.unwrap(); +webhooks_patch_1: |- + let mut update = WebhookUpdate::new(); + update.remove_header("referer"); + let webhook = client + .update_webhook("WEBHOOK_UUID", &update) + .await + .unwrap(); +webhooks_delete_1: |- + client.delete_webhook("WEBHOOK_UUID").await.unwrap(); diff --git a/Cargo.toml b/Cargo.toml index 9bbead8e..4d197a91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ meilisearch-index-setting-macro = { path = "meilisearch-index-setting-macro", ve pin-project-lite = { version = "0.2.16", optional = true } reqwest = { version = "0.12.22", optional = true, default-features = false, features = ["http2", "stream"] } bytes = { version = "1.10.1", optional = true } -uuid = { version = "1.17.0", features = ["v4"] } +uuid = { version = "1.17.0", features = ["v4", "serde"] } futures-core = "0.3.31" futures-io = "0.3.31" futures-channel = "0.3.31" @@ -37,7 +37,7 @@ jsonwebtoken = { version = "9.3.1", default-features = false } tokio = { version = "1.38", optional = true, features = ["time"] } [target.'cfg(target_arch = "wasm32")'.dependencies] -uuid = { version = "1.17.0", default-features = false, features = ["v4", "js"] } +uuid = { version = "1.17.0", default-features = false, features = ["v4", "js", "serde"] } web-sys = "0.3.77" wasm-bindgen-futures = "0.4" diff --git a/src/client.rs b/src/client.rs index 5e02abaa..efb40683 100644 --- a/src/client.rs +++ b/src/client.rs @@ -14,6 +14,7 @@ use crate::{ task_info::TaskInfo, tasks::{Task, TasksCancelQuery, TasksDeleteQuery, TasksResults, TasksSearchQuery}, utils::SleepBackend, + webhooks::{WebhookCreate, WebhookInfo, WebhookList, WebhookUpdate}, DefaultHttpClient, }; @@ -1189,6 +1190,168 @@ impl Client { self.update_network_state(&update).await } + /// List all webhooks registered on the Meilisearch instance. + /// + /// + /// # Example + /// + /// ``` + /// # use meilisearch_sdk::{client::*, webhooks::*}; + /// # + /// # let MEILISEARCH_URL = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700"); + /// # let MEILISEARCH_API_KEY = option_env!("MEILISEARCH_API_KEY").unwrap_or("masterKey"); + /// # + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + /// # let client = Client::new(MEILISEARCH_URL, Some(MEILISEARCH_API_KEY)).unwrap(); + /// if let Ok(webhooks) = client.get_webhooks().await { + /// println!("{}", webhooks.results.len()); + /// } + /// # }); + /// ``` + pub async fn get_webhooks(&self) -> Result { + self.http_client + .request::<(), (), WebhookList>( + &format!("{}/webhooks", self.host), + Method::Get { query: () }, + 200, + ) + .await + } + + /// Retrieve a single webhook by its UUID. + /// + /// + /// # Example + /// + /// ``` + /// # use meilisearch_sdk::{client::*, webhooks::*}; + /// # + /// # let MEILISEARCH_URL = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700"); + /// # let MEILISEARCH_API_KEY = option_env!("MEILISEARCH_API_KEY").unwrap_or("masterKey"); + /// # + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + /// # let client = Client::new(MEILISEARCH_URL, Some(MEILISEARCH_API_KEY)).unwrap(); + /// # if let Ok(created) = client.create_webhook(&WebhookCreate::new("https://example.com")).await { + /// if let Ok(webhook) = client.get_webhook(&created.uuid.to_string()).await { + /// println!("{}", webhook.webhook.url); + /// # let _ = client.delete_webhook(&webhook.uuid.to_string()).await; + /// } + /// # } + /// # }); + /// ``` + pub async fn get_webhook(&self, uuid: impl AsRef) -> Result { + self.http_client + .request::<(), (), WebhookInfo>( + &format!("{}/webhooks/{}", self.host, uuid.as_ref()), + Method::Get { query: () }, + 200, + ) + .await + } + + /// Create a new webhook. + /// + /// + /// # Example + /// + /// ``` + /// # use meilisearch_sdk::{client::*, webhooks::*}; + /// # + /// # let MEILISEARCH_URL = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700"); + /// # let MEILISEARCH_API_KEY = option_env!("MEILISEARCH_API_KEY").unwrap_or("masterKey"); + /// # + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + /// # let client = Client::new(MEILISEARCH_URL, Some(MEILISEARCH_API_KEY)).unwrap(); + /// if let Ok(webhook) = client + /// .create_webhook(&WebhookCreate::new("https://example.com/webhook")) + /// .await + /// { + /// assert!(webhook.is_editable); + /// # let _ = client.delete_webhook(&webhook.uuid.to_string()).await; + /// } + /// # }); + /// ``` + pub async fn create_webhook(&self, webhook: &WebhookCreate) -> Result { + self.http_client + .request::<(), &WebhookCreate, WebhookInfo>( + &format!("{}/webhooks", self.host), + Method::Post { + query: (), + body: webhook, + }, + 201, + ) + .await + } + + /// Update an existing webhook. + /// + /// + /// # Example + /// + /// ``` + /// # use meilisearch_sdk::{client::*, webhooks::*}; + /// # + /// # let MEILISEARCH_URL = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700"); + /// # let MEILISEARCH_API_KEY = option_env!("MEILISEARCH_API_KEY").unwrap_or("masterKey"); + /// # + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + /// # let client = Client::new(MEILISEARCH_URL, Some(MEILISEARCH_API_KEY)).unwrap(); + /// if let Ok(webhook) = client.create_webhook(&WebhookCreate::new("https://example.com")).await { + /// let mut update = WebhookUpdate::new(); + /// update.set_header("authorization", "SECURITY_KEY"); + /// let _ = client + /// .update_webhook(&webhook.uuid.to_string(), &update) + /// .await; + /// # let _ = client.delete_webhook(&webhook.uuid.to_string()).await; + /// } + /// # }); + /// ``` + pub async fn update_webhook( + &self, + uuid: impl AsRef, + webhook: &WebhookUpdate, + ) -> Result { + self.http_client + .request::<(), &WebhookUpdate, WebhookInfo>( + &format!("{}/webhooks/{}", self.host, uuid.as_ref()), + Method::Patch { + query: (), + body: webhook, + }, + 200, + ) + .await + } + + /// Delete a webhook by its UUID. + /// + /// + /// # Example + /// + /// ``` + /// # use meilisearch_sdk::{client::*, webhooks::*}; + /// # + /// # let MEILISEARCH_URL = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700"); + /// # let MEILISEARCH_API_KEY = option_env!("MEILISEARCH_API_KEY").unwrap_or("masterKey"); + /// # + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + /// # let client = Client::new(MEILISEARCH_URL, Some(MEILISEARCH_API_KEY)).unwrap(); + /// if let Ok(webhook) = client.create_webhook(&WebhookCreate::new("https://example.com")).await { + /// let _ = client.delete_webhook(&webhook.uuid.to_string()).await; + /// } + /// # }); + /// ``` + pub async fn delete_webhook(&self, uuid: impl AsRef) -> Result<(), Error> { + self.http_client + .request::<(), (), ()>( + &format!("{}/webhooks/{}", self.host, uuid.as_ref()), + Method::Delete { query: () }, + 204, + ) + .await + } + fn sleep_backend(&self) -> SleepBackend { SleepBackend::infer(self.http_client.is_tokio()) } diff --git a/src/lib.rs b/src/lib.rs index 2ba31fa2..19a595c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -264,6 +264,8 @@ pub mod tasks; mod tenant_tokens; /// Module containing utilizes functions. mod utils; +/// Module to manage webhooks. +pub mod webhooks; #[cfg(feature = "reqwest")] pub mod reqwest; diff --git a/src/webhooks.rs b/src/webhooks.rs new file mode 100644 index 00000000..096dbb8b --- /dev/null +++ b/src/webhooks.rs @@ -0,0 +1,241 @@ +use serde::Deserialize; +use serde::{ser::SerializeMap, Serialize, Serializer}; +use serde_json::Value; +use std::collections::BTreeMap; +use uuid::Uuid; + +/// Representation of a webhook configuration in Meilisearch. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Webhook { + pub url: String, + #[serde(default)] + pub headers: BTreeMap, +} + +/// Metadata returned for each webhook by the Meilisearch API. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WebhookInfo { + pub uuid: Uuid, + pub is_editable: bool, + #[serde(flatten)] + pub webhook: Webhook, +} + +/// Results wrapper returned by the `GET /webhooks` endpoint. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WebhookList { + pub results: Vec, +} + +/// Payload used to create a new webhook. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WebhookCreate { + pub url: String, + #[serde(default)] + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, +} + +impl WebhookCreate { + /// Creates a new webhook payload with the given target URL. + #[must_use] + pub fn new(url: impl Into) -> Self { + Self { + url: url.into(), + headers: BTreeMap::new(), + } + } + + /// Adds or replaces an HTTP header that will be sent with the webhook request. + pub fn with_header(mut self, name: impl Into, value: impl Into) -> Self { + self.headers.insert(name.into(), value.into()); + self + } + + /// Adds or replaces an HTTP header in-place. + pub fn insert_header( + &mut self, + name: impl Into, + value: impl Into, + ) -> &mut Self { + self.headers.insert(name.into(), value.into()); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum HeadersUpdate { + NotSet, + Reset, + Set(BTreeMap>), +} + +impl Default for HeadersUpdate { + fn default() -> Self { + Self::NotSet + } +} + +/// Payload used to update or delete settings of an existing webhook. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct WebhookUpdate { + url: Option, + headers: HeadersUpdate, +} + +impl WebhookUpdate { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Updates the webhook target URL. + pub fn with_url(&mut self, url: impl Into) -> &mut Self { + self.url = Some(url.into()); + self + } + + /// Adds or replaces an HTTP header to be sent with the webhook request. + pub fn set_header(&mut self, name: impl Into, value: impl Into) -> &mut Self { + match &mut self.headers { + HeadersUpdate::Set(map) => { + map.insert(name.into(), Some(value.into())); + } + _ => { + let mut map = BTreeMap::new(); + map.insert(name.into(), Some(value.into())); + self.headers = HeadersUpdate::Set(map); + } + } + self + } + + /// Removes a specific HTTP header from the webhook configuration. + pub fn remove_header(&mut self, name: impl Into) -> &mut Self { + match &mut self.headers { + HeadersUpdate::Set(map) => { + map.insert(name.into(), None); + } + _ => { + let mut map = BTreeMap::new(); + map.insert(name.into(), None); + self.headers = HeadersUpdate::Set(map); + } + } + self + } + + /// Clears all HTTP headers associated with this webhook. + pub fn reset_headers(&mut self) -> &mut Self { + self.headers = HeadersUpdate::Reset; + self + } +} + +impl Serialize for WebhookUpdate { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut field_count = 0; + if self.url.is_some() { + field_count += 1; + } + if !matches!(self.headers, HeadersUpdate::NotSet) { + field_count += 1; + } + + let mut map = serializer.serialize_map(Some(field_count))?; + if let Some(url) = &self.url { + map.serialize_entry("url", url)?; + } + match &self.headers { + HeadersUpdate::NotSet => {} + HeadersUpdate::Reset => { + map.serialize_entry("headers", &Value::Null)?; + } + HeadersUpdate::Set(values) => { + map.serialize_entry("headers", values)?; + } + } + map.end() + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::client::Client; + use crate::errors::Error; + use meilisearch_test_macro::meilisearch_test; + + #[test] + fn serialize_update_variants() { + let mut update = WebhookUpdate::new(); + update.set_header("authorization", "token"); + update.remove_header("referer"); + + let json = serde_json::to_value(&update).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "headers": { + "authorization": "token", + "referer": null + } + }) + ); + + let mut reset = WebhookUpdate::new(); + reset.reset_headers(); + let json = serde_json::to_value(&reset).unwrap(); + assert_eq!(json, serde_json::json!({ "headers": null })); + } + + #[meilisearch_test] + async fn webhook_crud(client: Client) -> Result<(), Error> { + let initial = client.get_webhooks().await?.results.len(); + + let unique_url = format!("https://example.com/webhooks/{}", Uuid::new_v4()); + + let mut create = WebhookCreate::new(unique_url.clone()); + create + .insert_header("authorization", "SECURITY_KEY") + .insert_header("referer", "https://example.com"); + + let created = client.create_webhook(&create).await?; + assert_eq!(created.webhook.url, unique_url); + assert!(created.is_editable); + assert_eq!(created.webhook.headers.len(), 2); + + let fetched = client.get_webhook(&created.uuid.to_string()).await?; + assert_eq!(fetched.uuid, created.uuid); + + let mut update = WebhookUpdate::new(); + update.remove_header("referer"); + update.set_header("x-extra", "value"); + + let updated = client + .update_webhook(&created.uuid.to_string(), &update) + .await?; + assert!(!updated.webhook.headers.contains_key("referer")); + assert_eq!( + updated.webhook.headers.get("x-extra"), + Some(&"value".to_string()) + ); + + client.delete_webhook(&created.uuid.to_string()).await?; + + let remaining = client.get_webhooks().await?; + assert!( + remaining.results.len() == initial + || !remaining.results.iter().any(|w| w.uuid == created.uuid) + ); + + Ok(()) + } +} From 653c3675e1b0d477dd1a861ced217fef762484fb Mon Sep 17 00:00:00 2001 From: Kumar Ujjawal Date: Sat, 18 Oct 2025 13:49:56 +0530 Subject: [PATCH 10/11] Polish webhook helpers --- .code-samples.meilisearch.yaml | 4 ++-- src/webhooks.rs | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index 96f53fc2..6bdef025 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -1961,13 +1961,13 @@ webhooks_get_1: |- webhooks_get_single_1: |- let webhook = client.get_webhook("WEBHOOK_UUID").await.unwrap(); webhooks_post_1: |- - let mut payload = WebhookCreate::new("WEBHOOK_TARGET_URL"); + let mut payload = meilisearch_sdk::webhooks::WebhookCreate::new("WEBHOOK_TARGET_URL"); payload .insert_header("authorization", "SECURITY_KEY") .insert_header("referer", "https://example.com"); let webhook = client.create_webhook(&payload).await.unwrap(); webhooks_patch_1: |- - let mut update = WebhookUpdate::new(); + let mut update = meilisearch_sdk::webhooks::WebhookUpdate::new(); update.remove_header("referer"); let webhook = client .update_webhook("WEBHOOK_UUID", &update) diff --git a/src/webhooks.rs b/src/webhooks.rs index 096dbb8b..b5299b0c 100644 --- a/src/webhooks.rs +++ b/src/webhooks.rs @@ -1,6 +1,5 @@ use serde::Deserialize; use serde::{ser::SerializeMap, Serialize, Serializer}; -use serde_json::Value; use std::collections::BTreeMap; use uuid::Uuid; @@ -93,6 +92,12 @@ impl WebhookUpdate { Self::default() } + #[must_use] + pub fn with_url_owned(mut self, url: impl Into) -> Self { + self.url = Some(url.into()); + self + } + /// Updates the webhook target URL. pub fn with_url(&mut self, url: impl Into) -> &mut Self { self.url = Some(url.into()); @@ -156,7 +161,8 @@ impl Serialize for WebhookUpdate { match &self.headers { HeadersUpdate::NotSet => {} HeadersUpdate::Reset => { - map.serialize_entry("headers", &Value::Null)?; + let none: Option<()> = None; + map.serialize_entry("headers", &none)?; } HeadersUpdate::Set(values) => { map.serialize_entry("headers", values)?; @@ -228,6 +234,13 @@ mod test { Some(&"value".to_string()) ); + let mut clear = WebhookUpdate::new(); + clear.reset_headers(); + let cleared = client + .update_webhook(&created.uuid.to_string(), &clear) + .await?; + assert!(cleared.webhook.headers.is_empty()); + client.delete_webhook(&created.uuid.to_string()).await?; let remaining = client.get_webhooks().await?; From e8b06f7b3d4e7603028e531560faa07851181e90 Mon Sep 17 00:00:00 2001 From: Kumar Ujjawal Date: Sat, 18 Oct 2025 13:57:17 +0530 Subject: [PATCH 11/11] Cleaned up webhook module --- src/webhooks.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/webhooks.rs b/src/webhooks.rs index b5299b0c..85a852a3 100644 --- a/src/webhooks.rs +++ b/src/webhooks.rs @@ -92,12 +92,6 @@ impl WebhookUpdate { Self::default() } - #[must_use] - pub fn with_url_owned(mut self, url: impl Into) -> Self { - self.url = Some(url.into()); - self - } - /// Updates the webhook target URL. pub fn with_url(&mut self, url: impl Into) -> &mut Self { self.url = Some(url.into());