diff --git a/examples/android/src/main/AndroidManifest.xml b/examples/android/src/main/AndroidManifest.xml
index f786b5f32..a1d081133 100644
--- a/examples/android/src/main/AndroidManifest.xml
+++ b/examples/android/src/main/AndroidManifest.xml
@@ -212,6 +212,12 @@
android:name=".showcase.compose.agent.AgentStudioShowcase"
android:parentActivityName=".directory.DirectoryActivity" />
+
+
+
+
>) =
diff --git a/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/composition/CompositionShowcase.kt b/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/composition/CompositionShowcase.kt
new file mode 100644
index 000000000..5de8b90ff
--- /dev/null
+++ b/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/composition/CompositionShowcase.kt
@@ -0,0 +1,237 @@
+package com.algolia.instantsearch.examples.android.showcase.compose.composition
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.Button
+import androidx.compose.material.Divider
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.OutlinedTextField
+import androidx.compose.material.Scaffold
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.algolia.client.model.search.Hit
+import com.algolia.instantsearch.compose.hits.HitsState
+import com.algolia.instantsearch.compose.item.StatsTextState
+import com.algolia.instantsearch.compose.searchbox.SearchBoxState
+import com.algolia.instantsearch.core.connection.ConnectionHandler
+import com.algolia.instantsearch.core.hits.connectHitsView
+import com.algolia.instantsearch.examples.android.showcase.compose.ui.ShowcaseTheme
+import com.algolia.instantsearch.examples.android.showcase.compose.ui.component.SearchTopBar
+import com.algolia.instantsearch.examples.android.showcase.compose.ui.component.TitleTopBar
+import com.algolia.instantsearch.searchbox.SearchBoxConnector
+import com.algolia.instantsearch.searchbox.connectView
+import com.algolia.instantsearch.searcher.composition.CompositionSearcher
+import com.algolia.instantsearch.stats.DefaultStatsPresenter
+import com.algolia.instantsearch.stats.StatsConnector
+import com.algolia.instantsearch.stats.connectView
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.contentOrNull
+
+/**
+ * Showcase for [CompositionSearcher], the searcher targeting the Algolia
+ * Composition API (`/1/compositions/{compositionID}/run` endpoint).
+ *
+ * A composition is configured per application in the Algolia dashboard, so
+ * there is no public demo composition to hardcode here. The screen starts with
+ * a small form asking for your application credentials and composition ID,
+ * then drives the regular InstantSearch components (search box, stats, hits)
+ * through a [CompositionSearcher].
+ */
+class CompositionShowcase : AppCompatActivity() {
+
+ private var session by mutableStateOf(null)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ ShowcaseTheme {
+ when (val current = session) {
+ null -> CredentialsForm { applicationID, apiKey, compositionID ->
+ session = CompositionSession(applicationID, apiKey, compositionID)
+ }
+ else -> CompositionSearchScreen(current)
+ }
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ session?.close()
+ }
+}
+
+/**
+ * Holds the searcher and its connections, created once the credentials are
+ * submitted.
+ */
+private class CompositionSession(
+ applicationID: String,
+ apiKey: String,
+ compositionID: String,
+) {
+ val searchBoxState = SearchBoxState()
+ val statsText = StatsTextState()
+ val hitsState = HitsState()
+ var error by mutableStateOf(null)
+
+ private val searcher = CompositionSearcher(
+ applicationID = applicationID,
+ apiKey = apiKey,
+ compositionID = compositionID,
+ )
+ private val searchBox = SearchBoxConnector(searcher)
+ private val stats = StatsConnector(searcher)
+ private val connections = ConnectionHandler(searchBox, stats)
+
+ init {
+ connections += searchBox.connectView(searchBoxState)
+ connections += stats.connectView(statsText, DefaultStatsPresenter())
+ connections += searcher.connectHitsView(hitsState) { response ->
+ response.hits.map { it.toCompositionHit() }
+ }
+ searcher.error.subscribe { error = it?.message }
+ searcher.searchAsync()
+ }
+
+ fun close() {
+ searcher.error.unsubscribeAll()
+ searcher.cancel()
+ connections.clear()
+ }
+}
+
+/**
+ * Composition records don't have a fixed schema, so the row displays the most
+ * common "title" attributes and falls back to the objectID.
+ */
+private data class CompositionHit(
+ val objectID: String,
+ val title: String,
+)
+
+private fun Hit.toCompositionHit(): CompositionHit {
+ val title = listOf("name", "title", "label")
+ .firstNotNullOfOrNull { attribute ->
+ (additionalProperties?.get(attribute) as? JsonPrimitive)?.contentOrNull
+ }
+ return CompositionHit(objectID = objectID, title = title ?: objectID)
+}
+
+@Composable
+private fun CredentialsForm(onSubmit: (String, String, String) -> Unit) {
+ var applicationID by remember { mutableStateOf("") }
+ var apiKey by remember { mutableStateOf("") }
+ var compositionID by remember { mutableStateOf("") }
+
+ Scaffold(
+ topBar = { TitleTopBar(title = "Composition (experimental)") },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = "Compositions are configured per application in the Algolia dashboard. " +
+ "Enter the credentials of an application with at least one composition.",
+ style = MaterialTheme.typography.body2,
+ )
+ OutlinedTextField(
+ value = applicationID,
+ onValueChange = { applicationID = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Application ID") },
+ singleLine = true,
+ )
+ OutlinedTextField(
+ value = apiKey,
+ onValueChange = { apiKey = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Search-only API Key") },
+ singleLine = true,
+ )
+ OutlinedTextField(
+ value = compositionID,
+ onValueChange = { compositionID = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Composition ID") },
+ singleLine = true,
+ )
+ Button(
+ enabled = applicationID.isNotBlank() && apiKey.isNotBlank() && compositionID.isNotBlank(),
+ onClick = { onSubmit(applicationID.trim(), apiKey.trim(), compositionID.trim()) },
+ ) {
+ Text("Start searching")
+ }
+ }
+ }
+}
+
+@Composable
+private fun CompositionSearchScreen(session: CompositionSession) {
+ val listState = rememberLazyListState()
+ Scaffold(
+ topBar = {
+ SearchTopBar(
+ placeHolderText = "Search in your composition",
+ searchBoxState = session.searchBoxState,
+ lazyListState = listState,
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) {
+ Text(
+ text = session.statsText.stats,
+ style = MaterialTheme.typography.caption,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ session.error?.let {
+ Text(
+ text = it,
+ color = MaterialTheme.colors.error,
+ style = MaterialTheme.typography.caption,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
+ )
+ }
+ LazyColumn(state = listState) {
+ items(session.hitsState.hits) { hit ->
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ Text(text = hit.title, style = MaterialTheme.typography.body1)
+ Text(
+ text = hit.objectID,
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ )
+ }
+ Divider()
+ }
+ }
+ }
+ }
+}
diff --git a/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/CompositionFacetsSearcher.kt b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/CompositionFacetsSearcher.kt
new file mode 100644
index 000000000..8d486225b
--- /dev/null
+++ b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/CompositionFacetsSearcher.kt
@@ -0,0 +1,114 @@
+package com.algolia.instantsearch.searcher.composition
+
+import com.algolia.client.api.CompositionClient
+import com.algolia.client.model.search.SearchParamsObject
+import com.algolia.client.transport.RequestOptions
+import com.algolia.instantsearch.searcher.SearcherForFacets
+import com.algolia.instantsearch.searcher.SearcherScope
+import com.algolia.instantsearch.searcher.composition.internal.DefaultCompositionFacetsSearchService
+import com.algolia.instantsearch.searcher.composition.internal.DefaultCompositionFacetsSearcher
+import com.algolia.instantsearch.searcher.facets.SearchForFacetQuery
+import com.algolia.instantsearch.searcher.internal.defaultDispatcher
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+
+/**
+ * The component handling search requests and managing the search sessions.
+ * This implementation searches for facet values using the Algolia Composition API
+ * (`/1/compositions/{compositionID}/facets/{facetName}/query` endpoint).
+ */
+public interface CompositionFacetsSearcher : SearcherForFacets {
+
+ /**
+ * Unique Composition ObjectID.
+ */
+ public var compositionID: String
+
+ /**
+ * Facets query.
+ */
+ public var facetQuery: String?
+
+ /**
+ * Maximum number of facet values to return.
+ */
+ public var maxFacetHits: Int?
+}
+
+/**
+ * Creates an instance of [CompositionFacetsSearcher].
+ *
+ * @param client composition client instance
+ * @param compositionID unique Composition ObjectID
+ * @param attribute facet attribute
+ * @param query the query used to narrow down the facet values search
+ * @param facetQuery the facet query used to search for facets
+ * @param maxFacetHits maximum number of facet values to return
+ * @param requestOptions request local configuration
+ * @param coroutineScope scope of coroutine operations
+ * @param coroutineDispatcher async search dispatcher
+ * @param triggerSearchFor request condition
+ */
+public fun CompositionFacetsSearcher(
+ client: CompositionClient,
+ compositionID: String,
+ attribute: String,
+ query: SearchParamsObject = SearchParamsObject(),
+ facetQuery: String? = null,
+ maxFacetHits: Int? = null,
+ requestOptions: RequestOptions? = null,
+ coroutineScope: CoroutineScope = SearcherScope(),
+ coroutineDispatcher: CoroutineDispatcher = defaultDispatcher,
+ triggerSearchFor: SearchForFacetQuery = SearchForFacetQuery.All,
+): CompositionFacetsSearcher = DefaultCompositionFacetsSearcher(
+ searchService = DefaultCompositionFacetsSearchService(client),
+ compositionID = compositionID,
+ query = query,
+ attribute = attribute,
+ facetQuery = facetQuery,
+ maxFacetHits = maxFacetHits,
+ requestOptions = requestOptions,
+ coroutineScope = coroutineScope,
+ coroutineDispatcher = coroutineDispatcher,
+ triggerSearchFor = triggerSearchFor,
+)
+
+/**
+ * Creates an instance of [CompositionFacetsSearcher].
+ *
+ * @param applicationID application ID
+ * @param apiKey API Key
+ * @param compositionID unique Composition ObjectID
+ * @param attribute facet attribute
+ * @param query the query used to narrow down the facet values search
+ * @param facetQuery the facet query used to search for facets
+ * @param maxFacetHits maximum number of facet values to return
+ * @param requestOptions request local configuration
+ * @param coroutineScope scope of coroutine operations
+ * @param coroutineDispatcher async search dispatcher
+ * @param triggerSearchFor request condition
+ */
+public fun CompositionFacetsSearcher(
+ applicationID: String,
+ apiKey: String,
+ compositionID: String,
+ attribute: String,
+ query: SearchParamsObject = SearchParamsObject(),
+ facetQuery: String? = null,
+ maxFacetHits: Int? = null,
+ requestOptions: RequestOptions? = null,
+ coroutineScope: CoroutineScope = SearcherScope(),
+ coroutineDispatcher: CoroutineDispatcher = defaultDispatcher,
+ triggerSearchFor: SearchForFacetQuery = SearchForFacetQuery.All,
+): CompositionFacetsSearcher = CompositionFacetsSearcher(
+ client = CompositionClient(applicationID, apiKey),
+ compositionID = compositionID,
+ attribute = attribute,
+ query = query,
+ facetQuery = facetQuery,
+ maxFacetHits = maxFacetHits,
+ requestOptions = requestOptions,
+ coroutineScope = coroutineScope,
+ coroutineDispatcher = coroutineDispatcher,
+ triggerSearchFor = triggerSearchFor,
+)
diff --git a/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/CompositionSearcher.kt b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/CompositionSearcher.kt
new file mode 100644
index 000000000..d6e7047d3
--- /dev/null
+++ b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/CompositionSearcher.kt
@@ -0,0 +1,96 @@
+package com.algolia.instantsearch.searcher.composition
+
+import com.algolia.client.api.CompositionClient
+import com.algolia.client.model.search.SearchParamsObject
+import com.algolia.client.transport.RequestOptions
+import com.algolia.instantsearch.searcher.FilterGroupsHolder
+import com.algolia.instantsearch.searcher.SearcherForHits
+import com.algolia.instantsearch.searcher.SearcherScope
+import com.algolia.instantsearch.searcher.composition.internal.DefaultCompositionSearchService
+import com.algolia.instantsearch.searcher.composition.internal.DefaultCompositionSearcher
+import com.algolia.instantsearch.searcher.hits.SearchForQuery
+import com.algolia.instantsearch.searcher.internal.defaultDispatcher
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+
+/**
+ * The component handling search requests and managing the search sessions.
+ * This implementation runs an Algolia Composition (`/1/compositions/{compositionID}/run` endpoint).
+ *
+ * The composition run response is presented as a regular [com.algolia.client.model.search.SearchResponse],
+ * so a [CompositionSearcher] can be connected to the same components as a
+ * [com.algolia.instantsearch.searcher.hits.HitsSearcher] (hits, stats, facet list, search box,
+ * filter state, paging, ...).
+ *
+ * Unlike [com.algolia.instantsearch.searcher.hits.HitsSearcher], disjunctive faceting is handled
+ * server-side by the Composition API: the refined disjunctive attributes are annotated with the
+ * `disjunctive` modifier in the requested facets instead of performing a multi-query fan-out.
+ * A [CompositionSearcher] cannot join a [com.algolia.instantsearch.searcher.multi.MultiSearcher].
+ */
+public interface CompositionSearcher : SearcherForHits, FilterGroupsHolder {
+
+ /**
+ * Unique Composition ObjectID.
+ */
+ public var compositionID: String
+}
+
+/**
+ * Creates an instance of [CompositionSearcher].
+ *
+ * @param client composition client instance
+ * @param compositionID unique Composition ObjectID
+ * @param query the query used for search
+ * @param requestOptions request local configuration
+ * @param coroutineScope scope of coroutine operations
+ * @param coroutineDispatcher async search dispatcher
+ * @param triggerSearchFor request condition
+ */
+public fun CompositionSearcher(
+ client: CompositionClient,
+ compositionID: String,
+ query: SearchParamsObject = SearchParamsObject(),
+ requestOptions: RequestOptions? = null,
+ coroutineScope: CoroutineScope = SearcherScope(),
+ coroutineDispatcher: CoroutineDispatcher = defaultDispatcher,
+ triggerSearchFor: SearchForQuery = SearchForQuery.All,
+): CompositionSearcher = DefaultCompositionSearcher(
+ searchService = DefaultCompositionSearchService(client),
+ compositionID = compositionID,
+ query = query,
+ requestOptions = requestOptions,
+ coroutineScope = coroutineScope,
+ coroutineDispatcher = coroutineDispatcher,
+ triggerSearchFor = triggerSearchFor,
+)
+
+/**
+ * Creates an instance of [CompositionSearcher].
+ *
+ * @param applicationID application ID
+ * @param apiKey API Key
+ * @param compositionID unique Composition ObjectID
+ * @param query the query used for search
+ * @param requestOptions request local configuration
+ * @param coroutineScope scope of coroutine operations
+ * @param coroutineDispatcher async search dispatcher
+ * @param triggerSearchFor request condition
+ */
+public fun CompositionSearcher(
+ applicationID: String,
+ apiKey: String,
+ compositionID: String,
+ query: SearchParamsObject = SearchParamsObject(),
+ requestOptions: RequestOptions? = null,
+ coroutineScope: CoroutineScope = SearcherScope(),
+ coroutineDispatcher: CoroutineDispatcher = defaultDispatcher,
+ triggerSearchFor: SearchForQuery = SearchForQuery.All,
+): CompositionSearcher = CompositionSearcher(
+ client = CompositionClient(applicationID, apiKey),
+ compositionID = compositionID,
+ query = query,
+ requestOptions = requestOptions,
+ coroutineScope = coroutineScope,
+ coroutineDispatcher = coroutineDispatcher,
+ triggerSearchFor = triggerSearchFor,
+)
diff --git a/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionExtensions.kt b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionExtensions.kt
new file mode 100644
index 000000000..70497f589
--- /dev/null
+++ b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionExtensions.kt
@@ -0,0 +1,62 @@
+package com.algolia.instantsearch.searcher.composition.internal
+
+import com.algolia.client.model.composition.Params
+import com.algolia.client.model.composition.SearchResultsItem
+import com.algolia.client.model.search.SearchParamsObject
+import com.algolia.client.model.search.SearchResponse
+import kotlinx.serialization.json.Json
+import com.algolia.client.model.search.SearchForFacetValuesResponse
+import com.algolia.client.model.composition.SearchForFacetValuesResults as CompositionSearchForFacetValuesResults
+import com.algolia.client.model.search.FacetHits as SearchFacetHits
+
+/**
+ * Lenient json used to convert between structurally identical models generated for different API
+ * client namespaces: the parameters or response fields not supported by the destination model are
+ * dropped.
+ */
+private val compositionJson = Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = false
+}
+
+/**
+ * Converts search parameters to composition run parameters.
+ * The parameters not supported by the Composition API are dropped.
+ */
+internal fun SearchParamsObject.toCompositionParams(): Params {
+ val element = compositionJson.encodeToJsonElement(SearchParamsObject.serializer(), this)
+ return compositionJson.decodeFromJsonElement(Params.serializer(), element)
+}
+
+/**
+ * Annotates the requested facets with the `disjunctive` modifier for the given attributes.
+ */
+internal fun Params.annotateDisjunctiveFacets(disjunctiveAttributes: Set): Params {
+ val facets = facets
+ if (disjunctiveAttributes.isEmpty() || facets.isNullOrEmpty()) return this
+ return copy(
+ facets = facets.map { attribute ->
+ if (attribute in disjunctiveAttributes) "disjunctive($attribute)" else attribute
+ }
+ )
+}
+
+/**
+ * Converts a composition run result to a regular [SearchResponse], allowing the reuse of all the
+ * components consuming search responses.
+ */
+internal fun SearchResultsItem.toSearchResponse(): SearchResponse {
+ val element = compositionJson.encodeToJsonElement(SearchResultsItem.serializer(), this)
+ return compositionJson.decodeFromJsonElement(SearchResponse.serializer(), element)
+}
+
+/**
+ * Converts a composition facet values search result to a regular [SearchForFacetValuesResponse].
+ */
+internal fun CompositionSearchForFacetValuesResults.toSearchForFacetValuesResponse(): SearchForFacetValuesResponse {
+ return SearchForFacetValuesResponse(
+ facetHits = facetHits.map { SearchFacetHits(value = it.value, highlighted = it.highlighted, count = it.count) },
+ exhaustiveFacetsCount = exhaustiveFacetsCount,
+ processingTimeMS = processingTimeMS,
+ )
+}
diff --git a/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionFacetsSearchService.kt b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionFacetsSearchService.kt
new file mode 100644
index 000000000..f505f6538
--- /dev/null
+++ b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionFacetsSearchService.kt
@@ -0,0 +1,61 @@
+package com.algolia.instantsearch.searcher.composition.internal
+
+import com.algolia.client.api.CompositionClient
+import com.algolia.client.model.search.SearchForFacetValuesResponse
+import com.algolia.client.model.search.SearchParamsObject
+import com.algolia.client.transport.RequestOptions
+import com.algolia.instantsearch.searcher.multi.internal.SearchService
+import com.algolia.client.model.composition.SearchForFacetValuesParams as CompositionSearchForFacetValuesParams
+import com.algolia.client.model.composition.SearchForFacetValuesRequest as CompositionSearchForFacetValuesRequest
+
+/**
+ * Search service for facet values using the Algolia Composition API.
+ */
+internal interface CompositionFacetsSearchService :
+ SearchService {
+
+ /**
+ * Client to perform composition operations.
+ */
+ val client: CompositionClient
+
+ /**
+ * Composition facets service's request.
+ */
+ data class Request(
+ val compositionID: String,
+ val query: SearchParamsObject,
+ val facetAttribute: String,
+ val facetQuery: String?,
+ val maxFacetHits: Int?,
+ )
+}
+
+/**
+ * Default implementation of [CompositionFacetsSearchService].
+ */
+internal class DefaultCompositionFacetsSearchService(
+ override val client: CompositionClient,
+) : CompositionFacetsSearchService {
+
+ override suspend fun search(
+ request: CompositionFacetsSearchService.Request,
+ requestOptions: RequestOptions?,
+ ): SearchForFacetValuesResponse {
+ val response = client.searchForFacetValues(
+ compositionID = request.compositionID,
+ facetName = request.facetAttribute,
+ searchForFacetValuesRequest = CompositionSearchForFacetValuesRequest(
+ params = CompositionSearchForFacetValuesParams(
+ query = request.facetQuery,
+ maxFacetHits = request.maxFacetHits,
+ searchQuery = request.query.toCompositionParams(),
+ )
+ ),
+ requestOptions = requestOptions,
+ )
+ val result = response.results?.firstOrNull()
+ ?: error("The composition facet values search response contains no results")
+ return result.toSearchForFacetValuesResponse()
+ }
+}
diff --git a/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionSearchService.kt b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionSearchService.kt
new file mode 100644
index 000000000..aaae27002
--- /dev/null
+++ b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionSearchService.kt
@@ -0,0 +1,73 @@
+package com.algolia.instantsearch.searcher.composition.internal
+
+import com.algolia.client.api.CompositionClient
+import com.algolia.client.model.composition.RequestBody
+import com.algolia.client.model.search.SearchParamsObject
+import com.algolia.client.model.search.SearchResponse
+import com.algolia.client.transport.RequestOptions
+import com.algolia.instantsearch.filter.FilterGroup
+import com.algolia.instantsearch.searcher.multi.internal.SearchService
+
+/**
+ * Search service running Algolia Compositions.
+ */
+internal interface CompositionSearchService : SearchService {
+
+ /**
+ * Client to perform composition run operations.
+ */
+ val client: CompositionClient
+
+ /**
+ * Contains a [Set] of [FilterGroup], used to determine the disjunctive facets attributes.
+ */
+ var filterGroups: Set>
+
+ /**
+ * Composition service's request.
+ */
+ data class Request(
+ val compositionID: String,
+ val query: SearchParamsObject,
+ )
+}
+
+/**
+ * Default implementation of [CompositionSearchService].
+ *
+ * The [SearchParamsObject] is converted to composition run parameters and the first result of the
+ * composition run response is converted back to a regular [SearchResponse], allowing the reuse of
+ * all the components consuming search responses.
+ */
+internal class DefaultCompositionSearchService(
+ override val client: CompositionClient,
+ override var filterGroups: Set> = setOf(),
+) : CompositionSearchService {
+
+ override suspend fun search(request: CompositionSearchService.Request, requestOptions: RequestOptions?): SearchResponse {
+ val params = request.query
+ .toCompositionParams()
+ .annotateDisjunctiveFacets(disjunctiveFacetAttributes())
+ val response = client.search(
+ compositionID = request.compositionID,
+ requestBody = RequestBody(params),
+ requestOptions = requestOptions,
+ )
+ val result = response.results.firstOrNull()
+ ?: error("The composition run response contains no results")
+ return result.toSearchResponse()
+ }
+
+ /**
+ * Attributes of the refined disjunctive (OR group) facets, annotated with the `disjunctive`
+ * modifier in the requested facets, so that the Composition API computes their facet counts
+ * disjunctively server-side.
+ */
+ private fun disjunctiveFacetAttributes(): Set {
+ return filterGroups
+ .filterIsInstance()
+ .flatten()
+ .map { it.attribute }
+ .toSet()
+ }
+}
diff --git a/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/DefaultCompositionFacetsSearcher.kt b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/DefaultCompositionFacetsSearcher.kt
new file mode 100644
index 000000000..71005df18
--- /dev/null
+++ b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/DefaultCompositionFacetsSearcher.kt
@@ -0,0 +1,71 @@
+package com.algolia.instantsearch.searcher.composition.internal
+
+import com.algolia.client.model.search.SearchForFacetValuesResponse
+import com.algolia.client.model.search.SearchParamsObject
+import com.algolia.client.transport.RequestOptions
+import com.algolia.instantsearch.core.searcher.Sequencer
+import com.algolia.instantsearch.core.subscription.SubscriptionValue
+import com.algolia.instantsearch.searcher.composition.CompositionFacetsSearcher
+import com.algolia.instantsearch.searcher.facets.SearchForFacetQuery
+import com.algolia.instantsearch.searcher.internal.SearcherExceptionHandler
+import com.algolia.instantsearch.searcher.internal.runAsLoading
+import com.algolia.instantsearch.searcher.internal.withAlgoliaAgent
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * The component handling search requests and managing the search sessions.
+ * This implementation searches for facet values using the Algolia Composition API.
+ */
+internal class DefaultCompositionFacetsSearcher(
+ private val searchService: CompositionFacetsSearchService,
+ override var compositionID: String,
+ override var query: SearchParamsObject,
+ override val attribute: String,
+ override var facetQuery: String?,
+ override var maxFacetHits: Int?,
+ override val requestOptions: RequestOptions?,
+ override val coroutineScope: CoroutineScope,
+ override val coroutineDispatcher: CoroutineDispatcher,
+ private val triggerSearchFor: SearchForFacetQuery,
+) : CompositionFacetsSearcher {
+
+ override val isLoading: SubscriptionValue = SubscriptionValue(false)
+ override val error: SubscriptionValue = SubscriptionValue(null)
+ override val response: SubscriptionValue = SubscriptionValue(null)
+
+ private val exceptionHandler = SearcherExceptionHandler(this)
+ private val sequencer = Sequencer()
+
+ private val options get() = requestOptions.withAlgoliaAgent()
+ private val request
+ get() = CompositionFacetsSearchService.Request(compositionID, query, attribute, facetQuery, maxFacetHits)
+
+ override fun setQuery(text: String?) {
+ facetQuery = text
+ }
+
+ override fun searchAsync(): Job {
+ return coroutineScope.launch(exceptionHandler) {
+ isLoading.runAsLoading {
+ response.value = search()
+ }
+ }.also {
+ sequencer.addOperation(it)
+ }
+ }
+
+ override suspend fun search(): SearchForFacetValuesResponse? {
+ if (!triggerSearchFor.trigger(query, attribute, facetQuery)) return null
+ return withContext(coroutineDispatcher) {
+ searchService.search(request, options)
+ }
+ }
+
+ override fun cancel() {
+ sequencer.cancelAll()
+ }
+}
diff --git a/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/DefaultCompositionSearcher.kt b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/DefaultCompositionSearcher.kt
new file mode 100644
index 000000000..88a8bdee3
--- /dev/null
+++ b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/DefaultCompositionSearcher.kt
@@ -0,0 +1,68 @@
+package com.algolia.instantsearch.searcher.composition.internal
+
+import com.algolia.client.model.search.SearchParamsObject
+import com.algolia.client.model.search.SearchResponse
+import com.algolia.client.transport.RequestOptions
+import com.algolia.instantsearch.core.searcher.Sequencer
+import com.algolia.instantsearch.core.subscription.SubscriptionValue
+import com.algolia.instantsearch.filter.FilterGroup
+import com.algolia.instantsearch.searcher.composition.CompositionSearcher
+import com.algolia.instantsearch.searcher.hits.SearchForQuery
+import com.algolia.instantsearch.searcher.internal.SearcherExceptionHandler
+import com.algolia.instantsearch.searcher.internal.runAsLoading
+import com.algolia.instantsearch.searcher.internal.withAlgoliaAgent
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * The component handling search requests and managing the search sessions.
+ * This implementation runs an Algolia Composition.
+ */
+internal class DefaultCompositionSearcher(
+ private val searchService: CompositionSearchService,
+ override var compositionID: String,
+ override var query: SearchParamsObject,
+ override val requestOptions: RequestOptions?,
+ override val coroutineScope: CoroutineScope,
+ override val coroutineDispatcher: CoroutineDispatcher,
+ private val triggerSearchFor: SearchForQuery,
+) : CompositionSearcher {
+
+ override val isLoading: SubscriptionValue = SubscriptionValue(false)
+ override val error: SubscriptionValue = SubscriptionValue(null)
+ override val response: SubscriptionValue = SubscriptionValue(null)
+ override var filterGroups: Set> by searchService::filterGroups
+
+ private val exceptionHandler = SearcherExceptionHandler(this)
+ private val sequencer = Sequencer()
+
+ private val options get() = requestOptions.withAlgoliaAgent()
+
+ override fun setQuery(text: String?) {
+ query = query.copy(query = text)
+ }
+
+ override fun searchAsync(): Job {
+ return coroutineScope.launch(exceptionHandler) {
+ isLoading.runAsLoading {
+ response.value = search()
+ }
+ }.also {
+ sequencer.addOperation(it)
+ }
+ }
+
+ override suspend fun search(): SearchResponse? {
+ if (!triggerSearchFor.trigger(query)) return null
+ return withContext(coroutineDispatcher) {
+ searchService.search(CompositionSearchService.Request(compositionID, query), options)
+ }
+ }
+
+ override fun cancel() {
+ sequencer.cancelAll()
+ }
+}
diff --git a/instantsearch/src/commonTest/kotlin/searcher/TestCompositionSearcher.kt b/instantsearch/src/commonTest/kotlin/searcher/TestCompositionSearcher.kt
new file mode 100644
index 000000000..55ace3f97
--- /dev/null
+++ b/instantsearch/src/commonTest/kotlin/searcher/TestCompositionSearcher.kt
@@ -0,0 +1,222 @@
+package searcher
+
+import JsonNoDefaults
+import com.algolia.client.api.CompositionClient
+import com.algolia.client.configuration.ClientOptions
+import com.algolia.client.model.composition.Params
+import com.algolia.client.model.composition.SearchResultsItem
+import com.algolia.client.model.search.SearchParamsObject
+import com.algolia.instantsearch.filter.Filter
+import com.algolia.instantsearch.filter.FilterGroup
+import com.algolia.instantsearch.filter.state.FilterGroupID
+import com.algolia.instantsearch.filter.state.FilterOperator
+import com.algolia.instantsearch.filter.state.FilterState
+import com.algolia.instantsearch.searcher.composition.CompositionFacetsSearcher
+import com.algolia.instantsearch.searcher.composition.CompositionSearcher
+import com.algolia.instantsearch.searcher.composition.internal.annotateDisjunctiveFacets
+import com.algolia.instantsearch.searcher.composition.internal.toCompositionParams
+import com.algolia.instantsearch.searcher.composition.internal.toSearchResponse
+import com.algolia.instantsearch.searcher.connectFilterState
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.client.engine.mock.respondBadRequest
+import io.ktor.http.ContentType
+import io.ktor.http.headersOf
+import io.ktor.client.plugins.logging.LogLevel
+import io.ktor.utils.io.ByteReadChannel
+import kotlin.test.Test
+import kotlinx.coroutines.test.runTest
+import shouldBeNull
+import shouldBeTrue
+import shouldEqual
+import shouldNotBeNull
+import com.algolia.client.model.composition.FacetHits as CompositionFacetHits
+import com.algolia.client.model.composition.Hit as CompositionHit
+import com.algolia.client.model.composition.SearchForFacetValuesResponse as CompositionSearchForFacetValuesResponse
+import com.algolia.client.model.composition.SearchForFacetValuesResults as CompositionSearchForFacetValuesResults
+import com.algolia.client.model.composition.SearchResponse as CompositionSearchResponse
+
+class TestCompositionSearcher {
+
+ private val compositionID = "my-composition"
+
+ private val runResponse = CompositionSearchResponse(
+ results = listOf(
+ SearchResultsItem(
+ hits = listOf(CompositionHit(objectID = "obj1"), CompositionHit(objectID = "obj2")),
+ hitsPerPage = 20,
+ nbHits = 2,
+ nbPages = 1,
+ page = 0,
+ params = "query=phone",
+ query = "phone",
+ compositions = emptyMap(),
+ facets = mapOf("color" to mapOf("red" to 1, "blue" to 2)),
+ queryID = "queryID",
+ processingTimeMS = 7,
+ )
+ )
+ )
+
+ private val facetValuesResponse = CompositionSearchForFacetValuesResponse(
+ results = listOf(
+ CompositionSearchForFacetValuesResults(
+ indexName = "myIndex",
+ facetHits = listOf(CompositionFacetHits(value = "red", highlighted = "red", count = 3)),
+ exhaustiveFacetsCount = true,
+ processingTimeMS = 2,
+ )
+ )
+ )
+
+ private fun mockCompositionClient(engine: MockEngine): CompositionClient {
+ return CompositionClient(
+ appId = "A",
+ apiKey = "B",
+ options = ClientOptions(
+ engine = engine,
+ logLevel = LogLevel.ALL
+ )
+ )
+ }
+
+ private fun respondRun(): CompositionClient {
+ val responseString = JsonNoDefaults.encodeToString(CompositionSearchResponse.serializer(), runResponse)
+ return mockCompositionClient(
+ MockEngine {
+ respond(
+ headers = headersOf("Content-Type", listOf(ContentType.Application.Json.toString())),
+ content = ByteReadChannel(responseString)
+ )
+ }
+ )
+ }
+
+ private fun respondFacetValues(): CompositionClient {
+ val responseString =
+ JsonNoDefaults.encodeToString(CompositionSearchForFacetValuesResponse.serializer(), facetValuesResponse)
+ return mockCompositionClient(
+ MockEngine {
+ respond(
+ headers = headersOf("Content-Type", listOf(ContentType.Application.Json.toString())),
+ content = ByteReadChannel(responseString)
+ )
+ }
+ )
+ }
+
+ @Test
+ fun searchShouldMapCompositionRunResponse() = runTest {
+ val searcher = CompositionSearcher(
+ client = respondRun(),
+ compositionID = compositionID,
+ coroutineScope = TestCoroutineScope,
+ )
+ searcher.searchAsync().join()
+ searcher.response.value.shouldNotBeNull()
+ val response = searcher.response.value!!
+ response.hits.map { it.objectID } shouldEqual listOf("obj1", "obj2")
+ response.nbHits shouldEqual 2
+ response.page shouldEqual 0
+ response.nbPages shouldEqual 1
+ response.hitsPerPage shouldEqual 20
+ response.query shouldEqual "phone"
+ response.queryID shouldEqual "queryID"
+ response.processingTimeMS shouldEqual 7
+ response.facets shouldEqual mapOf("color" to mapOf("red" to 1, "blue" to 2))
+ searcher.error.value.shouldBeNull()
+ }
+
+ @Test
+ fun searchShouldUpdateError() = runTest {
+ val searcher = CompositionSearcher(
+ client = mockCompositionClient(MockEngine { respondBadRequest() }),
+ compositionID = compositionID,
+ coroutineScope = TestCoroutineScope,
+ )
+ searcher.searchAsync().join()
+ searcher.error.value.shouldNotBeNull()
+ searcher.response.value.shouldBeNull()
+ }
+
+ @Test
+ fun setQueryShouldUpdateQuery() {
+ val searcher = CompositionSearcher(
+ client = respondRun(),
+ compositionID = compositionID,
+ coroutineScope = TestCoroutineScope,
+ )
+ searcher.setQuery("phone")
+ searcher.query.query shouldEqual "phone"
+ }
+
+ @Test
+ fun connectFilterStateShouldUpdateFilters() {
+ val searcher = CompositionSearcher(
+ client = respondRun(),
+ compositionID = compositionID,
+ coroutineScope = TestCoroutineScope,
+ )
+ val filter = Filter.Facet("color", "red")
+ val filterState = FilterState(mapOf(FilterGroupID("color", FilterOperator.Or) to setOf(filter)))
+ searcher.connectFilterState(filterState)
+ searcher.query.filters shouldEqual "(\"color\":\"red\")"
+ searcher.filterGroups shouldEqual setOf(FilterGroup.Or.Facet(setOf(filter), name = "color"))
+ }
+
+ @Test
+ fun searchForFacetValuesShouldMapResponse() = runTest {
+ val searcher = CompositionFacetsSearcher(
+ client = respondFacetValues(),
+ compositionID = compositionID,
+ attribute = "color",
+ coroutineScope = TestCoroutineScope,
+ )
+ searcher.searchAsync().join()
+ searcher.response.value.shouldNotBeNull()
+ val response = searcher.response.value!!
+ response.facetHits.size shouldEqual 1
+ response.facetHits.first().value shouldEqual "red"
+ response.facetHits.first().highlighted shouldEqual "red"
+ response.facetHits.first().count shouldEqual 3
+ response.exhaustiveFacetsCount.shouldBeTrue()
+ response.processingTimeMS shouldEqual 2
+ }
+
+ @Test
+ fun searchParamsShouldMapToCompositionParams() {
+ val params = SearchParamsObject(
+ query = "phone",
+ filters = "(\"color\":\"red\")",
+ facets = listOf("color", "brand"),
+ page = 2,
+ hitsPerPage = 50,
+ highlightPreTag = "",
+ highlightPostTag = "",
+ ).toCompositionParams()
+
+ params.query shouldEqual "phone"
+ params.filters shouldEqual "(\"color\":\"red\")"
+ params.facets shouldEqual listOf("color", "brand")
+ params.page shouldEqual 2
+ params.hitsPerPage shouldEqual 50
+ }
+
+ @Test
+ fun annotateDisjunctiveFacetsShouldTagRefinedAttributes() {
+ val params = Params(facets = listOf("color", "brand"))
+ val annotated = params.annotateDisjunctiveFacets(setOf("color"))
+ annotated.facets shouldEqual listOf("disjunctive(color)", "brand")
+ }
+
+ @Test
+ fun searchResultsItemShouldMapToSearchResponse() {
+ val response = runResponse.results.first().toSearchResponse()
+ response.hits.map { it.objectID } shouldEqual listOf("obj1", "obj2")
+ response.query shouldEqual "phone"
+ response.params shouldEqual "query=phone"
+ response.nbHits shouldEqual 2
+ response.facets shouldEqual mapOf("color" to mapOf("red" to 1, "blue" to 2))
+ response.queryID shouldEqual "queryID"
+ }
+}