-
Notifications
You must be signed in to change notification settings - Fork 0
Add API v2 with structured responses (chi-based rebuild) #42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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. | ||||||||||||||||||||
| */ | ||||||||||||||||||||
|
Comment on lines
+1
to
+15
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| 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"` | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+34
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Note that this change will require updating the type V2APIResponse struct {
Status string `json:"status"`
Data interface{} `json:"data,omitempty"`
Error *V2APIError `json:"error,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) | ||||||||||||||||||||
| }) | ||||||||||||||||||||
|
Comment on lines
+71
to
+93
|
||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // 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 | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+190
to
+192
|
||||||||||||||||||||
| if p, err := strconv.Atoi(pageStr); err == nil { | |
| page = p | |
| } | |
| p, err := strconv.Atoi(pageStr) | |
| if err != nil || p < 0 { | |
| respondError(w, http.StatusBadRequest, "INVALID_PAGE", "Invalid 'page' parameter; must be a non-negative integer") | |
| return | |
| } | |
| page = p |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The documentation states that the response envelope includes a top-level
messagefield. However, this field is unused in the API responses, and the error message is contained within theerrorobject. This description is misleading and should be corrected to reflect the actual response structure.