diff --git a/main.go b/main.go index bb9e4e7..8dcc202 100644 --- a/main.go +++ b/main.go @@ -1363,7 +1363,7 @@ func addOutputFlag() { dciCmd.PersistentFlags().IntP("table-max-col-width", "X", 0, "Maximum width per column when fitting or wrapping (0 = auto)") dciCmd.PersistentFlags().StringP("customer-context", "D", "", "Override the active customer context for this command (e.g. acme.com)") dciCmd.PersistentFlags().String("fields", "", "Comma-separated response fields to include") - dciCmd.PersistentFlags().String("exclude", "", "Comma-separated response fields to exclude") + dciCmd.PersistentFlags().String("exclude", "", "Comma-separated top-level fields to exclude from response items or wrappers") dciCmd.PersistentFlags().Bool("full", false, "Return the full response without agent-oriented truncation") dciCmd.PersistentFlags().Bool("no-truncate", false, "Disable long-value truncation") dciCmd.PersistentFlags().Bool("yes", false, "Confirm a destructive operation") diff --git a/output_contract.go b/output_contract.go index f67fe36..2916c8f 100644 --- a/output_contract.go +++ b/output_contract.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "strings" "github.com/rest-sh/restish/cli" @@ -13,7 +14,18 @@ func shapeResponseBody(body interface{}) interface{} { fields := commaSeparatedValues(viper.GetString("agent-fields")) excluded := commaSeparatedValues(viper.GetString("agent-exclude")) if len(fields) > 0 { - body = projectResponseValue(body, fields) + matchedFields := make(map[string]bool, len(fields)) + var hasComparableRows bool + body, hasComparableRows = projectResponseValue(body, fields, matchedFields) + missingFields := make([]string, 0, len(fields)) + for _, field := range fields { + if !matchedFields[field] { + missingFields = append(missingFields, field) + } + } + if hasComparableRows && len(missingFields) > 0 && cli.Stderr != nil { + fmt.Fprintf(cli.Stderr, "warning: requested fields not present in the response: %s\n", strings.Join(missingFields, ", ")) + } } if len(excluded) > 0 { body = excludeResponseValue(body, excluded) @@ -58,34 +70,38 @@ func commaSeparatedValues(value string) []string { return result } -func projectResponseValue(value interface{}, fields []string) interface{} { +func projectResponseValue(value interface{}, fields []string, matchedFields map[string]bool) (interface{}, bool) { switch item := value.(type) { case []interface{}: - return projectRows(item, fields) + return projectRows(item, fields, matchedFields) case map[string]interface{}: - if result, ok := projectNestedRows(item, fields); ok { - return result + if result, hasComparableRows, ok := projectNestedRows(item, fields, matchedFields); ok { + return result, hasComparableRows } if key, rows, ok := listWrapperRows(item); ok { result := copyObject(item) - result[key] = projectRows(rows, fields) - return result + projectedRows, hasComparableRows := projectRows(rows, fields, matchedFields) + result[key] = projectedRows + return result, hasComparableRows } - return projectObject(item, fields) + return projectObject(item, fields, matchedFields) default: - return value + return value, false } } -func projectRows(rows []interface{}, fields []string) []interface{} { +func projectRows(rows []interface{}, fields []string, matchedFields map[string]bool) ([]interface{}, bool) { result := make([]interface{}, len(rows)) + hasComparableRows := false for index, row := range rows { - result[index] = projectObject(row, fields) + var rowComparable bool + result[index], rowComparable = projectObject(row, fields, matchedFields) + hasComparableRows = hasComparableRows || rowComparable } - return result + return result, hasComparableRows } -func projectNestedRows(root map[string]interface{}, fields []string) (map[string]interface{}, bool) { +func projectNestedRows(root map[string]interface{}, fields []string, matchedFields map[string]bool) (map[string]interface{}, bool, bool) { for _, key := range []string{"result", "results"} { container, ok := root[key].(map[string]interface{}) if !ok { @@ -97,27 +113,32 @@ func projectNestedRows(root map[string]interface{}, fields []string) (map[string } result := copyObject(root) projectedContainer := copyObject(container) - projectedContainer["rows"] = projectSchemaRows(rows, readReportSchemaColumnNames(container["schema"]), fields) + projectedRows, hasComparableRows := projectSchemaRows(rows, readReportSchemaColumnNames(container["schema"]), fields, matchedFields) + projectedContainer["rows"] = projectedRows result[key] = projectedContainer - return result, true + return result, hasComparableRows, true } - return nil, false + return nil, false, false } -func projectSchemaRows(rows []interface{}, schema []string, fields []string) []interface{} { +func projectSchemaRows(rows []interface{}, schema []string, fields []string, matchedFields map[string]bool) ([]interface{}, bool) { result := make([]interface{}, len(rows)) + hasComparableRows := false for index, row := range rows { if cells, ok := row.([]interface{}); ok { object := make(map[string]interface{}, len(cells)) for cellIndex, cell := range cells { object[reportColumnName(schema, cellIndex)] = cell } - result[index] = projectObject(object, fields) + result[index], _ = projectObject(object, fields, matchedFields) + hasComparableRows = true continue } - result[index] = projectObject(row, fields) + var rowComparable bool + result[index], rowComparable = projectObject(row, fields, matchedFields) + hasComparableRows = hasComparableRows || rowComparable } - return result + return result, hasComparableRows } func listWrapperRows(object map[string]interface{}) (string, []interface{}, bool) { @@ -163,18 +184,19 @@ func copyObject(object map[string]interface{}) map[string]interface{} { return result } -func projectObject(value interface{}, fields []string) interface{} { +func projectObject(value interface{}, fields []string, matchedFields map[string]bool) (interface{}, bool) { object, ok := value.(map[string]interface{}) if !ok { - return value + return value, false } result := make(map[string]interface{}, len(fields)) for _, field := range fields { if child, exists := object[field]; exists { result[field] = child + matchedFields[field] = true } } - return result + return result, len(object) > 0 } func excludeResponseValue(value interface{}, excluded []string) interface{} { @@ -182,34 +204,111 @@ func excludeResponseValue(value interface{}, excluded []string) interface{} { for _, field := range excluded { excludedSet[field] = true } - return transformResponseObjects(value, func(object map[string]interface{}) map[string]interface{} { - result := make(map[string]interface{}, len(object)) - for key, child := range object { + switch item := value.(type) { + case []interface{}: + return excludeRows(item, excludedSet) + case map[string]interface{}: + if result, ok := excludeNestedRows(item, excludedSet); ok { + return result + } + if key, rows, ok := listWrapperRows(item); ok { + result := excludeObject(item, excludedSet).(map[string]interface{}) if !excludedSet[key] { - result[key] = child + result[key] = excludeRows(rows, excludedSet) } + return result } - return result - }) + return excludeObject(item, excludedSet) + default: + return value + } } -func transformResponseObjects(value interface{}, transform func(map[string]interface{}) map[string]interface{}) interface{} { - switch item := value.(type) { - case []interface{}: - result := make([]interface{}, len(item)) - for index, child := range item { - result[index] = transformResponseObjects(child, transform) +func excludeNestedRows(root map[string]interface{}, excluded map[string]bool) (map[string]interface{}, bool) { + for _, key := range []string{"result", "results"} { + container, ok := root[key].(map[string]interface{}) + if !ok { + continue } - return result - case map[string]interface{}: - result := make(map[string]interface{}, len(item)) - for key, child := range item { - result[key] = transformResponseObjects(child, transform) + rows, ok := container["rows"].([]interface{}) + if !ok { + continue } - return transform(result) - default: + result := excludeObject(root, excluded).(map[string]interface{}) + if excluded[key] { + return result, true + } + filteredContainer := excludeObject(container, excluded).(map[string]interface{}) + if !excluded["rows"] { + filteredRows, filteredSchema := excludeReportRows(rows, container["schema"], excluded) + filteredContainer["rows"] = filteredRows + if _, hasSchema := container["schema"]; hasSchema && !excluded["schema"] { + filteredContainer["schema"] = filteredSchema + } + } + result[key] = filteredContainer + return result, true + } + return nil, false +} + +func excludeReportRows(rows []interface{}, schemaValue interface{}, excluded map[string]bool) ([]interface{}, interface{}) { + schema, ok := schemaValue.([]interface{}) + columnNames := readReportSchemaColumnNames(schemaValue) + if !ok || len(columnNames) == 0 { + return excludeRows(rows, excluded), schemaValue + } + + keptIndexes := make([]int, 0, len(columnNames)) + filteredSchema := make([]interface{}, 0, len(schema)) + for index, columnName := range columnNames { + if excluded[columnName] { + continue + } + keptIndexes = append(keptIndexes, index) + if index < len(schema) { + filteredSchema = append(filteredSchema, schema[index]) + } + } + + filteredRows := make([]interface{}, len(rows)) + for rowIndex, row := range rows { + cells, ok := row.([]interface{}) + if !ok { + filteredRows[rowIndex] = excludeObject(row, excluded) + continue + } + filteredCells := make([]interface{}, 0, len(keptIndexes)) + for _, cellIndex := range keptIndexes { + if cellIndex < len(cells) { + filteredCells = append(filteredCells, cells[cellIndex]) + } + } + filteredRows[rowIndex] = filteredCells + } + return filteredRows, filteredSchema +} + +func excludeRows(rows []interface{}, excluded map[string]bool) []interface{} { + result := make([]interface{}, len(rows)) + for index, row := range rows { + result[index] = excludeObject(row, excluded) + } + return result +} + +func excludeObject(value interface{}, excluded map[string]bool) interface{} { + object, ok := value.(map[string]interface{}) + if !ok { return value } + result := make(map[string]interface{}, len(object)) + for key, child := range object { + if !excluded[key] { + result[key] = child + } + } + return result } func truncateResponseValue(value interface{}, limit int) interface{} { diff --git a/output_contract_test.go b/output_contract_test.go index f1cc6cc..6871192 100644 --- a/output_contract_test.go +++ b/output_contract_test.go @@ -52,6 +52,162 @@ func TestShapeResponseBodyProjectsExcludesAndTruncates(t *testing.T) { } } +func TestExcludeHonorsWrapperFieldsWithoutRecursingIntoNestedObjects(t *testing.T) { + viper.Set("agent-exclude", "rowCount,amount") + t.Cleanup(viper.Reset) + + input := map[string]interface{}{ + "budgets": []interface{}{ + map[string]interface{}{ + "id": "budget-1", + "amount": 1000, + "alertThresholds": []interface{}{ + map[string]interface{}{"amount": 900, "percentage": 90}, + }, + }, + }, + "rowCount": 1, + } + + shaped := shapeResponseBody(input).(map[string]interface{}) + if _, exists := shaped["rowCount"]; exists { + t.Fatal("explicitly excluded rowCount remains") + } + row := shaped["budgets"].([]interface{})[0].(map[string]interface{}) + if _, exists := row["amount"]; exists { + t.Fatal("top-level row amount remains") + } + threshold := row["alertThresholds"].([]interface{})[0].(map[string]interface{}) + if threshold["amount"] != 900 { + t.Fatalf("nested amount = %#v", threshold["amount"]) + } +} + +func TestExcludeHonorsListWrapperKey(t *testing.T) { + viper.Set("agent-exclude", "budgets") + t.Cleanup(viper.Reset) + + input := map[string]interface{}{ + "budgets": []interface{}{map[string]interface{}{"id": "budget-1"}}, + "rowCount": 1, + } + shaped := shapeResponseBody(input).(map[string]interface{}) + if _, exists := shaped["budgets"]; exists { + t.Fatal("explicitly excluded list wrapper remains") + } + if shaped["rowCount"] != 1 { + t.Fatalf("rowCount = %#v", shaped["rowCount"]) + } +} + +func TestExcludeFiltersReportSchemaAndPositionalRows(t *testing.T) { + viper.Set("agent-exclude", "cost") + t.Cleanup(viper.Reset) + + input := map[string]interface{}{ + "result": map[string]interface{}{ + "schema": []interface{}{ + map[string]interface{}{"name": "service"}, + map[string]interface{}{"name": "cost"}, + }, + "rows": []interface{}{[]interface{}{"BigQuery", 123.45}}, + }, + } + shaped := shapeResponseBody(input).(map[string]interface{}) + result := shaped["result"].(map[string]interface{}) + wantSchema := []interface{}{map[string]interface{}{"name": "service"}} + if !reflect.DeepEqual(result["schema"], wantSchema) { + t.Fatalf("schema = %#v, want %#v", result["schema"], wantSchema) + } + wantRows := []interface{}{[]interface{}{"BigQuery"}} + if !reflect.DeepEqual(result["rows"], wantRows) { + t.Fatalf("rows = %#v, want %#v", result["rows"], wantRows) + } +} + +func TestExcludeHonorsReportContainerFields(t *testing.T) { + viper.Set("agent-exclude", "schema") + t.Cleanup(viper.Reset) + + input := map[string]interface{}{ + "result": map[string]interface{}{ + "schema": []interface{}{map[string]interface{}{"name": "service"}}, + "rows": []interface{}{[]interface{}{"BigQuery"}}, + }, + } + result := shapeResponseBody(input).(map[string]interface{})["result"].(map[string]interface{}) + if _, exists := result["schema"]; exists { + t.Fatal("explicitly excluded schema remains") + } + if _, exists := result["rows"]; !exists { + t.Fatal("rows were removed with schema") + } +} + +func TestUnknownProjectionFieldsWriteWarning(t *testing.T) { + viper.Set("agent-fields", "id,nosuchfield") + oldStderr := cli.Stderr + var stderr bytes.Buffer + cli.Stderr = &stderr + t.Cleanup(func() { + cli.Stderr = oldStderr + viper.Reset() + }) + + input := map[string]interface{}{ + "budgets": []interface{}{map[string]interface{}{"id": "budget-1"}}, + "rowCount": 1, + } + shaped := shapeResponseBody(input).(map[string]interface{}) + if !strings.Contains(stderr.String(), "requested fields not present in the response: nosuchfield") { + t.Fatalf("stderr = %q", stderr.String()) + } + if strings.Contains(stderr.String(), "response: id") { + t.Fatalf("matched field was reported missing: %q", stderr.String()) + } + if shaped["rowCount"] != 1 { + t.Fatalf("rowCount = %#v", shaped["rowCount"]) + } +} + +func TestProjectionWarningUsesObjectReportRowFields(t *testing.T) { + viper.Set("agent-fields", "service") + oldStderr := cli.Stderr + var stderr bytes.Buffer + cli.Stderr = &stderr + t.Cleanup(func() { + cli.Stderr = oldStderr + viper.Reset() + }) + + input := map[string]interface{}{ + "result": map[string]interface{}{ + "schema": []interface{}{map[string]interface{}{"name": "colA"}}, + "rows": []interface{}{map[string]interface{}{"service": "BigQuery"}}, + }, + } + shapeResponseBody(input) + if stderr.Len() != 0 { + t.Fatalf("stderr = %q", stderr.String()) + } +} + +func TestEmptyProjectionDoesNotWarn(t *testing.T) { + viper.Set("agent-fields", "id") + oldStderr := cli.Stderr + var stderr bytes.Buffer + cli.Stderr = &stderr + t.Cleanup(func() { + cli.Stderr = oldStderr + viper.Reset() + }) + + shapeResponseBody([]interface{}{}) + if stderr.Len() != 0 { + t.Fatalf("stderr = %q", stderr.String()) + } +} + func TestShapeResponseBodyDefinitiveEmptyState(t *testing.T) { oldAgentMode := agentMode agentMode = true