diff --git a/internal/graphql/content_type_test.go b/internal/graphql/content_type_test.go new file mode 100644 index 0000000..2eefab2 --- /dev/null +++ b/internal/graphql/content_type_test.go @@ -0,0 +1,34 @@ +package graphql + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestExecuteGraphQLDoesNotDuplicateContentType(t *testing.T) { + for _, tc := range []struct { + headers []string + want string + }{ + {nil, "application/json"}, + {[]string{"content-type: application/json; charset=utf-8"}, "application/json; charset=utf-8"}, + } { + received := make(chan []string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received <- r.Header.Values("Content-Type") + _, _ = w.Write([]byte(`{"data":{}}`)) + })) + result, err := ExecuteGraphQL(Options{URL: server.URL, Query: "{ hello }", Headers: tc.headers}) + if err != nil { + server.Close() + t.Fatal(err) + } + result.Response.Body.Close() + server.Close() + values := <-received + if len(values) != 1 || values[0] != tc.want { + t.Errorf("got Content-Type %v, want only %q", values, tc.want) + } + } +} diff --git a/internal/graphql/graphql.go b/internal/graphql/graphql.go index 6ab4f88..fdc18ec 100644 --- a/internal/graphql/graphql.go +++ b/internal/graphql/graphql.go @@ -1,10 +1,12 @@ package graphql import ( + "bytes" "encoding/json" "fmt" "net/http" "os" + "strings" "github.com/kavix/kurl/client" ) @@ -63,7 +65,12 @@ func BuildPayload(queryStr, variablesStr string, introspect bool) ([]byte, error if variablesStr != "" { var vars map[string]interface{} - if err := json.Unmarshal([]byte(variablesStr), &vars); err != nil { + if !json.Valid([]byte(variablesStr)) { + return nil, fmt.Errorf("invalid GraphQL variables JSON") + } + decoder := json.NewDecoder(bytes.NewBufferString(variablesStr)) + decoder.UseNumber() + if err := decoder.Decode(&vars); err != nil { return nil, fmt.Errorf("invalid GraphQL variables JSON: %w", err) } payload.Variables = vars @@ -92,7 +99,18 @@ func ExecuteGraphQL(opts Options) (*client.Result, error) { return nil, err } - headers := append([]string{"Content-Type: application/json"}, opts.Headers...) + headers := append([]string(nil), opts.Headers...) + hasContentType := false + for _, header := range headers { + name, _, ok := strings.Cut(header, ":") + if ok && strings.EqualFold(strings.TrimSpace(name), "Content-Type") { + hasContentType = true + break + } + } + if !hasContentType { + headers = append([]string{"Content-Type: application/json"}, headers...) + } fetchOpts := client.Options{ Method: http.MethodPost, diff --git a/internal/graphql/graphql_test.go b/internal/graphql/graphql_test.go index e381ce6..0aa31e4 100644 --- a/internal/graphql/graphql_test.go +++ b/internal/graphql/graphql_test.go @@ -50,3 +50,34 @@ func TestGenerateQueryForType(t *testing.T) { t.Errorf("expected %q, got %q", expected, query) } } + +func TestBuildPayloadPreservesNumericVariables(t *testing.T) { + payload, err := BuildPayload(`query ($id: ID!) { user(id: $id) { name } }`, `{"id":9007199254740993,"nested":{"ratio":0.1234567890123456789}}`, false) + if err != nil { + t.Fatal(err) + } + var result struct { + Variables json.RawMessage `json:"variables"` + } + if err := json.Unmarshal(payload, &result); err != nil { + t.Fatal(err) + } + var variables map[string]json.RawMessage + if err := json.Unmarshal(result.Variables, &variables); err != nil { + t.Fatal(err) + } + if string(variables["id"]) != `9007199254740993` { + t.Fatalf("numeric ID changed: %s", variables["id"]) + } + if string(variables["nested"]) != `{"ratio":0.1234567890123456789}` { + t.Fatalf("decimal changed: %s", variables["nested"]) + } +} + +func TestBuildPayloadRejectsInvalidVariableObjects(t *testing.T) { + for _, variables := range []string{`{"x":1} {}`, `[]`, `1`, `{`} { + if _, err := BuildPayload(`{ user { name } }`, variables, false); err == nil { + t.Errorf("accepted invalid variables %s", variables) + } + } +}