Skip to content

Commit c51b2d9

Browse files
scottaddieCopilot
andauthored
Extend JMESPath support to Message methods (#6735)
* Extend JMESPath support to Message methods * Update cli/azd/pkg/input/console.go Co-authored-by: Copilot <[email protected]> * Update cli/azd/pkg/input/console.go Co-authored-by: Copilot <[email protected]> * Update TestAskerConsole_Message_JsonQueryFilter --------- Co-authored-by: Copilot <[email protected]>
1 parent 117ed50 commit c51b2d9

File tree

5 files changed

+183
-3
lines changed

5 files changed

+183
-3
lines changed

cli/azd/pkg/input/console.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,15 @@ func (c *AskerConsole) Message(ctx context.Context, message string) {
217217
if c.formatter != nil && c.formatter.Kind() == output.JsonFormat {
218218
// we call json.Marshal directly, because the formatter marshalls using indentation, and we would prefer
219219
// these objects be written on a single line.
220-
jsonMessage, err := json.Marshal(output.EventForMessage(message))
220+
var obj interface{} = output.EventForMessage(message)
221+
if q, ok := c.formatter.(output.Queryable); ok {
222+
if filtered, err := q.QueryFilter(obj); err == nil {
223+
obj = filtered
224+
} else {
225+
log.Printf("failed to apply query filter in Message: %v", err)
226+
}
227+
}
228+
jsonMessage, err := json.Marshal(obj)
221229
if err != nil {
222230
panic(fmt.Sprintf("Message: unexpected error during marshaling for a valid object: %v", err))
223231
}
@@ -270,8 +278,24 @@ func (c *AskerConsole) MessageUxItem(ctx context.Context, item ux.UxItem) {
270278
if c.formatter != nil && c.formatter.Kind() == output.JsonFormat {
271279
// no need to check the spinner for json format, as the spinner won't start when using json format
272280
// instead, there would be a message about starting spinner
273-
json, _ := json.Marshal(item)
274-
fmt.Fprintln(c.writer, string(json))
281+
var obj interface{} = item
282+
if q, ok := c.formatter.(output.Queryable); ok {
283+
// UxItem.MarshalJSON() produces an EventEnvelope. To apply JMESPath we
284+
// need the unmarshaled structure, so round-trip through JSON first.
285+
if raw, err := json.Marshal(item); err == nil {
286+
var envelope interface{}
287+
if err := json.Unmarshal(raw, &envelope); err == nil {
288+
if filtered, err := q.QueryFilter(envelope); err == nil {
289+
obj = filtered
290+
}
291+
}
292+
}
293+
}
294+
jsonBytes, err := json.Marshal(obj)
295+
if err != nil {
296+
panic(err)
297+
}
298+
fmt.Fprintln(c.writer, string(jsonBytes))
275299
return
276300
}
277301

cli/azd/pkg/input/console_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"time"
1515

1616
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
17+
"github.com/azure/azure-dev/cli/azd/pkg/contracts"
1718
"github.com/azure/azure-dev/cli/azd/pkg/output"
1819
"github.com/stretchr/testify/require"
1920
)
@@ -266,3 +267,105 @@ func newTestExternalPromptServer(handler func(promptOptions) json.RawMessage) *h
266267
_, _ = w.Write(respBody)
267268
}))
268269
}
270+
271+
func TestAskerConsole_Message_JsonQueryFilter(t *testing.T) {
272+
tests := []struct {
273+
name string
274+
query string
275+
assert func(t *testing.T, got string)
276+
}{
277+
{
278+
name: "NoQuery",
279+
query: "",
280+
assert: func(t *testing.T, got string) {
281+
// Unmarshal into the full envelope and verify structure
282+
var env contracts.EventEnvelope
283+
err := json.Unmarshal([]byte(strings.TrimSpace(got)), &env)
284+
require.NoError(t, err, "output should be valid JSON envelope")
285+
require.Equal(t, contracts.ConsoleMessageEventDataType, env.Type)
286+
287+
data, ok := env.Data.(map[string]any)
288+
require.True(t, ok, "Data should be a map, got %T", env.Data)
289+
// EventForMessage appends a trailing newline to the message
290+
require.Equal(t, "hello world\n", data["message"])
291+
},
292+
},
293+
{
294+
name: "QueryDataMessage",
295+
query: "data.message",
296+
assert: func(t *testing.T, got string) {
297+
// Query should extract a bare JSON string, not an object
298+
var s string
299+
err := json.Unmarshal([]byte(strings.TrimSpace(got)), &s)
300+
require.NoError(t, err, "output should unmarshal as a JSON string")
301+
// EventForMessage appends a trailing newline to the message
302+
require.Equal(t, "hello world\n", s)
303+
},
304+
},
305+
{
306+
name: "QueryType",
307+
query: "type",
308+
assert: func(t *testing.T, got string) {
309+
// Query should extract a bare JSON string, not an object
310+
var s string
311+
err := json.Unmarshal([]byte(strings.TrimSpace(got)), &s)
312+
require.NoError(t, err, "output should unmarshal as a JSON string")
313+
require.Equal(t, "consoleMessage", s)
314+
},
315+
},
316+
}
317+
318+
for _, tc := range tests {
319+
t.Run(tc.name, func(t *testing.T) {
320+
buf := &strings.Builder{}
321+
formatter := &output.JsonFormatter{Query: tc.query}
322+
323+
c := NewConsole(
324+
true,
325+
false,
326+
Writers{Output: writerAdapter{buf}},
327+
ConsoleHandles{
328+
Stderr: os.Stderr,
329+
Stdin: os.Stdin,
330+
Stdout: writerAdapter{buf},
331+
},
332+
formatter,
333+
nil,
334+
)
335+
336+
c.Message(context.Background(), "hello world")
337+
338+
tc.assert(t, buf.String())
339+
})
340+
}
341+
}
342+
343+
func TestAskerConsole_Message_InvalidQuery_FallsBack(t *testing.T) {
344+
buf := &strings.Builder{}
345+
formatter := &output.JsonFormatter{Query: "[invalid"}
346+
347+
c := NewConsole(
348+
true,
349+
false,
350+
Writers{Output: writerAdapter{buf}},
351+
ConsoleHandles{
352+
Stderr: os.Stderr,
353+
Stdin: os.Stdin,
354+
Stdout: writerAdapter{buf},
355+
},
356+
formatter,
357+
nil,
358+
)
359+
360+
// Should not panic; falls back to unfiltered output
361+
c.Message(context.Background(), "hello world")
362+
363+
got := buf.String()
364+
require.Contains(t, got, `"consoleMessage"`,
365+
"invalid query should fall back to full envelope")
366+
}
367+
368+
// writerAdapter wraps *strings.Builder to satisfy io.Writer for test purposes.
369+
type writerAdapter struct {
370+
*strings.Builder
371+
}

cli/azd/pkg/output/formatter.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ type Formatter interface {
2222
Format(obj interface{}, writer io.Writer, opts interface{}) error
2323
}
2424

25+
// Queryable is an optional interface that a Formatter may implement to support
26+
// JMESPath filtering on arbitrary objects. Console messages (e.g. error output)
27+
// use this to apply --query without going through the full Format() pipeline.
28+
type Queryable interface {
29+
QueryFilter(obj interface{}) (interface{}, error)
30+
}
31+
2532
func NewFormatter(format string) (Formatter, error) {
2633
switch format {
2734
case string(JsonFormat):

cli/azd/pkg/output/json.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ func (f *JsonFormatter) Format(obj interface{}, writer io.Writer, _ interface{})
5353
}
5454

5555
var _ Formatter = (*JsonFormatter)(nil)
56+
var _ Queryable = (*JsonFormatter)(nil)
57+
58+
// QueryFilter applies the JMESPath query (if any) to the given object.
59+
// When no query is configured, the object is returned unchanged.
60+
func (f *JsonFormatter) QueryFilter(obj interface{}) (interface{}, error) {
61+
if f.Query == "" {
62+
return obj, nil
63+
}
64+
return ApplyQuery(obj, f.Query)
65+
}
5666

5767
// jsonObjectForMessage creates a json object representing a message. Any ANSI control sequences from the message are
5868
// removed. A trailing newline is added to the message.

cli/azd/pkg/output/query_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,39 @@ func TestApplyQuery_DocumentationHintInError(t *testing.T) {
200200
})
201201
}
202202
}
203+
204+
func TestJsonFormatter_QueryFilter(t *testing.T) {
205+
t.Run("NoQuery", func(t *testing.T) {
206+
f := &JsonFormatter{}
207+
data := map[string]interface{}{"name": "test"}
208+
result, err := f.QueryFilter(data)
209+
require.NoError(t, err)
210+
require.Equal(t, data, result)
211+
})
212+
213+
t.Run("WithQuery", func(t *testing.T) {
214+
f := &JsonFormatter{Query: "data.message"}
215+
data := map[string]interface{}{
216+
"type": "consoleMessage",
217+
"data": map[string]interface{}{
218+
"message": "hello world",
219+
},
220+
}
221+
result, err := f.QueryFilter(data)
222+
require.NoError(t, err)
223+
require.Equal(t, "hello world", result)
224+
})
225+
226+
t.Run("InvalidQuery", func(t *testing.T) {
227+
f := &JsonFormatter{Query: "[invalid"}
228+
data := map[string]interface{}{"name": "test"}
229+
_, err := f.QueryFilter(data)
230+
require.Error(t, err)
231+
require.Contains(t, err.Error(), "jmespath.org")
232+
})
233+
}
234+
235+
func TestQueryable_Interface(t *testing.T) {
236+
// Verify JsonFormatter implements Queryable
237+
var _ Queryable = (*JsonFormatter)(nil)
238+
}

0 commit comments

Comments
 (0)