Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions data_sources.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

137 changes: 137 additions & 0 deletions datasource_tools_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package flashduty

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
)

func TestDatasourceToolsInvokePreservesJSON(t *testing.T) {
params := json.RawMessage(`{"cursor":9007199254740993,"threshold":1.000000000000000001,"nested":{"empty":[],"flag":false}}`)
evidence := `{"counter":18446744073709551615,"ratio":0.1234567890123456789,"values":[null,false,0]}`
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/monit/datasource/tools/invoke" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var input DatasourceToolInvokeRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
t.Error(err)
}
if input.DatasourceID != 42 || input.Tool != "mysql.overview" || string(input.Params) != string(params) {
t.Errorf("request changed: %+v", input)
}
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"request_id":"trace-tools","data":{"datasource_id":42,"tool":"mysql.overview","data":%s,"summary":"bounded evidence","truncated":{"reason":"row_limit"}}}`, evidence)
})
result, resp, err := client.DataSources.ToolsInvoke(context.Background(), &DatasourceToolInvokeRequest{DatasourceID: 42, Tool: "mysql.overview", Params: params})
if err != nil {
t.Fatal(err)
}
if resp.RequestID != "trace-tools" || result.DatasourceID != 42 || result.Tool != "mysql.overview" || string(result.Data) != evidence || result.Summary == nil || result.Truncated == nil || result.Truncated.Reason != "row_limit" {
t.Fatalf("response changed: %+v", result)
}
raw, err := json.Marshal(result)
if err != nil || !strings.Contains(string(raw), evidence) {
t.Fatalf("round trip changed evidence: %s, %v", raw, err)
}
}

func TestDatasourceToolsInvokeHTTPErrorReason(t *testing.T) {
for _, tc := range []struct {
status int
reason string
}{{400, "tool_not_supported"}, {409, "datasource_disabled"}, {429, "overloaded"}, {503, "edge_upgrade_required"}, {504, "timeout"}} {
t.Run(tc.reason, func(t *testing.T) {
calls := 0
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tc.status)
_, _ = fmt.Fprintf(w, `{"request_id":"trace-error","error":{"code":"InvalidParameter","reason":%q,"message":"tool unavailable"}}`, tc.reason)
})
result, _, err := client.DataSources.ToolsInvoke(context.Background(), &DatasourceToolInvokeRequest{DatasourceID: 42, Tool: "mysql.overview"})
var apiErr *ErrorResponse
if result != nil || !errors.As(err, &apiErr) || apiErr.Reason != tc.reason || apiErr.Response.StatusCode != tc.status || apiErr.RequestID != "trace-error" || calls != 1 {
t.Fatalf("error lost or call replayed: result=%+v error=%+v calls=%d", result, err, calls)
}
if !strings.Contains(err.Error(), "reason "+tc.reason) || !strings.Contains(err.Error(), "request_id trace-error") {
t.Fatalf("CLI error text lost reason or request ID: %s", err)
}
})
}
}

func TestDatasourceWritePresenceAndDiagnosticSecrets(t *testing.T) {
for _, action := range []string{"create", "update"} {
for _, present := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/present=%v", action, present), func(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/monit/datasource/"+action {
t.Errorf("wrong path: %s", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
var input map[string]json.RawMessage
if err := json.Unmarshal(body, &input); err != nil {
t.Fatal(err)
}
for _, key := range []string{"enabled", "alerting_enabled"} {
value, ok := input[key]
if ok != present || (present && string(value) != "false") {
t.Errorf("%s presence lost: %s", key, body)
}
}
var payload map[string]map[string]json.RawMessage
if err := json.Unmarshal(input["payload"], &payload); err != nil {
t.Fatal(err)
}
password, ok := payload["redis_node"]["password"]
if ok != present || (present && string(password) != `""`) {
t.Errorf("secret presence lost: %s", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"request_id":"write","data":{"id":42,"enabled":false,"alerting_enabled":false,"type_ident":"redis_node","payload":{"redis_node":{"database":0}}}}`)
})
var req DataSourceUpsertRequest
raw := `{"id":42,"name":"cache","type_ident":"redis_node","edge_cluster_name":"default","address":"redis.example.com:6379","payload":{"redis_node":{"database":0}}}`
if present {
raw = `{"id":42,"name":"cache","type_ident":"redis_node","edge_cluster_name":"default","address":"redis.example.com:6379","enabled":false,"alerting_enabled":false,"payload":{"redis_node":{"database":0,"password":""}}}`
}
if err := json.Unmarshal([]byte(raw), &req); err != nil {
t.Fatal(err)
}
var result *DataSourceItem
var err error
if action == "create" {
result, _, err = client.DataSources.WriteCreate(context.Background(), &req)
} else {
result, _, err = client.DataSources.WriteUpdate(context.Background(), &req)
}
if err != nil {
t.Fatal(err)
}
encoded, _ := json.Marshal(result)
if !strings.Contains(string(encoded), `"enabled":false`) || !strings.Contains(string(encoded), `"alerting_enabled":false`) {
t.Fatalf("false response fields lost: %s", encoded)
}
})
}
}
}

func TestErrorResponseReasonText(t *testing.T) {
for _, tc := range []struct{ code, reason, want string }{
{"InvalidParameter", "", "flashduty: unavailable (code InvalidParameter, http 503, request_id trace-error)"},
{"", "", "flashduty: unavailable (http 503, request_id trace-error)"},
{"", "edge_upgrade_required", "flashduty: unavailable (http 503, reason edge_upgrade_required, request_id trace-error)"},
} {
err := &ErrorResponse{Response: &http.Response{StatusCode: 503}, Code: tc.code, Reason: tc.reason, Message: "unavailable", RequestID: "trace-error"}
if got := err.Error(); got != tc.want {
t.Errorf("Error() = %q, want %q", got, tc.want)
}
}
}
8 changes: 5 additions & 3 deletions diagnostics.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 15 additions & 2 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,19 @@ import (
// envelope's "error" field.
type DutyError struct {
Code string `json:"code"`
Reason string `json:"reason,omitempty"`
Message string `json:"message"`
}

func (e *DutyError) Error() string { return fmt.Sprintf("%s: %s", e.Code, e.Message) }

func (e *DutyError) reasonOrEmpty() string {
if e == nil {
return ""
}
return e.Reason
}

func (e *DutyError) codeOr(d string) string {
if e == nil {
return d
Expand All @@ -36,6 +44,7 @@ func (e *DutyError) errMessageOr(d string) string {
type ErrorResponse struct {
Response *http.Response `json:"-"`
Code string `json:"code"`
Reason string `json:"reason,omitempty"`
Message string `json:"message"`
RequestID string `json:"request_id"`
}
Expand All @@ -45,10 +54,14 @@ func (e *ErrorResponse) Error() string {
if e.Response != nil {
status = e.Response.StatusCode
}
reason := ""
if e.Reason != "" {
reason = ", reason " + e.Reason
}
if e.Code != "" {
return fmt.Sprintf("flashduty: %s (code %s, http %d, request_id %s)", e.Message, e.Code, status, e.RequestID)
return fmt.Sprintf("flashduty: %s (code %s, http %d%s, request_id %s)", e.Message, e.Code, status, reason, e.RequestID)
}
return fmt.Sprintf("flashduty: %s (http %d, request_id %s)", e.Message, status, e.RequestID)
return fmt.Sprintf("flashduty: %s (http %d%s, request_id %s)", e.Message, status, reason, e.RequestID)
}

// RateLimitError is returned when the API responds 429. It embeds the standard
Expand Down
4 changes: 2 additions & 2 deletions flashduty.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,11 @@ func (c *Client) processResponse(httpResp *http.Response, out any) (*Response, e
}

if env.Error != nil && isFailureCode(env.Error.Code) {
apiErr := &ErrorResponse{Response: httpResp, Code: env.Error.Code, Message: env.Error.Message, RequestID: resp.RequestID}
apiErr := &ErrorResponse{Response: httpResp, Code: env.Error.Code, Reason: env.Error.Reason, Message: env.Error.Message, RequestID: resp.RequestID}
return resp, asAPIError(apiErr, resp.RateLimit)
}
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
apiErr := &ErrorResponse{Response: httpResp, Code: env.Error.codeOr(""), Message: env.Error.errMessageOr(string(raw)), RequestID: resp.RequestID}
apiErr := &ErrorResponse{Response: httpResp, Code: env.Error.codeOr(""), Reason: env.Error.reasonOrEmpty(), Message: env.Error.errMessageOr(string(raw)), RequestID: resp.RequestID}
return resp, asAPIError(apiErr, resp.RateLimit)
}

Expand Down
12 changes: 10 additions & 2 deletions internal/cmd/gen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,9 @@ func (g *Gen) emitModels() string {
structs.WriteString(g.emitStruct(n, schema))
}

if strings.Contains(structs.String(), "json.RawMessage") {
b.WriteString("import \"encoding/json\"\n\n")
}
b.WriteString(enumsAndAliases.String())
b.WriteString(structs.String())
b.WriteString(g.emitGetRequests())
Expand Down Expand Up @@ -814,13 +817,14 @@ func (g *Gen) emitStruct(name string, s map[string]any) string {
preserveAbsence, _ := pv["x-flashduty-preserve-absence"].(bool)
isOptionalResponseField := isOptionalUnionField || (!inReq && preserveAbsence)
needsPointer = needsPointer || (isOptionalResponseField && (pointerizableScalar(gt) || isStructField || strings.HasPrefix(gt, "[]") || strings.HasPrefix(gt, "map[")))
// A request object marked x-flashduty-preserve-absence branches
// A request field marked x-flashduty-preserve-absence branches
// server-side on the key's presence (e.g. /rum/application/update
// leaves an omitted alerting/links container untouched but replaces
// the stored config when the object is present). A bare struct with
// `,omitzero` cannot put an all-zero object on the wire, so emit a
// pointer: nil stays absent, a non-nil pointer always serializes.
needsPointer = needsPointer || (inReq && !required[k] && preserveAbsence && isStructField)
// Scalars use the same rule so false and empty secrets stay explicit.
needsPointer = needsPointer || (inReq && !required[k] && preserveAbsence && (isStructField || pointerizableScalar(gt)))
if needsPointer {
gt = "*" + gt
}
Expand Down Expand Up @@ -881,6 +885,10 @@ func (g *Gen) emitStruct(name string, s map[string]any) string {
// goTypeOf resolves a schema map to a Go type string, synthesizing nested
// struct types (queued for emission) as needed.
func (g *Gen) goTypeOf(s map[string]any, hint string) string {
// Preserve arbitrary tool JSON, including integer and decimal precision.
if raw, _ := s["x-flashduty-raw-json"].(bool); raw {
return "json.RawMessage"
}
if s == nil {
return "any"
}
Expand Down
Loading