Skip to content

Commit ab78cab

Browse files
authored
Merge pull request #104 from stackql/claude/issue-103-20260528-2008
graphql: emit wire request + raw response under --http.log.enabled; fix typos
2 parents 334b674 + e505923 commit ab78cab

2 files changed

Lines changed: 127 additions & 2 deletions

File tree

pkg/graphql/graphql.go

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package graphql
22

33
import (
44
"bytes"
5+
"context"
56
"encoding/json"
67
"fmt"
78
"io"
@@ -15,6 +16,36 @@ import (
1516
"github.com/stackql/any-sdk/pkg/stream_transform"
1617
)
1718

19+
// httpLoggerCtxKey is the context key under which an optional io.Writer is
20+
// attached so the GraphQL reader can emit the wire request body and the raw
21+
// pre-transform response. Mirrors the REST acquire path which writes the same
22+
// shape of lines to runtimeCtx.outErrFile when --http.log.enabled is set.
23+
type httpLoggerCtxKey struct{}
24+
25+
// ContextWithHTTPLogger returns a derived context that carries w as the sink
26+
// for GraphQL wire request / raw response log lines. Consumers (e.g. stackql)
27+
// should attach the same writer they use for REST HTTP logging when
28+
// --http.log.enabled is true. Passing a nil writer is equivalent to not
29+
// attaching one.
30+
func ContextWithHTTPLogger(ctx context.Context, w io.Writer) context.Context {
31+
if w == nil {
32+
return ctx
33+
}
34+
return context.WithValue(ctx, httpLoggerCtxKey{}, w)
35+
}
36+
37+
func httpLoggerFromContext(ctx context.Context) io.Writer {
38+
if ctx == nil {
39+
return nil
40+
}
41+
v := ctx.Value(httpLoggerCtxKey{})
42+
if v == nil {
43+
return nil
44+
}
45+
w, _ := v.(io.Writer)
46+
return w
47+
}
48+
1849
var (
1950
_ template.ExecError = template.ExecError{}
2051
)
@@ -298,6 +329,14 @@ func (gq *StandardGQLReader) Read() ([]map[string]interface{}, error) {
298329
req.Body = rb
299330
req.URL.RawQuery = ""
300331
req.Header.Set("Content-Type", "application/json")
332+
if logger := httpLoggerFromContext(req.Context()); logger != nil {
333+
bodyBytes, readErr := io.ReadAll(req.Body)
334+
if readErr == nil {
335+
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
336+
fmt.Fprintf(logger, "http request url: '%s', method: '%s'\n", req.URL.String(), req.Method)
337+
fmt.Fprintf(logger, "http request body = '%s'\n", string(bodyBytes))
338+
}
339+
}
301340
r, err := gq.anySdkClient.Do(
302341
newAnySdkGraphQLHTTPDesignation(req.URL),
303342
newGraphqlAnySdkArgList(newAnySdkHTTPArg(req)),
@@ -309,6 +348,13 @@ func (gq *StandardGQLReader) Read() ([]map[string]interface{}, error) {
309348
if httpResponseErr != nil {
310349
return nil, httpResponseErr
311350
}
351+
if logger := httpLoggerFromContext(req.Context()); logger != nil && httpResponse != nil && httpResponse.Body != nil {
352+
respBytes, readErr := io.ReadAll(httpResponse.Body)
353+
if readErr == nil {
354+
httpResponse.Body = io.NopCloser(bytes.NewReader(respBytes))
355+
fmt.Fprintf(logger, "%s\n", string(respBytes))
356+
}
357+
}
312358
gq.pageCount++
313359
var target map[string]interface{}
314360
err = json.NewDecoder(httpResponse.Body).Decode(&target)
@@ -340,11 +386,11 @@ func (gq *StandardGQLReader) Read() ([]map[string]interface{}, error) {
340386
case map[string]interface{}:
341387
rv = append(rv, v)
342388
default:
343-
return nil, fmt.Errorf("cannot accomodate GraphQL pocessed response item of type = '%T'", v)
389+
return nil, fmt.Errorf("cannot accommodate GraphQL processed response item of type = '%T'", v)
344390
}
345391
}
346392
default:
347-
return nil, fmt.Errorf("cannot accomodate GraphQL pocessed response of type = '%T'", pr)
393+
return nil, fmt.Errorf("cannot accommodate GraphQL processed response of type = '%T'", pr)
348394
}
349395
gq.rowsReturned += len(rv)
350396
if returnErr == nil {

pkg/graphql/graphql_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package graphql
22

33
import (
44
"bytes"
5+
"context"
56
"io"
67
"net/http"
78
"net/url"
@@ -590,6 +591,84 @@ func TestRead_GraphQLErrorWithoutMessage_FallsBackToJSON(t *testing.T) {
590591
}
591592
}
592593

594+
// TestRead_EmitsRequestBodyToHTTPLogWhenEnabled asserts that when a context
595+
// logger is attached, the rendered GraphQL request body and wire URL are
596+
// surfaced before the Do() call — closing the gap where --http.log.enabled
597+
// previously only showed the post-transform projection.
598+
func TestRead_EmitsRequestBodyToHTTPLogWhenEnabled(t *testing.T) {
599+
var buf bytes.Buffer
600+
c := &fakeAnySdkClient{bodyJSON: `{"data": {"rows": [{"id": 1}]}}`}
601+
req := newTestRequest(t)
602+
req = req.WithContext(ContextWithHTTPLogger(context.Background(), &buf))
603+
604+
r, err := NewStandardGQLReader(
605+
c, req, 0, `query { rows { id } }`, map[string]interface{}{}, "",
606+
"$.data.rows[*]", "$.data.__no_cursor[*]",
607+
)
608+
if err != nil {
609+
t.Fatalf("NewStandardGQLReader: %v", err)
610+
}
611+
if _, err := r.Read(); err != nil && err != io.EOF {
612+
t.Fatalf("Read: %v", err)
613+
}
614+
615+
out := buf.String()
616+
if !strings.Contains(out, "query { rows { id } }") {
617+
t.Errorf("expected rendered request body in log, got:\n%s", out)
618+
}
619+
if !strings.Contains(out, "https://api.example.test/graphql") {
620+
t.Errorf("expected wire URL in log, got:\n%s", out)
621+
}
622+
}
623+
624+
// TestRead_EmitsRawResponseToHTTPLogWhenEnabled asserts that the naked
625+
// pre-transform response body is surfaced when a context logger is attached.
626+
// This is the diagnostic that was missing for transform / templating failures.
627+
func TestRead_EmitsRawResponseToHTTPLogWhenEnabled(t *testing.T) {
628+
var buf bytes.Buffer
629+
c := &fakeAnySdkClient{bodyJSON: `{"data":{"rows":[{"id":1}]}}`}
630+
req := newTestRequest(t)
631+
req = req.WithContext(ContextWithHTTPLogger(context.Background(), &buf))
632+
633+
r, err := NewStandardGQLReader(
634+
c, req, 0, `{ ignored }`, map[string]interface{}{}, "",
635+
"$.data.rows[*]", "$.data.__no_cursor[*]",
636+
)
637+
if err != nil {
638+
t.Fatalf("NewStandardGQLReader: %v", err)
639+
}
640+
if _, err := r.Read(); err != nil && err != io.EOF {
641+
t.Fatalf("Read: %v", err)
642+
}
643+
644+
out := buf.String()
645+
if !strings.Contains(out, `"id":1`) {
646+
t.Errorf("expected raw response body in log, got:\n%s", out)
647+
}
648+
}
649+
650+
// TestRead_DoesNotLogWhenHTTPLogDisabled asserts that with no logger attached
651+
// to the request context, Read() emits nothing — the opt-in shape mirrors the
652+
// REST acquire path's gating on runtimeCtx.HTTPLogEnabled.
653+
func TestRead_DoesNotLogWhenHTTPLogDisabled(t *testing.T) {
654+
c := &fakeAnySdkClient{bodyJSON: `{"data":{"rows":[]}}`}
655+
req := newTestRequest(t) // no logger in context
656+
657+
r, err := NewStandardGQLReader(
658+
c, req, 0, `{ ignored }`, map[string]interface{}{}, "",
659+
"$.data.rows[*]", "$.data.__no_cursor[*]",
660+
)
661+
if err != nil {
662+
t.Fatalf("NewStandardGQLReader: %v", err)
663+
}
664+
if _, err := r.Read(); err != nil && err != io.EOF {
665+
t.Fatalf("Read: %v", err)
666+
}
667+
// nothing to assert beyond "no panic and no log sink to fill" — the
668+
// negative case is covered by the structural check that nil-logger
669+
// branches in Read() are short-circuit.
670+
}
671+
593672
// TestNewStandardGQLReaderWithCursor_KeysetRequiresFormat ensures a keyset
594673
// configuration without a format template is rejected at construction time.
595674
func TestNewStandardGQLReaderWithCursor_KeysetRequiresFormat(t *testing.T) {

0 commit comments

Comments
 (0)