├── .code-samples.meilisearch.yaml ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── dependabot.yml ├── release-draft-template.yml ├── scripts │ └── check-release.sh └── workflows │ ├── pre-release-tests.yml │ ├── release-check.yml │ ├── release-drafter.yml │ └── tests.yml ├── .gitignore ├── .yamllint.yml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── bors.toml ├── client.go ├── client_bench_test.go ├── client_test.go ├── codecov.yml ├── doc.go ├── docker-compose.yml ├── encoding.go ├── encoding_bench_test.go ├── encoding_test.go ├── error.go ├── error_test.go ├── example_test.go ├── features.go ├── features_test.go ├── go.mod ├── go.sum ├── helper.go ├── helper_test.go ├── index.go ├── index_document.go ├── index_document_test.go ├── index_interface.go ├── index_search.go ├── index_search_test.go ├── index_settings.go ├── index_settings_test.go ├── index_task.go ├── index_task_test.go ├── index_test.go ├── main_test.go ├── makefile ├── meilisearch.go ├── meilisearch_interface.go ├── meilisearch_test.go ├── options.go ├── options_test.go ├── testdata └── movies.json ├── types.go ├── types_easyjson.go ├── types_test.go ├── version.go └── version_test.go /.editorconfig: -------------------------------------------------------------------------------- 1 | # see http://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | end_of_line = lf 9 | charset = utf-8 10 | tab_width = 4 11 | indent_style = space 12 | 13 | [{Makefile,go.mod,go.sum,*.go,.gitmodules}] 14 | indent_style = tab 15 | indent_size = 8 16 | 17 | [*.{yaml,yml}] 18 | indent_size = 2 19 | indent_style = space 20 | 21 | [*.md] 22 | indent_style = space 23 | max_line_length = 80 24 | indent_size = 4 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 🐞 3 | about: Create a report to help us improve. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | **Description** 12 | Description of what the bug is about. 13 | 14 | **Expected behavior** 15 | What you expected to happen. 16 | 17 | **Current behavior** 18 | What happened. 19 | 20 | **Screenshots or Logs** 21 | If applicable, add screenshots or logs to help explain your problem. 22 | 23 | **Environment (please complete the following information):** 24 | - OS: [e.g. Debian GNU/Linux] 25 | - Meilisearch version: [e.g. v.0.20.0] 26 | - meilisearch-go version: [e.g v0.14.1] 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Support questions & other 4 | url: https://discord.meilisearch.com/ 5 | about: Support is not handled here but on our Discord 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request & Enhancement 💡 3 | about: Suggest a new idea for the project. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | **Description** 12 | Brief explanation of the feature. 13 | 14 | **Basic example** 15 | If the proposal involves something new or a change, include a basic example. How would you use the feature? In which context? 16 | 17 | **Other** 18 | Any other things you want to add. 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'github-actions' 4 | directory: '/' 5 | schedule: 6 | interval: "monthly" 7 | labels: 8 | - 'skip-changelog' 9 | - 'dependencies' 10 | rebase-strategy: disabled 11 | 12 | - package-ecosystem: gomod 13 | directory: '/' 14 | schedule: 15 | interval: "monthly" 16 | time: '04:00' 17 | open-pull-requests-limit: 10 18 | labels: 19 | - skip-changelog 20 | - dependencies 21 | rebase-strategy: disabled 22 | -------------------------------------------------------------------------------- /.github/release-draft-template.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION 🐹' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | exclude-labels: 4 | - 'skip-changelog' 5 | version-resolver: 6 | minor: 7 | labels: 8 | - 'breaking-change' 9 | default: patch 10 | categories: 11 | - title: '⚠️ Breaking changes' 12 | label: 'breaking-change' 13 | - title: '🚀 Enhancements' 14 | label: 'enhancement' 15 | - title: '🐛 Bug Fixes' 16 | label: 'bug' 17 | - title: '🔒 Security' 18 | label: 'security' 19 | - title: '⚙️ Maintenance/misc' 20 | label: 21 | - 'maintenance' 22 | - 'documentation' 23 | template: | 24 | $CHANGES 25 | 26 | Thanks again to $CONTRIBUTORS! 🎉 27 | no-changes-template: 'Changes are coming soon 😎' 28 | sort-direction: 'ascending' 29 | replacers: 30 | - search: '/(?:and )?@dependabot-preview(?:\[bot\])?,?/g' 31 | replace: '' 32 | - search: '/(?:and )?@dependabot(?:\[bot\])?,?/g' 33 | replace: '' 34 | - search: '/(?:and )?@bors(?:\[bot\])?,?/g' 35 | replace: '' 36 | - search: '/(?:and )?@meili-bot,?/g' 37 | replace: '' 38 | - search: '/(?:and )?@meili-bors(?:\[bot\])?,?/g' 39 | replace: '' 40 | -------------------------------------------------------------------------------- /.github/scripts/check-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Checking if current tag matches the package version 4 | current_tag=$(echo $GITHUB_REF | cut -d '/' -f 3 | sed -r 's/^v//') 5 | 6 | file1='version.go' 7 | file_tag1=$(grep 'const VERSION' -A 0 $file1 | cut -d '=' -f2 | tr -d '"' | tr -d ' ') 8 | 9 | if [ "$current_tag" != "$file_tag1" ]; then 10 | echo "Error: the current tag does not match the version in package file(s)." 11 | echo "$file1: found $file_tag1 - expected $current_tag" 12 | exit 1 13 | fi 14 | 15 | echo 'OK' 16 | exit 0 17 | -------------------------------------------------------------------------------- /.github/workflows/pre-release-tests.yml: -------------------------------------------------------------------------------- 1 | # Testing the code base against the Meilisearch pre-releases 2 | name: Pre-Release Tests 3 | 4 | # Will only run for PRs and pushes to bump-meilisearch-v* 5 | on: 6 | push: 7 | branches: bump-meilisearch-v* 8 | pull_request: 9 | branches: bump-meilisearch-v* 10 | 11 | jobs: 12 | integration_tests: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | # Current go.mod version and latest stable go version 17 | go: [1.16, 1.17] 18 | include: 19 | - go: 1.16 20 | tag: current 21 | - go: 1.17 22 | tag: latest 23 | 24 | name: integration-tests-against-rc (go ${{ matrix.tag }} version) 25 | steps: 26 | - name: Set up Go ${{ matrix.go }} 27 | uses: actions/setup-go@v5 28 | with: 29 | go-version: ${{ matrix.go }} 30 | - name: Check out code into the Go module directory 31 | uses: actions/checkout@v4 32 | - name: Get dependencies 33 | run: | 34 | go get -v -t -d ./... 35 | if [ -f Gopkg.toml ]; then 36 | curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh 37 | dep ensure 38 | fi 39 | - name: Get the latest Meilisearch RC 40 | run: echo "MEILISEARCH_VERSION=$(curl https://raw.githubusercontent.com/meilisearch/integration-guides/main/scripts/get-latest-meilisearch-rc.sh | bash)" >> $GITHUB_ENV 41 | - name: Meilisearch (${{ env.MEILISEARCH_VERSION }}) setup with Docker 42 | run: docker run -d -p 7700:7700 getmeili/meilisearch:${{ env.MEILISEARCH_VERSION }} meilisearch --master-key=masterKey --no-analytics 43 | - name: Run integration tests 44 | run: go test -v ./... 45 | -------------------------------------------------------------------------------- /.github/workflows/release-check.yml: -------------------------------------------------------------------------------- 1 | name: Validate release version 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+' 7 | 8 | jobs: 9 | publish: 10 | name: Validate release version 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Check release validity 15 | run: sh .github/scripts/check-release.sh 16 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: release-drafter/release-drafter@v6 13 | with: 14 | config-name: release-draft-template.yml 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.RELEASE_DRAFTER_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | # trying and staging branches are for BORS config 7 | branches: 8 | - trying 9 | - staging 10 | - main 11 | 12 | jobs: 13 | linter: 14 | name: linter 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/setup-go@v5 18 | with: 19 | go-version: 1.17 20 | - uses: actions/checkout@v4 21 | - name: golangci-lint 22 | uses: golangci/golangci-lint-action@v6 23 | with: 24 | version: v1.45.2 25 | - name: Run go vet 26 | run: go vet 27 | - name: Yaml linter 28 | uses: ibiqlik/action-yamllint@v3 29 | with: 30 | config_file: .yamllint.yml 31 | 32 | integration_tests: 33 | runs-on: ubuntu-latest 34 | # Will not run if the event is a PR to bump-meilisearch-v* (so a pre-release PR) 35 | # Will still run for each push to bump-meilisearch-v* 36 | if: github.event_name != 'pull_request' || !startsWith(github.base_ref, 'bump-meilisearch-v') 37 | strategy: 38 | matrix: 39 | # Current go.mod version and latest stable go version 40 | go: [1.16, 1.17] 41 | include: 42 | - go: 1.16 43 | tag: current 44 | - go: 1.17 45 | tag: latest 46 | 47 | name: integration-tests (go ${{ matrix.tag }} version) 48 | steps: 49 | - name: Set up Go ${{ matrix.go }} 50 | uses: actions/setup-go@v5 51 | with: 52 | go-version: ${{ matrix.go }} 53 | - name: Check out code into the Go module directory 54 | uses: actions/checkout@v4 55 | - name: Get dependencies 56 | run: | 57 | go get -v -t -d ./... 58 | if [ -f Gopkg.toml ]; then 59 | curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh 60 | dep ensure 61 | fi 62 | - name: Meilisearch setup (latest version) with Docker 63 | run: docker run -d -p 7700:7700 getmeili/meilisearch:latest meilisearch --master-key=masterKey --no-analytics 64 | - name: Run integration tests 65 | run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... 66 | - name: Upload coverage report 67 | uses: codecov/codecov-action@v5 68 | env: 69 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | meilisearch-go.iml 3 | vendor 4 | -------------------------------------------------------------------------------- /.yamllint.yml: -------------------------------------------------------------------------------- 1 | extends: default 2 | ignore: | 3 | node_modules 4 | 5 | rules: 6 | line-length: disable 7 | document-start: disable 8 | brackets: disable 9 | truthy: disable 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First of all, thank you for contributing to Meilisearch! The goal of this document is to provide everything you need to know in order to contribute to Meilisearch and its different integrations. 4 | 5 | - [Assumptions](#assumptions) 6 | - [How to Contribute](#how-to-contribute) 7 | - [Development Workflow](#development-workflow) 8 | - [Git Guidelines](#git-guidelines) 9 | - [Release Process (for internal team only)](#release-process-for-internal-team-only) 10 | 11 | ## Assumptions 12 | 13 | 1. **You're familiar with [GitHub](https://github.com) and the [Pull Request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests)(PR) workflow.** 14 | 2. **You've read the Meilisearch [documentation](https://www.meilisearch.com/docs) and the [README](/README.md).** 15 | 3. **You know about the [Meilisearch community](https://discord.meilisearch.com). Please use this for help.** 16 | 17 | ## How to Contribute 18 | 19 | 1. Make sure that the contribution you want to make is explained or detailed in a GitHub issue! Find an [existing issue](https://github.com/meilisearch/meilisearch-go/issues/) or [open a new one](https://github.com/meilisearch/meilisearch-go/issues/new). 20 | 2. Once done, [fork the meilisearch-go repository](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) in your own GitHub account. Ask a maintainer if you want your issue to be checked before making a PR. 21 | 3. [Create a new Git branch](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-and-deleting-branches-within-your-repository). 22 | 4. Review the [Development Workflow](#development-workflow) section that describes the steps to maintain the repository. 23 | 5. Make the changes on your branch. 24 | 6. [Submit the branch as a PR](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) pointing to the `main` branch of the main meilisearch-go repository. A maintainer should comment and/or review your Pull Request within a few days. Although depending on the circumstances, it may take longer.
25 | We do not enforce a naming convention for the PRs, but **please use something descriptive of your changes**, having in mind that the title of your PR will be automatically added to the next [release changelog](https://github.com/meilisearch/meilisearch-go/releases/). 26 | 27 | ## Development Workflow 28 | 29 | ### Requirements 30 | 31 | - `docker`: for running integration tests and linting 32 | - `easyjson`: for generating type marshalers and unmarshalers 33 | - Retrieve SDK dependencies 34 | 35 | You can install these tools and dependencies by using the `make requirements` command. 36 | 37 | ### Test 38 | 39 | You can run integration test and linter check by command: 40 | 41 | ```shell 42 | make test 43 | ``` 44 | 45 | ### EasyJson 46 | 47 | [`easyjson`](https://github.com/mailru/easyjson) is a package used for optimizing marshal/unmarshal Go structs to/from JSON. 48 | It takes the `types.go` file as an input, and auto-generates `types_easyjson.go` with optimized 49 | marshalling and unmarshalling methods for this SDK. 50 | 51 | If for any reason `types.go` is modified, this file should be regenerated by running easyjson again. 52 | 53 | ```shell 54 | make easyjson 55 | ``` 56 | 57 | ## Git Guidelines 58 | 59 | ### Git Branches 60 | 61 | All changes must be made in a branch and submitted as PR. 62 | We do not enforce any branch naming style, but please use something descriptive of your changes. 63 | 64 | ### Git Commits 65 | 66 | As minimal requirements, your commit message should: 67 | - be capitalized 68 | - not finish by a dot or any other punctuation character (!,?) 69 | - start with a verb so that we can read your commit message this way: "This commit will ...", where "..." is the commit message. 70 | e.g.: "Fix the home page button" or "Add more tests for create_index method" 71 | 72 | We don't follow any other convention, but if you want to use one, we recommend [this one](https://chris.beams.io/posts/git-commit/). 73 | 74 | ### GitHub Pull Requests 75 | 76 | Some notes on GitHub PRs: 77 | 78 | - [Convert your PR as a draft](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/changing-the-stage-of-a-pull-request) if your changes are a work in progress: no one will review it until you pass your PR as ready for review.
79 | The draft PR can be very useful if you want to show that you are working on something and make your work visible. 80 | - The branch related to the PR must be **up-to-date with `main`** before merging. Fortunately, this project [integrates a bot](https://github.com/meilisearch/integration-guides/blob/main/resources/bors.md) to automatically enforce this requirement without the PR author having to do it manually. 81 | - All PRs must be reviewed and approved by at least one maintainer. 82 | - The PR title should be accurate and descriptive of the changes. The title of the PR will be indeed automatically added to the next [release changelogs](https://github.com/meilisearch/meilisearch-go/releases/). 83 | 84 | ## Release Process (for the internal team only) 85 | 86 | Meilisearch tools follow the [Semantic Versioning Convention](https://semver.org/). 87 | 88 | ### Automation to Rebase and Merge the PRs 89 | 90 | This project integrates a bot that helps us manage pull requests merging.
91 | _[Read more about this](https://github.com/meilisearch/integration-guides/blob/main/resources/bors.md)._ 92 | 93 | ### Automated Changelogs 94 | 95 | This project integrates a tool to create automated changelogs.
96 | _[Read more about this](https://github.com/meilisearch/integration-guides/blob/main/resources/release-drafter.md)._ 97 | 98 | ### How to Publish the Release 99 | 100 | ⚠️ Before doing anything, make sure you got through the guide about [Releasing an Integration](https://github.com/meilisearch/integration-guides/blob/main/resources/integration-release.md). 101 | 102 | Make a PR updating the version with the new one on this file: 103 | - [`version.go`](/version.go): 104 | ```go 105 | const VERSION = "X.X.X" 106 | ``` 107 | 108 | Once the changes are merged on `main`, you can publish the current draft release via the [GitHub interface](https://github.com/meilisearch/meilisearch-go/releases): on this page, click on `Edit` (related to the draft release) > update the description (be sure you apply [these recommendations](https://github.com/meilisearch/integration-guides/blob/main/resources/integration-release.md#writting-the-release-description)) > when you are ready, click on `Publish release`. 109 | 110 |
111 | 112 | Thank you again for reading this through. We can not wait to begin to work with you if you make your way through this contributing guide ❤️ 113 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.17.11-buster 2 | 3 | WORKDIR /home/package 4 | 5 | COPY go.mod . 6 | COPY go.sum . 7 | 8 | COPY --from=golangci/golangci-lint:v1.42.0 /usr/bin/golangci-lint /usr/local/bin/golangci-lint 9 | 10 | RUN go mod download 11 | RUN go mod verify 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2025 Meili SAS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Meilisearch-Go 3 |

4 | 5 |

Meilisearch Go

6 | 7 |

8 | Meilisearch | 9 | Meilisearch Cloud | 10 | Documentation | 11 | Discord | 12 | Roadmap | 13 | Website | 14 | FAQ 15 |

16 | 17 |

18 | GitHub Workflow Status 19 | Test 20 | CodeCov 21 | Go Reference 22 | License 23 | Bors enabled 24 |

25 | 26 |

⚡ The Meilisearch API client written for Golang

27 | 28 | **Meilisearch Go** is the Meilisearch API client for Go developers. 29 | 30 | **Meilisearch** is an open-source search engine. [Learn more about Meilisearch.](https://github.com/meilisearch/Meilisearch) 31 | 32 | ## Table of Contents 33 | 34 | - [📖 Documentation](#-documentation) 35 | - [🔧 Installation](#-installation) 36 | - [🚀 Getting started](#-getting-started) 37 | - [Add documents](#add-documents) 38 | - [Basic search](#basic-search) 39 | - [Custom search](#custom-search) 40 | - [Custom search with filter](#custom-search-with-filters) 41 | - [Customize client](#customize-client) 42 | - [🤖 Compatibility with Meilisearch](#-compatibility-with-meilisearch) 43 | - [⚡️ Benchmark performance](#-benchmark-performance) 44 | - [💡 Learn more](#-learn-more) 45 | - [⚙️ Contributing](#️-contributing) 46 | 47 | ## 📖 Documentation 48 | 49 | This readme contains all the documentation you need to start using this Meilisearch SDK. 50 | 51 | For general information on how to use Meilisearch—such as our API reference, tutorials, guides, and in-depth articles—refer to our [main documentation website](https://www.meilisearch.com/docs/). 52 | 53 | 54 | ## 🔧 Installation 55 | 56 | With `go get` in command line: 57 | ```bash 58 | go get github.com/meilisearch/meilisearch-go 59 | ``` 60 | 61 | ### Run Meilisearch 62 | 63 | ⚡️ **Launch, scale, and streamline in minutes with Meilisearch Cloud**—no maintenance, no commitment, cancel anytime. [Try it free now](https://cloud.meilisearch.com/login?utm_campaign=oss&utm_source=github&utm_medium=meilisearch-go). 64 | 65 | 🪨 Prefer to self-host? [Download and deploy](https://www.meilisearch.com/docs/learn/self_hosted/getting_started_with_self_hosted_meilisearch?utm_campaign=oss&utm_source=github&utm_medium=meilisearch-go) our fast, open-source search engine on your own infrastructure. 66 | 67 | ## 🚀 Getting started 68 | 69 | #### Add documents 70 | 71 | ```go 72 | package main 73 | 74 | import ( 75 | "fmt" 76 | "os" 77 | 78 | "github.com/meilisearch/meilisearch-go" 79 | ) 80 | 81 | func main() { 82 | client := meilisearch.New("http://localhost:7700", meilisearch.WithAPIKey("foobar")) 83 | 84 | // An index is where the documents are stored. 85 | index := client.Index("movies") 86 | 87 | // If the index 'movies' does not exist, Meilisearch creates it when you first add the documents. 88 | documents := []map[string]interface{}{ 89 | { "id": 1, "title": "Carol", "genres": []string{"Romance", "Drama"} }, 90 | { "id": 2, "title": "Wonder Woman", "genres": []string{"Action", "Adventure"} }, 91 | { "id": 3, "title": "Life of Pi", "genres": []string{"Adventure", "Drama"} }, 92 | { "id": 4, "title": "Mad Max: Fury Road", "genres": []string{"Adventure", "Science Fiction"} }, 93 | { "id": 5, "title": "Moana", "genres": []string{"Fantasy", "Action"} }, 94 | { "id": 6, "title": "Philadelphia", "genres": []string{"Drama"} }, 95 | } 96 | task, err := index.AddDocuments(documents) 97 | if err != nil { 98 | fmt.Println(err) 99 | os.Exit(1) 100 | } 101 | 102 | fmt.Println(task.TaskUID) 103 | } 104 | ``` 105 | 106 | With the `taskUID`, you can check the status (`enqueued`, `canceled`, `processing`, `succeeded` or `failed`) of your documents addition using the [task endpoint](https://www.meilisearch.com/docs/reference/api/tasks). 107 | 108 | #### Basic Search 109 | 110 | ```go 111 | package main 112 | 113 | import ( 114 | "fmt" 115 | "os" 116 | 117 | "github.com/meilisearch/meilisearch-go" 118 | ) 119 | 120 | func main() { 121 | // Meilisearch is typo-tolerant: 122 | searchRes, err := client.Index("movies").Search("philoudelphia", 123 | &meilisearch.SearchRequest{ 124 | Limit: 10, 125 | }) 126 | if err != nil { 127 | fmt.Println(err) 128 | os.Exit(1) 129 | } 130 | 131 | fmt.Println(searchRes.Hits) 132 | } 133 | ``` 134 | 135 | JSON output: 136 | ```json 137 | { 138 | "hits": [{ 139 | "id": 6, 140 | "title": "Philadelphia", 141 | "genres": ["Drama"] 142 | }], 143 | "offset": 0, 144 | "limit": 10, 145 | "processingTimeMs": 1, 146 | "query": "philoudelphia" 147 | } 148 | ``` 149 | 150 | #### Custom Search 151 | 152 | All the supported options are described in the [search parameters](https://www.meilisearch.com/docs/reference/api/search#search-parameters) section of the documentation. 153 | 154 | ```go 155 | func main() { 156 | searchRes, err := client.Index("movies").Search("wonder", 157 | &meilisearch.SearchRequest{ 158 | AttributesToHighlight: []string{"*"}, 159 | }) 160 | if err != nil { 161 | fmt.Println(err) 162 | os.Exit(1) 163 | } 164 | 165 | fmt.Println(searchRes.Hits) 166 | } 167 | ``` 168 | 169 | JSON output: 170 | ```json 171 | { 172 | "hits": [ 173 | { 174 | "id": 2, 175 | "title": "Wonder Woman", 176 | "genres": ["Action", "Adventure"], 177 | "_formatted": { 178 | "id": 2, 179 | "title": "Wonder Woman" 180 | } 181 | } 182 | ], 183 | "offset": 0, 184 | "limit": 20, 185 | "processingTimeMs": 0, 186 | "query": "wonder" 187 | } 188 | ``` 189 | 190 | #### Custom Search With Filters 191 | 192 | If you want to enable filtering, you must add your attributes to the `filterableAttributes` index setting. 193 | 194 | ```go 195 | task, err := index.UpdateFilterableAttributes(&[]string{"id", "genres"}) 196 | ``` 197 | 198 | You only need to perform this operation once. 199 | 200 | Note that Meilisearch will rebuild your index whenever you update `filterableAttributes`. Depending on the size of your dataset, this might take time. You can track the process using the [task status](https://www.meilisearch.com/docs/learn/advanced/asynchronous_operations). 201 | 202 | Then, you can perform the search: 203 | 204 | ```go 205 | searchRes, err := index.Search("wonder", 206 | &meilisearch.SearchRequest{ 207 | Filter: "id > 1 AND genres = Action", 208 | }) 209 | ``` 210 | 211 | ```json 212 | { 213 | "hits": [ 214 | { 215 | "id": 2, 216 | "title": "Wonder Woman", 217 | "genres": ["Action","Adventure"] 218 | } 219 | ], 220 | "offset": 0, 221 | "limit": 20, 222 | "estimatedTotalHits": 1, 223 | "processingTimeMs": 0, 224 | "query": "wonder" 225 | } 226 | ``` 227 | 228 | #### Customize Client 229 | 230 | The client supports many customization options: 231 | 232 | - `WithCustomClient` sets a custom `http.Client`. 233 | - `WithCustomClientWithTLS` enables TLS for the HTTP client. 234 | - `WithAPIKey` sets the API key or master [key](https://www.meilisearch.com/docs/reference/api/keys). 235 | - `WithContentEncoding` configures [content encoding](https://www.meilisearch.com/docs/reference/api/overview#content-encoding) for requests and responses. Currently, gzip, deflate, and brotli are supported. 236 | - `WithCustomRetries` customizes retry behavior based on specific HTTP status codes (`retryOnStatus`, defaults to 502, 503, and 504) and allows setting the maximum number of retries. 237 | - `DisableRetries` disables the retry logic. By default, retries are enabled. 238 | 239 | ```go 240 | package main 241 | 242 | import ( 243 | "net/http" 244 | "github.com/meilisearch/meilisearch-go" 245 | ) 246 | 247 | func main() { 248 | client := meilisearch.New("http://localhost:7700", 249 | meilisearch.WithAPIKey("foobar"), 250 | meilisearch.WithCustomClient(http.DefaultClient), 251 | meilisearch.WithContentEncoding(meilisearch.GzipEncoding, meilisearch.BestCompression), 252 | meilisearch.WithCustomRetries([]int{502}, 20), 253 | ) 254 | } 255 | ``` 256 | 257 | ## 🤖 Compatibility with Meilisearch 258 | 259 | This package guarantees compatibility with [version v1.x of Meilisearch](https://github.com/meilisearch/meilisearch/releases/latest), but some features may not be present. Please check the [issues](https://github.com/meilisearch/meilisearch-go/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22+label%3Aenhancement) for more info. 260 | 261 | ## ⚡️ Benchmark Performance 262 | 263 | The Meilisearch client performance was tested in [client_bench_test.go](/client_bench_test.go). 264 | 265 | ```shell 266 | goos: linux 267 | goarch: amd64 268 | pkg: github.com/meilisearch/meilisearch-go 269 | cpu: AMD Ryzen 7 5700U with Radeon Graphics 270 | ``` 271 | 272 | **Results** 273 | 274 | ```shell 275 | Benchmark_ExecuteRequest-16 10000 105880 ns/op 7241 B/op 87 allocs/op 276 | Benchmark_ExecuteRequestWithEncoding-16 2716 455548 ns/op 1041998 B/op 169 allocs/op 277 | Benchmark_ExecuteRequestWithoutRetries-16 1 3002787257 ns/op 56528 B/op 332 allocs/op 278 | ``` 279 | 280 | ## 💡 Learn more 281 | 282 | The following sections in our main documentation website may interest you: 283 | 284 | - **Manipulate documents**: see the [API references](https://www.meilisearch.com/docs/reference/api/documents) or read more about [documents](https://www.meilisearch.com/docs/learn/core_concepts/documents). 285 | - **Search**: see the [API references](https://www.meilisearch.com/docs/reference/api/search) or follow our guide on [search parameters](https://www.meilisearch.com/docs/reference/api/search#search-parameters). 286 | - **Manage the indexes**: see the [API references](https://www.meilisearch.com/docs/reference/api/indexes) or read more about [indexes](https://www.meilisearch.com/docs/learn/core_concepts/indexes). 287 | - **ClientConfigure the index settings**: see the [API references](https://www.meilisearch.com/docs/reference/api/settings) or follow our guide on [settings parameters](https://www.meilisearch.com/docs/reference/api/settings#settings_parameters). 288 | 289 | ## ⚙️ Contributing 290 | 291 | Any new contribution is more than welcome in this project! 292 | 293 | If you want to know more about the development workflow or want to contribute, please visit our [contributing guidelines](/CONTRIBUTING.md) for detailed instructions! 294 | 295 |
296 | 297 | **Meilisearch** provides and maintains many **SDKs and Integration tools** like this one. We want to provide everyone with an **amazing search experience for any kind of project**. If you want to contribute, make suggestions, or just know what's going on right now, visit us in the [integration-guides](https://github.com/meilisearch/integration-guides) repository. 298 | -------------------------------------------------------------------------------- /bors.toml: -------------------------------------------------------------------------------- 1 | status = ['integration-tests (go current version)', 'integration-tests (go latest version)', 'linter'] 2 | # 1 hour timeout 3 | timeout-sec = 3600 4 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | type client struct { 17 | client *http.Client 18 | host string 19 | apiKey string 20 | bufferPool *sync.Pool 21 | encoder encoder 22 | contentEncoding ContentEncoding 23 | retryOnStatus map[int]bool 24 | disableRetry bool 25 | maxRetries uint8 26 | retryBackoff func(attempt uint8) time.Duration 27 | } 28 | 29 | type clientConfig struct { 30 | contentEncoding ContentEncoding 31 | encodingCompressionLevel EncodingCompressionLevel 32 | retryOnStatus map[int]bool 33 | disableRetry bool 34 | maxRetries uint8 35 | } 36 | 37 | type internalRequest struct { 38 | endpoint string 39 | method string 40 | contentType string 41 | withRequest interface{} 42 | withResponse interface{} 43 | withQueryParams map[string]string 44 | 45 | acceptedStatusCodes []int 46 | 47 | functionName string 48 | } 49 | 50 | func newClient(cli *http.Client, host, apiKey string, cfg clientConfig) *client { 51 | c := &client{ 52 | client: cli, 53 | host: host, 54 | apiKey: apiKey, 55 | bufferPool: &sync.Pool{ 56 | New: func() interface{} { 57 | return new(bytes.Buffer) 58 | }, 59 | }, 60 | disableRetry: cfg.disableRetry, 61 | maxRetries: cfg.maxRetries, 62 | retryOnStatus: cfg.retryOnStatus, 63 | } 64 | 65 | if c.retryOnStatus == nil { 66 | c.retryOnStatus = map[int]bool{ 67 | 502: true, 68 | 503: true, 69 | 504: true, 70 | } 71 | } 72 | 73 | if !c.disableRetry && c.retryBackoff == nil { 74 | c.retryBackoff = func(attempt uint8) time.Duration { 75 | return time.Second * time.Duration(attempt) 76 | } 77 | } 78 | 79 | if !cfg.contentEncoding.IsZero() { 80 | c.contentEncoding = cfg.contentEncoding 81 | c.encoder = newEncoding(cfg.contentEncoding, cfg.encodingCompressionLevel) 82 | } 83 | 84 | return c 85 | } 86 | 87 | func (c *client) executeRequest(ctx context.Context, req *internalRequest) error { 88 | internalError := &Error{ 89 | Endpoint: req.endpoint, 90 | Method: req.method, 91 | Function: req.functionName, 92 | RequestToString: "empty request", 93 | ResponseToString: "empty response", 94 | MeilisearchApiError: meilisearchApiError{ 95 | Message: "empty meilisearch message", 96 | }, 97 | StatusCodeExpected: req.acceptedStatusCodes, 98 | encoder: c.encoder, 99 | } 100 | 101 | resp, err := c.sendRequest(ctx, req, internalError) 102 | if err != nil { 103 | return err 104 | } 105 | 106 | defer func() { 107 | _ = resp.Body.Close() 108 | }() 109 | 110 | internalError.StatusCode = resp.StatusCode 111 | 112 | b, err := io.ReadAll(resp.Body) 113 | if err != nil { 114 | return err 115 | } 116 | 117 | err = c.handleStatusCode(req, resp.StatusCode, b, internalError) 118 | if err != nil { 119 | return err 120 | } 121 | 122 | err = c.handleResponse(req, b, internalError) 123 | if err != nil { 124 | return err 125 | } 126 | return nil 127 | } 128 | 129 | func (c *client) sendRequest( 130 | ctx context.Context, 131 | req *internalRequest, 132 | internalError *Error, 133 | ) (*http.Response, error) { 134 | 135 | apiURL, err := url.Parse(c.host + req.endpoint) 136 | if err != nil { 137 | return nil, fmt.Errorf("unable to parse url: %w", err) 138 | } 139 | 140 | if req.withQueryParams != nil { 141 | query := apiURL.Query() 142 | for key, value := range req.withQueryParams { 143 | query.Set(key, value) 144 | } 145 | 146 | apiURL.RawQuery = query.Encode() 147 | } 148 | 149 | // Create request body 150 | var body io.Reader = nil 151 | if req.withRequest != nil { 152 | if req.method == http.MethodGet || req.method == http.MethodHead { 153 | return nil, ErrInvalidRequestMethod 154 | } 155 | if req.contentType == "" { 156 | return nil, ErrRequestBodyWithoutContentType 157 | } 158 | 159 | rawRequest := req.withRequest 160 | 161 | buf := c.bufferPool.Get().(*bytes.Buffer) 162 | buf.Reset() 163 | 164 | if b, ok := rawRequest.([]byte); ok { 165 | buf.Write(b) 166 | body = buf 167 | } else if reader, ok := rawRequest.(io.Reader); ok { 168 | // If the request body is an io.Reader then stream it directly 169 | body = reader 170 | } else { 171 | // Otherwise convert it to JSON 172 | var ( 173 | data []byte 174 | err error 175 | ) 176 | if marshaler, ok := rawRequest.(json.Marshaler); ok { 177 | data, err = marshaler.MarshalJSON() 178 | if err != nil { 179 | return nil, internalError.WithErrCode(ErrCodeMarshalRequest, 180 | fmt.Errorf("failed to marshal with MarshalJSON: %w", err)) 181 | } 182 | if data == nil { 183 | return nil, internalError.WithErrCode(ErrCodeMarshalRequest, 184 | errors.New("MarshalJSON returned nil data")) 185 | } 186 | } else { 187 | data, err = json.Marshal(rawRequest) 188 | if err != nil { 189 | return nil, internalError.WithErrCode(ErrCodeMarshalRequest, 190 | fmt.Errorf("failed to marshal with json.Marshal: %w", err)) 191 | } 192 | } 193 | buf.Write(data) 194 | body = buf 195 | } 196 | 197 | if !c.contentEncoding.IsZero() { 198 | // Get the data from the buffer before encoding 199 | var bufData []byte 200 | if buf, ok := body.(*bytes.Buffer); ok { 201 | bufData = buf.Bytes() 202 | encodedBuf, err := c.encoder.Encode(bytes.NewReader(bufData)) 203 | if err != nil { 204 | if buf, ok := body.(*bytes.Buffer); ok { 205 | c.bufferPool.Put(buf) 206 | } 207 | return nil, internalError.WithErrCode(ErrCodeMarshalRequest, 208 | fmt.Errorf("failed to encode request body: %w", err)) 209 | } 210 | // Return the original buffer to the pool since we have a new one 211 | if buf, ok := body.(*bytes.Buffer); ok { 212 | c.bufferPool.Put(buf) 213 | } 214 | body = encodedBuf 215 | } 216 | } 217 | } 218 | 219 | // Create the HTTP request 220 | request, err := http.NewRequestWithContext(ctx, req.method, apiURL.String(), body) 221 | if err != nil { 222 | return nil, fmt.Errorf("unable to create request: %w", err) 223 | } 224 | 225 | // adding request headers 226 | if req.contentType != "" { 227 | request.Header.Set("Content-Type", req.contentType) 228 | } 229 | if c.apiKey != "" { 230 | request.Header.Set("Authorization", "Bearer "+c.apiKey) 231 | } 232 | 233 | if req.withResponse != nil && !c.contentEncoding.IsZero() { 234 | request.Header.Set("Accept-Encoding", c.contentEncoding.String()) 235 | } 236 | 237 | if req.withRequest != nil && !c.contentEncoding.IsZero() { 238 | request.Header.Set("Content-Encoding", c.contentEncoding.String()) 239 | } 240 | 241 | request.Header.Set("User-Agent", GetQualifiedVersion()) 242 | 243 | resp, err := c.do(request, internalError) 244 | if err != nil { 245 | return nil, err 246 | } 247 | 248 | if body != nil { 249 | if buf, ok := body.(*bytes.Buffer); ok { 250 | c.bufferPool.Put(buf) 251 | } 252 | } 253 | return resp, nil 254 | } 255 | 256 | func (c *client) do(req *http.Request, internalError *Error) (resp *http.Response, err error) { 257 | retriesCount := uint8(0) 258 | 259 | for { 260 | resp, err = c.client.Do(req) 261 | if err != nil { 262 | if errors.Is(err, context.DeadlineExceeded) { 263 | return nil, internalError.WithErrCode(MeilisearchTimeoutError, err) 264 | } 265 | return nil, internalError.WithErrCode(MeilisearchCommunicationError, err) 266 | } 267 | 268 | // Exit if retries are disabled 269 | if c.disableRetry { 270 | break 271 | } 272 | 273 | // Check if response status is retryable and we haven't exceeded max retries 274 | if c.retryOnStatus[resp.StatusCode] && retriesCount < c.maxRetries { 275 | retriesCount++ 276 | 277 | // Close response body to prevent memory leaks 278 | resp.Body.Close() 279 | 280 | // Handle backoff with context cancellation support 281 | backoff := c.retryBackoff(retriesCount) 282 | timer := time.NewTimer(backoff) 283 | 284 | select { 285 | case <-req.Context().Done(): 286 | err := req.Context().Err() 287 | timer.Stop() 288 | return nil, internalError.WithErrCode(MeilisearchTimeoutError, err) 289 | case <-timer.C: 290 | // Retry after backoff 291 | timer.Stop() 292 | } 293 | 294 | continue 295 | } 296 | 297 | break 298 | } 299 | 300 | // Return error if retries exceeded the maximum limit 301 | if !c.disableRetry && retriesCount >= c.maxRetries { 302 | return nil, internalError.WithErrCode(MeilisearchMaxRetriesExceeded, nil) 303 | } 304 | 305 | return resp, nil 306 | } 307 | 308 | func (c *client) handleStatusCode(req *internalRequest, statusCode int, body []byte, internalError *Error) error { 309 | if req.acceptedStatusCodes != nil { 310 | 311 | // A successful status code is required so check if the response status code is in the 312 | // expected status code list. 313 | for _, acceptedCode := range req.acceptedStatusCodes { 314 | if statusCode == acceptedCode { 315 | return nil 316 | } 317 | } 318 | 319 | internalError.ErrorBody(body) 320 | 321 | if internalError.MeilisearchApiError.Code == "" { 322 | return internalError.WithErrCode(MeilisearchApiErrorWithoutMessage) 323 | } 324 | return internalError.WithErrCode(MeilisearchApiError) 325 | } 326 | 327 | return nil 328 | } 329 | 330 | func (c *client) handleResponse(req *internalRequest, body []byte, internalError *Error) (err error) { 331 | if req.withResponse != nil { 332 | if !c.contentEncoding.IsZero() { 333 | if err := c.encoder.Decode(body, req.withResponse); err != nil { 334 | return internalError.WithErrCode(ErrCodeResponseUnmarshalBody, err) 335 | } 336 | } else { 337 | internalError.ResponseToString = string(body) 338 | 339 | if internalError.ResponseToString == nullBody { 340 | req.withResponse = nil 341 | return nil 342 | } 343 | 344 | var err error 345 | if resp, ok := req.withResponse.(json.Unmarshaler); ok { 346 | err = resp.UnmarshalJSON(body) 347 | req.withResponse = resp 348 | } else { 349 | err = json.Unmarshal(body, req.withResponse) 350 | } 351 | if err != nil { 352 | return internalError.WithErrCode(ErrCodeResponseUnmarshalBody, err) 353 | } 354 | } 355 | } 356 | return nil 357 | } 358 | -------------------------------------------------------------------------------- /client_bench_test.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | ) 12 | 13 | func Benchmark_ExecuteRequest(b *testing.B) { 14 | b.ReportAllocs() 15 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | if r.Method == http.MethodGet && r.URL.Path == "/test" { 17 | w.WriteHeader(http.StatusOK) 18 | _, _ = w.Write([]byte(`{"message":"get successful"}`)) 19 | } else { 20 | w.WriteHeader(http.StatusNotFound) 21 | } 22 | })) 23 | defer ts.Close() 24 | 25 | c := newClient(&http.Client{}, ts.URL, "testApiKey", clientConfig{ 26 | disableRetry: true, 27 | }) 28 | 29 | b.ResetTimer() 30 | for i := 0; i < b.N; i++ { 31 | err := c.executeRequest(context.Background(), &internalRequest{ 32 | endpoint: "/test", 33 | method: http.MethodGet, 34 | withResponse: &mockResponse{}, 35 | acceptedStatusCodes: []int{http.StatusOK}, 36 | }) 37 | if err != nil { 38 | b.Fatal(err) 39 | } 40 | } 41 | } 42 | 43 | func Benchmark_ExecuteRequestWithEncoding(b *testing.B) { 44 | b.ReportAllocs() 45 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 46 | if r.Method == http.MethodPost && r.URL.Path == "/test" { 47 | accept := r.Header.Get("Accept-Encoding") 48 | ce := r.Header.Get("Content-Encoding") 49 | 50 | reqEnc := newEncoding(ContentEncoding(ce), DefaultCompression) 51 | respEnc := newEncoding(ContentEncoding(accept), DefaultCompression) 52 | req := new(mockData) 53 | 54 | if len(ce) != 0 { 55 | body, err := io.ReadAll(r.Body) 56 | if err != nil { 57 | b.Fatal(err) 58 | } 59 | 60 | err = reqEnc.Decode(body, req) 61 | if err != nil { 62 | b.Fatal(err) 63 | } 64 | } 65 | 66 | if len(accept) != 0 { 67 | d, err := json.Marshal(req) 68 | if err != nil { 69 | b.Fatal(err) 70 | } 71 | res, err := respEnc.Encode(bytes.NewReader(d)) 72 | if err != nil { 73 | b.Fatal(err) 74 | } 75 | _, _ = w.Write(res.Bytes()) 76 | w.WriteHeader(http.StatusOK) 77 | } 78 | } else { 79 | w.WriteHeader(http.StatusNotFound) 80 | } 81 | })) 82 | defer ts.Close() 83 | 84 | c := newClient(&http.Client{}, ts.URL, "testApiKey", clientConfig{ 85 | disableRetry: true, 86 | contentEncoding: GzipEncoding, 87 | encodingCompressionLevel: DefaultCompression, 88 | }) 89 | 90 | b.ResetTimer() 91 | for i := 0; i < b.N; i++ { 92 | err := c.executeRequest(context.Background(), &internalRequest{ 93 | endpoint: "/test", 94 | method: http.MethodPost, 95 | contentType: contentTypeJSON, 96 | withRequest: &mockData{Name: "foo", Age: 30}, 97 | withResponse: &mockData{}, 98 | acceptedStatusCodes: []int{http.StatusOK}, 99 | }) 100 | if err != nil { 101 | b.Fatal(err) 102 | } 103 | } 104 | } 105 | 106 | func Benchmark_ExecuteRequestWithoutRetries(b *testing.B) { 107 | b.ReportAllocs() 108 | retryCount := 0 109 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 110 | if r.Method == http.MethodGet && r.URL.Path == "/test" { 111 | if retryCount == 2 { 112 | w.WriteHeader(http.StatusOK) 113 | return 114 | } 115 | w.WriteHeader(http.StatusBadGateway) 116 | retryCount++ 117 | } else { 118 | w.WriteHeader(http.StatusNotFound) 119 | } 120 | })) 121 | defer ts.Close() 122 | 123 | c := newClient(&http.Client{}, ts.URL, "testApiKey", clientConfig{ 124 | disableRetry: false, 125 | maxRetries: 3, 126 | retryOnStatus: map[int]bool{ 127 | 502: true, 128 | 503: true, 129 | 504: true, 130 | }, 131 | }) 132 | 133 | b.ResetTimer() 134 | for i := 0; i < b.N; i++ { 135 | err := c.executeRequest(context.Background(), &internalRequest{ 136 | endpoint: "/test", 137 | method: http.MethodGet, 138 | withResponse: nil, 139 | withRequest: nil, 140 | acceptedStatusCodes: []int{http.StatusOK}, 141 | }) 142 | if err != nil { 143 | b.Fatal(err) 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "io" 9 | "net/http" 10 | "net/http/httptest" 11 | "testing" 12 | "time" 13 | 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | // Mock structures for testing 18 | type mockResponse struct { 19 | Message string `json:"message"` 20 | } 21 | 22 | type mockJsonMarshaller struct { 23 | valid bool 24 | null bool 25 | Foo string `json:"foo"` 26 | Bar string `json:"bar"` 27 | } 28 | 29 | // failingEncoder is used to simulate encoder failure 30 | type failingEncoder struct{} 31 | 32 | func (fe failingEncoder) Encode(r io.Reader) (*bytes.Buffer, error) { 33 | return nil, errors.New("dummy encoding failure") 34 | } 35 | 36 | // Implement Decode method to satisfy the encoder interface, though it won't be used here 37 | func (fe failingEncoder) Decode(b []byte, v interface{}) error { 38 | return errors.New("dummy decode failure") 39 | } 40 | 41 | func TestExecuteRequest(t *testing.T) { 42 | retryCount := 0 43 | 44 | // Create a mock server 45 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 46 | if r.Method == http.MethodGet && r.URL.Path == "/test-get" { 47 | w.WriteHeader(http.StatusOK) 48 | _, _ = w.Write([]byte(`{"message":"get successful"}`)) 49 | } else if r.Method == http.MethodGet && r.URL.Path == "/test-get-encoding" { 50 | encode := r.Header.Get("Accept-Encoding") 51 | if len(encode) != 0 { 52 | enc := newEncoding(ContentEncoding(encode), DefaultCompression) 53 | d := &mockData{Name: "foo", Age: 30} 54 | 55 | b, err := json.Marshal(d) 56 | require.NoError(t, err) 57 | 58 | res, err := enc.Encode(bytes.NewReader(b)) 59 | require.NoError(t, err) 60 | _, _ = w.Write(res.Bytes()) 61 | w.WriteHeader(http.StatusOK) 62 | return 63 | } 64 | _, _ = w.Write([]byte("invalid message")) 65 | w.WriteHeader(http.StatusInternalServerError) 66 | } else if r.Method == http.MethodPost && r.URL.Path == "/test-req-resp-encoding" { 67 | accept := r.Header.Get("Accept-Encoding") 68 | ce := r.Header.Get("Content-Encoding") 69 | 70 | reqEnc := newEncoding(ContentEncoding(ce), DefaultCompression) 71 | respEnc := newEncoding(ContentEncoding(accept), DefaultCompression) 72 | req := new(mockData) 73 | 74 | if len(ce) != 0 { 75 | b, err := io.ReadAll(r.Body) 76 | require.NoError(t, err) 77 | 78 | err = reqEnc.Decode(b, req) 79 | require.NoError(t, err) 80 | } 81 | 82 | if len(accept) != 0 { 83 | d, err := json.Marshal(req) 84 | require.NoError(t, err) 85 | res, err := respEnc.Encode(bytes.NewReader(d)) 86 | require.NoError(t, err) 87 | _, _ = w.Write(res.Bytes()) 88 | w.WriteHeader(http.StatusOK) 89 | } 90 | } else if r.Method == http.MethodPost && r.URL.Path == "/test-post" { 91 | w.WriteHeader(http.StatusCreated) 92 | msg := []byte(`{"message":"post successful"}`) 93 | _, _ = w.Write(msg) 94 | 95 | } else if r.Method == http.MethodGet && r.URL.Path == "/test-null-body" { 96 | w.WriteHeader(http.StatusOK) 97 | msg := []byte(`null`) 98 | _, _ = w.Write(msg) 99 | } else if r.Method == http.MethodPost && r.URL.Path == "/test-post-encoding" { 100 | w.WriteHeader(http.StatusCreated) 101 | msg := []byte(`{"message":"post successful"}`) 102 | 103 | enc := r.Header.Get("Accept-Encoding") 104 | if len(enc) != 0 { 105 | e := newEncoding(ContentEncoding(enc), DefaultCompression) 106 | b, err := e.Encode(bytes.NewReader(msg)) 107 | require.NoError(t, err) 108 | _, _ = w.Write(b.Bytes()) 109 | return 110 | } 111 | _, _ = w.Write(msg) 112 | } else if r.URL.Path == "/test-bad-request" { 113 | w.WriteHeader(http.StatusBadRequest) 114 | _, _ = w.Write([]byte(`{"message":"bad request"}`)) 115 | } else if r.URL.Path == "/invalid-response-body" { 116 | w.WriteHeader(http.StatusInternalServerError) 117 | _, _ = w.Write([]byte(`{"message":"bad response body"}`)) 118 | } else if r.URL.Path == "/io-reader" { 119 | w.WriteHeader(http.StatusOK) 120 | _, _ = w.Write([]byte(`{"message":"io reader"}`)) 121 | } else if r.URL.Path == "/failed-retry" { 122 | w.WriteHeader(http.StatusBadGateway) 123 | } else if r.URL.Path == "/success-retry" { 124 | if retryCount == 2 { 125 | w.WriteHeader(http.StatusOK) 126 | return 127 | } 128 | w.WriteHeader(http.StatusBadGateway) 129 | retryCount++ 130 | } else if r.URL.Path == "/dummy" { 131 | w.WriteHeader(http.StatusOK) 132 | } else { 133 | w.WriteHeader(http.StatusNotFound) 134 | } 135 | })) 136 | defer ts.Close() 137 | 138 | tests := []struct { 139 | name string 140 | internalReq *internalRequest 141 | expectedResp interface{} 142 | contentEncoding ContentEncoding 143 | withTimeout bool 144 | disableRetry bool 145 | wantErr bool 146 | }{ 147 | { 148 | name: "Successful GET request", 149 | internalReq: &internalRequest{ 150 | endpoint: "/test-get", 151 | method: http.MethodGet, 152 | withResponse: &mockResponse{}, 153 | acceptedStatusCodes: []int{http.StatusOK}, 154 | }, 155 | expectedResp: &mockResponse{Message: "get successful"}, 156 | wantErr: false, 157 | }, 158 | { 159 | name: "Successful POST request", 160 | internalReq: &internalRequest{ 161 | endpoint: "/test-post", 162 | method: http.MethodPost, 163 | withRequest: map[string]string{"key": "value"}, 164 | contentType: contentTypeJSON, 165 | withResponse: &mockResponse{}, 166 | acceptedStatusCodes: []int{http.StatusCreated}, 167 | }, 168 | expectedResp: &mockResponse{Message: "post successful"}, 169 | wantErr: false, 170 | }, 171 | { 172 | name: "404 Not Found", 173 | internalReq: &internalRequest{ 174 | endpoint: "/not-found", 175 | method: http.MethodGet, 176 | withResponse: &mockResponse{}, 177 | acceptedStatusCodes: []int{http.StatusOK}, 178 | }, 179 | expectedResp: nil, 180 | wantErr: true, 181 | }, 182 | { 183 | name: "Invalid URL", 184 | internalReq: &internalRequest{ 185 | endpoint: "/invalid-url$%^*()*#", 186 | method: http.MethodGet, 187 | withResponse: &mockResponse{}, 188 | acceptedStatusCodes: []int{http.StatusOK}, 189 | }, 190 | expectedResp: nil, 191 | wantErr: true, 192 | }, 193 | { 194 | name: "Invalid response body", 195 | internalReq: &internalRequest{ 196 | endpoint: "/invalid-response-body", 197 | method: http.MethodGet, 198 | withResponse: struct{}{}, 199 | acceptedStatusCodes: []int{http.StatusInternalServerError}, 200 | }, 201 | expectedResp: nil, 202 | wantErr: true, 203 | }, 204 | { 205 | name: "Invalid request method", 206 | internalReq: &internalRequest{ 207 | endpoint: "/invalid-request-method", 208 | method: http.MethodGet, 209 | withResponse: nil, 210 | withRequest: struct{}{}, 211 | acceptedStatusCodes: []int{http.StatusBadRequest}, 212 | }, 213 | expectedResp: nil, 214 | wantErr: true, 215 | }, 216 | { 217 | name: "Invalid request content type", 218 | internalReq: &internalRequest{ 219 | endpoint: "/invalid-request-content-type", 220 | method: http.MethodPost, 221 | withResponse: nil, 222 | contentType: "", 223 | withRequest: struct{}{}, 224 | acceptedStatusCodes: []int{http.StatusBadRequest}, 225 | }, 226 | expectedResp: nil, 227 | wantErr: true, 228 | }, 229 | { 230 | name: "Invalid json marshaler", 231 | internalReq: &internalRequest{ 232 | endpoint: "/invalid-marshaler", 233 | method: http.MethodPost, 234 | withResponse: nil, 235 | withRequest: &mockJsonMarshaller{ 236 | valid: false, 237 | }, 238 | contentType: "application/json", 239 | }, 240 | expectedResp: nil, 241 | wantErr: true, 242 | }, 243 | { 244 | name: "Null data marshaler", 245 | internalReq: &internalRequest{ 246 | endpoint: "/null-data-marshaler", 247 | method: http.MethodPost, 248 | withResponse: nil, 249 | withRequest: &mockJsonMarshaller{ 250 | valid: true, 251 | null: true, 252 | }, 253 | contentType: "application/json", 254 | }, 255 | expectedResp: nil, 256 | wantErr: true, 257 | }, 258 | { 259 | name: "Test null body response", 260 | internalReq: &internalRequest{ 261 | endpoint: "/test-null-body", 262 | method: http.MethodGet, 263 | withResponse: make([]byte, 0), 264 | contentType: "application/json", 265 | acceptedStatusCodes: []int{http.StatusOK}, 266 | }, 267 | expectedResp: nil, 268 | wantErr: false, 269 | }, 270 | { 271 | name: "400 Bad Request", 272 | internalReq: &internalRequest{ 273 | endpoint: "/test-bad-request", 274 | method: http.MethodGet, 275 | withResponse: &mockResponse{}, 276 | acceptedStatusCodes: []int{http.StatusOK}, 277 | }, 278 | expectedResp: nil, 279 | wantErr: true, 280 | }, 281 | { 282 | name: "Test request encoding gzip", 283 | internalReq: &internalRequest{ 284 | endpoint: "/test-post-encoding", 285 | method: http.MethodPost, 286 | withRequest: map[string]string{"key": "value"}, 287 | contentType: contentTypeJSON, 288 | withResponse: &mockResponse{}, 289 | acceptedStatusCodes: []int{http.StatusCreated}, 290 | }, 291 | expectedResp: &mockResponse{Message: "post successful"}, 292 | contentEncoding: GzipEncoding, 293 | wantErr: false, 294 | }, 295 | { 296 | name: "Test request encoding deflate", 297 | internalReq: &internalRequest{ 298 | endpoint: "/test-post-encoding", 299 | method: http.MethodPost, 300 | withRequest: map[string]string{"key": "value"}, 301 | contentType: contentTypeJSON, 302 | withResponse: &mockResponse{}, 303 | acceptedStatusCodes: []int{http.StatusCreated}, 304 | }, 305 | expectedResp: &mockResponse{Message: "post successful"}, 306 | contentEncoding: DeflateEncoding, 307 | wantErr: false, 308 | }, 309 | { 310 | name: "Test request encoding brotli", 311 | internalReq: &internalRequest{ 312 | endpoint: "/test-post-encoding", 313 | method: http.MethodPost, 314 | withRequest: map[string]string{"key": "value"}, 315 | contentType: contentTypeJSON, 316 | withResponse: &mockResponse{}, 317 | acceptedStatusCodes: []int{http.StatusCreated}, 318 | }, 319 | expectedResp: &mockResponse{Message: "post successful"}, 320 | contentEncoding: BrotliEncoding, 321 | wantErr: false, 322 | }, 323 | { 324 | name: "Test response decoding gzip", 325 | internalReq: &internalRequest{ 326 | endpoint: "/test-get-encoding", 327 | method: http.MethodGet, 328 | withRequest: nil, 329 | withResponse: &mockData{}, 330 | acceptedStatusCodes: []int{http.StatusOK}, 331 | }, 332 | expectedResp: &mockData{Name: "foo", Age: 30}, 333 | contentEncoding: GzipEncoding, 334 | wantErr: false, 335 | }, 336 | { 337 | name: "Test response decoding deflate", 338 | internalReq: &internalRequest{ 339 | endpoint: "/test-get-encoding", 340 | method: http.MethodGet, 341 | withRequest: nil, 342 | withResponse: &mockData{}, 343 | acceptedStatusCodes: []int{http.StatusOK}, 344 | }, 345 | expectedResp: &mockData{Name: "foo", Age: 30}, 346 | contentEncoding: DeflateEncoding, 347 | wantErr: false, 348 | }, 349 | { 350 | name: "Test response decoding brotli", 351 | internalReq: &internalRequest{ 352 | endpoint: "/test-get-encoding", 353 | method: http.MethodGet, 354 | withRequest: nil, 355 | withResponse: &mockData{}, 356 | acceptedStatusCodes: []int{http.StatusOK}, 357 | }, 358 | expectedResp: &mockData{Name: "foo", Age: 30}, 359 | contentEncoding: BrotliEncoding, 360 | wantErr: false, 361 | }, 362 | { 363 | name: "Test request and response encoding", 364 | internalReq: &internalRequest{ 365 | endpoint: "/test-req-resp-encoding", 366 | method: http.MethodPost, 367 | contentType: contentTypeJSON, 368 | withRequest: &mockData{Name: "foo", Age: 30}, 369 | withResponse: &mockData{}, 370 | acceptedStatusCodes: []int{http.StatusOK}, 371 | }, 372 | expectedResp: &mockData{Name: "foo", Age: 30}, 373 | contentEncoding: GzipEncoding, 374 | wantErr: false, 375 | }, 376 | { 377 | name: "Test successful retries", 378 | internalReq: &internalRequest{ 379 | endpoint: "/success-retry", 380 | method: http.MethodGet, 381 | withResponse: nil, 382 | withRequest: nil, 383 | acceptedStatusCodes: []int{http.StatusOK}, 384 | }, 385 | expectedResp: nil, 386 | wantErr: false, 387 | }, 388 | { 389 | name: "Test failed retries", 390 | internalReq: &internalRequest{ 391 | endpoint: "/failed-retry", 392 | method: http.MethodGet, 393 | withResponse: nil, 394 | withRequest: nil, 395 | acceptedStatusCodes: []int{http.StatusOK}, 396 | }, 397 | expectedResp: nil, 398 | wantErr: true, 399 | }, 400 | { 401 | name: "Test disable retries", 402 | internalReq: &internalRequest{ 403 | endpoint: "/test-get", 404 | method: http.MethodGet, 405 | withResponse: nil, 406 | withRequest: nil, 407 | acceptedStatusCodes: []int{http.StatusOK}, 408 | }, 409 | expectedResp: nil, 410 | disableRetry: true, 411 | wantErr: false, 412 | }, 413 | { 414 | name: "Test request timeout on retries", 415 | internalReq: &internalRequest{ 416 | endpoint: "/failed-retry", 417 | method: http.MethodGet, 418 | withResponse: nil, 419 | withRequest: nil, 420 | acceptedStatusCodes: []int{http.StatusOK}, 421 | }, 422 | expectedResp: nil, 423 | withTimeout: true, 424 | wantErr: true, 425 | }, 426 | { 427 | name: "Test request encoding with []byte encode failure", 428 | internalReq: &internalRequest{ 429 | endpoint: "/dummy", 430 | method: http.MethodPost, 431 | withRequest: []byte("test data"), 432 | contentType: "text/plain", 433 | acceptedStatusCodes: []int{http.StatusOK}, 434 | }, 435 | expectedResp: nil, 436 | contentEncoding: GzipEncoding, 437 | wantErr: true, 438 | }, 439 | } 440 | 441 | for _, tt := range tests { 442 | t.Run(tt.name, func(t *testing.T) { 443 | c := newClient(&http.Client{}, ts.URL, "testApiKey", clientConfig{ 444 | contentEncoding: tt.contentEncoding, 445 | encodingCompressionLevel: DefaultCompression, 446 | maxRetries: 3, 447 | disableRetry: tt.disableRetry, 448 | retryOnStatus: map[int]bool{ 449 | 502: true, 450 | 503: true, 451 | 504: true, 452 | }, 453 | }) 454 | 455 | // For the specific test case, override the encoder to force an error 456 | if tt.name == "Test request encoding with []byte encode failure" { 457 | c.encoder = failingEncoder{} 458 | } 459 | 460 | ctx := context.Background() 461 | 462 | if tt.withTimeout { 463 | timeoutCtx, cancel := context.WithTimeout(ctx, 1*time.Second) 464 | ctx = timeoutCtx 465 | defer cancel() 466 | } 467 | 468 | err := c.executeRequest(ctx, tt.internalReq) 469 | if tt.wantErr { 470 | require.Error(t, err) 471 | } else { 472 | require.NoError(t, err) 473 | require.Equal(t, tt.expectedResp, tt.internalReq.withResponse) 474 | } 475 | }) 476 | } 477 | } 478 | 479 | func TestNewClientNilRetryOnStatus(t *testing.T) { 480 | c := newClient(&http.Client{}, "", "", clientConfig{ 481 | maxRetries: 3, 482 | retryOnStatus: nil, 483 | }) 484 | 485 | require.NotNil(t, c.retryOnStatus) 486 | } 487 | 488 | func (m mockJsonMarshaller) MarshalJSON() ([]byte, error) { 489 | type Alias mockJsonMarshaller 490 | 491 | if !m.valid { 492 | return nil, errors.New("mockJsonMarshaller not valid") 493 | } 494 | 495 | if m.null { 496 | return nil, nil 497 | } 498 | 499 | return json.Marshal(&struct { 500 | Alias 501 | }{ 502 | Alias: Alias(m), 503 | }) 504 | } 505 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "types_easyjson.go" 3 | coverage: 4 | range: 60..80 5 | round: down 6 | precision: 2 7 | status: 8 | project: 9 | default: 10 | threshold: 1% 11 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package meilisearch is the official Meilisearch SDK for the Go programming language. 2 | // 3 | // The meilisearch-go SDK for Go provides APIs and utilities that developers can use to 4 | // build Go applications that use meilisearch service. 5 | // 6 | // See the meilisearch package documentation for more information. 7 | // https://www.meilisearch.com/docs/reference 8 | // 9 | // Example: 10 | // 11 | // meili := New("http://localhost:7700", WithAPIKey("foobar")) 12 | // 13 | // idx := meili.Index("movies") 14 | // 15 | // documents := []map[string]interface{}{ 16 | // {"id": 1, "title": "Carol", "genres": []string{"Romance", "Drama"}}, 17 | // {"id": 2, "title": "Wonder Woman", "genres": []string{"Action", "Adventure"}}, 18 | // {"id": 3, "title": "Life of Pi", "genres": []string{"Adventure", "Drama"}}, 19 | // {"id": 4, "title": "Mad Max: Fury Road", "genres": []string{"Adventure", "Science Fiction"}}, 20 | // {"id": 5, "title": "Moana", "genres": []string{"Fantasy", "Action"}}, 21 | // {"id": 6, "title": "Philadelphia", "genres": []string{"Drama"}}, 22 | // } 23 | // task, err := idx.AddDocuments(documents) 24 | // if err != nil { 25 | // fmt.Println(err) 26 | // os.Exit(1) 27 | // } 28 | // 29 | // fmt.Println(task.TaskUID) 30 | package meilisearch 31 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | volumes: 4 | gopkg: 5 | 6 | services: 7 | package: 8 | build: . 9 | tty: true 10 | stdin_open: true 11 | working_dir: /home/package 12 | environment: 13 | - MEILISEARCH_URL=http://meilisearch:7700 14 | depends_on: 15 | - meilisearch 16 | links: 17 | - meilisearch 18 | volumes: 19 | - gopkg:/go/pkg/mod 20 | - ./:/home/package 21 | 22 | meilisearch: 23 | image: getmeili/meilisearch:latest 24 | ports: 25 | - "7700" 26 | environment: 27 | - MEILI_MASTER_KEY=masterKey 28 | - MEILI_NO_ANALYTICS=true 29 | -------------------------------------------------------------------------------- /encoding.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "compress/zlib" 7 | "encoding/json" 8 | "github.com/andybalholm/brotli" 9 | "io" 10 | "sync" 11 | ) 12 | 13 | type encoder interface { 14 | Encode(rc io.Reader) (*bytes.Buffer, error) 15 | Decode(data []byte, vPtr interface{}) error 16 | } 17 | 18 | func newEncoding(ce ContentEncoding, level EncodingCompressionLevel) encoder { 19 | switch ce { 20 | case GzipEncoding: 21 | return &gzipEncoder{ 22 | gzWriterPool: &sync.Pool{ 23 | New: func() interface{} { 24 | w, err := gzip.NewWriterLevel(io.Discard, level.Int()) 25 | return &gzipWriter{ 26 | writer: w, 27 | err: err, 28 | } 29 | }, 30 | }, 31 | bufferPool: &sync.Pool{ 32 | New: func() interface{} { 33 | return new(bytes.Buffer) 34 | }, 35 | }, 36 | } 37 | case DeflateEncoding: 38 | return &flateEncoder{ 39 | flWriterPool: &sync.Pool{ 40 | New: func() interface{} { 41 | w, err := zlib.NewWriterLevel(io.Discard, level.Int()) 42 | return &flateWriter{ 43 | writer: w, 44 | err: err, 45 | } 46 | }, 47 | }, 48 | bufferPool: &sync.Pool{ 49 | New: func() interface{} { 50 | return new(bytes.Buffer) 51 | }, 52 | }, 53 | } 54 | case BrotliEncoding: 55 | return &brotliEncoder{ 56 | brWriterPool: &sync.Pool{ 57 | New: func() interface{} { 58 | return brotli.NewWriterLevel(io.Discard, level.Int()) 59 | }, 60 | }, 61 | bufferPool: &sync.Pool{ 62 | New: func() interface{} { 63 | return new(bytes.Buffer) 64 | }, 65 | }, 66 | } 67 | default: 68 | return nil 69 | } 70 | } 71 | 72 | type gzipEncoder struct { 73 | gzWriterPool *sync.Pool 74 | bufferPool *sync.Pool 75 | } 76 | 77 | type gzipWriter struct { 78 | writer *gzip.Writer 79 | err error 80 | } 81 | 82 | func (g *gzipEncoder) Encode(rc io.Reader) (*bytes.Buffer, error) { 83 | w := g.gzWriterPool.Get().(*gzipWriter) 84 | defer g.gzWriterPool.Put(w) 85 | 86 | if w.err != nil { 87 | return nil, w.err 88 | } 89 | 90 | defer func() { 91 | _ = w.writer.Close() 92 | }() 93 | 94 | buf := g.bufferPool.Get().(*bytes.Buffer) 95 | defer g.bufferPool.Put(buf) 96 | 97 | buf.Reset() 98 | w.writer.Reset(buf) 99 | 100 | if _, err := copyZeroAlloc(w.writer, rc); err != nil { 101 | return nil, err 102 | } 103 | 104 | return buf, nil 105 | } 106 | 107 | func (g *gzipEncoder) Decode(data []byte, vPtr interface{}) error { 108 | r, err := gzip.NewReader(bytes.NewReader(data)) 109 | if err != nil { 110 | return err 111 | } 112 | defer func() { 113 | _ = r.Close() 114 | }() 115 | 116 | if err := json.NewDecoder(r).Decode(vPtr); err != nil { 117 | return err 118 | } 119 | 120 | return nil 121 | } 122 | 123 | type flateEncoder struct { 124 | flWriterPool *sync.Pool 125 | bufferPool *sync.Pool 126 | } 127 | 128 | type flateWriter struct { 129 | writer *zlib.Writer 130 | err error 131 | } 132 | 133 | func (d *flateEncoder) Encode(rc io.Reader) (*bytes.Buffer, error) { 134 | w := d.flWriterPool.Get().(*flateWriter) 135 | defer d.flWriterPool.Put(w) 136 | 137 | if w.err != nil { 138 | return nil, w.err 139 | } 140 | 141 | defer func() { 142 | _ = w.writer.Close() 143 | }() 144 | 145 | buf := d.bufferPool.Get().(*bytes.Buffer) 146 | buf.Reset() 147 | w.writer.Reset(buf) 148 | 149 | if _, err := copyZeroAlloc(w.writer, rc); err != nil { 150 | return nil, err 151 | } 152 | 153 | return buf, nil 154 | } 155 | 156 | func (d *flateEncoder) Decode(data []byte, vPtr interface{}) error { 157 | r, err := zlib.NewReader(bytes.NewBuffer(data)) 158 | if err != nil { 159 | return err 160 | } 161 | 162 | defer func() { 163 | _ = r.Close() 164 | }() 165 | 166 | if err := json.NewDecoder(r).Decode(vPtr); err != nil { 167 | return err 168 | } 169 | 170 | return nil 171 | } 172 | 173 | type brotliEncoder struct { 174 | brWriterPool *sync.Pool 175 | bufferPool *sync.Pool 176 | } 177 | 178 | func (b *brotliEncoder) Encode(rc io.Reader) (*bytes.Buffer, error) { 179 | w := b.brWriterPool.Get().(*brotli.Writer) 180 | defer func() { 181 | _ = w.Close() 182 | b.brWriterPool.Put(w) 183 | }() 184 | 185 | buf := b.bufferPool.Get().(*bytes.Buffer) 186 | buf.Reset() 187 | w.Reset(buf) 188 | 189 | if _, err := copyZeroAlloc(w, rc); err != nil { 190 | return nil, err 191 | } 192 | 193 | return buf, nil 194 | } 195 | 196 | func (b *brotliEncoder) Decode(data []byte, vPtr interface{}) error { 197 | r := brotli.NewReader(bytes.NewBuffer(data)) 198 | if err := json.NewDecoder(r).Decode(vPtr); err != nil { 199 | return err 200 | } 201 | 202 | return nil 203 | } 204 | 205 | var copyBufPool = sync.Pool{ 206 | New: func() interface{} { 207 | return make([]byte, 4096) 208 | }, 209 | } 210 | 211 | func copyZeroAlloc(w io.Writer, r io.Reader) (int64, error) { 212 | if wt, ok := r.(io.WriterTo); ok { 213 | return wt.WriteTo(w) 214 | } 215 | if rt, ok := w.(io.ReaderFrom); ok { 216 | return rt.ReadFrom(r) 217 | } 218 | vbuf := copyBufPool.Get() 219 | buf := vbuf.([]byte) 220 | n, err := io.CopyBuffer(w, r, buf) 221 | copyBufPool.Put(vbuf) 222 | return n, err 223 | } 224 | -------------------------------------------------------------------------------- /encoding_bench_test.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "testing" 7 | ) 8 | 9 | func BenchmarkGzipEncoder(b *testing.B) { 10 | encoder := newEncoding(GzipEncoding, DefaultCompression) 11 | data := bytes.NewReader(make([]byte, 1024*1024)) // 1 MB of data 12 | b.ResetTimer() 13 | b.ReportAllocs() 14 | 15 | for i := 0; i < b.N; i++ { 16 | buf, err := encoder.Encode(data) 17 | if err != nil { 18 | b.Fatalf("Encode failed: %v", err) 19 | } 20 | _ = buf 21 | } 22 | } 23 | 24 | func BenchmarkDeflateEncoder(b *testing.B) { 25 | encoder := newEncoding(DeflateEncoding, DefaultCompression) 26 | data := bytes.NewReader(make([]byte, 1024*1024)) // 1 MB of data 27 | b.ResetTimer() 28 | b.ReportAllocs() 29 | 30 | for i := 0; i < b.N; i++ { 31 | buf, err := encoder.Encode(data) 32 | if err != nil { 33 | b.Fatalf("Encode failed: %v", err) 34 | } 35 | _ = buf 36 | } 37 | } 38 | 39 | func BenchmarkBrotliEncoder(b *testing.B) { 40 | encoder := newEncoding(BrotliEncoding, DefaultCompression) 41 | data := bytes.NewReader(make([]byte, 1024*1024)) // 1 MB of data 42 | b.ResetTimer() 43 | b.ReportAllocs() 44 | 45 | for i := 0; i < b.N; i++ { 46 | buf, err := encoder.Encode(data) 47 | if err != nil { 48 | b.Fatalf("Encode failed: %v", err) 49 | } 50 | _ = buf 51 | } 52 | } 53 | 54 | func BenchmarkGzipDecoder(b *testing.B) { 55 | encoder := newEncoding(GzipEncoding, DefaultCompression) 56 | 57 | // Prepare a valid JSON input 58 | data := map[string]interface{}{ 59 | "key1": "value1", 60 | "key2": 12345, 61 | "key3": []string{"item1", "item2", "item3"}, 62 | } 63 | jsonData, err := json.Marshal(data) 64 | if err != nil { 65 | b.Fatalf("JSON marshal failed: %v", err) 66 | } 67 | 68 | // Encode the valid JSON data 69 | input := bytes.NewReader(jsonData) 70 | encoded, err := encoder.Encode(input) 71 | if err != nil { 72 | b.Fatalf("Encode failed: %v", err) 73 | } 74 | 75 | b.ResetTimer() 76 | b.ReportAllocs() 77 | 78 | for i := 0; i < b.N; i++ { 79 | var result map[string]interface{} 80 | if err := encoder.Decode(encoded.Bytes(), &result); err != nil { 81 | b.Fatalf("Decode failed: %v", err) 82 | } 83 | } 84 | } 85 | 86 | func BenchmarkFlateDecoder(b *testing.B) { 87 | encoder := newEncoding(DeflateEncoding, DefaultCompression) 88 | 89 | // Prepare valid JSON input 90 | data := map[string]interface{}{ 91 | "key1": "value1", 92 | "key2": 12345, 93 | "key3": []string{"item1", "item2", "item3"}, 94 | } 95 | jsonData, err := json.Marshal(data) 96 | if err != nil { 97 | b.Fatalf("JSON marshal failed: %v", err) 98 | } 99 | 100 | // Encode the valid JSON data 101 | input := bytes.NewReader(jsonData) 102 | encoded, err := encoder.Encode(input) 103 | if err != nil { 104 | b.Fatalf("Encode failed: %v", err) 105 | } 106 | 107 | b.ResetTimer() 108 | b.ReportAllocs() 109 | 110 | for i := 0; i < b.N; i++ { 111 | var result map[string]interface{} 112 | if err := encoder.Decode(encoded.Bytes(), &result); err != nil { 113 | b.Fatalf("Decode failed: %v", err) 114 | } 115 | } 116 | } 117 | 118 | func BenchmarkBrotliDecoder(b *testing.B) { 119 | encoder := newEncoding(BrotliEncoding, DefaultCompression) 120 | 121 | // Prepare valid JSON input 122 | data := map[string]interface{}{ 123 | "key1": "value1", 124 | "key2": 12345, 125 | "key3": []string{"item1", "item2", "item3"}, 126 | } 127 | jsonData, err := json.Marshal(data) 128 | if err != nil { 129 | b.Fatalf("JSON marshal failed: %v", err) 130 | } 131 | 132 | // Encode the valid JSON data 133 | input := bytes.NewReader(jsonData) 134 | encoded, err := encoder.Encode(input) 135 | if err != nil { 136 | b.Fatalf("Encode failed: %v", err) 137 | } 138 | 139 | b.ResetTimer() 140 | b.ReportAllocs() 141 | 142 | for i := 0; i < b.N; i++ { 143 | var result map[string]interface{} 144 | if err := encoder.Decode(encoded.Bytes(), &result); err != nil { 145 | b.Fatalf("Decode failed: %v", err) 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /encoding_test.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "bytes" 5 | "compress/flate" 6 | "compress/gzip" 7 | "compress/zlib" 8 | "encoding/json" 9 | "errors" 10 | "github.com/andybalholm/brotli" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "io" 14 | "strings" 15 | "sync" 16 | "testing" 17 | ) 18 | 19 | type mockData struct { 20 | Name string 21 | Age int 22 | } 23 | 24 | type errorWriter struct{} 25 | 26 | func (e *errorWriter) Write(p []byte) (int, error) { 27 | return 0, errors.New("write error") 28 | } 29 | 30 | type errorReader struct{} 31 | 32 | func (e *errorReader) Read(p []byte) (int, error) { 33 | return 0, errors.New("read error") 34 | } 35 | 36 | func Test_Encode_ErrorOnNewWriter(t *testing.T) { 37 | g := &gzipEncoder{ 38 | gzWriterPool: &sync.Pool{ 39 | New: func() interface{} { 40 | return &gzipWriter{ 41 | writer: nil, 42 | err: errors.New("new writer error"), 43 | } 44 | }, 45 | }, 46 | bufferPool: &sync.Pool{ 47 | New: func() interface{} { 48 | return new(bytes.Buffer) 49 | }, 50 | }, 51 | } 52 | 53 | d := &flateEncoder{ 54 | flWriterPool: &sync.Pool{ 55 | New: func() interface{} { 56 | return &flateWriter{ 57 | writer: nil, 58 | err: errors.New("new writer error"), 59 | } 60 | }, 61 | }, 62 | bufferPool: &sync.Pool{ 63 | New: func() interface{} { 64 | return new(bytes.Buffer) 65 | }, 66 | }, 67 | } 68 | 69 | _, err := g.Encode(bytes.NewReader([]byte("test"))) 70 | require.Error(t, err) 71 | 72 | _, err = d.Encode(bytes.NewReader([]byte("test"))) 73 | require.Error(t, err) 74 | } 75 | 76 | func Test_Encode_ErrorInCopyZeroAlloc(t *testing.T) { 77 | g := &gzipEncoder{ 78 | gzWriterPool: &sync.Pool{ 79 | New: func() interface{} { 80 | w, _ := gzip.NewWriterLevel(io.Discard, gzip.DefaultCompression) 81 | return &gzipWriter{ 82 | writer: w, 83 | err: nil, 84 | } 85 | }, 86 | }, 87 | bufferPool: &sync.Pool{ 88 | New: func() interface{} { 89 | return new(bytes.Buffer) 90 | }, 91 | }, 92 | } 93 | 94 | d := &flateEncoder{ 95 | flWriterPool: &sync.Pool{ 96 | New: func() interface{} { 97 | w, err := zlib.NewWriterLevel(io.Discard, flate.DefaultCompression) 98 | return &flateWriter{ 99 | writer: w, 100 | err: err, 101 | } 102 | }, 103 | }, 104 | bufferPool: &sync.Pool{ 105 | New: func() interface{} { 106 | return new(bytes.Buffer) 107 | }, 108 | }, 109 | } 110 | 111 | b := &brotliEncoder{ 112 | brWriterPool: &sync.Pool{ 113 | New: func() interface{} { 114 | return brotli.NewWriterLevel(io.Discard, brotli.DefaultCompression) 115 | }, 116 | }, 117 | bufferPool: &sync.Pool{ 118 | New: func() interface{} { 119 | return new(bytes.Buffer) 120 | }, 121 | }, 122 | } 123 | 124 | _, err := g.Encode(&errorReader{}) 125 | require.Error(t, err) 126 | 127 | _, err = d.Encode(&errorReader{}) 128 | require.Error(t, err) 129 | 130 | _, err = b.Encode(&errorReader{}) 131 | require.Error(t, err) 132 | } 133 | 134 | func Test_InvalidContentType(t *testing.T) { 135 | enc := newEncoding("invalid", DefaultCompression) 136 | require.Nil(t, enc) 137 | } 138 | 139 | func TestGzipEncoder(t *testing.T) { 140 | encoder := newEncoding(GzipEncoding, DefaultCompression) 141 | assert.NotNil(t, encoder, "gzip encoder should not be nil") 142 | 143 | original := &mockData{Name: "John Doe", Age: 30} 144 | 145 | originalJSON, err := json.Marshal(original) 146 | assert.NoError(t, err, "marshalling original data should not produce an error") 147 | 148 | readCloser := io.NopCloser(bytes.NewReader(originalJSON)) 149 | 150 | encodedData, err := encoder.Encode(readCloser) 151 | assert.NoError(t, err, "encoding should not produce an error") 152 | assert.NotNil(t, encodedData, "encoded data should not be nil") 153 | 154 | var decoded mockData 155 | err = encoder.Decode(encodedData.Bytes(), &decoded) 156 | assert.NoError(t, err, "decoding should not produce an error") 157 | assert.Equal(t, original, &decoded, "decoded data should match the original") 158 | 159 | var invalidType int 160 | err = encoder.Decode(encodedData.Bytes(), &invalidType) 161 | assert.Error(t, err) 162 | } 163 | 164 | func TestDeflateEncoder(t *testing.T) { 165 | encoder := newEncoding(DeflateEncoding, DefaultCompression) 166 | assert.NotNil(t, encoder, "deflate encoder should not be nil") 167 | 168 | original := &mockData{Name: "Jane Doe", Age: 25} 169 | 170 | originalJSON, err := json.Marshal(original) 171 | assert.NoError(t, err, "marshalling original data should not produce an error") 172 | 173 | readCloser := io.NopCloser(bytes.NewReader(originalJSON)) 174 | 175 | encodedData, err := encoder.Encode(readCloser) 176 | assert.NoError(t, err, "encoding should not produce an error") 177 | assert.NotNil(t, encodedData, "encoded data should not be nil") 178 | 179 | var decoded mockData 180 | err = encoder.Decode(encodedData.Bytes(), &decoded) 181 | assert.NoError(t, err, "decoding should not produce an error") 182 | assert.Equal(t, original, &decoded, "decoded data should match the original") 183 | 184 | var invalidType int 185 | err = encoder.Decode(encodedData.Bytes(), &invalidType) 186 | assert.Error(t, err) 187 | } 188 | 189 | func TestBrotliEncoder(t *testing.T) { 190 | encoder := newEncoding(BrotliEncoding, DefaultCompression) 191 | assert.NotNil(t, encoder, "brotli encoder should not be nil") 192 | 193 | original := &mockData{Name: "Jane Doe", Age: 25} 194 | 195 | originalJSON, err := json.Marshal(original) 196 | assert.NoError(t, err, "marshalling original data should not produce an error") 197 | 198 | readCloser := io.NopCloser(bytes.NewReader(originalJSON)) 199 | 200 | encodedData, err := encoder.Encode(readCloser) 201 | assert.NoError(t, err, "encoding should not produce an error") 202 | assert.NotNil(t, encodedData, "encoded data should not be nil") 203 | 204 | var decoded mockData 205 | err = encoder.Decode(encodedData.Bytes(), &decoded) 206 | assert.NoError(t, err, "decoding should not produce an error") 207 | assert.Equal(t, original, &decoded, "decoded data should match the original") 208 | 209 | var invalidType int 210 | err = encoder.Decode(encodedData.Bytes(), &invalidType) 211 | assert.Error(t, err) 212 | } 213 | 214 | func TestGzipEncoder_EmptyData(t *testing.T) { 215 | encoder := newEncoding(GzipEncoding, DefaultCompression) 216 | assert.NotNil(t, encoder, "gzip encoder should not be nil") 217 | 218 | original := &mockData{} 219 | 220 | originalJSON, err := json.Marshal(original) 221 | assert.NoError(t, err, "marshalling original data should not produce an error") 222 | 223 | readCloser := io.NopCloser(bytes.NewReader(originalJSON)) 224 | 225 | encodedData, err := encoder.Encode(readCloser) 226 | assert.NoError(t, err, "encoding should not produce an error") 227 | assert.NotNil(t, encodedData, "encoded data should not be nil") 228 | 229 | var decoded mockData 230 | err = encoder.Decode(encodedData.Bytes(), &decoded) 231 | assert.NoError(t, err, "decoding should not produce an error") 232 | assert.Equal(t, original, &decoded, "decoded data should match the original") 233 | } 234 | 235 | func TestDeflateEncoder_EmptyData(t *testing.T) { 236 | encoder := newEncoding(DeflateEncoding, DefaultCompression) 237 | assert.NotNil(t, encoder, "deflate encoder should not be nil") 238 | 239 | original := &mockData{} 240 | 241 | originalJSON, err := json.Marshal(original) 242 | assert.NoError(t, err, "marshalling original data should not produce an error") 243 | 244 | readCloser := io.NopCloser(bytes.NewReader(originalJSON)) 245 | 246 | encodedData, err := encoder.Encode(readCloser) 247 | assert.NoError(t, err, "encoding should not produce an error") 248 | assert.NotNil(t, encodedData, "encoded data should not be nil") 249 | 250 | var decoded mockData 251 | err = encoder.Decode(encodedData.Bytes(), &decoded) 252 | assert.NoError(t, err, "decoding should not produce an error") 253 | assert.Equal(t, original, &decoded, "decoded data should match the original") 254 | } 255 | 256 | func TestBrotliEncoder_EmptyData(t *testing.T) { 257 | encoder := newEncoding(BrotliEncoding, DefaultCompression) 258 | assert.NotNil(t, encoder, "brotli encoder should not be nil") 259 | 260 | original := &mockData{} 261 | 262 | originalJSON, err := json.Marshal(original) 263 | assert.NoError(t, err, "marshalling original data should not produce an error") 264 | 265 | readCloser := io.NopCloser(bytes.NewReader(originalJSON)) 266 | 267 | encodedData, err := encoder.Encode(readCloser) 268 | assert.NoError(t, err, "encoding should not produce an error") 269 | assert.NotNil(t, encodedData, "encoded data should not be nil") 270 | 271 | var decoded mockData 272 | err = encoder.Decode(encodedData.Bytes(), &decoded) 273 | assert.NoError(t, err, "decoding should not produce an error") 274 | assert.Equal(t, original, &decoded, "decoded data should match the original") 275 | } 276 | 277 | func TestGzipEncoder_InvalidData(t *testing.T) { 278 | encoder := newEncoding(GzipEncoding, DefaultCompression) 279 | assert.NotNil(t, encoder, "gzip encoder should not be nil") 280 | 281 | var decoded mockData 282 | err := encoder.Decode([]byte("invalid data"), &decoded) 283 | assert.Error(t, err, "decoding invalid data should produce an error") 284 | } 285 | 286 | func TestDeflateEncoder_InvalidData(t *testing.T) { 287 | encoder := newEncoding(DeflateEncoding, DefaultCompression) 288 | assert.NotNil(t, encoder, "deflate encoder should not be nil") 289 | 290 | var decoded mockData 291 | err := encoder.Decode([]byte("invalid data"), &decoded) 292 | assert.Error(t, err, "decoding invalid data should produce an error") 293 | } 294 | 295 | func TestBrotliEncoder_InvalidData(t *testing.T) { 296 | encoder := newEncoding(BrotliEncoding, DefaultCompression) 297 | assert.NotNil(t, encoder, "brotli encoder should not be nil") 298 | 299 | var decoded mockData 300 | err := encoder.Decode([]byte("invalid data"), &decoded) 301 | assert.Error(t, err, "decoding invalid data should produce an error") 302 | } 303 | 304 | func TestCopyZeroAlloc(t *testing.T) { 305 | t.Run("RegularCopy", func(t *testing.T) { 306 | src := strings.NewReader("hello world") 307 | dst := &bytes.Buffer{} 308 | 309 | n, err := copyZeroAlloc(dst, src) 310 | assert.NoError(t, err, "copy should not produce an error") 311 | assert.Equal(t, int64(11), n, "copy length should be 11") 312 | assert.Equal(t, "hello world", dst.String(), "destination should contain the copied data") 313 | }) 314 | 315 | t.Run("EmptySource", func(t *testing.T) { 316 | src := strings.NewReader("") 317 | dst := &bytes.Buffer{} 318 | 319 | n, err := copyZeroAlloc(dst, src) 320 | assert.NoError(t, err, "copy should not produce an error") 321 | assert.Equal(t, int64(0), n, "copy length should be 0") 322 | assert.Equal(t, "", dst.String(), "destination should be empty") 323 | }) 324 | 325 | t.Run("LargeDataCopy", func(t *testing.T) { 326 | data := strings.Repeat("a", 10000) 327 | src := strings.NewReader(data) 328 | dst := &bytes.Buffer{} 329 | 330 | n, err := copyZeroAlloc(dst, src) 331 | assert.NoError(t, err, "copy should not produce an error") 332 | assert.Equal(t, int64(len(data)), n, "copy length should match the source data length") 333 | assert.Equal(t, data, dst.String(), "destination should contain the copied data") 334 | }) 335 | 336 | t.Run("ErrorOnWrite", func(t *testing.T) { 337 | src := strings.NewReader("hello world") 338 | dst := &errorWriter{} 339 | 340 | n, err := copyZeroAlloc(dst, src) 341 | assert.Error(t, err, "copy should produce an error") 342 | assert.Equal(t, int64(0), n, "copy length should be 0 due to the error") 343 | assert.Equal(t, "write error", err.Error(), "error should match expected error") 344 | }) 345 | 346 | t.Run("ErrorOnRead", func(t *testing.T) { 347 | src := &errorReader{} 348 | dst := &bytes.Buffer{} 349 | 350 | n, err := copyZeroAlloc(dst, src) 351 | assert.Error(t, err, "copy should produce an error") 352 | assert.Equal(t, int64(0), n, "copy length should be 0 due to the error") 353 | assert.Equal(t, "read error", err.Error(), "error should match expected error") 354 | }) 355 | 356 | t.Run("ConcurrentAccess", func(t *testing.T) { 357 | var wg sync.WaitGroup 358 | data := "concurrent data" 359 | var mu sync.Mutex 360 | dst := &bytes.Buffer{} 361 | 362 | for i := 0; i < 10; i++ { 363 | wg.Add(1) 364 | go func() { 365 | defer wg.Done() 366 | src := strings.NewReader(data) // each goroutine gets its own reader 367 | buf := &bytes.Buffer{} // each goroutine uses a separate buffer 368 | _, _ = copyZeroAlloc(buf, src) 369 | mu.Lock() 370 | defer mu.Unlock() 371 | dst.Write(buf.Bytes()) // safely combine results 372 | }() 373 | } 374 | wg.Wait() 375 | 376 | mu.Lock() 377 | assert.Equal(t, strings.Repeat(data, 10), dst.String(), "destination should contain the copied data") 378 | mu.Unlock() 379 | }) 380 | } 381 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | // ErrCode are all possible errors found during requests 11 | type ErrCode int 12 | 13 | const ( 14 | // ErrCodeUnknown default error code, undefined 15 | ErrCodeUnknown ErrCode = 0 16 | // ErrCodeMarshalRequest impossible to serialize request body 17 | ErrCodeMarshalRequest ErrCode = iota + 1 18 | // ErrCodeResponseUnmarshalBody impossible deserialize the response body 19 | ErrCodeResponseUnmarshalBody 20 | // MeilisearchApiError send by the meilisearch api 21 | MeilisearchApiError 22 | // MeilisearchApiErrorWithoutMessage MeilisearchApiError send by the meilisearch api 23 | MeilisearchApiErrorWithoutMessage 24 | // MeilisearchTimeoutError 25 | MeilisearchTimeoutError 26 | // MeilisearchCommunicationError impossible execute a request 27 | MeilisearchCommunicationError 28 | // MeilisearchMaxRetriesExceeded used max retries and exceeded 29 | MeilisearchMaxRetriesExceeded 30 | ) 31 | 32 | const ( 33 | rawStringCtx = `(path "${method} ${endpoint}" with method "${function}")` 34 | rawStringMarshalRequest = `unable to marshal body from request: '${request}'` 35 | rawStringResponseUnmarshalBody = `unable to unmarshal body from response: '${response}' status code: ${statusCode}` 36 | rawStringMeilisearchApiError = `unaccepted status code found: ${statusCode} expected: ${statusCodeExpected}, MeilisearchApiError Message: ${message}, Code: ${code}, Type: ${type}, Link: ${link}` 37 | rawStringMeilisearchApiErrorWithoutMessage = `unaccepted status code found: ${statusCode} expected: ${statusCodeExpected}, MeilisearchApiError Message: ${message}` 38 | rawStringMeilisearchTimeoutError = `MeilisearchTimeoutError` 39 | rawStringMeilisearchCommunicationError = `MeilisearchCommunicationError unable to execute request` 40 | rawStringMeilisearchMaxRetriesExceeded = "failed to request and max retries exceeded" 41 | ) 42 | 43 | func (e ErrCode) rawMessage() string { 44 | switch e { 45 | case ErrCodeMarshalRequest: 46 | return rawStringMarshalRequest + " " + rawStringCtx 47 | case ErrCodeResponseUnmarshalBody: 48 | return rawStringResponseUnmarshalBody + " " + rawStringCtx 49 | case MeilisearchApiError: 50 | return rawStringMeilisearchApiError + " " + rawStringCtx 51 | case MeilisearchApiErrorWithoutMessage: 52 | return rawStringMeilisearchApiErrorWithoutMessage + " " + rawStringCtx 53 | case MeilisearchTimeoutError: 54 | return rawStringMeilisearchTimeoutError + " " + rawStringCtx 55 | case MeilisearchCommunicationError: 56 | return rawStringMeilisearchCommunicationError + " " + rawStringCtx 57 | case MeilisearchMaxRetriesExceeded: 58 | return rawStringMeilisearchMaxRetriesExceeded + " " + rawStringCtx 59 | default: 60 | return rawStringCtx 61 | } 62 | } 63 | 64 | type meilisearchApiError struct { 65 | Message string `json:"message"` 66 | Code string `json:"code"` 67 | Type string `json:"type"` 68 | Link string `json:"link"` 69 | } 70 | 71 | // Error is the internal error structure that all exposed method use. 72 | // So ALL errors returned by this library can be cast to this struct (as a pointer) 73 | type Error struct { 74 | // Endpoint is the path of the request (host is not in) 75 | Endpoint string 76 | 77 | // Method is the HTTP verb of the request 78 | Method string 79 | 80 | // Function name used 81 | Function string 82 | 83 | // RequestToString is the raw request into string ('empty request' if not present) 84 | RequestToString string 85 | 86 | // RequestToString is the raw request into string ('empty response' if not present) 87 | ResponseToString string 88 | 89 | // Error info from meilisearch api 90 | // Message is the raw request into string ('empty meilisearch message' if not present) 91 | MeilisearchApiError meilisearchApiError 92 | 93 | // StatusCode of the request 94 | StatusCode int 95 | 96 | // StatusCode expected by the endpoint to be considered as a success 97 | StatusCodeExpected []int 98 | 99 | rawMessage string 100 | 101 | // OriginError is the origin error that produce the current Error. It can be nil in case of a bad status code. 102 | OriginError error 103 | 104 | // ErrCode is the internal error code that represent the different step when executing a request that can produce 105 | // an error. 106 | ErrCode ErrCode 107 | 108 | encoder 109 | } 110 | 111 | // Error return a well human formatted message. 112 | func (e *Error) Error() string { 113 | message := namedSprintf(e.rawMessage, map[string]interface{}{ 114 | "endpoint": e.Endpoint, 115 | "method": e.Method, 116 | "function": e.Function, 117 | "request": e.RequestToString, 118 | "response": e.ResponseToString, 119 | "statusCodeExpected": e.StatusCodeExpected, 120 | "statusCode": e.StatusCode, 121 | "message": e.MeilisearchApiError.Message, 122 | "code": e.MeilisearchApiError.Code, 123 | "type": e.MeilisearchApiError.Type, 124 | "link": e.MeilisearchApiError.Link, 125 | }) 126 | if e.OriginError != nil { 127 | return fmt.Sprintf("%s: %s", message, e.OriginError.Error()) 128 | } 129 | 130 | return message 131 | } 132 | 133 | // WithErrCode add an error code to an error 134 | func (e *Error) WithErrCode(err ErrCode, errs ...error) *Error { 135 | if errs != nil { 136 | e.OriginError = errs[0] 137 | } 138 | 139 | e.rawMessage = err.rawMessage() 140 | e.ErrCode = err 141 | return e 142 | } 143 | 144 | // ErrorBody add a body to an error 145 | func (e *Error) ErrorBody(body []byte) { 146 | msg := meilisearchApiError{} 147 | 148 | if e.encoder != nil { 149 | err := e.encoder.Decode(body, &msg) 150 | if err == nil { 151 | e.MeilisearchApiError.Message = msg.Message 152 | e.MeilisearchApiError.Code = msg.Code 153 | e.MeilisearchApiError.Type = msg.Type 154 | e.MeilisearchApiError.Link = msg.Link 155 | } 156 | return 157 | } 158 | 159 | e.ResponseToString = string(body) 160 | err := json.Unmarshal(body, &msg) 161 | if err == nil { 162 | e.MeilisearchApiError.Message = msg.Message 163 | e.MeilisearchApiError.Code = msg.Code 164 | e.MeilisearchApiError.Type = msg.Type 165 | e.MeilisearchApiError.Link = msg.Link 166 | } 167 | } 168 | 169 | // VersionErrorHintMessage a hint to the error message if it may come from a version incompatibility with meilisearch 170 | func VersionErrorHintMessage(err error, req *internalRequest) error { 171 | return fmt.Errorf("%w. Hint: It might not be working because you're not up to date with the "+ 172 | "Meilisearch version that %s call requires", err, req.functionName) 173 | } 174 | 175 | func namedSprintf(format string, params map[string]interface{}) string { 176 | for key, val := range params { 177 | format = strings.ReplaceAll(format, "${"+key+"}", fmt.Sprintf("%v", val)) 178 | } 179 | return format 180 | } 181 | 182 | // General errors 183 | var ( 184 | ErrInvalidRequestMethod = errors.New("request body is not expected for GET and HEAD requests") 185 | ErrRequestBodyWithoutContentType = errors.New("request body without Content-Type is not allowed") 186 | ErrNoSearchRequest = errors.New("no search request provided") 187 | ErrNoFacetSearchRequest = errors.New("no search facet request provided") 188 | ErrConnectingFailed = errors.New("meilisearch is not connected") 189 | ) 190 | -------------------------------------------------------------------------------- /error_test.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestError_VersionErrorHintMessage(t *testing.T) { 12 | type args struct { 13 | request *internalRequest 14 | mockedError error 15 | } 16 | tests := []struct { 17 | name string 18 | args args 19 | }{ 20 | { 21 | name: "VersionErrorHintMessageGetDocument", 22 | args: args{ 23 | request: &internalRequest{ 24 | functionName: "GetDocuments", 25 | }, 26 | mockedError: &Error{ 27 | Endpoint: "endpointForGetDocuments", 28 | Method: http.MethodPost, 29 | Function: "GetDocuments", 30 | RequestToString: "empty request", 31 | ResponseToString: "empty response", 32 | MeilisearchApiError: meilisearchApiError{ 33 | Message: "empty Meilisearch message", 34 | }, 35 | StatusCode: 1, 36 | rawMessage: "empty message", 37 | }, 38 | }, 39 | }, 40 | } 41 | for _, tt := range tests { 42 | t.Run(tt.name, func(t *testing.T) { 43 | err := VersionErrorHintMessage(tt.args.mockedError, tt.args.request) 44 | require.Error(t, err) 45 | fmt.Println(err) 46 | require.Equal(t, tt.args.mockedError.Error()+". Hint: It might not be working because you're not up to date with the Meilisearch version that "+tt.args.request.functionName+" call requires", err.Error()) 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func ExampleNew() { 9 | // WithAPIKey is optional 10 | meili := New("http://localhost:7700", WithAPIKey("foobar")) 11 | 12 | // An index is where the documents are stored. 13 | idx := meili.Index("movies") 14 | 15 | // If the index 'movies' does not exist, Meilisearch creates it when you first add the documents. 16 | documents := []map[string]interface{}{ 17 | {"id": 1, "title": "Carol", "genres": []string{"Romance", "Drama"}}, 18 | {"id": 2, "title": "Wonder Woman", "genres": []string{"Action", "Adventure"}}, 19 | {"id": 3, "title": "Life of Pi", "genres": []string{"Adventure", "Drama"}}, 20 | {"id": 4, "title": "Mad Max: Fury Road", "genres": []string{"Adventure", "Science Fiction"}}, 21 | {"id": 5, "title": "Moana", "genres": []string{"Fantasy", "Action"}}, 22 | {"id": 6, "title": "Philadelphia", "genres": []string{"Drama"}}, 23 | } 24 | task, err := idx.AddDocuments(documents) 25 | if err != nil { 26 | fmt.Println(err) 27 | os.Exit(1) 28 | } 29 | 30 | fmt.Println(task.TaskUID) 31 | } 32 | 33 | func ExampleConnect() { 34 | meili, err := Connect("http://localhost:7700", WithAPIKey("foobar")) 35 | if err != nil { 36 | fmt.Println(err) 37 | return 38 | } 39 | 40 | ver, err := meili.Version() 41 | if err != nil { 42 | fmt.Println(err) 43 | return 44 | } 45 | 46 | fmt.Println(ver.PkgVersion) 47 | } 48 | -------------------------------------------------------------------------------- /features.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | // Type for experimental features with additional client field 9 | type ExperimentalFeatures struct { 10 | client *client 11 | ExperimentalFeaturesBase 12 | } 13 | 14 | func (m *meilisearch) ExperimentalFeatures() *ExperimentalFeatures { 15 | return &ExperimentalFeatures{client: m.client} 16 | } 17 | 18 | func (ef *ExperimentalFeatures) SetLogsRoute(logsRoute bool) *ExperimentalFeatures { 19 | ef.LogsRoute = &logsRoute 20 | return ef 21 | } 22 | 23 | func (ef *ExperimentalFeatures) SetMetrics(metrics bool) *ExperimentalFeatures { 24 | ef.Metrics = &metrics 25 | return ef 26 | } 27 | 28 | func (ef *ExperimentalFeatures) SetEditDocumentsByFunction(editDocumentsByFunction bool) *ExperimentalFeatures { 29 | ef.EditDocumentsByFunction = &editDocumentsByFunction 30 | return ef 31 | } 32 | 33 | func (ef *ExperimentalFeatures) SetContainsFilter(containsFilter bool) *ExperimentalFeatures { 34 | ef.ContainsFilter = &containsFilter 35 | return ef 36 | } 37 | 38 | func (ef *ExperimentalFeatures) Get() (*ExperimentalFeaturesResult, error) { 39 | return ef.GetWithContext(context.Background()) 40 | } 41 | 42 | func (ef *ExperimentalFeatures) GetWithContext(ctx context.Context) (*ExperimentalFeaturesResult, error) { 43 | resp := new(ExperimentalFeaturesResult) 44 | req := &internalRequest{ 45 | endpoint: "/experimental-features", 46 | method: http.MethodGet, 47 | withRequest: nil, 48 | withResponse: &resp, 49 | withQueryParams: map[string]string{}, 50 | acceptedStatusCodes: []int{http.StatusOK}, 51 | functionName: "GetExperimentalFeatures", 52 | } 53 | 54 | if err := ef.client.executeRequest(ctx, req); err != nil { 55 | return nil, err 56 | } 57 | 58 | return resp, nil 59 | } 60 | 61 | func (ef *ExperimentalFeatures) Update() (*ExperimentalFeaturesResult, error) { 62 | return ef.UpdateWithContext(context.Background()) 63 | } 64 | 65 | func (ef *ExperimentalFeatures) UpdateWithContext(ctx context.Context) (*ExperimentalFeaturesResult, error) { 66 | request := ExperimentalFeaturesBase{ 67 | LogsRoute: ef.LogsRoute, 68 | Metrics: ef.Metrics, 69 | EditDocumentsByFunction: ef.EditDocumentsByFunction, 70 | ContainsFilter: ef.ContainsFilter, 71 | } 72 | resp := new(ExperimentalFeaturesResult) 73 | req := &internalRequest{ 74 | endpoint: "/experimental-features", 75 | method: http.MethodPatch, 76 | contentType: contentTypeJSON, 77 | withRequest: request, 78 | withResponse: resp, 79 | withQueryParams: nil, 80 | acceptedStatusCodes: []int{http.StatusOK}, 81 | functionName: "UpdateExperimentalFeatures", 82 | } 83 | if err := ef.client.executeRequest(ctx, req); err != nil { 84 | return nil, err 85 | } 86 | return resp, nil 87 | } 88 | -------------------------------------------------------------------------------- /features_test.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "crypto/tls" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestGet_ExperimentalFeatures(t *testing.T) { 11 | sv := setup(t, "") 12 | customSv := setup(t, "", WithCustomClientWithTLS(&tls.Config{ 13 | InsecureSkipVerify: true, 14 | })) 15 | 16 | tests := []struct { 17 | name string 18 | client ServiceManager 19 | }{ 20 | { 21 | name: "TestGetStats", 22 | client: sv, 23 | }, 24 | { 25 | name: "TestGetStatsWithCustomClient", 26 | client: customSv, 27 | }, 28 | } 29 | 30 | for _, tt := range tests { 31 | t.Run(tt.name, func(t *testing.T) { 32 | ef := tt.client.ExperimentalFeatures() 33 | gotResp, err := ef.Get() 34 | require.NoError(t, err) 35 | require.NotNil(t, gotResp, "ExperimentalFeatures.Get() should not return nil value") 36 | }) 37 | } 38 | } 39 | 40 | func TestUpdate_ExperimentalFeatures(t *testing.T) { 41 | sv := setup(t, "") 42 | customSv := setup(t, "", WithCustomClientWithTLS(&tls.Config{ 43 | InsecureSkipVerify: true, 44 | })) 45 | 46 | tests := []struct { 47 | name string 48 | client ServiceManager 49 | }{ 50 | { 51 | name: "TestUpdateStats", 52 | client: sv, 53 | }, 54 | { 55 | name: "TestUpdateStatsWithCustomClient", 56 | client: customSv, 57 | }, 58 | } 59 | 60 | for _, tt := range tests { 61 | t.Run(tt.name, func(t *testing.T) { 62 | ef := tt.client.ExperimentalFeatures() 63 | ef.SetLogsRoute(true) 64 | ef.SetMetrics(true) 65 | ef.SetEditDocumentsByFunction(true) 66 | ef.SetContainsFilter(true) 67 | gotResp, err := ef.Update() 68 | require.NoError(t, err) 69 | require.Equal(t, true, gotResp.LogsRoute, "ExperimentalFeatures.Update() should return logsRoute as true") 70 | require.Equal(t, true, gotResp.Metrics, "ExperimentalFeatures.Update() should return metrics as true") 71 | require.Equal(t, true, gotResp.EditDocumentsByFunction, "ExperimentalFeatures.Update() should return editDocumentsByFunction as true") 72 | require.Equal(t, true, gotResp.ContainsFilter, "ExperimentalFeatures.Update() should return containsFilter as true") 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/meilisearch/meilisearch-go 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/andybalholm/brotli v1.1.1 7 | github.com/golang-jwt/jwt/v4 v4.5.1 8 | github.com/mailru/easyjson v0.9.0 9 | github.com/stretchr/testify v1.8.2 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= 2 | github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= 7 | github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 8 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 9 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 10 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 11 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 15 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 16 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 17 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 18 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 19 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 20 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 21 | github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= 22 | github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 23 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 24 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 25 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 26 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 27 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 28 | -------------------------------------------------------------------------------- /helper.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/csv" 7 | "encoding/json" 8 | "fmt" 9 | "net/url" 10 | "regexp" 11 | "strconv" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | func IsValidUUID(uuid string) bool { 17 | r := regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$") 18 | return r.MatchString(uuid) 19 | } 20 | 21 | // This function allows the user to create a Key with an ExpiresAt in time.Time 22 | // and transform the Key structure into a KeyParsed structure to send the time format 23 | // managed by meilisearch 24 | func convertKeyToParsedKey(key Key) (resp KeyParsed) { 25 | resp = KeyParsed{ 26 | Name: key.Name, 27 | Description: key.Description, 28 | UID: key.UID, 29 | Actions: key.Actions, 30 | Indexes: key.Indexes, 31 | } 32 | 33 | // Convert time.Time to *string to feat the exact ISO-8601 34 | // format of meilisearch 35 | if !key.ExpiresAt.IsZero() { 36 | resp.ExpiresAt = formatDate(key.ExpiresAt, true) 37 | } 38 | return resp 39 | } 40 | 41 | func encodeTasksQuery(param *TasksQuery, req *internalRequest) { 42 | if param.Limit != 0 { 43 | req.withQueryParams["limit"] = strconv.FormatInt(param.Limit, 10) 44 | } 45 | if param.From != 0 { 46 | req.withQueryParams["from"] = strconv.FormatInt(param.From, 10) 47 | } 48 | if len(param.Statuses) != 0 { 49 | var statuses []string 50 | for _, status := range param.Statuses { 51 | statuses = append(statuses, string(status)) 52 | } 53 | req.withQueryParams["statuses"] = strings.Join(statuses, ",") 54 | } 55 | if len(param.Types) != 0 { 56 | var types []string 57 | for _, t := range param.Types { 58 | types = append(types, string(t)) 59 | } 60 | req.withQueryParams["types"] = strings.Join(types, ",") 61 | } 62 | if len(param.IndexUIDS) != 0 { 63 | req.withQueryParams["indexUids"] = strings.Join(param.IndexUIDS, ",") 64 | } 65 | if len(param.UIDS) != 0 { 66 | req.withQueryParams["uids"] = strings.Trim(strings.Join(strings.Fields(fmt.Sprint(param.UIDS)), ","), "[]") 67 | } 68 | if len(param.CanceledBy) != 0 { 69 | req.withQueryParams["canceledBy"] = strings.Trim(strings.Join(strings.Fields(fmt.Sprint(param.CanceledBy)), ","), "[]") 70 | } 71 | if !param.BeforeEnqueuedAt.IsZero() { 72 | req.withQueryParams["beforeEnqueuedAt"] = *formatDate(param.BeforeEnqueuedAt, false) 73 | } 74 | if !param.AfterEnqueuedAt.IsZero() { 75 | req.withQueryParams["afterEnqueuedAt"] = *formatDate(param.AfterEnqueuedAt, false) 76 | } 77 | if !param.BeforeStartedAt.IsZero() { 78 | req.withQueryParams["beforeStartedAt"] = *formatDate(param.BeforeStartedAt, false) 79 | } 80 | if !param.AfterStartedAt.IsZero() { 81 | req.withQueryParams["afterStartedAt"] = *formatDate(param.AfterStartedAt, false) 82 | } 83 | if !param.BeforeFinishedAt.IsZero() { 84 | req.withQueryParams["beforeFinishedAt"] = *formatDate(param.BeforeFinishedAt, false) 85 | } 86 | if !param.AfterFinishedAt.IsZero() { 87 | req.withQueryParams["afterFinishedAt"] = *formatDate(param.AfterFinishedAt, false) 88 | } 89 | } 90 | 91 | func formatDate(date time.Time, _ bool) *string { 92 | const format = "2006-01-02T15:04:05Z" 93 | timeParsedToString := date.Format(format) 94 | return &timeParsedToString 95 | } 96 | 97 | func transformStringVariadicToMap(primaryKey ...string) (options map[string]string) { 98 | if primaryKey != nil { 99 | return map[string]string{ 100 | "primaryKey": primaryKey[0], 101 | } 102 | } 103 | return nil 104 | } 105 | 106 | func transformCsvDocumentsQueryToMap(options *CsvDocumentsQuery) map[string]string { 107 | var optionsMap map[string]string 108 | data, _ := json.Marshal(options) 109 | _ = json.Unmarshal(data, &optionsMap) 110 | return optionsMap 111 | } 112 | 113 | func generateQueryForOptions(options map[string]string) (urlQuery string) { 114 | q := url.Values{} 115 | for key, val := range options { 116 | q.Add(key, val) 117 | } 118 | return q.Encode() 119 | } 120 | 121 | func sendCsvRecords(ctx context.Context, documentsCsvFunc func(ctx context.Context, recs []byte, op *CsvDocumentsQuery) (resp *TaskInfo, err error), records [][]string, options *CsvDocumentsQuery) (*TaskInfo, error) { 122 | b := new(bytes.Buffer) 123 | w := csv.NewWriter(b) 124 | w.UseCRLF = true 125 | 126 | err := w.WriteAll(records) 127 | if err != nil { 128 | return nil, fmt.Errorf("could not write CSV records: %w", err) 129 | } 130 | 131 | resp, err := documentsCsvFunc(ctx, b.Bytes(), options) 132 | if err != nil { 133 | return nil, err 134 | } 135 | return resp, nil 136 | } 137 | -------------------------------------------------------------------------------- /helper_test.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "net/url" 5 | "reflect" 6 | "strconv" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func (req *internalRequest) init() { 12 | req.withQueryParams = make(map[string]string) 13 | } 14 | 15 | func formatDateForComparison(date time.Time) string { 16 | const format = "2006-01-02T15:04:05Z" 17 | return date.Format(format) 18 | } 19 | 20 | func TestConvertKeyToParsedKey(t *testing.T) { 21 | key := Key{ 22 | Name: "test", 23 | Description: "test description", 24 | UID: "123", 25 | Actions: []string{"read", "write"}, 26 | Indexes: []string{"index1", "index2"}, 27 | ExpiresAt: time.Now(), 28 | } 29 | 30 | expectedExpiresAt := formatDateForComparison(key.ExpiresAt) 31 | parsedKey := convertKeyToParsedKey(key) 32 | 33 | if parsedKey.Name != key.Name || 34 | parsedKey.Description != key.Description || 35 | parsedKey.UID != key.UID || 36 | !reflect.DeepEqual(parsedKey.Actions, key.Actions) || 37 | !reflect.DeepEqual(parsedKey.Indexes, key.Indexes) || 38 | parsedKey.ExpiresAt == nil || *parsedKey.ExpiresAt != expectedExpiresAt { 39 | t.Errorf("convertKeyToParsedKey(%v) = %v; want %v", key, parsedKey, key) 40 | } 41 | } 42 | 43 | func TestEncodeTasksQuery(t *testing.T) { 44 | param := &TasksQuery{ 45 | Limit: 10, 46 | From: 5, 47 | Statuses: []TaskStatus{"queued", "running"}, 48 | Types: []TaskType{"type1", "type2"}, 49 | IndexUIDS: []string{"uid1", "uid2"}, 50 | UIDS: []int64{1, 2, 3}, 51 | CanceledBy: []int64{4, 5}, 52 | BeforeEnqueuedAt: time.Now().Add(-10 * time.Hour), 53 | AfterEnqueuedAt: time.Now().Add(-20 * time.Hour), 54 | BeforeStartedAt: time.Now().Add(-30 * time.Hour), 55 | AfterStartedAt: time.Now().Add(-40 * time.Hour), 56 | BeforeFinishedAt: time.Now().Add(-50 * time.Hour), 57 | AfterFinishedAt: time.Now().Add(-60 * time.Hour), 58 | } 59 | req := &internalRequest{} 60 | req.init() 61 | 62 | encodeTasksQuery(param, req) 63 | 64 | expectedParams := map[string]string{ 65 | "limit": strconv.FormatInt(param.Limit, 10), 66 | "from": strconv.FormatInt(param.From, 10), 67 | "statuses": "queued,running", 68 | "types": "type1,type2", 69 | "indexUids": "uid1,uid2", 70 | "uids": "1,2,3", 71 | "canceledBy": "4,5", 72 | "beforeEnqueuedAt": formatDateForComparison(param.BeforeEnqueuedAt), 73 | "afterEnqueuedAt": formatDateForComparison(param.AfterEnqueuedAt), 74 | "beforeStartedAt": formatDateForComparison(param.BeforeStartedAt), 75 | "afterStartedAt": formatDateForComparison(param.AfterStartedAt), 76 | "beforeFinishedAt": formatDateForComparison(param.BeforeFinishedAt), 77 | "afterFinishedAt": formatDateForComparison(param.AfterFinishedAt), 78 | } 79 | 80 | for k, v := range expectedParams { 81 | if req.withQueryParams[k] != v { 82 | t.Errorf("encodeTasksQuery() param %v = %v; want %v", k, req.withQueryParams[k], v) 83 | } 84 | } 85 | } 86 | 87 | func TestTransformStringVariadicToMap(t *testing.T) { 88 | tests := []struct { 89 | input []string 90 | expect map[string]string 91 | }{ 92 | {[]string{"primaryKey1"}, map[string]string{"primaryKey": "primaryKey1"}}, 93 | {nil, nil}, 94 | } 95 | 96 | for _, test := range tests { 97 | result := transformStringVariadicToMap(test.input...) 98 | if !reflect.DeepEqual(result, test.expect) { 99 | t.Errorf("transformStringVariadicToMap(%v) = %v; want %v", test.input, result, test.expect) 100 | } 101 | } 102 | } 103 | 104 | func TestGenerateQueryForOptions(t *testing.T) { 105 | options := map[string]string{ 106 | "key1": "value1", 107 | "key2": "value2", 108 | } 109 | 110 | expected := url.Values{} 111 | expected.Add("key1", "value1") 112 | expected.Add("key2", "value2") 113 | 114 | result := generateQueryForOptions(options) 115 | if result != expected.Encode() { 116 | t.Errorf("generateQueryForOptions(%v) = %v; want %v", options, result, expected.Encode()) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /index.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | // index is the type that represent an index in meilisearch 9 | type index struct { 10 | uid string 11 | primaryKey string 12 | client *client 13 | } 14 | 15 | func newIndex(cli *client, uid string) IndexManager { 16 | return &index{ 17 | client: cli, 18 | uid: uid, 19 | } 20 | } 21 | 22 | func (i *index) GetTaskReader() TaskReader { 23 | return i 24 | } 25 | 26 | func (i *index) GetDocumentManager() DocumentManager { 27 | return i 28 | } 29 | 30 | func (i *index) GetDocumentReader() DocumentReader { 31 | return i 32 | } 33 | 34 | func (i *index) GetSettingsManager() SettingsManager { 35 | return i 36 | } 37 | 38 | func (i *index) GetSettingsReader() SettingsReader { 39 | return i 40 | } 41 | 42 | func (i *index) GetSearch() SearchReader { 43 | return i 44 | } 45 | 46 | func (i *index) GetIndexReader() IndexReader { 47 | return i 48 | } 49 | 50 | func (i *index) FetchInfo() (*IndexResult, error) { 51 | return i.FetchInfoWithContext(context.Background()) 52 | } 53 | 54 | func (i *index) FetchInfoWithContext(ctx context.Context) (*IndexResult, error) { 55 | resp := new(IndexResult) 56 | req := &internalRequest{ 57 | endpoint: "/indexes/" + i.uid, 58 | method: http.MethodGet, 59 | withRequest: nil, 60 | withResponse: resp, 61 | acceptedStatusCodes: []int{http.StatusOK}, 62 | functionName: "FetchInfo", 63 | } 64 | if err := i.client.executeRequest(ctx, req); err != nil { 65 | return nil, err 66 | } 67 | if resp.PrimaryKey != "" { 68 | i.primaryKey = resp.PrimaryKey 69 | } 70 | resp.IndexManager = i 71 | return resp, nil 72 | } 73 | 74 | func (i *index) FetchPrimaryKey() (*string, error) { 75 | return i.FetchPrimaryKeyWithContext(context.Background()) 76 | } 77 | 78 | func (i *index) FetchPrimaryKeyWithContext(ctx context.Context) (*string, error) { 79 | idx, err := i.FetchInfoWithContext(ctx) 80 | if err != nil { 81 | return nil, err 82 | } 83 | i.primaryKey = idx.PrimaryKey 84 | return &idx.PrimaryKey, nil 85 | } 86 | 87 | func (i *index) UpdateIndex(primaryKey string) (*TaskInfo, error) { 88 | return i.UpdateIndexWithContext(context.Background(), primaryKey) 89 | } 90 | 91 | func (i *index) UpdateIndexWithContext(ctx context.Context, primaryKey string) (*TaskInfo, error) { 92 | request := &UpdateIndexRequest{ 93 | PrimaryKey: primaryKey, 94 | } 95 | i.primaryKey = primaryKey 96 | resp := new(TaskInfo) 97 | 98 | req := &internalRequest{ 99 | endpoint: "/indexes/" + i.uid, 100 | method: http.MethodPatch, 101 | contentType: contentTypeJSON, 102 | withRequest: request, 103 | withResponse: resp, 104 | acceptedStatusCodes: []int{http.StatusAccepted}, 105 | functionName: "UpdateIndex", 106 | } 107 | if err := i.client.executeRequest(ctx, req); err != nil { 108 | return nil, err 109 | } 110 | i.primaryKey = primaryKey 111 | return resp, nil 112 | } 113 | 114 | func (i *index) Delete(uid string) (bool, error) { 115 | return i.DeleteWithContext(context.Background(), uid) 116 | } 117 | 118 | func (i *index) DeleteWithContext(ctx context.Context, uid string) (bool, error) { 119 | resp := new(TaskInfo) 120 | req := &internalRequest{ 121 | endpoint: "/indexes/" + uid, 122 | method: http.MethodDelete, 123 | withRequest: nil, 124 | withResponse: resp, 125 | acceptedStatusCodes: []int{http.StatusAccepted}, 126 | functionName: "Delete", 127 | } 128 | // err is not nil if status code is not 204 StatusNoContent 129 | if err := i.client.executeRequest(ctx, req); err != nil { 130 | return false, err 131 | } 132 | i.primaryKey = "" 133 | return true, nil 134 | } 135 | 136 | func (i *index) GetStats() (*StatsIndex, error) { 137 | return i.GetStatsWithContext(context.Background()) 138 | } 139 | 140 | func (i *index) GetStatsWithContext(ctx context.Context) (*StatsIndex, error) { 141 | resp := new(StatsIndex) 142 | req := &internalRequest{ 143 | endpoint: "/indexes/" + i.uid + "/stats", 144 | method: http.MethodGet, 145 | withRequest: nil, 146 | withResponse: resp, 147 | acceptedStatusCodes: []int{http.StatusOK}, 148 | functionName: "GetStats", 149 | } 150 | if err := i.client.executeRequest(ctx, req); err != nil { 151 | return nil, err 152 | } 153 | return resp, nil 154 | } 155 | -------------------------------------------------------------------------------- /index_document.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "encoding/csv" 8 | "fmt" 9 | "io" 10 | "math" 11 | "net/http" 12 | "reflect" 13 | "strconv" 14 | "strings" 15 | ) 16 | 17 | func (i *index) AddDocuments(documentsPtr interface{}, primaryKey ...string) (*TaskInfo, error) { 18 | return i.AddDocumentsWithContext(context.Background(), documentsPtr, primaryKey...) 19 | } 20 | 21 | func (i *index) AddDocumentsWithContext(ctx context.Context, documentsPtr interface{}, primaryKey ...string) (*TaskInfo, error) { 22 | return i.addDocuments(ctx, documentsPtr, contentTypeJSON, transformStringVariadicToMap(primaryKey...)) 23 | } 24 | 25 | func (i *index) AddDocumentsInBatches(documentsPtr interface{}, batchSize int, primaryKey ...string) ([]TaskInfo, error) { 26 | return i.AddDocumentsInBatchesWithContext(context.Background(), documentsPtr, batchSize, primaryKey...) 27 | } 28 | 29 | func (i *index) AddDocumentsInBatchesWithContext(ctx context.Context, documentsPtr interface{}, batchSize int, primaryKey ...string) ([]TaskInfo, error) { 30 | return i.saveDocumentsInBatches(ctx, documentsPtr, batchSize, i.AddDocumentsWithContext, primaryKey...) 31 | } 32 | 33 | func (i *index) AddDocumentsCsv(documents []byte, options *CsvDocumentsQuery) (*TaskInfo, error) { 34 | return i.AddDocumentsCsvWithContext(context.Background(), documents, options) 35 | } 36 | 37 | func (i *index) AddDocumentsCsvWithContext(ctx context.Context, documents []byte, options *CsvDocumentsQuery) (*TaskInfo, error) { 38 | // []byte avoids JSON conversion in Client.sendRequest() 39 | return i.addDocuments(ctx, documents, contentTypeCSV, transformCsvDocumentsQueryToMap(options)) 40 | } 41 | 42 | func (i *index) AddDocumentsCsvInBatches(documents []byte, batchSize int, options *CsvDocumentsQuery) ([]TaskInfo, error) { 43 | return i.AddDocumentsCsvInBatchesWithContext(context.Background(), documents, batchSize, options) 44 | } 45 | 46 | func (i *index) AddDocumentsCsvInBatchesWithContext(ctx context.Context, documents []byte, batchSize int, options *CsvDocumentsQuery) ([]TaskInfo, error) { 47 | // Reuse io.Reader implementation 48 | return i.AddDocumentsCsvFromReaderInBatchesWithContext(ctx, bytes.NewReader(documents), batchSize, options) 49 | } 50 | 51 | func (i *index) AddDocumentsCsvFromReaderInBatches(documents io.Reader, batchSize int, options *CsvDocumentsQuery) (resp []TaskInfo, err error) { 52 | return i.AddDocumentsCsvFromReaderInBatchesWithContext(context.Background(), documents, batchSize, options) 53 | } 54 | 55 | func (i *index) AddDocumentsCsvFromReaderInBatchesWithContext(ctx context.Context, documents io.Reader, batchSize int, options *CsvDocumentsQuery) (resp []TaskInfo, err error) { 56 | return i.saveDocumentsFromReaderInBatches(ctx, documents, batchSize, i.AddDocumentsCsvWithContext, options) 57 | } 58 | 59 | func (i *index) AddDocumentsCsvFromReader(documents io.Reader, options *CsvDocumentsQuery) (resp *TaskInfo, err error) { 60 | return i.AddDocumentsCsvFromReaderWithContext(context.Background(), documents, options) 61 | } 62 | 63 | func (i *index) AddDocumentsCsvFromReaderWithContext(ctx context.Context, documents io.Reader, options *CsvDocumentsQuery) (resp *TaskInfo, err error) { 64 | // Using io.Reader would avoid JSON conversion in Client.sendRequest(), but 65 | // read content to memory anyway because of problems with streamed bodies 66 | data, err := io.ReadAll(documents) 67 | if err != nil { 68 | return nil, fmt.Errorf("could not read documents: %w", err) 69 | } 70 | return i.addDocuments(ctx, data, contentTypeCSV, transformCsvDocumentsQueryToMap(options)) 71 | } 72 | 73 | func (i *index) AddDocumentsNdjson(documents []byte, primaryKey ...string) (*TaskInfo, error) { 74 | return i.AddDocumentsNdjsonWithContext(context.Background(), documents, primaryKey...) 75 | } 76 | 77 | func (i *index) AddDocumentsNdjsonWithContext(ctx context.Context, documents []byte, primaryKey ...string) (*TaskInfo, error) { 78 | // []byte avoids JSON conversion in Client.sendRequest() 79 | return i.addDocuments(ctx, documents, contentTypeNDJSON, transformStringVariadicToMap(primaryKey...)) 80 | } 81 | 82 | func (i *index) AddDocumentsNdjsonInBatches(documents []byte, batchSize int, primaryKey ...string) ([]TaskInfo, error) { 83 | return i.AddDocumentsNdjsonInBatchesWithContext(context.Background(), documents, batchSize, primaryKey...) 84 | } 85 | 86 | func (i *index) AddDocumentsNdjsonInBatchesWithContext(ctx context.Context, documents []byte, batchSize int, primaryKey ...string) ([]TaskInfo, error) { 87 | // Reuse io.Reader implementation 88 | return i.AddDocumentsNdjsonFromReaderInBatchesWithContext(ctx, bytes.NewReader(documents), batchSize, primaryKey...) 89 | } 90 | 91 | func (i *index) AddDocumentsNdjsonFromReaderInBatches(documents io.Reader, batchSize int, primaryKey ...string) (resp []TaskInfo, err error) { 92 | return i.AddDocumentsNdjsonFromReaderInBatchesWithContext(context.Background(), documents, batchSize, primaryKey...) 93 | } 94 | 95 | func (i *index) AddDocumentsNdjsonFromReaderInBatchesWithContext(ctx context.Context, documents io.Reader, batchSize int, primaryKey ...string) (resp []TaskInfo, err error) { 96 | // NDJSON files supposed to contain a valid JSON document in each line, so 97 | // it's safe to split by lines. 98 | // Lines are read and sent continuously to avoid reading all content into 99 | // memory. However, this means that only part of the documents might be 100 | // added successfully. 101 | 102 | sendNdjsonLines := func(lines []string) (*TaskInfo, error) { 103 | b := new(bytes.Buffer) 104 | for _, line := range lines { 105 | _, err := b.WriteString(line) 106 | if err != nil { 107 | return nil, fmt.Errorf("could not write NDJSON line: %w", err) 108 | } 109 | err = b.WriteByte('\n') 110 | if err != nil { 111 | return nil, fmt.Errorf("could not write NDJSON line: %w", err) 112 | } 113 | } 114 | 115 | resp, err := i.AddDocumentsNdjsonWithContext(ctx, b.Bytes(), primaryKey...) 116 | if err != nil { 117 | return nil, err 118 | } 119 | return resp, nil 120 | } 121 | 122 | var ( 123 | responses []TaskInfo 124 | lines []string 125 | ) 126 | 127 | scanner := bufio.NewScanner(documents) 128 | for scanner.Scan() { 129 | line := strings.TrimSpace(scanner.Text()) 130 | 131 | // Skip empty lines (NDJSON might not allow this, but just to be sure) 132 | if line == "" { 133 | continue 134 | } 135 | 136 | lines = append(lines, line) 137 | // After reaching batchSize send NDJSON lines 138 | if len(lines) == batchSize { 139 | resp, err := sendNdjsonLines(lines) 140 | if err != nil { 141 | return nil, err 142 | } 143 | responses = append(responses, *resp) 144 | lines = nil 145 | } 146 | } 147 | if err := scanner.Err(); err != nil { 148 | return nil, fmt.Errorf("could not read NDJSON: %w", err) 149 | } 150 | 151 | // Send remaining records as the last batch if there is any 152 | if len(lines) > 0 { 153 | resp, err := sendNdjsonLines(lines) 154 | if err != nil { 155 | return nil, err 156 | } 157 | responses = append(responses, *resp) 158 | } 159 | 160 | return responses, nil 161 | } 162 | 163 | func (i *index) AddDocumentsNdjsonFromReader(documents io.Reader, primaryKey ...string) (resp *TaskInfo, err error) { 164 | return i.AddDocumentsNdjsonFromReaderWithContext(context.Background(), documents, primaryKey...) 165 | } 166 | 167 | func (i *index) AddDocumentsNdjsonFromReaderWithContext(ctx context.Context, documents io.Reader, primaryKey ...string) (resp *TaskInfo, err error) { 168 | // Using io.Reader would avoid JSON conversion in Client.sendRequest(), but 169 | // read content to memory anyway because of problems with streamed bodies 170 | data, err := io.ReadAll(documents) 171 | if err != nil { 172 | return nil, fmt.Errorf("could not read documents: %w", err) 173 | } 174 | return i.addDocuments(ctx, data, contentTypeNDJSON, transformStringVariadicToMap(primaryKey...)) 175 | } 176 | 177 | func (i *index) UpdateDocuments(documentsPtr interface{}, primaryKey ...string) (*TaskInfo, error) { 178 | return i.UpdateDocumentsWithContext(context.Background(), documentsPtr, primaryKey...) 179 | } 180 | 181 | func (i *index) UpdateDocumentsWithContext(ctx context.Context, documentsPtr interface{}, primaryKey ...string) (*TaskInfo, error) { 182 | return i.updateDocuments(ctx, documentsPtr, contentTypeJSON, transformStringVariadicToMap(primaryKey...)) 183 | } 184 | 185 | func (i *index) UpdateDocumentsInBatches(documentsPtr interface{}, batchSize int, primaryKey ...string) ([]TaskInfo, error) { 186 | return i.UpdateDocumentsInBatchesWithContext(context.Background(), documentsPtr, batchSize, primaryKey...) 187 | } 188 | 189 | func (i *index) UpdateDocumentsInBatchesWithContext(ctx context.Context, documentsPtr interface{}, batchSize int, primaryKey ...string) ([]TaskInfo, error) { 190 | return i.saveDocumentsInBatches(ctx, documentsPtr, batchSize, i.UpdateDocumentsWithContext, primaryKey...) 191 | } 192 | 193 | func (i *index) UpdateDocumentsCsv(documents []byte, options *CsvDocumentsQuery) (*TaskInfo, error) { 194 | return i.UpdateDocumentsCsvWithContext(context.Background(), documents, options) 195 | } 196 | 197 | func (i *index) UpdateDocumentsCsvWithContext(ctx context.Context, documents []byte, options *CsvDocumentsQuery) (*TaskInfo, error) { 198 | return i.updateDocuments(ctx, documents, contentTypeCSV, transformCsvDocumentsQueryToMap(options)) 199 | } 200 | 201 | func (i *index) UpdateDocumentsCsvInBatches(documents []byte, batchSize int, options *CsvDocumentsQuery) ([]TaskInfo, error) { 202 | return i.UpdateDocumentsCsvInBatchesWithContext(context.Background(), documents, batchSize, options) 203 | } 204 | 205 | func (i *index) UpdateDocumentsCsvInBatchesWithContext(ctx context.Context, documents []byte, batchSize int, options *CsvDocumentsQuery) ([]TaskInfo, error) { 206 | // Reuse io.Reader implementation 207 | return i.updateDocumentsCsvFromReaderInBatches(ctx, bytes.NewReader(documents), batchSize, options) 208 | } 209 | 210 | func (i *index) UpdateDocumentsNdjson(documents []byte, primaryKey ...string) (*TaskInfo, error) { 211 | return i.UpdateDocumentsNdjsonWithContext(context.Background(), documents, primaryKey...) 212 | } 213 | 214 | func (i *index) UpdateDocumentsNdjsonWithContext(ctx context.Context, documents []byte, primaryKey ...string) (*TaskInfo, error) { 215 | return i.updateDocuments(ctx, documents, contentTypeNDJSON, transformStringVariadicToMap(primaryKey...)) 216 | } 217 | 218 | func (i *index) UpdateDocumentsNdjsonInBatches(documents []byte, batchSize int, primaryKey ...string) ([]TaskInfo, error) { 219 | return i.UpdateDocumentsNdjsonInBatchesWithContext(context.Background(), documents, batchSize, primaryKey...) 220 | } 221 | 222 | func (i *index) UpdateDocumentsNdjsonInBatchesWithContext(ctx context.Context, documents []byte, batchSize int, primaryKey ...string) ([]TaskInfo, error) { 223 | return i.updateDocumentsNdjsonFromReaderInBatches(ctx, bytes.NewReader(documents), batchSize, primaryKey...) 224 | } 225 | 226 | func (i *index) UpdateDocumentsByFunction(req *UpdateDocumentByFunctionRequest) (*TaskInfo, error) { 227 | return i.UpdateDocumentsByFunctionWithContext(context.Background(), req) 228 | } 229 | 230 | func (i *index) UpdateDocumentsByFunctionWithContext(ctx context.Context, req *UpdateDocumentByFunctionRequest) (*TaskInfo, error) { 231 | resp := new(TaskInfo) 232 | r := &internalRequest{ 233 | endpoint: "/indexes/" + i.uid + "/documents/edit", 234 | method: http.MethodPost, 235 | withRequest: req, 236 | withResponse: resp, 237 | contentType: contentTypeJSON, 238 | acceptedStatusCodes: []int{http.StatusAccepted}, 239 | functionName: "UpdateDocumentsByFunction", 240 | } 241 | if err := i.client.executeRequest(ctx, r); err != nil { 242 | return nil, err 243 | } 244 | return resp, nil 245 | } 246 | 247 | func (i *index) GetDocument(identifier string, request *DocumentQuery, documentPtr interface{}) error { 248 | return i.GetDocumentWithContext(context.Background(), identifier, request, documentPtr) 249 | } 250 | 251 | func (i *index) GetDocumentWithContext(ctx context.Context, identifier string, request *DocumentQuery, documentPtr interface{}) error { 252 | req := &internalRequest{ 253 | endpoint: "/indexes/" + i.uid + "/documents/" + identifier, 254 | method: http.MethodGet, 255 | withRequest: nil, 256 | withResponse: documentPtr, 257 | withQueryParams: map[string]string{}, 258 | acceptedStatusCodes: []int{http.StatusOK}, 259 | functionName: "GetDocument", 260 | } 261 | if request != nil { 262 | if len(request.Fields) != 0 { 263 | req.withQueryParams["fields"] = strings.Join(request.Fields, ",") 264 | } 265 | if request.RetrieveVectors { 266 | req.withQueryParams["retrieveVectors"] = "true" 267 | } 268 | } 269 | if err := i.client.executeRequest(ctx, req); err != nil { 270 | return err 271 | } 272 | return nil 273 | } 274 | 275 | func (i *index) GetDocuments(param *DocumentsQuery, resp *DocumentsResult) error { 276 | return i.GetDocumentsWithContext(context.Background(), param, resp) 277 | } 278 | 279 | func (i *index) GetDocumentsWithContext(ctx context.Context, param *DocumentsQuery, resp *DocumentsResult) error { 280 | req := &internalRequest{ 281 | endpoint: "/indexes/" + i.uid + "/documents", 282 | method: http.MethodGet, 283 | contentType: contentTypeJSON, 284 | withRequest: nil, 285 | withResponse: resp, 286 | withQueryParams: nil, 287 | acceptedStatusCodes: []int{http.StatusOK}, 288 | functionName: "GetDocuments", 289 | } 290 | if param != nil && param.Filter == nil { 291 | req.withQueryParams = map[string]string{} 292 | if param.Limit != 0 { 293 | req.withQueryParams["limit"] = strconv.FormatInt(param.Limit, 10) 294 | } 295 | if param.Offset != 0 { 296 | req.withQueryParams["offset"] = strconv.FormatInt(param.Offset, 10) 297 | } 298 | if len(param.Fields) != 0 { 299 | req.withQueryParams["fields"] = strings.Join(param.Fields, ",") 300 | } 301 | if param.RetrieveVectors { 302 | req.withQueryParams["retrieveVectors"] = "true" 303 | } 304 | } else if param != nil && param.Filter != nil { 305 | req.withRequest = param 306 | req.method = http.MethodPost 307 | req.endpoint = req.endpoint + "/fetch" 308 | } 309 | if err := i.client.executeRequest(ctx, req); err != nil { 310 | return VersionErrorHintMessage(err, req) 311 | } 312 | return nil 313 | } 314 | 315 | func (i *index) DeleteDocument(identifier string) (*TaskInfo, error) { 316 | return i.DeleteDocumentWithContext(context.Background(), identifier) 317 | } 318 | 319 | func (i *index) DeleteDocumentWithContext(ctx context.Context, identifier string) (*TaskInfo, error) { 320 | resp := new(TaskInfo) 321 | req := &internalRequest{ 322 | endpoint: "/indexes/" + i.uid + "/documents/" + identifier, 323 | method: http.MethodDelete, 324 | withRequest: nil, 325 | withResponse: resp, 326 | acceptedStatusCodes: []int{http.StatusAccepted}, 327 | functionName: "DeleteDocument", 328 | } 329 | if err := i.client.executeRequest(ctx, req); err != nil { 330 | return nil, err 331 | } 332 | return resp, nil 333 | } 334 | 335 | func (i *index) DeleteDocuments(identifiers []string) (*TaskInfo, error) { 336 | return i.DeleteDocumentsWithContext(context.Background(), identifiers) 337 | } 338 | 339 | func (i *index) DeleteDocumentsWithContext(ctx context.Context, identifiers []string) (*TaskInfo, error) { 340 | resp := new(TaskInfo) 341 | req := &internalRequest{ 342 | endpoint: "/indexes/" + i.uid + "/documents/delete-batch", 343 | method: http.MethodPost, 344 | contentType: contentTypeJSON, 345 | withRequest: identifiers, 346 | withResponse: resp, 347 | acceptedStatusCodes: []int{http.StatusAccepted}, 348 | functionName: "DeleteDocuments", 349 | } 350 | if err := i.client.executeRequest(ctx, req); err != nil { 351 | return nil, err 352 | } 353 | return resp, nil 354 | } 355 | 356 | func (i *index) DeleteDocumentsByFilter(filter interface{}) (*TaskInfo, error) { 357 | return i.DeleteDocumentsByFilterWithContext(context.Background(), filter) 358 | } 359 | 360 | func (i *index) DeleteDocumentsByFilterWithContext(ctx context.Context, filter interface{}) (*TaskInfo, error) { 361 | resp := new(TaskInfo) 362 | req := &internalRequest{ 363 | endpoint: "/indexes/" + i.uid + "/documents/delete", 364 | method: http.MethodPost, 365 | contentType: contentTypeJSON, 366 | withRequest: map[string]interface{}{ 367 | "filter": filter, 368 | }, 369 | withResponse: resp, 370 | acceptedStatusCodes: []int{http.StatusAccepted}, 371 | functionName: "DeleteDocumentsByFilter", 372 | } 373 | if err := i.client.executeRequest(ctx, req); err != nil { 374 | return nil, VersionErrorHintMessage(err, req) 375 | } 376 | return resp, nil 377 | } 378 | 379 | func (i *index) DeleteAllDocuments() (*TaskInfo, error) { 380 | return i.DeleteAllDocumentsWithContext(context.Background()) 381 | } 382 | 383 | func (i *index) DeleteAllDocumentsWithContext(ctx context.Context) (*TaskInfo, error) { 384 | resp := new(TaskInfo) 385 | req := &internalRequest{ 386 | endpoint: "/indexes/" + i.uid + "/documents", 387 | method: http.MethodDelete, 388 | withRequest: nil, 389 | withResponse: resp, 390 | acceptedStatusCodes: []int{http.StatusAccepted}, 391 | functionName: "DeleteAllDocuments", 392 | } 393 | if err := i.client.executeRequest(ctx, req); err != nil { 394 | return nil, err 395 | } 396 | return resp, nil 397 | } 398 | 399 | func (i *index) addDocuments(ctx context.Context, documentsPtr interface{}, contentType string, options map[string]string) (resp *TaskInfo, err error) { 400 | resp = new(TaskInfo) 401 | endpoint := "" 402 | if options == nil { 403 | endpoint = "/indexes/" + i.uid + "/documents" 404 | } else { 405 | for key, val := range options { 406 | if key == "primaryKey" { 407 | i.primaryKey = val 408 | } 409 | } 410 | endpoint = "/indexes/" + i.uid + "/documents?" + generateQueryForOptions(options) 411 | } 412 | req := &internalRequest{ 413 | endpoint: endpoint, 414 | method: http.MethodPost, 415 | contentType: contentType, 416 | withRequest: documentsPtr, 417 | withResponse: resp, 418 | acceptedStatusCodes: []int{http.StatusAccepted}, 419 | functionName: "AddDocuments", 420 | } 421 | if err = i.client.executeRequest(ctx, req); err != nil { 422 | return nil, err 423 | } 424 | return resp, nil 425 | } 426 | 427 | func (i *index) saveDocumentsFromReaderInBatches(ctx context.Context, documents io.Reader, batchSize int, documentsCsvFunc func(ctx context.Context, recs []byte, op *CsvDocumentsQuery) (resp *TaskInfo, err error), options *CsvDocumentsQuery) (resp []TaskInfo, err error) { 428 | // Because of the possibility of multiline fields it's not safe to split 429 | // into batches by lines, we'll have to parse the file and reassemble it 430 | // into smaller parts. RFC 4180 compliant input with a header row is 431 | // expected. 432 | // Records are read and sent continuously to avoid reading all content 433 | // into memory. However, this means that only part of the documents might 434 | // be added successfully. 435 | 436 | var ( 437 | responses []TaskInfo 438 | header []string 439 | records [][]string 440 | ) 441 | 442 | r := csv.NewReader(documents) 443 | for { 444 | // Read CSV record (empty lines and comments are already skipped by csv.Reader) 445 | record, err := r.Read() 446 | if err == io.EOF { 447 | break 448 | } 449 | if err != nil { 450 | return nil, fmt.Errorf("could not read CSV record: %w", err) 451 | } 452 | 453 | // Store first record as header 454 | if header == nil { 455 | header = record 456 | continue 457 | } 458 | 459 | // Add header record to every batch 460 | if len(records) == 0 { 461 | records = append(records, header) 462 | } 463 | 464 | records = append(records, record) 465 | 466 | // After reaching batchSize (not counting the header record) assemble a CSV file and send records 467 | if len(records) == batchSize+1 { 468 | resp, err := sendCsvRecords(ctx, documentsCsvFunc, records, options) 469 | if err != nil { 470 | return nil, err 471 | } 472 | responses = append(responses, *resp) 473 | records = nil 474 | } 475 | } 476 | 477 | // Send remaining records as the last batch if there is any 478 | if len(records) > 0 { 479 | resp, err := sendCsvRecords(ctx, documentsCsvFunc, records, options) 480 | if err != nil { 481 | return nil, err 482 | } 483 | responses = append(responses, *resp) 484 | } 485 | 486 | return responses, nil 487 | } 488 | 489 | func (i *index) saveDocumentsInBatches(ctx context.Context, documentsPtr interface{}, batchSize int, documentFunc func(ctx context.Context, documentsPtr interface{}, primaryKey ...string) (resp *TaskInfo, err error), primaryKey ...string) (resp []TaskInfo, err error) { 490 | arr := reflect.ValueOf(documentsPtr) 491 | lenDocs := arr.Len() 492 | numBatches := int(math.Ceil(float64(lenDocs) / float64(batchSize))) 493 | resp = make([]TaskInfo, numBatches) 494 | 495 | for j := 0; j < numBatches; j++ { 496 | end := (j + 1) * batchSize 497 | if end > lenDocs { 498 | end = lenDocs 499 | } 500 | 501 | batch := arr.Slice(j*batchSize, end).Interface() 502 | 503 | if len(primaryKey) != 0 { 504 | respID, err := documentFunc(ctx, batch, primaryKey[0]) 505 | if err != nil { 506 | return nil, err 507 | } 508 | 509 | resp[j] = *respID 510 | } else { 511 | respID, err := documentFunc(ctx, batch) 512 | if err != nil { 513 | return nil, err 514 | } 515 | 516 | resp[j] = *respID 517 | } 518 | } 519 | 520 | return resp, nil 521 | } 522 | 523 | func (i *index) updateDocuments(ctx context.Context, documentsPtr interface{}, contentType string, options map[string]string) (resp *TaskInfo, err error) { 524 | resp = &TaskInfo{} 525 | endpoint := "" 526 | if options == nil { 527 | endpoint = "/indexes/" + i.uid + "/documents" 528 | } else { 529 | for key, val := range options { 530 | if key == "primaryKey" { 531 | i.primaryKey = val 532 | } 533 | } 534 | endpoint = "/indexes/" + i.uid + "/documents?" + generateQueryForOptions(options) 535 | } 536 | req := &internalRequest{ 537 | endpoint: endpoint, 538 | method: http.MethodPut, 539 | contentType: contentType, 540 | withRequest: documentsPtr, 541 | withResponse: resp, 542 | acceptedStatusCodes: []int{http.StatusAccepted}, 543 | functionName: "UpdateDocuments", 544 | } 545 | if err = i.client.executeRequest(ctx, req); err != nil { 546 | return nil, err 547 | } 548 | return resp, nil 549 | } 550 | 551 | func (i *index) updateDocumentsCsvFromReaderInBatches(ctx context.Context, documents io.Reader, batchSize int, options *CsvDocumentsQuery) (resp []TaskInfo, err error) { 552 | return i.saveDocumentsFromReaderInBatches(ctx, documents, batchSize, i.UpdateDocumentsCsvWithContext, options) 553 | } 554 | 555 | func (i *index) updateDocumentsNdjsonFromReaderInBatches(ctx context.Context, documents io.Reader, batchSize int, primaryKey ...string) (resp []TaskInfo, err error) { 556 | // NDJSON files supposed to contain a valid JSON document in each line, so 557 | // it's safe to split by lines. 558 | // Lines are read and sent continuously to avoid reading all content into 559 | // memory. However, this means that only part of the documents might be 560 | // added successfully. 561 | 562 | sendNdjsonLines := func(lines []string) (*TaskInfo, error) { 563 | b := new(bytes.Buffer) 564 | for _, line := range lines { 565 | _, err := b.WriteString(line) 566 | if err != nil { 567 | return nil, fmt.Errorf("could not write NDJSON line: %w", err) 568 | } 569 | err = b.WriteByte('\n') 570 | if err != nil { 571 | return nil, fmt.Errorf("could not write NDJSON line: %w", err) 572 | } 573 | } 574 | 575 | resp, err := i.UpdateDocumentsNdjsonWithContext(ctx, b.Bytes(), primaryKey...) 576 | if err != nil { 577 | return nil, err 578 | } 579 | return resp, nil 580 | } 581 | 582 | var ( 583 | responses []TaskInfo 584 | lines []string 585 | ) 586 | 587 | scanner := bufio.NewScanner(documents) 588 | for scanner.Scan() { 589 | line := strings.TrimSpace(scanner.Text()) 590 | 591 | // Skip empty lines (NDJSON might not allow this, but just to be sure) 592 | if line == "" { 593 | continue 594 | } 595 | 596 | lines = append(lines, line) 597 | // After reaching batchSize send NDJSON lines 598 | if len(lines) == batchSize { 599 | resp, err := sendNdjsonLines(lines) 600 | if err != nil { 601 | return nil, err 602 | } 603 | responses = append(responses, *resp) 604 | lines = nil 605 | } 606 | } 607 | if err := scanner.Err(); err != nil { 608 | return nil, fmt.Errorf("Could not read NDJSON: %w", err) 609 | } 610 | 611 | // Send remaining records as the last batch if there is any 612 | if len(lines) > 0 { 613 | resp, err := sendNdjsonLines(lines) 614 | if err != nil { 615 | return nil, err 616 | } 617 | responses = append(responses, *resp) 618 | } 619 | 620 | return responses, nil 621 | } 622 | -------------------------------------------------------------------------------- /index_search.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | ) 8 | 9 | func (i *index) Search(query string, request *SearchRequest) (*SearchResponse, error) { 10 | return i.SearchWithContext(context.Background(), query, request) 11 | } 12 | 13 | func (i *index) SearchWithContext(ctx context.Context, query string, request *SearchRequest) (*SearchResponse, error) { 14 | if request == nil { 15 | return nil, ErrNoSearchRequest 16 | } 17 | 18 | if query != "" { 19 | request.Query = query 20 | } 21 | 22 | if request.IndexUID != "" { 23 | request.IndexUID = "" 24 | } 25 | 26 | request.validate() 27 | 28 | resp := new(SearchResponse) 29 | 30 | req := &internalRequest{ 31 | endpoint: "/indexes/" + i.uid + "/search", 32 | method: http.MethodPost, 33 | contentType: contentTypeJSON, 34 | withRequest: request, 35 | withResponse: resp, 36 | acceptedStatusCodes: []int{http.StatusOK}, 37 | functionName: "Search", 38 | } 39 | 40 | if err := i.client.executeRequest(ctx, req); err != nil { 41 | return nil, err 42 | } 43 | 44 | return resp, nil 45 | } 46 | 47 | func (i *index) SearchRaw(query string, request *SearchRequest) (*json.RawMessage, error) { 48 | return i.SearchRawWithContext(context.Background(), query, request) 49 | } 50 | 51 | func (i *index) SearchRawWithContext(ctx context.Context, query string, request *SearchRequest) (*json.RawMessage, error) { 52 | if request == nil { 53 | return nil, ErrNoSearchRequest 54 | } 55 | 56 | if query != "" { 57 | request.Query = query 58 | } 59 | 60 | if request.IndexUID != "" { 61 | request.IndexUID = "" 62 | } 63 | 64 | request.validate() 65 | 66 | resp := new(json.RawMessage) 67 | 68 | req := &internalRequest{ 69 | endpoint: "/indexes/" + i.uid + "/search", 70 | method: http.MethodPost, 71 | contentType: contentTypeJSON, 72 | withRequest: request, 73 | withResponse: resp, 74 | acceptedStatusCodes: []int{http.StatusOK}, 75 | functionName: "SearchRaw", 76 | } 77 | 78 | if err := i.client.executeRequest(ctx, req); err != nil { 79 | return nil, err 80 | } 81 | 82 | return resp, nil 83 | } 84 | 85 | func (i *index) FacetSearch(request *FacetSearchRequest) (*json.RawMessage, error) { 86 | return i.FacetSearchWithContext(context.Background(), request) 87 | } 88 | 89 | func (i *index) FacetSearchWithContext(ctx context.Context, request *FacetSearchRequest) (*json.RawMessage, error) { 90 | if request == nil { 91 | return nil, ErrNoFacetSearchRequest 92 | } 93 | 94 | resp := new(json.RawMessage) 95 | 96 | req := &internalRequest{ 97 | endpoint: "/indexes/" + i.uid + "/facet-search", 98 | method: http.MethodPost, 99 | contentType: contentTypeJSON, 100 | withRequest: request, 101 | withResponse: resp, 102 | acceptedStatusCodes: []int{http.StatusOK}, 103 | functionName: "FacetSearch", 104 | } 105 | 106 | if err := i.client.executeRequest(ctx, req); err != nil { 107 | return nil, err 108 | } 109 | 110 | return resp, nil 111 | } 112 | 113 | func (i *index) SearchSimilarDocuments(param *SimilarDocumentQuery, resp *SimilarDocumentResult) error { 114 | return i.SearchSimilarDocumentsWithContext(context.Background(), param, resp) 115 | } 116 | 117 | func (i *index) SearchSimilarDocumentsWithContext(ctx context.Context, param *SimilarDocumentQuery, resp *SimilarDocumentResult) error { 118 | req := &internalRequest{ 119 | endpoint: "/indexes/" + i.uid + "/similar", 120 | method: http.MethodPost, 121 | withRequest: param, 122 | withResponse: resp, 123 | acceptedStatusCodes: []int{http.StatusOK}, 124 | functionName: "SearchSimilarDocuments", 125 | contentType: contentTypeJSON, 126 | } 127 | 128 | if err := i.client.executeRequest(ctx, req); err != nil { 129 | return err 130 | } 131 | return nil 132 | } 133 | -------------------------------------------------------------------------------- /index_task.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | func (i *index) GetTask(taskUID int64) (*Task, error) { 12 | return i.GetTaskWithContext(context.Background(), taskUID) 13 | } 14 | 15 | func (i *index) GetTaskWithContext(ctx context.Context, taskUID int64) (*Task, error) { 16 | return getTask(ctx, i.client, taskUID) 17 | } 18 | 19 | func (i *index) GetTasks(param *TasksQuery) (*TaskResult, error) { 20 | return i.GetTasksWithContext(context.Background(), param) 21 | } 22 | 23 | func (i *index) GetTasksWithContext(ctx context.Context, param *TasksQuery) (*TaskResult, error) { 24 | resp := new(TaskResult) 25 | req := &internalRequest{ 26 | endpoint: "/tasks", 27 | method: http.MethodGet, 28 | withRequest: nil, 29 | withResponse: resp, 30 | withQueryParams: map[string]string{}, 31 | acceptedStatusCodes: []int{http.StatusOK}, 32 | functionName: "GetTasks", 33 | } 34 | if param != nil { 35 | if param.Limit != 0 { 36 | req.withQueryParams["limit"] = strconv.FormatInt(param.Limit, 10) 37 | } 38 | if param.From != 0 { 39 | req.withQueryParams["from"] = strconv.FormatInt(param.From, 10) 40 | } 41 | if len(param.Statuses) != 0 { 42 | statuses := make([]string, len(param.Statuses)) 43 | for i, status := range param.Statuses { 44 | statuses[i] = string(status) 45 | } 46 | req.withQueryParams["statuses"] = strings.Join(statuses, ",") 47 | } 48 | 49 | if len(param.Types) != 0 { 50 | types := make([]string, len(param.Types)) 51 | for i, t := range param.Types { 52 | types[i] = string(t) 53 | } 54 | req.withQueryParams["types"] = strings.Join(types, ",") 55 | } 56 | if len(param.IndexUIDS) != 0 { 57 | param.IndexUIDS = append(param.IndexUIDS, i.uid) 58 | req.withQueryParams["indexUids"] = strings.Join(param.IndexUIDS, ",") 59 | } else { 60 | req.withQueryParams["indexUids"] = i.uid 61 | } 62 | 63 | if param.Reverse { 64 | req.withQueryParams["reverse"] = "true" 65 | } 66 | } 67 | if err := i.client.executeRequest(ctx, req); err != nil { 68 | return nil, err 69 | } 70 | return resp, nil 71 | } 72 | 73 | func (i *index) WaitForTask(taskUID int64, interval time.Duration) (*Task, error) { 74 | return waitForTask(context.Background(), i.client, taskUID, interval) 75 | } 76 | 77 | func (i *index) WaitForTaskWithContext(ctx context.Context, taskUID int64, interval time.Duration) (*Task, error) { 78 | return waitForTask(ctx, i.client, taskUID, interval) 79 | } 80 | -------------------------------------------------------------------------------- /index_task_test.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestIndex_GetTask(t *testing.T) { 12 | sv := setup(t, "") 13 | customSv := setup(t, "", WithCustomClientWithTLS(&tls.Config{ 14 | InsecureSkipVerify: true, 15 | })) 16 | 17 | type args struct { 18 | UID string 19 | client ServiceManager 20 | taskUID int64 21 | document []docTest 22 | } 23 | tests := []struct { 24 | name string 25 | args args 26 | }{ 27 | { 28 | name: "TestIndexBasicGetTask", 29 | args: args{ 30 | UID: "TestIndexBasicGetTask", 31 | client: sv, 32 | taskUID: 0, 33 | document: []docTest{ 34 | {ID: "123", Name: "Pride and Prejudice"}, 35 | }, 36 | }, 37 | }, 38 | { 39 | name: "TestIndexGetTaskWithCustomClient", 40 | args: args{ 41 | UID: "TestIndexGetTaskWithCustomClient", 42 | client: customSv, 43 | taskUID: 0, 44 | document: []docTest{ 45 | {ID: "123", Name: "Pride and Prejudice"}, 46 | }, 47 | }, 48 | }, 49 | { 50 | name: "TestIndexGetTask", 51 | args: args{ 52 | UID: "TestIndexGetTask", 53 | client: sv, 54 | taskUID: 0, 55 | document: []docTest{ 56 | {ID: "456", Name: "Le Petit Prince"}, 57 | {ID: "1", Name: "Alice In Wonderland"}, 58 | }, 59 | }, 60 | }, 61 | } 62 | 63 | t.Cleanup(cleanup(sv, customSv)) 64 | 65 | for _, tt := range tests { 66 | t.Run(tt.name, func(t *testing.T) { 67 | c := tt.args.client 68 | i := c.Index(tt.args.UID) 69 | t.Cleanup(cleanup(c)) 70 | 71 | task, err := i.AddDocuments(tt.args.document) 72 | require.NoError(t, err) 73 | 74 | _, err = c.WaitForTask(task.TaskUID, 0) 75 | require.NoError(t, err) 76 | 77 | gotResp, err := i.GetTask(task.TaskUID) 78 | require.NoError(t, err) 79 | require.NotNil(t, gotResp) 80 | require.GreaterOrEqual(t, gotResp.UID, tt.args.taskUID) 81 | require.Equal(t, gotResp.IndexUID, tt.args.UID) 82 | require.Equal(t, gotResp.Status, TaskStatusSucceeded) 83 | 84 | // Make sure that timestamps are also retrieved 85 | require.NotZero(t, gotResp.EnqueuedAt) 86 | require.NotZero(t, gotResp.StartedAt) 87 | require.NotZero(t, gotResp.FinishedAt) 88 | }) 89 | } 90 | } 91 | 92 | func TestIndex_GetTasks(t *testing.T) { 93 | sv := setup(t, "") 94 | customSv := setup(t, "", WithCustomClientWithTLS(&tls.Config{ 95 | InsecureSkipVerify: true, 96 | })) 97 | 98 | type args struct { 99 | UID string 100 | client ServiceManager 101 | document []docTest 102 | query *TasksQuery 103 | } 104 | tests := []struct { 105 | name string 106 | args args 107 | }{ 108 | { 109 | name: "TestIndexBasicGetTasks", 110 | args: args{ 111 | UID: "indexUID", 112 | client: sv, 113 | document: []docTest{ 114 | {ID: "123", Name: "Pride and Prejudice"}, 115 | }, 116 | }, 117 | }, 118 | { 119 | name: "TestIndexGetTasksWithCustomClient", 120 | args: args{ 121 | UID: "indexUID", 122 | client: customSv, 123 | document: []docTest{ 124 | {ID: "123", Name: "Pride and Prejudice"}, 125 | }, 126 | }, 127 | }, 128 | { 129 | name: "TestIndexBasicGetTasksWithFilters", 130 | args: args{ 131 | UID: "indexUID", 132 | client: sv, 133 | document: []docTest{ 134 | {ID: "123", Name: "Pride and Prejudice"}, 135 | }, 136 | query: &TasksQuery{ 137 | Statuses: []TaskStatus{TaskStatusSucceeded}, 138 | Types: []TaskType{TaskTypeDocumentAdditionOrUpdate}, 139 | }, 140 | }, 141 | }, 142 | { 143 | name: "TestTasksWithParams", 144 | args: args{ 145 | UID: "indexUID", 146 | client: sv, 147 | document: []docTest{ 148 | {ID: "123", Name: "Pride and Prejudice"}, 149 | }, 150 | query: &TasksQuery{ 151 | IndexUIDS: []string{"indexUID"}, 152 | Limit: 10, 153 | From: 0, 154 | Statuses: []TaskStatus{TaskStatusSucceeded}, 155 | Types: []TaskType{TaskTypeDocumentAdditionOrUpdate}, 156 | Reverse: true, 157 | }, 158 | }, 159 | }, 160 | } 161 | for _, tt := range tests { 162 | t.Run(tt.name, func(t *testing.T) { 163 | c := tt.args.client 164 | i := c.Index(tt.args.UID) 165 | t.Cleanup(cleanup(c)) 166 | 167 | task, err := i.AddDocuments(tt.args.document) 168 | require.NoError(t, err) 169 | 170 | _, err = c.WaitForTask(task.TaskUID, 0) 171 | require.NoError(t, err) 172 | 173 | gotResp, err := i.GetTasks(tt.args.query) 174 | require.NoError(t, err) 175 | require.NotNil(t, (*gotResp).Results[0].Status) 176 | require.NotNil(t, (*gotResp).Results[0].Type) 177 | }) 178 | } 179 | } 180 | 181 | func TestIndex_WaitForTask(t *testing.T) { 182 | sv := setup(t, "") 183 | customSv := setup(t, "", WithCustomClientWithTLS(&tls.Config{ 184 | InsecureSkipVerify: true, 185 | })) 186 | 187 | type args struct { 188 | UID string 189 | client ServiceManager 190 | interval time.Duration 191 | timeout time.Duration 192 | document []docTest 193 | } 194 | tests := []struct { 195 | name string 196 | args args 197 | want TaskStatus 198 | }{ 199 | { 200 | name: "TestWaitForTask50", 201 | args: args{ 202 | UID: "TestWaitForTask50", 203 | client: sv, 204 | interval: time.Millisecond * 50, 205 | timeout: time.Second * 5, 206 | document: []docTest{ 207 | {ID: "123", Name: "Pride and Prejudice"}, 208 | {ID: "456", Name: "Le Petit Prince"}, 209 | {ID: "1", Name: "Alice In Wonderland"}, 210 | }, 211 | }, 212 | want: "succeeded", 213 | }, 214 | { 215 | name: "TestWaitForTask50WithCustomClient", 216 | args: args{ 217 | UID: "TestWaitForTask50WithCustomClient", 218 | client: customSv, 219 | interval: time.Millisecond * 50, 220 | timeout: time.Second * 5, 221 | document: []docTest{ 222 | {ID: "123", Name: "Pride and Prejudice"}, 223 | {ID: "456", Name: "Le Petit Prince"}, 224 | {ID: "1", Name: "Alice In Wonderland"}, 225 | }, 226 | }, 227 | want: "succeeded", 228 | }, 229 | { 230 | name: "TestWaitForTask10", 231 | args: args{ 232 | UID: "TestWaitForTask10", 233 | client: sv, 234 | interval: time.Millisecond * 10, 235 | timeout: time.Second * 5, 236 | document: []docTest{ 237 | {ID: "123", Name: "Pride and Prejudice"}, 238 | {ID: "456", Name: "Le Petit Prince"}, 239 | {ID: "1", Name: "Alice In Wonderland"}, 240 | }, 241 | }, 242 | want: "succeeded", 243 | }, 244 | { 245 | name: "TestWaitForTaskWithTimeout", 246 | args: args{ 247 | UID: "TestWaitForTaskWithTimeout", 248 | client: sv, 249 | interval: time.Millisecond * 50, 250 | timeout: time.Millisecond * 10, 251 | document: []docTest{ 252 | {ID: "123", Name: "Pride and Prejudice"}, 253 | {ID: "456", Name: "Le Petit Prince"}, 254 | {ID: "1", Name: "Alice In Wonderland"}, 255 | }, 256 | }, 257 | want: "succeeded", 258 | }, 259 | } 260 | for _, tt := range tests { 261 | t.Run(tt.name, func(t *testing.T) { 262 | c := tt.args.client 263 | i := c.Index(tt.args.UID) 264 | t.Cleanup(cleanup(c)) 265 | 266 | task, err := i.AddDocuments(tt.args.document) 267 | require.NoError(t, err) 268 | 269 | ctx, cancelFunc := context.WithTimeout(context.Background(), tt.args.timeout) 270 | defer cancelFunc() 271 | 272 | gotTask, err := i.WaitForTaskWithContext(ctx, task.TaskUID, 0) 273 | if tt.args.timeout < tt.args.interval { 274 | require.Error(t, err) 275 | } else { 276 | require.NoError(t, err) 277 | require.Equal(t, tt.want, gotTask.Status) 278 | } 279 | }) 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /index_test.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "crypto/tls" 5 | "github.com/stretchr/testify/require" 6 | "testing" 7 | ) 8 | 9 | func TestIndex_Delete(t *testing.T) { 10 | sv := setup(t, "") 11 | customSv := setup(t, "", WithCustomClientWithTLS(&tls.Config{ 12 | InsecureSkipVerify: true, 13 | })) 14 | 15 | type args struct { 16 | createUid []string 17 | deleteUid []string 18 | } 19 | tests := []struct { 20 | name string 21 | client ServiceManager 22 | args args 23 | }{ 24 | { 25 | name: "TestIndexDeleteOneIndex", 26 | client: sv, 27 | args: args{ 28 | createUid: []string{"TestIndexDeleteOneIndex"}, 29 | deleteUid: []string{"TestIndexDeleteOneIndex"}, 30 | }, 31 | }, 32 | { 33 | name: "TestIndexDeleteOneIndexWithCustomClient", 34 | client: customSv, 35 | args: args{ 36 | createUid: []string{"TestIndexDeleteOneIndexWithCustomClient"}, 37 | deleteUid: []string{"TestIndexDeleteOneIndexWithCustomClient"}, 38 | }, 39 | }, 40 | { 41 | name: "TestIndexDeleteMultipleIndex", 42 | client: sv, 43 | args: args{ 44 | createUid: []string{ 45 | "TestIndexDeleteMultipleIndex_1", 46 | "TestIndexDeleteMultipleIndex_2", 47 | "TestIndexDeleteMultipleIndex_3", 48 | "TestIndexDeleteMultipleIndex_4", 49 | "TestIndexDeleteMultipleIndex_5", 50 | }, 51 | deleteUid: []string{ 52 | "TestIndexDeleteMultipleIndex_1", 53 | "TestIndexDeleteMultipleIndex_2", 54 | "TestIndexDeleteMultipleIndex_3", 55 | "TestIndexDeleteMultipleIndex_4", 56 | "TestIndexDeleteMultipleIndex_5", 57 | }, 58 | }, 59 | }, 60 | { 61 | name: "TestIndexDeleteNotExistingIndex", 62 | client: sv, 63 | args: args{ 64 | createUid: []string{}, 65 | deleteUid: []string{"TestIndexDeleteNotExistingIndex"}, 66 | }, 67 | }, 68 | { 69 | name: "TestIndexDeleteMultipleNotExistingIndex", 70 | client: sv, 71 | args: args{ 72 | createUid: []string{}, 73 | deleteUid: []string{ 74 | "TestIndexDeleteMultipleNotExistingIndex_1", 75 | "TestIndexDeleteMultipleNotExistingIndex_2", 76 | "TestIndexDeleteMultipleNotExistingIndex_3", 77 | }, 78 | }, 79 | }, 80 | } 81 | for _, tt := range tests { 82 | t.Run(tt.name, func(t *testing.T) { 83 | c := tt.client 84 | t.Cleanup(cleanup(c)) 85 | 86 | for _, uid := range tt.args.createUid { 87 | _, err := setUpEmptyIndex(sv, &IndexConfig{Uid: uid}) 88 | require.NoError(t, err, "CreateIndex() in DeleteTest error should be nil") 89 | } 90 | for k := range tt.args.deleteUid { 91 | i := c.Index(tt.args.deleteUid[k]) 92 | gotResp, err := i.Delete(tt.args.deleteUid[k]) 93 | require.True(t, gotResp) 94 | require.NoError(t, err) 95 | } 96 | }) 97 | } 98 | } 99 | 100 | func TestIndex_GetStats(t *testing.T) { 101 | sv := setup(t, "") 102 | customSv := setup(t, "", WithCustomClientWithTLS(&tls.Config{ 103 | InsecureSkipVerify: true, 104 | })) 105 | 106 | type args struct { 107 | UID string 108 | client ServiceManager 109 | } 110 | tests := []struct { 111 | name string 112 | args args 113 | wantResp *StatsIndex 114 | }{ 115 | { 116 | name: "TestIndexBasicGetStats", 117 | args: args{ 118 | UID: "TestIndexBasicGetStats", 119 | client: sv, 120 | }, 121 | wantResp: &StatsIndex{ 122 | NumberOfDocuments: 6, 123 | IsIndexing: false, 124 | FieldDistribution: map[string]int64{"book_id": 6, "title": 6}, 125 | RawDocumentDbSize: 4096, 126 | AvgDocumentSize: 674, 127 | }, 128 | }, 129 | { 130 | name: "TestIndexGetStatsWithCustomClient", 131 | args: args{ 132 | UID: "TestIndexGetStatsWithCustomClient", 133 | client: customSv, 134 | }, 135 | wantResp: &StatsIndex{ 136 | NumberOfDocuments: 6, 137 | IsIndexing: false, 138 | FieldDistribution: map[string]int64{"book_id": 6, "title": 6}, 139 | RawDocumentDbSize: 4096, 140 | AvgDocumentSize: 674, 141 | }, 142 | }, 143 | } 144 | for _, tt := range tests { 145 | t.Run(tt.name, func(t *testing.T) { 146 | setUpBasicIndex(sv, tt.args.UID) 147 | c := tt.args.client 148 | i := c.Index(tt.args.UID) 149 | t.Cleanup(cleanup(c)) 150 | 151 | gotResp, err := i.GetStats() 152 | require.NoError(t, err) 153 | require.Equal(t, tt.wantResp, gotResp) 154 | }) 155 | } 156 | } 157 | 158 | func Test_newIndex(t *testing.T) { 159 | sv := setup(t, "") 160 | customSv := setup(t, "", WithCustomClientWithTLS(&tls.Config{ 161 | InsecureSkipVerify: true, 162 | })) 163 | 164 | type args struct { 165 | client ServiceManager 166 | uid string 167 | } 168 | tests := []struct { 169 | name string 170 | args args 171 | want IndexManager 172 | }{ 173 | { 174 | name: "TestBasicNewIndex", 175 | args: args{ 176 | client: sv, 177 | uid: "TestBasicNewIndex", 178 | }, 179 | want: sv.Index("TestBasicNewIndex"), 180 | }, 181 | { 182 | name: "TestNewIndexCustomClient", 183 | args: args{ 184 | client: sv, 185 | uid: "TestNewIndexCustomClient", 186 | }, 187 | want: customSv.Index("TestNewIndexCustomClient"), 188 | }, 189 | } 190 | for _, tt := range tests { 191 | t.Run(tt.name, func(t *testing.T) { 192 | c := tt.args.client 193 | t.Cleanup(cleanup(c)) 194 | 195 | gotIdx := c.Index(tt.args.uid) 196 | 197 | task, err := c.CreateIndex(&IndexConfig{Uid: tt.args.uid}) 198 | require.NoError(t, err) 199 | 200 | testWaitForTask(t, gotIdx, task) 201 | 202 | gotIdxResult, err := gotIdx.FetchInfo() 203 | require.NoError(t, err) 204 | 205 | wantIdxResult, err := tt.want.FetchInfo() 206 | require.NoError(t, err) 207 | 208 | require.Equal(t, gotIdxResult.UID, wantIdxResult.UID) 209 | // Timestamps should be empty unless fetched 210 | require.NotZero(t, gotIdxResult.CreatedAt) 211 | require.NotZero(t, gotIdxResult.UpdatedAt) 212 | }) 213 | } 214 | } 215 | 216 | func TestIndex_FetchInfo(t *testing.T) { 217 | sv := setup(t, "") 218 | customSv := setup(t, "", WithCustomClientWithTLS(&tls.Config{ 219 | InsecureSkipVerify: true, 220 | })) 221 | broken := setup(t, "", WithAPIKey("wrong")) 222 | 223 | type args struct { 224 | UID string 225 | client ServiceManager 226 | } 227 | tests := []struct { 228 | name string 229 | args args 230 | wantResp *IndexResult 231 | }{ 232 | { 233 | name: "TestIndexBasicFetchInfo", 234 | args: args{ 235 | UID: "TestIndexBasicFetchInfo", 236 | client: sv, 237 | }, 238 | wantResp: &IndexResult{ 239 | UID: "TestIndexBasicFetchInfo", 240 | PrimaryKey: "book_id", 241 | }, 242 | }, 243 | { 244 | name: "TestIndexFetchInfoWithCustomClient", 245 | args: args{ 246 | UID: "TestIndexFetchInfoWithCustomClient", 247 | client: customSv, 248 | }, 249 | wantResp: &IndexResult{ 250 | UID: "TestIndexFetchInfoWithCustomClient", 251 | PrimaryKey: "book_id", 252 | }, 253 | }, 254 | { 255 | name: "TestIndexFetchInfoWithBrokenClient", 256 | args: args{ 257 | UID: "TestIndexFetchInfoWithCustomClient", 258 | client: broken, 259 | }, 260 | wantResp: nil, 261 | }, 262 | } 263 | for _, tt := range tests { 264 | t.Run(tt.name, func(t *testing.T) { 265 | setUpBasicIndex(sv, tt.args.UID) 266 | c := tt.args.client 267 | t.Cleanup(cleanup(c)) 268 | 269 | i := c.Index(tt.args.UID) 270 | 271 | gotResp, err := i.FetchInfo() 272 | 273 | if tt.wantResp == nil { 274 | require.Error(t, err) 275 | require.Nil(t, gotResp) 276 | } else { 277 | require.NoError(t, err) 278 | require.Equal(t, tt.wantResp.UID, gotResp.UID) 279 | require.Equal(t, tt.wantResp.PrimaryKey, gotResp.PrimaryKey) 280 | // Make sure that timestamps are also fetched and are updated 281 | require.NotZero(t, gotResp.CreatedAt) 282 | require.NotZero(t, gotResp.UpdatedAt) 283 | } 284 | 285 | }) 286 | } 287 | } 288 | 289 | func TestIndex_FetchPrimaryKey(t *testing.T) { 290 | sv := setup(t, "") 291 | customSv := setup(t, "", WithCustomClientWithTLS(&tls.Config{ 292 | InsecureSkipVerify: true, 293 | })) 294 | 295 | type args struct { 296 | UID string 297 | client ServiceManager 298 | } 299 | tests := []struct { 300 | name string 301 | args args 302 | wantPrimaryKey string 303 | }{ 304 | { 305 | name: "TestIndexBasicFetchPrimaryKey", 306 | args: args{ 307 | UID: "TestIndexBasicFetchPrimaryKey", 308 | client: sv, 309 | }, 310 | wantPrimaryKey: "book_id", 311 | }, 312 | { 313 | name: "TestIndexFetchPrimaryKeyWithCustomClient", 314 | args: args{ 315 | UID: "TestIndexFetchPrimaryKeyWithCustomClient", 316 | client: customSv, 317 | }, 318 | wantPrimaryKey: "book_id", 319 | }, 320 | } 321 | for _, tt := range tests { 322 | t.Run(tt.name, func(t *testing.T) { 323 | setUpBasicIndex(tt.args.client, tt.args.UID) 324 | c := tt.args.client 325 | i := c.Index(tt.args.UID) 326 | t.Cleanup(cleanup(c)) 327 | 328 | gotPrimaryKey, err := i.FetchPrimaryKey() 329 | require.NoError(t, err) 330 | require.Equal(t, &tt.wantPrimaryKey, gotPrimaryKey) 331 | }) 332 | } 333 | } 334 | 335 | func TestIndex_UpdateIndex(t *testing.T) { 336 | sv := setup(t, "") 337 | customSv := setup(t, "", WithCustomClientWithTLS(&tls.Config{ 338 | InsecureSkipVerify: true, 339 | })) 340 | 341 | type args struct { 342 | primaryKey string 343 | config IndexConfig 344 | client ServiceManager 345 | } 346 | tests := []struct { 347 | name string 348 | args args 349 | wantResp *IndexResult 350 | }{ 351 | { 352 | name: "TestIndexBasicUpdateIndex", 353 | args: args{ 354 | client: sv, 355 | config: IndexConfig{ 356 | Uid: "indexUID", 357 | }, 358 | primaryKey: "book_id", 359 | }, 360 | wantResp: &IndexResult{ 361 | UID: "indexUID", 362 | PrimaryKey: "book_id", 363 | }, 364 | }, 365 | { 366 | name: "TestIndexUpdateIndexWithCustomClient", 367 | args: args{ 368 | client: customSv, 369 | config: IndexConfig{ 370 | Uid: "indexUID", 371 | }, 372 | primaryKey: "book_id", 373 | }, 374 | wantResp: &IndexResult{ 375 | UID: "indexUID", 376 | PrimaryKey: "book_id", 377 | }, 378 | }, 379 | } 380 | for _, tt := range tests { 381 | t.Run(tt.name, func(t *testing.T) { 382 | c := tt.args.client 383 | t.Cleanup(cleanup(c)) 384 | 385 | i, err := setUpEmptyIndex(tt.args.client, &tt.args.config) 386 | require.NoError(t, err) 387 | require.Equal(t, tt.args.config.Uid, i.UID) 388 | // Store original timestamps 389 | createdAt := i.CreatedAt 390 | updatedAt := i.UpdatedAt 391 | 392 | gotResp, err := i.UpdateIndex(tt.args.primaryKey) 393 | require.NoError(t, err) 394 | 395 | _, err = c.WaitForTask(gotResp.TaskUID, 0) 396 | require.NoError(t, err) 397 | 398 | require.NoError(t, err) 399 | require.Equal(t, tt.wantResp.UID, gotResp.IndexUID) 400 | 401 | gotIndex, err := c.GetIndex(tt.args.config.Uid) 402 | require.NoError(t, err) 403 | require.Equal(t, tt.wantResp.PrimaryKey, gotIndex.PrimaryKey) 404 | // Make sure that timestamps were correctly updated as well 405 | require.Equal(t, createdAt, gotIndex.CreatedAt) 406 | require.NotEqual(t, updatedAt, gotIndex.UpdatedAt) 407 | }) 408 | } 409 | } 410 | 411 | func TestIndexManagerAndReaders(t *testing.T) { 412 | c := setup(t, "") 413 | idx := c.Index("indexUID") 414 | require.NotNil(t, idx) 415 | require.NotNil(t, idx.GetIndexReader()) 416 | require.NotNil(t, idx.GetTaskReader()) 417 | require.NotNil(t, idx.GetSettingsManager()) 418 | require.NotNil(t, idx.GetSettingsReader()) 419 | require.NotNil(t, idx.GetSearch()) 420 | require.NotNil(t, idx.GetDocumentManager()) 421 | require.NotNil(t, idx.GetDocumentReader()) 422 | } 423 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/csv" 7 | "encoding/json" 8 | "fmt" 9 | "github.com/stretchr/testify/require" 10 | "io" 11 | "net/http" 12 | "os" 13 | "strings" 14 | "testing" 15 | ) 16 | 17 | var ( 18 | masterKey = "masterKey" 19 | defaultRankingRules = []string{ 20 | "words", "typo", "proximity", "attribute", "sort", "exactness", 21 | } 22 | defaultTypoTolerance = TypoTolerance{ 23 | Enabled: true, 24 | MinWordSizeForTypos: MinWordSizeForTypos{ 25 | OneTypo: 5, 26 | TwoTypos: 9, 27 | }, 28 | DisableOnWords: []string{}, 29 | DisableOnAttributes: []string{}, 30 | } 31 | defaultPagination = Pagination{ 32 | MaxTotalHits: 1000, 33 | } 34 | defaultFaceting = Faceting{ 35 | MaxValuesPerFacet: 100, 36 | SortFacetValuesBy: map[string]SortFacetType{ 37 | "*": SortFacetTypeAlpha, 38 | }, 39 | } 40 | ) 41 | 42 | var testNdjsonDocuments = []byte(`{"id": 1, "name": "Alice In Wonderland"} 43 | {"id": 2, "name": "Pride and Prejudice"} 44 | {"id": 3, "name": "Le Petit Prince"} 45 | {"id": 4, "name": "The Great Gatsby"} 46 | {"id": 5, "name": "Don Quixote"} 47 | `) 48 | 49 | var testCsvDocuments = []byte(`id,name 50 | 1,Alice In Wonderland 51 | 2,Pride and Prejudice 52 | 3,Le Petit Prince 53 | 4,The Great Gatsby 54 | 5,Don Quixote 55 | `) 56 | 57 | type docTest struct { 58 | ID string `json:"id"` 59 | Name string `json:"name"` 60 | } 61 | 62 | type docTestBooks struct { 63 | BookID int `json:"book_id"` 64 | Title string `json:"title"` 65 | Tag string `json:"tag"` 66 | Year int `json:"year"` 67 | } 68 | 69 | func setup(t *testing.T, host string, options ...Option) ServiceManager { 70 | t.Helper() 71 | 72 | opts := make([]Option, 0) 73 | opts = append(opts, WithAPIKey(masterKey)) 74 | opts = append(opts, options...) 75 | 76 | if host == "" { 77 | host = getenv("MEILISEARCH_URL", "http://localhost:7700") 78 | } 79 | 80 | sv := New(host, opts...) 81 | return sv 82 | } 83 | 84 | func cleanup(services ...ServiceManager) func() { 85 | return func() { 86 | for _, s := range services { 87 | _, _ = deleteAllIndexes(s) 88 | _, _ = deleteAllKeys(s) 89 | } 90 | } 91 | } 92 | 93 | func getPrivateKey(sv ServiceManager) (key string) { 94 | list, err := sv.GetKeys(nil) 95 | if err != nil { 96 | return "" 97 | } 98 | for _, key := range list.Results { 99 | if strings.Contains(key.Name, "Default Admin API Key") || (key.Description == "") { 100 | return key.Key 101 | } 102 | } 103 | return "" 104 | } 105 | 106 | func getPrivateUIDKey(sv ServiceManager) (key string) { 107 | list, err := sv.GetKeys(nil) 108 | if err != nil { 109 | return "" 110 | } 111 | for _, key := range list.Results { 112 | if strings.Contains(key.Name, "Default Admin API Key") || (key.Description == "") { 113 | return key.UID 114 | } 115 | } 116 | return "" 117 | } 118 | 119 | func deleteAllIndexes(sv ServiceManager) (ok bool, err error) { 120 | list, err := sv.ListIndexes(nil) 121 | if err != nil { 122 | return false, err 123 | } 124 | 125 | for _, index := range list.Results { 126 | task, _ := sv.DeleteIndex(index.UID) 127 | _, err := sv.WaitForTask(task.TaskUID, 0) 128 | if err != nil { 129 | return false, err 130 | } 131 | } 132 | 133 | return true, nil 134 | } 135 | 136 | func deleteAllKeys(sv ServiceManager) (ok bool, err error) { 137 | list, err := sv.GetKeys(nil) 138 | if err != nil { 139 | return false, err 140 | } 141 | 142 | for _, key := range list.Results { 143 | if strings.Contains(key.Description, "Test") || (key.Description == "") { 144 | _, err = sv.DeleteKey(key.Key) 145 | if err != nil { 146 | return false, err 147 | } 148 | } 149 | } 150 | 151 | return true, nil 152 | } 153 | 154 | func getenv(key, fallback string) string { 155 | value := os.Getenv(key) 156 | if len(value) == 0 { 157 | return fallback 158 | } 159 | return value 160 | } 161 | 162 | func testWaitForTask(t *testing.T, i IndexManager, u *TaskInfo) { 163 | t.Helper() 164 | r, err := i.WaitForTask(u.TaskUID, 0) 165 | require.NoError(t, err) 166 | require.Equal(t, TaskStatusSucceeded, r.Status, fmt.Sprintf("Task failed: %#+v", r)) 167 | } 168 | 169 | func testWaitForBatchTask(t *testing.T, i IndexManager, u []TaskInfo) { 170 | for _, id := range u { 171 | _, err := i.WaitForTask(id.TaskUID, 0) 172 | require.NoError(t, err) 173 | } 174 | } 175 | 176 | func setUpEmptyIndex(sv ServiceManager, index *IndexConfig) (resp *IndexResult, err error) { 177 | task, err := sv.CreateIndex(index) 178 | if err != nil { 179 | fmt.Println(err) 180 | return nil, err 181 | } 182 | finalTask, _ := sv.WaitForTask(task.TaskUID, 0) 183 | if finalTask.Status != "succeeded" { 184 | cleanup(sv) 185 | return setUpEmptyIndex(sv, index) 186 | } 187 | return sv.GetIndex(index.Uid) 188 | } 189 | 190 | func setUpBasicIndex(sv ServiceManager, indexUID string) { 191 | index := sv.Index(indexUID) 192 | 193 | documents := []map[string]interface{}{ 194 | {"book_id": 123, "title": "Pride and Prejudice"}, 195 | {"book_id": 456, "title": "Le Petit Prince"}, 196 | {"book_id": 1, "title": "Alice In Wonderland"}, 197 | {"book_id": 1344, "title": "The Hobbit"}, 198 | {"book_id": 4, "title": "Harry Potter and the Half-Blood Prince"}, 199 | {"book_id": 42, "title": "The Hitchhiker's Guide to the Galaxy"}, 200 | } 201 | 202 | task, err := index.AddDocuments(documents) 203 | if err != nil { 204 | fmt.Println(err) 205 | os.Exit(1) 206 | } 207 | finalTask, _ := index.WaitForTask(task.TaskUID, 0) 208 | if finalTask.Status != "succeeded" { 209 | os.Exit(1) 210 | } 211 | } 212 | 213 | func setupMovieIndex(t *testing.T, client ServiceManager) IndexManager { 214 | t.Helper() 215 | 216 | idx := client.Index("indexUID") 217 | 218 | testdata, err := os.Open("./testdata/movies.json") 219 | require.NoError(t, err) 220 | defer testdata.Close() 221 | 222 | tests := make([]map[string]interface{}, 0) 223 | 224 | require.NoError(t, json.NewDecoder(testdata).Decode(&tests)) 225 | 226 | task, err := idx.AddDocuments(tests) 227 | require.NoError(t, err) 228 | testWaitForTask(t, idx, task) 229 | 230 | task, err = idx.UpdateFilterableAttributes(&[]string{"id"}) 231 | require.NoError(t, err) 232 | testWaitForTask(t, idx, task) 233 | 234 | return idx 235 | } 236 | 237 | func setUpIndexForFaceting(client ServiceManager) { 238 | idx := client.Index("indexUID") 239 | 240 | booksTest := []docTestBooks{ 241 | {BookID: 123, Title: "Pride and Prejudice", Tag: "Romance", Year: 1813}, 242 | {BookID: 456, Title: "Le Petit Prince", Tag: "Tale", Year: 1943}, 243 | {BookID: 1, Title: "Alice In Wonderland", Tag: "Tale", Year: 1865}, 244 | {BookID: 1344, Title: "The Hobbit", Tag: "Epic fantasy", Year: 1937}, 245 | {BookID: 4, Title: "Harry Potter and the Half-Blood Prince", Tag: "Epic fantasy", Year: 2005}, 246 | {BookID: 42, Title: "The Hitchhiker's Guide to the Galaxy", Tag: "Epic fantasy", Year: 1978}, 247 | {BookID: 742, Title: "The Great Gatsby", Tag: "Tragedy", Year: 1925}, 248 | {BookID: 834, Title: "One Hundred Years of Solitude", Tag: "Tragedy", Year: 1967}, 249 | {BookID: 17, Title: "In Search of Lost Time", Tag: "Modernist literature", Year: 1913}, 250 | {BookID: 204, Title: "Ulysses", Tag: "Novel", Year: 1922}, 251 | {BookID: 7, Title: "Don Quixote", Tag: "Satiric", Year: 1605}, 252 | {BookID: 10, Title: "Moby Dick", Tag: "Novel", Year: 1851}, 253 | {BookID: 730, Title: "War and Peace", Tag: "Historical fiction", Year: 1865}, 254 | {BookID: 69, Title: "Hamlet", Tag: "Tragedy", Year: 1598}, 255 | {BookID: 32, Title: "The Odyssey", Tag: "Epic", Year: 1571}, 256 | {BookID: 71, Title: "Madame Bovary", Tag: "Novel", Year: 1857}, 257 | {BookID: 56, Title: "The Divine Comedy", Tag: "Epic", Year: 1303}, 258 | {BookID: 254, Title: "Lolita", Tag: "Novel", Year: 1955}, 259 | {BookID: 921, Title: "The Brothers Karamazov", Tag: "Novel", Year: 1879}, 260 | {BookID: 1032, Title: "Crime and Punishment", Tag: "Crime fiction", Year: 1866}, 261 | {BookID: 1039, Title: "The Girl in the white shirt", Tag: "white shirt", Year: 1999}, 262 | {BookID: 1050, Title: "星の王子さま", Tag: "物語", Year: 1943}, 263 | } 264 | task, err := idx.AddDocuments(booksTest) 265 | if err != nil { 266 | fmt.Println(err) 267 | os.Exit(1) 268 | } 269 | finalTask, _ := idx.WaitForTask(task.TaskUID, 0) 270 | if finalTask.Status != "succeeded" { 271 | os.Exit(1) 272 | } 273 | } 274 | 275 | func setUpIndexWithNestedFields(client ServiceManager, indexUID string) { 276 | index := client.Index(indexUID) 277 | 278 | documents := []map[string]interface{}{ 279 | {"id": 1, "title": "Pride and Prejudice", "info": map[string]interface{}{"comment": "A great book", "reviewNb": 50}}, 280 | {"id": 2, "title": "Le Petit Prince", "info": map[string]interface{}{"comment": "A french book", "reviewNb": 600}}, 281 | {"id": 3, "title": "Le Rouge et le Noir", "info": map[string]interface{}{"comment": "Another french book", "reviewNb": 700}}, 282 | {"id": 4, "title": "Alice In Wonderland", "comment": "A weird book", "info": map[string]interface{}{"comment": "A weird book", "reviewNb": 800}}, 283 | {"id": 5, "title": "The Hobbit", "info": map[string]interface{}{"comment": "An awesome book", "reviewNb": 900}}, 284 | {"id": 6, "title": "Harry Potter and the Half-Blood Prince", "info": map[string]interface{}{"comment": "The best book", "reviewNb": 1000}}, 285 | {"id": 7, "title": "The Hitchhiker's Guide to the Galaxy"}, 286 | } 287 | task, err := index.AddDocuments(documents) 288 | if err != nil { 289 | fmt.Println(err) 290 | os.Exit(1) 291 | } 292 | finalTask, _ := index.WaitForTask(task.TaskUID, 0) 293 | if finalTask.Status != "succeeded" { 294 | os.Exit(1) 295 | } 296 | } 297 | 298 | func setUpIndexWithVector(client *meilisearch, indexUID string) (resp *IndexResult, err error) { 299 | req := &internalRequest{ 300 | endpoint: "/experimental-features", 301 | method: http.MethodPatch, 302 | contentType: "application/json", 303 | withRequest: map[string]interface{}{ 304 | "vectorStore": true, 305 | }, 306 | } 307 | 308 | if err := client.client.executeRequest(context.Background(), req); err != nil { 309 | return nil, err 310 | } 311 | 312 | idx := client.Index(indexUID) 313 | taskInfo, err := idx.UpdateSettings(&Settings{ 314 | Embedders: map[string]Embedder{ 315 | "default": { 316 | Source: "userProvided", 317 | Dimensions: 3, 318 | }, 319 | }, 320 | }) 321 | if err != nil { 322 | return nil, err 323 | } 324 | settingsTask, err := idx.WaitForTask(taskInfo.TaskUID, 0) 325 | if err != nil { 326 | return nil, err 327 | } 328 | if settingsTask.Status != TaskStatusSucceeded { 329 | return nil, fmt.Errorf("Update settings task failed: %#+v", settingsTask) 330 | } 331 | 332 | documents := []map[string]interface{}{ 333 | {"book_id": 123, "title": "Pride and Prejudice", "_vectors": map[string]interface{}{"default": []float64{0.1, 0.2, 0.3}}}, 334 | {"book_id": 456, "title": "Le Petit Prince", "_vectors": map[string]interface{}{"default": []float64{2.4, 8.5, 1.6}}}, 335 | } 336 | 337 | taskInfo, err = idx.AddDocuments(documents) 338 | if err != nil { 339 | return nil, err 340 | } 341 | 342 | finalTask, _ := idx.WaitForTask(taskInfo.TaskUID, 0) 343 | if finalTask.Status != TaskStatusSucceeded { 344 | return nil, fmt.Errorf("Add documents task failed: %#+v", finalTask) 345 | } 346 | 347 | return client.GetIndex(indexUID) 348 | } 349 | 350 | func setUpDistinctIndex(client ServiceManager, indexUID string) { 351 | idx := client.Index(indexUID) 352 | 353 | atters := []string{"product_id", "title", "sku", "url"} 354 | task, err := idx.UpdateFilterableAttributes(&atters) 355 | if err != nil { 356 | fmt.Println(err) 357 | os.Exit(1) 358 | } 359 | 360 | finalTask, _ := idx.WaitForTask(task.TaskUID, 0) 361 | if finalTask.Status != "succeeded" { 362 | os.Exit(1) 363 | } 364 | 365 | documents := []map[string]interface{}{ 366 | {"product_id": 123, "title": "white shirt", "sku": "sku1234", "url": "https://example.com/products/p123"}, 367 | {"product_id": 456, "title": "red shirt", "sku": "sku213", "url": "https://example.com/products/p456"}, 368 | {"product_id": 1, "title": "green shirt", "sku": "sku876", "url": "https://example.com/products/p1"}, 369 | {"product_id": 1344, "title": "blue shirt", "sku": "sku963", "url": "https://example.com/products/p1344"}, 370 | {"product_id": 4, "title": "yellow shirt", "sku": "sku9064", "url": "https://example.com/products/p4"}, 371 | {"product_id": 42, "title": "gray shirt", "sku": "sku964", "url": "https://example.com/products/p42"}, 372 | } 373 | task, err = idx.AddDocuments(documents) 374 | if err != nil { 375 | fmt.Println(err) 376 | os.Exit(1) 377 | } 378 | finalTask, _ = idx.WaitForTask(task.TaskUID, 0) 379 | if finalTask.Status != "succeeded" { 380 | os.Exit(1) 381 | } 382 | } 383 | 384 | func testParseCsvDocuments(t *testing.T, documents io.Reader) []map[string]interface{} { 385 | var ( 386 | docs []map[string]interface{} 387 | header []string 388 | ) 389 | r := csv.NewReader(documents) 390 | for { 391 | record, err := r.Read() 392 | if err == io.EOF { 393 | break 394 | } 395 | require.NoError(t, err) 396 | if header == nil { 397 | header = record 398 | continue 399 | } 400 | doc := make(map[string]interface{}) 401 | for i, key := range header { 402 | doc[key] = record[i] 403 | } 404 | docs = append(docs, doc) 405 | } 406 | return docs 407 | } 408 | 409 | func testParseNdjsonDocuments(t *testing.T, documents io.Reader) []map[string]interface{} { 410 | var docs []map[string]interface{} 411 | scanner := bufio.NewScanner(documents) 412 | for scanner.Scan() { 413 | line := strings.TrimSpace(scanner.Text()) 414 | if line == "" { 415 | continue 416 | } 417 | doc := make(map[string]interface{}) 418 | err := json.Unmarshal([]byte(line), &doc) 419 | require.NoError(t, err) 420 | docs = append(docs, doc) 421 | } 422 | require.NoError(t, scanner.Err()) 423 | return docs 424 | } 425 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test easyjson requirements 2 | 3 | easyjson: 4 | easyjson -all types.go 5 | 6 | test: 7 | docker compose run --rm package bash -c "go get && golangci-lint run -v && go test -v" 8 | 9 | requirements: 10 | go get github.com/mailru/easyjson && go install github.com/mailru/easyjson/...@latest 11 | curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh 12 | go get -v -t ./... 13 | -------------------------------------------------------------------------------- /meilisearch.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/golang-jwt/jwt/v4" 11 | ) 12 | 13 | type meilisearch struct { 14 | client *client 15 | } 16 | 17 | // New create new service manager for operating on meilisearch 18 | func New(host string, options ...Option) ServiceManager { 19 | defOpt := defaultMeiliOpt 20 | 21 | for _, opt := range options { 22 | opt(defOpt) 23 | } 24 | 25 | return &meilisearch{ 26 | client: newClient( 27 | defOpt.client, 28 | host, 29 | defOpt.apiKey, 30 | clientConfig{ 31 | contentEncoding: defOpt.contentEncoding.encodingType, 32 | encodingCompressionLevel: defOpt.contentEncoding.level, 33 | disableRetry: defOpt.disableRetry, 34 | retryOnStatus: defOpt.retryOnStatus, 35 | maxRetries: defOpt.maxRetries, 36 | }, 37 | ), 38 | } 39 | } 40 | 41 | // Connect create service manager and check connection with meilisearch 42 | func Connect(host string, options ...Option) (ServiceManager, error) { 43 | meili := New(host, options...) 44 | 45 | if !meili.IsHealthy() { 46 | return nil, ErrConnectingFailed 47 | } 48 | 49 | return meili, nil 50 | } 51 | 52 | func (m *meilisearch) ServiceReader() ServiceReader { 53 | return m 54 | } 55 | 56 | func (m *meilisearch) TaskManager() TaskManager { 57 | return m 58 | } 59 | 60 | func (m *meilisearch) TaskReader() TaskReader { 61 | return m 62 | } 63 | 64 | func (m *meilisearch) KeyManager() KeyManager { 65 | return m 66 | } 67 | 68 | func (m *meilisearch) KeyReader() KeyReader { 69 | return m 70 | } 71 | 72 | func (m *meilisearch) Index(uid string) IndexManager { 73 | return newIndex(m.client, uid) 74 | } 75 | 76 | func (m *meilisearch) GetIndex(indexID string) (*IndexResult, error) { 77 | return m.GetIndexWithContext(context.Background(), indexID) 78 | } 79 | 80 | func (m *meilisearch) GetIndexWithContext(ctx context.Context, indexID string) (*IndexResult, error) { 81 | return newIndex(m.client, indexID).FetchInfoWithContext(ctx) 82 | } 83 | 84 | func (m *meilisearch) GetRawIndex(uid string) (map[string]interface{}, error) { 85 | return m.GetRawIndexWithContext(context.Background(), uid) 86 | } 87 | 88 | func (m *meilisearch) GetRawIndexWithContext(ctx context.Context, uid string) (map[string]interface{}, error) { 89 | resp := map[string]interface{}{} 90 | req := &internalRequest{ 91 | endpoint: "/indexes/" + uid, 92 | method: http.MethodGet, 93 | withRequest: nil, 94 | withResponse: &resp, 95 | acceptedStatusCodes: []int{http.StatusOK}, 96 | functionName: "GetRawIndex", 97 | } 98 | if err := m.client.executeRequest(ctx, req); err != nil { 99 | return nil, err 100 | } 101 | return resp, nil 102 | } 103 | 104 | func (m *meilisearch) ListIndexes(param *IndexesQuery) (*IndexesResults, error) { 105 | return m.ListIndexesWithContext(context.Background(), param) 106 | } 107 | 108 | func (m *meilisearch) ListIndexesWithContext(ctx context.Context, param *IndexesQuery) (*IndexesResults, error) { 109 | resp := new(IndexesResults) 110 | req := &internalRequest{ 111 | endpoint: "/indexes", 112 | method: http.MethodGet, 113 | withRequest: nil, 114 | withResponse: &resp, 115 | withQueryParams: map[string]string{}, 116 | acceptedStatusCodes: []int{http.StatusOK}, 117 | functionName: "GetIndexes", 118 | } 119 | if param != nil && param.Limit != 0 { 120 | req.withQueryParams["limit"] = strconv.FormatInt(param.Limit, 10) 121 | } 122 | if param != nil && param.Offset != 0 { 123 | req.withQueryParams["offset"] = strconv.FormatInt(param.Offset, 10) 124 | } 125 | if err := m.client.executeRequest(ctx, req); err != nil { 126 | return nil, err 127 | } 128 | 129 | for i := range resp.Results { 130 | resp.Results[i].IndexManager = newIndex(m.client, resp.Results[i].UID) 131 | } 132 | 133 | return resp, nil 134 | } 135 | 136 | func (m *meilisearch) GetRawIndexes(param *IndexesQuery) (map[string]interface{}, error) { 137 | return m.GetRawIndexesWithContext(context.Background(), param) 138 | } 139 | 140 | func (m *meilisearch) GetRawIndexesWithContext(ctx context.Context, param *IndexesQuery) (map[string]interface{}, error) { 141 | resp := map[string]interface{}{} 142 | req := &internalRequest{ 143 | endpoint: "/indexes", 144 | method: http.MethodGet, 145 | withRequest: nil, 146 | withResponse: &resp, 147 | withQueryParams: map[string]string{}, 148 | acceptedStatusCodes: []int{http.StatusOK}, 149 | functionName: "GetRawIndexes", 150 | } 151 | if param != nil && param.Limit != 0 { 152 | req.withQueryParams["limit"] = strconv.FormatInt(param.Limit, 10) 153 | } 154 | if param != nil && param.Offset != 0 { 155 | req.withQueryParams["offset"] = strconv.FormatInt(param.Offset, 10) 156 | } 157 | if err := m.client.executeRequest(ctx, req); err != nil { 158 | return nil, err 159 | } 160 | return resp, nil 161 | } 162 | 163 | func (m *meilisearch) CreateIndex(config *IndexConfig) (*TaskInfo, error) { 164 | return m.CreateIndexWithContext(context.Background(), config) 165 | } 166 | 167 | func (m *meilisearch) CreateIndexWithContext(ctx context.Context, config *IndexConfig) (*TaskInfo, error) { 168 | request := &CreateIndexRequest{ 169 | UID: config.Uid, 170 | PrimaryKey: config.PrimaryKey, 171 | } 172 | resp := new(TaskInfo) 173 | req := &internalRequest{ 174 | endpoint: "/indexes", 175 | method: http.MethodPost, 176 | contentType: contentTypeJSON, 177 | withRequest: request, 178 | withResponse: resp, 179 | acceptedStatusCodes: []int{http.StatusAccepted}, 180 | functionName: "CreateIndex", 181 | } 182 | if err := m.client.executeRequest(ctx, req); err != nil { 183 | return nil, err 184 | } 185 | return resp, nil 186 | } 187 | 188 | func (m *meilisearch) DeleteIndex(uid string) (*TaskInfo, error) { 189 | return m.DeleteIndexWithContext(context.Background(), uid) 190 | } 191 | 192 | func (m *meilisearch) DeleteIndexWithContext(ctx context.Context, uid string) (*TaskInfo, error) { 193 | resp := new(TaskInfo) 194 | req := &internalRequest{ 195 | endpoint: "/indexes/" + uid, 196 | method: http.MethodDelete, 197 | withRequest: nil, 198 | withResponse: resp, 199 | acceptedStatusCodes: []int{http.StatusAccepted}, 200 | functionName: "DeleteIndex", 201 | } 202 | if err := m.client.executeRequest(ctx, req); err != nil { 203 | return nil, err 204 | } 205 | return resp, nil 206 | } 207 | 208 | func (m *meilisearch) MultiSearch(queries *MultiSearchRequest) (*MultiSearchResponse, error) { 209 | return m.MultiSearchWithContext(context.Background(), queries) 210 | } 211 | 212 | func (m *meilisearch) MultiSearchWithContext(ctx context.Context, queries *MultiSearchRequest) (*MultiSearchResponse, error) { 213 | resp := new(MultiSearchResponse) 214 | 215 | for i := 0; i < len(queries.Queries); i++ { 216 | queries.Queries[i].validate() 217 | } 218 | 219 | req := &internalRequest{ 220 | endpoint: "/multi-search", 221 | method: http.MethodPost, 222 | contentType: contentTypeJSON, 223 | withRequest: queries, 224 | withResponse: resp, 225 | acceptedStatusCodes: []int{http.StatusOK}, 226 | functionName: "MultiSearch", 227 | } 228 | 229 | if err := m.client.executeRequest(ctx, req); err != nil { 230 | return nil, err 231 | } 232 | 233 | return resp, nil 234 | } 235 | 236 | func (m *meilisearch) CreateKey(request *Key) (*Key, error) { 237 | return m.CreateKeyWithContext(context.Background(), request) 238 | } 239 | 240 | func (m *meilisearch) CreateKeyWithContext(ctx context.Context, request *Key) (*Key, error) { 241 | parsedRequest := convertKeyToParsedKey(*request) 242 | resp := new(Key) 243 | req := &internalRequest{ 244 | endpoint: "/keys", 245 | method: http.MethodPost, 246 | contentType: contentTypeJSON, 247 | withRequest: &parsedRequest, 248 | withResponse: resp, 249 | acceptedStatusCodes: []int{http.StatusCreated}, 250 | functionName: "CreateKey", 251 | } 252 | if err := m.client.executeRequest(ctx, req); err != nil { 253 | return nil, err 254 | } 255 | return resp, nil 256 | } 257 | 258 | func (m *meilisearch) GetKey(identifier string) (*Key, error) { 259 | return m.GetKeyWithContext(context.Background(), identifier) 260 | } 261 | 262 | func (m *meilisearch) GetKeyWithContext(ctx context.Context, identifier string) (*Key, error) { 263 | resp := new(Key) 264 | req := &internalRequest{ 265 | endpoint: "/keys/" + identifier, 266 | method: http.MethodGet, 267 | withRequest: nil, 268 | withResponse: resp, 269 | acceptedStatusCodes: []int{http.StatusOK}, 270 | functionName: "GetKey", 271 | } 272 | if err := m.client.executeRequest(ctx, req); err != nil { 273 | return nil, err 274 | } 275 | return resp, nil 276 | } 277 | 278 | func (m *meilisearch) GetKeys(param *KeysQuery) (*KeysResults, error) { 279 | return m.GetKeysWithContext(context.Background(), param) 280 | } 281 | 282 | func (m *meilisearch) GetKeysWithContext(ctx context.Context, param *KeysQuery) (*KeysResults, error) { 283 | resp := new(KeysResults) 284 | req := &internalRequest{ 285 | endpoint: "/keys", 286 | method: http.MethodGet, 287 | withRequest: nil, 288 | withResponse: resp, 289 | withQueryParams: map[string]string{}, 290 | acceptedStatusCodes: []int{http.StatusOK}, 291 | functionName: "GetKeys", 292 | } 293 | if param != nil && param.Limit != 0 { 294 | req.withQueryParams["limit"] = strconv.FormatInt(param.Limit, 10) 295 | } 296 | if param != nil && param.Offset != 0 { 297 | req.withQueryParams["offset"] = strconv.FormatInt(param.Offset, 10) 298 | } 299 | if err := m.client.executeRequest(ctx, req); err != nil { 300 | return nil, err 301 | } 302 | return resp, nil 303 | } 304 | 305 | func (m *meilisearch) UpdateKey(keyOrUID string, request *Key) (*Key, error) { 306 | return m.UpdateKeyWithContext(context.Background(), keyOrUID, request) 307 | } 308 | 309 | func (m *meilisearch) UpdateKeyWithContext(ctx context.Context, keyOrUID string, request *Key) (*Key, error) { 310 | parsedRequest := KeyUpdate{Name: request.Name, Description: request.Description} 311 | resp := new(Key) 312 | req := &internalRequest{ 313 | endpoint: "/keys/" + keyOrUID, 314 | method: http.MethodPatch, 315 | contentType: contentTypeJSON, 316 | withRequest: &parsedRequest, 317 | withResponse: resp, 318 | acceptedStatusCodes: []int{http.StatusOK}, 319 | functionName: "UpdateKey", 320 | } 321 | if err := m.client.executeRequest(ctx, req); err != nil { 322 | return nil, err 323 | } 324 | return resp, nil 325 | } 326 | 327 | func (m *meilisearch) DeleteKey(keyOrUID string) (bool, error) { 328 | return m.DeleteKeyWithContext(context.Background(), keyOrUID) 329 | } 330 | 331 | func (m *meilisearch) DeleteKeyWithContext(ctx context.Context, keyOrUID string) (bool, error) { 332 | req := &internalRequest{ 333 | endpoint: "/keys/" + keyOrUID, 334 | method: http.MethodDelete, 335 | withRequest: nil, 336 | withResponse: nil, 337 | acceptedStatusCodes: []int{http.StatusNoContent}, 338 | functionName: "DeleteKey", 339 | } 340 | if err := m.client.executeRequest(ctx, req); err != nil { 341 | return false, err 342 | } 343 | return true, nil 344 | } 345 | 346 | func (m *meilisearch) GetTask(taskUID int64) (*Task, error) { 347 | return m.GetTaskWithContext(context.Background(), taskUID) 348 | } 349 | 350 | func (m *meilisearch) GetTaskWithContext(ctx context.Context, taskUID int64) (*Task, error) { 351 | return getTask(ctx, m.client, taskUID) 352 | } 353 | 354 | func (m *meilisearch) GetTasks(param *TasksQuery) (*TaskResult, error) { 355 | return m.GetTasksWithContext(context.Background(), param) 356 | } 357 | 358 | func (m *meilisearch) GetTasksWithContext(ctx context.Context, param *TasksQuery) (*TaskResult, error) { 359 | resp := new(TaskResult) 360 | req := &internalRequest{ 361 | endpoint: "/tasks", 362 | method: http.MethodGet, 363 | withRequest: nil, 364 | withResponse: &resp, 365 | withQueryParams: map[string]string{}, 366 | acceptedStatusCodes: []int{http.StatusOK}, 367 | functionName: "GetTasks", 368 | } 369 | if param != nil { 370 | encodeTasksQuery(param, req) 371 | } 372 | if err := m.client.executeRequest(ctx, req); err != nil { 373 | return nil, err 374 | } 375 | return resp, nil 376 | } 377 | 378 | func (m *meilisearch) CancelTasks(param *CancelTasksQuery) (*TaskInfo, error) { 379 | return m.CancelTasksWithContext(context.Background(), param) 380 | } 381 | 382 | func (m *meilisearch) CancelTasksWithContext(ctx context.Context, param *CancelTasksQuery) (*TaskInfo, error) { 383 | resp := new(TaskInfo) 384 | req := &internalRequest{ 385 | endpoint: "/tasks/cancel", 386 | method: http.MethodPost, 387 | withRequest: nil, 388 | withResponse: &resp, 389 | withQueryParams: map[string]string{}, 390 | acceptedStatusCodes: []int{http.StatusOK}, 391 | functionName: "CancelTasks", 392 | } 393 | if param != nil { 394 | paramToSend := &TasksQuery{ 395 | UIDS: param.UIDS, 396 | IndexUIDS: param.IndexUIDS, 397 | Statuses: param.Statuses, 398 | Types: param.Types, 399 | BeforeEnqueuedAt: param.BeforeEnqueuedAt, 400 | AfterEnqueuedAt: param.AfterEnqueuedAt, 401 | BeforeStartedAt: param.BeforeStartedAt, 402 | AfterStartedAt: param.AfterStartedAt, 403 | } 404 | encodeTasksQuery(paramToSend, req) 405 | } 406 | if err := m.client.executeRequest(ctx, req); err != nil { 407 | return nil, err 408 | } 409 | return resp, nil 410 | } 411 | 412 | func (m *meilisearch) DeleteTasks(param *DeleteTasksQuery) (*TaskInfo, error) { 413 | return m.DeleteTasksWithContext(context.Background(), param) 414 | } 415 | 416 | func (m *meilisearch) DeleteTasksWithContext(ctx context.Context, param *DeleteTasksQuery) (*TaskInfo, error) { 417 | resp := new(TaskInfo) 418 | req := &internalRequest{ 419 | endpoint: "/tasks", 420 | method: http.MethodDelete, 421 | withRequest: nil, 422 | withResponse: &resp, 423 | withQueryParams: map[string]string{}, 424 | acceptedStatusCodes: []int{http.StatusOK}, 425 | functionName: "DeleteTasks", 426 | } 427 | if param != nil { 428 | paramToSend := &TasksQuery{ 429 | UIDS: param.UIDS, 430 | IndexUIDS: param.IndexUIDS, 431 | Statuses: param.Statuses, 432 | Types: param.Types, 433 | CanceledBy: param.CanceledBy, 434 | BeforeEnqueuedAt: param.BeforeEnqueuedAt, 435 | AfterEnqueuedAt: param.AfterEnqueuedAt, 436 | BeforeStartedAt: param.BeforeStartedAt, 437 | AfterStartedAt: param.AfterStartedAt, 438 | BeforeFinishedAt: param.BeforeFinishedAt, 439 | AfterFinishedAt: param.AfterFinishedAt, 440 | } 441 | encodeTasksQuery(paramToSend, req) 442 | } 443 | if err := m.client.executeRequest(ctx, req); err != nil { 444 | return nil, err 445 | } 446 | return resp, nil 447 | } 448 | 449 | func (m *meilisearch) SwapIndexes(param []*SwapIndexesParams) (*TaskInfo, error) { 450 | return m.SwapIndexesWithContext(context.Background(), param) 451 | } 452 | 453 | func (m *meilisearch) SwapIndexesWithContext(ctx context.Context, param []*SwapIndexesParams) (*TaskInfo, error) { 454 | resp := new(TaskInfo) 455 | req := &internalRequest{ 456 | endpoint: "/swap-indexes", 457 | method: http.MethodPost, 458 | contentType: contentTypeJSON, 459 | withRequest: param, 460 | withResponse: &resp, 461 | acceptedStatusCodes: []int{http.StatusAccepted}, 462 | functionName: "SwapIndexes", 463 | } 464 | if err := m.client.executeRequest(ctx, req); err != nil { 465 | return nil, err 466 | } 467 | return resp, nil 468 | } 469 | 470 | func (m *meilisearch) WaitForTask(taskUID int64, interval time.Duration) (*Task, error) { 471 | return waitForTask(context.Background(), m.client, taskUID, interval) 472 | } 473 | 474 | func (m *meilisearch) WaitForTaskWithContext(ctx context.Context, taskUID int64, interval time.Duration) (*Task, error) { 475 | return waitForTask(ctx, m.client, taskUID, interval) 476 | } 477 | 478 | func (m *meilisearch) GenerateTenantToken( 479 | apiKeyUID string, 480 | searchRules map[string]interface{}, 481 | options *TenantTokenOptions, 482 | ) (string, error) { 483 | // validate the arguments 484 | if searchRules == nil { 485 | return "", fmt.Errorf("GenerateTenantToken: The search rules added in the token generation " + 486 | "must be of type array or object") 487 | } 488 | if (options == nil || options.APIKey == "") && m.client.apiKey == "" { 489 | return "", fmt.Errorf("GenerateTenantToken: The API key used for the token " + 490 | "generation must exist and be a valid meilisearch key") 491 | } 492 | if apiKeyUID == "" || !IsValidUUID(apiKeyUID) { 493 | return "", fmt.Errorf("GenerateTenantToken: The uid used for the token " + 494 | "generation must exist and comply to uuid4 format") 495 | } 496 | if options != nil && !options.ExpiresAt.IsZero() && options.ExpiresAt.Before(time.Now()) { 497 | return "", fmt.Errorf("GenerateTenantToken: When the expiresAt field in " + 498 | "the token generation has a value, it must be a date set in the future") 499 | } 500 | 501 | var secret string 502 | if options == nil || options.APIKey == "" { 503 | secret = m.client.apiKey 504 | } else { 505 | secret = options.APIKey 506 | } 507 | 508 | // For HMAC signing method, the key should be any []byte 509 | hmacSampleSecret := []byte(secret) 510 | 511 | // Create the claims 512 | claims := TenantTokenClaims{} 513 | if options != nil && !options.ExpiresAt.IsZero() { 514 | claims.RegisteredClaims = jwt.RegisteredClaims{ 515 | ExpiresAt: jwt.NewNumericDate(options.ExpiresAt), 516 | } 517 | } 518 | claims.APIKeyUID = apiKeyUID 519 | claims.SearchRules = searchRules 520 | 521 | // Create a new token object, specifying signing method and the claims 522 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 523 | 524 | // Sign and get the complete encoded token as a string using the secret 525 | tokenString, err := token.SignedString(hmacSampleSecret) 526 | 527 | return tokenString, err 528 | } 529 | 530 | func (m *meilisearch) GetStats() (*Stats, error) { 531 | return m.GetStatsWithContext(context.Background()) 532 | } 533 | 534 | func (m *meilisearch) GetStatsWithContext(ctx context.Context) (*Stats, error) { 535 | resp := new(Stats) 536 | req := &internalRequest{ 537 | endpoint: "/stats", 538 | method: http.MethodGet, 539 | withRequest: nil, 540 | withResponse: resp, 541 | acceptedStatusCodes: []int{http.StatusOK}, 542 | functionName: "GetStats", 543 | } 544 | if err := m.client.executeRequest(ctx, req); err != nil { 545 | return nil, err 546 | } 547 | return resp, nil 548 | } 549 | 550 | func (m *meilisearch) CreateDump() (*TaskInfo, error) { 551 | return m.CreateDumpWithContext(context.Background()) 552 | } 553 | 554 | func (m *meilisearch) CreateDumpWithContext(ctx context.Context) (*TaskInfo, error) { 555 | resp := new(TaskInfo) 556 | req := &internalRequest{ 557 | endpoint: "/dumps", 558 | method: http.MethodPost, 559 | contentType: contentTypeJSON, 560 | withRequest: nil, 561 | withResponse: resp, 562 | acceptedStatusCodes: []int{http.StatusAccepted}, 563 | functionName: "CreateDump", 564 | } 565 | if err := m.client.executeRequest(ctx, req); err != nil { 566 | return nil, err 567 | } 568 | return resp, nil 569 | } 570 | 571 | func (m *meilisearch) Version() (*Version, error) { 572 | return m.VersionWithContext(context.Background()) 573 | } 574 | 575 | func (m *meilisearch) VersionWithContext(ctx context.Context) (*Version, error) { 576 | resp := new(Version) 577 | req := &internalRequest{ 578 | endpoint: "/version", 579 | method: http.MethodGet, 580 | withRequest: nil, 581 | withResponse: resp, 582 | acceptedStatusCodes: []int{http.StatusOK}, 583 | functionName: "Version", 584 | } 585 | if err := m.client.executeRequest(ctx, req); err != nil { 586 | return nil, err 587 | } 588 | return resp, nil 589 | } 590 | 591 | func (m *meilisearch) Health() (*Health, error) { 592 | return m.HealthWithContext(context.Background()) 593 | } 594 | 595 | func (m *meilisearch) HealthWithContext(ctx context.Context) (*Health, error) { 596 | resp := new(Health) 597 | req := &internalRequest{ 598 | endpoint: "/health", 599 | method: http.MethodGet, 600 | withRequest: nil, 601 | withResponse: resp, 602 | acceptedStatusCodes: []int{http.StatusOK}, 603 | functionName: "Health", 604 | } 605 | if err := m.client.executeRequest(ctx, req); err != nil { 606 | return nil, err 607 | } 608 | return resp, nil 609 | } 610 | 611 | func (m *meilisearch) CreateSnapshot() (*TaskInfo, error) { 612 | return m.CreateSnapshotWithContext(context.Background()) 613 | } 614 | 615 | func (m *meilisearch) CreateSnapshotWithContext(ctx context.Context) (*TaskInfo, error) { 616 | resp := new(TaskInfo) 617 | req := &internalRequest{ 618 | endpoint: "/snapshots", 619 | method: http.MethodPost, 620 | withRequest: nil, 621 | withResponse: resp, 622 | acceptedStatusCodes: []int{http.StatusAccepted}, 623 | contentType: contentTypeJSON, 624 | functionName: "CreateSnapshot", 625 | } 626 | 627 | if err := m.client.executeRequest(ctx, req); err != nil { 628 | return nil, err 629 | } 630 | return resp, nil 631 | } 632 | 633 | func (m *meilisearch) IsHealthy() bool { 634 | res, err := m.HealthWithContext(context.Background()) 635 | return err == nil && res.Status == "available" 636 | } 637 | 638 | func (m *meilisearch) Close() { 639 | m.client.client.CloseIdleConnections() 640 | } 641 | 642 | func getTask(ctx context.Context, cli *client, taskUID int64) (*Task, error) { 643 | resp := new(Task) 644 | req := &internalRequest{ 645 | endpoint: "/tasks/" + strconv.FormatInt(taskUID, 10), 646 | method: http.MethodGet, 647 | withRequest: nil, 648 | withResponse: resp, 649 | acceptedStatusCodes: []int{http.StatusOK}, 650 | functionName: "GetTask", 651 | } 652 | if err := cli.executeRequest(ctx, req); err != nil { 653 | return nil, err 654 | } 655 | return resp, nil 656 | } 657 | 658 | func waitForTask(ctx context.Context, cli *client, taskUID int64, interval time.Duration) (*Task, error) { 659 | if interval == 0 { 660 | interval = 50 * time.Millisecond 661 | } 662 | 663 | // extract closure to get the task and check the status first before the ticker 664 | fn := func() (*Task, error) { 665 | getTask, err := getTask(ctx, cli, taskUID) 666 | if err != nil { 667 | return nil, err 668 | } 669 | 670 | if getTask.Status != TaskStatusEnqueued && getTask.Status != TaskStatusProcessing { 671 | return getTask, nil 672 | } 673 | return nil, nil 674 | } 675 | 676 | // run first before the ticker, we do not want to wait for the first interval 677 | task, err := fn() 678 | if err != nil { 679 | // Return error if it exists 680 | return nil, err 681 | } 682 | 683 | // Return task if it exists 684 | if task != nil { 685 | return task, nil 686 | } 687 | 688 | // Create a ticker to check the task status, because our initial check was not successful 689 | ticker := time.NewTicker(interval) 690 | 691 | // Defer the stop of the ticker, help GC to cleanup 692 | defer func() { 693 | // we might want to revist this, go.mod now is 1.16 694 | // however I still encouter the issue on go 1.22.2 695 | // there are 2 issues regarding tickers 696 | // https://go-review.googlesource.com/c/go/+/512355 697 | // https://github.com/golang/go/issues/61542 698 | ticker.Stop() 699 | ticker = nil 700 | }() 701 | 702 | for { 703 | select { 704 | case <-ctx.Done(): 705 | return nil, ctx.Err() 706 | case <-ticker.C: 707 | task, err := fn() 708 | if err != nil { 709 | return nil, err 710 | } 711 | 712 | if task != nil { 713 | return task, nil 714 | } 715 | } 716 | } 717 | } 718 | -------------------------------------------------------------------------------- /meilisearch_interface.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | type ServiceManager interface { 9 | ServiceReader 10 | KeyManager 11 | TaskManager 12 | 13 | ServiceReader() ServiceReader 14 | 15 | TaskManager() TaskManager 16 | TaskReader() TaskReader 17 | 18 | KeyManager() KeyManager 19 | KeyReader() KeyReader 20 | 21 | // CreateIndex creates a new index. 22 | CreateIndex(config *IndexConfig) (*TaskInfo, error) 23 | 24 | // CreateIndexWithContext creates a new index with a context for cancellation. 25 | CreateIndexWithContext(ctx context.Context, config *IndexConfig) (*TaskInfo, error) 26 | 27 | // DeleteIndex deletes a specific index. 28 | DeleteIndex(uid string) (*TaskInfo, error) 29 | 30 | // DeleteIndexWithContext deletes a specific index with a context for cancellation. 31 | DeleteIndexWithContext(ctx context.Context, uid string) (*TaskInfo, error) 32 | 33 | // SwapIndexes swaps the positions of two indexes. 34 | SwapIndexes(param []*SwapIndexesParams) (*TaskInfo, error) 35 | 36 | // SwapIndexesWithContext swaps the positions of two indexes with a context for cancellation. 37 | SwapIndexesWithContext(ctx context.Context, param []*SwapIndexesParams) (*TaskInfo, error) 38 | 39 | // GenerateTenantToken generates a tenant token for multi-tenancy. 40 | GenerateTenantToken(apiKeyUID string, searchRules map[string]interface{}, options *TenantTokenOptions) (string, error) 41 | 42 | // CreateDump creates a database dump. 43 | CreateDump() (*TaskInfo, error) 44 | 45 | // CreateDumpWithContext creates a database dump with a context for cancellation. 46 | CreateDumpWithContext(ctx context.Context) (*TaskInfo, error) 47 | 48 | // CreateSnapshot create database snapshot from meilisearch 49 | CreateSnapshot() (*TaskInfo, error) 50 | 51 | // CreateSnapshotWithContext create database snapshot from meilisearch and support parent context 52 | CreateSnapshotWithContext(ctx context.Context) (*TaskInfo, error) 53 | 54 | // ExperimentalFeatures returns the experimental features manager. 55 | ExperimentalFeatures() *ExperimentalFeatures 56 | 57 | // Close closes the connection to the Meilisearch server. 58 | Close() 59 | } 60 | 61 | type ServiceReader interface { 62 | // Index retrieves an IndexManager for a specific index. 63 | Index(uid string) IndexManager 64 | 65 | // GetIndex fetches the details of a specific index. 66 | GetIndex(indexID string) (*IndexResult, error) 67 | 68 | // GetIndexWithContext fetches the details of a specific index with a context for cancellation. 69 | GetIndexWithContext(ctx context.Context, indexID string) (*IndexResult, error) 70 | 71 | // GetRawIndex fetches the raw JSON representation of a specific index. 72 | GetRawIndex(uid string) (map[string]interface{}, error) 73 | 74 | // GetRawIndexWithContext fetches the raw JSON representation of a specific index with a context for cancellation. 75 | GetRawIndexWithContext(ctx context.Context, uid string) (map[string]interface{}, error) 76 | 77 | // ListIndexes lists all indexes. 78 | ListIndexes(param *IndexesQuery) (*IndexesResults, error) 79 | 80 | // ListIndexesWithContext lists all indexes with a context for cancellation. 81 | ListIndexesWithContext(ctx context.Context, param *IndexesQuery) (*IndexesResults, error) 82 | 83 | // GetRawIndexes fetches the raw JSON representation of all indexes. 84 | GetRawIndexes(param *IndexesQuery) (map[string]interface{}, error) 85 | 86 | // GetRawIndexesWithContext fetches the raw JSON representation of all indexes with a context for cancellation. 87 | GetRawIndexesWithContext(ctx context.Context, param *IndexesQuery) (map[string]interface{}, error) 88 | 89 | // MultiSearch performs a multi-index search. 90 | MultiSearch(queries *MultiSearchRequest) (*MultiSearchResponse, error) 91 | 92 | // MultiSearchWithContext performs a multi-index search with a context for cancellation. 93 | MultiSearchWithContext(ctx context.Context, queries *MultiSearchRequest) (*MultiSearchResponse, error) 94 | 95 | // GetStats fetches global stats. 96 | GetStats() (*Stats, error) 97 | 98 | // GetStatsWithContext fetches global stats with a context for cancellation. 99 | GetStatsWithContext(ctx context.Context) (*Stats, error) 100 | 101 | // Version fetches the version of the Meilisearch server. 102 | Version() (*Version, error) 103 | 104 | // VersionWithContext fetches the version of the Meilisearch server with a context for cancellation. 105 | VersionWithContext(ctx context.Context) (*Version, error) 106 | 107 | // Health checks the health of the Meilisearch server. 108 | Health() (*Health, error) 109 | 110 | // HealthWithContext checks the health of the Meilisearch server with a context for cancellation. 111 | HealthWithContext(ctx context.Context) (*Health, error) 112 | 113 | // IsHealthy checks if the Meilisearch server is healthy. 114 | IsHealthy() bool 115 | } 116 | 117 | type KeyManager interface { 118 | KeyReader 119 | 120 | // CreateKey creates a new API key. 121 | CreateKey(request *Key) (*Key, error) 122 | 123 | // CreateKeyWithContext creates a new API key with a context for cancellation. 124 | CreateKeyWithContext(ctx context.Context, request *Key) (*Key, error) 125 | 126 | // UpdateKey updates a specific API key. 127 | UpdateKey(keyOrUID string, request *Key) (*Key, error) 128 | 129 | // UpdateKeyWithContext updates a specific API key with a context for cancellation. 130 | UpdateKeyWithContext(ctx context.Context, keyOrUID string, request *Key) (*Key, error) 131 | 132 | // DeleteKey deletes a specific API key. 133 | DeleteKey(keyOrUID string) (bool, error) 134 | 135 | // DeleteKeyWithContext deletes a specific API key with a context for cancellation. 136 | DeleteKeyWithContext(ctx context.Context, keyOrUID string) (bool, error) 137 | } 138 | 139 | type KeyReader interface { 140 | // GetKey fetches the details of a specific API key. 141 | GetKey(identifier string) (*Key, error) 142 | 143 | // GetKeyWithContext fetches the details of a specific API key with a context for cancellation. 144 | GetKeyWithContext(ctx context.Context, identifier string) (*Key, error) 145 | 146 | // GetKeys lists all API keys. 147 | GetKeys(param *KeysQuery) (*KeysResults, error) 148 | 149 | // GetKeysWithContext lists all API keys with a context for cancellation. 150 | GetKeysWithContext(ctx context.Context, param *KeysQuery) (*KeysResults, error) 151 | } 152 | 153 | type TaskManager interface { 154 | TaskReader 155 | 156 | // CancelTasks cancels specific tasks. 157 | CancelTasks(param *CancelTasksQuery) (*TaskInfo, error) 158 | 159 | // CancelTasksWithContext cancels specific tasks with a context for cancellation. 160 | CancelTasksWithContext(ctx context.Context, param *CancelTasksQuery) (*TaskInfo, error) 161 | 162 | // DeleteTasks deletes specific tasks. 163 | DeleteTasks(param *DeleteTasksQuery) (*TaskInfo, error) 164 | 165 | // DeleteTasksWithContext deletes specific tasks with a context for cancellation. 166 | DeleteTasksWithContext(ctx context.Context, param *DeleteTasksQuery) (*TaskInfo, error) 167 | } 168 | 169 | type TaskReader interface { 170 | // GetTask retrieves a task by its UID. 171 | GetTask(taskUID int64) (*Task, error) 172 | 173 | // GetTaskWithContext retrieves a task by its UID using the provided context for cancellation. 174 | GetTaskWithContext(ctx context.Context, taskUID int64) (*Task, error) 175 | 176 | // GetTasks retrieves multiple tasks based on query parameters. 177 | GetTasks(param *TasksQuery) (*TaskResult, error) 178 | 179 | // GetTasksWithContext retrieves multiple tasks based on query parameters using the provided context for cancellation. 180 | GetTasksWithContext(ctx context.Context, param *TasksQuery) (*TaskResult, error) 181 | 182 | // WaitForTask waits for a task to complete by its UID with the given interval. 183 | WaitForTask(taskUID int64, interval time.Duration) (*Task, error) 184 | 185 | // WaitForTaskWithContext waits for a task to complete by its UID with the given interval using the provided context for cancellation. 186 | WaitForTaskWithContext(ctx context.Context, taskUID int64, interval time.Duration) (*Task, error) 187 | } 188 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "crypto/tls" 5 | "net" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | var ( 11 | defaultMeiliOpt = &meiliOpt{ 12 | client: &http.Client{ 13 | Transport: baseTransport(), 14 | }, 15 | contentEncoding: &encodingOpt{ 16 | level: DefaultCompression, 17 | }, 18 | retryOnStatus: map[int]bool{ 19 | 502: true, 20 | 503: true, 21 | 504: true, 22 | }, 23 | disableRetry: false, 24 | maxRetries: 3, 25 | } 26 | ) 27 | 28 | type meiliOpt struct { 29 | client *http.Client 30 | apiKey string 31 | contentEncoding *encodingOpt 32 | retryOnStatus map[int]bool 33 | disableRetry bool 34 | maxRetries uint8 35 | } 36 | 37 | type encodingOpt struct { 38 | encodingType ContentEncoding 39 | level EncodingCompressionLevel 40 | } 41 | 42 | type Option func(*meiliOpt) 43 | 44 | // WithCustomClient set custom http.Client 45 | func WithCustomClient(client *http.Client) Option { 46 | return func(opt *meiliOpt) { 47 | opt.client = client 48 | } 49 | } 50 | 51 | // WithCustomClientWithTLS client support tls configuration 52 | func WithCustomClientWithTLS(tlsConfig *tls.Config) Option { 53 | return func(opt *meiliOpt) { 54 | trans := baseTransport() 55 | trans.TLSClientConfig = tlsConfig 56 | opt.client = &http.Client{Transport: trans} 57 | } 58 | } 59 | 60 | // WithAPIKey is API key or master key. 61 | // 62 | // more: https://www.meilisearch.com/docs/reference/api/keys 63 | func WithAPIKey(key string) Option { 64 | return func(opt *meiliOpt) { 65 | opt.apiKey = key 66 | } 67 | } 68 | 69 | // WithContentEncoding support the Content-Encoding header indicates the media type is compressed by a given algorithm. 70 | // compression improves transfer speed and reduces bandwidth consumption by sending and receiving smaller payloads. 71 | // the Accept-Encoding header, instead, indicates the compression algorithm the client understands. 72 | // 73 | // more: https://www.meilisearch.com/docs/reference/api/overview#content-encoding 74 | func WithContentEncoding(encodingType ContentEncoding, level EncodingCompressionLevel) Option { 75 | return func(opt *meiliOpt) { 76 | opt.contentEncoding = &encodingOpt{ 77 | encodingType: encodingType, 78 | level: level, 79 | } 80 | } 81 | } 82 | 83 | // WithCustomRetries set retry on specific http error code and max retries (min: 1, max: 255) 84 | func WithCustomRetries(retryOnStatus []int, maxRetries uint8) Option { 85 | return func(opt *meiliOpt) { 86 | opt.retryOnStatus = make(map[int]bool) 87 | for _, status := range retryOnStatus { 88 | opt.retryOnStatus[status] = true 89 | } 90 | 91 | if maxRetries == 0 { 92 | maxRetries = 1 93 | } 94 | 95 | opt.maxRetries = maxRetries 96 | } 97 | } 98 | 99 | // DisableRetries disable retry logic in client 100 | func DisableRetries() Option { 101 | return func(opt *meiliOpt) { 102 | opt.disableRetry = true 103 | } 104 | } 105 | 106 | func baseTransport() *http.Transport { 107 | return &http.Transport{ 108 | Proxy: http.ProxyFromEnvironment, 109 | DialContext: (&net.Dialer{ 110 | Timeout: 30 * time.Second, 111 | KeepAlive: 30 * time.Second, 112 | }).DialContext, 113 | MaxIdleConns: 100, 114 | MaxIdleConnsPerHost: 100, 115 | IdleConnTimeout: 90 * time.Second, 116 | TLSHandshakeTimeout: 10 * time.Second, 117 | ExpectContinueTimeout: 1 * time.Second, 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /options_test.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "crypto/tls" 5 | "github.com/stretchr/testify/require" 6 | "net/http" 7 | "testing" 8 | ) 9 | 10 | func TestOptions_WithCustomClient(t *testing.T) { 11 | meili := setup(t, "", WithCustomClient(http.DefaultClient)) 12 | require.NotNil(t, meili) 13 | 14 | m, ok := meili.(*meilisearch) 15 | require.True(t, ok) 16 | 17 | require.Equal(t, m.client.client, http.DefaultClient) 18 | } 19 | 20 | func TestOptions_WithCustomClientWithTLS(t *testing.T) { 21 | tl := new(tls.Config) 22 | meili := setup(t, "", WithCustomClientWithTLS(tl)) 23 | require.NotNil(t, meili) 24 | 25 | m, ok := meili.(*meilisearch) 26 | require.True(t, ok) 27 | 28 | tr, ok := m.client.client.Transport.(*http.Transport) 29 | require.True(t, ok) 30 | 31 | require.Equal(t, tr.TLSClientConfig, tl) 32 | } 33 | 34 | func TestOptions_WithAPIKey(t *testing.T) { 35 | meili := setup(t, "", WithAPIKey("foobar")) 36 | require.NotNil(t, meili) 37 | 38 | m, ok := meili.(*meilisearch) 39 | require.True(t, ok) 40 | 41 | require.Equal(t, m.client.apiKey, "foobar") 42 | } 43 | 44 | func TestOptions_WithContentEncoding(t *testing.T) { 45 | meili := setup(t, "", WithContentEncoding(GzipEncoding, DefaultCompression)) 46 | require.NotNil(t, meili) 47 | 48 | m, ok := meili.(*meilisearch) 49 | require.True(t, ok) 50 | 51 | require.Equal(t, m.client.contentEncoding, GzipEncoding) 52 | require.NotNil(t, m.client.encoder) 53 | } 54 | 55 | func TestOptions_WithCustomRetries(t *testing.T) { 56 | meili := setup(t, "", WithCustomRetries([]int{http.StatusInternalServerError}, 10)) 57 | require.NotNil(t, meili) 58 | 59 | m, ok := meili.(*meilisearch) 60 | require.True(t, ok) 61 | require.True(t, m.client.retryOnStatus[http.StatusInternalServerError]) 62 | require.Equal(t, m.client.maxRetries, uint8(10)) 63 | 64 | meili = setup(t, "", WithCustomRetries([]int{http.StatusInternalServerError}, 0)) 65 | require.NotNil(t, meili) 66 | 67 | m, ok = meili.(*meilisearch) 68 | require.True(t, ok) 69 | require.True(t, m.client.retryOnStatus[http.StatusInternalServerError]) 70 | require.Equal(t, m.client.maxRetries, uint8(1)) 71 | } 72 | 73 | func TestOptions_DisableRetries(t *testing.T) { 74 | meili := setup(t, "", DisableRetries()) 75 | require.NotNil(t, meili) 76 | 77 | m, ok := meili.(*meilisearch) 78 | require.True(t, ok) 79 | require.Equal(t, m.client.disableRetry, true) 80 | } 81 | -------------------------------------------------------------------------------- /types_test.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestRawType_UnmarshalJSON(t *testing.T) { 9 | var r RawType 10 | 11 | data := []byte(`"example"`) 12 | err := r.UnmarshalJSON(data) 13 | assert.NoError(t, err) 14 | assert.Equal(t, RawType(`"example"`), r) 15 | 16 | data = []byte(`""`) 17 | err = r.UnmarshalJSON(data) 18 | assert.NoError(t, err) 19 | assert.Equal(t, RawType(`""`), r) 20 | 21 | data = []byte(`{invalid}`) 22 | err = r.UnmarshalJSON(data) 23 | assert.NoError(t, err) 24 | assert.Equal(t, RawType(`{invalid}`), r) 25 | } 26 | 27 | func TestRawType_MarshalJSON(t *testing.T) { 28 | r := RawType(`"example"`) 29 | data, err := r.MarshalJSON() 30 | assert.NoError(t, err) 31 | assert.Equal(t, []byte(`"example"`), data) 32 | 33 | r = RawType(`""`) 34 | data, err = r.MarshalJSON() 35 | assert.NoError(t, err) 36 | assert.Equal(t, []byte(`""`), data) 37 | 38 | r = RawType(`{random}`) 39 | data, err = r.MarshalJSON() 40 | assert.NoError(t, err) 41 | assert.Equal(t, []byte(`{random}`), data) 42 | } 43 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import "fmt" 4 | 5 | const VERSION = "0.32.0" 6 | 7 | func GetQualifiedVersion() (qualifiedVersion string) { 8 | return getQualifiedVersion(VERSION) 9 | } 10 | 11 | func getQualifiedVersion(version string) (qualifiedVersion string) { 12 | return fmt.Sprintf("Meilisearch Go (v%s)", version) 13 | } 14 | -------------------------------------------------------------------------------- /version_test.go: -------------------------------------------------------------------------------- 1 | package meilisearch 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestVersion_GetQualifiedVersion(t *testing.T) { 13 | version := GetQualifiedVersion() 14 | 15 | require.NotNil(t, version) 16 | require.Equal(t, version, fmt.Sprintf("Meilisearch Go (v%s)", VERSION)) 17 | } 18 | 19 | func TestVersion_qualifiedVersionFormat(t *testing.T) { 20 | version := getQualifiedVersion("2.2.5") 21 | 22 | require.NotNil(t, version) 23 | require.Equal(t, version, "Meilisearch Go (v2.2.5)") 24 | } 25 | 26 | func TestVersion_constVERSIONFormat(t *testing.T) { 27 | match, _ := regexp.MatchString("[0-9]+.[0-9]+.[0-9]+", VERSION) 28 | 29 | assert.True(t, match) 30 | } 31 | --------------------------------------------------------------------------------