import gleam/bool import gleam/dynamic/decode import gleam/http.{Delete, Get, Patch, Post, Put} import gleam/http/request.{type Request} import gleam/http/response.{type Response} import gleam/int import gleam/json import gleam/list import gleam/option.{type Option, None} import gleam/result import gleam/string import gleam/time/duration import gleam/time/timestamp.{type Timestamp} // ----- PUBLIC HELPER TYPES AND FNS ----- pub type ParseResponseFunc(data_type) = fn(Response(String)) -> Result(data_type, DeweyError) pub type DeweyError { JSONDecodeError(err: json.DecodeError) APIError(message: String, code: String, error_type: String, link: String) // This should only occur if the assumption about the shape of the error object is incorrect. // The error object type is outlined at: https://www.meilisearch.com/docs/reference/errors/overview UnexpectedAPIError } pub type Operation(data_type) = #(Request(String), ParseResponseFunc(data_type)) // ----- CLIENT ----- pub opaque type Client { Client(url: String, api_key: String) } pub fn new_client(url: String, api_key: String) -> Result(Client, Nil) { // TODO: Check for valid http/https url Ok(Client(url:, api_key:)) } // ----- TASKS ----- /// A summarized version of the Task type, returned as a response to /// endpoints that create a Task object. To retrieve a more detailed look at a task /// use the get_task function. pub type SummarizedTask { SummarizedTask( task_uid: Int, index_uid: Option(String), status: TaskStatus, task_type: TaskType, enqueued_at: timestamp.Timestamp, ) } fn parse_summarized_task_response( resp: Response(String), ) -> Result(SummarizedTask, DeweyError) { use resp_body <- result.try(verify_response(resp)) json.parse(resp_body, summarized_task_decoder()) |> result.map_error(JSONDecodeError) } /// A detailed description of a task generated by asynchronous endpoints. pub type Task { Task( uid: Int, index_uid: Option(String), batch_uid: Int, status: TaskStatus, task_type: TaskType, canceled_by: Option(Int), details: Option(TaskDetails), error: Option(TaskError), // TODO: Figure out how to parse an ISO8601 duration // duration: duration.Duration, enqueued_at: timestamp.Timestamp, started_at: Option(timestamp.Timestamp), finished_at: Option(timestamp.Timestamp), ) } /// The current status of a task. pub type TaskStatus { Enqueued Processing Succeeded Failed Canceled // This should never happen. // https://www.meilisearch.com/docs/reference/api/tasks#status UnexpectedTaskStatus } fn string_to_task_status(str: String) -> TaskStatus { case str { "enqueued" -> Enqueued "processing" -> Processing "succeeded" -> Succeeded "failed" -> Failed "canceled" -> Canceled _ -> UnexpectedTaskStatus } } fn task_status_to_string(status: TaskStatus) -> String { case status { Enqueued -> "enqueued" Processing -> "processing" Succeeded -> "succeeded" Failed -> "failed" Canceled -> "canceled" UnexpectedTaskStatus -> "" } } /// The type of task. pub type TaskType { IndexCreation IndexUpdate IndexDeletion IndexSwap DocumentAdditionOrUpdate DocumentDeletion SettingsUpdate DumpCreation TaskCancelation TaskDeletion UpgradeDatabase DocumentEdition SnapshotCreation // This should never happen. // https://www.meilisearch.com/docs/reference/api/tasks#type UnexpectedTaskType } fn string_to_task_type(str: String) -> TaskType { case str { "indexCreation" -> IndexCreation "indexUpdate" -> IndexUpdate "indexDeletion" -> IndexDeletion "indexSwap" -> IndexSwap "documentAdditionOrUpdate" -> DocumentAdditionOrUpdate "documentDeletion" -> DocumentDeletion "settingsUpdate" -> SettingsUpdate "dumpCreation" -> DumpCreation "taskCancelation" -> TaskCancelation "taskDeletion" -> TaskDeletion "upgradeDatabase" -> UpgradeDatabase "documentEdition" -> DocumentEdition "snapshotCreation" -> SnapshotCreation _ -> UnexpectedTaskType } } fn task_type_to_string(task_type: TaskType) -> String { case task_type { IndexCreation -> "indexCreation" IndexUpdate -> "indexUpdate" IndexDeletion -> "indexDeletion" IndexSwap -> "indexSwap" DocumentAdditionOrUpdate -> "documentAdditionOrUpdate" DocumentDeletion -> "documentDeletion" SettingsUpdate -> "settingsUpdate" DumpCreation -> "dumpCreation" TaskCancelation -> "taskCancelation" TaskDeletion -> "taskDeletion" UpgradeDatabase -> "upgradeDatabase" DocumentEdition -> "documentEdition" SnapshotCreation -> "snapshotCreation" UnexpectedTaskType -> "" } } /// A record describing the details of a specific task. pub type TaskDetails { DocumentAdditionOrUpdateDetails( received_documents: Int, indexed_documents: Option(Int), ) DocumentDeletionDetails( provided_ids: Int, original_filter: Option(String), deleted_documents: Option(Int), ) IndexCreationOrUpdateDetails(primary_key: Option(String)) IndexDeletionDetails(deleted_documents: Option(Int)) IndexSwapDetails(swaps: List(Swap)) // TODO: fully implement missing features // missing: synonyms, typoTolerance, pagination, faceting, filterable_attributes SettingsUpdateDetails( ranking_rules: Option(List(String)), distinct_attribute: Option(String), searchable_attributes: Option(List(String)), displayed_attributes: Option(List(String)), sortable_attributes: Option(List(String)), stop_words: Option(List(String)), ) DumpCreationDetails(dump_uid: Option(String)) TaskCancelationDetails( matched_tasks: Int, canceled_tasks: Option(Int), original_filter: String, ) TaskDeletionDetails( matched_tasks: Int, deleted_tasks: Option(Int), original_filter: String, ) } /// An error message that can accompany a failed task. pub type TaskError { TaskError(message: String, code: String, error_type: String, link: String) } pub type TasksParam { TasksByUIDs(List(Int)) TasksByBatchUIDs(List(Int)) TasksByStatuses(List(TaskStatus)) TasksByTypes(List(TaskType)) TasksByIndexUIDs(List(String)) TasksLimit(Int) TasksFromTaskUID(Int) TasksInReverse(Bool) TasksBeforeEnqueuedAt(timestamp.Timestamp) TasksAfterEnqueuedAt(timestamp.Timestamp) TasksBeforeStartedAt(timestamp.Timestamp) TasksAfterStartedAt(timestamp.Timestamp) TasksBeforeFinishedAt(timestamp.Timestamp) TasksAfterFinishedAt(timestamp.Timestamp) } fn tasks_param_to_tuple(param: TasksParam) -> #(String, String) { case param { TasksByUIDs(uids) -> { let uids = list.map(uids, int.to_string) #("uids", string.join(uids, ",")) } TasksByBatchUIDs(batch_uids) -> { let batch_uids = list.map(batch_uids, int.to_string) #("batchUids", string.join(batch_uids, ",")) } TasksByStatuses(statuses) -> { let statuses = list.map(statuses, task_status_to_string) #("statuses", string.join(statuses, ",")) } TasksByTypes(types) -> { let types = list.map(types, task_type_to_string) #("types", string.join(types, ",")) } TasksByIndexUIDs(index_uids) -> #("indexUids", string.join(index_uids, ",")) TasksLimit(limit) -> #("limit", int.to_string(limit)) TasksFromTaskUID(from_uid) -> #("from", int.to_string(from_uid)) TasksInReverse(reverse) -> #( "reverse", string.lowercase(bool.to_string(reverse)), ) TasksBeforeEnqueuedAt(before_enqueued_at) -> #( "beforeEnqueuedAt", timestamp.to_rfc3339(before_enqueued_at, duration.seconds(0)), ) TasksAfterEnqueuedAt(after_enqueued_at) -> #( "afterEnqueuedAt", timestamp.to_rfc3339(after_enqueued_at, duration.seconds(0)), ) TasksBeforeStartedAt(before_started_at) -> #( "beforeStartedAt", timestamp.to_rfc3339(before_started_at, duration.seconds(0)), ) TasksAfterStartedAt(after_started_at) -> #( "afterStartedAt", timestamp.to_rfc3339(after_started_at, duration.seconds(0)), ) TasksBeforeFinishedAt(before_finished_at) -> #( "beforeFinishedAt", timestamp.to_rfc3339(before_finished_at, duration.seconds(0)), ) TasksAfterFinishedAt(after_finished_at) -> #( "afterFinishedAt", timestamp.to_rfc3339(after_finished_at, duration.seconds(0)), ) } } pub fn get_tasks( client: Client, params: List(TasksParam), ) -> Operation(List(Task)) { let url = client.url <> "/tasks" let req = base_request(url, client.api_key) |> request.set_method(Get) |> request.set_query(list.map(params, tasks_param_to_tuple)) #(req, fn(resp: Response(String)) -> Result(List(Task), DeweyError) { use resp_body <- result.try(verify_response(resp)) let decoder = { use results <- decode.field("results", decode.list(task_decoder())) decode.success(results) } json.parse(resp_body, decoder) |> result.map_error(JSONDecodeError) }) } pub fn get_one_task(client: Client, task_uid: Int) -> Operation(Task) { let url = client.url <> "/tasks/" <> int.to_string(task_uid) let req = base_request(url, client.api_key) |> request.set_method(Get) #(req, fn(resp: Response(String)) -> Result(Task, DeweyError) { use resp_body <- result.try(verify_response(resp)) json.parse(resp_body, task_decoder()) |> result.map_error(JSONDecodeError) }) } pub fn cancel_tasks( client: Client, params: List(TasksParam), ) -> Operation(SummarizedTask) { let url = client.url <> "/tasks/cancel" let req = base_request(url, client.api_key) |> request.set_method(Post) |> request.set_query(list.map(params, tasks_param_to_tuple)) #(req, parse_summarized_task_response) } pub fn delete_tasks( client: Client, params: List(TasksParam), ) -> Operation(SummarizedTask) { let url = client.url <> "/tasks" let req = base_request(url, client.api_key) |> request.set_method(Delete) |> request.set_query(list.map(params, tasks_param_to_tuple)) #(req, parse_summarized_task_response) } pub fn delete_all_tasks(client: Client) -> Operation(SummarizedTask) { let url = client.url <> "/tasks" let req = base_request(url, client.api_key) |> request.set_method(Delete) |> request.set_query([#("statuses", "failed,canceled,succeeded")]) #(req, parse_summarized_task_response) } // ----- INDEXES ----- /// A collection of documents, much like a table in MySQL or a collection in MongoDB. /// /// Indexes are outlined further at: https://www.meilisearch.com/docs/learn/getting_started/indexes pub type Index { Index( uid: String, created_at: Timestamp, updated_at: Timestamp, primary_key: String, ) } /// Returns an Operation record for the Get All Indexes endpoint /// /// This endpoint is outlined at: https://www.meilisearch.com/docs/reference/api/indexes#list-all-indexes pub fn get_all_indexes(client: Client) -> Operation(List(Index)) { let url = client.url <> "/indexes" let req = base_request(url, client.api_key) |> request.set_method(Get) #(req, fn(resp: Response(String)) -> Result(List(Index), DeweyError) { use resp_body <- result.try(verify_response(resp)) let decoder = { use indexes <- decode.field("results", decode.list(index_decoder())) decode.success(indexes) } json.parse(resp_body, decoder) |> result.map_error(JSONDecodeError) }) } /// Returns an Operation record for the Get One Index endpoint /// /// This endpoint is outlined at: https://www.meilisearch.com/docs/reference/api/indexes#get-one-index pub fn get_one_index(client: Client, index_uid: String) -> Operation(Index) { let url = client.url <> "/indexes/" <> index_uid let req = base_request(url, client.api_key) |> request.set_method(Get) #(req, fn(resp: Response(String)) -> Result(Index, DeweyError) { use resp_body <- result.try(verify_response(resp)) json.parse(resp_body, index_decoder()) |> result.map_error(JSONDecodeError) }) } /// Returns an Operation record for the Create Index endpoint /// /// This endpoint is outlined at: https://www.meilisearch.com/docs/reference/api/indexes#create-an-index pub fn create_index( client: Client, index_uid: String, primary_key: Option(String), ) -> Operation(SummarizedTask) { let url = client.url <> "/indexes" let req_body = json.object([ #("uid", json.string(index_uid)), #("primaryKey", json.nullable(primary_key, json.string)), ]) |> json.to_string let req = base_request(url, client.api_key) |> request.set_method(Post) |> request.set_header("Content-Type", "application/json") |> request.set_body(req_body) #(req, parse_summarized_task_response) } /// Returns an Operation record for the Update Index endpoint /// /// This endpoint is outlined at: https://www.meilisearch.com/docs/reference/api/indexes#update-an-index pub fn update_index( client: Client, index_uid: String, new_primary_key: Option(String), ) -> Operation(SummarizedTask) { let url = client.url <> "/indexes/" <> index_uid let req_body = json.object([#("primaryKey", json.nullable(new_primary_key, json.string))]) |> json.to_string let req = base_request(url, client.api_key) |> request.set_method(Patch) |> request.set_header("Content-Type", "application/json") |> request.set_body(req_body) #(req, parse_summarized_task_response) } /// Returns an Operation record for the Delete Index endpoint. /// /// This endpoint is outlined at: https://www.meilisearch.com/docs/reference/api/indexes#delete-an-index pub fn delete_index( client: Client, index_uid: String, ) -> Operation(SummarizedTask) { let url = client.url <> "/indexes/" <> index_uid let req = base_request(url, client.api_key) |> request.set_method(Delete) #(req, parse_summarized_task_response) } /// Returns an Operation record for the Swap Indexes endpoint /// /// This function does not include the rename parameter, and therefore does not rename /// indexes when the second index does not exist. To rename indexes, use the rename_indexes /// function instead. /// /// This endpoint is outlined at: https://www.meilisearch.com/docs/reference/api/indexes#swap-indexes pub fn swap_indexes( client: Client, index_uids_to_swap: List(#(String, String)), ) -> Operation(SummarizedTask) { let url = client.url <> "/swap-indexes" let indexes_list = list.map(index_uids_to_swap, fn(tuple) { [tuple.0, tuple.1] }) let req_body = json.array(indexes_list, fn(indexes) { json.object([#("indexes", json.array(indexes, json.string))]) }) |> json.to_string let req = base_request(url, client.api_key) |> request.set_method(Post) |> request.set_header("Content-Type", "application/json") |> request.set_body(req_body) #(req, parse_summarized_task_response) } /// Returns an Operation record for the Swap Indexes endpoint, utilizing the /// rename parameter. /// /// This function includes the rename parameter within the request body, meaning that /// the first index_uid will be renamed to the second index_uid listed (assuming the second uid does not exist). /// /// This endpoint is outlined at: https://www.meilisearch.com/docs/reference/api/indexes#swap-indexes pub fn rename_indexes( client: Client, index_uids_to_swap: List(#(String, String)), ) -> Operation(SummarizedTask) { let url = client.url <> "/swap-indexes" let indexes_list = list.map(index_uids_to_swap, fn(tuple) { [tuple.0, tuple.1] }) let req_body = json.array(indexes_list, fn(indexes) { json.object([ #("indexes", json.array(indexes, json.string)), #("rename", json.bool(True)), ]) }) |> json.to_string let req = base_request(url, client.api_key) |> request.set_method(Post) |> request.set_header("Content-Type", "application/json") |> request.set_body(req_body) #(req, parse_summarized_task_response) } // ------ DOCUMENTS ----- /// A record containing the response from the Get Documents endpoint. /// /// This endpoint is outlined at: https://www.meilisearch.com/docs/reference/api/documents#get-documents-with-post pub type DocumentsResponse(document_type) { DocumentsResponse( results: List(document_type), offset: Int, limit: Int, total: Int, ) } /// A collection of options when using the Get Documents endpoint. /// /// These options are outlined at: https://www.meilisearch.com/docs/reference/api/documents#body pub type GetDocumentsOptions { GetDocumentsOptions( offset: Int, limit: Int, fields: Option(List(String)), filter: Option(String), retrieve_vectors: Bool, sort: Option(String), ids: Option(List(String)), ) } /// A function returning the default set of options for the Get Documents endpoint. /// /// These options are outlined at: https://www.meilisearch.com/docs/reference/api/documents#body pub fn default_get_documents_options() -> GetDocumentsOptions { GetDocumentsOptions( offset: 0, limit: 20, fields: None, filter: None, retrieve_vectors: False, sort: None, ids: None, ) } /// Returns an Operation record for the Get Documents endpoint. /// /// This endpoint is outlined at: https://www.meilisearch.com/docs/reference/api/documents#get-documents-with-post pub fn get_documents( client: Client, index_uid: String, options: GetDocumentsOptions, documents_decoder: decode.Decoder(document_type), ) -> Operation(DocumentsResponse(document_type)) { let url = client.url <> "/indexes/" <> index_uid <> "/documents/fetch" let json_string_array = json.array(_, json.string) let req_body = json.object([ #("offset", json.int(options.offset)), #("limit", json.int(options.limit)), #("fields", json.nullable(options.fields, json_string_array)), #("filter", json.nullable(options.filter, json.string)), #("retrieveVectors", json.bool(options.retrieve_vectors)), #("sort", json.nullable(options.sort, json.string)), #("ids", json.nullable(options.ids, json_string_array)), ]) |> json.to_string let req = base_request(url, client.api_key) |> request.set_method(Post) |> request.set_header("Content-Type", "application/json") |> request.set_body(req_body) #(req, fn(resp: Response(String)) -> Result( DocumentsResponse(document_type), DeweyError, ) { use resp_body <- result.try(verify_response(resp)) let decoder = { use results <- decode.field("results", decode.list(documents_decoder)) use offset <- decode.field("offset", decode.int) use limit <- decode.field("limit", decode.int) use total <- decode.field("total", decode.int) decode.success(DocumentsResponse(results:, offset:, limit:, total:)) } json.parse(resp_body, decoder) |> result.map_error(JSONDecodeError) }) } /// Returns an Operation record for the Get One Document endpoint. /// /// This endpoint is outlined at: https://www.meilisearch.com/docs/reference/api/documents#get-one-document pub fn get_one_document( client: Client, index_uid: String, document_id: String, document_decoder: decode.Decoder(document_type), ) -> Operation(document_type) { let url = client.url <> "/indexes/" <> index_uid <> "/documents/" <> document_id let req = base_request(url, client.api_key) |> request.set_method(Get) #(req, fn(resp: Response(String)) -> Result(document_type, DeweyError) { use resp_body <- result.try(verify_response(resp)) json.parse(resp_body, document_decoder) |> result.map_error(JSONDecodeError) }) } /// Returns an Operation record for the Add or Replace Documents endpoint. /// /// This endpoint is outlined at: https://www.meilisearch.com/docs/reference/api/documents#add-or-replace-documents pub fn add_or_replace_documents( client: Client, index_uid: String, documents: List(document_type), document_encoder: fn(document_type) -> json.Json, ) -> Operation(SummarizedTask) { let url = client.url <> "/indexes/" <> index_uid <> "/documents" let req_body = json.array(documents, document_encoder) |> json.to_string let req = base_request(url, client.api_key) |> request.set_method(Post) |> request.set_header("Content-Type", "application/json") |> request.set_body(req_body) #(req, parse_summarized_task_response) } /// Returns an Operation record for the Add or Update Documents endpoint. /// /// This endpoint is outlined at: https://www.meilisearch.com/docs/reference/api/documents#add-or-update-documents pub fn add_or_update_documents( client: Client, index_uid: String, documents: List(document_type), document_encoder: fn(document_type) -> json.Json, ) -> Operation(SummarizedTask) { let url = client.url <> "/indexes/" <> index_uid <> "/documents" let req_body = json.array(documents, document_encoder) |> json.to_string let req = base_request(url, client.api_key) |> request.set_method(Put) |> request.set_header("Content-Type", "application/json") |> request.set_body(req_body) #(req, parse_summarized_task_response) } /// Returns an Operation record for the Delete All Documents endpoint. /// /// This endpoint is outlined at: https://www.meilisearch.com/docs/reference/api/documents#delete-all-documents pub fn delete_all_documents( client: Client, index_uid: String, ) -> Operation(SummarizedTask) { let url = client.url <> "/indexes/" <> index_uid <> "/documents" let req = base_request(url, client.api_key) |> request.set_method(Delete) #(req, parse_summarized_task_response) } /// Returns an Operation record for the Delete One Document endpoint. /// /// This endpoint is outlined at: https://www.meilisearch.com/docs/reference/api/documents#delete-one-document pub fn delete_one_document( client: Client, index_uid: String, document_id: String, ) -> Operation(SummarizedTask) { let url = client.url <> "/indexes/" <> index_uid <> "/documents/" <> document_id let req = base_request(url, client.api_key) |> request.set_method(Delete) #(req, parse_summarized_task_response) } /// Returns an Operation record for the Delete Documents by Filter endpoint. /// /// This endpoint is outlined at: https://www.meilisearch.com/docs/reference/api/documents#delete-documents-by-filter pub fn delete_documents_by_filter( client: Client, index_uid: String, filter: String, ) -> Operation(SummarizedTask) { let url = client.url <> "/indexes/" <> index_uid <> "/documents/delete" let req_body = json.object([#("filter", json.string(filter))]) |> json.to_string let req = base_request(url, client.api_key) |> request.set_method(Post) |> request.set_header("Content-Type", "application/json") |> request.set_body(req_body) #(req, parse_summarized_task_response) } /// Returns an Operation record for the Delete Documents by Batch endpoint. /// /// This endpoint is outlined at: https://www.meilisearch.com/docs/reference/api/documents#delete-documents-by-batch pub fn delete_documents_by_batch( client: Client, index_uid: String, batch: List(String), ) -> Operation(SummarizedTask) { let url = client.url <> "/indexes/" <> index_uid <> "/documents/delete-batch" let req_body = json.array(batch, json.string) |> json.to_string let req = base_request(url, client.api_key) |> request.set_method(Post) |> request.set_header("Content-Type", "application/json") |> request.set_body(req_body) #(req, parse_summarized_task_response) } // ----- SEARCH ----- /// Collection of Search Parameter records for adjusting the search functionality. /// /// Keep in mind that some of these parameters change the structure of the documents returned. /// You will have to adjust your document decoder function to account for the changes caused. /// For this reason, it is important to read through the search API documentation to understand /// how each parameter can affect the document return type. /// /// For more details visit this link: https://www.meilisearch.com/docs/reference/api/search#search-parameters pub type SearchParam { SearchOffset(offset: Int) SearchLimit(limit: Int) SearchHitsPerPage(hits: Int) SearchPage(page_num: Int) SearchFilter(filter: String) // TODO: SearchFacets(facets: String) SearchDistinctAttribute(attribute: String) SearchAttributesToRetrieve(attributes: List(String)) SearchAttributesToCrop(attributes: List(String)) SearchCropLength(length: Int) SearchCropMarker(marker: String) SearchAttributesToHighlight(attributes: List(String)) SearchHighlightPreTag(pre_tag: String) SearchHighlighPostTag(post_tag: String) SearchShowMatchesPosition(show_matches_position: Bool) SearchSortAttributes(attributes: List(String)) SearchMatchingStrategy(strategy: String) SearchShowRankingScore(show_ranking_score: Bool) SearchShowRankingScoreDetails(show_ranking_score_details: Bool) SearchRankingScoreThreshold(threshold: Float) SearchAttributesToSearchOn(attributes: List(String)) // TODO: SearchHybrid SearchVector(vector: List(Float)) SearchRetrieveVectors(retrieve_vectors: Bool) SearchLocales(locales: List(String)) // TODO: SearchMedia // TODO: SearchPersonalize } fn search_param_to_json_field(param: SearchParam) -> #(String, json.Json) { case param { SearchOffset(offset:) -> #("offset", json.int(offset)) SearchLimit(limit:) -> #("limit", json.int(limit)) SearchHitsPerPage(hits:) -> #("hitsPerPage", json.int(hits)) SearchPage(page_num:) -> #("page", json.int(page_num)) SearchFilter(filter:) -> #("filter", json.string(filter)) SearchDistinctAttribute(attribute:) -> #("distinct", json.string(attribute)) SearchAttributesToRetrieve(attributes:) -> #( "attributesToRetrieve", json.array(attributes, json.string), ) SearchAttributesToCrop(attributes:) -> #( "attributesToCrop", json.array(attributes, json.string), ) SearchCropLength(length:) -> #("cropLength", json.int(length)) SearchCropMarker(marker:) -> #("cropMarker", json.string(marker)) SearchAttributesToHighlight(attributes:) -> #( "attributesToHighlight", json.array(attributes, json.string), ) SearchHighlightPreTag(pre_tag:) -> #( "highlightPreTag", json.string(pre_tag), ) SearchHighlighPostTag(post_tag:) -> #( "highlightPostTag", json.string(post_tag), ) SearchShowMatchesPosition(show_matches_position:) -> #( "showMatchesPosition", json.bool(show_matches_position), ) SearchSortAttributes(attributes:) -> #( "sort", json.array(attributes, json.string), ) SearchMatchingStrategy(strategy:) -> #( "matchingStrategy", json.string(strategy), ) SearchShowRankingScore(show_ranking_score:) -> #( "showRankingScore", json.bool(show_ranking_score), ) SearchShowRankingScoreDetails(show_ranking_score_details:) -> #( "showRankingScoreDetails", json.bool(show_ranking_score_details), ) SearchRankingScoreThreshold(threshold:) -> #( "rankingScoreThreshold", json.float(threshold), ) SearchAttributesToSearchOn(attributes:) -> #( "attributesToSearchOn", json.array(attributes, json.string), ) SearchVector(vector:) -> #("vector", json.array(vector, json.float)) SearchRetrieveVectors(retrieve_vectors:) -> #( "retrieveVectors", json.bool(retrieve_vectors), ) SearchLocales(locales:) -> #("locales", json.array(locales, json.string)) } } pub type SearchResponse(document_type) { SearchResponse( hits: List(document_type), offset: Int, limit: Int, estimated_total_hits: Option(Int), total_hits: Option(Int), total_pages: Option(Int), hits_per_page: Option(Int), processing_time_ms: Int, query: String, request_uid: String, ) } /// Returns an Operation record for the Search endpoint /// /// This endpoint is outlined at: https://www.meilisearch.com/docs/reference/api/search#search-in-an-index-with-post pub fn search( client: Client, index_uid: String, query: String, document_decoder: decode.Decoder(document_type), params: List(SearchParam), ) -> Operation(SearchResponse(document_type)) { let url = client.url <> "/indexes/" <> index_uid <> "/search" let param_fields = [ #("q", json.string(query)), ..list.map(params, search_param_to_json_field) ] let req_body = json.object(param_fields) |> json.to_string let req = base_request(url, client.api_key) |> request.set_method(Post) |> request.set_header("Content-Type", "application/json") |> request.set_body(req_body) #(req, fn(resp: Response(String)) -> Result( SearchResponse(document_type), DeweyError, ) { use resp_body <- result.try(verify_response(resp)) let decoder = { use hits <- decode.field("hits", decode.list(document_decoder)) use offset <- decode.field("offset", decode.int) use limit <- decode.field("limit", decode.int) use estimated_total_hits <- decode.optional_field( "estimatedTotalHits", None, decode.optional(decode.int), ) use total_hits <- decode.optional_field( "totalHits", None, decode.optional(decode.int), ) use total_pages <- decode.optional_field( "totalPages", None, decode.optional(decode.int), ) use hits_per_page <- decode.optional_field( "hitsPerPage", None, decode.optional(decode.int), ) use processing_time_ms <- decode.field("processingTimeMs", decode.int) use query <- decode.field("query", decode.string) use request_uid <- decode.field("requestUid", decode.string) decode.success(SearchResponse( hits:, offset:, limit:, estimated_total_hits:, total_hits:, total_pages:, hits_per_page:, processing_time_ms:, query:, request_uid:, )) } json.parse(resp_body, decoder) |> result.map_error(JSONDecodeError) }) } // ----- DECODERS ----- fn summarized_task_decoder() -> decode.Decoder(SummarizedTask) { use task_uid <- decode.field("taskUid", decode.int) use index_uid <- decode.field("indexUid", decode.optional(decode.string)) use status_str <- decode.field("status", decode.string) use type_str <- decode.field("type", decode.string) use enqueued_at_str <- decode.field("enqueuedAt", decode.string) let status = string_to_task_status(status_str) let task_type = string_to_task_type(type_str) // This should never fail, as the API should always return an RFC3339 string // https://www.meilisearch.com/docs/reference/api/tasks#enqueuedat let enqueued_at = timestamp.parse_rfc3339(enqueued_at_str) |> result.unwrap(timestamp.from_unix_seconds(0)) decode.success(SummarizedTask( task_uid:, index_uid:, status:, task_type:, enqueued_at:, )) } fn index_decoder() -> decode.Decoder(Index) { use uid <- decode.field("uid", decode.string) use created_at_string <- decode.field("createdAt", decode.string) use updated_at_string <- decode.field("updatedAt", decode.string) use primary_key <- decode.field("primaryKey", decode.string) let created_at = timestamp.parse_rfc3339(created_at_string) |> result.unwrap(timestamp.from_unix_seconds(0)) let updated_at = timestamp.parse_rfc3339(updated_at_string) |> result.unwrap(timestamp.from_unix_seconds(0)) decode.success(Index(uid:, created_at:, updated_at:, primary_key:)) } fn task_decoder() -> decode.Decoder(Task) { use uid <- decode.field("uid", decode.int) use batch_uid <- decode.field("batchUid", decode.int) use index_uid <- decode.field("indexUid", decode.optional(decode.string)) use status_string <- decode.field("status", decode.string) use task_type_string <- decode.field("type", decode.string) use canceled_by <- decode.field("canceledBy", decode.optional(decode.int)) use details <- decode.field("details", task_details_decoder()) use error <- decode.field("error", decode.optional(task_error_decoder())) use enqueued_at_string <- decode.field("enqueuedAt", decode.string) use started_at_string <- decode.field( "startedAt", decode.optional(decode.string), ) use finished_at_string <- decode.field( "finishedAt", decode.optional(decode.string), ) let status = string_to_task_status(status_string) let task_type = string_to_task_type(task_type_string) let default_time = timestamp.from_unix_seconds(0) let enqueued_at = timestamp.parse_rfc3339(enqueued_at_string) |> result.unwrap(default_time) let started_at = option.map(started_at_string, fn(str) { timestamp.parse_rfc3339(str) |> result.unwrap(default_time) }) let finished_at = option.map(finished_at_string, fn(str) { timestamp.parse_rfc3339(str) |> result.unwrap(default_time) }) decode.success(Task( uid:, batch_uid:, index_uid:, status:, task_type:, canceled_by:, details:, error:, enqueued_at:, started_at:, finished_at:, )) } pub type Swap { Swap(swapped_indexes: #(String, String), rename: Bool) } fn task_details_decoder() -> decode.Decoder(Option(TaskDetails)) { let document_addition_or_update = { use received_documents <- decode.field("receivedDocuments", decode.int) use indexed_documents <- decode.field( "indexedDocuments", decode.optional(decode.int), ) decode.success(DocumentAdditionOrUpdateDetails( received_documents:, indexed_documents:, )) } let document_deletion = { use provided_ids <- decode.field("providedIds", decode.int) use original_filter <- decode.field( "originalFilter", decode.optional(decode.string), ) use deleted_documents <- decode.field( "deletedDocuments", decode.optional(decode.int), ) decode.success(DocumentDeletionDetails( provided_ids:, original_filter:, deleted_documents:, )) } let index_creation_or_update = { use primary_key <- decode.field( "primaryKey", decode.optional(decode.string), ) decode.success(IndexCreationOrUpdateDetails(primary_key:)) } let index_deletion = { use deleted_documents <- decode.field( "deletedDocuments", decode.optional(decode.int), ) decode.success(IndexDeletionDetails(deleted_documents:)) } let index_swap = { let swap_decoder = { use indexes <- decode.field("indexes", decode.list(decode.string)) use rename <- decode.field("rename", decode.bool) case indexes { [first_index, second_index] -> { decode.success(Swap( swapped_indexes: #(first_index, second_index), rename:, )) } _ -> decode.failure(Swap(#("", ""), False), "Swap") } } use swaps <- decode.field("swaps", decode.list(swap_decoder)) decode.success(IndexSwapDetails(swaps:)) } let optional_string_list = decode.optional(decode.list(decode.string)) let settings_update = { use ranking_rules <- decode.optional_field( "rankingRules", None, optional_string_list, ) use distinct_attribute <- decode.optional_field( "distinctAttribute", None, decode.optional(decode.string), ) use searchable_attributes <- decode.optional_field( "searchableAttributes", None, optional_string_list, ) use displayed_attributes <- decode.optional_field( "displayedAttributes", None, optional_string_list, ) use sortable_attributes <- decode.optional_field( "sortableAttributes", None, optional_string_list, ) use stop_words <- decode.optional_field( "stopWords", None, optional_string_list, ) decode.success(SettingsUpdateDetails( ranking_rules:, distinct_attribute:, searchable_attributes:, displayed_attributes:, sortable_attributes:, stop_words:, )) } let dump_creation = { use dump_uid <- decode.field("dumpUid", decode.optional(decode.string)) decode.success(DumpCreationDetails(dump_uid:)) } let task_cancelation = { use matched_tasks <- decode.field("matchedTasks", decode.int) use canceled_tasks <- decode.field( "canceledTasks", decode.optional(decode.int), ) use original_filter <- decode.field("originalFilter", decode.string) decode.success(TaskCancelationDetails( matched_tasks:, canceled_tasks:, original_filter:, )) } let task_deletion = { use matched_tasks <- decode.field("matchedTasks", decode.int) use deleted_tasks <- decode.field( "deletedTasks", decode.optional(decode.int), ) use original_filter <- decode.field("originalFilter", decode.string) decode.success(TaskDeletionDetails( matched_tasks:, deleted_tasks:, original_filter:, )) } decode.optional( decode.one_of(settings_update, [ document_deletion, index_creation_or_update, index_deletion, index_swap, document_addition_or_update, dump_creation, task_cancelation, task_deletion, ]), ) } fn task_error_decoder() -> decode.Decoder(TaskError) { use message <- decode.field("message", decode.string) use code <- decode.field("code", decode.string) use error_type <- decode.field("type", decode.string) use link <- decode.field("link", decode.string) decode.success(TaskError(message:, code:, error_type:, link:)) } // ----- PRIVATE HELPER TYPES AND FNS ----- fn base_request(url: String, api_key: String) -> Request(String) { let assert Ok(base_req) = request.to(url) request.set_header(base_req, "Authorization", "Bearer " <> api_key) } const acceptable_response_codes = [200, 201, 202, 204, 205] fn verify_response(resp: Response(String)) -> Result(String, DeweyError) { case list.contains(acceptable_response_codes, resp.status) { True -> Ok(resp.body) False -> { let decoder = { use message <- decode.field("message", decode.string) use code <- decode.field("code", decode.string) use error_type <- decode.field("type", decode.string) use link <- decode.field("link", decode.string) decode.success(APIError(message:, code:, error_type:, link:)) } case json.parse(resp.body, decoder) { Ok(api_error) -> Error(api_error) Error(_) -> Error(UnexpectedAPIError) } } } }