From d928d2452627ac5bb2d6d0310f68c09ca53ba9ac Mon Sep 17 00:00:00 2001 From: Hayden Hung Hoang Date: Wed, 29 Oct 2025 16:38:45 +0700 Subject: [PATCH 1/2] feat: stemming API --- preprocessed_openapi.yml | 2 + typesense/src/client/mod.rs | 28 ++++++ typesense/src/client/stemming/dictionaries.rs | 53 ++++++++++ typesense/src/client/stemming/dictionary.rs | 38 ++++++++ typesense/src/client/stemming/mod.rs | 40 ++++++++ typesense/tests/client/mod.rs | 1 + .../client/stemming_dictionaries_test.rs | 96 +++++++++++++++++++ typesense_codegen/src/apis/stemming_api.rs | 11 +-- xtask/src/add_vendor_attributes.rs | 8 ++ xtask/src/preprocess_openapi.rs | 1 + xtask/src/vendor_attributes.rs | 1 + 11 files changed, 272 insertions(+), 7 deletions(-) create mode 100644 typesense/src/client/stemming/dictionaries.rs create mode 100644 typesense/src/client/stemming/dictionary.rs create mode 100644 typesense/src/client/stemming/mod.rs create mode 100644 typesense/tests/client/stemming_dictionaries_test.rs diff --git a/preprocessed_openapi.yml b/preprocessed_openapi.yml index eeabdc0..d0fc19d 100644 --- a/preprocessed_openapi.yml +++ b/preprocessed_openapi.yml @@ -2974,6 +2974,8 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' + x-rust-body-is-raw-text: true + x-supports-plain-text: true /nl_search_models: get: tags: diff --git a/typesense/src/client/mod.rs b/typesense/src/client/mod.rs index fd1146b..8cd6f14 100644 --- a/typesense/src/client/mod.rs +++ b/typesense/src/client/mod.rs @@ -116,6 +116,7 @@ mod multi_search; mod operations; mod preset; mod presets; +mod stemming; mod stopword; mod stopwords; @@ -129,6 +130,7 @@ use keys::Keys; use operations::Operations; use preset::Preset; use presets::Presets; +use stemming::Stemming; use stopword::Stopword; use stopwords::Stopwords; @@ -735,6 +737,32 @@ impl Client { Preset::new(self, preset_id) } + /// Provides access to the stemming-related API endpoints. + /// + /// # Example + /// + /// ```no_run + /// # #[cfg(not(target_family = "wasm"))] + /// # { + /// # use typesense::Client; + /// # + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # let client = Client::builder() + /// # .nodes(vec!["http://localhost:8108"]) + /// # .api_key("xyz") + /// # .build() + /// # .unwrap(); + /// let response = client.stemming().dictionaries().retrieve().await.unwrap(); + /// # Ok(()) + /// # } + /// # } + /// ``` + #[inline] + pub fn stemming(&self) -> Stemming<'_> { + Stemming::new(self) + } + /// Provides access to endpoints for managing the collection of stopwords sets. /// /// # Example diff --git a/typesense/src/client/stemming/dictionaries.rs b/typesense/src/client/stemming/dictionaries.rs new file mode 100644 index 0000000..72854d1 --- /dev/null +++ b/typesense/src/client/stemming/dictionaries.rs @@ -0,0 +1,53 @@ +//! Provides access to the API endpoints for managing the collection of stemming dictionaries. +//! +//! A `Dictionaries` instance is created via the `client.stemming().dictionaries()` method. + +use crate::{ + client::{Client, Error}, + execute_wrapper, +}; +use typesense_codegen::{apis::stemming_api, models}; + +/// Provides methods for interacting with the collection of stemming dictionaries. +/// +/// This struct is created by calling `client.stemming().dictionaries()`. +pub struct Dictionaries<'a> { + pub(super) client: &'a Client, +} + +impl<'a> Dictionaries<'a> { + /// Creates a new `Dictionaries` instance. + #[inline] + pub(super) fn new(client: &'a Client) -> Self { + Self { client } + } + + /// Imports a stemming dictionary from a JSONL file content. + /// + /// This creates or updates a dictionary with the given ID. + /// + /// # Arguments + /// * `dictionary_id` - The ID to assign to the dictionary. + /// * `dictionary_jsonl` - A string containing the word mappings in JSONL format. + pub async fn import( + &self, + dictionary_id: impl Into, + dictionary_jsonl: String, + ) -> Result> { + let params = stemming_api::ImportStemmingDictionaryParams { + id: dictionary_id.into(), + body: dictionary_jsonl, + }; + execute_wrapper!(self, stemming_api::import_stemming_dictionary, params) + } + + /// Retrieves a list of all stemming dictionaries. + pub async fn retrieve( + &self, + ) -> Result< + models::ListStemmingDictionaries200Response, + Error, + > { + execute_wrapper!(self, stemming_api::list_stemming_dictionaries) + } +} diff --git a/typesense/src/client/stemming/dictionary.rs b/typesense/src/client/stemming/dictionary.rs new file mode 100644 index 0000000..bbd8212 --- /dev/null +++ b/typesense/src/client/stemming/dictionary.rs @@ -0,0 +1,38 @@ +//! Provides access to the API endpoints for managing a single stemming dictionary. +//! +//! An instance of `Dictionary` is created via the `client.stemming().dictionary()` method. + +use crate::{ + client::{Client, Error}, + execute_wrapper, +}; +use typesense_codegen::{apis::stemming_api, models}; + +/// Provides methods for interacting with a specific stemming dictionary. +/// +/// This struct is created by calling `client.stemming().dictionary("dictionary_id")`. +pub struct Dictionary<'a> { + pub(super) client: &'a Client, + pub(super) dictionary_id: &'a str, +} + +impl<'a> Dictionary<'a> { + /// Creates a new `Dictionary` instance for a specific dictionary ID. + #[inline] + pub(super) fn new(client: &'a Client, dictionary_id: &'a str) -> Self { + Self { + client, + dictionary_id, + } + } + + /// Retrieves the details of this specific stemming dictionary. + pub async fn retrieve( + &self, + ) -> Result> { + let params = stemming_api::GetStemmingDictionaryParams { + dictionary_id: self.dictionary_id.to_owned(), + }; + execute_wrapper!(self, stemming_api::get_stemming_dictionary, params) + } +} diff --git a/typesense/src/client/stemming/mod.rs b/typesense/src/client/stemming/mod.rs new file mode 100644 index 0000000..dd11916 --- /dev/null +++ b/typesense/src/client/stemming/mod.rs @@ -0,0 +1,40 @@ +//! Provides access to the API endpoints for managing stemming. +//! +//! An instance of `Stemming` is created via the `client.stemming()` method. + +pub mod dictionaries; +pub mod dictionary; + +use super::Client; +use dictionaries::Dictionaries; +use dictionary::Dictionary; + +/// Provides methods for managing Typesense stemming. +/// +/// This struct is created by calling `client.stemming()`. +pub struct Stemming<'a> { + pub(super) client: &'a Client, +} + +impl<'a> Stemming<'a> { + /// Creates a new `Stemming` instance. + #[inline] + pub(super) fn new(client: &'a Client) -> Self { + Self { client } + } + + /// Provides access to endpoints for managing the collection of dictionaries. + #[inline] + pub fn dictionaries(&self) -> Dictionaries<'a> { + Dictionaries::new(self.client) + } + + /// Provides access to endpoints for managing a single dictionary. + /// + /// # Arguments + /// * `dictionary_id` - The ID of the dictionary to manage. + #[inline] + pub fn dictionary(&self, dictionary_id: &'a str) -> Dictionary<'a> { + Dictionary::new(self.client, dictionary_id) + } +} diff --git a/typesense/tests/client/mod.rs b/typesense/tests/client/mod.rs index 668d897..553f8f6 100644 --- a/typesense/tests/client/mod.rs +++ b/typesense/tests/client/mod.rs @@ -6,6 +6,7 @@ mod keys_test; mod multi_search_test; mod operations_test; mod presets_test; +mod stemming_dictionaries_test; mod stopwords_test; use reqwest_retry::policies::ExponentialBackoff; diff --git a/typesense/tests/client/stemming_dictionaries_test.rs b/typesense/tests/client/stemming_dictionaries_test.rs new file mode 100644 index 0000000..3735933 --- /dev/null +++ b/typesense/tests/client/stemming_dictionaries_test.rs @@ -0,0 +1,96 @@ +use crate::{get_client, new_id}; + +async fn run_test_stemming_dictionary_import_and_retrieve() { + let client = get_client(); + let dictionary_id = new_id("verb_stems_v2"); + + // --- 1. Define and Import the Dictionary --- + // The JSONL payload uses "word" and "root" keys. + let dictionary_data = r#"{"word": "running", "root": "run"} +{"word": "flies", "root": "fly"}"# + .to_owned(); + let import_result = client + .stemming() + .dictionaries() + .import(&dictionary_id, dictionary_data) + .await; + assert!( + import_result.is_ok(), + "Failed to import stemming dictionary. Error: {:?}", + import_result.err() + ); + + // --- 2. Retrieve the specific dictionary by its ID to verify contents --- + // This is necessary because the list operation only returns IDs. + let get_result = client + .stemming() + .dictionary(&dictionary_id) + .retrieve() + .await; + assert!( + get_result.is_ok(), + "Failed to retrieve the specific stemming dictionary. Error: {:?}", + get_result.err() + ); + + let dictionary = get_result.unwrap(); + assert_eq!(dictionary.id, dictionary_id); + assert_eq!( + dictionary.words.len(), + 2, + "The number of words in the retrieved dictionary is incorrect." + ); + assert!( + dictionary + .words + .iter() + .any(|w| w.word == "running" && w.root == "run"), + "The mapping for 'running' -> 'run' was not found." + ); + + // --- 3. Retrieve all dictionary IDs and find ours --- + let list_result = client.stemming().dictionaries().retrieve().await; + assert!( + list_result.is_ok(), + "Failed to retrieve the list of stemming dictionaries. Error: {:?}", + list_result.err() + ); + + let list_response = list_result.unwrap(); + let dictionary_ids = list_response.dictionaries; + + assert!( + dictionary_ids.is_some(), + "The list of dictionary IDs should not be None." + ); + + let ids_vec = dictionary_ids.unwrap(); + assert!( + ids_vec.iter().any(|id| id == &dictionary_id), + "The newly imported dictionary's ID was not found in the master list." + ); +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tokio_test { + use super::*; + + #[tokio::test] + async fn test_stemming_dictionary_import_and_retrieve() { + run_test_stemming_dictionary_import_and_retrieve().await; + } +} + +#[cfg(all(test, target_arch = "wasm32"))] +mod wasm_test { + use super::*; + use wasm_bindgen_test::wasm_bindgen_test; + + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + + #[wasm_bindgen_test] + async fn test_stemming_dictionary_import_and_retrieve() { + console_error_panic_hook::set_once(); + run_test_stemming_dictionary_import_and_retrieve().await; + } +} diff --git a/typesense_codegen/src/apis/stemming_api.rs b/typesense_codegen/src/apis/stemming_api.rs index cd5e9cb..de8470f 100644 --- a/typesense_codegen/src/apis/stemming_api.rs +++ b/typesense_codegen/src/apis/stemming_api.rs @@ -135,7 +135,9 @@ pub async fn import_stemming_dictionary( }; req_builder = req_builder.header("X-TYPESENSE-API-KEY", value); }; - req_builder = req_builder.json(¶ms.body); + req_builder = req_builder + .header(reqwest::header::CONTENT_TYPE, "text/plain") + .body(params.body.to_owned()); let req = req_builder.build()?; let resp = configuration.client.execute(req).await?; @@ -151,12 +153,7 @@ pub async fn import_stemming_dictionary( if !status.is_client_error() && !status.is_server_error() { let content = resp.text().await?; match content_type { - ContentType::Json => serde_json::from_str(&content).map_err(Error::from), - ContentType::Text => { - return Err(Error::from(serde_json::Error::custom( - "Received `text/plain` content type response that cannot be converted to `String`", - ))); - } + ContentType::Json | ContentType::Text => return Ok(content), ContentType::Unsupported(unknown_type) => { return Err(Error::from(serde_json::Error::custom(format!( "Received `{unknown_type}` content type response that cannot be converted to `String`" diff --git a/xtask/src/add_vendor_attributes.rs b/xtask/src/add_vendor_attributes.rs index ac3013f..8fe1fe1 100644 --- a/xtask/src/add_vendor_attributes.rs +++ b/xtask/src/add_vendor_attributes.rs @@ -57,12 +57,20 @@ pub fn add_vendor_attributes(doc_root: &mut Mapping) -> Result<(), String> { .return_type("Option>") .done()?; + // The documents /import endpoint expects a text/plain body and response attrs .operation("/collections/{collectionName}/documents/import", "post") .body_is_raw_text() .supports_plain_text() .done()?; + // The stemming /import endpoint also expects a text/plain body and response + attrs + .operation("/stemming/dictionaries/import", "post") + .body_is_raw_text() + .supports_plain_text() + .done()?; + attrs .operation("/collections/{collectionName}/documents/export", "get") .supports_plain_text() diff --git a/xtask/src/preprocess_openapi.rs b/xtask/src/preprocess_openapi.rs index abb1046..d2a16ba 100644 --- a/xtask/src/preprocess_openapi.rs +++ b/xtask/src/preprocess_openapi.rs @@ -23,6 +23,7 @@ pub fn preprocess_openapi_file( // --- Step 2: Apply all the required transformations --- println!("Preprocessing the spec..."); + println!("Adding custom x-* vendor attributes..."); add_vendor_attributes(doc_root)?; println!("Unwrapping parameters..."); diff --git a/xtask/src/vendor_attributes.rs b/xtask/src/vendor_attributes.rs index 466d88a..39baceb 100644 --- a/xtask/src/vendor_attributes.rs +++ b/xtask/src/vendor_attributes.rs @@ -236,6 +236,7 @@ impl<'a, 'b> OperationContext<'a, 'b> { self } + /// Indicate that the response supports plain text besides JSON pub fn supports_plain_text(mut self) -> Self { self.try_set("x-supports-plain-text", Value::Bool(true)); self From 7e40f663ba82539e60b26de01114adf1fbdb7706 Mon Sep 17 00:00:00 2001 From: Hayden Hung Hoang Date: Wed, 29 Oct 2025 16:50:19 +0700 Subject: [PATCH 2/2] feat: conversations API --- typesense/src/client/conversations/mod.rs | 44 +++ typesense/src/client/conversations/model.rs | 66 ++++ typesense/src/client/conversations/models.rs | 48 +++ typesense/src/client/mod.rs | 26 ++ .../tests/client/conversation_models_test.rs | 346 ++++++++++++++++++ typesense/tests/client/mod.rs | 1 + 6 files changed, 531 insertions(+) create mode 100644 typesense/src/client/conversations/mod.rs create mode 100644 typesense/src/client/conversations/model.rs create mode 100644 typesense/src/client/conversations/models.rs create mode 100644 typesense/tests/client/conversation_models_test.rs diff --git a/typesense/src/client/conversations/mod.rs b/typesense/src/client/conversations/mod.rs new file mode 100644 index 0000000..632723b --- /dev/null +++ b/typesense/src/client/conversations/mod.rs @@ -0,0 +1,44 @@ +//! Provides access to the API endpoints for managing conversation models. +//! +//! An `Conversations` instance is created via the main `client.conversations()` method. + +use super::Client; +use model::Model; +use models::Models; + +mod model; +mod models; + +/// Provides methods for managing Typesense conversation models. +/// +/// This struct is created by calling `client.conversations()`. +pub struct Conversations<'a> { + pub(super) client: &'a Client, +} + +impl<'a> Conversations<'a> { + /// Creates a new `Conversations` instance. + #[inline] + pub(super) fn new(client: &'a Client) -> Self { + Self { client } + } + + /// Provides access to endpoints for managing the collection of conversation models. + /// + /// Example: `client.conversations().models().list().await` + #[inline] + pub fn models(&self) -> Models<'a> { + Models::new(self.client) + } + + /// Provides access to endpoints for managing a single conversation model. + /// + /// # Arguments + /// * `model_id` - The ID of the conversation model to manage. + /// + /// Example: `client.conversations().model("...").get().await` + #[inline] + pub fn model(&self, model_id: &'a str) -> Model<'a> { + Model::new(self.client, model_id) + } +} diff --git a/typesense/src/client/conversations/model.rs b/typesense/src/client/conversations/model.rs new file mode 100644 index 0000000..e57faf2 --- /dev/null +++ b/typesense/src/client/conversations/model.rs @@ -0,0 +1,66 @@ +//! Provides access to the API endpoints for managing a single conversation model. +//! +//! An instance of `Model` is created via the `client.conversations().model("model_id")` method. + +use crate::{Client, Error, execute_wrapper, models}; +use typesense_codegen::apis::conversations_api; + +/// Provides methods for interacting with a specific conversation model. +/// +/// This struct is created by calling `client.conversations().model("model_id")`. +pub struct Model<'a> { + pub(super) client: &'a Client, + pub(super) model_id: &'a str, +} + +impl<'a> Model<'a> { + /// Creates a new `Model` instance for a specific model ID. + #[inline] + pub(super) fn new(client: &'a Client, model_id: &'a str) -> Self { + Self { client, model_id } + } + + /// Retrieves the details of this specific conversation model. + pub async fn retrieve( + &self, + ) -> Result< + models::ConversationModelSchema, + Error, + > { + let params = conversations_api::RetrieveConversationModelParams { + model_id: self.model_id.to_owned(), + }; + execute_wrapper!(self, conversations_api::retrieve_conversation_model, params) + } + + /// Updates this specific conversation model. + /// + /// # Arguments + /// * `schema` - A `ConversationModelUpdateSchema` object with the fields to update. + pub async fn update( + &self, + schema: models::ConversationModelUpdateSchema, + ) -> Result< + models::ConversationModelSchema, + Error, + > { + let params = conversations_api::UpdateConversationModelParams { + model_id: self.model_id.to_owned(), + conversation_model_update_schema: schema, + }; + execute_wrapper!(self, conversations_api::update_conversation_model, params) + } + + /// Deletes this specific conversation model. + pub async fn delete( + &self, + ) -> Result< + models::ConversationModelSchema, + Error, + > { + let params = conversations_api::DeleteConversationModelParams { + model_id: self.model_id.to_owned(), + }; + execute_wrapper!(self, conversations_api::delete_conversation_model, params) + } +} diff --git a/typesense/src/client/conversations/models.rs b/typesense/src/client/conversations/models.rs new file mode 100644 index 0000000..be6960e --- /dev/null +++ b/typesense/src/client/conversations/models.rs @@ -0,0 +1,48 @@ +//! Provides access to the API endpoints for managing conversation models. +//! +//! An instance of `Models` is created via the `client.conversations().models()` method. + +use crate::{Client, Error, execute_wrapper, models}; +use typesense_codegen::apis::conversations_api; + +/// Provides methods for creating and listing conversation models. +/// +/// This struct is created by calling `client.conversations().models()`. +pub struct Models<'a> { + pub(super) client: &'a Client, +} + +impl<'a> Models<'a> { + /// Creates a new `Models` instance. + #[inline] + pub(super) fn new(client: &'a Client) -> Self { + Self { client } + } + + /// Creates a new conversation model. + /// + /// # Arguments + /// * `schema` - A `ConversationModelCreateSchema` object describing the model. + pub async fn create( + &self, + schema: models::ConversationModelCreateSchema, + ) -> Result< + models::ConversationModelSchema, + Error, + > { + let params = conversations_api::CreateConversationModelParams { + conversation_model_create_schema: schema, + }; + execute_wrapper!(self, conversations_api::create_conversation_model, params) + } + + /// Retrieves a summary of all conversation models. + pub async fn retrieve( + &self, + ) -> Result< + Vec, + Error, + > { + execute_wrapper!(self, conversations_api::retrieve_all_conversation_models) + } +} diff --git a/typesense/src/client/mod.rs b/typesense/src/client/mod.rs index 8cd6f14..5b15871 100644 --- a/typesense/src/client/mod.rs +++ b/typesense/src/client/mod.rs @@ -110,6 +110,7 @@ mod alias; mod aliases; mod collection; mod collections; +mod conversations; mod key; mod keys; mod multi_search; @@ -125,6 +126,7 @@ use alias::Alias; use aliases::Aliases; use collection::Collection; use collections::Collections; +use conversations::Conversations; use key::Key; use keys::Keys; use operations::Operations; @@ -566,6 +568,30 @@ impl Client { Collection::new(self, collection_name) } + /// Returns a `Conversations` instance for managing conversation models. + /// # Example + /// ```no_run + /// # #[cfg(not(target_family = "wasm"))] + /// # { + /// # use typesense::Client; + /// # + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # let client = Client::builder() + /// # .nodes(vec!["http://localhost:8108"]) + /// # .api_key("xyz") + /// # .build() + /// # .unwrap(); + /// let conversation = client.conversations().models().retrieve().await.unwrap(); + /// # Ok(()) + /// # } + /// # } + /// ``` + #[inline] + pub fn conversations(&self) -> Conversations<'_> { + Conversations::new(self) + } + /// Provides access to endpoints for managing the collection of API keys. /// /// # Example diff --git a/typesense/tests/client/conversation_models_test.rs b/typesense/tests/client/conversation_models_test.rs new file mode 100644 index 0000000..d85e469 --- /dev/null +++ b/typesense/tests/client/conversation_models_test.rs @@ -0,0 +1,346 @@ +#![cfg(not(target_arch = "wasm32"))] + +use std::time::Duration; + +use typesense::{ + Error as TypesenseError, ExponentialBackoff, + models::{ + CollectionSchema, ConversationModelCreateSchema, ConversationModelUpdateSchema, Field, + }, +}; + +use super::{get_client, new_id}; + +#[tokio::test] +async fn test_create_model_with_invalid_key_fails_as_expected() { + let client = get_client(); + let model_id = new_id("gpt-4-invalid-key-test"); + let collection_name = new_id("conversation_store_invalid"); + + // --- 1. Setup: Create the prerequisite collection for history --- + let schema = CollectionSchema { + name: collection_name.clone(), + fields: vec![ + Field { + name: "conversation_id".to_owned(), + r#type: "string".to_owned(), + ..Default::default() + }, + Field { + name: "model_id".to_owned(), + r#type: "string".to_owned(), + ..Default::default() + }, + Field { + name: "timestamp".to_owned(), + r#type: "int32".to_owned(), + ..Default::default() + }, + Field { + name: "role".to_owned(), + r#type: "string".to_owned(), + index: Some(false), + ..Default::default() + }, + Field { + name: "message".to_owned(), + r#type: "string".to_owned(), + index: Some(false), + ..Default::default() + }, + ], + ..Default::default() + }; + let create_collection_result = client.collections().create(schema).await; + assert!( + create_collection_result.is_ok(), + "Setup failed: Could not create the collection needed for the test." + ); + + // --- 2. Action: Attempt to create a model with a deliberately invalid API key --- + let create_schema = ConversationModelCreateSchema { + id: Some(model_id.clone()), + model_name: "openai/gpt-4".to_owned(), + api_key: Some("THIS_IS_AN_INVALID_KEY".to_owned()), + history_collection: collection_name.clone(), + max_bytes: 10000, + ..Default::default() + }; + let create_result = client.conversations().models().create(create_schema).await; + + // --- 3. Assertion: Verify that the creation failed with the correct error --- + assert!( + create_result.is_err(), + "Model creation should have failed due to an invalid API key, but it succeeded." + ); + match create_result.err() { + Some(TypesenseError::Api(response_content)) => match response_content { + typesense::ApiError::ResponseError(api_error) => { + assert_eq!( + api_error.status.as_u16(), + 400, + "Expected HTTP status code 400 for an invalid key." + ); + assert!( + api_error.content.contains("Incorrect API key provided"), + "The error message did not match the expected content. Got: {}", + api_error.content + ); + } + other_entity => { + panic!( + "Expected a Status400 error entity but got something else: {:?}", + other_entity + ); + } + }, + other_error => { + panic!( + "Expected a Typesense ResponseError, but got a different kind of error: {:?}", + other_error + ); + } + } +} + +use typesense::Client; +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{body_json, method, path}, +}; + +// Helper to create a Typesense client configured for a mock server. +fn get_test_client(uri: &str) -> Client { + Client::builder() + .nodes(vec![uri]) + .api_key("TEST_API_KEY") + .healthcheck_interval(Duration::from_secs(60)) + .retry_policy(ExponentialBackoff::builder().build_with_max_retries(0)) + .connection_timeout(Duration::from_secs(1)) + .build() + .expect("Failed to create client") +} + +#[tokio::test] +async fn test_create_model_with_wiremock() { + // --- 1. Setup: Start a mock server --- + let mock_server = MockServer::start().await; + + // --- 2. Setup: Configure the Typesense client to use the mock server's URI --- + let client = get_test_client(&mock_server.uri()); + + // --- 3. Setup: Define the request and the expected successful response --- + let model_id = new_id("conv-model-test"); + let collection_name = new_id("history-collection"); + + let create_schema = ConversationModelCreateSchema { + id: Some(model_id.clone()), + model_name: "openai/gpt-4".to_owned(), + api_key: Some("A-FAKE-BUT-VALID-LOOKING-KEY".to_owned()), + history_collection: collection_name.clone(), + system_prompt: Some("You are a helpful assistant.".to_owned()), + ..Default::default() + }; + + // This is the successful JSON body we expect the mock server to return. + // It should match the structure of `ConversationModelSchema`. + let mock_response_body = serde_json::json!({ + "id": model_id, + "model_name": "openai/gpt-4", + "history_collection": collection_name, + "api_key": "sk-FA**********************************KEY", // Masked key + "system_prompt": "You are a helpful assistant.", + "max_bytes": 16384, + "ttl": 86400 + }); + + // --- 4. Setup: Define the mock server's behavior --- + Mock::given(method("POST")) + .and(path("/conversations/models")) + .and(body_json(&create_schema)) // Ensure the client sends the correct body + .respond_with(ResponseTemplate::new(200).set_body_json(mock_response_body.clone())) + .expect(1) // Expect this mock to be called exactly once + .mount(&mock_server) + .await; + + // --- 5. Action: Call the client method --- + let create_result = client.conversations().models().create(create_schema).await; + + // --- 6. Assertion: Verify the result --- + assert!( + create_result.is_ok(), + "The client should have successfully parsed the 200 response from the mock server. Error: {:?}", + create_result.err() + ); + + // Unwrap the successful result and check if its fields match the mocked response + let created_model = create_result.unwrap(); + assert_eq!(created_model.id, model_id); + assert_eq!(created_model.model_name, "openai/gpt-4"); + assert_eq!(created_model.history_collection, collection_name); + assert_eq!( + created_model.system_prompt, + Some("You are a helpful assistant.".to_owned()) + ); +} + +#[tokio::test] +async fn test_retrieve_all_models_with_wiremock() { + // --- 1. Setup --- + let mock_server = MockServer::start().await; + let client = get_test_client(&mock_server.uri()); + + // The response body should be a Vec + let mock_response_body = serde_json::json!([ + { + "id": "model-1", + "model_name": "openai/gpt-3.5-turbo", + "history_collection": "conversation_store", + "api_key": "OPENAI_API_KEY", + "system_prompt": "Hey, you are an **intelligent** assistant for question-answering. You can only make conversations based on the provided context. If a response cannot be formed strictly using the provided context, politely say you do not have knowledge about that topic.", + "max_bytes": 16384 + }, + { + "id": "model-2", + "model_name": "openai/gpt-3.5-turbo", + "history_collection": "conversation_store", + "api_key": "OPENAI_API_KEY", + "system_prompt": "Hey, you are an **intelligent** assistant for question-answering. You can only make conversations based on the provided context. If a response cannot be formed strictly using the provided context, politely say you do not have knowledge about that topic.", + "max_bytes": 16384 + } + ]); + + // --- 2. Mocking --- + Mock::given(method("GET")) + .and(path("/conversations/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response_body)) + .expect(1) + .mount(&mock_server) + .await; + + // --- 3. Action --- + let retrieve_result = client.conversations().models().retrieve().await; + + // --- 4. Assertion --- + assert!(retrieve_result.is_ok(), "Retrieving all models failed"); + let models = retrieve_result.unwrap(); + assert_eq!(models.len(), 2); + assert_eq!(models[0].id, "model-1"); + assert_eq!(models[1].id, "model-2"); +} + +#[tokio::test] +async fn test_retrieve_single_model_with_wiremock() { + // --- 1. Setup --- + let mock_server = MockServer::start().await; + let client = get_test_client(&mock_server.uri()); + + let model_id = new_id("conv-model"); + let mock_response_body = serde_json::json!({ + "id": model_id, + "model_name": "openai/gpt-3.5-turbo", + "history_collection": "conversation_store", + "api_key": "OPENAI_API_KEY", + "system_prompt": "Hey, you are an **intelligent** assistant for question-answering. You can only make conversations based on the provided context. If a response cannot be formed strictly using the provided context, politely say you do not have knowledge about that topic.", + "max_bytes": 16384 + }); + + // --- 2. Mocking --- + Mock::given(method("GET")) + .and(path(format!("/conversations/models/{}", model_id))) + .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response_body)) + .expect(1) + .mount(&mock_server) + .await; + + // --- 3. Action --- + let retrieve_result = client.conversations().model(&model_id).retrieve().await; + + // --- 4. Assertion --- + assert!(retrieve_result.is_ok()); + assert_eq!(retrieve_result.unwrap().id, model_id); +} + +#[tokio::test] +async fn test_update_single_model_with_wiremock() { + // --- 1. Setup --- + let mock_server = MockServer::start().await; + let client = get_test_client(&mock_server.uri()); + + let model_id = new_id("conv-model"); + + let update_schema = ConversationModelUpdateSchema { + system_prompt: Some("A new, updated prompt.".to_owned()), + ..Default::default() + }; + + // The response body reflects the updated state of the resource + let mock_response_body = serde_json::json!({ + "id": model_id, + "model_name": "openai/gpt-3.5-turbo", + "history_collection": "conversation_store", + "api_key": "OPENAI_API_KEY", + "system_prompt": "A new, updated prompt.", + "max_bytes": 16384 + }); + + // --- 2. Mocking --- + Mock::given(method("PUT")) // As per docs, update uses PUT + .and(path(format!("/conversations/models/{}", model_id))) + .and(body_json(&update_schema)) // Verify the client sends the correct update payload + .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response_body)) + .expect(1) + .mount(&mock_server) + .await; + + // --- 3. Action --- + let update_result = client + .conversations() + .model(&model_id) + .update(update_schema) + .await; + + // --- 4. Assertion --- + assert!(update_result.is_ok()); + let updated_model = update_result.unwrap(); + assert_eq!(updated_model.id, model_id); + assert_eq!( + updated_model.system_prompt.unwrap(), + "A new, updated prompt." + ); +} + +#[tokio::test] +async fn test_delete_single_model_with_wiremock() { + // --- 1. Setup --- + let mock_server = MockServer::start().await; + let client = get_test_client(&mock_server.uri()); + + let model_id = new_id("conv-model-to-delete"); + + // The API returns the object that was just deleted + let mock_response_body = serde_json::json!({ + "id": model_id, + "model_name": "openai/gpt-3.5-turbo", + "history_collection": "conversation_store", + "api_key": "OPENAI_API_KEY", + "system_prompt": "Hey, you are an **intelligent** assistant for question-answering. You can only make conversations based on the provided context. If a response cannot be formed strictly using the provided context, politely say you do not have knowledge about that topic.", + "max_bytes": 16384 + }); + + // --- 2. Mocking --- + Mock::given(method("DELETE")) + .and(path(format!("/conversations/models/{}", model_id))) + .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response_body)) + .expect(1) + .mount(&mock_server) + .await; + + // --- 3. Action --- + let delete_result = client.conversations().model(&model_id).delete().await; + + // --- 4. Assertion --- + assert!(delete_result.is_ok()); + let deleted_model = delete_result.unwrap(); + assert_eq!(deleted_model.id, model_id); +} diff --git a/typesense/tests/client/mod.rs b/typesense/tests/client/mod.rs index 553f8f6..a8a645e 100644 --- a/typesense/tests/client/mod.rs +++ b/typesense/tests/client/mod.rs @@ -1,6 +1,7 @@ mod aliases_test; mod client_test; mod collections_test; +mod conversation_models_test; mod documents_test; mod keys_test; mod multi_search_test;