Skip to content

Searchcraft API Rust Client Library

The Searchcraft API Rust Client is an async, fully typed client that acts as a language-specific wrapper around the Searchcraft API.

  • Async throughout, built on reqwest.
  • Fully typed requests and responses, with no untyped JSON in the public API.
  • Composable, immutable query builder.
  • Support for every Searchcraft query kind (fuzzy, exact, dynamic, term, more-like-this) and operation.
  • Full index, document, federation, synonym, and stopword management.
  • Streaming AI search summaries over Server-Sent Events.
  • rustls by default, with an optional native-tls backend.
  • API keys held in secrecy wrappers so they are not logged by accident.
Terminal window
cargo add searchcraft

Or add it to your Cargo.toml directly:

[dependencies]
searchcraft = "0.1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

The client is async and does not bundle a runtime, so you need one of your own. The examples here use Tokio.

rustls is enabled by default. To use the platform’s native TLS instead:

[dependencies]
searchcraft = { version = "0.1", default-features = false, features = ["native-tls"] }

The minimum supported Rust version is 1.75.

use searchcraft::SearchcraftClient;
use searchcraft::search::query::QueryBuilder;
#[tokio::main]
async fn main() -> Result<(), searchcraft::Error> {
// Initialize the client
let client = SearchcraftClient::new(
"https://your-searchcraft-instance.com",
Some("your-read-key"),
None::<String>,
)?;
// Perform a search
let request = QueryBuilder::fuzzy().term("search query").limit(10).build_request();
let response = client
.search_index::<serde_json::Value>("your-index", &request)
.await?;
println!("Found {} results", response.data.count);
for hit in &response.data.hits {
println!("{}: {:?}", hit.document_id, hit.doc);
}
Ok(())
}

For a read-only client, SearchcraftClient::new takes the endpoint and up to two keys:

use searchcraft::SearchcraftClient;
let client = SearchcraftClient::new(
"https://your-instance.com",
Some("your-read-key"),
Some("your-ingest-key"), // Optional, for document operations
)?;

At least one of the read or ingest key must be supplied. For an admin key, a custom timeout, or extra headers, build a Config instead:

use std::time::Duration;
use searchcraft::{Config, SearchcraftClient};
let config = Config::new(
"https://your-instance.com",
Some("your-read-key"),
Some("your-ingest-key"),
)?
.with_admin_key("your-admin-key") // Only needed for self-hosted clusters
.with_timeout(Duration::from_secs(30)) // Optional, default 30 seconds
// Optional Searchcraft headers for analytics and tracking:
.with_header("X-Sc-User-Id", "user-123")
.with_header("X-Sc-Session-Id", "session-abc")
.with_header("X-Sc-User-Type", "authenticated");
let client = SearchcraftClient::from_config(config)?;

The client picks the right key per operation, so you never pass a key to an individual call. SearchcraftClient is cheap to clone and holds a pooled connection — build it once and share it rather than creating one per request.

Note: The admin key is only required for self-hosted Searchcraft clusters. If you’re using Searchcraft Cloud, you do not need to provide one.

use searchcraft::search::query::QueryBuilder;
// Fuzzy search (typo-tolerant, uses weight ranking, synonyms and stopwords)
let fuzzy_query = QueryBuilder::fuzzy().term("search term").limit(20).build_request();
// Exact search
let exact_query = QueryBuilder::exact().term("exact match").build_request();
// Dynamic search (adapts based on word count)
let dynamic_query = QueryBuilder::dynamic().term("adaptive search").build_request();
let response = client
.search_index::<serde_json::Value>("your-index", &fuzzy_query)
.await?;

Search results deserialize into any type implementing serde::Deserialize, so you can work with your own structs instead of raw JSON:

#[derive(serde::Deserialize)]
struct Product {
title: String,
price: f64,
}
let response = client.search_index::<Product>("products", &request).await?;
for hit in &response.data.hits {
println!("{} — ${}", hit.doc.title, hit.doc.price);
}

The query builder provides a fluent, immutable API for constructing complex queries. Each method returns a new builder, leaving the original unchanged.

📚 Full Query Syntax Documentation: For complete details on the Searchcraft query language, operators, and advanced features, see Using the Searchcraft Query Language.

use searchcraft::search::query::QueryBuilder;
use searchcraft::types::SortDirection;
let request = QueryBuilder::fuzzy()
.term("laptop")
.and("gaming")
.not("refurbished")
.order_by("price", SortDirection::Asc)
.limit(20)
.build_request();
// Match a single field
let request = QueryBuilder::exact().field("category", "electronics").build_request();
// Match any of several values
let request = QueryBuilder::exact()
.field_in("status", &["active", "pending"])
.build_request();
use searchcraft::search::query::CompareOp;
// Inclusive range: price:[10 TO 100]
let request = QueryBuilder::exact().range("price", 10, 100, true).build_request();
// Exclusive range: price:{10 TO 100}
let request = QueryBuilder::exact().range("price", 10, 100, false).build_request();
// Comparisons: Gt, Lt, Gte, Lte
let request = QueryBuilder::exact()
.compare("price", CompareOp::Gte, 50)
.build_request();
let request = QueryBuilder::fuzzy()
.term("laptop")
.and("gaming") // AND
.or("notebook") // OR
.not("refurbished") // NOT
.group("(ssd OR nvme)") // Grouping
.build_request();
use searchcraft::types::SortDirection;
let request = QueryBuilder::fuzzy()
.term("laptop")
.limit(20)
.offset(40) // Third page of 20
.order_by("created_at", SortDirection::Desc)
.build_request();

The engine clamps limit to the instance’s configured maximum (200 unless the operator raised it) rather than rejecting the request.

Note: Ordering by a field means results carry no relevance score, so hit.score is None for those queries.

Search several indices at once through a federation:

let request = QueryBuilder::fuzzy().term("laptop").limit(10).build_request();
let response = client
.search_federation::<serde_json::Value>("your-federation", &request)
.await?;
for hit in &response.data.hits {
// source_index tells you which index each hit came from
println!("[{}] {}", hit.source_index, hit.document_id);
}

The builder covers most cases, but you can construct requests directly for anything it does not express — including the term and more-like-this query kinds, field-scoped fuzzy matching, and minimum_number_should_match:

use searchcraft::search::types::{
FieldSelector, OccurMode, QueryPayload, SearchQuery, SearchRequest,
};
let request = SearchRequest {
query: QueryPayload::Multiple(vec![
SearchQuery::fuzzy("laptop")
.with_fields(FieldSelector::Multi(vec!["title".into(), "body".into()]))
.with_occur(OccurMode::Must),
SearchQuery::term("electronics", "category").with_occur(OccurMode::Must),
SearchQuery::exact("refurbished").with_occur(OccurMode::MustNot),
]),
limit: Some(20),
offset: None,
order_by: None,
sort: None,
time_decay_field: None,
index_weighting: None,
minimum_number_should_match: Some(1),
};
let response = client.search_index::<serde_json::Value>("products", &request).await?;

Find documents similar to an existing one using its internal Searchcraft ID:

let request = SearchRequest {
query: QueryPayload::Single(SearchQuery::more_like_this("12345")),
limit: Some(10),
..request
};

On engine 0.10.0 and later, search_summary streams an LLM-generated summary of a result set as it is produced. Check the index’s capabilities first — the endpoint requires AI features to be enabled and a key with summary permissions:

use searchcraft::search::StreamExt;
use searchcraft::search::types::SummaryStreamEvent;
let capabilities = client.get_index_capabilities("products").await?;
if capabilities.ai.enabled && capabilities.ai.search_summary_configured {
let request = QueryBuilder::fuzzy().term("laptop").build_request();
let mut stream = client.search_summary("products", &request).await?;
while let Some(event) = stream.next().await {
match event {
SummaryStreamEvent::Metadata(m) => {
println!("Summarizing {} results (cached: {})", m.results_count, m.cached);
}
SummaryStreamEvent::Delta(d) => print!("{}", d.content),
SummaryStreamEvent::Done(d) => println!("\nDone ({} results)", d.results_count),
SummaryStreamEvent::Error(e) => eprintln!("Summary failed: {}", e.message),
}
}
}

Malformed frames and mid-stream connection failures arrive as Error events rather than ending the stream. Unlike other requests, the configured timeout bounds only connection setup here, so a slow generation is never cut short.

use serde_json::json;
// Insert a single document — it must include an `id` field
let doc = json!({ "id": "doc-1", "title": "Gaming Laptop", "price": 999.99 });
client.insert_document("products", &doc).await?;
// Batch insert
let docs = vec![
json!({ "id": "doc-2", "title": "Ultrabook" }),
json!({ "id": "doc-3", "title": "Workstation" }),
];
client.batch_insert_documents("products", &docs).await?;
// Delete by your own `id` field
let result = client.delete_document("products", "doc-1").await?;
println!("Removed {} documents", result.num_removed);
// Delete several at once
client.batch_delete_documents("products", &["doc-2", "doc-3"]).await?;
// Delete by Searchcraft's internal document ID (the `document_id` on a search hit)
client.delete_document_by_internal_id("products", "12345").await?;
// Delete every document, leaving the index in place
client.delete_all_documents("products").await?;
// Fetch one document by its internal ID
let hit = client.get_document::<serde_json::Value>("products", "12345").await?;

Writes are buffered until committed. An index’s auto_commit_delay commits them on a timer, or you can commit immediately:

client.commit_transaction("products").await?; // Make buffered writes searchable
client.rollback_transaction("products").await?; // Discard them instead
use std::collections::HashMap;
use searchcraft::admin::types::{FieldConfig, FieldType, IndexConfig};
// List every index
let indices = client.list_indices().await?;
println!("{:?}", indices.index_names);
// Create an index
let config = IndexConfig {
language: Some("en".into()),
search_fields: Some(vec!["title".into(), "body".into()]),
fields: Some(HashMap::from([
(
"title".to_string(),
FieldConfig {
stored: Some(true),
required: Some(true),
..FieldConfig::new(FieldType::Text)
},
),
(
"price".to_string(),
FieldConfig { fast: Some(true), ..FieldConfig::new(FieldType::F64) },
),
])),
..Default::default()
};
client.create_index("products", &config).await?;
// Read the current configuration
let current = client.get_index("products").await?;
// Patch specific settings, leaving everything else alone
let patch = IndexConfig { auto_commit_delay: Some(5000), ..Default::default() };
client.update_index("products", &patch).await?;
// Stats
let stats = client.get_index_stats("products").await?;
println!("Documents: {}", stats.document_count);
let all_stats = client.get_all_index_stats().await?;
println!("Total documents: {}", all_stats.total_document_count);
// Delete the index and everything in it
client.delete_index("products").await?;

Field types are Text, Facet, Bool, F64, U64, I64, Datetime, and Json. Every option on FieldConfig is optional — anything left unset takes the engine’s default.

Note: The patch endpoint cannot change schema fields, and treats an empty search_fields, weight_multipliers, or language as “leave unchanged” rather than a reset. Use replace_index to change the schema or clear those values. To overwrite an existing index on create, use create_index_overwriting.

use searchcraft::admin::types::FederationRequest;
// List federations
let federations = client.list_federations().await?;
// Create one over several indices, each weighted equally
let request = FederationRequest::new("global", "Global", ["products", "articles"]);
client.create_federation(&request).await?;
// Inspect
let federation = client.get_federation("global").await?;
let index_names = client.list_federation_indices("global").await?;
let stats = client.get_federation_stats("global").await?;
println!("{} documents across {} indices", stats.num_docs, stats.indices.len());
// Update or delete
client.update_federation("global", &request).await?;
client.delete_federation("global").await?;

Synonym entries use the format "synonym:original-term".

let synonyms = client.get_synonyms("products").await?;
client.add_synonyms("products", &["notebook:laptop"]).await?;
client.delete_synonyms("products", &["notebook:laptop"]).await?;
client.delete_all_synonyms("products").await?;
// The index's current list
let stopwords = client.get_stopwords("products").await?;
// The engine's built-in defaults for the index language
let defaults = client.get_default_stopwords("products").await?;
client.add_stopwords("products", &["the", "and"]).await?;
client.delete_stopwords("products", &["the"]).await?;
client.delete_all_stopwords("products").await?;

Key management is available on self-hosted clusters and requires an admin key.

use searchcraft::admin::types::{permissions, AuthKeyPermission, CreateAuthKeyRequest};
// Permissions are a bitmask. Named presets cover the common cases.
let request = CreateAuthKeyRequest::new("web-frontend", AuthKeyPermission::READ, ["products"]);
let created = client.create_auth_key(&request).await?;
println!("New key: {}", created.token);
// Or build an exact mask from individual bits
let custom = AuthKeyPermission::from_bits(permissions::SEARCH | permissions::READ_ANALYTICS);
assert!(custom.contains(permissions::READ_ANALYTICS));
// List keys, optionally scoped by owner
let all_keys = client.list_auth_keys().await?;
let index_keys = client.list_index_auth_keys("products").await?;
let app_keys = client.list_application_auth_keys("app-1").await?;
// Look one up — returns None when it does not exist
if let Some(key) = client.get_auth_key("sc-abc").await? {
println!("{:?}", key.permissions);
}
// Ask whether the configured admin key carries a permission
let can_read_analytics = client
.check_auth_key_permissions(permissions::READ_ANALYTICS)
.await?;

The presets are READ (search only), INGEST (search plus document, synonym, and stopword writes), ADMIN (adds index and key management), and SUPER_USER (everything, including AI and analytics).

Measure records search and click telemetry. Analytics must be configured server-side, so check the status first — the write endpoints silently succeed as no-ops when it is disabled.

use searchcraft::admin::types::{
event_names, MeasureDashboardParams, MeasureEvent, MeasureQueryGranularity,
MeasureRequestProperties, MeasureRequestUser,
};
let status = client.get_measure_status().await?;
if status.enabled {
let event = MeasureEvent::new(
event_names::DOCUMENT_CLICKED,
MeasureRequestProperties {
external_document_id: Some("doc-1".into()),
document_position: Some(3),
..MeasureRequestProperties::new(["products"])
},
MeasureRequestUser::new("user-42"),
);
client.track_measure_event(&event).await?;
}
// Dashboard reports accept filters for scope, date range, granularity and paging
let params = MeasureDashboardParams {
organization_id: Some("org-1".into()),
index_names: vec!["products".into()],
date_start: Some(1_700_000_000),
granularity: Some(MeasureQueryGranularity::Days),
..Default::default()
};
let summary = client.get_measure_dashboard_summary(&params).await?;

track_measure_batch sends several events in one request, and get_measure_dashboard_conversion and get_measure_dashboard_usage return the other two reports.

let health = client.health_check().await?;
println!("Status: {}", health.status); // 200
println!("Message: {}", health.data); // "Searchcraft is healthy."

All methods are async and return searchcraft::error::Result<T>.

MethodDescription
SearchcraftClient::new(url, read_key, ingest_key)Create a client from an endpoint and up to two keys
SearchcraftClient::from_config(config)Create a client from a Config
client.config()The configuration the client was built with
MethodReturns
search_index::<T>(index, request)SearchResponse<T>
search_federation::<T>(federation, request)SearchResponse<T>
search_summary(index, request)A stream of SummaryStreamEvent
MethodReturns
insert_document(index, document)String
batch_insert_documents(index, documents)String
delete_document(index, document_id)DocumentDeleteResponse
batch_delete_documents(index, document_ids)DocumentDeleteResponse
delete_document_by_internal_id(index, internal_id)String
delete_all_documents(index)String
get_document::<T>(index, internal_id)SearchHit<T>
MethodReturns
list_indices()IndexListResponse
get_index(index)IndexConfig
create_index(index, config)String
create_index_overwriting(index, config)String
replace_index(index, config)String
update_index(index, config)String
delete_index(index)String
get_index_stats(index)IndexStats
get_all_index_stats()AllIndexStatsResponse
get_index_capabilities(index)IndexCapabilities
MethodReturns
list_federations()Vec<Federation>
list_federations_by_organization(org_id)Vec<Federation>
get_federation(federation)Federation
create_federation(request)serde_json::Value
update_federation(federation, request)serde_json::Value
delete_federation(federation)String
list_federation_indices(federation)Vec<String>
get_federation_stats(federation)FederationStats
MethodReturns
get_synonyms(index)SynonymsMap
add_synonyms(index, synonyms)String
delete_synonyms(index, synonyms)String
delete_all_synonyms(index)String
get_stopwords(index)Vec<String>
get_default_stopwords(index)Vec<String>
add_stopwords(index, stopwords)String
delete_stopwords(index, stopwords)String
delete_all_stopwords(index)String
MethodReturns
commit_transaction(index)String
rollback_transaction(index)String
MethodReturns
list_auth_keys()Vec<AuthKey>
get_auth_key(key)Option<AuthKey>
create_auth_key(request)AuthKey
update_auth_key(key, request)AuthKey
delete_auth_key(key)String
delete_all_auth_keys()String
list_application_auth_keys(app_id)Vec<AuthKey>
list_organization_auth_keys(org_id)Vec<AuthKey>
list_federation_auth_keys(federation)Vec<AuthKey>
list_index_auth_keys(index)Vec<AuthKey>
check_auth_key_permissions(bits)bool
MethodReturns
get_measure_status()MeasureStatus
track_measure_event(event)String
track_measure_batch(events)String
get_measure_dashboard_summary(params)serde_json::Value
get_measure_dashboard_conversion(params)serde_json::Value
get_measure_dashboard_usage(params)serde_json::Value
health_check()HealthCheckResponse

Every fallible call returns searchcraft::error::Result<T>, whose error type separates the cases you are likely to handle differently:

use searchcraft::Error;
match client.search_index::<serde_json::Value>("products", &request).await {
Ok(response) => println!("{} hits", response.data.count),
Err(Error::Authentication { status, .. }) => eprintln!("Auth failed ({status})"),
Err(Error::NotFound(message)) => eprintln!("Missing: {message}"),
Err(Error::Validation { message, field }) => {
eprintln!("Invalid request: {message} ({field:?})")
}
Err(e) if e.is_retryable() => eprintln!("Transient failure, retry: {e}"),
Err(e) => eprintln!("Failed: {e}"),
}

is_retryable() is true for network failures and 429/5xx responses, and false for configuration, authentication, not-found, and validation errors, which will fail the same way on a retry. status() returns the HTTP status where one applies.

Error is marked #[non_exhaustive], so match the variants you handle and include a _ arm.

If you encounter any issues with the client library or wish to request additional features please open an issue at the Searchcraft Issues repository.

The Searchcraft API Rust client is Apache 2.0 licensed and the source code is available on Github.