// githubv4mock package provides a mock GraphQL server used for testing queries produced via // shurcooL/githubv4 or shurcooL/graphql modules. package githubv4mock import ( "encoding/json" "fmt" "io" "net/http" ) type Matcher struct { Request string Variables map[string]any Response GQLResponse } // NewQueryMatcher constructs a new matcher for the provided query and variables. // If the provided query is a string, it will be used-as-is, otherwise it will be // converted to a string using the constructQuery function taken from shurcooL/graphql. func NewQueryMatcher(query any, variables map[string]any, response GQLResponse) Matcher { queryString, ok := query.(string) if !ok { queryString = constructQuery(query, variables) } return Matcher{ Request: queryString, Variables: variables, Response: response, } } // NewMutationMatcher constructs a new matcher for the provided mutation and variables. // If the provided mutation is a string, it will be used-as-is, otherwise it will be // converted to a string using the constructMutation function taken from shurcooL/graphql. // // The input parameter is a special form of variable, matching the usage in shurcooL/githubv4. It will be added // to the query as a variable called `input`. Furthermore, it will be converted to a map[string]any // to be used for later equality comparison, as when the http handler is called, the request body will no longer // contain the input struct type information. func NewMutationMatcher(mutation any, input any, variables map[string]any, response GQLResponse) Matcher { mutationString, ok := mutation.(string) if !ok { // Matching shurcooL/githubv4 mutation behaviour found in https://github.com/shurcooL/githubv4/blob/48295856cce734663ddbd790ff54800f784f3193/githubv4.go#L45-L56 if variables == nil { variables = map[string]any{"input": input} } else { variables["input"] = input } mutationString = constructMutation(mutation, variables) m, _ := githubv4InputStructToMap(input) variables["input"] = m } return Matcher{ Request: mutationString, Variables: variables, Response: response, } } type GQLResponse struct { Data map[string]any `json:"data"` Errors []struct { Message string `json:"message"` } `json:"errors,omitempty"` } // DataResponse is the happy path response constructor for a mocked GraphQL request. func DataResponse(data map[string]any) GQLResponse { return GQLResponse{ Data: data, } } // ErrorResponse is the unhappy path response constructor for a mocked GraphQL request.\ // Note that for the moment it is only possible to return a single error message. func ErrorResponse(errorMsg string) GQLResponse { return GQLResponse{ Errors: []struct { Message string `json:"message"` }{ { Message: errorMsg, }, }, } } // githubv4InputStructToMap converts a struct to a map[string]any, it uses JSON marshalling rather than reflection // to do so, because the json struct tags are used in the real implementation to produce the variable key names, // and we need to ensure that when variable matching occurs in the http handler, the keys correctly match. func githubv4InputStructToMap(s any) (map[string]any, error) { jsonBytes, err := json.Marshal(s) if err != nil { return nil, err } var result map[string]any err = json.Unmarshal(jsonBytes, &result) return result, err } // NewMockedHTTPClient creates a new HTTP client that registers a handler for /graphql POST requests. // For each request, an attempt will be be made to match the request body against the provided matchers. // If a match is found, the corresponding response will be returned with StatusOK. // // Note that query and variable matching can be slightly fickle. The client expects an EXACT match on the query, // which in most cases will have been constructed from a type with graphql tags. The query construction code in // shurcooL/githubv4 uses the field types to derive the query string, thus a go string is not the same as a graphql.ID, // even though `type ID string`. It is therefore expected that matching variables have the right type for example: // // githubv4mock.NewQueryMatcher( // struct { // Repository struct { // PullRequest struct { // ID githubv4.ID // } `graphql:"pullRequest(number: $prNum)"` // } `graphql:"repository(owner: $owner, name: $repo)"` // }{}, // map[string]any{ // "owner": githubv4.String("owner"), // "repo": githubv4.String("repo"), // "prNum": githubv4.Int(42), // }, // githubv4mock.DataResponse( // map[string]any{ // "repository": map[string]any{ // "pullRequest": map[string]any{ // "id": "PR_kwDODKw3uc6WYN1T", // }, // }, // }, // ), // ) // // To aid in variable equality checks, values are considered equal if they approximate to the same type. This is // required because when the http handler is called, the request body no longer has the type information. This manifests // particularly when using the githubv4.Input types which have type deffed fields in their structs. For example: // // type CloseIssueInput struct { // IssueID ID `json:"issueId"` // StateReason *IssueClosedStateReason `json:"stateReason,omitempty"` // } // // This client does not currently provide a mechanism for out-of-band errors e.g. returning a 500, // and errors are constrained to GQL errors returned in the response body with a 200 status code. func NewMockedHTTPClient(ms ...Matcher) *http.Client { matchers := make(map[string]Matcher, len(ms)) for _, m := range ms { matchers[m.Request] = m } mux := http.NewServeMux() mux.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } gqlRequest, err := parseBody(r.Body) if err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } defer func() { _ = r.Body.Close() }() matcher, ok := matchers[gqlRequest.Query] if !ok { http.Error(w, fmt.Sprintf("no matcher found for query %s", gqlRequest.Query), http.StatusNotFound) return } if len(gqlRequest.Variables) > 0 { if len(gqlRequest.Variables) != len(matcher.Variables) { http.Error(w, "variables do not have the same length", http.StatusBadRequest) return } for k, v := range matcher.Variables { if !objectsAreEqualValues(v, gqlRequest.Variables[k]) { http.Error(w, "variable does not match", http.StatusBadRequest) return } } } responseBody, err := json.Marshal(matcher.Response) if err != nil { http.Error(w, "error marshalling response", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write(responseBody) }) return &http.Client{Transport: &localRoundTripper{ handler: mux, }} } type gqlRequest struct { Query string `json:"query"` Variables map[string]any `json:"variables,omitempty"` } func parseBody(r io.Reader) (gqlRequest, error) { var req gqlRequest err := json.NewDecoder(r).Decode(&req) return req, err } func Ptr[T any](v T) *T { return &v }