Skip to content

Commit 5dc4042

Browse files
authored
feat: add GraphQL schema explorer command (#38)
Add interactive GraphQL schema exploration using introspection: - api/graphql.go: IntrospectSchema method and introspection types - api/graphql_test.go: Tests for introspection functionality - internal/cmd/graphql/graphql.go: explore command with --type and --field Commands: - hspt graphql explore - list all available types - hspt graphql explore --type CRM - show fields of a type - hspt graphql explore --type CRM --field contact - show field details Closes #27
1 parent 89bb627 commit 5dc4042

3 files changed

Lines changed: 553 additions & 0 deletions

File tree

api/graphql.go

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,213 @@ func (c *Client) ExecuteGraphQL(query string, variables map[string]interface{})
8989

9090
return &result, nil
9191
}
92+
93+
// GraphQL Introspection types
94+
95+
// IntrospectionType represents a GraphQL type from schema introspection
96+
type IntrospectionType struct {
97+
Kind string `json:"kind"`
98+
Name string `json:"name"`
99+
Description string `json:"description,omitempty"`
100+
Fields []IntrospectionField `json:"fields,omitempty"`
101+
InputFields []IntrospectionField `json:"inputFields,omitempty"`
102+
EnumValues []IntrospectionEnumVal `json:"enumValues,omitempty"`
103+
Interfaces []IntrospectionTypeRef `json:"interfaces,omitempty"`
104+
PossibleTypes []IntrospectionTypeRef `json:"possibleTypes,omitempty"`
105+
}
106+
107+
// IntrospectionField represents a field in a GraphQL type
108+
type IntrospectionField struct {
109+
Name string `json:"name"`
110+
Description string `json:"description,omitempty"`
111+
Type IntrospectionTypeRef `json:"type"`
112+
Args []IntrospectionArg `json:"args,omitempty"`
113+
IsDeprecated bool `json:"isDeprecated,omitempty"`
114+
DeprecationReason string `json:"deprecationReason,omitempty"`
115+
}
116+
117+
// IntrospectionArg represents an argument on a GraphQL field
118+
type IntrospectionArg struct {
119+
Name string `json:"name"`
120+
Description string `json:"description,omitempty"`
121+
Type IntrospectionTypeRef `json:"type"`
122+
DefaultValue *string `json:"defaultValue,omitempty"`
123+
}
124+
125+
// IntrospectionTypeRef represents a type reference (can be nested for non-null/list)
126+
type IntrospectionTypeRef struct {
127+
Kind string `json:"kind"`
128+
Name *string `json:"name,omitempty"`
129+
OfType *IntrospectionTypeRef `json:"ofType,omitempty"`
130+
}
131+
132+
// IntrospectionEnumVal represents an enum value
133+
type IntrospectionEnumVal struct {
134+
Name string `json:"name"`
135+
Description string `json:"description,omitempty"`
136+
IsDeprecated bool `json:"isDeprecated,omitempty"`
137+
DeprecationReason string `json:"deprecationReason,omitempty"`
138+
}
139+
140+
// IntrospectionSchema represents the full schema from introspection
141+
type IntrospectionSchema struct {
142+
QueryType *IntrospectionTypeRef `json:"queryType"`
143+
MutationType *IntrospectionTypeRef `json:"mutationType,omitempty"`
144+
SubscriptionType *IntrospectionTypeRef `json:"subscriptionType,omitempty"`
145+
Types []IntrospectionType `json:"types"`
146+
}
147+
148+
// IntrospectionResponse wraps the schema introspection response
149+
type IntrospectionResponse struct {
150+
Schema IntrospectionSchema `json:"__schema"`
151+
}
152+
153+
// TypeName returns the full type name including wrappers (NonNull, List)
154+
func (t IntrospectionTypeRef) TypeName() string {
155+
if t.Name != nil {
156+
return *t.Name
157+
}
158+
if t.OfType != nil {
159+
inner := t.OfType.TypeName()
160+
switch t.Kind {
161+
case "NON_NULL":
162+
return inner + "!"
163+
case "LIST":
164+
return "[" + inner + "]"
165+
}
166+
return inner
167+
}
168+
return ""
169+
}
170+
171+
// IntrospectSchema fetches the GraphQL schema via introspection
172+
func (c *Client) IntrospectSchema() (*IntrospectionSchema, error) {
173+
query := `
174+
query IntrospectionQuery {
175+
__schema {
176+
queryType { name }
177+
mutationType { name }
178+
subscriptionType { name }
179+
types {
180+
kind
181+
name
182+
description
183+
fields(includeDeprecated: true) {
184+
name
185+
description
186+
args {
187+
name
188+
description
189+
type {
190+
kind
191+
name
192+
ofType {
193+
kind
194+
name
195+
ofType {
196+
kind
197+
name
198+
ofType {
199+
kind
200+
name
201+
}
202+
}
203+
}
204+
}
205+
defaultValue
206+
}
207+
type {
208+
kind
209+
name
210+
ofType {
211+
kind
212+
name
213+
ofType {
214+
kind
215+
name
216+
ofType {
217+
kind
218+
name
219+
}
220+
}
221+
}
222+
}
223+
isDeprecated
224+
deprecationReason
225+
}
226+
inputFields {
227+
name
228+
description
229+
type {
230+
kind
231+
name
232+
ofType {
233+
kind
234+
name
235+
ofType {
236+
kind
237+
name
238+
}
239+
}
240+
}
241+
defaultValue
242+
}
243+
interfaces {
244+
kind
245+
name
246+
}
247+
enumValues(includeDeprecated: true) {
248+
name
249+
description
250+
isDeprecated
251+
deprecationReason
252+
}
253+
possibleTypes {
254+
kind
255+
name
256+
}
257+
}
258+
}
259+
}`
260+
261+
resp, err := c.ExecuteGraphQL(query, nil)
262+
if err != nil {
263+
return nil, err
264+
}
265+
266+
if resp.HasErrors() {
267+
return nil, fmt.Errorf("introspection failed: %s", resp.ErrorMessages())
268+
}
269+
270+
var result IntrospectionResponse
271+
if err := json.Unmarshal(resp.Data, &result); err != nil {
272+
return nil, fmt.Errorf("failed to parse introspection response: %w", err)
273+
}
274+
275+
return &result.Schema, nil
276+
}
277+
278+
// GetType retrieves a specific type from the schema by name
279+
func (s *IntrospectionSchema) GetType(name string) *IntrospectionType {
280+
for i := range s.Types {
281+
if s.Types[i].Name == name {
282+
return &s.Types[i]
283+
}
284+
}
285+
return nil
286+
}
287+
288+
// GetRootTypes returns the main queryable types (non-internal types)
289+
func (s *IntrospectionSchema) GetRootTypes() []IntrospectionType {
290+
var result []IntrospectionType
291+
for _, t := range s.Types {
292+
// Skip internal types (start with __)
293+
if len(t.Name) > 0 && t.Name[0:1] != "_" {
294+
// Include OBJECT and INTERFACE types that have fields
295+
if (t.Kind == "OBJECT" || t.Kind == "INTERFACE") && len(t.Fields) > 0 {
296+
result = append(result, t)
297+
}
298+
}
299+
}
300+
return result
301+
}

api/graphql_test.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,171 @@ func TestGraphQLResponse_ErrorMessages(t *testing.T) {
199199
assert.Equal(t, "First; Second; Third", resp.ErrorMessages())
200200
})
201201
}
202+
203+
func TestClient_IntrospectSchema(t *testing.T) {
204+
t.Run("success", func(t *testing.T) {
205+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
206+
assert.Equal(t, "/collector/graphql", r.URL.Path)
207+
assert.Equal(t, http.MethodPost, r.Method)
208+
209+
w.WriteHeader(http.StatusOK)
210+
w.Write([]byte(`{
211+
"data": {
212+
"__schema": {
213+
"queryType": {"name": "Query"},
214+
"mutationType": null,
215+
"subscriptionType": null,
216+
"types": [
217+
{
218+
"kind": "OBJECT",
219+
"name": "CRM",
220+
"description": "CRM root type",
221+
"fields": [
222+
{
223+
"name": "contact_collection",
224+
"description": "List contacts",
225+
"type": {"kind": "OBJECT", "name": "ContactCollection"},
226+
"args": [
227+
{
228+
"name": "limit",
229+
"type": {"kind": "SCALAR", "name": "Int"}
230+
}
231+
]
232+
}
233+
]
234+
},
235+
{
236+
"kind": "OBJECT",
237+
"name": "Contact",
238+
"description": "A CRM contact",
239+
"fields": [
240+
{
241+
"name": "id",
242+
"type": {"kind": "SCALAR", "name": "ID"}
243+
},
244+
{
245+
"name": "email",
246+
"type": {"kind": "SCALAR", "name": "String"}
247+
}
248+
]
249+
}
250+
]
251+
}
252+
}
253+
}`))
254+
}))
255+
defer server.Close()
256+
257+
client := &Client{
258+
BaseURL: server.URL,
259+
AccessToken: "test-token",
260+
HTTPClient: server.Client(),
261+
}
262+
263+
schema, err := client.IntrospectSchema()
264+
require.NoError(t, err)
265+
assert.NotNil(t, schema.QueryType)
266+
assert.Equal(t, "Query", *schema.QueryType.Name)
267+
assert.Len(t, schema.Types, 2)
268+
})
269+
270+
t.Run("graphql error", func(t *testing.T) {
271+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
272+
w.WriteHeader(http.StatusOK)
273+
w.Write([]byte(`{
274+
"errors": [{"message": "Introspection not allowed"}]
275+
}`))
276+
}))
277+
defer server.Close()
278+
279+
client := &Client{
280+
BaseURL: server.URL,
281+
AccessToken: "test-token",
282+
HTTPClient: server.Client(),
283+
}
284+
285+
schema, err := client.IntrospectSchema()
286+
assert.Error(t, err)
287+
assert.Contains(t, err.Error(), "Introspection not allowed")
288+
assert.Nil(t, schema)
289+
})
290+
}
291+
292+
func TestIntrospectionSchema_GetType(t *testing.T) {
293+
schema := &IntrospectionSchema{
294+
Types: []IntrospectionType{
295+
{Name: "CRM", Kind: "OBJECT"},
296+
{Name: "Contact", Kind: "OBJECT"},
297+
},
298+
}
299+
300+
t.Run("found", func(t *testing.T) {
301+
result := schema.GetType("Contact")
302+
assert.NotNil(t, result)
303+
assert.Equal(t, "Contact", result.Name)
304+
})
305+
306+
t.Run("not found", func(t *testing.T) {
307+
result := schema.GetType("NonExistent")
308+
assert.Nil(t, result)
309+
})
310+
}
311+
312+
func TestIntrospectionSchema_GetRootTypes(t *testing.T) {
313+
schema := &IntrospectionSchema{
314+
Types: []IntrospectionType{
315+
{Name: "CRM", Kind: "OBJECT", Fields: []IntrospectionField{{Name: "contacts"}}},
316+
{Name: "__Schema", Kind: "OBJECT", Fields: []IntrospectionField{{Name: "types"}}},
317+
{Name: "String", Kind: "SCALAR"},
318+
{Name: "Contact", Kind: "OBJECT", Fields: []IntrospectionField{{Name: "id"}}},
319+
},
320+
}
321+
322+
types := schema.GetRootTypes()
323+
assert.Len(t, types, 2) // CRM and Contact, not __Schema (internal) or String (scalar)
324+
325+
names := make([]string, len(types))
326+
for i, typ := range types {
327+
names[i] = typ.Name
328+
}
329+
assert.Contains(t, names, "CRM")
330+
assert.Contains(t, names, "Contact")
331+
}
332+
333+
func TestIntrospectionTypeRef_TypeName(t *testing.T) {
334+
t.Run("simple type", func(t *testing.T) {
335+
name := "String"
336+
ref := IntrospectionTypeRef{Kind: "SCALAR", Name: &name}
337+
assert.Equal(t, "String", ref.TypeName())
338+
})
339+
340+
t.Run("non-null type", func(t *testing.T) {
341+
name := "String"
342+
ref := IntrospectionTypeRef{
343+
Kind: "NON_NULL",
344+
OfType: &IntrospectionTypeRef{Kind: "SCALAR", Name: &name},
345+
}
346+
assert.Equal(t, "String!", ref.TypeName())
347+
})
348+
349+
t.Run("list type", func(t *testing.T) {
350+
name := "Contact"
351+
ref := IntrospectionTypeRef{
352+
Kind: "LIST",
353+
OfType: &IntrospectionTypeRef{Kind: "OBJECT", Name: &name},
354+
}
355+
assert.Equal(t, "[Contact]", ref.TypeName())
356+
})
357+
358+
t.Run("non-null list", func(t *testing.T) {
359+
name := "Contact"
360+
ref := IntrospectionTypeRef{
361+
Kind: "NON_NULL",
362+
OfType: &IntrospectionTypeRef{
363+
Kind: "LIST",
364+
OfType: &IntrospectionTypeRef{Kind: "OBJECT", Name: &name},
365+
},
366+
}
367+
assert.Equal(t, "[Contact]!", ref.TypeName())
368+
})
369+
}

0 commit comments

Comments
 (0)