commonMain.com.algolia.client.extensions.SearchClient.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of algoliasearch-client-kotlin-jvm Show documentation
Show all versions of algoliasearch-client-kotlin-jvm Show documentation
"Algolia is a powerful search-as-a-service solution, made easy to use with API clients, UI libraries, and pre-built integrations. Algolia API Client for Kotlin lets you easily use the Algolia Search REST API from your JVM project, such as Android or backend implementations."
package com.algolia.client.extensions
import com.algolia.client.api.SearchClient
import com.algolia.client.exception.AlgoliaApiException
import com.algolia.client.extensions.internal.*
import com.algolia.client.extensions.internal.DisjunctiveFaceting
import com.algolia.client.extensions.internal.buildRestrictionString
import com.algolia.client.extensions.internal.encodeKeySHA256
import com.algolia.client.extensions.internal.retryUntil
import com.algolia.client.model.search.*
import com.algolia.client.transport.RequestOptions
import io.ktor.util.*
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.json.*
import kotlinx.serialization.json.JsonObject
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* Wait for an API key to be added, updated or deleted based on a given `operation`.
*
* @param key The `key` that has been added, deleted or updated.
* @param operation The `operation` that was done on a `key`.
* @param apiKey Necessary to know if an `update` operation has been processed, compare fields of
* the response with it.
* @param maxRetries The maximum number of retries. 50 by default. (optional)
* @param timeout The function to decide how long to wait between retries. min(retries * 200,
* 5000) by default. (optional)
* @param requestOptions The requestOptions to send along with the query, they will be merged with
* the transporter requestOptions. (optional)
*/
public suspend fun SearchClient.waitForApiKey(
key: String,
operation: ApiKeyOperation,
apiKey: ApiKey? = null,
maxRetries: Int = 50,
timeout: Duration = Duration.INFINITE,
initialDelay: Duration = 200.milliseconds,
maxDelay: Duration = 5.seconds,
requestOptions: RequestOptions? = null,
): GetApiKeyResponse? {
return when (operation) {
ApiKeyOperation.Add -> waitKeyCreation(
key = key,
maxRetries = maxRetries,
timeout = timeout,
initialDelay = initialDelay,
maxDelay = maxDelay,
requestOptions = requestOptions,
)
ApiKeyOperation.Delete -> waitKeyDelete(
key = key,
maxRetries = maxRetries,
timeout = timeout,
initialDelay = initialDelay,
maxDelay = maxDelay,
requestOptions = requestOptions,
)
ApiKeyOperation.Update -> waitKeyUpdate(
key = key,
apiKey = requireNotNull(apiKey) { "apiKey is required for update api key operation" },
timeout = timeout,
maxRetries = maxRetries,
initialDelay = initialDelay,
maxDelay = maxDelay,
requestOptions = requestOptions,
)
}
}
/**
* Wait for a [taskID] to complete before executing the next line of code, to synchronize index
* updates. All write operations in Algolia are asynchronous by design. It means that when you add
* or update an object to your index, our servers will reply to your request with a [taskID] as soon
* as they understood the write operation. The actual insert and indexing will be done after
* replying to your code. You can wait for a task to complete by using the [taskID] and this method.
*
* @param indexName The index in which to perform the request.
* @param taskID The ID of the task to wait for.
* @param timeout If specified, the method will throw a
* [kotlinx.coroutines.TimeoutCancellationException] after the timeout value in milliseconds is
* elapsed.
* @param maxRetries maximum number of retry attempts.
* @param requestOptions additional request configuration.
*/
public suspend fun SearchClient.waitForTask(
indexName: String,
taskID: Long,
maxRetries: Int = 50,
timeout: Duration = Duration.INFINITE,
initialDelay: Duration = 200.milliseconds,
maxDelay: Duration = 5.seconds,
requestOptions: RequestOptions? = null,
): GetTaskResponse {
return retryUntil(
timeout = timeout,
maxRetries = maxRetries,
initialDelay = initialDelay,
maxDelay = maxDelay,
retry = { getTask(indexName, taskID, requestOptions) },
until = { it.status == TaskStatus.Published },
)
}
@Deprecated(
"Please use waitForTask instead",
ReplaceWith("waitForTask(indexName, taskID, maxRetries, timeout, initialDelay, maxDelay, requestOptions)"),
)
public suspend fun SearchClient.waitTask(
indexName: String,
taskID: Long,
maxRetries: Int = 50,
timeout: Duration = Duration.INFINITE,
initialDelay: Duration = 200.milliseconds,
maxDelay: Duration = 5.seconds,
requestOptions: RequestOptions? = null,
): TaskStatus {
return waitForTask(
indexName = indexName,
taskID = taskID,
maxRetries = maxRetries,
timeout = timeout,
initialDelay = initialDelay,
maxDelay = maxDelay,
requestOptions = requestOptions,
).status
}
/**
* Wait for an application-level [taskID] to complete before executing the next line of code.
*
* @param taskID The ID of the task to wait for.
* @param timeout If specified, the method will throw a
* [kotlinx.coroutines.TimeoutCancellationException] after the timeout value in milliseconds is
* elapsed.
* @param maxRetries maximum number of retry attempts.
* @param requestOptions additional request configuration.
*/
public suspend fun SearchClient.waitForAppTask(
taskID: Long,
maxRetries: Int = 50,
timeout: Duration = Duration.INFINITE,
initialDelay: Duration = 200.milliseconds,
maxDelay: Duration = 5.seconds,
requestOptions: RequestOptions? = null,
): GetTaskResponse {
return retryUntil(
timeout = timeout,
maxRetries = maxRetries,
initialDelay = initialDelay,
maxDelay = maxDelay,
retry = { getAppTask(taskID, requestOptions) },
until = { it.status == TaskStatus.Published },
)
}
@Deprecated(
"Please use waitForAppTask instead",
ReplaceWith("waitForAppTask(taskID, maxRetries, timeout, initialDelay, maxDelay, requestOptions)"),
)
public suspend fun SearchClient.waitAppTask(
taskID: Long,
maxRetries: Int = 50,
timeout: Duration = Duration.INFINITE,
initialDelay: Duration = 200.milliseconds,
maxDelay: Duration = 5.seconds,
requestOptions: RequestOptions? = null,
): TaskStatus {
return waitForAppTask(
taskID = taskID,
maxRetries = maxRetries,
timeout = timeout,
initialDelay = initialDelay,
maxDelay = maxDelay,
requestOptions = requestOptions,
).status
}
/**
* Wait on an API key update operation.
*
* @param key The key that has been updated.
* @param apiKey Necessary to know if an `update` operation has been processed, compare fields of
* the response with it.
* @param timeout If specified, the method will throw a
* [kotlinx.coroutines.TimeoutCancellationException] after the timeout value in milliseconds is
* elapsed.
* @param maxRetries Maximum number of retry attempts.
* @param requestOptions Additional request configuration.
*/
public suspend fun SearchClient.waitKeyUpdate(
key: String,
apiKey: ApiKey,
maxRetries: Int = 50,
timeout: Duration = Duration.INFINITE,
initialDelay: Duration = 200.milliseconds,
maxDelay: Duration = 5.seconds,
requestOptions: RequestOptions? = null,
): GetApiKeyResponse {
return retryUntil(
timeout = timeout,
maxRetries = maxRetries,
initialDelay = initialDelay,
maxDelay = maxDelay,
retry = { getApiKey(key, requestOptions) },
until = {
apiKey ==
ApiKey(
acl = it.acl,
description = it.description,
indexes = it.indexes,
maxHitsPerQuery = it.maxHitsPerQuery,
maxQueriesPerIPPerHour = it.maxQueriesPerIPPerHour,
queryParameters = it.queryParameters,
referers = it.referers,
validity = it.validity,
)
},
)
}
/**
* Wait on an API key creation operation.
*
* @param timeout If specified, the method will throw a
* [kotlinx.coroutines.TimeoutCancellationException] after the timeout value in milliseconds is
* elapsed.
* @param maxRetries Maximum number of retry attempts.
* @param requestOptions Additional request configuration.
*/
public suspend fun SearchClient.waitKeyCreation(
key: String,
maxRetries: Int = 50,
timeout: Duration = Duration.INFINITE,
initialDelay: Duration = 200.milliseconds,
maxDelay: Duration = 5.seconds,
requestOptions: RequestOptions? = null,
): GetApiKeyResponse {
return retryUntil(
timeout = timeout,
maxRetries = maxRetries,
initialDelay = initialDelay,
maxDelay = maxDelay,
retry = {
try {
val response = getApiKey(key, requestOptions)
Result.success(response)
} catch (e: AlgoliaApiException) {
Result.failure(e)
}
},
until = { it.isSuccess },
).getOrThrow()
}
/**
* Wait on a delete API ket operation.
*
* @param maxRetries Maximum number of retry attempts.
* @param timeout If specified, the method will throw a
* [kotlinx.coroutines.TimeoutCancellationException] after the timeout value in milliseconds is
* elapsed.
* @param requestOptions Additional request configuration.
*/
public suspend fun SearchClient.waitKeyDelete(
key: String,
maxRetries: Int = 50,
timeout: Duration = Duration.INFINITE,
initialDelay: Duration = 200.milliseconds,
maxDelay: Duration = 5.seconds,
requestOptions: RequestOptions? = null,
): GetApiKeyResponse? {
return retryUntil(
timeout = timeout,
maxRetries = maxRetries,
initialDelay = initialDelay,
maxDelay = maxDelay,
retry = {
try {
return@retryUntil getApiKey(key, requestOptions)
} catch (e: AlgoliaApiException) {
if (e.httpErrorCode == 404) {
return@retryUntil null
}
throw e
}
},
until = { result ->
result == null
},
)
}
/**
* Calls the `search` method but with certainty that we will only request Algolia records (hits).
*/
public suspend fun SearchClient.searchForHits(
requests: List,
strategy: SearchStrategy? = null,
requestOptions: RequestOptions? = null,
): List {
val request = SearchMethodParams(requests = requests, strategy = strategy)
return search(searchMethodParams = request, requestOptions = requestOptions).results.map { it as SearchResponse }
}
/**
* Calls the `search` method but with certainty that we will only request Algolia facets.
*/
public suspend fun SearchClient.searchForFacets(
requests: List,
strategy: SearchStrategy? = null,
requestOptions: RequestOptions? = null,
): List {
val request = SearchMethodParams(requests = requests, strategy = strategy)
return search(
searchMethodParams = request,
requestOptions = requestOptions,
).results.map { it as SearchForFacetValuesResponse }
}
/**
* Helper: Chunks the given `objects` list in subset of 1000 elements max to make it fit in `batch` requests.
*
* @param indexName The index in which to perform the request.
* @param objects The list of objects to index.
* @param action The action to perform on the objects. Default is `Action.AddObject`.
* @param waitForTask If true, wait for the task to complete.
* @param batchSize The size of the batch. Default is 1000.
* @param requestOptions The requestOptions to send along with the query, they will be merged with the transporter requestOptions.
* @return The list of responses from the batch requests.
*
*/
public suspend fun SearchClient.chunkedBatch(
indexName: String,
objects: List,
action: Action = Action.AddObject,
waitForTask: Boolean,
batchSize: Int = 1000,
requestOptions: RequestOptions? = null,
): List {
val tasks = mutableListOf()
objects.chunked(batchSize).forEach { chunk ->
val requests = chunk.map {
BatchRequest(
action = action,
body = it,
)
}
val batch = batch(
indexName = indexName,
batchWriteParams = BatchWriteParams(requests),
requestOptions = requestOptions,
)
tasks.add(batch)
}
if (waitForTask) {
tasks.forEach { waitForTask(indexName, it.taskID) }
}
return tasks
}
/**
* Helper: Saves the given array of objects in the given index. The `chunkedBatch` helper is used under the hood, which creates a `batch` requests with at most 1000 objects in it.
*
* @param indexName The index in which to perform the request.
* @param objects The list of objects to index.
* @param waitForTask If true, wait for the task to complete.
* @param requestOptions The requestOptions to send along with the query, they will be merged with the transporter requestOptions.
* @return The list of responses from the batch requests.
*
*/
public suspend fun SearchClient.saveObjects(
indexName: String,
objects: List,
waitForTask: Boolean = false,
requestOptions: RequestOptions? = null,
): List {
return this.chunkedBatch(
indexName = indexName,
objects = objects,
action = Action.AddObject,
waitForTask = waitForTask,
batchSize = 1000,
requestOptions = requestOptions,
)
}
/**
* Helper: Deletes every records for the given objectIDs. The `chunkedBatch` helper is used under the hood, which creates a `batch` requests with at most 1000 objectIDs in it.
*
* @param indexName The index in which to perform the request.
* @param objectIDs The list of objectIDs to delete from the index.
* @param waitForTask If true, wait for the task to complete.
* @param requestOptions The requestOptions to send along with the query, they will be merged with the transporter requestOptions.
* @return The list of responses from the batch requests.
*
*/
public suspend fun SearchClient.deleteObjects(
indexName: String,
objectIDs: List,
waitForTask: Boolean = false,
requestOptions: RequestOptions? = null,
): List {
return this.chunkedBatch(
indexName = indexName,
objects = objectIDs.map { id -> JsonObject(mapOf("objectID" to Json.encodeToJsonElement(id))) },
action = Action.DeleteObject,
waitForTask = waitForTask,
batchSize = 1000,
requestOptions = requestOptions,
)
}
/**
* Helper: Replaces object content of all the given objects according to their respective `objectID` field. The `chunkedBatch` helper is used under the hood, which creates a `batch` requests with at most 1000 objects in it.
*
* @param indexName The index in which to perform the request.
* @param objects The list of objects to update in the index.
* @param createIfNotExists To be provided if non-existing objects are passed, otherwise, the call will fail..
* @param waitForTask If true, wait for the task to complete.
* @param requestOptions The requestOptions to send along with the query, they will be merged with the transporter requestOptions.
* @return The list of responses from the batch requests.
*
*/
public suspend fun SearchClient.partialUpdateObjects(
indexName: String,
objects: List,
createIfNotExists: Boolean,
waitForTask: Boolean = false,
requestOptions: RequestOptions? = null,
): List {
return this.chunkedBatch(
indexName = indexName,
objects = objects,
action = if (createIfNotExists) Action.PartialUpdateObject else Action.PartialUpdateObjectNoCreate,
waitForTask = waitForTask,
batchSize = 1000,
requestOptions = requestOptions,
)
}
/**
* Push a new set of objects and remove all previous ones. Settings, synonyms and query rules are untouched.
* Replace all objects in an index without any downtime.
* Internally, this method copies the existing index settings, synonyms and query rules and indexes all
* passed objects. Finally, the temporary one replaces the existing index.
*
* See https://api-clients-automation.netlify.app/docs/add-new-api-client#5-helpers for implementation details.
*
* @param indexName The index in which to perform the request.
* @param objects The list of objects to replace.
* @param batchSize The size of the batch. Default is 1000.
* @return responses from the three-step operations: copy, batch, move.
*/
public suspend fun SearchClient.replaceAllObjects(
indexName: String,
objects: List,
batchSize: Int = 1000,
requestOptions: RequestOptions? = null,
): ReplaceAllObjectsResponse {
val tmpIndexName = "${indexName}_tmp_${Random.nextInt(from = 0, until = 100)}"
var copy = operationIndex(
indexName = indexName,
operationIndexParams = OperationIndexParams(
operation = OperationType.Copy,
destination = tmpIndexName,
scope = listOf(ScopeType.Settings, ScopeType.Rules, ScopeType.Synonyms),
),
requestOptions = requestOptions,
)
val batchResponses = this.chunkedBatch(
indexName = tmpIndexName,
objects = objects,
action = Action.AddObject,
waitForTask = true,
batchSize = batchSize,
requestOptions = requestOptions,
)
waitForTask(indexName = tmpIndexName, taskID = copy.taskID)
copy = operationIndex(
indexName = indexName,
operationIndexParams = OperationIndexParams(
operation = OperationType.Copy,
destination = tmpIndexName,
scope = listOf(ScopeType.Settings, ScopeType.Rules, ScopeType.Synonyms),
),
requestOptions = requestOptions,
)
waitForTask(indexName = tmpIndexName, taskID = copy.taskID)
val move = operationIndex(
indexName = tmpIndexName,
operationIndexParams = OperationIndexParams(operation = OperationType.Move, destination = indexName),
requestOptions = requestOptions,
)
waitForTask(indexName = tmpIndexName, taskID = move.taskID)
return ReplaceAllObjectsResponse(copy, batchResponses, move)
}
/**
* Generate a virtual API Key without any call to the server.
*
* @param parentApiKey API key to generate from.
* @param restrictions Restriction to add the key
* @throws Exception if an error occurs during the encoding
*/
public fun SearchClient.generateSecuredApiKey(parentApiKey: String, restrictions: SecuredApiKeyRestrictions): String {
val restrictionString = buildRestrictionString(restrictions)
val hash = encodeKeySHA256(parentApiKey, restrictionString)
return "$hash$restrictionString".encodeBase64()
}
/**
* Gets how many milliseconds are left before the secured API key expires.
*
* @param apiKey The secured API Key to check.
* @return Duration left before the secured API key expires.
* @throws IllegalArgumentException if [apiKey] doesn't have a [SecuredApiKeyRestrictions.validUntil].
*/
public fun securedApiKeyRemainingValidity(apiKey: String): Duration {
val decoded = apiKey.decodeBase64String()
val pattern = Regex("validUntil=(\\d+)")
val match = requireNotNull(pattern.find(decoded)) { "The Secured API Key doesn't have a validUntil parameter." }
val validUntil = Instant.fromEpochMilliseconds(match.groupValues[1].toLong())
return validUntil - Clock.System.now()
}
public suspend fun SearchClient.indexExists(indexName: String): Boolean {
try {
getSettings(indexName)
} catch (e: AlgoliaApiException) {
if (e.httpErrorCode == 404) {
return false
}
throw e
}
return true
}
public data class SearchDisjunctiveFacetingResponse(
val response: SearchResponse,
val disjunctiveFacets: Map>,
)
/**
* Method used for perform search with disjunctive facets.
*
* @param indexName The name of the index in which the search queries should be performed
* @param searchParamsObject The search query params.
* @param refinements Refinements to apply to the search in form of dictionary with
* facet attribute as a key and a list of facet values for the designated attribute.
* Any facet in this list not present in the `disjunctiveFacets` set will be filtered conjunctively (with AND operator).
* @param disjunctiveFacets Set of facets attributes applied disjunctively (with OR operator)
* @param requestOptions Configure request locally with RequestOptions.
* @return SearchDisjunctiveFacetingResponse - a struct containing the merge response from all the
* disjunctive faceting search queries, and a list of disjunctive facets
* @throws NoSuchElementException if there are no search response from the multi-query search
*/
public suspend fun SearchClient.searchDisjunctiveFaceting(
indexName: String,
searchParamsObject: SearchParamsObject,
refinements: Map>,
disjunctiveFacets: Set,
requestOptions: RequestOptions? = null
): SearchDisjunctiveFacetingResponse {
val helper = DisjunctiveFaceting(
query = SearchForHits.from(searchParamsObject, indexName),
refinements = refinements,
disjunctiveFacets = disjunctiveFacets,
)
val queries = helper.buildQueries()
val responses = searchForHits(queries, requestOptions = requestOptions)
return helper.mergeResponses(responses)
}
/**
* Helper: Returns an iterator on top of the `browse` method.
*
* @param indexName The index in which to perform the request.
* @param params The `browse` parameters.
* @param validate The function to validate the response. Default is to check if the cursor is not null.
* @param aggregator The function to aggregate the response.
* @param requestOptions The requestOptions to send along with the query, they will be merged with
* the transporter requestOptions. (optional)
*/
public suspend fun SearchClient.browseObjects(
indexName: String,
params: BrowseParamsObject,
validate: (BrowseResponse) -> Boolean = { response -> response.cursor == null },
aggregator: ((BrowseResponse) -> Unit),
requestOptions: RequestOptions? = null,
): BrowseResponse {
return createIterable(
execute = { previousResponse ->
browse(
indexName,
params.copy(hitsPerPage = params.hitsPerPage ?: 1000, cursor = previousResponse?.cursor),
requestOptions,
)
},
validate = validate,
aggregator = aggregator,
)
}
/**
* Helper: Returns an iterator on top of the `browse` method.
*
* @param indexName The index in which to perform the request.
* @param searchRulesParams The search rules request parameters
* @param validate The function to validate the response. Default is to check if the cursor is not null.
* @param requestOptions The requestOptions to send along with the query, they will be merged with
* the transporter requestOptions. (optional)
*/
public suspend fun SearchClient.browseRules(
indexName: String,
searchRulesParams: SearchRulesParams,
validate: ((SearchRulesResponse) -> Boolean)? = null,
aggregator: (SearchRulesResponse) -> Unit,
requestOptions: RequestOptions? = null,
): SearchRulesResponse {
val hitsPerPage = searchRulesParams.hitsPerPage ?: 1000
return createIterable(
execute = { previousResponse ->
searchRules(
indexName,
searchRulesParams.copy(
page = if (previousResponse != null) (previousResponse.page + 1) else 0,
hitsPerPage = hitsPerPage,
),
requestOptions,
)
},
validate = validate ?: { response -> response.hits.count() < hitsPerPage },
aggregator = aggregator,
)
}
/**
* Helper: Returns an iterator on top of the `browse` method.
*
* @param indexName The index in which to perform the request.
* @param searchSynonymsParams The search synonyms request parameters
* @param validate The function to validate the response. Default is to check if the cursor is not null.
* @param requestOptions The requestOptions to send along with the query, they will be merged with
* the transporter requestOptions. (optional)
*/
public suspend fun SearchClient.browseSynonyms(
indexName: String,
searchSynonymsParams: SearchSynonymsParams,
validate: ((SearchSynonymsResponse) -> Boolean)? = null,
aggregator: (SearchSynonymsResponse) -> Unit,
requestOptions: RequestOptions? = null,
): SearchSynonymsResponse {
val hitsPerPage = 1000
var page = searchSynonymsParams.page ?: 0
return createIterable(
execute = { _ ->
try {
searchSynonyms(
indexName,
searchSynonymsParams = searchSynonymsParams.copy(
page = page,
hitsPerPage = hitsPerPage,
),
requestOptions,
)
} finally {
page += 1
}
},
validate = validate ?: { response -> response.hits.count() < hitsPerPage },
aggregator = aggregator,
)
}
© 2015 - 2024 Weber Informatics LLC | Privacy Policy