From 974c00a92902764f51e8ed13dacfea6cb1714e77 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sun, 1 Feb 2026 17:25:30 -0500 Subject: [PATCH] feat: add GraphQL schema explorer command 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 --- api/graphql.go | 210 ++++++++++++++++++++++++++++++++ api/graphql_test.go | 168 +++++++++++++++++++++++++ internal/cmd/graphql/graphql.go | 175 ++++++++++++++++++++++++++ 3 files changed, 553 insertions(+) diff --git a/api/graphql.go b/api/graphql.go index d929510..5568ba6 100644 --- a/api/graphql.go +++ b/api/graphql.go @@ -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 +} diff --git a/api/graphql_test.go b/api/graphql_test.go index b26e6f7..50c1d89 100644 --- a/api/graphql_test.go +++ b/api/graphql_test.go @@ -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()) + }) +} diff --git a/internal/cmd/graphql/graphql.go b/internal/cmd/graphql/graphql.go index cc121b1..2ce1ed4 100644 --- a/internal/cmd/graphql/graphql.go +++ b/internal/cmd/graphql/graphql.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" + "github.com/open-cli-collective/hubspot-cli/api" "github.com/open-cli-collective/hubspot-cli/internal/cmd/root" ) @@ -19,6 +20,7 @@ func Register(parent *cobra.Command, opts *root.Options) { } cmd.AddCommand(newQueryCmd(opts)) + cmd.AddCommand(newExploreCmd(opts)) parent.AddCommand(cmd) } @@ -119,3 +121,176 @@ The query can be provided via --file (path to a .graphql file) or return cmd } + +func newExploreCmd(opts *root.Options) *cobra.Command { + var typeName string + var fieldName string + + cmd := &cobra.Command{ + Use: "explore", + Short: "Explore the GraphQL schema", + Long: `Explore HubSpot's GraphQL schema using introspection. + +Without flags, lists all available types. Use --type to show fields of a +specific type, and --field to show details of a specific field.`, + Example: ` # List all available types + hspt graphql explore + + # Show fields of the CRM type + hspt graphql explore --type CRM + + # Show details of a specific field + hspt graphql explore --type CRM --field contact_collection + + # Explore Contact fields + hspt graphql explore --type CRM_contact`, + RunE: func(cmd *cobra.Command, args []string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + schema, err := client.IntrospectSchema() + if err != nil { + return err + } + + // If no type specified, list root types + if typeName == "" { + return listRootTypes(v, schema) + } + + // Get the specified type + t := schema.GetType(typeName) + if t == nil { + v.Error("Type %q not found in schema", typeName) + return nil + } + + // If no field specified, show type fields + if fieldName == "" { + return showTypeFields(v, t) + } + + // Show specific field details + return showFieldDetails(v, t, fieldName) + }, + } + + cmd.Flags().StringVarP(&typeName, "type", "t", "", "Type name to explore") + cmd.Flags().StringVarP(&fieldName, "field", "f", "", "Field name to show details (requires --type)") + + return cmd +} + +func listRootTypes(v interface { + Render([]string, [][]string, interface{}) error + Info(string, ...interface{}) +}, schema *api.IntrospectionSchema) error { + types := schema.GetRootTypes() + if len(types) == 0 { + v.Info("No types found in schema") + return nil + } + + headers := []string{"TYPE", "KIND", "FIELDS", "DESCRIPTION"} + rows := make([][]string, 0, len(types)) + + for _, t := range types { + desc := t.Description + if len(desc) > 60 { + desc = desc[:57] + "..." + } + rows = append(rows, []string{ + t.Name, + t.Kind, + fmt.Sprintf("%d", len(t.Fields)), + desc, + }) + } + + return v.Render(headers, rows, types) +} + +func showTypeFields(v interface { + Render([]string, [][]string, interface{}) error + Info(string, ...interface{}) +}, t *api.IntrospectionType) error { + if len(t.Fields) == 0 { + v.Info("Type %s has no fields", t.Name) + return nil + } + + headers := []string{"FIELD", "TYPE", "ARGS", "DESCRIPTION"} + rows := make([][]string, 0, len(t.Fields)) + + for _, f := range t.Fields { + desc := f.Description + if len(desc) > 50 { + desc = desc[:47] + "..." + } + argsStr := "" + if len(f.Args) > 0 { + argsStr = fmt.Sprintf("%d", len(f.Args)) + } + rows = append(rows, []string{ + f.Name, + f.Type.TypeName(), + argsStr, + desc, + }) + } + + return v.Render(headers, rows, t.Fields) +} + +func showFieldDetails(v interface { + Render([]string, [][]string, interface{}) error + Info(string, ...interface{}) + Error(string, ...interface{}) +}, t *api.IntrospectionType, fieldName string) error { + var field *api.IntrospectionField + for i := range t.Fields { + if t.Fields[i].Name == fieldName { + field = &t.Fields[i] + break + } + } + + if field == nil { + v.Error("Field %q not found in type %s", fieldName, t.Name) + return nil + } + + headers := []string{"PROPERTY", "VALUE"} + rows := [][]string{ + {"Name", field.Name}, + {"Type", field.Type.TypeName()}, + } + + if field.Description != "" { + rows = append(rows, []string{"Description", field.Description}) + } + if field.IsDeprecated { + rows = append(rows, []string{"Deprecated", "Yes"}) + if field.DeprecationReason != "" { + rows = append(rows, []string{"Deprecation Reason", field.DeprecationReason}) + } + } + + if len(field.Args) > 0 { + rows = append(rows, []string{"", ""}) + rows = append(rows, []string{"ARGUMENTS", ""}) + for _, arg := range field.Args { + argDesc := arg.Type.TypeName() + if arg.DefaultValue != nil { + argDesc += fmt.Sprintf(" = %s", *arg.DefaultValue) + } + rows = append(rows, []string{" " + arg.Name, argDesc}) + } + } + + return v.Render(headers, rows, field) +}