From 6030cdae3ab2257263b41ba1743a0a438e70defd Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 01:07:13 +0000 Subject: [PATCH] Add API v2 with structured JSON responses and proper HTTP status codes Implement a new v2 REST API mounted at /api/v2 that provides: - Consistent JSON response envelope (V2APIResponse with status/data/error) - Proper HTTP status codes (404, 400, 500, 503) instead of always 200 - RESTful URL structure with nested resources - Machine-readable error codes alongside human-readable messages Endpoints: clusters, cluster info, cluster instances, topology, instance details, recoveries, active recoveries, status, and ProxySQL servers. Closes #33 --- docs/api-v2.md | 150 +++++++++++++++++++++++++++ go/app/http.go | 1 + go/http/apiv2.go | 235 ++++++++++++++++++++++++++++++++++++++++++ go/http/apiv2_test.go | 157 ++++++++++++++++++++++++++++ 4 files changed, 543 insertions(+) create mode 100644 docs/api-v2.md create mode 100644 go/http/apiv2.go create mode 100644 go/http/apiv2_test.go diff --git a/docs/api-v2.md b/docs/api-v2.md new file mode 100644 index 00000000..0ac1d92c --- /dev/null +++ b/docs/api-v2.md @@ -0,0 +1,150 @@ +# API v2 + +Orchestrator API v2 provides a cleaner, more consistent REST API with structured JSON responses and proper HTTP status codes. + +All v2 endpoints are mounted under `/api/v2` (respecting the configured `URLPrefix`). + +## Response Format + +All v2 endpoints return a consistent JSON envelope: + +### Success + +```json +{ + "status": "ok", + "data": { ... } +} +``` + +### Error + +```json +{ + "status": "error", + "error": { + "code": "ERROR_CODE", + "message": "Human-readable error description" + } +} +``` + +HTTP status codes are used appropriately: + +- `200` for successful requests +- `400` for invalid input (bad instance key, etc.) +- `404` for resources not found (unknown cluster, instance, etc.) +- `500` for internal server errors +- `503` for unavailable services (e.g., ProxySQL not configured) + +## Endpoints + +### Clusters + +#### `GET /api/v2/clusters` + +Returns a list of all known clusters with metadata. + +**Response:** Array of cluster info objects including cluster name, alias, master instance, count of instances, etc. + +#### `GET /api/v2/clusters/{name}` + +Returns detailed information about a specific cluster. The `{name}` parameter can be a cluster name, alias, or instance key hint. + +**Response:** Single cluster info object. + +**Errors:** +- `404` if the cluster cannot be resolved. + +#### `GET /api/v2/clusters/{name}/instances` + +Returns all instances belonging to a given cluster. + +**Response:** Array of instance objects. + +**Errors:** +- `404` if the cluster cannot be resolved. + +#### `GET /api/v2/clusters/{name}/topology` + +Returns the ASCII topology representation for a cluster. + +**Response:** + +```json +{ + "status": "ok", + "data": { + "clusterName": "my-cluster:3306", + "topology": "..." + } +} +``` + +**Errors:** +- `404` if the cluster cannot be resolved. + +### Instances + +#### `GET /api/v2/instances/{host}/{port}` + +Returns detailed information about a specific MySQL instance. + +**Response:** Single instance object with replication status, version info, etc. + +**Errors:** +- `400` if the instance key is invalid. +- `404` if the instance is not found. + +### Recoveries + +#### `GET /api/v2/recoveries` + +Returns recent recovery entries. + +**Query Parameters:** +- `cluster` (optional): filter by cluster name +- `alias` (optional): filter by cluster alias +- `page` (optional): page number for pagination (default: 0) + +**Response:** Array of recovery objects. + +#### `GET /api/v2/recoveries/active` + +Returns currently active (in-progress) recoveries. + +**Response:** Array of active recovery objects. + +### Status + +#### `GET /api/v2/status` + +Returns the health status of the orchestrator node. + +**Response:** Health status object including hostname, active node info, raft status, etc. + +**Errors:** +- `500` if the node is unhealthy. + +### ProxySQL + +#### `GET /api/v2/proxysql/servers` + +Returns all servers from ProxySQL's `runtime_mysql_servers` table. + +**Response:** Array of ProxySQL server entry objects. + +**Errors:** +- `503` if ProxySQL is not configured. +- `500` if querying ProxySQL fails. + +## Migration from API v1 + +The v2 API wraps the same underlying functions as v1 but provides: + +1. **Consistent response envelope**: All responses use the `V2APIResponse` struct with `status`, `data`, `error`, and `message` fields. +2. **Proper HTTP status codes**: v1 always returned 200 with error info in the body; v2 uses appropriate 4xx/5xx codes. +3. **RESTful URL structure**: Resources are nested logically (e.g., `/clusters/{name}/instances` instead of `/cluster/{clusterHint}`). +4. **Structured error codes**: Machine-readable error codes (e.g., `NOT_FOUND`, `CLUSTER_LIST_ERROR`) alongside human-readable messages. + +The v1 API remains fully functional. Both APIs can be used simultaneously. diff --git a/go/app/http.go b/go/app/http.go index b51616ee..65353759 100644 --- a/go/app/http.go +++ b/go/app/http.go @@ -132,6 +132,7 @@ func standardHttp(continuousDiscovery bool) { http.API.URLPrefix = config.Config.URLPrefix http.Web.URLPrefix = config.Config.URLPrefix http.API.RegisterRequests(router) + http.RegisterV2Routes(router) http.Web.RegisterRequests(router) // Serve diff --git a/go/http/apiv2.go b/go/http/apiv2.go new file mode 100644 index 00000000..8b80b707 --- /dev/null +++ b/go/http/apiv2.go @@ -0,0 +1,235 @@ +/* + Copyright 2024 Orchestrator Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package http + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/proxysql/orchestrator/go/config" + "github.com/proxysql/orchestrator/go/inst" + "github.com/proxysql/orchestrator/go/logic" + "github.com/proxysql/orchestrator/go/process" + "github.com/proxysql/orchestrator/go/proxysql" +) + +// V2APIResponse is the standard response envelope for API v2 endpoints. +type V2APIResponse struct { + Status string `json:"status"` + Data interface{} `json:"data,omitempty"` + Error *V2APIError `json:"error,omitempty"` + Message string `json:"message,omitempty"` +} + +// V2APIError represents an error in the API v2 response. +type V2APIError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// respondOK writes a successful JSON response with the given data. +func respondOK(w http.ResponseWriter, data interface{}) { + renderJSON(w, http.StatusOK, &V2APIResponse{ + Status: "ok", + Data: data, + }) +} + +// respondError writes an error JSON response with the given HTTP status, error code, and message. +func respondError(w http.ResponseWriter, status int, code string, message string) { + renderJSON(w, status, &V2APIResponse{ + Status: "error", + Error: &V2APIError{ + Code: code, + Message: message, + }, + }) +} + +// respondNotFound writes a 404 JSON response with the given message. +func respondNotFound(w http.ResponseWriter, message string) { + respondError(w, http.StatusNotFound, "NOT_FOUND", message) +} + +// RegisterV2Routes mounts all v2 API routes under /api/v2 on the given router. +func RegisterV2Routes(r chi.Router) { + prefix := config.Config.URLPrefix + r.Route(fmt.Sprintf("%s/api/v2", prefix), func(r chi.Router) { + // Cluster endpoints + r.Get("/clusters", V2Clusters) + r.Get("/clusters/{name}", V2ClusterInfo) + r.Get("/clusters/{name}/instances", V2ClusterInstances) + r.Get("/clusters/{name}/topology", V2Topology) + + // Instance endpoints + r.Get("/instances/{host}/{port}", V2Instance) + + // Recovery endpoints + r.Get("/recoveries", V2Recoveries) + r.Get("/recoveries/active", V2ActiveRecoveries) + + // Status endpoints + r.Get("/status", V2Status) + + // ProxySQL endpoints + r.Get("/proxysql/servers", V2ProxySQLServers) + }) +} + +// V2Clusters returns a list of all known clusters with metadata. +func V2Clusters(w http.ResponseWriter, r *http.Request) { + clustersInfo, err := inst.ReadClustersInfo("") + if err != nil { + respondError(w, http.StatusInternalServerError, "CLUSTER_LIST_ERROR", fmt.Sprintf("Failed to read clusters: %v", err)) + return + } + respondOK(w, clustersInfo) +} + +// V2ClusterInfo returns detailed information about a specific cluster. +func V2ClusterInfo(w http.ResponseWriter, r *http.Request) { + clusterName := chi.URLParam(r, "name") + clusterName, err := figureClusterName(clusterName) + if err != nil { + respondNotFound(w, fmt.Sprintf("Cluster not found: %v", err)) + return + } + clusterInfo, err := inst.ReadClusterInfo(clusterName) + if err != nil { + respondError(w, http.StatusInternalServerError, "CLUSTER_INFO_ERROR", fmt.Sprintf("Failed to read cluster info: %v", err)) + return + } + respondOK(w, clusterInfo) +} + +// V2ClusterInstances returns all instances belonging to a given cluster. +func V2ClusterInstances(w http.ResponseWriter, r *http.Request) { + clusterName := chi.URLParam(r, "name") + clusterName, err := figureClusterName(clusterName) + if err != nil { + respondNotFound(w, fmt.Sprintf("Cluster not found: %v", err)) + return + } + instances, err := inst.ReadClusterInstances(clusterName) + if err != nil { + respondError(w, http.StatusInternalServerError, "CLUSTER_INSTANCES_ERROR", fmt.Sprintf("Failed to read cluster instances: %v", err)) + return + } + respondOK(w, instances) +} + +// V2Instance returns detailed information about a specific MySQL instance. +func V2Instance(w http.ResponseWriter, r *http.Request) { + host := chi.URLParam(r, "host") + port := chi.URLParam(r, "port") + + instanceKey, err := inst.NewResolveInstanceKeyStrings(host, port) + if err != nil { + respondError(w, http.StatusBadRequest, "INVALID_INSTANCE", fmt.Sprintf("Invalid instance key: %v", err)) + return + } + instanceKey, err = inst.FigureInstanceKey(instanceKey, nil) + if err != nil { + respondError(w, http.StatusBadRequest, "INVALID_INSTANCE", fmt.Sprintf("Cannot resolve instance: %v", err)) + return + } + instance, found, err := inst.ReadInstance(instanceKey) + if err != nil { + respondError(w, http.StatusInternalServerError, "INSTANCE_READ_ERROR", fmt.Sprintf("Failed to read instance: %v", err)) + return + } + if !found { + respondNotFound(w, fmt.Sprintf("Instance not found: %s:%s", host, port)) + return + } + respondOK(w, instance) +} + +// V2Topology returns the ASCII topology representation for a given cluster. +func V2Topology(w http.ResponseWriter, r *http.Request) { + clusterName := chi.URLParam(r, "name") + clusterName, err := figureClusterName(clusterName) + if err != nil { + respondNotFound(w, fmt.Sprintf("Cluster not found: %v", err)) + return + } + asciiOutput, err := inst.ASCIITopology(clusterName, "", false, false) + if err != nil { + respondError(w, http.StatusInternalServerError, "TOPOLOGY_ERROR", fmt.Sprintf("Failed to generate topology: %v", err)) + return + } + respondOK(w, map[string]interface{}{ + "clusterName": clusterName, + "topology": asciiOutput, + }) +} + +// V2Recoveries returns recent recovery entries, optionally filtered by cluster. +func V2Recoveries(w http.ResponseWriter, r *http.Request) { + clusterName := r.URL.Query().Get("cluster") + clusterAlias := r.URL.Query().Get("alias") + page := 0 + if pageStr := r.URL.Query().Get("page"); pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil { + page = p + } + } + recoveries, err := logic.ReadRecentRecoveries(clusterName, clusterAlias, false, page) + if err != nil { + respondError(w, http.StatusInternalServerError, "RECOVERIES_ERROR", fmt.Sprintf("Failed to read recoveries: %v", err)) + return + } + respondOK(w, recoveries) +} + +// V2ActiveRecoveries returns currently active (in-progress) recoveries. +func V2ActiveRecoveries(w http.ResponseWriter, r *http.Request) { + recoveries, err := logic.ReadActiveRecoveries() + if err != nil { + respondError(w, http.StatusInternalServerError, "ACTIVE_RECOVERIES_ERROR", fmt.Sprintf("Failed to read active recoveries: %v", err)) + return + } + respondOK(w, recoveries) +} + +// V2Status returns the health status of the orchestrator node. +func V2Status(w http.ResponseWriter, r *http.Request) { + health, err := process.HealthTest() + if err != nil { + respondError(w, http.StatusInternalServerError, "UNHEALTHY", fmt.Sprintf("Application node is unhealthy: %v", err)) + return + } + respondOK(w, health) +} + +// V2ProxySQLServers returns all servers from ProxySQL's runtime_mysql_servers table. +func V2ProxySQLServers(w http.ResponseWriter, r *http.Request) { + hook := proxysql.GetHook() + if hook == nil || !hook.IsConfigured() { + respondError(w, http.StatusServiceUnavailable, "PROXYSQL_NOT_CONFIGURED", "ProxySQL is not configured") + return + } + servers, err := hook.GetClient().GetServers() + if err != nil { + respondError(w, http.StatusInternalServerError, "PROXYSQL_ERROR", fmt.Sprintf("Failed to query ProxySQL servers: %v", err)) + return + } + respondOK(w, servers) +} diff --git a/go/http/apiv2_test.go b/go/http/apiv2_test.go new file mode 100644 index 00000000..6add849d --- /dev/null +++ b/go/http/apiv2_test.go @@ -0,0 +1,157 @@ +package http + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRespondOK(t *testing.T) { + w := httptest.NewRecorder() + data := map[string]string{"key": "value"} + respondOK(w, data) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } + + var resp V2APIResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Status != "ok" { + t.Errorf("expected status 'ok', got '%s'", resp.Status) + } + if resp.Error != nil { + t.Errorf("expected no error, got %+v", resp.Error) + } + if resp.Data == nil { + t.Error("expected data to be present") + } +} + +func TestRespondOKNilData(t *testing.T) { + w := httptest.NewRecorder() + respondOK(w, nil) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } + + var resp V2APIResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Status != "ok" { + t.Errorf("expected status 'ok', got '%s'", resp.Status) + } +} + +func TestRespondError(t *testing.T) { + w := httptest.NewRecorder() + respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "something went wrong") + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code) + } + + var resp V2APIResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Status != "error" { + t.Errorf("expected status 'error', got '%s'", resp.Status) + } + if resp.Error == nil { + t.Fatal("expected error to be present") + } + if resp.Error.Code != "INTERNAL_ERROR" { + t.Errorf("expected error code 'INTERNAL_ERROR', got '%s'", resp.Error.Code) + } + if resp.Error.Message != "something went wrong" { + t.Errorf("expected error message 'something went wrong', got '%s'", resp.Error.Message) + } + if resp.Data != nil { + t.Errorf("expected no data in error response, got %+v", resp.Data) + } +} + +func TestRespondNotFound(t *testing.T) { + w := httptest.NewRecorder() + respondNotFound(w, "resource not found") + + if w.Code != http.StatusNotFound { + t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code) + } + + var resp V2APIResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Status != "error" { + t.Errorf("expected status 'error', got '%s'", resp.Status) + } + if resp.Error == nil { + t.Fatal("expected error to be present") + } + if resp.Error.Code != "NOT_FOUND" { + t.Errorf("expected error code 'NOT_FOUND', got '%s'", resp.Error.Code) + } + if resp.Error.Message != "resource not found" { + t.Errorf("expected error message 'resource not found', got '%s'", resp.Error.Message) + } +} + +func TestRespondErrorBadRequest(t *testing.T) { + w := httptest.NewRecorder() + respondError(w, http.StatusBadRequest, "INVALID_INPUT", "bad input") + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + var resp V2APIResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Error.Code != "INVALID_INPUT" { + t.Errorf("expected error code 'INVALID_INPUT', got '%s'", resp.Error.Code) + } +} + +func TestV2APIResponseJSONOmitempty(t *testing.T) { + resp := V2APIResponse{Status: "ok"} + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if _, ok := raw["data"]; ok { + t.Error("expected 'data' to be omitted when nil") + } + if _, ok := raw["error"]; ok { + t.Error("expected 'error' to be omitted when nil") + } + if _, ok := raw["message"]; ok { + t.Error("expected 'message' to be omitted when empty") + } + if _, ok := raw["status"]; !ok { + t.Error("expected 'status' to be present") + } +} + +func TestRespondOKContentType(t *testing.T) { + w := httptest.NewRecorder() + respondOK(w, "test") + + ct := w.Header().Get("Content-Type") + if ct != "application/json; charset=UTF-8" { + t.Errorf("expected Content-Type 'application/json; charset=UTF-8', got '%s'", ct) + } +}