Repository: meilisearch/meilisearch-go

Issue:
[v1.13] Support network to create a network of Meilisearch instances
**Description:**
The Meilisearch documentation introduces a new experimental `/network` API that allows managing a network of Meilisearch instances. This is particularly useful when combined with federated search for implementing sharding or horizontal partitioning strategies.

https://www.meilisearch.com/docs/reference/api/network

Currently, the SDK does not support this feature. We should add client support for both retrieving and updating the `network` object.

---

### 📌 API Details

#### **Get the network object**

* **Endpoint:** `GET /network`
* **Description:** Returns the current network object.
* **Response Example:**

```json
{
  "self": "ms-00",
  "remotes": {
    "ms-00": {
      "url": "http://ms-1235.example.meilisearch.io",
      "searchApiKey": "Ecd1SDDi4pqdJD6qYLxD3y7VZAEb4d9j6LJgt4d6xas"
    },
    "ms-01": {
      "url": "http://ms-4242.example.meilisearch.io",
      "searchApiKey": "hrVu-OMcjPGElK7692K7bwriBoGyHXTMvB5NmZkMKqQ"
    }
  }
}
```

#### **Update the network object**

* **Endpoint:** `PATCH /network`

* **Description:** Updates `self` and/or `remotes`. Updates are partial; only the provided fields are updated.

* **Notes:**

  * To reset `self` or `remotes`, set them to `null`.
  * To remove a single remote, set its value to `null`.

* **Request Example:**

```json
{
  "self": "ms-00",
  "remotes": {
    "ms-00": {
      "url": "http://INSTANCE_URL",
      "searchApiKey": "INSTANCE_API_KEY"
    },
    "ms-01": {
      "url": "http://ANOTHER_INSTANCE_URL",
      "searchApiKey": "ANOTHER_INSTANCE_API_KEY"
    }
  }
}
```

* **Response Example:**

```json
{
  "self": "ms-00",
  "remotes": {
    "ms-00": {
      "url": "http://INSTANCE_URL",
      "searchApiKey": "INSTANCE_API_KEY"
    },
    "ms-01": {
      "url": "http://ANOTHER_INSTANCE_URL",
      "searchApiKey": "ANOTHER_INSTANCE_API_KEY"
    }
  }
}
```

---

### ✅ Tasks

* [ ] Add method for `GET /network`
* [ ] Add method for `PATCH /network`
* [ ] Define corresponding models (`Network`, `Remote`)
* [ ] Write integration tests
* [ ] Update documentation with examples


Discussion:

Solution patch:
diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml
index cf3e972..987bec8 100644
--- a/.code-samples.meilisearch.yaml
+++ b/.code-samples.meilisearch.yaml
@@ -1036,3 +1036,20 @@ webhooks_patch_1: |-
   });
 webhooks_delete_1: |-
   client.DeleteWebhook("WEBHOOK_UUID");
+network_get: |-
+  client.GetNetwork();
+network_patch_1: |-
+  client.UpdateNetwork(&meilisearch.Network{
+    Self: meilisearch.String("TEST"),
+    Remotes: meilisearch.NewOpt(map[string]meilisearch.Opt[meilisearch.Remote]{
+      "ms-00": meilisearch.NewOpt(meilisearch.Remote{
+        URL:          meilisearch.String("https://example.com"),
+        SearchAPIKey: meilisearch.String("TEST"),
+      },
+    },
+  });
+network_patch_2: |-
+  client.UpdateNetwork(&meilisearch.Network{
+    Self: meilisearch.String("TEST"),
+    Remotes: meilisearch.Null[map[string]meilisearch.Opt[meilisearch.Remote]](),
+  });
diff --git a/meilisearch_interface.go b/meilisearch_interface.go
index 99c9fee..5993376 100644
--- a/meilisearch_interface.go
+++ b/meilisearch_interface.go
@@ -69,6 +69,14 @@ type ServiceManager interface {
 	// ExportWithContext transfers data from your origin instance to a remote target instance with a context for cancellation.
 	ExportWithContext(ctx context.Context, params *ExportParams) (*TaskInfo, error)
 
+	// UpdateNetwork updates the network object.
+	// Updates are partial; only the provided fields are updated.
+	UpdateNetwork(params *Network) (*Network, error)
+
+	// UpdateNetworkWithContext updates the network object with a context.
+	// Updates are partial; only the provided fields are updated.
+	UpdateNetworkWithContext(ctx context.Context, params *Network) (*Network, error)
+
 	// Close closes the connection to the Meilisearch server.
 	Close()
 }
@@ -139,6 +147,12 @@ type ServiceReader interface {
 
 	// GetBatchWithContext retrieves a specific batch by its UID with a context for cancellation.
 	GetBatchWithContext(ctx context.Context, batchUID int) (*Batch, error)
+
+	// GetNetwork gets the current value of the instance’s network object.
+	GetNetwork() (*Network, error)
+
+	// GetNetworkWithContext gets the current value of the instance’s network object with a context.
+	GetNetworkWithContext(ctx context.Context) (*Network, error)
 }
 
 type KeyManager interface {
diff --git a/networks.go b/networks.go
new file mode 100644
index 0000000..60cda7f
--- /dev/null
+++ b/networks.go
@@ -0,0 +1,47 @@
+package meilisearch
+
+import (
+	"context"
+	"net/http"
+)
+
+func (m *meilisearch) UpdateNetwork(params *Network) (*Network, error) {
+	return m.UpdateNetworkWithContext(context.Background(), params)
+}
+
+func (m *meilisearch) UpdateNetworkWithContext(ctx context.Context, params *Network) (*Network, error) {
+	resp := new(Network)
+	req := &internalRequest{
+		endpoint:            "/network",
+		method:              http.MethodPatch,
+		contentType:         contentTypeJSON,
+		withRequest:         params,
+		withResponse:        resp,
+		acceptedStatusCodes: []int{http.StatusOK},
+		functionName:        "UpdateNetwork",
+	}
+	if err := m.client.executeRequest(ctx, req); err != nil {
+		return nil, err
+	}
+	return resp, nil
+}
+
+func (m *meilisearch) GetNetwork() (*Network, error) {
+	return m.GetNetworkWithContext(context.Background())
+}
+
+func (m *meilisearch) GetNetworkWithContext(ctx context.Context) (*Network, error) {
+	resp := new(Network)
+	req := &internalRequest{
+		endpoint:            "/network",
+		method:              http.MethodGet,
+		withRequest:         nil,
+		withResponse:        resp,
+		acceptedStatusCodes: []int{http.StatusOK},
+		functionName:        "GetNetwork",
+	}
+	if err := m.client.executeRequest(ctx, req); err != nil {
+		return nil, err
+	}
+	return resp, nil
+}
diff --git a/types.go b/types.go
index b943afc..ebbe2f1 100644
--- a/types.go
+++ b/types.go
@@ -14,6 +14,58 @@ const (
 	nullBody                 = "null"
 )
 
+// Network represents the Meilisearch network configuration.
+// Each field is wrapped in an Opt so it can be explicitly included,
+// set to JSON null, or omitted entirely.
+type Network struct {
+	Self    Opt[string]                 `json:"self,omitempty"`
+	Remotes Opt[map[string]Opt[Remote]] `json:"remotes,omitempty"`
+}
+
+func (n Network) MarshalJSON() ([]byte, error) {
+	m := make(map[string]any)
+
+	if n.Self.Valid() {
+		m["self"] = n.Self.Value
+	} else if n.Self.Null() {
+		m["self"] = nil
+	}
+
+	if n.Remotes.Valid() {
+		m["remotes"] = n.Remotes.Value
+	} else if n.Remotes.Null() {
+		m["remotes"] = nil
+	}
+
+	return json.Marshal(m)
+}
+
+// Remote describes a single remote Meilisearch node.
+// Each field is wrapped in an Opt so it can be explicitly included,
+// set to JSON null, or omitted entirely.
+type Remote struct {
+	URL          Opt[string] `json:"url"`
+	SearchAPIKey Opt[string] `json:"searchApiKey,omitempty"`
+}
+
+func (r Remote) MarshalJSON() ([]byte, error) {
+	m := make(map[string]any)
+
+	if r.URL.Valid() {
+		m["url"] = r.URL.Value
+	} else if r.URL.Null() {
+		m["url"] = nil
+	}
+
+	if r.SearchAPIKey.Valid() {
+		m["searchApiKey"] = r.SearchAPIKey.Value
+	} else if r.SearchAPIKey.Null() {
+		m["searchApiKey"] = nil
+	}
+
+	return json.Marshal(m)
+}
+
 type UpdateWebhookRequest struct {
 	URL     string            `json:"url,omitempty"`
 	Headers map[string]string `json:"headers,omitempty"`



Test patch:
diff --git a/integration/main_test.go b/integration/main_test.go
index 8679205..89821e5 100644
--- a/integration/main_test.go
+++ b/integration/main_test.go
@@ -122,6 +122,14 @@ func cleanupWebhook(services ...meilisearch.WebhookManager) func() {
 	}
 }
 
+func cleanupNetwork(services ...meilisearch.ServiceManager) func() {
+	return func() {
+		for _, s := range services {
+			_, _ = resetNetwork(s)
+		}
+	}
+}
+
 func getPrivateKey(sv meilisearch.ServiceManager) (key string) {
 	list, err := sv.GetKeys(nil)
 	if err != nil {
@@ -165,6 +173,17 @@ func deleteAllIndexes(sv meilisearch.ServiceManager) (ok bool, err error) {
 	return true, nil
 }
 
+func resetNetwork(sv meilisearch.ServiceManager) (ok bool, err error) {
+	_, err = sv.UpdateNetwork(&meilisearch.Network{
+		Self:    meilisearch.Null[string](),
+		Remotes: meilisearch.Null[map[string]meilisearch.Opt[meilisearch.Remote]](),
+	})
+	if err != nil {
+		return false, err
+	}
+	return true, nil
+}
+
 func deleteAllWebhooks(webhookMgr meilisearch.WebhookManager) (ok bool, err error) {
 	list, err := webhookMgr.ListWebhooks()
 	if err != nil {
diff --git a/integration/network_test.go b/integration/network_test.go
new file mode 100644
index 0000000..8a0e7ee
--- /dev/null
+++ b/integration/network_test.go
@@ -0,0 +1,160 @@
+package integration
+
+import (
+	"testing"
+
+	"github.com/meilisearch/meilisearch-go"
+	"github.com/stretchr/testify/require"
+)
+
+func Test_GetNetwork(t *testing.T) {
+	sv := setup(t, "")
+	t.Cleanup(cleanupNetwork(sv))
+
+	experimentalFeatures, err := sv.ExperimentalFeatures().SetNetwork(true).Update()
+	require.NoError(t, err)
+	require.True(t, experimentalFeatures.Network)
+
+	network, err := sv.GetNetwork()
+	require.NoError(t, err)
+	require.True(t, network.Remotes.Valid())
+	require.True(t, network.Self.Null())
+}
+
+func Test_UpdateNetwork(t *testing.T) {
+	sv := setup(t, "")
+	t.Cleanup(cleanupNetwork(sv))
+
+	experimentalFeatures, err := sv.ExperimentalFeatures().SetNetwork(true).Update()
+	require.NoError(t, err)
+	require.True(t, experimentalFeatures.Network)
+
+	tests := []struct {
+		name        string
+		input       *meilisearch.Network
+		wantSelf    string
+		wantRemotes map[string]meilisearch.Opt[meilisearch.Remote]
+	}{
+		{
+			name: "set initial network",
+			input: &meilisearch.Network{
+				Self: meilisearch.String("TEST"),
+				Remotes: meilisearch.NewOpt(map[string]meilisearch.Opt[meilisearch.Remote]{
+					"ms-00": meilisearch.NewOpt(meilisearch.Remote{
+						URL:          meilisearch.String("https://example.com"),
+						SearchAPIKey: meilisearch.String("TEST"),
+					}),
+				}),
+			},
+			wantSelf: "TEST",
+			wantRemotes: map[string]meilisearch.Opt[meilisearch.Remote]{
+				"ms-00": meilisearch.NewOpt(meilisearch.Remote{
+					URL:          meilisearch.String("https://example.com"),
+					SearchAPIKey: meilisearch.String("TEST"),
+				}),
+			},
+		},
+		{
+			name: "update self only (remotes omitted)",
+			input: &meilisearch.Network{
+				Self: meilisearch.NewOpt("NEW-SELF"),
+			},
+			wantSelf: "NEW-SELF",
+			wantRemotes: map[string]meilisearch.Opt[meilisearch.Remote]{
+				"ms-00": meilisearch.NewOpt(meilisearch.Remote{
+					URL:          meilisearch.String("https://example.com"),
+					SearchAPIKey: meilisearch.String("TEST"),
+				}),
+			},
+		},
+		{
+			name: "update remote url only",
+			input: &meilisearch.Network{
+				Remotes: meilisearch.NewOpt(map[string]meilisearch.Opt[meilisearch.Remote]{
+					"ms-00": meilisearch.NewOpt(meilisearch.Remote{
+						URL: meilisearch.String("https://updated.com"),
+					}),
+				}),
+			},
+			wantSelf: "NEW-SELF",
+			wantRemotes: map[string]meilisearch.Opt[meilisearch.Remote]{
+				"ms-00": meilisearch.NewOpt(meilisearch.Remote{
+					URL:          meilisearch.String("https://updated.com"),
+					SearchAPIKey: meilisearch.String("TEST"), // unchanged
+				}),
+			},
+		},
+		{
+			name: "update remote searchApiKey only",
+			input: &meilisearch.Network{
+				Remotes: meilisearch.NewOpt(map[string]meilisearch.Opt[meilisearch.Remote]{
+					"ms-00": meilisearch.NewOpt(meilisearch.Remote{
+						SearchAPIKey: meilisearch.String("UPDATED_API_KEY"),
+					}),
+				}),
+			},
+			wantSelf: "NEW-SELF",
+			wantRemotes: map[string]meilisearch.Opt[meilisearch.Remote]{
+				"ms-00": meilisearch.NewOpt(meilisearch.Remote{
+					URL:          meilisearch.String("https://updated.com"),
+					SearchAPIKey: meilisearch.String("UPDATED_API_KEY"), // unchanged
+				}),
+			},
+		},
+		{
+			name: "remove searchApiKey",
+			input: &meilisearch.Network{
+				Remotes: meilisearch.NewOpt(map[string]meilisearch.Opt[meilisearch.Remote]{
+					"ms-00": meilisearch.NewOpt(meilisearch.Remote{
+						SearchAPIKey: meilisearch.Null[string](),
+					}),
+				}),
+			},
+			wantSelf: "NEW-SELF",
+			wantRemotes: map[string]meilisearch.Opt[meilisearch.Remote]{
+				"ms-00": meilisearch.NewOpt(meilisearch.Remote{
+					URL:          meilisearch.String("https://updated.com"),
+					SearchAPIKey: meilisearch.Null[string](),
+				}),
+			},
+		},
+		{
+			name: "remove remote",
+			input: &meilisearch.Network{
+				Remotes: meilisearch.NewOpt(map[string]meilisearch.Opt[meilisearch.Remote]{
+					"ms-00": meilisearch.Null[meilisearch.Remote](),
+				}),
+			},
+			wantSelf:    "NEW-SELF",
+			wantRemotes: map[string]meilisearch.Opt[meilisearch.Remote]{},
+		},
+		{
+			name: "add new remote",
+			input: &meilisearch.Network{
+				Remotes: meilisearch.NewOpt(map[string]meilisearch.Opt[meilisearch.Remote]{
+					"ms-01": meilisearch.NewOpt(meilisearch.Remote{
+						URL:          meilisearch.String("https://new-remote.com"),
+						SearchAPIKey: meilisearch.NewOpt("NEW-REMOTE-KEY"),
+					}),
+				}),
+			},
+			wantSelf: "NEW-SELF",
+			wantRemotes: map[string]meilisearch.Opt[meilisearch.Remote]{
+				"ms-01": meilisearch.NewOpt(meilisearch.Remote{
+					URL:          meilisearch.String("https://new-remote.com"),
+					SearchAPIKey: meilisearch.NewOpt("NEW-REMOTE-KEY"),
+				}),
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			network, err := sv.UpdateNetwork(tt.input)
+			require.NoError(t, err)
+
+			require.Equal(t, tt.wantSelf, network.Self.Value)
+			require.Equal(t, tt.wantRemotes, network.Remotes.Value)
+		})
+	}
+}
diff --git a/types_test.go b/types_test.go
index fdc88d5..ca27f7e 100644
--- a/types_test.go
+++ b/types_test.go
@@ -116,3 +116,129 @@ func TestTimestampz_ToTime(t *testing.T) {
 		require.Equal(t, c.expected, c.input.ToTime())
 	}
 }
+
+func TestNetwork_MarshalJSON(t *testing.T) {
+	t.Parallel()
+
+	type R = Opt[Remote]
+
+	tests := []struct {
+		name     string
+		in       Network
+		wantJSON string
+	}{
+		{
+			name:     "omit all when both fields are zero value",
+			in:       Network{},
+			wantJSON: `{}`,
+		},
+		{
+			name: "self set, remotes omitted",
+			in: Network{
+				Self: String("primary-node"),
+			},
+			wantJSON: `{"self":"primary-node"}`,
+		},
+		{
+			name: "self null, remotes omitted",
+			in: Network{
+				Self: Null[string](),
+			},
+			wantJSON: `{"self":null}`,
+		},
+		{
+			name: "remotes set (one valid remote, one null), self omitted",
+			in: Network{
+				Remotes: NewOpt(map[string]R{
+					"east": NewOpt(Remote{
+						URL: String("https://east.example.com"),
+					}),
+					"west": Null[Remote](),
+				}),
+			},
+			wantJSON: `{
+				"remotes": {
+					"east": { "url": "https://east.example.com" },
+					"west": null
+				}
+			}`,
+		},
+		{
+			name: "self set and remotes set",
+			in: Network{
+				Self: String("primary"),
+				Remotes: NewOpt(map[string]R{
+					"a": NewOpt(Remote{URL: String("https://a.example.com"), SearchAPIKey: String("sek_a")}),
+					"b": NewOpt(Remote{URL: Null[string]()}),
+				}),
+			},
+			wantJSON: `{
+				"self":"primary",
+				"remotes": {
+					"a": {"url":"https://a.example.com","searchApiKey":"sek_a"},
+					"b": {"url":null}
+				}
+			}`,
+		},
+		{
+			name: "remotes explicitly null",
+			in: Network{
+				Self:    String("primary"),
+				Remotes: Null[map[string]R](),
+			},
+			wantJSON: `{"self":"primary","remotes":null}`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := tt.in.MarshalJSON()
+			require.NoError(t, err)
+			require.JSONEq(t, tt.wantJSON, string(got))
+		})
+	}
+}
+
+func TestRemote_MarshalJSON(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		in       Remote
+		wantJSON string
+	}{
+		{
+			name:     "omit all when both fields are zero value",
+			in:       Remote{},
+			wantJSON: `{}`,
+		},
+		{
+			name:     "url set, searchApiKey omitted",
+			in:       Remote{URL: String("https://east.example.com")},
+			wantJSON: `{"url":"https://east.example.com"}`,
+		},
+		{
+			name:     "url null, searchApiKey omitted",
+			in:       Remote{URL: Null[string]()},
+			wantJSON: `{"url":null}`,
+		},
+		{
+			name:     "url set, searchApiKey null",
+			in:       Remote{URL: String("https://east.example.com"), SearchAPIKey: Null[string]()},
+			wantJSON: `{"url":"https://east.example.com","searchApiKey":null}`,
+		},
+		{
+			name:     "both set",
+			in:       Remote{URL: String("https://east.example.com"), SearchAPIKey: String("sek_abc")},
+			wantJSON: `{"url":"https://east.example.com","searchApiKey":"sek_abc"}`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := tt.in.MarshalJSON()
+			require.NoError(t, err)
+			require.JSONEq(t, tt.wantJSON, string(got))
+		})
+	}
+}