Repository: algolia/algoliasearch-client-go
Pull request #644 (2021-01-29 12:22:46)

feat: Dictionaries API methods
| Q                 | A
| ----------------- | ----------
| Bug fix?          | no
| New feature?      | yes
| BC breaks?        | no   
| Need Doc update   | yes


## Describe your change

This PR adds the implementation of [Dictionaries API methods](https://www.algolia.com/doc/rest-api/search/#dictionaries-endpoints). 


VladislavFitz: Hey @marchelbling, @samouss , @rayrutjes .
Could please one of you take a look at this PR? (#645 is also pending, btw)
This is my first significant contribution to our Go codebase, so your feedback would be greatly appreciated. 
Thanks in advance!
VladislavFitz: @netgusto Thank you for such a detailed review! I applied the fixes and replied to your questions. Could you reiterate over it?

Patch:
diff --git a/algolia/analytics/client.go b/algolia/analytics/client.go
index aa02beff..9ac0f69d 100644
--- a/algolia/analytics/client.go
+++ b/algolia/analytics/client.go
@@ -65,6 +65,6 @@ func NewClientWithConfig(config Configuration) *Client {
 	}
 }
 
-func (c *Client) waitTaskSearchClient(index string, taskID int64) error {
-	return c.searchClient.InitIndex(index).WaitTask(taskID)
+func (c *Client) waitTaskSearchClient(index string, taskID int64, opts ...interface{}) error {
+	return c.searchClient.InitIndex(index).WaitTask(taskID, opts...)
 }
diff --git a/algolia/internal/gen/functions.go b/algolia/internal/gen/functions.go
index 9865eb0e..fe8aeecc 100644
--- a/algolia/internal/gen/functions.go
+++ b/algolia/internal/gen/functions.go
@@ -113,6 +113,7 @@ func convertInterfaceToString(defaultValue interface{}) string {
 		map[string]string,
 		map[string][]string,
 		map[string]map[string]string,
+		map[string]map[string]bool,
 		map[string]interface{}:
 		s = fmt.Sprintf("%#v", v)
 	default:
diff --git a/algolia/internal/gen/gen_extract.go b/algolia/internal/gen/gen_extract.go
index d3de8dce..ee56a14c 100644
--- a/algolia/internal/gen/gen_extract.go
+++ b/algolia/internal/gen/gen_extract.go
@@ -23,7 +23,7 @@ func main() {
 
 		if shouldBeGenerated(filepath) {
 			switch opt.DefaultValue.(type) {
-			case nil, bool, int, string, []string, map[string][]string, map[string]map[string]string:
+			case nil, bool, int, string, []string, map[string][]string, map[string]map[string]string, map[string]map[string]bool:
 				generateFile(extractLiteralTemplate, opt.Name, filepath)
 			case map[string]string:
 				generateFile(extractMapStringStringTemplate, opt.Name, filepath)
diff --git a/algolia/internal/gen/gen_option.go b/algolia/internal/gen/gen_option.go
index 9a812ed0..31772953 100644
--- a/algolia/internal/gen/gen_option.go
+++ b/algolia/internal/gen/gen_option.go
@@ -25,6 +25,7 @@ func main() {
 		optionTestMapStringStringTemplate          = createTemplate("templates/option_test/map_string_string.go.tmpl")
 		optionTestMapStringStringSliceTemplate     = createTemplate("templates/option_test/map_string_string_slice.go.tmpl")
 		optionTestMapStringMapStringStringTemplate = createTemplate("templates/option_test/map_string_map_string_string.go.tmpl")
+		optionTestMapStringMapStringBoolTemplate   = createTemplate("templates/option_test/map_string_map_string_bool.go.tmpl")
 		optionTestMapStringInterfaceTemplate       = createTemplate("templates/option_test/map_string_interface_slice.go.tmpl")
 
 		optionLiteralTemplate     = createTemplate("templates/option/literal.go.tmpl")
@@ -59,7 +60,7 @@ func main() {
 			switch opt.DefaultValue.(type) {
 			case bool, int, string:
 				generateFile(optionLiteralTemplate, data, filepath)
-			case map[string]string, map[string][]string, map[string]interface{}, map[string]map[string]string:
+			case map[string]string, map[string][]string, map[string]interface{}, map[string]map[string]string, map[string]map[string]bool:
 				generateFile(optionMapTemplate, data, filepath)
 			case []string:
 				generateFile(optionStringSliceTemplate, data, filepath)
@@ -93,6 +94,8 @@ func main() {
 				generateFile(optionTestMapStringMapStringStringTemplate, data, testFilepath)
 			case map[string]interface{}:
 				generateFile(optionTestMapStringInterfaceTemplate, data, testFilepath)
+			case map[string]map[string]bool:
+				generateFile(optionTestMapStringMapStringBoolTemplate, data, testFilepath)
 			default:
 				fmt.Printf("cannot generate option test file for %s: unhandled type %#v", opt.Name, opt.DefaultValue)
 				os.Exit(1)
diff --git a/algolia/internal/gen/options.go b/algolia/internal/gen/options.go
index 76e01ebb..423c102a 100644
--- a/algolia/internal/gen/options.go
+++ b/algolia/internal/gen/options.go
@@ -173,4 +173,6 @@ var options = []Option{
 	{"safe", Other, false, ""},
 	{"filterPromotes", Other, false, ""},
 	{"exposeIntermediateNetworkErrors", Other, false, ""},
+	{"language", Other, "", ""},
+	{"disableStandardEntries", Other, map[string]map[string]bool{}, ""},
 }
diff --git a/algolia/internal/opt/disable_standard_entries.go b/algolia/internal/opt/disable_standard_entries.go
new file mode 100644
index 00000000..e55bbe9b
--- /dev/null
+++ b/algolia/internal/opt/disable_standard_entries.go
@@ -0,0 +1,18 @@
+// Code generated by go generate. DO NOT EDIT.
+
+package opt
+
+import (
+	"github.com/algolia/algoliasearch-client-go/v3/algolia/opt"
+)
+
+// ExtractDisableStandardEntries returns the first found DisableStandardEntriesOption from the
+// given variadic arguments or nil otherwise.
+func ExtractDisableStandardEntries(opts ...interface{}) *opt.DisableStandardEntriesOption {
+	for _, o := range opts {
+		if v, ok := o.(*opt.DisableStandardEntriesOption); ok {
+			return v
+		}
+	}
+	return nil
+}
diff --git a/algolia/internal/opt/language.go b/algolia/internal/opt/language.go
new file mode 100644
index 00000000..97ff4fd8
--- /dev/null
+++ b/algolia/internal/opt/language.go
@@ -0,0 +1,18 @@
+// Code generated by go generate. DO NOT EDIT.
+
+package opt
+
+import (
+	"github.com/algolia/algoliasearch-client-go/v3/algolia/opt"
+)
+
+// ExtractLanguage returns the first found LanguageOption from the
+// given variadic arguments or nil otherwise.
+func ExtractLanguage(opts ...interface{}) *opt.LanguageOption {
+	for _, o := range opts {
+		if v, ok := o.(*opt.LanguageOption); ok {
+			return v
+		}
+	}
+	return nil
+}
diff --git a/algolia/opt/disable_standard_entries.go b/algolia/opt/disable_standard_entries.go
new file mode 100644
index 00000000..f1ce58ae
--- /dev/null
+++ b/algolia/opt/disable_standard_entries.go
@@ -0,0 +1,63 @@
+// Code generated by go generate. DO NOT EDIT.
+
+package opt
+
+import (
+	"encoding/json"
+	"reflect"
+)
+
+// DisableStandardEntriesOption is a wrapper for an DisableStandardEntries option parameter. It holds
+// the actual value of the option that can be accessed by calling Get.
+type DisableStandardEntriesOption struct {
+	value map[string]map[string]bool
+}
+
+// DisableStandardEntries wraps the given value into a DisableStandardEntriesOption.
+func DisableStandardEntries(v map[string]map[string]bool) *DisableStandardEntriesOption {
+	return &DisableStandardEntriesOption{v}
+}
+
+// Get retrieves the actual value of the option parameter.
+func (o *DisableStandardEntriesOption) Get() map[string]map[string]bool {
+	if o == nil {
+		return map[string]map[string]bool{}
+	}
+	return o.value
+}
+
+// MarshalJSON implements the json.Marshaler interface for
+// DisableStandardEntriesOption.
+func (o DisableStandardEntriesOption) MarshalJSON() ([]byte, error) {
+	return json.Marshal(o.value)
+}
+
+// UnmarshalJSON implements the json.Unmarshaler interface for
+// DisableStandardEntriesOption.
+func (o *DisableStandardEntriesOption) UnmarshalJSON(data []byte) error {
+	if string(data) == "null" {
+		o.value = map[string]map[string]bool{}
+		return nil
+	}
+	return json.Unmarshal(data, &o.value)
+}
+
+// Equal returns true if the given option is equal to the instance one. In case
+// the given option is nil, we checked the instance one is set to the default
+// value of the option.
+func (o *DisableStandardEntriesOption) Equal(o2 *DisableStandardEntriesOption) bool {
+	if o == nil {
+		return o2 == nil || reflect.DeepEqual(o2.value, map[string]map[string]bool{})
+	}
+	if o2 == nil {
+		return o == nil || reflect.DeepEqual(o.value, map[string]map[string]bool{})
+	}
+	return reflect.DeepEqual(o.value, o2.value)
+}
+
+// DisableStandardEntriesEqual returns true if the two options are equal.
+// In case of one option being nil, the value of the other must be nil as well
+// or be set to the default value of this option.
+func DisableStandardEntriesEqual(o1, o2 *DisableStandardEntriesOption) bool {
+	return o1.Equal(o2)
+}
diff --git a/algolia/opt/language.go b/algolia/opt/language.go
new file mode 100644
index 00000000..14d433fb
--- /dev/null
+++ b/algolia/opt/language.go
@@ -0,0 +1,60 @@
+// Code generated by go generate. DO NOT EDIT.
+
+package opt
+
+import "encoding/json"
+
+// LanguageOption is a wrapper for an Language option parameter. It holds
+// the actual value of the option that can be accessed by calling Get.
+type LanguageOption struct {
+	value string
+}
+
+// Language wraps the given value into a LanguageOption.
+func Language(v string) *LanguageOption {
+	return &LanguageOption{v}
+}
+
+// Get retrieves the actual value of the option parameter.
+func (o *LanguageOption) Get() string {
+	if o == nil {
+		return ""
+	}
+	return o.value
+}
+
+// MarshalJSON implements the json.Marshaler interface for
+// LanguageOption.
+func (o LanguageOption) MarshalJSON() ([]byte, error) {
+	return json.Marshal(o.value)
+}
+
+// UnmarshalJSON implements the json.Unmarshaler interface for
+// LanguageOption.
+func (o *LanguageOption) UnmarshalJSON(data []byte) error {
+	if string(data) == "null" {
+		o.value = ""
+		return nil
+	}
+	return json.Unmarshal(data, &o.value)
+}
+
+// Equal returns true if the given option is equal to the instance one. In case
+// the given option is nil, we checked the instance one is set to the default
+// value of the option.
+func (o *LanguageOption) Equal(o2 *LanguageOption) bool {
+	if o == nil {
+		return o2 == nil || o2.value == ""
+	}
+	if o2 == nil {
+		return o == nil || o.value == ""
+	}
+	return o.value == o2.value
+}
+
+// LanguageEqual returns true if the two options are equal.
+// In case of one option being nil, the value of the other must be nil as well
+// or be set to the default value of this option.
+func LanguageEqual(o1, o2 *LanguageOption) bool {
+	return o1.Equal(o2)
+}
diff --git a/algolia/search/client.go b/algolia/search/client.go
index 14e08842..27b4bae8 100644
--- a/algolia/search/client.go
+++ b/algolia/search/client.go
@@ -127,3 +127,23 @@ func (c *Client) CustomRequest(
 ) error {
 	return c.transport.Request(&res, method, path, body, k, opts...)
 }
+
+// GetStatus retrieves the task status according to the Algolia engine for the
+// given task.
+func (c *Client) GetStatus(taskID int64, opts ...interface{}) (res TaskStatusRes, err error) {
+	path := c.path("/task/%d", taskID)
+	err = c.transport.Request(&res, http.MethodGet, path, nil, call.Read, opts...)
+	return
+}
+
+// WaitTask blocks until the task identified by the given taskID is completed on
+// Algolia engine.
+func (c *Client) WaitTask(taskID int64, opts ...interface{}) error {
+	return waitWithRetry(func() (bool, error) {
+		res, err := c.GetStatus(taskID, opts...)
+		if err != nil {
+			return true, err
+		}
+		return res.Status == "published", nil
+	})
+}
diff --git a/algolia/search/client_dictionaries.go b/algolia/search/client_dictionaries.go
new file mode 100644
index 00000000..d163af15
--- /dev/null
+++ b/algolia/search/client_dictionaries.go
@@ -0,0 +1,62 @@
+package search
+
+import (
+	"net/http"
+
+	"github.com/algolia/algoliasearch-client-go/v3/algolia/call"
+)
+
+// SaveDictionaryEntries saves dictionary entries for the given dictionary
+func (c *Client) SaveDictionaryEntries(dictionaryName DictionaryName, dictionaryEntries []DictionaryEntry, opts ...interface{}) (res UpdateTaskRes, err error) {
+	batchRequest := newAddDictionaryEntriesBatch(dictionaryEntries, false)
+	path := c.path("/dictionaries/%s/batch", dictionaryName)
+	err = c.transport.Request(&res, http.MethodPost, path, batchRequest, call.Write, opts...)
+	res.wait = c.WaitTask
+	return
+}
+
+// ReplaceDictionaryEntries replaces existing dictionary entries present in the dictionary with the given ones
+func (c *Client) ReplaceDictionaryEntries(dictionaryName DictionaryName, dictionaryEntries []DictionaryEntry, opts ...interface{}) (res UpdateTaskRes, err error) {
+	batchRequest := newAddDictionaryEntriesBatch(dictionaryEntries, true)
+	path := c.path("/dictionaries/%s/batch", dictionaryName)
+	err = c.transport.Request(&res, http.MethodPost, path, batchRequest, call.Write, opts...)
+	res.wait = c.WaitTask
+	return
+}
+
+// DeleteDictionaryEntries deletes dictionary entries with the given objectIDs
+func (c *Client) DeleteDictionaryEntries(dictionaryName DictionaryName, objectIDs []string, opts ...interface{}) (res UpdateTaskRes, err error) {
+	batchRequest := newDeleteDictionaryEntriesBatch(objectIDs, false)
+	path := c.path("/dictionaries/%s/batch", dictionaryName)
+	err = c.transport.Request(&res, http.MethodPost, path, batchRequest, call.Write, opts...)
+	res.wait = c.WaitTask
+	return
+}
+
+// ClearDictionaryEntries deletes all the dictionary entries from the given dictionary
+func (c *Client) ClearDictionaryEntries(dictionaryName DictionaryName, opts ...interface{}) (res UpdateTaskRes, err error) {
+	return c.ReplaceDictionaryEntries(dictionaryName, []DictionaryEntry{}, opts)
+}
+
+// SearchDictionaryEntries searches for dictionary entries according to the given query string and any rule
+func (c *Client) SearchDictionaryEntries(dictionaryName DictionaryName, query string, opts ...interface{}) (res SearchDictionariesRes, err error) {
+	body := newSearchDictionariesParams(query, opts...)
+	path := c.path("/dictionaries/%s/search", dictionaryName)
+	err = c.transport.Request(&res, http.MethodPost, path, body, call.Read, opts...)
+	return
+}
+
+// GetDictionarySettings Retrieve dictionaries settings
+func (c *Client) GetDictionarySettings(opts ...interface{}) (res DictionarySettings, err error) {
+	path := c.path("/dictionaries/*/settings")
+	err = c.transport.Request(&res, http.MethodGet, path, nil, call.Read, opts...)
+	return
+}
+
+// SetDictionarySettings Set dictionary settings
+func (c *Client) SetDictionarySettings(settings DictionarySettings, opts ...interface{}) (res UpdateTaskRes, err error) {
+	path := c.path("/dictionaries/*/settings")
+	err = c.transport.Request(&res, http.MethodPut, path, settings, call.Write, opts...)
+	res.wait = c.WaitTask
+	return
+}
diff --git a/algolia/search/client_interface.go b/algolia/search/client_interface.go
index 638d7884..b2ead447 100644
--- a/algolia/search/client_interface.go
+++ b/algolia/search/client_interface.go
@@ -8,6 +8,8 @@ import (
 
 type ClientInterface interface {
 	// Misc
+	WaitTask(taskID int64, opts ...interface{}) error
+	GetStatus(taskID int64, opts ...interface{}) (res TaskStatusRes, err error)
 	InitIndex(indexName string) *Index
 	ListIndices(opts ...interface{}) (res ListIndicesRes, err error)
 	GetLogs(opts ...interface{}) (res GetLogsRes, err error)
@@ -47,6 +49,15 @@ type ClientInterface interface {
 	SearchUserIDs(query string, opts ...interface{}) (res SearchUserIDRes, err error)
 	HasPendingMappings(opts ...interface{}) (res HasPendingMappingsRes, err error)
 
+	// Custom dictionaries methods
+	SaveDictionaryEntries(dictionaryName DictionaryName, dictionaryEntries []DictionaryEntry, opts ...interface{}) (res UpdateTaskRes, err error)
+	ReplaceDictionaryEntries(dictionaryName DictionaryName, dictionaryEntries []DictionaryEntry, opts ...interface{}) (res UpdateTaskRes, err error)
+	DeleteDictionaryEntries(dictionaryName DictionaryName, objectIDs []string, opts ...interface{}) (res UpdateTaskRes, err error)
+	ClearDictionaryEntries(dictionaryName DictionaryName, opts ...interface{}) (res UpdateTaskRes, err error)
+	SearchDictionaryEntries(dictionaryName DictionaryName, query string, opts ...interface{}) (res SearchDictionariesRes, err error)
+	GetDictionarySettings(opts ...interface{}) (res DictionarySettings, err error)
+	SetDictionarySettings(settings DictionarySettings, opts ...interface{}) (res UpdateTaskRes, err error)
+
 	// Personalization
 
 	// Deprecated: use recommendation.Client.SetPersonalizationStrategy() instead
diff --git a/algolia/search/dictionaries.go b/algolia/search/dictionaries.go
new file mode 100644
index 00000000..31c6e357
--- /dev/null
+++ b/algolia/search/dictionaries.go
@@ -0,0 +1,17 @@
+package search
+
+import "github.com/algolia/algoliasearch-client-go/v3/algolia/opt"
+
+// DictionaryName represents a name of a dictionary containing linguistic resources provided by Algolia
+type DictionaryName string
+
+const (
+	Stopwords DictionaryName = "stopwords"
+	Compounds DictionaryName = "compounds"
+	Plurals   DictionaryName = "plurals"
+)
+
+// DictionarySettings represents dictionaries settings.
+type DictionarySettings struct {
+	DisableStandardEntries *opt.DisableStandardEntriesOption `json:"disableStandardEntries,omitempty"`
+}
diff --git a/algolia/search/dictionary_entry.go b/algolia/search/dictionary_entry.go
new file mode 100644
index 00000000..ca8e3283
--- /dev/null
+++ b/algolia/search/dictionary_entry.go
@@ -0,0 +1,52 @@
+package search
+
+import (
+	"encoding/json"
+	"fmt"
+)
+
+// DictionaryEntry represents an entry in a given dictionary.
+type DictionaryEntry interface {
+	ObjectID() string
+	Language() string
+}
+
+type rawDictionaryEntry struct{ impl DictionaryEntry }
+
+func (d *rawDictionaryEntry) UnmarshalJSON(data []byte) error {
+	var (
+		rawDictEntry map[string]interface{}
+		err          error
+	)
+
+	if err = json.Unmarshal(data, &rawDictEntry); err != nil {
+		return fmt.Errorf("cannot unmarshal dictionary entry: error when unmarshalling %v", err)
+	}
+
+	_, ok := rawDictEntry["decomposition"]
+	if ok {
+		var compound Compound
+		err = json.Unmarshal(data, &compound)
+		d.impl = compound
+		return err
+	}
+
+	_, ok = rawDictEntry["words"]
+	if ok {
+		var plural Plural
+		err = json.Unmarshal(data, &plural)
+		d.impl = plural
+		return err
+	}
+
+	_, ok = rawDictEntry["word"]
+	if ok {
+		var stopWord Stopword
+		err = json.Unmarshal(data, &stopWord)
+		d.impl = stopWord
+		return err
+	}
+
+	return fmt.Errorf("cannot unmarshal dictionary entry: incorrect input")
+
+}
diff --git a/algolia/search/dictionary_entry_compound.go b/algolia/search/dictionary_entry_compound.go
new file mode 100644
index 00000000..6038cae0
--- /dev/null
+++ b/algolia/search/dictionary_entry_compound.go
@@ -0,0 +1,52 @@
+package search
+
+import (
+	"encoding/json"
+	"fmt"
+)
+
+// Compound represents an entry in the Compounds dictionary.
+type Compound struct {
+	objectID      string
+	language      string
+	Word          string
+	Decomposition []string
+}
+
+// Compound implements DictionaryEntry
+func (c Compound) ObjectID() string { return c.objectID }
+func (c Compound) Language() string { return c.language }
+
+// NewCompound is a constructor of Compound
+func NewCompound(objectID, language, word string, decomposition []string) Compound {
+	return Compound{objectID, language, word, decomposition}
+}
+
+type compound struct {
+	ObjectID      string   `json:"objectID"`
+	Language      string   `json:"language"`
+	Word          string   `json:"word"`
+	Decomposition []string `json:"decomposition"`
+}
+
+func (c Compound) MarshalJSON() ([]byte, error) {
+	return json.Marshal(compound{c.objectID, c.language, c.Word, c.Decomposition})
+}
+
+func (c *Compound) UnmarshalJSON(data []byte) error {
+	if string(data) == jsonNull {
+		return nil
+	}
+
+	var compound compound
+	err := json.Unmarshal(data, &compound)
+	if err != nil {
+		return fmt.Errorf("cannot unmarshal Compound dictionary entry: %v", err)
+	}
+
+	c.objectID = compound.ObjectID
+	c.language = compound.Language
+	c.Word = compound.Word
+	c.Decomposition = compound.Decomposition
+	return nil
+}
diff --git a/algolia/search/dictionary_entry_plural.go b/algolia/search/dictionary_entry_plural.go
new file mode 100644
index 00000000..549c806b
--- /dev/null
+++ b/algolia/search/dictionary_entry_plural.go
@@ -0,0 +1,48 @@
+package search
+
+import (
+	"encoding/json"
+	"fmt"
+)
+
+// Plural represents an entry in the Plurals dictionary.
+type Plural struct {
+	objectID string
+	language string
+	Words    []string
+}
+
+// Plural implements DictionaryEntry
+func (p Plural) ObjectID() string { return p.objectID }
+func (p Plural) Language() string { return p.language }
+
+func NewPlural(objectID, language string, words []string) Plural {
+	return Plural{objectID, language, words}
+}
+
+type plural struct {
+	ObjectID string   `json:"objectID"`
+	Language string   `json:"language"`
+	Words    []string `json:"words"`
+}
+
+func (p Plural) MarshalJSON() ([]byte, error) {
+	return json.Marshal(plural{p.objectID, p.language, p.Words})
+}
+
+func (p *Plural) UnmarshalJSON(data []byte) error {
+	if string(data) == jsonNull {
+		return nil
+	}
+
+	var plural plural
+	err := json.Unmarshal(data, &plural)
+	if err != nil {
+		return fmt.Errorf("cannot unmarshal Plural dictionary entry: %v", err)
+	}
+
+	p.objectID = plural.ObjectID
+	p.language = plural.Language
+	p.Words = plural.Words
+	return nil
+}
diff --git a/algolia/search/dictionary_entry_stopword.go b/algolia/search/dictionary_entry_stopword.go
new file mode 100644
index 00000000..fbe49ccd
--- /dev/null
+++ b/algolia/search/dictionary_entry_stopword.go
@@ -0,0 +1,55 @@
+package search
+
+import (
+	"encoding/json"
+	"fmt"
+)
+
+// Stopword represents an entry in the Compounds dictionary.
+type Stopword struct {
+	objectID string
+	language string
+	Word     string
+	State    string
+}
+
+// Stopword implements DictionaryEntry
+func (s Stopword) ObjectID() string { return s.objectID }
+func (s Stopword) Language() string { return s.language }
+
+func NewStopword(objectID, language, word, state string) Stopword {
+	return Stopword{objectID, language, word, state}
+}
+
+type stopword struct {
+	ObjectID string  `json:"objectID"`
+	Language string  `json:"language"`
+	Word     string  `json:"word"`
+	State    *string `json:"state,omitempty"`
+}
+
+func (s Stopword) MarshalJSON() ([]byte, error) {
+	return json.Marshal(stopword{s.objectID, s.language, s.Word, &s.State})
+}
+
+func (s *Stopword) UnmarshalJSON(data []byte) error {
+	if string(data) == jsonNull {
+		return nil
+	}
+
+	var stopword stopword
+	err := json.Unmarshal(data, &stopword)
+	if err != nil {
+		return fmt.Errorf("cannot unmarshal Stopword dictionary entry: %v", err)
+	}
+
+	s.objectID = stopword.ObjectID
+	s.language = stopword.Language
+	s.Word = stopword.Word
+	if stopword.State == nil {
+		s.State = "enabled"
+	} else {
+		s.State = *stopword.State
+	}
+	return nil
+}
diff --git a/algolia/search/index.go b/algolia/search/index.go
index ec6d8ef0..d7ce3690 100644
--- a/algolia/search/index.go
+++ b/algolia/search/index.go
@@ -45,10 +45,7 @@ func (i *Index) WaitTask(taskID int64, opts ...interface{}) error {
 		if err != nil {
 			return true, err
 		}
-		if res.Status == "published" {
-			return true, nil
-		}
-		return false, nil
+		return res.Status == "published", nil
 	})
 }
 
diff --git a/algolia/search/requests_dictionaries.go b/algolia/search/requests_dictionaries.go
new file mode 100644
index 00000000..381fdaf7
--- /dev/null
+++ b/algolia/search/requests_dictionaries.go
@@ -0,0 +1,55 @@
+package search
+
+import (
+	iopt "github.com/algolia/algoliasearch-client-go/v3/algolia/internal/opt"
+	"github.com/algolia/algoliasearch-client-go/v3/algolia/opt"
+)
+
+type dictionaryEntryRequest struct {
+	Action string      `json:"action"`
+	Body   interface{} `json:"body"`
+}
+
+type dictionaryEntryRequestsBatch struct {
+	Requests                       []interface{} `json:"requests"`
+	ClearExistingDictionaryEntries bool          `json:"clearExistingDictionaryEntries"`
+}
+
+func newAddDictionaryEntriesBatch(dictionaryEntries []DictionaryEntry, clearExistingDictionaryEntries bool) dictionaryEntryRequestsBatch {
+	addRequests := make([]interface{}, len(dictionaryEntries))
+	for i, entry := range dictionaryEntries {
+		addRequests[i] = dictionaryEntryRequest{"addEntry", entry}
+	}
+	return dictionaryEntryRequestsBatch{addRequests, clearExistingDictionaryEntries}
+}
+
+func newDeleteDictionaryEntriesBatch(objectIDs []string, clearExistingDictionaryEntries bool) dictionaryEntryRequestsBatch {
+	deleteRequests := make([]interface{}, len(objectIDs))
+	for i, objectID := range objectIDs {
+		deleteRequests[i] = dictionaryEntryRequest{
+			"deleteEntry",
+			struct {
+				ObjectID string `json:"objectID"`
+			}{
+				objectID,
+			},
+		}
+	}
+	return dictionaryEntryRequestsBatch{deleteRequests, clearExistingDictionaryEntries}
+}
+
+type searchDictionariesParams struct {
+	Query       string                 `json:"query"`
+	Page        *opt.PageOption        `json:"page,omitempty"`
+	HitsPerPage *opt.HitsPerPageOption `json:"hitsPerPage,omitempty"`
+	Language    *opt.LanguageOption    `json:"language,omitempty"`
+}
+
+func newSearchDictionariesParams(query string, opts ...interface{}) searchDictionariesParams {
+	return searchDictionariesParams{
+		Query:       query,
+		Page:        iopt.ExtractPage(opts...),
+		HitsPerPage: iopt.ExtractHitsPerPage(opts...),
+		Language:    iopt.ExtractLanguage(opts...),
+	}
+}
diff --git a/algolia/search/responses_dictionaries.go b/algolia/search/responses_dictionaries.go
new file mode 100644
index 00000000..05a9fd65
--- /dev/null
+++ b/algolia/search/responses_dictionaries.go
@@ -0,0 +1,37 @@
+package search
+
+import (
+	"encoding/json"
+	"fmt"
+)
+
+type SearchDictionariesRes struct {
+	Hits    interface{} `json:"hits"`
+	NbHits  int         `json:"nbHits"`
+	Page    int         `json:"page"`
+	NbPages int         `json:"nbPages"`
+}
+
+func (r SearchDictionariesRes) DictionaryEntries() ([]DictionaryEntry, error) {
+	var (
+		rawDictionaryEntries []rawDictionaryEntry
+		dictionaryEntries    []DictionaryEntry
+		err                  error
+	)
+
+	data, err := json.Marshal(r.Hits)
+	if err != nil {
+		return nil, fmt.Errorf("cannot unmarshal dictionary entries: error while marshalling original dictionary entries: %v", err)
+	}
+
+	err = json.Unmarshal(data, &rawDictionaryEntries)
+	if err != nil {
+		return nil, fmt.Errorf("cannot unmarshal dictionary entries: error while unmarshalling to intermediate type: %v", err)
+	}
+
+	for _, s := range rawDictionaryEntries {
+		dictionaryEntries = append(dictionaryEntries, s.impl)
+	}
+
+	return dictionaryEntries, nil
+}


Test patch:
diff --git a/algolia/analytics/responses_ab_testing.go b/algolia/analytics/responses_ab_testing.go
index c7512d86..c967f7c2 100644
--- a/algolia/analytics/responses_ab_testing.go
+++ b/algolia/analytics/responses_ab_testing.go
@@ -12,7 +12,7 @@ type ABTestTaskRes struct {
 	ABTestID int    `json:"abTestID"`
 	Index    string `json:"index"`
 	TaskID   int64  `json:"taskID"`
-	wait     func(index string, taskID int64) error
+	wait     func(index string, taskID int64, opts ...interface{}) error
 }
 
 // Wait blocks until the AB test task completes or if there is an error while
diff --git a/algolia/internal/gen/templates/option_test/map_string_map_string_bool.go.tmpl b/algolia/internal/gen/templates/option_test/map_string_map_string_bool.go.tmpl
new file mode 100644
index 00000000..2a81dde0
--- /dev/null
+++ b/algolia/internal/gen/templates/option_test/map_string_map_string_bool.go.tmpl
@@ -0,0 +1,41 @@
+// Code generated by go generate. DO NOT EDIT.
+
+package opt
+
+import (
+    "encoding/json"
+    "testing"
+
+    "github.com/algolia/algoliasearch-client-go/v3/algolia/opt"
+    "github.com/stretchr/testify/require"
+)
+
+func TestExtract{{ .Name }}(t *testing.T) {
+    for _, c := range []struct {
+        opts     []interface{}
+        expected *opt.{{ .Name }}Option
+    }{
+        {
+            opts:     []interface{}{nil},
+            expected: opt.{{ .Name }}({{ .DefaultValue }}),
+        },
+        {
+            opts:     []interface{}{opt.{{ .Name }}(map[string]map[string]bool{})},
+            expected: opt.{{ .Name }}(map[string]map[string]bool{}),
+        },
+        {
+            opts:     []interface{}{opt.{{ .Name }}(map[string]map[string]bool{"outer1": map[string]bool{"k1": true, "k2": false}, "outer2": map[string]bool{"k3": true}})},
+            expected: opt.{{ .Name }}(map[string]map[string]bool{"outer1": map[string]bool{"k1": true, "k2": false}, "outer2": map[string]bool{"k3": true}}),
+        },
+    } {
+        var (
+            in  = Extract{{ .Name }}(c.opts...)
+            out opt.{{ .Name }}Option
+        )
+        data, err := json.Marshal(&in)
+        require.NoError(t, err)
+        err = json.Unmarshal(data, &out)
+        require.NoError(t, err)
+        require.Equal(t, *c.expected, out)
+    }
+}
diff --git a/algolia/internal/opt/disable_standard_entries_test.go b/algolia/internal/opt/disable_standard_entries_test.go
new file mode 100644
index 00000000..96d5e3c2
--- /dev/null
+++ b/algolia/internal/opt/disable_standard_entries_test.go
@@ -0,0 +1,41 @@
+// Code generated by go generate. DO NOT EDIT.
+
+package opt
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/algolia/algoliasearch-client-go/v3/algolia/opt"
+	"github.com/stretchr/testify/require"
+)
+
+func TestExtractDisableStandardEntries(t *testing.T) {
+	for _, c := range []struct {
+		opts     []interface{}
+		expected *opt.DisableStandardEntriesOption
+	}{
+		{
+			opts:     []interface{}{nil},
+			expected: opt.DisableStandardEntries(map[string]map[string]bool{}),
+		},
+		{
+			opts:     []interface{}{opt.DisableStandardEntries(map[string]map[string]bool{})},
+			expected: opt.DisableStandardEntries(map[string]map[string]bool{}),
+		},
+		{
+			opts:     []interface{}{opt.DisableStandardEntries(map[string]map[string]bool{"outer1": map[string]bool{"k1": true, "k2": false}, "outer2": map[string]bool{"k3": true}})},
+			expected: opt.DisableStandardEntries(map[string]map[string]bool{"outer1": map[string]bool{"k1": true, "k2": false}, "outer2": map[string]bool{"k3": true}}),
+		},
+	} {
+		var (
+			in  = ExtractDisableStandardEntries(c.opts...)
+			out opt.DisableStandardEntriesOption
+		)
+		data, err := json.Marshal(&in)
+		require.NoError(t, err)
+		err = json.Unmarshal(data, &out)
+		require.NoError(t, err)
+		require.Equal(t, *c.expected, out)
+	}
+}
diff --git a/algolia/internal/opt/language_test.go b/algolia/internal/opt/language_test.go
new file mode 100644
index 00000000..9d140fcf
--- /dev/null
+++ b/algolia/internal/opt/language_test.go
@@ -0,0 +1,41 @@
+// Code generated by go generate. DO NOT EDIT.
+
+package opt
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/algolia/algoliasearch-client-go/v3/algolia/opt"
+	"github.com/stretchr/testify/require"
+)
+
+func TestLanguage(t *testing.T) {
+	for _, c := range []struct {
+		opts     []interface{}
+		expected *opt.LanguageOption
+	}{
+		{
+			opts:     []interface{}{nil},
+			expected: opt.Language(""),
+		},
+		{
+			opts:     []interface{}{opt.Language("")},
+			expected: opt.Language(""),
+		},
+		{
+			opts:     []interface{}{opt.Language("content of the string value")},
+			expected: opt.Language("content of the string value"),
+		},
+	} {
+		var (
+			in  = ExtractLanguage(c.opts...)
+			out opt.LanguageOption
+		)
+		data, err := json.Marshal(&in)
+		require.NoError(t, err)
+		err = json.Unmarshal(data, &out)
+		require.NoError(t, err)
+		require.Equal(t, *c.expected, out)
+	}
+}
diff --git a/algolia/opt/option_getters_test.go b/algolia/opt/option_getters_test.go
index a38d36cc..c1701d2f 100644
--- a/algolia/opt/option_getters_test.go
+++ b/algolia/opt/option_getters_test.go
@@ -353,4 +353,10 @@ func TestOptionGetters(t *testing.T) {
 	var exposeIntermediateNetworkErrors *ExposeIntermediateNetworkErrorsOption = nil
 	exposeIntermediateNetworkErrors.Get()
 
+	var language *LanguageOption = nil
+	language.Get()
+
+	var disableStandardEntries *DisableStandardEntriesOption = nil
+	disableStandardEntries.Get()
+
 }
diff --git a/algolia/search/stopword_test.go b/algolia/search/stopword_test.go
new file mode 100644
index 00000000..2ffce65e
--- /dev/null
+++ b/algolia/search/stopword_test.go
@@ -0,0 +1,68 @@
+package search
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestStopword_MarshalJSON(t *testing.T) {
+	stopWord := NewStopword("myObject", "en", "bad-word", "enabled")
+	serialized, err := json.Marshal(stopWord)
+	require.NoError(t, err)
+	require.Equal(t, string(serialized), "{\"objectID\":\"myObject\",\"language\":\"en\",\"word\":\"bad-word\",\"state\":\"enabled\"}")
+
+	stopWord = NewStopword("myObject", "en", "bad-word", "disabled")
+	serialized, err = json.Marshal(stopWord)
+	require.NoError(t, err)
+	require.Equal(t, string(serialized), "{\"objectID\":\"myObject\",\"language\":\"en\",\"word\":\"bad-word\",\"state\":\"disabled\"}")
+}
+
+func TestStopword_UnmarshalJSON(t *testing.T) {
+
+	// Implicitly enabled
+	payload := `{
+		"objectID": "myObject",
+		"language": "en",
+		"word": "bad-word"
+	}`
+	var s Stopword
+	err := json.Unmarshal([]byte(payload), &s)
+	require.NoError(t, err)
+	require.Equal(t, s.Language(), "en")
+	require.Equal(t, s.ObjectID(), "myObject")
+	require.Equal(t, s.Word, "bad-word")
+	require.Equal(t, s.State, "enabled")
+
+	// Explicitly enabled
+	payload = `{
+		"objectID": "myObject",
+		"language": "en",
+		"word": "bad-word",
+		"state": "enabled"
+	}`
+
+	err = json.Unmarshal([]byte(payload), &s)
+	require.NoError(t, err)
+	require.Equal(t, s.Language(), "en")
+	require.Equal(t, s.ObjectID(), "myObject")
+	require.Equal(t, s.Word, "bad-word")
+	require.Equal(t, s.State, "enabled")
+
+	// Explicitly disabled
+	payload = `{
+		"objectID": "myObject",
+		"language": "en",
+		"word": "bad-word",
+		"state": "disabled"
+	}`
+
+	err = json.Unmarshal([]byte(payload), &s)
+	require.NoError(t, err)
+	require.Equal(t, s.Language(), "en")
+	require.Equal(t, s.ObjectID(), "myObject")
+	require.Equal(t, s.Word, "bad-word")
+	require.Equal(t, s.State, "disabled")
+
+}
diff --git a/cts/search/dictionaries/compounds_test.go b/cts/search/dictionaries/compounds_test.go
new file mode 100644
index 00000000..790c1644
--- /dev/null
+++ b/cts/search/dictionaries/compounds_test.go
@@ -0,0 +1,50 @@
+package search
+
+import (
+	"testing"
+
+	"github.com/algolia/algoliasearch-client-go/v3/algolia/search"
+	"github.com/algolia/algoliasearch-client-go/v3/cts"
+	"github.com/stretchr/testify/require"
+)
+
+func TestCompounds(t *testing.T) {
+	t.Parallel()
+	client := cts.InitSearchClient2(t)
+
+	entryID := cts.GenerateRandomString(10)
+	{
+		res, _ := client.SearchDictionaryEntries(search.Compounds, entryID)
+		require.Empty(t, res.Hits)
+	}
+
+	compoundEntry := search.NewCompound(entryID, "de", "kopfschmerztablette", []string{"kopf", "schmerz", "tablette"})
+
+	{
+		res, err := client.SaveDictionaryEntries(search.Compounds, []search.DictionaryEntry{compoundEntry})
+		require.NoError(t, err)
+		require.NoError(t, res.Wait())
+	}
+
+	{
+		res, err := client.SearchDictionaryEntries(search.Compounds, entryID)
+		require.NoError(t, err)
+		require.Equal(t, 1, res.NbHits)
+		resEntries, err := res.DictionaryEntries()
+		require.NoError(t, err)
+		require.Equal(t, compoundEntry, resEntries[0])
+	}
+
+	{
+		res, err := client.DeleteDictionaryEntries(search.Compounds, []string{entryID})
+		require.NoError(t, err)
+		require.NoError(t, res.Wait())
+	}
+
+	{
+		res, err := client.SearchDictionaryEntries(search.Compounds, entryID)
+		require.NoError(t, err)
+		require.Equal(t, 0, res.NbHits)
+	}
+
+}
diff --git a/cts/search/dictionaries/plurals_test.go b/cts/search/dictionaries/plurals_test.go
new file mode 100644
index 00000000..aae78dda
--- /dev/null
+++ b/cts/search/dictionaries/plurals_test.go
@@ -0,0 +1,49 @@
+package search
+
+import (
+	"testing"
+
+	"github.com/algolia/algoliasearch-client-go/v3/algolia/search"
+	"github.com/algolia/algoliasearch-client-go/v3/cts"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPlurals(t *testing.T) {
+	t.Parallel()
+	client := cts.InitSearchClient2(t)
+
+	entryID := cts.GenerateRandomString(10)
+	{
+		res, _ := client.SearchDictionaryEntries(search.Plurals, entryID)
+		require.Empty(t, res.Hits)
+	}
+
+	pluralEntry := search.NewPlural(entryID, "fr", []string{"cheval", "chevaux"})
+
+	{
+		res, err := client.SaveDictionaryEntries(search.Plurals, []search.DictionaryEntry{pluralEntry})
+		require.NoError(t, err)
+		require.NoError(t, res.Wait())
+	}
+
+	{
+		res, err := client.SearchDictionaryEntries(search.Plurals, entryID)
+		require.NoError(t, err)
+		require.Equal(t, 1, res.NbHits)
+		resEntries, err := res.DictionaryEntries()
+		require.NoError(t, err)
+		require.Equal(t, pluralEntry, resEntries[0])
+	}
+
+	{
+		res, err := client.DeleteDictionaryEntries(search.Plurals, []string{entryID})
+		require.NoError(t, err)
+		require.NoError(t, res.Wait())
+	}
+
+	{
+		res, err := client.SearchDictionaryEntries(search.Plurals, entryID)
+		require.NoError(t, err)
+		require.Equal(t, 0, res.NbHits)
+	}
+}
diff --git a/cts/search/dictionaries/stopwords_test.go b/cts/search/dictionaries/stopwords_test.go
new file mode 100644
index 00000000..62ae9db0
--- /dev/null
+++ b/cts/search/dictionaries/stopwords_test.go
@@ -0,0 +1,91 @@
+package search
+
+import (
+	"testing"
+
+	"github.com/algolia/algoliasearch-client-go/v3/algolia/opt"
+	"github.com/algolia/algoliasearch-client-go/v3/algolia/search"
+	"github.com/algolia/algoliasearch-client-go/v3/cts"
+	"github.com/stretchr/testify/require"
+)
+
+func TestStopWords(t *testing.T) {
+	t.Parallel()
+	client := cts.InitSearchClient2(t)
+
+	entryID := cts.GenerateRandomString(10)
+	{
+		res, _ := client.SearchDictionaryEntries(search.Stopwords, entryID)
+		require.Empty(t, res.Hits)
+	}
+
+	stopwordEntry := search.NewStopword(entryID, "en", "down", "enabled")
+
+	{
+		res, err := client.SaveDictionaryEntries(search.Stopwords, []search.DictionaryEntry{stopwordEntry})
+		require.NoError(t, err)
+		require.NoError(t, res.Wait())
+	}
+
+	{
+		res, err := client.SearchDictionaryEntries(search.Stopwords, entryID)
+		require.NoError(t, err)
+		require.Equal(t, 1, res.NbHits)
+		resEntries, err := res.DictionaryEntries()
+		require.NoError(t, err)
+		require.Equal(t, stopwordEntry, resEntries[0])
+	}
+
+	{
+		res, err := client.DeleteDictionaryEntries(search.Stopwords, []string{entryID})
+		require.NoError(t, err)
+		require.NoError(t, res.Wait())
+	}
+
+	{
+		res, err := client.SearchDictionaryEntries(search.Stopwords, entryID)
+		require.NoError(t, err)
+		require.Equal(t, 0, res.NbHits)
+	}
+
+	{
+		searchRes, err := client.SearchDictionaryEntries(search.Stopwords, "")
+		require.NoError(t, err)
+
+		previousEntries, err := searchRes.DictionaryEntries()
+		require.NoError(t, err)
+
+		updateRes, err := client.SaveDictionaryEntries(search.Stopwords, []search.DictionaryEntry{stopwordEntry})
+		require.NoError(t, err)
+		require.NoError(t, updateRes.Wait())
+
+		searchRes, err = client.SearchDictionaryEntries(search.Stopwords, entryID)
+		require.NoError(t, err)
+		require.Equal(t, 1, searchRes.NbHits)
+
+		updateRes, err = client.ReplaceDictionaryEntries(search.Stopwords, previousEntries)
+		require.NoError(t, err)
+		require.NoError(t, updateRes.Wait())
+
+		searchRes, err = client.SearchDictionaryEntries(search.Stopwords, entryID)
+		require.NoError(t, err)
+		require.Empty(t, searchRes.Hits)
+	}
+
+	var settings = search.DictionarySettings{
+		DisableStandardEntries: opt.DisableStandardEntries(map[string]map[string]bool{"stopwords": {"en": true}}),
+	}
+
+	{
+		res, err := client.SetDictionarySettings(settings)
+		require.NoError(t, err)
+		require.NoError(t, res.Wait())
+	}
+
+	{
+		res, err := client.GetDictionarySettings()
+		require.NoError(t, err)
+		require.Equal(t, settings, res)
+	}
+
+}
diff --git a/cts/testing_utils.go b/cts/testing_utils.go
index 06c13b30..8f86c32a 100644
--- a/cts/testing_utils.go
+++ b/cts/testing_utils.go
@@ -6,6 +6,7 @@ import (
 	"encoding/base64"
 	"encoding/hex"
 	"fmt"
+	"math/rand"
 	"os"
 	"os/user"
 	"strings"
@@ -161,3 +162,13 @@ func GenerateSecuredAPIKeyWithArbitraryParameters(
 	checksum := hex.EncodeToString(h.Sum(nil))
 	return base64.StdEncoding.EncodeToString([]byte(checksum + message))
 }
+
+var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
+
+func GenerateRandomString(length int) string {
+	b := make([]rune, length)
+	for i := range b {
+		b[i] = letters[rand.Intn(len(letters))]
+	}
+	return string(b)
+}