├── .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 |
3 |
4 |
5 | Meilisearch Go
6 |
7 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
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 |
--------------------------------------------------------------------------------