Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions api/graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,213 @@ func (c *Client) ExecuteGraphQL(query string, variables map[string]interface{})

return &result, nil
}

// GraphQL Introspection types

// IntrospectionType represents a GraphQL type from schema introspection
type IntrospectionType struct {
Kind string `json:"kind"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Fields []IntrospectionField `json:"fields,omitempty"`
InputFields []IntrospectionField `json:"inputFields,omitempty"`
EnumValues []IntrospectionEnumVal `json:"enumValues,omitempty"`
Interfaces []IntrospectionTypeRef `json:"interfaces,omitempty"`
PossibleTypes []IntrospectionTypeRef `json:"possibleTypes,omitempty"`
}

// IntrospectionField represents a field in a GraphQL type
type IntrospectionField struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Type IntrospectionTypeRef `json:"type"`
Args []IntrospectionArg `json:"args,omitempty"`
IsDeprecated bool `json:"isDeprecated,omitempty"`
DeprecationReason string `json:"deprecationReason,omitempty"`
}

// IntrospectionArg represents an argument on a GraphQL field
type IntrospectionArg struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Type IntrospectionTypeRef `json:"type"`
DefaultValue *string `json:"defaultValue,omitempty"`
}

// IntrospectionTypeRef represents a type reference (can be nested for non-null/list)
type IntrospectionTypeRef struct {
Kind string `json:"kind"`
Name *string `json:"name,omitempty"`
OfType *IntrospectionTypeRef `json:"ofType,omitempty"`
}

// IntrospectionEnumVal represents an enum value
type IntrospectionEnumVal struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
IsDeprecated bool `json:"isDeprecated,omitempty"`
DeprecationReason string `json:"deprecationReason,omitempty"`
}

// IntrospectionSchema represents the full schema from introspection
type IntrospectionSchema struct {
QueryType *IntrospectionTypeRef `json:"queryType"`
MutationType *IntrospectionTypeRef `json:"mutationType,omitempty"`
SubscriptionType *IntrospectionTypeRef `json:"subscriptionType,omitempty"`
Types []IntrospectionType `json:"types"`
}

// IntrospectionResponse wraps the schema introspection response
type IntrospectionResponse struct {
Schema IntrospectionSchema `json:"__schema"`
}

// TypeName returns the full type name including wrappers (NonNull, List)
func (t IntrospectionTypeRef) TypeName() string {
if t.Name != nil {
return *t.Name
}
if t.OfType != nil {
inner := t.OfType.TypeName()
switch t.Kind {
case "NON_NULL":
return inner + "!"
case "LIST":
return "[" + inner + "]"
}
return inner
}
return ""
}

// IntrospectSchema fetches the GraphQL schema via introspection
func (c *Client) IntrospectSchema() (*IntrospectionSchema, error) {
query := `
query IntrospectionQuery {
__schema {
queryType { name }
mutationType { name }
subscriptionType { name }
types {
kind
name
description
fields(includeDeprecated: true) {
name
description
args {
name
description
type {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
}
}
}
}
defaultValue
}
type {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
}
}
}
}
isDeprecated
deprecationReason
}
inputFields {
name
description
type {
kind
name
ofType {
kind
name
ofType {
kind
name
}
}
}
defaultValue
}
interfaces {
kind
name
}
enumValues(includeDeprecated: true) {
name
description
isDeprecated
deprecationReason
}
possibleTypes {
kind
name
}
}
}
}`

resp, err := c.ExecuteGraphQL(query, nil)
if err != nil {
return nil, err
}

if resp.HasErrors() {
return nil, fmt.Errorf("introspection failed: %s", resp.ErrorMessages())
}

var result IntrospectionResponse
if err := json.Unmarshal(resp.Data, &result); err != nil {
return nil, fmt.Errorf("failed to parse introspection response: %w", err)
}

return &result.Schema, nil
}

// GetType retrieves a specific type from the schema by name
func (s *IntrospectionSchema) GetType(name string) *IntrospectionType {
for i := range s.Types {
if s.Types[i].Name == name {
return &s.Types[i]
}
}
return nil
}

// GetRootTypes returns the main queryable types (non-internal types)
func (s *IntrospectionSchema) GetRootTypes() []IntrospectionType {
var result []IntrospectionType
for _, t := range s.Types {
// Skip internal types (start with __)
if len(t.Name) > 0 && t.Name[0:1] != "_" {
// Include OBJECT and INTERFACE types that have fields
if (t.Kind == "OBJECT" || t.Kind == "INTERFACE") && len(t.Fields) > 0 {
result = append(result, t)
}
}
}
return result
}
168 changes: 168 additions & 0 deletions api/graphql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,171 @@ func TestGraphQLResponse_ErrorMessages(t *testing.T) {
assert.Equal(t, "First; Second; Third", resp.ErrorMessages())
})
}

func TestClient_IntrospectSchema(t *testing.T) {
t.Run("success", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/collector/graphql", r.URL.Path)
assert.Equal(t, http.MethodPost, r.Method)

w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"data": {
"__schema": {
"queryType": {"name": "Query"},
"mutationType": null,
"subscriptionType": null,
"types": [
{
"kind": "OBJECT",
"name": "CRM",
"description": "CRM root type",
"fields": [
{
"name": "contact_collection",
"description": "List contacts",
"type": {"kind": "OBJECT", "name": "ContactCollection"},
"args": [
{
"name": "limit",
"type": {"kind": "SCALAR", "name": "Int"}
}
]
}
]
},
{
"kind": "OBJECT",
"name": "Contact",
"description": "A CRM contact",
"fields": [
{
"name": "id",
"type": {"kind": "SCALAR", "name": "ID"}
},
{
"name": "email",
"type": {"kind": "SCALAR", "name": "String"}
}
]
}
]
}
}
}`))
}))
defer server.Close()

client := &Client{
BaseURL: server.URL,
AccessToken: "test-token",
HTTPClient: server.Client(),
}

schema, err := client.IntrospectSchema()
require.NoError(t, err)
assert.NotNil(t, schema.QueryType)
assert.Equal(t, "Query", *schema.QueryType.Name)
assert.Len(t, schema.Types, 2)
})

t.Run("graphql error", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"errors": [{"message": "Introspection not allowed"}]
}`))
}))
defer server.Close()

client := &Client{
BaseURL: server.URL,
AccessToken: "test-token",
HTTPClient: server.Client(),
}

schema, err := client.IntrospectSchema()
assert.Error(t, err)
assert.Contains(t, err.Error(), "Introspection not allowed")
assert.Nil(t, schema)
})
}

func TestIntrospectionSchema_GetType(t *testing.T) {
schema := &IntrospectionSchema{
Types: []IntrospectionType{
{Name: "CRM", Kind: "OBJECT"},
{Name: "Contact", Kind: "OBJECT"},
},
}

t.Run("found", func(t *testing.T) {
result := schema.GetType("Contact")
assert.NotNil(t, result)
assert.Equal(t, "Contact", result.Name)
})

t.Run("not found", func(t *testing.T) {
result := schema.GetType("NonExistent")
assert.Nil(t, result)
})
}

func TestIntrospectionSchema_GetRootTypes(t *testing.T) {
schema := &IntrospectionSchema{
Types: []IntrospectionType{
{Name: "CRM", Kind: "OBJECT", Fields: []IntrospectionField{{Name: "contacts"}}},
{Name: "__Schema", Kind: "OBJECT", Fields: []IntrospectionField{{Name: "types"}}},
{Name: "String", Kind: "SCALAR"},
{Name: "Contact", Kind: "OBJECT", Fields: []IntrospectionField{{Name: "id"}}},
},
}

types := schema.GetRootTypes()
assert.Len(t, types, 2) // CRM and Contact, not __Schema (internal) or String (scalar)

names := make([]string, len(types))
for i, typ := range types {
names[i] = typ.Name
}
assert.Contains(t, names, "CRM")
assert.Contains(t, names, "Contact")
}

func TestIntrospectionTypeRef_TypeName(t *testing.T) {
t.Run("simple type", func(t *testing.T) {
name := "String"
ref := IntrospectionTypeRef{Kind: "SCALAR", Name: &name}
assert.Equal(t, "String", ref.TypeName())
})

t.Run("non-null type", func(t *testing.T) {
name := "String"
ref := IntrospectionTypeRef{
Kind: "NON_NULL",
OfType: &IntrospectionTypeRef{Kind: "SCALAR", Name: &name},
}
assert.Equal(t, "String!", ref.TypeName())
})

t.Run("list type", func(t *testing.T) {
name := "Contact"
ref := IntrospectionTypeRef{
Kind: "LIST",
OfType: &IntrospectionTypeRef{Kind: "OBJECT", Name: &name},
}
assert.Equal(t, "[Contact]", ref.TypeName())
})

t.Run("non-null list", func(t *testing.T) {
name := "Contact"
ref := IntrospectionTypeRef{
Kind: "NON_NULL",
OfType: &IntrospectionTypeRef{
Kind: "LIST",
OfType: &IntrospectionTypeRef{Kind: "OBJECT", Name: &name},
},
}
assert.Equal(t, "[Contact]!", ref.TypeName())
})
}
Loading