graph

package module
v1.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 30, 2025 License: MIT Imports: 19 Imported by: 0

README

go-graph

A modern, secure GraphQL handler for Go with built-in authentication, validation, and an intuitive builder API.

Features

  • 🚀 Zero Config Start - Default hello world schema included
  • 🔧 Type-Safe Resolvers - Compile-time type safety with generic resolvers
  • 🎯 Type-Safe Arguments - Automatic argument parsing with NewArgsResolver
  • 🏗️ Fluent Builder API - Clean, intuitive schema construction
  • 🔐 Built-in Auth - Automatic Bearer token extraction
  • 🛡️ Security First - Query depth, complexity, and introspection protection
  • 🧹 Response Sanitization - Remove field suggestions from errors
  • 🎭 Middleware System - Built-in logging, auth, caching + custom middleware support
  • 🔄 Real-time Subscriptions - WebSocket subscriptions with dual protocol support
  • Framework Agnostic - Works with net/http, Gin, or any framework
  • High Performance - ~60μs per request, 100k+ RPS capable

Built on top of graphql-go.

Installation

go get github.com/paulmanoni/go-graph

Quick Start

Option 1: Default Schema (Zero Config)

Start immediately with a built-in hello world schema:

package main

import (
    "log"
    "net/http"
    "github.com/paulmanoni/go-graph"
)

func main() {
    // No schema needed! Includes default hello query & echo mutation
    handler := graph.NewHTTP(&graph.GraphContext{
        Playground: true,
        DEBUG:      true,
    })

    http.Handle("/graphql", handler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Test it:

# Query
{ hello }

# Mutation
mutation { echo(message: "test") }

Use the fluent builder API for clean, type-safe schema construction:

package main

import (
    "log"
    "net/http"
    "github.com/paulmanoni/go-graph"
)

type User struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

// Define your queries with type-safe resolvers
func getHello() graph.QueryField {
    return graph.NewResolver[string]("hello").
        WithResolver(func(p graph.ResolveParams) (*string, error) {
            msg := "Hello, World!"
            return &msg, nil
        }).BuildQuery()
}

func getUser() graph.QueryField {
    return graph.NewResolver[User]("user").
        WithArg("id", graph.String).
        WithResolver(func(p graph.ResolveParams, args graph.Args) (*User, error) {
            id := graph.Get[string](args, "id")
            return &User{ID: id, Name: "Alice"}, nil
        }).BuildQuery()
}

func main() {
    handler := graph.NewHTTP(&graph.GraphContext{
        SchemaParams: &graph.SchemaBuilderParams{
            QueryFields: []graph.QueryField{
                getHello(),
                getUser(),
            },
            MutationFields: []graph.MutationField{},
        },
        Playground: true,
        DEBUG:      false,
    })

    http.Handle("/graphql", handler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}
Option 3: Custom Schema

Bring your own graphql-go schema:

import "github.com/graphql-go/graphql"

schema, _ := graphql.NewSchema(graphql.SchemaConfig{
    Query: graphql.NewObject(graphql.ObjectConfig{
        Name: "Query",
        Fields: graphql.Fields{
            "hello": &graphql.Field{
                Type: graphql.String,
                Resolve: func(p graph.ResolveParams) (interface{}, error) {
                    return "world", nil
                },
            },
        },
    }),
})

handler := graph.NewHTTP(&graph.GraphContext{
    Schema:     &schema,
    Playground: true,
})

Authentication

Automatic Bearer Token Extraction

Token is automatically extracted from Authorization: Bearer <token> header and available in all resolvers:

handler := graph.NewHTTP(&graph.GraphContext{
    SchemaParams: &graph.SchemaBuilderParams{
        QueryFields: []graph.QueryField{
            getProtectedQuery(),
        },
    },

    // Optional: Fetch user details from token and update context
    UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
        // Validate JWT, query database, etc.
        user, err := validateAndGetUser(token)
        if err != nil {
            return ctx, nil, err
        }

        // Add values to context (accessible via p.Context.Value() in resolvers)
        ctx = context.WithValue(ctx, "userID", user.ID)
        ctx = context.WithValue(ctx, "roles", user.Roles)

        return ctx, user, nil
    },
})

Access in resolvers:

func getProtectedQuery() graph.QueryField {
    return graph.NewResolver[User]("me").
        WithResolver(func(p graph.ResolveParams) (*User, error) {
            // Option 1: Get values from context (set by UserDetailsFn)
            userID := p.Context.Value("userID").(string)

            // Option 2: Get token directly
            token, err := graph.GetRootString(p, "token")
            if err != nil {
                return nil, fmt.Errorf("authentication required")
            }

            // Option 3: Get user details struct (if UserDetailsFn provided)
            var user User
            if err := graph.GetRootInfo(p, "details", &user); err != nil {
                return nil, err
            }

            return &user, nil
        }).BuildQuery()
}
Custom Token Extraction

Extract tokens from cookies, custom headers, or query params:

handler := graph.NewHTTP(&graph.GraphContext{
    SchemaParams: &graph.SchemaBuilderParams{...},

    TokenExtractorFn: func(r *http.Request) string {
        // From cookie
        if cookie, err := r.Cookie("auth_token"); err == nil {
            return cookie.Value
        }

        // From custom header
        if apiKey := r.Header.Get("X-API-Key"); apiKey != "" {
            return apiKey
        }

        // From query param
        return r.URL.Query().Get("token")
    },

    UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
        user, err := getUserByToken(token)
        return ctx, user, err
    },
})

Security Features

Production Setup

Enable all security features for production:

handler := graph.NewHTTP(&graph.GraphContext{
    SchemaParams:       &graph.SchemaBuilderParams{...},
    DEBUG:              false,  // Enable security features
    EnableValidation:   true,   // Validate queries
    EnableSanitization: true,   // Sanitize errors
    Playground:         false,  // Disable playground

    UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
        user, err := validateAndGetUser(token)
        if err != nil {
            return ctx, nil, err
        }
        return ctx, user, nil
    },
})
Validation Rules (Legacy)

For simple validation needs, use EnableValidation: true:

  • Max Query Depth: 10 levels
  • Max Aliases: 4 per query
  • Max Complexity: 200
  • Introspection: Disabled (blocks __schema and __type)

For advanced validation needs, see the Custom Validation Rules section below.

Response Sanitization (when EnableSanitization: true)

Removes field suggestions from error messages:

Before:

{
  "errors": [{
    "message": "Cannot query field \"nam\". Did you mean \"name\"?"
  }]
}

After:

{
  "errors": [{
    "message": "Cannot query field \"nam\"."
  }]
}
Debug Mode

Use DEBUG: true during development to skip all validation and sanitization:

handler := graph.NewHTTP(&graph.GraphContext{
    SchemaParams: &graph.SchemaBuilderParams{...},
    DEBUG:        true,  // Disables validation & sanitization
    Playground:   true,  // Enable playground for testing
})

Custom Validation Rules

The package provides a powerful and flexible validation system that goes beyond simple EnableValidation: true. Create custom validation rules to enforce security policies, authentication requirements, rate limits, and more.

Features
  • 🛡️ Pre-built Security Rules - Max depth, complexity, aliases, introspection blocking, token limits
  • 🔐 Authentication & Authorization - Require auth for operations, role-based and permission-based access control
  • Rate Limiting - Budget-based rate limiting with role bypasses
  • 📦 Preset Collections - SecurityRules, StrictSecurityRules, DevelopmentRules
  • 🎯 Type-Safe - Strongly-typed AuthContext with user information
  • 🔧 Composable - Combine multiple rules and rule sets
  • ⚠️ Multi-Error Support - Collect and return all validation errors at once
  • 🚀 High Performance - Adds minimal overhead (~1-2μs per query)
Quick Start
handler := graph.NewHTTP(&graph.GraphContext{
    SchemaParams: &graph.SchemaBuilderParams{...},

    // Custom validation rules (replaces EnableValidation)
    ValidationRules: []graph.ValidationRule{
        graph.NewMaxDepthRule(10),
        graph.NewMaxComplexityRule(200),
        graph.NewNoIntrospectionRule(),
        graph.NewRequireAuthRule("mutation", "subscription"),
        graph.NewRoleRules(map[string][]string{
            "deleteUser": {"admin"},
            "viewAuditLog": {"admin", "auditor"},
        }),
    },

    // Fetch user details from token (reuses existing UserDetailsFn)
    UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
        user, err := validateJWT(token)
        if err != nil {
            return ctx, nil, err
        }
        return ctx, user, nil
    },
})
Security Rules
MaxDepthRule

Prevents deeply nested queries that can cause performance issues:

graph.NewMaxDepthRule(10)  // Max 10 levels deep

Blocks:

{
  level1 {
    level2 {
      level3 {
        # ... more than 10 levels
      }
    }
  }
}
MaxComplexityRule

Limits query computational cost (complexity = number of fields × depth):

graph.NewMaxComplexityRule(200)  // Max complexity of 200

Example: Query with 50 fields at depth 5 = complexity 250 → Rejected

MaxAliasesRule

Prevents alias-based denial-of-service attacks:

graph.NewMaxAliasesRule(4)  // Max 4 aliases per query

Blocks:

{
  u1: user(id: 1) { name }
  u2: user(id: 2) { name }
  u3: user(id: 3) { name }
  u4: user(id: 4) { name }
  u5: user(id: 5) { name }  # 5th alias rejected
}
NoIntrospectionRule

Blocks schema introspection in production:

graph.NewNoIntrospectionRule()

Blocks:

{ __schema { types { name } } }
{ __type(name: "User") { fields { name } } }
MaxTokensRule

Limits total tokens in query (prevents extremely large queries):

graph.NewMaxTokensRule(500)  // Max 500 tokens
Authentication & Authorization Rules
RequireAuthRule

Require authentication for specific operations or fields:

// Require auth for all mutations and subscriptions
graph.NewRequireAuthRule("mutation", "subscription")

// Require auth for specific fields
graph.NewRequireAuthRule("sensitiveData", "adminPanel")

// Combine both
graph.NewRequireAuthRule("mutation", "subscription", "sensitiveData")

Example response when unauthenticated:

{
  "errors": [{
    "message": "mutation operations require authentication",
    "rule": "RequireAuthRule"
  }]
}
RoleRule & RoleRules

Enforce role-based access control:

// Single field rule
graph.NewRoleRule("deleteUser", "admin")

// Multiple roles allowed
graph.NewRoleRule("viewReports", "admin", "manager")

// Batch configuration (preferred for multiple fields)
graph.NewRoleRules(map[string][]string{
    "deleteUser":     {"admin"},
    "deleteAccount":  {"admin"},
    "viewAuditLog":   {"admin", "auditor"},
    "approveOrder":   {"admin", "manager"},
})

Minimal interface requirements:

Your user struct only needs to implement the methods required by the rules you use:

// For RoleRule and RoleRules
type HasRolesInterface interface {
    HasRole(role string) bool
}

// For PermissionRule and PermissionRules
type HasPermissionsInterface interface {
    HasPermission(permission string) bool
}

// For RateLimitRule
type HasIDInterface interface {
    GetID() string
}

// Example user implementation
type User struct {
    ID          string
    Roles       []string
    Permissions []string
}

func (u *User) GetID() string {
    return u.ID
}

func (u *User) HasRole(role string) bool {
    for _, r := range u.Roles {
        if r == role {
            return true
        }
    }
    return false
}

func (u *User) HasPermission(perm string) bool {
    for _, p := range u.Permissions {
        if p == perm {
            return true
        }
    }
    return false
}
PermissionRule & PermissionRules

Fine-grained permission-based access control:

// Single field permission
graph.NewPermissionRule("sensitiveData", "read:sensitive")

// Multiple permissions
graph.NewPermissionRule("exportData", "export:data", "admin:all")

// Batch configuration
graph.NewPermissionRules(map[string][]string{
    "sensitiveData": {"read:sensitive"},
    "exportData":    {"export:data"},
    "adminPanel":    {"admin:access"},
})
BlockedFieldsRule

Block specific fields from being queried:

rule := graph.NewBlockedFieldsRule("internalUsers", "deprecatedField")

// With reasons
rule.BlockField("legacyAPI", "deprecated: use v2 API")
rule.BlockField("internalData", "not available in this version")
Rate Limiting

Budget-based rate limiting with role bypasses:

graph.NewRateLimitRule(
    graph.WithBudgetFunc(getBudgetFromRedis),
    graph.WithCostPerUnit(2),  // Multiply complexity by 2
    graph.WithBypassRoles("admin", "service"),
)

Budget function examples:

// Simple fixed budget
graph.SimpleBudgetFunc(1000)

// Per-user budgets
graph.PerUserBudgetFunc(map[string]int{
    "premium_user": 10000,
    "basic_user":   1000,
}, 500)  // default budget

// Redis-based sliding window
func getRedisBudget(userID string) (int, error) {
    key := fmt.Sprintf("rate_limit:%s", userID)
    remaining, err := redis.Get(ctx, key).Int()
    if err == redis.Nil {
        return 1000, nil  // Reset budget
    }
    return remaining, err
}
Preset Rule Collections

Pre-configured rule sets for common scenarios:

SecurityRules (Default)

Standard security for production:

graph.ValidationRules: graph.SecurityRules
  • Max depth: 10
  • Max complexity: 200
  • Max aliases: 4
  • Introspection: Blocked
StrictSecurityRules

Stricter limits for high-security environments:

graph.ValidationRules: graph.StrictSecurityRules
  • Max depth: 8
  • Max complexity: 150
  • Max aliases: 3
  • Max tokens: 500
  • Introspection: Blocked
DevelopmentRules

Lenient rules for development:

graph.ValidationRules: graph.DevelopmentRules
  • Max depth: 20
  • Max complexity: 500
  • No other restrictions
Combining Rules
CombineRules

Merge multiple rule sets:

rules := graph.CombineRules(
    graph.SecurityRules,
    []graph.ValidationRule{
        graph.NewRequireAuthRule("mutation"),
        graph.NewRoleRules(graph.AdminOnlyFields),
    },
)

handler := graph.NewHTTP(&graph.GraphContext{
    ValidationRules: rules,
})
Preset Role Configurations

Pre-built role configurations for common access patterns:

// Use built-in configurations
graph.NewRoleRules(graph.AdminOnlyFields)
graph.NewRoleRules(graph.ManagerFields)
graph.NewRoleRules(graph.AuditorFields)

// Merge multiple configurations
allRoles := graph.MergeRoleConfigs(
    graph.AdminOnlyFields,
    graph.ManagerFields,
    graph.AuditorFields,
)
graph.NewRoleRules(allRoles)

Built-in role configs:

  • AdminOnlyFields: deleteUser, deleteAccount, viewAuditLog, systemSettings, manageRoles
  • ManagerFields: approveOrder, viewReports, manageTeam, bulkOperations
  • AuditorFields: viewAuditLog, exportLogs, viewAnalytics
Production Example

Complete production setup with security, auth, and rate limiting:

package main

import (
    "net/http"
    graph "github.com/paulmanoni/go-graph"
)

func main() {
    handler := graph.NewHTTP(&graph.GraphContext{
        SchemaParams: &graph.SchemaBuilderParams{...},

        // Strict security validation
        ValidationRules: graph.CombineRules(
            graph.StrictSecurityRules,
            []graph.ValidationRule{
                // Authentication
                graph.NewRequireAuthRule("mutation", "subscription"),

                // Role-based access
                graph.NewRoleRules(map[string][]string{
                    "deleteUser":     {"admin"},
                    "viewAuditLog":   {"admin", "auditor"},
                    "approveOrder":   {"admin", "manager"},
                }),

                // Permission-based access
                graph.NewPermissionRules(map[string][]string{
                    "sensitiveData": {"read:sensitive"},
                    "exportData":    {"export:data"},
                }),

                // Rate limiting
                graph.NewRateLimitRule(
                    graph.WithBudgetFunc(getRedisBudget),
                    graph.WithCostPerUnit(2),
                    graph.WithBypassRoles("admin", "service"),
                ),
            },
        ),

        // Fetch user details from JWT token (reuses existing UserDetailsFn)
        UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
            user, err := validateJWT(token)
            if err != nil {
                return ctx, nil, err  // Invalid token
            }
            return ctx, user, nil  // Returns your user struct that implements minimal interfaces
        },

        // Validation options
        ValidationOptions: &graph.ValidationOptions{
            StopOnFirstError: false,  // Collect all errors
            SkipInDebug:      true,   // Skip in DEBUG mode
        },

        // Other production settings
        EnableSanitization: true,
        Playground:         false,
        DEBUG:              false,
    })

    http.Handle("/graphql", handler)
    http.ListenAndServe(":8080", nil)
}
Custom Validation Rules

Create custom rules by embedding BaseRule:

type IPWhitelistRule struct {
    graph.BaseRule
    allowedIPs []string
}

func NewIPWhitelistRule(ips ...string) graph.ValidationRule {
    return &IPWhitelistRule{
        BaseRule:   graph.NewBaseRule("IPWhitelistRule"),
        allowedIPs: ips,
    }
}

func (r *IPWhitelistRule) Validate(ctx *graph.ValidationContext) error {
    // Access request from context if needed
    // clientIP := getClientIP(ctx.Request)

    // Check IP whitelist
    // if !contains(r.allowedIPs, clientIP) {
    //     return r.NewError("IP not whitelisted")
    // }

    return nil
}

BaseRule provides:

  • NewError(msg string) *ValidationError
  • NewErrorf(format string, args ...interface{}) *ValidationError
  • Enable() / Disable() / Enabled() bool
  • Consistent error formatting
Error Responses
Single Validation Error
{
  "errors": [{
    "message": "query depth 12 exceeds maximum 10",
    "rule": "MaxDepthRule"
  }]
}
Multiple Validation Errors

When StopOnFirstError: false:

{
  "errors": [
    {
      "message": "query depth 12 exceeds maximum 10",
      "rule": "MaxDepthRule"
    },
    {
      "message": "field 'deleteUser' requires one of roles: [admin]",
      "rule": "RoleRules"
    },
    {
      "message": "query cost 250 exceeds available budget 200",
      "rule": "RateLimitRule"
    }
  ]
}
Validation Options

Configure validation behavior:

&graph.ValidationOptions{
    // Collect all errors vs stop on first
    StopOnFirstError: false,

    // Skip validation in DEBUG mode
    SkipInDebug: true,
}
Performance Impact

Validation adds minimal overhead (measured on Apple M1 Pro):

Rule Time/op Allocations Description
MaxDepthRule ~4.4 μs 43 allocs Query depth validation
MaxComplexityRule ~2.8 μs 43 allocs Complexity calculation
MaxAliasesRule ~3.0 μs 51 allocs Alias counting
NoIntrospectionRule ~10.5 μs 36 allocs Introspection blocking
RequireAuthRule ~1.3 μs 30 allocs Authentication check
RoleRule ~947 ns 20 allocs Role validation
PermissionRule ~858 ns 20 allocs Permission check
RateLimitRule ~3.7 μs 36 allocs Budget + complexity
SecurityRules (preset) ~3.2 μs 43 allocs Depth + complexity + aliases + introspection
StrictSecurityRules ~2.6 μs 36 allocs Stricter limits
Combined rules ~1.4 μs 29 allocs Multiple custom rules

For a complete HTTP request:

  • Debug mode (no validation): ~29 μs
  • With validation: ~31 μs
  • With sanitization: ~36 μs
  • Complete stack (validation + sanitization + auth): ~60 μs
  • Overhead: ~2-3 μs for validation (negligible)
Migration from EnableValidation

Before (simple validation):

handler := graph.NewHTTP(&graph.GraphContext{
    EnableValidation: true,  // Deprecated
})

After (custom rules):

handler := graph.NewHTTP(&graph.GraphContext{
    ValidationRules: graph.SecurityRules,  // Same defaults
})

Or use presets:

// Development
ValidationRules: graph.DevelopmentValidationRules()

// Production
ValidationRules: graph.ProductionValidationRules()

// Custom
ValidationRules: graph.DefaultValidationRules()
Best Practices
  1. Start with presets: Use SecurityRules or StrictSecurityRules as a base
  2. Add auth rules: Layer in RequireAuthRule and RoleRules as needed
  3. Test in development: Use DevelopmentRules with DEBUG: true for development
  4. Collect all errors: Set StopOnFirstError: false for better debugging
  5. Monitor performance: Validation overhead is minimal (~3-5μs total)
  6. Use UserDetailsFn: Centralize auth logic by implementing UserDetailsFn instead of checking tokens in each resolver
  7. Implement minimal interfaces: Only implement the interfaces needed by your validation rules (HasRolesInterface, HasPermissionsInterface, HasIDInterface)

Helper Functions

Accessing Root Values
// Get token
token, err := graph.GetRootString(p, "token")

// Get user details
var user User
err := graph.GetRootInfo(p, "details", &user)

Type-Safe Resolvers

Automatic Type Generation with Embedded Struct Support

The package automatically generates GraphQL types from your Go structs, including full support for embedded structs. Fields from embedded structs are automatically flattened to the parent level.

Embedded Struct Example
// Define a base entity with common fields
type BaseEntity struct {
    ID        string     `json:"id"`
    CreatedAt time.Time  `json:"created_at"`
    UpdatedAt *time.Time `json:"updated_at,omitempty"`
}

// Product embeds BaseEntity - fields are automatically flattened
type Product struct {
    BaseEntity
    Name  string  `json:"name"`
    Price float64 `json:"price"`
}

// GraphQL type is automatically generated with ALL fields at the same level:
// type Product {
//   id: String
//   created_at: DateTime
//   updated_at: DateTime
//   name: String
//   price: Float
// }

func getProduct() graph.QueryField {
    return graph.NewResolver[Product]("product").
        WithResolver(func(p graph.ResolveParams) (*Product, error) {
            return &Product{
                BaseEntity: BaseEntity{
                    ID:        "123",
                    CreatedAt: time.Now(),
                },
                Name:  "Widget",
                Price: 19.99,
            }, nil
        }).BuildQuery()
}

Embedded Struct Features:

  • Automatic field flattening - No nested objects, all fields at parent level
  • Multiple embedding - Embed multiple structs (e.g., BaseEntity + Metadata)
  • Nested embedding - Multi-level embedding supported (Level1 → Level2 → Level3)
  • Pointer embedding - Works with *BaseEntity as well
  • Field override - Child fields with same name take precedence
  • Works everywhere - Supported in queries, mutations, input objects, and arguments

Performance:

  • Field generation with embedded structs: ~1.2 μs
  • Field resolver execution: ~232 ns
  • Zero runtime overhead after type generation

The WithResolver method provides compile-time type safety by accepting a function that returns *T instead of interface{}:

// ✅ Type-safe - returns *User
graph.NewResolver[User]("user").
    WithArg("id", graph.String).
    WithResolver(func(p graph.ResolveParams, args graph.Args) (*User, error) {
        id := graph.Get[string](args, "id")
        user := db.GetUserByID(id)  // Most ORMs return *User
        return user, nil             // No type assertions needed!
    }).BuildQuery()

// ✅ Works with lists - returns *[]User
graph.NewResolver[User]("users").
    AsList().
    WithResolver(func(p graph.ResolveParams) (*[]User, error) {
        users := db.ListUsers()
        return &users, nil
    }).BuildQuery()

// ✅ Works with primitives - returns *string
graph.NewResolver[string]("message").
    WithResolver(func(p graph.ResolveParams) (*string, error) {
        msg := "Hello!"
        return &msg, nil
    }).BuildQuery()

Benefits:

  • ✅ No type assertions or casts needed
  • ✅ Compiler catches type mismatches at build time
  • ✅ Better IDE autocomplete and refactoring
  • ✅ Cleaner, more readable code
  • ✅ Works with pointers (can return nil for not found)
Real-World Example
type Post struct {
    ID       int    `json:"id"`
    Title    string `json:"title"`
    AuthorID int    `json:"authorId"`
}

// Type-safe query
func getPost() graph.QueryField {
    return graph.NewResolver[Post]("post").
        WithArg("id", graph.Int).
        WithResolver(func(p graph.ResolveParams, args graph.Args) (*Post, error) {
            id := graph.Get[int](args, "id")

            post, err := postService.GetByID(id)
            if err != nil {
                return nil, err
            }

            // Return *Post directly - no type assertions!
            return post, nil
        }).BuildQuery()
}

// Type-safe list query
func getPosts() graph.QueryField {
    return graph.NewResolver[Post]("posts").
        AsList().
        WithResolver(func(p graph.ResolveParams) (*[]Post, error) {
            posts, err := postService.List()
            if err != nil {
                return nil, err
            }
            return &posts, nil
        }).BuildQuery()
}

// Type-safe mutation
func createPost() graph.MutationField {
    type CreatePostInput struct {
        Title    string `json:"title"`
        AuthorID int    `json:"authorId"`
    }

    return graph.NewResolver[Post]("createPost").
        WithInputObject(CreatePostInput{}).
        WithResolver(func(p graph.ResolveParams) (*Post, error) {
            var input CreatePostInput
            if err := graph.GetArg(p, "input", &input); err != nil {
                return nil, err
            }

            return postService.Create(input.Title, input.AuthorID)
        }).BuildMutation()
}

Type-Safe Arguments with NewArgsResolver (Deprecated)

Deprecated: NewArgsResolver is deprecated in favor of the new WithArg API. See the Chainable Arguments with WithArg section below for the recommended approach.

Migration Guide

Before (deprecated):

type GetUserArgs struct {
    ID int `json:"id" graphql:"id,required"`
}

graph.NewArgsResolver[User, GetUserArgs]("user").
    WithResolver(func(ctx context.Context, p graph.ResolveParams, args GetUserArgs) (*User, error) {
        return userService.GetByID(args.ID)
    }).BuildQuery()

After (recommended):

graph.NewResolver[User]("user").
    WithArgRequired("id", graph.Int).
    WithResolver(func(p graph.ResolveParams, args graph.Args) (*User, error) {
        id := graph.Get[int](args, "id")
        return userService.GetByID(id)
    }).BuildQuery()

For struct inputs:

// Before (deprecated):
graph.NewArgsResolver[User, CreateUserInput]("createUser").
    WithResolver(func(ctx context.Context, p graph.ResolveParams, input CreateUserInput) (*User, error) {
        return userService.Create(input.Name, input.Email)
    }).BuildMutation()

// After (recommended):
graph.NewResolver[User]("createUser").
    WithArg("input", CreateUserInput{}).
    WithResolver(func(p graph.ResolveParams, args graph.Args) (*User, error) {
        input := graph.Get[CreateUserInput](args, "input")
        return userService.Create(input.Name, input.Email)
    }).BuildMutation()

Chainable Arguments with WithArg

WithArg provides a fluent API for adding arguments to your resolvers. It supports scalars, structs (including deeply nested), and works with a unified WithResolver that accepts both with and without args signatures.

Basic Usage
// Without args - simple resolver
graph.NewResolver[Message]("hello").
    WithResolver(func(p graph.ResolveParams) (*Message, error) {
        return &Message{Text: "Hello, World!"}, nil
    }).BuildQuery()

// With scalar args - using graph.String, graph.Int, etc.
graph.NewResolver[User]("user").
    WithArg("id", graph.String).
    WithArg("limit", graph.Int).
    WithResolver(func(p graph.ResolveParams, args graph.Args) (*User, error) {
        id := graph.Get[string](args, "id")
        limit := graph.GetOr[int](args, "limit", 10)
        return userService.GetByID(id)
    }).BuildQuery()

// With struct input - auto-generates InputObject
type UserInput struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

graph.NewResolver[User]("createUser").
    WithArg("input", UserInput{}).
    WithResolver(func(p graph.ResolveParams, args graph.Args) (*User, error) {
        input := graph.Get[UserInput](args, "input")
        return userService.Create(input.Name, input.Email)
    }).BuildMutation()
Supported Argument Types
Type Usage GraphQL Type
graph.String WithArg("id", graph.String) String
graph.Int WithArg("age", graph.Int) Int
graph.Float WithArg("price", graph.Float) Float
graph.Boolean WithArg("active", graph.Boolean) Boolean
graph.ID WithArg("userId", graph.ID) ID
Zero values WithArg("name", "") String
Structs WithArg("input", UserInput{}) UserInputInput
Args Helper Functions
// Get a value with type safety (returns zero value if missing)
id := graph.Get[string](args, "id")
age := graph.Get[int](args, "age")
active := graph.Get[bool](args, "active")

// Get struct argument with automatic conversion
input := graph.Get[UserInput](args, "input")

// Get with default value
limit := graph.GetOr[int](args, "limit", 10)
name := graph.GetOr[string](args, "name", "Anonymous")

// Get with error handling (returns error if missing or conversion fails)
input, err := graph.GetE[UserInput](args, "input")
if err != nil {
    return nil, fmt.Errorf("invalid input: %w", err)
}

// MustGet panics if missing or conversion fails (use when certain arg exists)
id := graph.MustGet[string](args, "id")

// Check if argument exists
if args.Has("optionalField") {
    // process optional field
}

// Get raw map for complex processing (if needed)
rawArgs := args.Raw()
Using Args with Subscriptions and Custom Resolvers (ArgsMap)

When using subscriptions or custom resolvers that receive p.Args directly (a map[string]interface{}), you can use graph.ArgsMap to wrap it and use the same type-safe getter functions:

// In subscriptions or custom resolvers with graphql.FieldConfigArgument
subscription := graph.NewSubscription[Message]("onMessage").
    WithField("channelID", graphql.String).
    WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *Message, error) {
        // Wrap p.Args with ArgsMap to use type-safe getters
        channelID := graph.Get[string](graph.ArgsMap(p.Args), "channelID")

        // Or with error handling
        channelID, err := graph.GetE[string](graph.ArgsMap(p.Args), "channelID")
        if err != nil {
            return nil, fmt.Errorf("channelID required: %w", err)
        }

        // ... create and return channel
    })

Both graph.Args (from WithArg chainable API) and graph.ArgsMap (wrapping p.Args) implement the ArgsGetter interface, so all getter functions work with both:

Source Usage When to Use
graph.Args graph.Get[T](args, "key") With WithResolver(func(p, args)...)
graph.ArgsMap(p.Args) graph.Get[T](graph.ArgsMap(p.Args), "key") Subscriptions, middlewares, custom resolvers
Error Handling Options
Function Missing Key Conversion Error Use Case
Get[T] Zero value Zero value Optional args, quick access
GetOr[T] Default Default Args with defaults
GetE[T] Error Error Required args with validation
MustGet[T] Panic Panic Required args (certain to exist)

Example with error handling:

graph.NewResolver[User]("createUser").
    WithArg("input", CreateUserInput{}).
    WithResolver(func(p graph.ResolveParams, args graph.Args) (*User, error) {
        // Explicit error handling
        input, err := graph.GetE[CreateUserInput](args, "input")
        if err != nil {
            return nil, fmt.Errorf("invalid input: %w", err)
        }
        return userService.Create(input.Name, input.Email)
    }).BuildMutation()
Required Arguments and Defaults
// Required argument (GraphQL NonNull)
graph.NewResolver[User]("user").
    WithArgRequired("id", graph.String).
    WithResolver(func(p graph.ResolveParams, args graph.Args) (*User, error) {
        id := graph.Get[string](args, "id") // always present
        return userService.GetByID(id)
    }).BuildQuery()

// Argument with default value
graph.NewResolver[User]("users").
    AsList().
    WithArgDefault("limit", graph.Int, 20).
    WithArgDefault("offset", graph.Int, 0).
    WithResolver(func(p graph.ResolveParams, args graph.Args) (*[]User, error) {
        limit := graph.Get[int](args, "limit")  // 20 if not provided
        offset := graph.Get[int](args, "offset") // 0 if not provided
        return userService.List(limit, offset)
    }).BuildQuery()
Nested Struct Support

Deeply nested structs are automatically converted to GraphQL InputObjects:

type AddressInput struct {
    Street  string `json:"street"`
    City    string `json:"city"`
    Country string `json:"country"`
}

type UserProfileInput struct {
    Name    string       `json:"name"`
    Age     int          `json:"age"`
    Address AddressInput `json:"address"`
}

// Generates nested InputObjects automatically
graph.NewResolver[User]("createUser").
    WithArg("profile", UserProfileInput{}).
    WithResolver(func(p graph.ResolveParams, args graph.Args) (*User, error) {
        profile := graph.Get[UserProfileInput](args, "profile")
        // Access nested data with type safety
        return userService.Create(profile.Name, profile.Address.City)
    }).BuildMutation()
Unified WithResolver

WithResolver automatically detects the function signature and handles both:

// Signature 1: Without args
func(p graph.ResolveParams) (*T, error)

// Signature 2: With args
func(p graph.ResolveParams, args graph.Args) (*T, error)

This means you can use the same WithResolver method regardless of whether you have arguments:

// Without args - signature detected automatically
graph.NewResolver[Message]("hello").
    WithResolver(func(p graph.ResolveParams) (*Message, error) {
        return &Message{Text: "Hello!"}, nil
    }).BuildQuery()

// With args - signature detected automatically
graph.NewResolver[User]("user").
    WithArg("id", graph.String).
    WithResolver(func(p graph.ResolveParams, args graph.Args) (*User, error) {
        id := graph.Get[string](args, "id")
        return userService.GetByID(id)
    }).BuildQuery()
Complete Example
type User struct {
    ID    string `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

type CreateUserInput struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

// Query with multiple args
func getUsers() graph.QueryField {
    return graph.NewResolver[User]("users").
        AsList().
        WithArg("search", graph.String).
        WithArgDefault("limit", graph.Int, 20).
        WithArgDefault("offset", graph.Int, 0).
        WithResolver(func(p graph.ResolveParams, args graph.Args) (*[]User, error) {
            search := graph.GetOr[string](args, "search", "")
            limit := graph.Get[int](args, "limit")
            offset := graph.Get[int](args, "offset")

            users := userService.Search(search, limit, offset)
            return &users, nil
        }).BuildQuery()
}

// Mutation with struct input
func createUser() graph.MutationField {
    return graph.NewResolver[User]("createUser").
        WithArg("input", CreateUserInput{}).
        WithResolver(func(p graph.ResolveParams, args graph.Args) (*User, error) {
            input := graph.Get[CreateUserInput](args, "input")
            return userService.Create(input.Name, input.Email)
        }).BuildMutation()
}
Comparison: WithArg vs NewArgsResolver
Feature WithArg NewArgsResolver
Argument definition Chainable method Struct with tags
Type safety Runtime (Get[T]) Compile-time
Flexibility Mix scalars and structs Single struct
Best for Flexible APIs Structured inputs

Use WithArg when:

  • You have a mix of scalar and struct arguments
  • Arguments are optional with defaults
  • You want chainable, fluent API

Use NewArgsResolver when:

  • All arguments fit in a single struct
  • You want compile-time type safety
  • Arguments have validation tags

Middleware

The library provides a powerful middleware system for adding cross-cutting concerns like authentication, logging, caching, and more to your resolvers.

Resolver Middleware

Apply middleware to the entire resolver using WithMiddleware(). Middleware functions are applied in the order they're added (first added = outermost layer):

graph.NewResolver[User]("user").
    WithArg("id", graph.Int).
    WithMiddleware(graph.LoggingMiddleware).
    WithMiddleware(graph.AuthMiddleware("admin")).
    WithResolver(func(p graph.ResolveParams, args graph.Args) (*User, error) {
        id := graph.Get[int](args, "id")
        return userService.GetByID(id)
    }).BuildQuery()

Execution flow:

  1. LoggingMiddleware (starts timer)
  2. AuthMiddleware (checks permissions)
  3. Your resolver (executes business logic)
  4. AuthMiddleware (returns)
  5. LoggingMiddleware (logs duration)
Built-in Middleware
LoggingMiddleware

Logs resolver execution time to stdout:

graph.NewResolver[Post]("post").
    WithMiddleware(graph.LoggingMiddleware).
    WithResolver(func(p graph.ResolveParams) (*Post, error) {
        return postService.GetByID(id)
    }).BuildQuery()

// Output: Field post resolved in 2.5ms
AuthMiddleware

Requires a specific user role from context:

graph.NewResolver[User]("adminUser").
    WithMiddleware(graph.AuthMiddleware("admin")).
    WithResolver(func(p graph.ResolveParams) (*User, error) {
        return userService.GetAdmin()
    }).BuildQuery()
CacheMiddleware

Caches resolver results based on a custom key function:

graph.NewResolver[Product]("product").
    WithArg("id", graph.Int).
    WithMiddleware(graph.CacheMiddleware(func(p graph.ResolveParams) string {
        id := graph.Get[int](graph.ArgsMap(p.Args), "id")
        return fmt.Sprintf("product:%d", id)
    })).
    WithResolver(func(p graph.ResolveParams, args graph.Args) (*Product, error) {
        // Only executes on cache miss
        id := graph.Get[int](args, "id")
        return productService.GetByID(id)
    }).BuildQuery()
Custom Middleware

Create custom middleware by implementing the FieldMiddleware function signature:

// FieldMiddleware wraps a resolver with additional functionality
type FieldMiddleware func(next FieldResolveFn) FieldResolveFn

// Example: Rate limiting middleware
func RateLimitMiddleware(limit int) graph.FieldMiddleware {
    requests := make(map[string]int)
    var mu sync.Mutex

    return func(next graph.FieldResolveFn) graph.FieldResolveFn {
        return func(p graph.ResolveParams) (interface{}, error) {
            // Extract user ID from context
            userID := p.Context.Value("userID").(string)

            mu.Lock()
            if requests[userID] >= limit {
                mu.Unlock()
                return nil, fmt.Errorf("rate limit exceeded")
            }
            requests[userID]++
            mu.Unlock()

            return next(p)
        }
    }
}

// Usage
graph.NewResolver[User]("user").
    WithMiddleware(RateLimitMiddleware(100)).
    WithResolver(func(p graph.ResolveParams) (*User, error) {
        return userService.GetByID(id)
    }).BuildQuery()
Middleware Patterns
Stacking Multiple Middleware

Chain multiple middleware for complex behavior:

graph.NewResolver[User]("user").
    WithMiddleware(graph.LoggingMiddleware).           // 1. Log timing
    WithMiddleware(graph.AuthMiddleware("user")).      // 2. Check auth
    WithMiddleware(RateLimitMiddleware(100)).          // 3. Rate limit
    WithMiddleware(graph.CacheMiddleware(cacheKeyFn)). // 4. Cache results
    WithResolver(func(p graph.ResolveParams) (*User, error) {
        return userService.GetByID(id)
    }).BuildQuery()
Field-Level Middleware

Apply middleware to specific fields within a type:

graph.NewResolver[User]("users").
    AsList().
    WithFieldMiddleware("email", graph.AuthMiddleware("admin")).
    WithFieldMiddleware("salary", graph.AuthMiddleware("hr")).
    WithResolver(func(p graph.ResolveParams) (*[]User, error) {
        return userService.List(), nil
    }).BuildQuery()
Permission Middleware (Convenience Method)

WithPermission() is a convenience wrapper around WithMiddleware() for authorization:

graph.NewResolver[User]("deleteUser").
    AsMutation().
    WithArg("id", graph.Int).
    WithPermission(graph.AuthMiddleware("admin")).
    WithResolver(func(p graph.ResolveParams, args graph.Args) (*User, error) {
        id := graph.Get[int](args, "id")
        return userService.Delete(id)
    }).BuildMutation()
Advanced Middleware Examples
Context Injection Middleware
func InjectDependencies(db *gorm.DB, cache *redis.Client) graph.FieldMiddleware {
    return func(next graph.FieldResolveFn) graph.FieldResolveFn {
        return func(p graph.ResolveParams) (interface{}, error) {
            ctx := p.Context
            ctx = context.WithValue(ctx, "db", db)
            ctx = context.WithValue(ctx, "cache", cache)

            // Create new params with injected context
            newParams := p
            newParams.Context = ctx

            return next(newParams)
        }
    }
}
Error Handling Middleware
func ErrorHandlingMiddleware(next graph.FieldResolveFn) graph.FieldResolveFn {
    return func(p graph.ResolveParams) (interface{}, error) {
        result, err := next(p)
        if err != nil {
            // Log error
            log.Printf("Error in %s: %v", p.Info.FieldName, err)

            // Transform error for user
            return nil, fmt.Errorf("an error occurred: please contact support")
        }
        return result, nil
    }
}
Performance Tracking Middleware
func MetricsMiddleware(metrics *prometheus.Registry) graph.FieldMiddleware {
    return func(next graph.FieldResolveFn) graph.FieldResolveFn {
        return func(p graph.ResolveParams) (interface{}, error) {
            start := time.Now()
            result, err := next(p)
            duration := time.Since(start)

            // Record metrics
            fieldName := fmt.Sprintf("%s.%s", p.Info.ParentType.Name(), p.Info.FieldName)
            recordMetric(metrics, fieldName, duration, err != nil)

            return result, err
        }
    }
}
Middleware Best Practices
  1. Order matters: Place authentication before caching, logging outermost
  2. Keep middleware pure: Avoid side effects when possible
  3. Use context for data: Pass request-scoped data via context
  4. Handle errors gracefully: Always return meaningful errors
  5. Measure performance: Use logging/metrics middleware to track slow resolvers
  6. Batch database queries: Use dataloaders to prevent N+1 queries

GraphQL Subscriptions

Real-time event streaming over WebSocket with type-safe resolvers and middleware support.

Features
  • 🔄 Real-time Events - Stream data to clients over WebSocket
  • 🎯 Type-Safe Resolvers - Generic subscription builders with compile-time safety
  • 🔌 Dual Protocol Support - Works with both graphql-ws (modern) and subscriptions-transport-ws (legacy)
  • 🏗️ Fluent Builder API - Same intuitive API as queries and mutations
  • 🛡️ Middleware Support - Apply authentication, logging, and custom middleware
  • 🔍 Event Filtering - Filter events before sending to clients
  • 📦 Pluggable PubSub - In-memory, Redis, Kafka, or custom backends
  • 🎭 Field-Level Control - Custom resolvers and middleware per field
Quick Start
package main

import (
    "context"
    "encoding/json"
    "net/http"
    "time"
    "github.com/graphql-go/graphql"
    graph "github.com/paulmanoni/go-graph"
)

type MessageEvent struct {
    ID        string    `json:"id"`
    Content   string    `json:"content"`
    Author    string    `json:"author"`
    Timestamp time.Time `json:"timestamp"`
}

func main() {
    // Initialize PubSub system
    pubsub := graph.NewInMemoryPubSub()
    defer pubsub.Close()

    // Create subscription
    messageSubscription := graph.NewSubscription[MessageEvent]("messageAdded").
        WithDescription("Subscribe to new messages").
        WithArgs(graphql.FieldConfigArgument{
            "channelID": &graphql.ArgumentConfig{
                Type: graphql.NewNonNull(graphql.String),
            },
        }).
        WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *MessageEvent, error) {
            channelID := graph.Get[string](graph.ArgsMap(p.Args), "channelID")

            // Create output channel
            events := make(chan *MessageEvent, 10)

            // Subscribe to PubSub topic
            subscription := pubsub.Subscribe(ctx, "messages:"+channelID)

            // Forward events to GraphQL channel
            go func() {
                defer close(events)
                for msg := range subscription {
                    var event MessageEvent
                    json.Unmarshal(msg.Data, &event)
                    events <- &event
                }
            }()

            return events, nil
        }).
        BuildSubscription()

    // Build GraphQL handler with subscriptions
    handler := graph.NewHTTP(&graph.GraphContext{
        SchemaParams: &graph.SchemaBuilderParams{
            QueryFields:        []graph.QueryField{...},
            MutationFields:     []graph.MutationField{...},
            SubscriptionFields: []graph.SubscriptionField{messageSubscription},
        },
        PubSub:              pubsub,
        EnableSubscriptions: true,
        Playground:          true,
    })

    http.Handle("/graphql", handler)
    http.ListenAndServe(":8080", nil)
}

Test the subscription:

subscription {
  messageAdded(channelID: "general") {
    id
    content
    author
    timestamp
  }
}
PubSub System

The PubSub interface enables pluggable backends for event distribution:

type PubSub interface {
    Publish(ctx context.Context, topic string, data interface{}) error
    Subscribe(ctx context.Context, topic string) <-chan *Message
    Unsubscribe(ctx context.Context, subscriptionID string) error
    Close() error
}
In-Memory PubSub (Development)

Perfect for development and single-instance deployments:

pubsub := graph.NewInMemoryPubSub()
defer pubsub.Close()

// Publish events
ctx := context.Background()
pubsub.Publish(ctx, "messages:general", &MessageEvent{
    ID:      "1",
    Content: "Hello!",
    Author:  "Alice",
})

// Subscribe to events
subscription := pubsub.Subscribe(ctx, "messages:general")
for msg := range subscription {
    // Process message
}
Custom PubSub Backends

Implement the PubSub interface for Redis, Kafka, or other message brokers:

type RedisPubSub struct {
    client *redis.Client
    // ... implementation
}

func (r *RedisPubSub) Publish(ctx context.Context, topic string, data interface{}) error {
    jsonData, _ := json.Marshal(data)
    return r.client.Publish(ctx, topic, jsonData).Err()
}

func (r *RedisPubSub) Subscribe(ctx context.Context, topic string) <-chan *graph.Message {
    // ... implementation
}

// Use with GraphContext
handler := graph.NewHTTP(&graph.GraphContext{
    PubSub: &RedisPubSub{client: redisClient},
    EnableSubscriptions: true,
})
Subscription Resolver API

The NewSubscription[T] builder provides a fluent API for creating type-safe subscriptions:

graph.NewSubscription[EventType]("subscriptionName").
    WithDescription("Description of the subscription").
    WithArgs(graphql.FieldConfigArgument{...}).
    WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *EventType, error) {
        // Return a channel that emits events
    }).
    WithFilter(func(ctx context.Context, data *EventType, p graph.ResolveParams) bool {
        // Filter events before sending to clients
        return true
    }).
    WithMiddleware(graph.AuthMiddleware("user")).
    WithFieldResolver("customField", func(p graph.ResolveParams) (interface{}, error) {
        // Custom resolver for specific fields
    }).
    WithFieldMiddleware("sensitiveField", graph.AuthMiddleware("admin")).
    BuildSubscription()
Basic Subscription
type UserStatusEvent struct {
    UserID    string    `json:"userID"`
    Status    string    `json:"status"`
    Timestamp time.Time `json:"timestamp"`
}

func userStatusSubscription(pubsub graph.PubSub) graph.SubscriptionField {
    return graph.NewSubscription[UserStatusEvent]("userStatusChanged").
        WithDescription("Subscribe to user status changes").
        WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *UserStatusEvent, error) {
            events := make(chan *UserStatusEvent, 10)
            subscription := pubsub.Subscribe(ctx, "user_status")

            go func() {
                defer close(events)
                for msg := range subscription {
                    var event UserStatusEvent
                    json.Unmarshal(msg.Data, &event)
                    events <- &event
                }
            }()

            return events, nil
        }).
        BuildSubscription()
}
With Arguments
func messageSubscription(pubsub graph.PubSub) graph.SubscriptionField {
    return graph.NewSubscription[Message]("messageAdded").
        WithArgs(graphql.FieldConfigArgument{
            "channelID": &graphql.ArgumentConfig{
                Type:        graphql.NewNonNull(graphql.String),
                Description: "Channel to subscribe to",
            },
        }).
        WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *Message, error) {
            channelID := graph.Get[string](graph.ArgsMap(p.Args), "channelID")

            events := make(chan *Message, 10)
            subscription := pubsub.Subscribe(ctx, "messages:"+channelID)

            go func() {
                defer close(events)
                for msg := range subscription {
                    var message Message
                    json.Unmarshal(msg.Data, &message)
                    events <- &message
                }
            }()

            return events, nil
        }).
        BuildSubscription()
}
With Event Filtering

Filter events based on user permissions or subscription criteria:

func messageSubscription(pubsub graph.PubSub) graph.SubscriptionField {
    return graph.NewSubscription[Message]("messageAdded").
        WithArgs(graphql.FieldConfigArgument{
            "channelID": &graphql.ArgumentConfig{Type: graphql.String},
        }).
        WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *Message, error) {
            // Subscribe to all messages
            events := make(chan *Message, 10)
            subscription := pubsub.Subscribe(ctx, "messages")

            go func() {
                defer close(events)
                for msg := range subscription {
                    var message Message
                    json.Unmarshal(msg.Data, &message)
                    events <- &message
                }
            }()

            return events, nil
        }).
        WithFilter(func(ctx context.Context, data *Message, p graph.ResolveParams) bool {
            // Filter by channel ID
            channelID := graph.Get[string](graph.ArgsMap(p.Args), "channelID")
            return data.ChannelID == channelID
        }).
        BuildSubscription()
}
With Authentication Middleware

Protect subscriptions with authentication:

func adminSubscription(pubsub graph.PubSub) graph.SubscriptionField {
    return graph.NewSubscription[AdminEvent]("adminEvents").
        WithDescription("Admin-only event stream").
        WithMiddleware(graph.AuthMiddleware("admin")).
        WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *AdminEvent, error) {
            // Only admins reach here
            events := make(chan *AdminEvent, 10)
            subscription := pubsub.Subscribe(ctx, "admin_events")

            go func() {
                defer close(events)
                for msg := range subscription {
                    var event AdminEvent
                    json.Unmarshal(msg.Data, &event)
                    events <- &event
                }
            }()

            return events, nil
        }).
        BuildSubscription()
}
With Field-Level Customization

Customize how specific fields are resolved:

type MessageEvent struct {
    ID       string `json:"id"`
    Content  string `json:"content"`
    AuthorID string `json:"authorID"`
}

func messageSubscription(pubsub graph.PubSub) graph.SubscriptionField {
    return graph.NewSubscription[MessageEvent]("messageAdded").
        WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *MessageEvent, error) {
            // ... resolver implementation
        }).
        WithFieldResolver("author", func(p graph.ResolveParams) (interface{}, error) {
            // Custom resolver for author field
            event := p.Source.(MessageEvent)
            return userService.GetByID(event.AuthorID), nil
        }).
        WithFieldMiddleware("content", graph.AuthMiddleware("user")).
        BuildSubscription()
}
WebSocket Configuration

Configure WebSocket behavior through GraphContext:

handler := graph.NewHTTP(&graph.GraphContext{
    SchemaParams: &graph.SchemaBuilderParams{
        SubscriptionFields: []graph.SubscriptionField{...},
    },

    // Enable subscriptions
    EnableSubscriptions: true,

    // PubSub backend
    PubSub: pubsub,

    // Optional: Custom WebSocket path (default: auto-detects)
    WebSocketPath: "/subscriptions",

    // Optional: Custom origin check
    WebSocketCheckOrigin: func(r *http.Request) bool {
        origin := r.Header.Get("Origin")
        return origin == "https://example.com"
    },

    // Optional: Authentication for WebSocket connections
    UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
        user, err := validateAndGetUser(token)
        return ctx, user, err
    },
})
Authentication in Subscriptions

WebSocket authentication works similarly to HTTP:

handler := graph.NewHTTP(&graph.GraphContext{
    SchemaParams: &graph.SchemaBuilderParams{
        SubscriptionFields: []graph.SubscriptionField{...},
    },
    EnableSubscriptions: true,
    PubSub:              pubsub,

    // Extract user details from token
    UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
        user, err := validateJWT(token)
        if err != nil {
            return ctx, nil, err
        }
        return ctx, user, nil
    },
})

Client sends token during connection initialization:

const client = new SubscriptionClient('ws://localhost:8080/graphql', {
  connectionParams: {
    authorization: 'Bearer <token>',
  },
});

Access user details in subscription resolver:

func messageSubscription(pubsub graph.PubSub) graph.SubscriptionField {
    return graph.NewSubscription[Message]("messageAdded").
        WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *Message, error) {
            // Get authenticated user
            var user User
            if err := graph.GetRootInfo(p, "details", &user); err != nil {
                return nil, fmt.Errorf("authentication required")
            }

            // Subscribe to user-specific messages
            events := make(chan *Message, 10)
            subscription := pubsub.Subscribe(ctx, "messages:"+user.ID)

            // ... forward events

            return events, nil
        }).
        BuildSubscription()
}
Publishing Events from Mutations

Trigger subscription events from mutations:

func sendMessageMutation(pubsub graph.PubSub) graph.MutationField {
    type SendMessageInput struct {
        ChannelID string `json:"channelID" graphql:"channelID,required"`
        Content   string `json:"content" graphql:"content,required"`
    }

    return graph.NewResolver[Message]("sendMessage").
        WithInputObject(SendMessageInput{}).
        WithResolver(func(p graph.ResolveParams) (*Message, error) {
            var input SendMessageInput
            graph.GetArg(p, "input", &input)

            // Create message
            msg := &Message{
                ID:        uuid.New().String(),
                Content:   input.Content,
                ChannelID: input.ChannelID,
                Timestamp: time.Now(),
            }

            // Store in database
            db.Create(msg)

            // Publish to subscribers
            ctx := context.Background()
            pubsub.Publish(ctx, "messages:"+input.ChannelID, msg)

            return msg, nil
        }).
        BuildMutation()
}
Protocol Support

The WebSocket handler supports both modern and legacy protocols:

Protocol Support Client
graphql-ws ✅ Full Apollo Client 3+, urql
subscriptions-transport-ws ✅ Full Apollo Client 2, GraphQL Playground

Both protocols work simultaneously - no configuration needed.

Production Considerations
Connection Limits

Monitor and limit concurrent WebSocket connections:

var (
    maxConnections = 10000
    currentConns   int64
)

handler := graph.NewHTTP(&graph.GraphContext{
    EnableSubscriptions: true,
    WebSocketCheckOrigin: func(r *http.Request) bool {
        if atomic.LoadInt64(&currentConns) >= int64(maxConnections) {
            return false
        }
        atomic.AddInt64(&currentConns, 1)
        return true
    },
})
Event Buffering

Use buffered channels to handle bursts:

// Good: Buffered channel prevents blocking
events := make(chan *Message, 100)

// Bad: Unbuffered channel may block publishers
events := make(chan *Message)
Error Handling

Handle errors gracefully in subscription resolvers:

WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *Event, error) {
    // Validate arguments first using type-safe API
    channelID, err := graph.GetE[string](graph.ArgsMap(p.Args), "channelID")
    if err != nil || channelID == "" {
        return nil, fmt.Errorf("channelID required")
    }

    // Create subscription
    events := make(chan *Event, 10)
    subscription := pubsub.Subscribe(ctx, "events:"+channelID)

    go func() {
        defer close(events)
        for msg := range subscription {
            var event Event
            if err := json.Unmarshal(msg.Data, &event); err != nil {
                log.Printf("Failed to unmarshal event: %v", err)
                continue // Skip malformed events
            }
            events <- &event
        }
    }()

    return events, nil
})
Graceful Shutdown

Close all connections during shutdown:

func main() {
    pubsub := graph.NewInMemoryPubSub()

    handler := graph.NewHTTP(&graph.GraphContext{
        PubSub:              pubsub,
        EnableSubscriptions: true,
    })

    server := &http.Server{Addr: ":8080", Handler: handler}

    // Graceful shutdown
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

    go func() {
        <-sigChan
        ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
        defer cancel()

        // Close PubSub (closes all subscriptions)
        pubsub.Close()

        // Shutdown HTTP server
        server.Shutdown(ctx)
    }()

    server.ListenAndServe()
}
Complete Example

See examples/subscription for a complete working example with:

  • Message subscriptions with channel filtering
  • User status change subscriptions
  • Authentication and authorization
  • Mutations that trigger subscription events
  • Background event simulator

Run the example:

cd examples/subscription
go run main.go

Then test subscriptions in GraphQL Playground at http://localhost:8080/graphql

Framework Integration

With Gin
import (
    "github.com/gin-gonic/gin"
    "github.com/paulmanoni/go-graph"
)

func main() {
    r := gin.Default()

    handler := graph.NewHTTP(&graph.GraphContext{
        SchemaParams:     &graph.SchemaBuilderParams{...},
        EnableValidation: true,
    })

    r.POST("/graphql", gin.WrapF(handler))
    r.GET("/graphql", gin.WrapF(handler))

    r.Run(":8080")
}
With Chi
import (
    "github.com/go-chi/chi/v5"
    "github.com/paulmanoni/go-graph"
)

func main() {
    r := chi.NewRouter()

    handler := graph.NewHTTP(&graph.GraphContext{
        SchemaParams: &graph.SchemaBuilderParams{...},
    })

    r.Handle("/graphql", handler)

    http.ListenAndServe(":8080", r)
}
With Standard net/http
handler := graph.NewHTTP(&graph.GraphContext{
    SchemaParams: &graph.SchemaBuilderParams{...},
})

http.Handle("/graphql", handler)
http.ListenAndServe(":8080", nil)

API Reference

NewHTTP(graphCtx *GraphContext) http.HandlerFunc

Creates a standard HTTP handler with validation and sanitization support.

GraphContext Configuration
Field Type Default Description
Schema *graphql.Schema nil Custom GraphQL schema (Option 3)
SchemaParams *SchemaBuilderParams nil Builder params (Option 2)
Playground bool false Enable GraphQL Playground
Pretty bool false Pretty-print JSON responses
DEBUG bool false Skip validation/sanitization
EnableValidation bool false Enable query validation
EnableSanitization bool false Enable error sanitization
TokenExtractorFn func(*http.Request) string Bearer token Custom token extraction
UserDetailsFn func(context.Context, string) (context.Context, interface{}, error) nil Fetch user from token and optionally update context
RootObjectFn func(context.Context, *http.Request) map[string]interface{} nil Custom root setup

Note: If both Schema and SchemaParams are nil, a default hello world schema is used.

SchemaBuilderParams
type SchemaBuilderParams struct {
    QueryFields        []QueryField
    MutationFields     []MutationField
    SubscriptionFields []SubscriptionField  // Optional: Real-time subscriptions
}

Testing

The library includes comprehensive test coverage including unit tests and benchmarks for all features including queries, mutations, and subscriptions.

Running Unit Tests
# Run all tests
go test -v

# Run tests with coverage
go test -cover

# Generate coverage report
go test -coverprofile=coverage.out
go tool cover -html=coverage.out

# Run specific test
go test -run TestSubscription_WithFilter

# Run tests for a specific file
go test -run Subscription
Subscription Tests

The subscription implementation includes extensive unit tests covering:

  • Basic subscription creation - Creating and configuring subscriptions
  • Event streaming - Testing event channels and data flow
  • Event filtering - Filtering events based on subscription parameters
  • Middleware integration - Applying middleware to subscriptions
  • PubSub integration - Testing with in-memory and custom PubSub backends
  • Context cancellation - Proper cleanup when subscriptions are cancelled
  • Type generation - Auto-generating GraphQL types from Go structs
  • Error handling - Proper error propagation and recovery

Example test run:

# Run all subscription tests
go test -v -run Subscription

# Run specific subscription test
go test -v -run TestSubscription_WithFilter
Writing Tests for Your Resolvers

Here's an example of how to test your custom resolvers:

func TestMySubscription(t *testing.T) {
    type MyEvent struct {
        ID      string `json:"id"`
        Message string `json:"message"`
    }

    sub := graph.NewSubscription[MyEvent]("myEvent").
        WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *MyEvent, error) {
            ch := make(chan *MyEvent, 1)
            ch <- &MyEvent{ID: "1", Message: "test"}
            close(ch)
            return ch, nil
        }).
        BuildSubscription()

    field := sub.Serve()

    // Test subscription execution
    result, err := field.Subscribe(graphql.ResolveParams{
        Context: context.Background(),
    })

    if err != nil {
        t.Fatalf("Subscribe error: %v", err)
    }

    outputCh, ok := result.(<-chan interface{})
    if !ok {
        t.Fatalf("Expected channel, got %T", result)
    }

    // Collect events
    var received []MyEvent
    for event := range outputCh {
        received = append(received, event.(MyEvent))
    }

    if len(received) != 1 {
        t.Errorf("Expected 1 event, got %d", len(received))
    }
}

Examples

See the examples directory for complete working examples:

  • main.go - Full example with authentication
  • subscription/main.go - Real-time subscriptions with WebSocket

Performance Benchmarks

Comprehensive benchmarks are included to measure performance across all package operations.

Running Benchmarks
# Run all benchmarks
go test -bench=. -benchmem

# Run specific benchmark
go test -bench=BenchmarkExtractBearerToken -benchmem

# Run with longer duration for more accurate results
go test -bench=. -benchmem -benchtime=5s

# Save results for comparison
go test -bench=. -benchmem > bench_results.txt
Benchmark Results

Performance metrics on Apple M1 Pro (results will vary by hardware):

Core Operations
Operation Time/op Allocations Description
Token Extraction ~31 ns 0 allocs Bearer token from header
Type Registration ~14 ns 0 allocs Object type caching
Get[string] ~10 ns 0 allocs Extract string argument
Get[int] ~10 ns 0 allocs Extract int argument
Get[bool] ~10 ns 0 allocs Extract bool argument
GetRootString ~10 ns 0 allocs Extract root string value
Schema Building
Operation Time/op Allocations Description
Simple Schema ~9 μs 122 allocs Default hello/echo schema
Complex Schema ~10 μs 147 allocs Multiple types with nesting
Schema from Context ~8-10 μs 109-136 allocs Build from GraphContext
Query Validation
Operation Time/op Allocations Description
Simple Query ~700 ns 27 allocs Basic field selection
Complex Query ~3.2 μs 103 allocs Nested 3 levels deep
Deep Query ~2.2 μs 72 allocs Nested 5+ levels
With Aliases ~3.9 μs 130 allocs Multiple field aliases
Depth Calculation ~6-16 ns 0 allocs AST traversal
Alias Counting ~20 ns 0 allocs AST analysis
Complexity Calc ~13 ns 0 allocs Complexity scoring
HTTP Handler Performance
Operation Time/op Allocations Description
Debug Mode ~28 μs 439 allocs No validation/sanitization
With Validation ~28 μs 478 allocs Query validation enabled
With Sanitization ~34 μs 607 allocs Error sanitization enabled
With Auth ~27 μs 443 allocs Token + user details fetch
Complete Stack ~60 μs 966 allocs All features enabled
GET Request ~29 μs 436 allocs Query string parsing
Resolver Creation
Operation Time/op Allocations Description
Simple Resolver ~234 ns 5 allocs Basic type resolver
With Arguments ~349 ns 9 allocs Field arguments included
List Resolver ~186 ns 5 allocs Array type resolver
Paginated ~230 ns 5 allocs Pagination wrapper
With Input Object ~411 ns 10 allocs Input type generation
Type-Safe Arguments (NewArgsResolver)
Operation Time/op Allocations Description
Struct Args Creation ~874 ns 17 allocs Create resolver with struct args
Primitive Args Creation ~372 ns 11 allocs Create resolver with primitive args
Nested Struct Args Creation ~581 ns 15 allocs Create resolver with nested structs
List Resolver Creation ~544 ns 14 allocs Create list resolver with args
Execute Struct Args ~259 ns 5 allocs Execute resolver with struct args
Execute Primitive Args ~66 ns 2 allocs Execute resolver with primitive args
Execute Nested Structs ~397 ns 6 allocs Execute resolver with nested structs
Execute With Context ~158 ns 4 allocs Execute with context.Context
Generate Args From Type ~1.3 μs 22 allocs Auto-generate GraphQL args from struct
Map Args to Struct ~457 ns 7 allocs Parse and map GraphQL args to Go struct
Map Nested Struct ~346 ns 4 allocs Parse nested struct arguments
Advanced Features
Operation Time/op Allocations Description
GetRootInfo ~742 ns 12 allocs Complex type extraction
GetArg (Complex) ~1.1 μs 15 allocs Struct argument parsing
Response Sanitization ~5.4 μs 80 allocs Regex error cleaning
Cached Field Resolver ~5.6 ns 0 allocs Cache hit scenario
Response Write ~3.4 ns 0 allocs Buffer write operation
Subscription Performance

Comprehensive benchmarks for real-time subscription operations:

Operation Time/op Allocations Description
Build Subscription ~200-600 ns 5-15 allocs Create subscription resolver
Build Complex Subscription ~800 ns 17 allocs With args, filter, middleware
Execute Subscription ~500-1000 ns 10-20 allocs Start event streaming
With Filter ~1-2 μs 15-25 allocs Filter 100 events
With Middleware ~500 ns 10 allocs 3 middleware layers
UnmarshalSubscriptionMessage ~300 ns 5 allocs JSON parsing
Event Throughput ~1-2 μs 20 allocs 1000 events/subscription
With PubSub ~1 μs 15 allocs PubSub integration
Type Generation ~800 ns 15 allocs Complex event type
Concurrent Subscriptions ~500 ns 12 allocs Parallel execution
Schema with Subscriptions ~10 μs 150 allocs Multiple subscriptions

Key Observations:

  • Subscription creation is fast (~200-800ns) with minimal allocations
  • Event filtering adds minimal overhead (~1-2μs for 100 events)
  • Type generation for complex events is efficient (~800ns)
  • Concurrent subscription handling performs excellently (~500ns per subscription)
  • PubSub integration adds negligible overhead (~1μs)

Running Subscription Benchmarks:

# Run all subscription benchmarks
go test -bench=BenchmarkSubscription -benchmem

# Run specific subscription benchmark
go test -bench=BenchmarkSubscription_WithFilter -benchmem

# Compare with and without middleware
go test -bench=BenchmarkSubscription_Execute -benchmem
go test -bench=BenchmarkSubscription_WithMiddleware -benchmem
Embedded Struct Field Generation

Performance benchmarks for automatic embedded struct flattening:

Operation Time/op Allocations Description
Generate Fields (Embedded) ~1.2 μs 22 allocs Single embedded struct
Generate Fields (Multiple) ~1.4 μs 28 allocs Multiple embedded structs
Generate Fields (Deep) ~1.7 μs 36 allocs 4-level deep embedding
Generate Fields (No Embedding) ~1.1 μs 20 allocs Baseline comparison
Generate Object (Embedded) ~1.4 μs 24 allocs Object with embedded fields
Generate Input (Embedded) ~1.1 μs 18 allocs Input type with embedding
Generate Args (Embedded) ~1.1 μs 20 allocs Args with embedded fields
Field Resolver (Embedded) ~232 ns 5 allocs Resolve embedded field
Parallel Generation ~779 ns 22 allocs Concurrent field generation
Complex Embedding ~2.1 μs 42 allocs Multiple mixed embeddings

Key Observations:

  • Embedded struct field flattening adds minimal overhead (~100-600ns depending on complexity)
  • Deep nesting (4+ levels) handled efficiently (~1.7μs)
  • Field resolution from embedded structs is extremely fast (~232ns)
  • Parallel generation scales well (~779ns vs ~1.2μs sequential)
  • Complex scenarios with multiple embeddings remain performant (~2.1μs)
Concurrency Performance
Operation Time/op Allocations Description
Parallel HTTP Requests ~17 μs 440 allocs Concurrent request handling
Parallel Schema Build ~3 μs 104 allocs Concurrent schema creation
Parallel Type Registration ~145 ns 0 allocs Thread-safe type caching
Parallel Embedded Generation ~779 ns 22 allocs Concurrent embedded field gen
Key Takeaways
  • Zero-allocation primitives: Token extraction and utility functions have zero heap allocations
  • Fast validation: Query validation adds minimal overhead (~700ns-4μs depending on complexity)
  • Type-safe arguments: NewArgsResolver execution is blazing fast (~66ns for primitives, ~259ns for structs)
  • Efficient type generation: Auto-generating GraphQL args from structs adds minimal overhead (~1.3μs one-time cost)
  • Efficient caching: Type registration uses read-write locks for optimal concurrent access
  • Predictable performance: End-to-end request handling is consistently under 100μs
  • Production ready: Complete stack with all security features runs at ~60μs per request
Optimization Tips
  1. Enable caching: Type registration is cached automatically - registered types are reused
  2. Use DEBUG mode wisely: Validation adds ~0-1μs overhead, only disable in development
  3. Minimize complexity: Keep query depth under 10 levels for optimal validation performance
  4. Batch operations: Use concurrent requests for multiple independent queries
  5. Profile your resolvers: The handler overhead is minimal (~30μs), optimize resolver logic first

High Load Performance Analysis

Is This Package Production-Ready for High Traffic?

Yes, absolutely. The benchmarks demonstrate excellent performance characteristics for high-load scenarios:

Throughput Capacity

Based on the benchmark results:

  • Handler overhead: ~60 μs per request (complete stack with all security features)
  • Theoretical capacity: ~16,600 requests/second per core
  • Multi-core scaling: On an 8-core system, potentially 100,000+ RPS (handler only)
Real-World Considerations
  1. Handler overhead is negligible: At 60 μs, the GraphQL handler represents a tiny fraction of total request time

    Example breakdown (not measured, for illustration):
    - GraphQL handler:        60 μs   (0.06%)  ← Measured
    - Database query:      50,000 μs  (50.00%) ← Example
    - External API calls:  45,000 μs  (45.00%) ← Example
    - Business logic:       4,940 μs   (4.94%) ← Example
    Total:               ~100,000 μs  (100 ms)
    
  2. Zero-allocation critical paths: Token extraction and argument parsing have 0 heap allocations, minimizing GC pressure

  3. Thread-safe design: Parallel benchmarks show excellent concurrent performance (17 μs vs 28 μs sequential)

  4. Predictable latency: Performance is consistent - no spikes or unpredictable behavior

Tested Load Scenarios

The package handles these scenarios efficiently:

Scenario Handler Overhead Notes
Simple queries ~28 μs Basic CRUD operations
Complex nested queries ~28 μs 3-5 levels deep
With authentication ~27 μs Token + user details
Full security stack ~60 μs Validation + sanitization + auth
Concurrent requests ~17 μs/req Parallel processing
Production Deployment Recommendations

For high-load production environments:

✅ Do:

  • Enable all security features (EnableValidation, EnableSanitization) - overhead is minimal
  • Use connection pooling for databases (your resolvers, not this package)
  • Implement resolver-level caching for expensive operations
  • Monitor resolver performance (this is where bottlenecks occur)
  • Use load balancing across multiple instances
  • Consider rate limiting at the API gateway level

⚠️ Bottlenecks will be in your code, not this package:

  • Database queries (typically 1-100+ ms)
  • External API calls (typically 10-500+ ms)
  • Complex business logic
  • N+1 query problems (use dataloader pattern)

❌ Don't:

  • Disable security features for "performance" - they add negligible overhead
  • Skip validation in production - the ~1 μs cost is worth it
  • Worry about handler performance - optimize your resolvers first
Memory Efficiency
  • Complete stack: 966 allocations per request (~62 KB)
  • Debug mode: 439 allocations per request (~33 KB)
  • GC impact: Minimal on modern Go runtimes (1.18+)
  • Memory footprint: Low even at 10,000+ concurrent requests
Proven Scalability

The benchmarks show:

  • Linear scaling: No performance degradation with concurrency
  • Type caching: Registered types reused (0 allocs after initial registration)
  • Lock contention: Minimal (RWMutex on type registry)
When NOT to Use This Package

This package may not be suitable if:

  • You need sub-10 μs total latency (extremely rare requirement)
  • You're running on severely resource-constrained environments (embedded systems)
  • You need custom validation rules beyond depth/complexity/aliases
Conclusion

This package is excellent for high-load production environments. The 60 μs overhead is negligible compared to typical resolver operations. Your performance bottlenecks will be in your business logic, database queries, and external API calls - not in this GraphQL handler.

For reference:

  • 100 RPS: Trivial (1% CPU on single core)
  • 1,000 RPS: Easy (10% CPU on single core)
  • 10,000 RPS: Manageable (multi-core, normal load)
  • 100,000 RPS: Achievable (horizontal scaling + optimization)
  • ⚠️ 1,000,000 RPS: Requires distributed architecture (but handler isn't the bottleneck)

The handler is not your problem. Focus on optimizing your resolvers.

License

MIT

Documentation

Overview

Package graph provides a modern, secure GraphQL handler for Go with built-in authentication, validation, and an intuitive builder API.

Built on top of graphql-go (github.com/graphql-go/graphql), this package simplifies GraphQL server development with sensible defaults while maintaining full flexibility.

Features

  • Zero Config Start: Default hello world schema included
  • Fluent Builder API: Clean, type-safe schema construction
  • Built-in Authentication: Automatic Bearer token extraction
  • Security First: Query depth, complexity, and introspection protection
  • Response Sanitization: Remove field suggestions from errors
  • Framework Agnostic: Works with net/http, Gin, Chi, or any HTTP framework

Quick Start

Start immediately with the default schema:

import "github.com/paulmanoni/graph"

func main() {
    handler := graph.NewHTTP(&graph.GraphContext{
        Playground: true,
        DEBUG:      true,
    })
    http.Handle("/graphql", handler)
    http.ListenAndServe(":8080", nil)
}

Builder Pattern

Use the fluent builder API for clean schema construction:

func getUser() graph.QueryField {
    return graph.NewResolver[User]("user").
        WithArgs(graphql.FieldConfigArgument{
            "id": &graphql.ArgumentConfig{Type: graphql.String},
        }).
        WithResolver(func(p graphql.ResolveParams) (interface{}, error) {
            id, _ := graph.GetArgString(p, "id")
            return User{ID: id, Name: "Alice"}, nil
        }).BuildQuery()
}

Authentication

Automatic Bearer token extraction with optional user details fetching:

handler := graph.NewHTTP(&graph.GraphContext{
    SchemaParams: &graph.SchemaBuilderParams{
        QueryFields: []graph.QueryField{getProtectedQuery()},
    },
    UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
        user, err := validateAndGetUser(token)
        if err != nil {
            return ctx, nil, err
        }
        // Add values to context accessible via p.Context.Value() in resolvers
        ctx = context.WithValue(ctx, "userID", user.ID)
        return ctx, user, nil
    },
})

Access token in resolvers:

func getProtectedQuery() graph.QueryField {
    return graph.NewResolver[User]("me").
        WithResolver(func(p graphql.ResolveParams) (interface{}, error) {
            token, err := graph.GetRootString(p, "token")
            if err != nil {
                return nil, fmt.Errorf("authentication required")
            }
            // Use token...
        }).BuildQuery()
}

Security

Enable security features for production:

handler := graph.NewHTTP(&graph.GraphContext{
    SchemaParams:       &graph.SchemaBuilderParams{...},
    DEBUG:              false,  // Enable security features
    EnableValidation:   true,   // Max depth: 10, Max aliases: 4, Max complexity: 200
    EnableSanitization: true,   // Remove field suggestions from errors
    Playground:         false,  // Disable playground in production
})

Helper Functions

Extract arguments safely:

name, err := graph.GetArgString(p, "name")
age, err := graph.GetArgInt(p, "age")
active, err := graph.GetArgBool(p, "active")

Access root values:

token, err := graph.GetRootString(p, "token")
var user User
err := graph.GetRootInfo(p, "details", &user)

For more information, see https://github.com/paulmanoni/graph

Index

Constants

View Source
const (
	// Client -> Server (graphql-ws)
	MessageTypeConnectionInit = "connection_init"
	MessageTypeSubscribe      = "subscribe"
	MessageTypePing           = "ping"

	// Client -> Server (subscriptions-transport-ws - legacy)
	MessageTypeStart = "start" // Legacy equivalent of "subscribe"
	MessageTypeStop  = "stop"  // Legacy equivalent of "complete"

	// Server -> Client (graphql-ws)
	MessageTypeConnectionAck = "connection_ack"
	MessageTypeNext          = "next"
	MessageTypeError         = "error"
	MessageTypeComplete      = "complete"
	MessageTypePong          = "pong"

	// Server -> Client (subscriptions-transport-ws - legacy)
	MessageTypeData                = "data"             // Legacy equivalent of "next"
	MessageTypeConnectionError     = "connection_error" // Legacy connection error
	MessageTypeConnectionKeepAlive = "ka"               // Legacy keep-alive
)

GraphQL WebSocket Protocol message types Supports both graphql-ws (new) and subscriptions-transport-ws (legacy) protocols

View Source
const SpringShortLayout = "2006-01-02T15:04"

SpringShortLayout is the time format used by Spring Boot for DateTime serialization. Format: yyyy-MM-dd'T'HH:mm (e.g., "2024-01-15T14:30")

Variables

View Source
var (
	String  = graphql.String
	Int     = graphql.Int
	Float   = graphql.Float
	Boolean = graphql.Boolean
	ID      = graphql.ID
)

GraphQL scalar type constants for use with WithArg These mirror Go's type names for intuitive usage:

WithArg("id", graph.String)   // like using 'string'
WithArg("limit", graph.Int)   // like using 'int'
View Source
var (
	// SecurityRules provides standard security validation
	// - Max depth: 10
	// - Max complexity: 200
	// - Max aliases: 4
	// - No introspection
	SecurityRules = []ValidationRule{
		NewMaxDepthRule(10),
		NewMaxComplexityRule(200),
		NewMaxAliasesRule(4),
		NewNoIntrospectionRule(),
	}

	// StrictSecurityRules provides strict security for production
	// - Max depth: 8
	// - Max complexity: 150
	// - Max aliases: 3
	// - Max tokens: 500
	// - No introspection
	StrictSecurityRules = []ValidationRule{
		NewMaxDepthRule(8),
		NewMaxComplexityRule(150),
		NewMaxAliasesRule(3),
		NewMaxTokensRule(500),
		NewNoIntrospectionRule(),
	}

	// DevelopmentRules provides lenient rules for development
	// - Max depth: 20
	// - Max complexity: 500
	DevelopmentRules = []ValidationRule{
		NewMaxDepthRule(20),
		NewMaxComplexityRule(500),
	}
)
View Source
var (
	// AdminOnlyFields - fields that require admin role
	AdminOnlyFields = map[string][]string{
		"deleteUser":     {"admin"},
		"deleteAccount":  {"admin"},
		"viewAuditLog":   {"admin"},
		"systemSettings": {"admin"},
		"manageRoles":    {"admin"},
	}

	// ManagerFields - fields that require admin or manager role
	ManagerFields = map[string][]string{
		"approveOrder":   {"admin", "manager"},
		"viewReports":    {"admin", "manager"},
		"manageTeam":     {"admin", "manager"},
		"bulkOperations": {"admin", "manager"},
	}

	// AuditorFields - fields that require admin or auditor role
	AuditorFields = map[string][]string{
		"viewAuditLog":  {"admin", "auditor"},
		"exportLogs":    {"admin", "auditor"},
		"viewAnalytics": {"admin", "auditor"},
	}
)

Common role configurations for convenience

View Source
var (
	ErrPubSubClosed         = newError("pubsub is closed")
	ErrSubscriptionNotFound = newError("subscription not found")
)

Common errors

View Source
var DateTime = graphql.NewScalar(graphql.ScalarConfig{
	Name:        "DateTime",
	Description: "The `DateTime` scalar type formatted as yyyy-MM-dd'T'HH:mm",
	Serialize:   serializeDateTime,
	ParseValue:  unserializeDateTime,
	ParseLiteral: func(valueAST ast.Value) interface{} {
		if v, ok := valueAST.(*ast.StringValue); ok {
			return unserializeDateTime(v.Value)
		}
		return nil
	},
})

DateTime is a GraphQL scalar type for date-time values. It uses the Spring Boot date format: yyyy-MM-dd'T'HH:mm (e.g., "2024-01-15T14:30"). All times are automatically converted to UTC.

Usage in struct fields:

type Event struct {
    Name      string    `json:"name"`
    StartTime time.Time `json:"startTime"` // Will use DateTime scalar
}

The scalar automatically handles:

  • Serialization: time.Time → "2024-01-15T14:30"
  • Deserialization: "2024-01-15T14:30" → time.Time
  • UTC conversion for all values

Functions

func AsyncFieldResolver

func AsyncFieldResolver(resolver graphql.FieldResolveFn) graphql.FieldResolveFn

AsyncFieldResolver executes a resolver asynchronously

func CachedFieldResolver

func CachedFieldResolver(cacheKey func(graphql.ResolveParams) string, resolver graphql.FieldResolveFn) graphql.FieldResolveFn

CachedFieldResolver caches field results with a key function

func ConditionalResolver

func ConditionalResolver(condition func(graphql.ResolveParams) bool, ifTrue, ifFalse graphql.FieldResolveFn) graphql.FieldResolveFn

ConditionalResolver resolves based on a condition

func DataTransformResolver

func DataTransformResolver(transform func(interface{}) interface{}) graphql.FieldResolveFn

DataTransformResolver applies a transformation to a field value

func ExecuteValidationRules added in v1.1.1

func ExecuteValidationRules(
	queryString string,
	schema *graphql.Schema,
	rules []ValidationRule,
	userDetails interface{},
	options *ValidationOptions,
) error

ExecuteValidationRules executes a set of validation rules against a GraphQL query. This is the modern validation system that supports custom rules.

Parameters:

  • queryString: The GraphQL query to validate
  • schema: The GraphQL schema
  • rules: The validation rules to execute
  • authCtx: Authentication context (can be nil if not needed)
  • options: Validation options (can be nil for defaults)

Returns:

  • nil if validation passes
  • *ValidationError for single rule failure
  • *MultiValidationError for multiple rule failures

Example:

rules := []ValidationRule{
    NewMaxDepthRule(10),
    NewRequireAuthRule("mutation"),
    NewRoleRules(AdminOnlyFields),
}
err := ExecuteValidationRules(query, schema, rules, authCtx, nil)

func ExtractBearerToken

func ExtractBearerToken(r *http.Request) string

ExtractBearerToken extracts the Bearer token from the Authorization header. It performs case-insensitive matching for the "Bearer " prefix and trims whitespace.

Returns an empty string if:

  • The Authorization header is missing
  • The header doesn't start with "Bearer " (case-insensitive)
  • The token value is empty

Example:

// Authorization: Bearer abc123xyz
token := graph.ExtractBearerToken(r) // Returns: "abc123xyz"

func GenerateArgsFromStruct

func GenerateArgsFromStruct[T any]() graphql.FieldConfigArgument

func GenerateGraphQLFields

func GenerateGraphQLFields[T any]() graphql.Fields

func GenerateGraphQLObject

func GenerateGraphQLObject[T any](name string) *graphql.Object

func GenerateInputObject

func GenerateInputObject[T any](name string) *graphql.InputObject

func Get added in v1.1.7

func Get[T any](a ArgsGetter, name string) T

Get retrieves an argument by name with type safety. Returns zero value if key is missing or conversion fails. Use GetE for explicit error handling.

Works with both graph.Args (from WithArg) and graph.ArgsMap (from p.Args):

// With graph.Args (from WithArg chainable API)
id := graph.Get[string](args, "id")

// With p.Args (from graphql.FieldConfigArgument)
channelID := graph.Get[string](graph.ArgsMap(p.Args), "channelID")

func GetArg

func GetArg(p ResolveParams, key string, target interface{}) error

GetArg safely extracts a value from p.Args and unmarshals it into the target. This is useful for extracting complex types like structs, slices, or maps.

The function handles:

  • Primitive types (string, int, bool) with optimized direct assignment
  • Complex types using JSON marshaling/unmarshaling for type conversion
  • Type mismatches with descriptive error messages

Returns an error if:

  • The argument key doesn't exist
  • Type conversion fails

Example:

var input CreateUserInput
if err := graph.GetArg(p, "input", &input); err != nil {
    return nil, err
}
// Use input.Name, input.Email, etc.

func GetArgBool

func GetArgBool(p ResolveParams, key string) (bool, error)

GetArgBool safely extracts a bool argument from p.Args. Returns an error if the argument doesn't exist or is not a boolean.

Example:

active, err := graph.GetArgBool(p, "active")

func GetArgInt

func GetArgInt(p ResolveParams, key string) (int, error)

GetArgInt safely extracts an int argument from p.Args. Handles both int and float64 types (JSON numbers are parsed as float64). Returns an error if the argument doesn't exist or is not a number.

Example:

age, err := graph.GetArgInt(p, "age")

func GetArgString

func GetArgString(p ResolveParams, key string) (string, error)

GetArgString safely extracts a string argument from p.Args. Returns an error if the argument doesn't exist or is not a string.

Example:

name, err := graph.GetArgString(p, "name")

func GetE added in v1.1.8

func GetE[T any](a ArgsGetter, name string) (T, error)

GetE retrieves an argument by name with type safety and error handling. Returns an error if the key is missing or type conversion fails.

Works with both graph.Args and graph.ArgsMap (p.Args):

// With graph.Args
input, err := graph.GetE[UserInput](args, "input")

// With p.Args
channelID, err := graph.GetE[string](graph.ArgsMap(p.Args), "channelID")

func GetOr added in v1.1.7

func GetOr[T any](a ArgsGetter, name string, defaultVal T) T

GetOr retrieves an argument by name with a default value if not found.

Works with both graph.Args and graph.ArgsMap (p.Args):

// With graph.Args
limit := graph.GetOr[int](args, "limit", 10)

// With p.Args
limit := graph.GetOr[int](graph.ArgsMap(p.Args), "limit", 10)

func GetRootInfo

func GetRootInfo(p ResolveParams, key string, target interface{}) error

GetRootInfo safely extracts a value from p.Info.RootValue and unmarshals it into the target. This is commonly used to retrieve user details set by UserDetailsFn in the GraphContext.

The function handles:

  • Primitive types (string, int) with optimized direct assignment
  • Complex types using JSON marshaling/unmarshaling for type conversion
  • Type mismatches with descriptive error messages

Returns an error if:

  • Root value is nil or not a map
  • The key doesn't exist in the root value
  • Type conversion fails

Example:

// In your resolver
var user UserDetails
if err := graph.GetRootInfo(p, "details", &user); err != nil {
    return nil, fmt.Errorf("authentication required")
}
// Use user.ID, user.Email, etc.

func GetRootString

func GetRootString(p ResolveParams, key string) (string, error)

GetRootString safely extracts a string value from p.Info.RootValue. This is commonly used to retrieve the authentication token.

Returns an error if:

  • Root value is nil or not a map
  • The key doesn't exist in the root value
  • The value is not a string

Example:

// Get authentication token
token, err := graph.GetRootString(p, "token")
if err != nil {
    return nil, fmt.Errorf("authentication required")
}
// Validate token...

func GetTypeName

func GetTypeName[T any]() string

func LazyFieldResolver

func LazyFieldResolver(fieldName string, loader func(interface{}) (interface{}, error)) graphql.FieldResolveFn

LazyFieldResolver loads a field only when requested

func MergeRoleConfigs added in v1.1.1

func MergeRoleConfigs(configs ...map[string][]string) map[string][]string

MergeRoleConfigs combines multiple role configurations

Example:

allRoles := MergeRoleConfigs(AdminOnlyFields, ManagerFields, AuditorFields)

func MustGet added in v1.1.8

func MustGet[T any](a ArgsGetter, name string) T

MustGet retrieves an argument by name with type safety. Panics if the key is missing or conversion fails. Use only when you're certain the argument exists and is valid.

Works with both graph.Args and graph.ArgsMap (p.Args).

func New

func New(graphCtx GraphContext) (*handler.Handler, error)

New creates a GraphQL handler from the provided GraphContext. It builds the schema and sets up authentication with token extraction and user details.

The handler automatically:

  • Extracts tokens using TokenExtractorFn (defaults to Bearer token extraction)
  • Fetches user details using UserDetailsFn if provided
  • Adds token and details to the root value for access in resolvers

Returns an error if schema building fails.

Example:

handler, err := graph.New(graph.GraphContext{
    SchemaParams: &graph.SchemaBuilderParams{
        QueryFields: []graph.QueryField{getUserQuery()},
    },
    Playground: true,
})

func NewHTTP

func NewHTTP(graphCtx *GraphContext) http.HandlerFunc

NewHTTP creates a standard http.HandlerFunc with built-in validation and sanitization support. This is the recommended way to create a GraphQL handler for production use.

The handler automatically detects WebSocket upgrade requests and handles them appropriately when subscriptions are enabled (EnableSubscriptions: true).

The handler is fully compatible with net/http and any HTTP framework (Gin, Chi, Echo, etc.). If graphCtx is nil, defaults to DEBUG mode with Playground enabled.

Behavior:

  • In DEBUG mode (DEBUG: true): Skips all validation and sanitization for easier development
  • In production (DEBUG: false): Enables validation and sanitization based on configuration
  • Panics during initialization if schema building fails (fail-fast approach)
  • WebSocket upgrade requests are handled when EnableSubscriptions: true

Security Features (when DEBUG: false):

  • EnableValidation: Validates query depth (max 10), aliases (max 4), complexity (max 200), and blocks introspection
  • EnableSanitization: Removes field suggestions from error messages to prevent information disclosure

Example without subscriptions:

// Development setup
handler := graph.NewHTTP(&graph.GraphContext{
    SchemaParams: &graph.SchemaBuilderParams{
        QueryFields: []graph.QueryField{getUserQuery()},
    },
    DEBUG:      true,
    Playground: true,
})

// Production setup
handler := graph.NewHTTP(&graph.GraphContext{
    SchemaParams:       &graph.SchemaBuilderParams{...},
    DEBUG:              false,
    EnableValidation:   true,
    EnableSanitization: true,
    Playground:         false,
    UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
        user, err := validateToken(token)
        if err != nil {
            return ctx, nil, err
        }
        // Add user ID to context for access in resolvers via p.Context.Value("userID")
        ctx = context.WithValue(ctx, "userID", user.ID)
        return ctx, user, nil
    },
})

http.Handle("/graphql", handler)
http.ListenAndServe(":8080", nil)

Example with subscriptions:

pubsub := graph.NewInMemoryPubSub()
defer pubsub.Close()

handler := graph.NewHTTP(&graph.GraphContext{
    SchemaParams: &graph.SchemaBuilderParams{
        QueryFields:        []graph.QueryField{getUserQuery()},
        MutationFields:     []graph.MutationField{createUserMutation()},
        SubscriptionFields: []graph.SubscriptionField{userSubscription(pubsub)},
    },
    PubSub:              pubsub,
    EnableSubscriptions: true,
    DEBUG:               false,
})

http.Handle("/graphql", handler)
http.ListenAndServe(":8080", nil)

func NewWebSocketHandler added in v1.1.0

func NewWebSocketHandler(params WebSocketParams) http.HandlerFunc

NewWebSocketHandler creates an HTTP handler for WebSocket connections. This handler upgrades HTTP connections to WebSocket and manages GraphQL subscriptions.

Example:

params := graph.WebSocketParams{
    Schema:      schema,
    PubSub:      pubsub,
    CheckOrigin: func(r *http.Request) bool {
        origin := r.Header.Get("Origin")
        return origin == "https://example.com"
    },
    AuthFn: func(r *http.Request) (interface{}, error) {
        token := ExtractBearerToken(r)
        return validateToken(token)
    },
}

http.Handle("/graphql", graph.NewWebSocketHandler(params))

func PerUserBudgetFunc added in v1.1.1

func PerUserBudgetFunc(budgets map[string]int, defaultBudget int) func(string) (int, error)

PerUserBudgetFunc creates a budget function with per-user budgets Useful for different tier users or testing

func RegisterObjectType

func RegisterObjectType(name string, typeFactory func() *graphql.Object) *graphql.Object

RegisterObjectType registers a GraphQL object type in the global registry Returns existing type if already registered, otherwise creates and registers new type

func SimpleBudgetFunc added in v1.1.1

func SimpleBudgetFunc(budget int) func(string) (int, error)

SimpleBudgetFunc creates a simple budget function that returns a fixed budget Useful for testing or simple rate limiting scenarios

func UnmarshalSubscriptionMessage added in v1.1.0

func UnmarshalSubscriptionMessage[T any](msg *Message) (*T, error)

Helper function to unmarshal subscription messages

func ValidateGraphQLQuery

func ValidateGraphQLQuery(queryString string, schema *graphql.Schema) error

ValidateGraphQLQuery validates a GraphQL query against security rules. This function implements multiple layers of protection against malicious or expensive queries.

Validation Rules:

  • Max Query Depth: 10 levels (prevents deeply nested queries)
  • Max Aliases: 4 per query (prevents alias-based DoS attacks)
  • Max Complexity: 200 (prevents computationally expensive queries)
  • Introspection: Blocked (__schema and __type queries are rejected)

Returns an error if:

  • Query depth exceeds 10 levels
  • Query contains more than 4 aliases
  • Query complexity exceeds 200
  • Query contains __schema or __type introspection fields
  • Query parsing fails (though parsing errors are allowed to pass through)

Example usage:

if err := graph.ValidateGraphQLQuery(queryString, schema); err != nil {
    // Reject query with HTTP 400
    return fmt.Errorf("invalid query: %w", err)
}
// Query is safe to execute

Enable this in production with GraphContext.EnableValidation = true.

Types

type ASTVisitor added in v1.1.1

type ASTVisitor struct {
	EnterField     func(field *ast.Field, ctx *ValidationContext) error
	LeaveField     func(field *ast.Field, ctx *ValidationContext) error
	EnterOperation func(op *ast.OperationDefinition, ctx *ValidationContext) error
	LeaveOperation func(op *ast.OperationDefinition, ctx *ValidationContext) error
	EnterFragment  func(frag *ast.FragmentDefinition, ctx *ValidationContext) error
	LeaveFragment  func(frag *ast.FragmentDefinition, ctx *ValidationContext) error
}

ASTVisitor allows traversing the AST with hooks

type Args added in v1.1.7

type Args struct {
	// contains filtered or unexported fields
}

Args provides type-safe access to GraphQL arguments

func NewArgs added in v1.1.7

func NewArgs(raw map[string]interface{}) Args

NewArgs creates an Args instance from a map

func (Args) GetArg added in v1.1.9

func (a Args) GetArg(name string) (interface{}, bool)

GetArg implements ArgsGetter interface

func (Args) Has added in v1.1.7

func (a Args) Has(name string) bool

Has checks if an argument exists

func (Args) Raw added in v1.1.7

func (a Args) Raw() map[string]interface{}

Raw returns the underlying map

type ArgsGetter added in v1.1.9

type ArgsGetter interface {
	GetArg(name string) (interface{}, bool)
}

ArgsGetter is an interface for types that can provide arguments by name. This allows Get[T], GetE[T], GetOr[T], and MustGet[T] to work with both:

  • graph.Args (from WithArg chainable API)
  • graph.ArgsMap (from graphql.FieldConfigArgument / p.Args)

type ArgsMap added in v1.1.9

type ArgsMap map[string]interface{}

ArgsMap wraps map[string]interface{} to implement ArgsGetter. This allows using p.Args directly with Get[T], GetE[T], etc.

Usage:

// In subscription resolvers with graphql.FieldConfigArgument
channelID := graph.Get[string](graph.ArgsMap(p.Args), "channelID")

func (ArgsMap) GetArg added in v1.1.9

func (m ArgsMap) GetArg(name string) (interface{}, bool)

GetArg implements ArgsGetter interface

type BaseRule added in v1.1.1

type BaseRule struct {
	// contains filtered or unexported fields
}

BaseRule provides common functionality for all validation rules All custom rules should embed this struct

func NewBaseRule added in v1.1.1

func NewBaseRule(name string) BaseRule

NewBaseRule creates a new base rule with the given name

func (*BaseRule) Disable added in v1.1.1

func (r *BaseRule) Disable()

func (*BaseRule) Enable added in v1.1.1

func (r *BaseRule) Enable()

func (*BaseRule) Enabled added in v1.1.1

func (r *BaseRule) Enabled() bool

func (*BaseRule) Name added in v1.1.1

func (r *BaseRule) Name() string

func (*BaseRule) NewError added in v1.1.1

func (r *BaseRule) NewError(message string) *ValidationError

NewValidationError creates a validation error for this rule

func (*BaseRule) NewErrorf added in v1.1.1

func (r *BaseRule) NewErrorf(format string, args ...interface{}) *ValidationError

NewErrorf creates a validation error with formatted message

func (*BaseRule) SetEnabled added in v1.1.1

func (r *BaseRule) SetEnabled(enabled bool)

SetEnabled sets the enabled state

type BlockedFieldsRule added in v1.1.1

type BlockedFieldsRule struct {
	BaseRule
	// contains filtered or unexported fields
}

BlockedFieldsRule blocks specific fields from being queried

func (*BlockedFieldsRule) BlockField added in v1.1.1

func (r *BlockedFieldsRule) BlockField(field string, reason string) *BlockedFieldsRule

BlockField adds a field to the blocked list with an optional reason

func (*BlockedFieldsRule) Validate added in v1.1.1

func (r *BlockedFieldsRule) Validate(ctx *ValidationContext) error

type Connection added in v1.1.0

type Connection struct {
	// contains filtered or unexported fields
}

Connection represents a single WebSocket connection.

type FieldConfig

type FieldConfig struct {
	Resolver          graphql.FieldResolveFn
	Description       string
	Args              graphql.FieldConfigArgument
	DeprecationReason string
}

type FieldGenerator

type FieldGenerator[T any] struct {
	// contains filtered or unexported fields
}

func NewFieldGenerator

func NewFieldGenerator[T any]() *FieldGenerator[T]

type FieldMiddleware

type FieldMiddleware func(next FieldResolveFn) FieldResolveFn

FieldMiddleware wraps a field resolver with additional functionality (auth, logging, caching, etc.)

func AuthMiddleware

func AuthMiddleware(requiredRole string) FieldMiddleware

AuthMiddleware requires a specific user role

func CacheMiddleware

func CacheMiddleware(cacheKey func(ResolveParams) string) FieldMiddleware

CacheMiddleware caches field results based on a key function

type FieldResolveFn

type FieldResolveFn func(p ResolveParams) (interface{}, error)

func LoggingMiddleware

func LoggingMiddleware(next FieldResolveFn) FieldResolveFn

LoggingMiddleware logs field resolution time

type GenericTypeInfo

type GenericTypeInfo struct {
	IsGeneric     bool
	IsWrapper     bool
	BaseTypeName  string
	ElementType   reflect.Type
	WrapperFields map[string]reflect.Type
}

GenericTypeInfo holds information about a generic type

type GraphContext

type GraphContext struct {
	// Schema: Provide either Schema OR SchemaParams (not both)
	// If both are nil, a default "hello world" schema will be created
	Schema *graphql.Schema

	// SchemaParams: Alternative to Schema - will be built automatically
	// If nil and Schema is also nil, defaults to hello world query/mutation
	SchemaParams *SchemaBuilderParams

	// PubSub: PubSub system for subscriptions (optional, only needed for subscriptions)
	// Use NewInMemoryPubSub() for development or RedisPubSub for production
	PubSub PubSub

	// EnableSubscriptions: Enable WebSocket support for GraphQL subscriptions
	// Default: false (subscriptions disabled)
	// Requires PubSub to be configured
	EnableSubscriptions bool

	// WebSocketPath: Path for WebSocket endpoint (default: same as HTTP endpoint)
	// If not set, WebSocket connections will be handled on the same path as HTTP
	WebSocketPath string

	// WebSocketCheckOrigin: Custom function to check WebSocket upgrade origin
	// If not provided, all origins are allowed (only use in development!)
	WebSocketCheckOrigin func(r *http.Request) bool

	// Pretty: Pretty-print JSON responses
	Pretty bool

	// GraphiQL: Enable GraphiQL interface (deprecated, use Playground instead)
	GraphiQL bool

	// Playground: Enable GraphQL Playground interface
	Playground bool

	// DEBUG mode skips validation and sanitization for easier development
	// Default: false (validation enabled)
	DEBUG bool

	// RootObjectFn: Custom function to set up root object for each request
	// Called before token extraction and user details fetching
	RootObjectFn func(ctx context.Context, r *http.Request) map[string]interface{}

	// TokenExtractorFn: Custom token extraction from request
	// If not provided, default Bearer token extraction will be used
	TokenExtractorFn func(*http.Request) string

	// UserDetailsFn: Custom user details fetching based on token
	// If not provided, user details will not be added to rootValue
	// The details are accessible in resolvers via GetRootInfo(p, "details", &user)
	//
	// The function receives the request context and token, and returns:
	//   - ctx: Updated context with custom values (accessible via p.Context.Value() in resolvers)
	//   - details: User details (accessible via GetRootInfo(p, "details", &user) in resolvers)
	//   - error: Any error during user details fetching
	//
	// Example:
	//
	//	UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
	//	    user, err := validateJWT(token)
	//	    if err != nil {
	//	        return ctx, nil, err
	//	    }
	//	    // Add user ID to context for access in resolvers via p.Context.Value("userID")
	//	    ctx = context.WithValue(ctx, "userID", user.ID)
	//	    return ctx, user, nil
	//	}
	UserDetailsFn func(ctx context.Context, token string) (context.Context, interface{}, error)

	// EnableValidation: Enable query validation (depth, complexity, introspection checks)
	// Default: false (validation disabled)
	// When enabled: Max depth=10, Max aliases=4, Max complexity=200, Introspection blocked
	// DEPRECATED: Use ValidationRules for more control
	EnableValidation bool

	// ValidationRules: Custom validation rules (takes precedence over EnableValidation)
	// Set to nil or empty slice to disable validation
	// Example:
	//   ValidationRules: []ValidationRule{
	//       NewMaxDepthRule(10),
	//       NewRequireAuthRule("mutation"),
	//       NewRoleRules(map[string][]string{
	//           "deleteUser": {"admin"},
	//       }),
	//   }
	ValidationRules []ValidationRule

	// ValidationOptions: Configure validation behavior (optional)
	// Default: StopOnFirstError=false, SkipInDebug=true
	ValidationOptions *ValidationOptions

	// EnableSanitization: Enable response sanitization (removes field suggestions from errors)
	// Default: false (sanitization disabled)
	// Prevents information disclosure by removing "Did you mean X?" suggestions
	EnableSanitization bool
}

GraphContext configures a GraphQL handler with schema, authentication, and security settings.

Schema Configuration (choose one):

  • Schema: Use a pre-built graphql.Schema
  • SchemaParams: Use the builder pattern with QueryFields and MutationFields
  • Neither: A default "hello world" schema will be created

Security Modes:

  • DEBUG mode (DEBUG: true): Disables all validation and sanitization for development
  • Production mode (DEBUG: false): Enables validation and sanitization based on configuration flags

Authentication:

  • TokenExtractorFn: Extract tokens from requests (defaults to Bearer token extraction)
  • UserDetailsFn: Fetch user details from the extracted token
  • RootObjectFn: Custom root object setup for advanced use cases

Example Development Setup:

ctx := &graph.GraphContext{
    SchemaParams: &graph.SchemaBuilderParams{
        QueryFields: []graph.QueryField{getUserQuery()},
    },
    DEBUG:      true,
    Playground: true,
}

Example Production Setup:

ctx := &graph.GraphContext{
    SchemaParams:       &graph.SchemaBuilderParams{...},
    DEBUG:              false,
    EnableValidation:   true,  // Max depth: 10, Max aliases: 4, Max complexity: 200
    EnableSanitization: true,  // Remove field suggestions from errors
    Playground:         false,
    UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
        user, err := validateJWT(token)
        if err != nil {
            return ctx, nil, err
        }
        ctx = context.WithValue(ctx, "userID", user.ID)
        return ctx, user, nil
    },
}

type HasIDInterface added in v1.1.1

type HasIDInterface interface {
	GetID() string
}

HasIDInterface - implement this on your user struct for rate limiting

type HasPermissionsInterface added in v1.1.1

type HasPermissionsInterface interface {
	HasPermission(permission string) bool
}

HasPermissionsInterface - implement this on your user struct for permission-based rules

type HasRolesInterface added in v1.1.1

type HasRolesInterface interface {
	HasRole(role string) bool
}

HasRolesInterface - implement this on your user struct for role-based rules

type InMemoryPubSub added in v1.1.0

type InMemoryPubSub struct {
	// contains filtered or unexported fields
}

InMemoryPubSub is a simple in-memory implementation of PubSub. It's suitable for development, testing, and single-instance deployments. For production multi-instance deployments, use RedisPubSub or similar.

func NewInMemoryPubSub added in v1.1.0

func NewInMemoryPubSub() *InMemoryPubSub

NewInMemoryPubSub creates a new in-memory PubSub implementation.

Example:

pubsub := graph.NewInMemoryPubSub()
defer pubsub.Close()

ctx := context.Background()
sub := pubsub.Subscribe(ctx, "events")

go func() {
    for msg := range sub {
        fmt.Println("Event:", string(msg.Data))
    }
}()

pubsub.Publish(ctx, "events", map[string]string{"type": "user_created"})

func (*InMemoryPubSub) Close added in v1.1.0

func (p *InMemoryPubSub) Close() error

Close shuts down the PubSub and closes all active subscriptions.

func (*InMemoryPubSub) Publish added in v1.1.0

func (p *InMemoryPubSub) Publish(ctx context.Context, topic string, data interface{}) error

Publish sends data to all subscribers of the topic. Slow subscribers are skipped to prevent blocking.

func (*InMemoryPubSub) Subscribe added in v1.1.0

func (p *InMemoryPubSub) Subscribe(ctx context.Context, topic string) <-chan *Message

Subscribe creates a subscription to a topic. The subscription is automatically cleaned up when the context is canceled.

func (*InMemoryPubSub) Unsubscribe added in v1.1.0

func (p *InMemoryPubSub) Unsubscribe(ctx context.Context, subscriptionID string) error

Unsubscribe removes a subscription by ID (not commonly used with context-based cleanup).

type JSONTime

type JSONTime time.Time

JSONTime is a custom time type that handles flexible JSON time formats. It supports both RFC3339 strings and array formats commonly used in some APIs.

Supported input formats:

  • RFC3339 string: "2024-01-15T14:30:00Z"
  • Array format: [year, month, day, hour, minute, second, nanosecond]
  • Minimum array: [2024, 1, 15] (time components default to 0)

Output format:

  • Always RFC3339 string: "2024-01-15T14:30:00Z"

Example usage:

type Event struct {
    Name      string         `json:"name"`
    StartTime graph.JSONTime `json:"startTime"`
}

// Accepts: {"startTime": "2024-01-15T14:30:00Z"}
// Accepts: {"startTime": [2024, 1, 15, 14, 30, 0]}
// Outputs: {"startTime": "2024-01-15T14:30:00Z"}

func (JSONTime) MarshalJSON

func (t JSONTime) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler interface. Serializes JSONTime to RFC3339 format string.

func (JSONTime) Time

func (t JSONTime) Time() time.Time

Time converts JSONTime back to the standard time.Time type. This is useful when you need to perform time operations or comparisons.

Example:

var event Event
json.Unmarshal(data, &event)
standardTime := event.StartTime.Time()
// Now use standard time operations
if standardTime.After(time.Now()) { ... }

func (*JSONTime) UnmarshalJSON

func (t *JSONTime) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler interface. Deserializes from either RFC3339 string or array format.

Array format: [year, month, day, hour?, minute?, second?, nanosecond?] Missing time components default to 0. All times are assumed to be UTC.

type MaxAliasesRule added in v1.1.1

type MaxAliasesRule struct {
	BaseRule
	// contains filtered or unexported fields
}

MaxAliasesRule validates number of aliases

func (*MaxAliasesRule) Validate added in v1.1.1

func (r *MaxAliasesRule) Validate(ctx *ValidationContext) error

type MaxComplexityRule added in v1.1.1

type MaxComplexityRule struct {
	BaseRule
	// contains filtered or unexported fields
}

MaxComplexityRule validates query complexity

func (*MaxComplexityRule) Validate added in v1.1.1

func (r *MaxComplexityRule) Validate(ctx *ValidationContext) error

type MaxDepthRule added in v1.1.1

type MaxDepthRule struct {
	BaseRule
	// contains filtered or unexported fields
}

MaxDepthRule validates maximum query depth

func (*MaxDepthRule) Validate added in v1.1.1

func (r *MaxDepthRule) Validate(ctx *ValidationContext) error

type MaxTokensRule added in v1.1.1

type MaxTokensRule struct {
	BaseRule
	// contains filtered or unexported fields
}

MaxTokensRule limits query size by token count

func (*MaxTokensRule) Validate added in v1.1.1

func (r *MaxTokensRule) Validate(ctx *ValidationContext) error

type Message added in v1.1.0

type Message struct {
	// Topic is the channel/topic name where this message was published
	Topic string

	// Data is the JSON-encoded payload
	Data []byte
}

Message represents a published message with its topic and data payload.

type MultiValidationError added in v1.1.1

type MultiValidationError struct {
	Errors []error
}

MultiValidationError combines multiple validation errors

func NewMultiValidationError added in v1.1.1

func NewMultiValidationError(errors []error) *MultiValidationError

func (*MultiValidationError) Error added in v1.1.1

func (e *MultiValidationError) Error() string

type MutationField

type MutationField interface {
	// Serve returns the GraphQL field configuration
	Serve() *graphql.Field

	// Name returns the field name used in the GraphQL schema
	Name() string
}

MutationField represents a GraphQL mutation field with its configuration. Implementations must provide both the field configuration and its name.

Use NewResolver to create MutationField instances:

mutation := graph.NewResolver[User]("createUser").
    WithInputObject(CreateUserInput{}).
    WithResolver(...).
    BuildMutation()

type NoIntrospectionRule added in v1.1.1

type NoIntrospectionRule struct {
	BaseRule
}

NoIntrospectionRule blocks introspection queries

func (*NoIntrospectionRule) Validate added in v1.1.1

func (r *NoIntrospectionRule) Validate(ctx *ValidationContext) error

type PageInfo

type PageInfo struct {
	HasNextPage     bool   `json:"hasNextPage" description:"Whether there are more pages"`
	HasPreviousPage bool   `json:"hasPreviousPage" description:"Whether there are previous pages"`
	StartCursor     string `json:"startCursor" description:"Cursor for the first item"`
	EndCursor       string `json:"endCursor" description:"Cursor for the last item"`
}

PageInfo contains pagination information

type PaginatedResponse

type PaginatedResponse[T any] struct {
	Items      []T      `json:"items" description:"List of items"`
	TotalCount int      `json:"totalCount" description:"Total number of items"`
	PageInfo   PageInfo `json:"pageInfo" description:"Pagination information"`
}

PaginatedResponse represents a paginated response structure

type PaginationArgs

type PaginationArgs struct {
	First  *int    `json:"first" description:"Number of items to fetch"`
	After  *string `json:"after" description:"Cursor to start after"`
	Last   *int    `json:"last" description:"Number of items to fetch from end"`
	Before *string `json:"before" description:"Cursor to start before"`
}

PaginationArgs contains pagination arguments

type PermissionRule added in v1.1.1

type PermissionRule struct {
	BaseRule
	// contains filtered or unexported fields
}

PermissionRule validates a single field requires specific permissions

func (*PermissionRule) Validate added in v1.1.1

func (r *PermissionRule) Validate(ctx *ValidationContext) error

type PermissionRules added in v1.1.1

type PermissionRules struct {
	BaseRule
	// contains filtered or unexported fields
}

PermissionRules validates multiple fields with permission requirements

func (*PermissionRules) Validate added in v1.1.1

func (r *PermissionRules) Validate(ctx *ValidationContext) error

type PubSub added in v1.1.0

type PubSub interface {
	// Publish sends data to all subscribers of a topic.
	// The data will be JSON-marshaled automatically.
	//
	// Returns an error if:
	//   - JSON marshaling fails
	//   - Context is canceled
	//   - PubSub is closed
	Publish(ctx context.Context, topic string, data interface{}) error

	// Subscribe creates a new subscription to a topic.
	// Returns a channel that receives messages published to the topic.
	//
	// The subscription remains active until:
	//   - The context is canceled
	//   - Unsubscribe is called with the subscription ID
	//   - The PubSub is closed
	//
	// The returned channel will be closed when the subscription ends.
	Subscribe(ctx context.Context, topic string) <-chan *Message

	// Unsubscribe removes a subscription by its ID.
	// The subscription's message channel will be closed.
	Unsubscribe(ctx context.Context, subscriptionID string) error

	// Close shuts down the PubSub system and closes all active subscriptions.
	Close() error
}

PubSub defines the interface for publishing and subscribing to events. Implementations can use in-memory channels, Redis, Kafka, or other message brokers.

Example Usage:

pubsub := graph.NewInMemoryPubSub()
defer pubsub.Close()

// Subscribe to a topic
ctx := context.Background()
subscription := pubsub.Subscribe(ctx, "messages:channel1")

// Publish an event
pubsub.Publish(ctx, "messages:channel1", map[string]string{"text": "Hello"})

// Receive events
for msg := range subscription {
    fmt.Println("Received:", string(msg.Data))
}

type QueryField

type QueryField interface {
	// Serve returns the GraphQL field configuration
	Serve() *graphql.Field

	// Name returns the field name used in the GraphQL schema
	Name() string
}

QueryField represents a GraphQL query field with its configuration. Implementations must provide both the field configuration and its name.

Use NewResolver to create QueryField instances:

query := graph.NewResolver[User]("user").
    WithArgs(...).
    WithResolver(...).
    BuildQuery()

type RateLimitOption added in v1.1.1

type RateLimitOption func(*RateLimitRule)

RateLimitOption configures rate limiting behavior

func WithBudgetFunc added in v1.1.1

func WithBudgetFunc(fn func(userID string) (int, error)) RateLimitOption

WithBudgetFunc sets the function to get user's remaining budget

func WithBypassRoles added in v1.1.1

func WithBypassRoles(roles ...string) RateLimitOption

WithBypassRoles sets roles that bypass rate limiting (e.g., "admin", "service")

func WithCostPerUnit added in v1.1.1

func WithCostPerUnit(cost int) RateLimitOption

WithCostPerUnit sets the cost multiplier per complexity unit (default: 1)

type RateLimitRule added in v1.1.1

type RateLimitRule struct {
	BaseRule
	// contains filtered or unexported fields
}

RateLimitRule implements per-user rate limiting based on query complexity

func (*RateLimitRule) Validate added in v1.1.1

func (r *RateLimitRule) Validate(ctx *ValidationContext) error

type RequireAuthRule added in v1.1.1

type RequireAuthRule struct {
	BaseRule
	// contains filtered or unexported fields
}

RequireAuthRule requires authentication for specific operations or fields Simply checks if ctx.UserDetails != nil

func (*RequireAuthRule) Validate added in v1.1.1

func (r *RequireAuthRule) Validate(ctx *ValidationContext) error

type ResolveParams

type ResolveParams graphql.ResolveParams

type RoleRule added in v1.1.1

type RoleRule struct {
	BaseRule
	// contains filtered or unexported fields
}

RoleRule validates a single field requires specific roles

func (*RoleRule) Validate added in v1.1.1

func (r *RoleRule) Validate(ctx *ValidationContext) error

type RoleRules added in v1.1.1

type RoleRules struct {
	BaseRule
	// contains filtered or unexported fields
}

RoleRules validates multiple fields with role requirements

func (*RoleRules) Validate added in v1.1.1

func (r *RoleRules) Validate(ctx *ValidationContext) error

type SchemaBuilder

type SchemaBuilder struct {
	// contains filtered or unexported fields
}

SchemaBuilder builds GraphQL schemas from QueryFields and MutationFields. Use NewSchemaBuilder to create an instance and Build() to generate the schema.

func NewSchemaBuilder

func NewSchemaBuilder(params SchemaBuilderParams) *SchemaBuilder

NewSchemaBuilder creates a new schema builder with the provided query and mutation fields.

Example:

params := graph.SchemaBuilderParams{
    QueryFields:    []graph.QueryField{getUserQuery()},
    MutationFields: []graph.MutationField{createUserMutation()},
}
builder := graph.NewSchemaBuilder(params)
schema, err := builder.Build()

func (*SchemaBuilder) Build

func (sb *SchemaBuilder) Build() (graphql.Schema, error)

Build constructs and returns a graphql.Schema from the configured fields. It creates Query and Mutation root types based on the provided fields.

Returns an error if:

  • Schema construction fails due to type conflicts
  • Field configurations are invalid

The schema can have:

  • Only queries (no mutations)
  • Only mutations (no queries)
  • Both queries and mutations
  • Neither (empty schema)

type SchemaBuilderParams

type SchemaBuilderParams struct {
	// QueryFields: List of query fields to include in the schema
	QueryFields []QueryField `group:"query_fields"`

	// MutationFields: List of mutation fields to include in the schema
	MutationFields []MutationField `group:"mutation_fields"`

	// SubscriptionFields: List of subscription fields to include in the schema
	// Requires WebSocket support and PubSub configuration
	SubscriptionFields []SubscriptionField `group:"subscription_fields"`
}

SchemaBuilderParams configures the fields for building a GraphQL schema. Use this with NewSchemaBuilder to construct schemas using the builder pattern.

Example:

params := graph.SchemaBuilderParams{
    QueryFields: []graph.QueryField{
        getUserQuery(),
        listUsersQuery(),
    },
    MutationFields: []graph.MutationField{
        createUserMutation(),
        updateUserMutation(),
    },
}
schema, err := graph.NewSchemaBuilder(params).Build()

type SubscriptionField added in v1.1.0

type SubscriptionField interface {
	// Serve returns the GraphQL field configuration
	Serve() *graphql.Field

	// Name returns the subscription field name
	Name() string
}

SubscriptionField represents a GraphQL subscription field that can be added to a schema. It follows the same interface pattern as QueryField and MutationField.

Create subscription fields using NewSubscription:

sub := NewSubscription[MessageEvent]("messageAdded").
    WithArgs(...).
    WithResolver(...).
    BuildSubscription()

type SubscriptionFilterFn added in v1.1.0

type SubscriptionFilterFn[T any] func(ctx context.Context, data *T, p ResolveParams) bool

SubscriptionFilterFn filters events before sending them to clients. Return true to send the event, false to skip it.

This is useful for:

  • User-specific filtering based on permissions
  • Content-based filtering based on subscription arguments
  • Rate limiting or throttling

Example:

func(ctx context.Context, data *MessageEvent, p ResolveParams) bool {
    userID, _ := GetRootString(p, "userID")
    return canUserViewMessage(userID, data.ID)
}

type SubscriptionResolveFn added in v1.1.0

type SubscriptionResolveFn[T any] func(ctx context.Context, p ResolveParams) (<-chan *T, error)

SubscriptionResolveFn is the resolver function for subscriptions. It returns a channel that emits events of type T.

The resolver should:

  • Create a buffered channel to prevent blocking
  • Start a goroutine to handle event publishing
  • Close the channel when done or context is canceled
  • Handle errors by returning nil channel and an error

Example:

func(ctx context.Context, p ResolveParams) (<-chan *MessageEvent, error) {
    channelID, _ := GetArgString(p, "channelID")
    events := make(chan *MessageEvent, 10)

    subscription := pubsub.Subscribe(ctx, "messages:"+channelID)

    go func() {
        defer close(events)
        for msg := range subscription {
            var event MessageEvent
            if err := json.Unmarshal(msg.Data, &event); err == nil {
                events <- &event
            }
        }
    }()

    return events, nil
}

type SubscriptionResolver added in v1.1.0

type SubscriptionResolver[T any] struct {
	// contains filtered or unexported fields
}

SubscriptionResolver builds type-safe subscription fields with extensive customization capabilities. It provides a fluent API similar to UnifiedResolver for building subscriptions.

Type Parameters:

  • T: The Go struct type that will be sent to subscribers

The resolver function should return a channel that emits events of type T. When the channel is closed or the context is canceled, the subscription ends.

Basic Usage:

type MessageEvent struct {
    ID        string    `json:"id"`
    Content   string    `json:"content"`
    Timestamp time.Time `json:"timestamp"`
}

sub := NewSubscription[MessageEvent]("messageAdded").
    WithDescription("Subscribe to new messages").
    WithArgs(graphql.FieldConfigArgument{
        "channelID": &graphql.ArgumentConfig{
            Type: graphql.NewNonNull(graphql.String),
        },
    }).
    WithResolver(func(ctx context.Context, p ResolveParams) (<-chan *MessageEvent, error) {
        channelID, _ := GetArgString(p, "channelID")
        events := make(chan *MessageEvent)
        subscription := pubsub.Subscribe(ctx, "messages:"+channelID)

        go func() {
            defer close(events)
            for msg := range subscription {
                var event MessageEvent
                json.Unmarshal(msg.Data, &event)
                events <- &event
            }
        }()

        return events, nil
    }).
    WithFilter(func(ctx context.Context, data *MessageEvent, p ResolveParams) bool {
        // Optional: filter events before sending to client
        return true
    }).
    WithMiddleware(AuthMiddleware("user")).
    BuildSubscription()

Advanced Features:

// Middleware support
sub := NewSubscription[Event]("events").
    WithMiddleware(LoggingMiddleware).
    WithMiddleware(AuthMiddleware("admin")).
    // ... rest of configuration

// Field-level customization (like UnifiedResolver)
sub := NewSubscription[ComplexEvent]("complexEvent").
    WithFieldResolver("computedField", func(p ResolveParams) (interface{}, error) {
        event := p.Source.(ComplexEvent)
        return computeValue(event), nil
    }).
    // ... rest of configuration

func NewSubscription added in v1.1.0

func NewSubscription[T any](name string) *SubscriptionResolver[T]

NewSubscription creates a new subscription resolver with the specified name. The type parameter T determines the event type that will be sent to subscribers.

Example:

type UserStatusEvent struct {
    UserID string `json:"userID"`
    Status string `json:"status"`
    Timestamp time.Time `json:"timestamp"`
}

sub := NewSubscription[UserStatusEvent]("userStatusChanged").
    WithArgs(graphql.FieldConfigArgument{
        "userID": &graphql.ArgumentConfig{Type: graphql.String},
    }).
    WithResolver(func(ctx context.Context, p ResolveParams) (<-chan *UserStatusEvent, error) {
        // Implementation
    }).
    BuildSubscription()

func (*SubscriptionResolver[T]) BuildSubscription added in v1.1.0

func (s *SubscriptionResolver[T]) BuildSubscription() SubscriptionField

BuildSubscription builds and returns a SubscriptionField that can be added to the schema.

This method:

  • Auto-generates the GraphQL type from the Go struct T
  • Applies field-level customizations and middleware
  • Creates the subscription and resolve functions
  • Registers the type in the global type registry

Example:

sub := NewSubscription[MessageEvent]("messageAdded").
    WithArgs(...).
    WithResolver(...).
    BuildSubscription()

// Add to schema
schema := graph.NewSchemaBuilder(graph.SchemaBuilderParams{
    SubscriptionFields: []graph.SubscriptionField{sub},
})

func (*SubscriptionResolver[T]) WithArgs added in v1.1.0

WithArgs sets custom arguments for the subscription.

Example:

WithArgs(graphql.FieldConfigArgument{
    "channelID": &graphql.ArgumentConfig{
        Type:        graphql.NewNonNull(graphql.String),
        Description: "Channel to subscribe to",
    },
    "filter": &graphql.ArgumentConfig{
        Type:        graphql.String,
        Description: "Optional filter pattern",
    },
})

func (*SubscriptionResolver[T]) WithDescription added in v1.1.0

func (s *SubscriptionResolver[T]) WithDescription(desc string) *SubscriptionResolver[T]

WithDescription adds a description to the subscription field.

func (*SubscriptionResolver[T]) WithFieldMiddleware added in v1.1.0

func (s *SubscriptionResolver[T]) WithFieldMiddleware(fieldName string, middleware FieldMiddleware) *SubscriptionResolver[T]

WithFieldMiddleware adds middleware to a specific field in the event type.

Example:

WithFieldMiddleware("sensitiveData", AuthMiddleware("admin"))

func (*SubscriptionResolver[T]) WithFieldResolver added in v1.1.0

func (s *SubscriptionResolver[T]) WithFieldResolver(fieldName string, resolver graphql.FieldResolveFn) *SubscriptionResolver[T]

WithFieldResolver overrides the resolver for a specific field in the event type. This allows customizing how specific fields are resolved.

Example:

WithFieldResolver("author", func(p ResolveParams) (interface{}, error) {
    event := p.Source.(MessageEvent)
    return userService.GetByID(event.AuthorID), nil
})

func (*SubscriptionResolver[T]) WithFilter added in v1.1.0

WithFilter adds a filter function to filter events before sending to clients. Only events that pass the filter (return true) will be sent.

Example:

WithFilter(func(ctx context.Context, data *MessageEvent, p ResolveParams) bool {
    userID, _ := GetRootString(p, "userID")
    return data.AuthorID != userID // Don't send user's own messages
})

func (*SubscriptionResolver[T]) WithMiddleware added in v1.1.0

func (s *SubscriptionResolver[T]) WithMiddleware(middleware FieldMiddleware) *SubscriptionResolver[T]

WithMiddleware adds middleware to the subscription resolver. Middleware is executed in the order it's added.

Example:

WithMiddleware(LoggingMiddleware).
WithMiddleware(AuthMiddleware("user"))

func (*SubscriptionResolver[T]) WithResolver added in v1.1.0

func (s *SubscriptionResolver[T]) WithResolver(fn SubscriptionResolveFn[T]) *SubscriptionResolver[T]

WithResolver sets the subscription resolver function. The resolver should return a channel that emits events of type T.

Example:

WithResolver(func(ctx context.Context, p ResolveParams) (<-chan *MessageEvent, error) {
    channelID, _ := GetArgString(p, "channelID")

    events := make(chan *MessageEvent, 10)
    subscription := pubsub.Subscribe(ctx, "messages:"+channelID)

    go func() {
        defer close(events)
        for msg := range subscription {
            var event MessageEvent
            if err := json.Unmarshal(msg.Data, &event); err == nil {
                events <- &event
            }
        }
    }()

    return events, nil
})

type TypedArgsResolver deprecated

type TypedArgsResolver[T any, A any] struct {
	// contains filtered or unexported fields
}

TypedArgsResolver provides type-safe argument handling

Deprecated: Use NewResolver with WithArg instead. The WithArg API provides a more flexible and chainable approach to defining arguments.

Migration example:

// Before (deprecated):
NewArgsResolver[User, GetUserArgs]("user").
	WithResolver(func(ctx context.Context, p ResolveParams, args GetUserArgs) (*User, error) {
		return userService.GetByID(args.ID)
	}).BuildQuery()

// After (recommended):
NewResolver[User]("user").
	WithArg("id", graph.Int).
	WithResolver(func(p ResolveParams, args Args) (*User, error) {
		id := Get[int](args, "id")
		return userService.GetByID(id)
	}).BuildQuery()

func NewArgsResolver deprecated

func NewArgsResolver[T any, A any](name string, argName ...string) *TypedArgsResolver[T, A]

NewArgsResolver creates a resolver with type-safe arguments.

Deprecated: Use NewResolver with WithArg instead for a more flexible API.

Migration guide:

// Before (deprecated):
type GetUserArgs struct {
	ID int `graphql:"id,required"`
}
NewArgsResolver[User, GetUserArgs]("user").
	WithResolver(func(ctx context.Context, p ResolveParams, args GetUserArgs) (*User, error) {
		return userService.GetByID(args.ID)
	}).BuildQuery()

// After (recommended):
NewResolver[User]("user").
	WithArgRequired("id", graph.Int).
	WithResolver(func(p ResolveParams, args Args) (*User, error) {
		id := Get[int](args, "id")
		return userService.GetByID(id)
	}).BuildQuery()

For struct inputs:

// Before (deprecated):
NewArgsResolver[User, CreateUserInput]("createUser").
	WithResolver(func(ctx context.Context, p ResolveParams, input CreateUserInput) (*User, error) {
		return userService.Create(input.Name, input.Email)
	}).BuildMutation()

// After (recommended):
NewResolver[User]("createUser").
	WithArg("input", CreateUserInput{}).
	WithResolver(func(p ResolveParams, args Args) (*User, error) {
		input := Get[CreateUserInput](args, "input")
		return userService.Create(input.Name, input.Email)
	}).BuildMutation()

func (*TypedArgsResolver[T, A]) AsList

func (r *TypedArgsResolver[T, A]) AsList() *TypedArgsResolver[T, A]

AsList configures the resolver to return a list of items

func (*TypedArgsResolver[T, A]) AsPaginated

func (r *TypedArgsResolver[T, A]) AsPaginated() *TypedArgsResolver[T, A]

AsPaginated configures the resolver to return paginated results

func (*TypedArgsResolver[T, A]) BuildMutation

func (r *TypedArgsResolver[T, A]) BuildMutation() MutationField

BuildMutation builds and returns a MutationField

func (*TypedArgsResolver[T, A]) BuildQuery

func (r *TypedArgsResolver[T, A]) BuildQuery() QueryField

BuildQuery builds and returns a QueryField

func (*TypedArgsResolver[T, A]) WithDescription

func (r *TypedArgsResolver[T, A]) WithDescription(desc string) *TypedArgsResolver[T, A]

WithDescription sets the field description

func (*TypedArgsResolver[T, A]) WithResolver

func (r *TypedArgsResolver[T, A]) WithResolver(resolver func(ctx context.Context, p ResolveParams, args A) (*T, error)) *TypedArgsResolver[T, A]

WithResolver sets a type-safe resolver with typed arguments and context support

Example usage:

type GetPostArgs struct {
	ID int `graphql:"id,required"`
}

resolver.WithArgs[GetPostArgs]().
	WithResolver(func(ctx context.Context, args GetPostArgs) (*Post, error) {
		return postService.GetByID(args.ID)
	})

type UnifiedResolver

type UnifiedResolver[T any] struct {
	// contains filtered or unexported fields
}

UnifiedResolver handles all GraphQL resolver scenarios with field-level customization

func NewResolver

func NewResolver[T any](name string) *UnifiedResolver[T]

func (*UnifiedResolver[T]) AsList

func (r *UnifiedResolver[T]) AsList() *UnifiedResolver[T]

Query Configuration

func (*UnifiedResolver[T]) AsMutation

func (r *UnifiedResolver[T]) AsMutation() *UnifiedResolver[T]

Mutation Configuration

func (*UnifiedResolver[T]) AsPaginated

func (r *UnifiedResolver[T]) AsPaginated() *UnifiedResolver[T]

func (*UnifiedResolver[T]) Build

func (r *UnifiedResolver[T]) Build() interface{}

func (*UnifiedResolver[T]) BuildMutation

func (r *UnifiedResolver[T]) BuildMutation() MutationField

func (*UnifiedResolver[T]) BuildQuery

func (r *UnifiedResolver[T]) BuildQuery() QueryField

Build Methods

func (*UnifiedResolver[T]) Name

func (r *UnifiedResolver[T]) Name() string

Interface Implementation

func (*UnifiedResolver[T]) Serve

func (r *UnifiedResolver[T]) Serve() *graphql.Field

func (*UnifiedResolver[T]) WithArg added in v1.1.7

func (r *UnifiedResolver[T]) WithArg(name string, argType interface{}) *UnifiedResolver[T]

WithArg adds an argument to the resolver. Supports: - Go primitive types: string, int, int64, float64, bool (pass zero value or instance) - GraphQL types: graph.String, graph.Int, graph.Float, graph.Boolean, graph.ID - Struct types: any struct will be converted to GraphQL InputObject (supports deeply nested structs) - Slices: []Type will be converted to [Type] GraphQL list

Usage:

// With Go primitive types (recommended)
NewResolver[User]("user").
	WithArg("id", "").           // string
	WithArg("limit", 0).         // int
	WithArg("active", false).    // bool
	WithResolver(func(p ResolveParams) (*User, error) {
		id := Get[string](ArgsMap(p.Args), "id")
		return userService.GetByID(id)
	})

// With struct type (deeply nested supported)
type AddressInput struct {
	Street  string `json:"street"`
	City    string `json:"city"`
}
type UserInput struct {
	Name    string       `json:"name"`
	Address AddressInput `json:"address"`
}

NewResolver[User]("createUser").
	WithArg("input", UserInput{}).
	WithResolver(func(p ResolveParams) (*User, error) {
		input := Get[UserInput](ArgsMap(p.Args), "input")
		return userService.Create(input)
	})

func (*UnifiedResolver[T]) WithArgDefault added in v1.1.7

func (r *UnifiedResolver[T]) WithArgDefault(name string, argType interface{}, defaultValue interface{}) *UnifiedResolver[T]

WithArgDefault adds an argument with a default value

func (*UnifiedResolver[T]) WithArgRequired added in v1.1.7

func (r *UnifiedResolver[T]) WithArgRequired(name string, argType interface{}) *UnifiedResolver[T]

WithArgRequired adds a required argument to the resolver

func (*UnifiedResolver[T]) WithArgs

func (*UnifiedResolver[T]) WithArgsFromStruct

func (r *UnifiedResolver[T]) WithArgsFromStruct(structType interface{}) *UnifiedResolver[T]

func (*UnifiedResolver[T]) WithAsyncField

func (r *UnifiedResolver[T]) WithAsyncField(fieldName string, resolver graphql.FieldResolveFn) *UnifiedResolver[T]

func (*UnifiedResolver[T]) WithCachedField

func (r *UnifiedResolver[T]) WithCachedField(fieldName string, cacheKeyFunc func(graphql.ResolveParams) string, resolver graphql.FieldResolveFn) *UnifiedResolver[T]

func (*UnifiedResolver[T]) WithComputedField

func (r *UnifiedResolver[T]) WithComputedField(name string, fieldType graphql.Output, resolver graphql.FieldResolveFn) *UnifiedResolver[T]

func (*UnifiedResolver[T]) WithCustomField

func (r *UnifiedResolver[T]) WithCustomField(name string, field *graphql.Field) *UnifiedResolver[T]

func (*UnifiedResolver[T]) WithDescription

func (r *UnifiedResolver[T]) WithDescription(desc string) *UnifiedResolver[T]

Basic Configuration

func (*UnifiedResolver[T]) WithFieldMiddleware

func (r *UnifiedResolver[T]) WithFieldMiddleware(fieldName string, middleware FieldMiddleware) *UnifiedResolver[T]

func (*UnifiedResolver[T]) WithFieldResolver

func (r *UnifiedResolver[T]) WithFieldResolver(fieldName string, resolver graphql.FieldResolveFn) *UnifiedResolver[T]

Field-Level Customization

func (*UnifiedResolver[T]) WithFieldResolvers

func (r *UnifiedResolver[T]) WithFieldResolvers(overrides map[string]graphql.FieldResolveFn) *UnifiedResolver[T]

func (*UnifiedResolver[T]) WithInputObject

func (r *UnifiedResolver[T]) WithInputObject(inputType interface{}) *UnifiedResolver[T]

func (*UnifiedResolver[T]) WithInputObjectFieldName

func (r *UnifiedResolver[T]) WithInputObjectFieldName(name string) *UnifiedResolver[T]

func (*UnifiedResolver[T]) WithInputObjectNullable

func (r *UnifiedResolver[T]) WithInputObjectNullable() *UnifiedResolver[T]

func (*UnifiedResolver[T]) WithLazyField

func (r *UnifiedResolver[T]) WithLazyField(fieldName string, loader func(interface{}) (interface{}, error)) *UnifiedResolver[T]

Utility Methods for Field Configuration

func (*UnifiedResolver[T]) WithMiddleware

func (r *UnifiedResolver[T]) WithMiddleware(middleware FieldMiddleware) *UnifiedResolver[T]

WithMiddleware adds middleware to the main resolver. Middleware functions are applied in the order they are added (first added = outermost layer). This is the foundation for all resolver-level middleware (auth, logging, caching, etc.).

Example usage:

NewResolver[User]("user").
	WithMiddleware(LoggingMiddleware).
	WithMiddleware(AuthMiddleware("admin")).
	WithResolver(func(p ResolveParams) (*User, error) {
		return userService.GetByID(p.Args["id"].(int))
	}).
	BuildQuery()

func (*UnifiedResolver[T]) WithPermission

func (r *UnifiedResolver[T]) WithPermission(middleware FieldMiddleware) *UnifiedResolver[T]

WithPermission adds permission middleware to the resolver (similar to Python @permission_classes decorator) This is now just a convenience wrapper around WithMiddleware for backwards compatibility

func (*UnifiedResolver[T]) WithResolver

func (r *UnifiedResolver[T]) WithResolver(resolver func(ResolveParams) (*T, error)) *UnifiedResolver[T]

WithResolver sets a type-safe resolver function that returns *T instead of interface{} This provides better type safety and eliminates the need for type assertions or casts.

WithResolver requires the function signature:

  • func(p ResolveParams) (*T, error)

Access arguments using type-safe getter functions with ArgsMap:

  • Get[T](ArgsMap(p.Args), "key") - returns zero value if not found
  • GetE[T](ArgsMap(p.Args), "key") - returns error if not found
  • GetOr[T](ArgsMap(p.Args), "key", defaultVal) - returns default if not found
  • MustGet[T](ArgsMap(p.Args), "key") - panics if not found

Example usage:

NewResolver[User]("user").
	WithArg("id", graph.String).
	WithResolver(func(p graph.ResolveParams) (*User, error) {
		id := graph.Get[string](graph.ArgsMap(p.Args), "id")
		return userService.GetByID(id)
	}).BuildQuery()

NewResolver[User]("users").
	AsList().
	WithResolver(func(p graph.ResolveParams) (*[]User, error) {
		users := userService.List()
		return &users, nil
	}).BuildQuery()

NewResolver[string]("hello").
	WithResolver(func(p graph.ResolveParams) (*string, error) {
		msg := "Hello, World!"
		return &msg, nil
	}).BuildQuery()

func (*UnifiedResolver[T]) WithTypedResolver

func (r *UnifiedResolver[T]) WithTypedResolver(typedResolver interface{}) *UnifiedResolver[T]

Typed Resolver Support - allows direct struct parameters instead of graphql.ResolveParams

Example usage:

func resolveUser(args GetUserArgs) (*User, error) {
    return &User{ID: args.ID, Name: "User"}, nil
}

NewResolver[User]("user", "User").
    WithTypedResolver(resolveUser).
    BuildQuery()

type ValidationContext added in v1.1.1

type ValidationContext struct {
	// GraphQL query components
	Query     string
	Document  *ast.Document
	Schema    *graphql.Schema
	Variables map[string]interface{}

	// Request context
	Request *http.Request

	// User details from UserDetailsFn (can be nil if not authenticated)
	// Validation rules can type-assert this to whatever structure they need
	UserDetails interface{}
}

ValidationContext provides all necessary information for validation

type ValidationError added in v1.1.1

type ValidationError struct {
	Rule     string
	Message  string
	Location *ast.Location
	Path     []string
}

ValidationError provides detailed error information

func (*ValidationError) Error added in v1.1.1

func (e *ValidationError) Error() string

type ValidationOptions added in v1.1.1

type ValidationOptions struct {
	// StopOnFirstError stops validation after first error
	StopOnFirstError bool

	// SkipInDebug skips validation when DEBUG=true
	SkipInDebug bool
}

ValidationOptions configures validation behavior

type ValidationRule added in v1.1.1

type ValidationRule interface {
	// Name returns a unique identifier for this rule
	Name() string

	// Validate executes the rule against the parsed query
	// Returns nil if valid, error if validation fails
	Validate(ctx *ValidationContext) error

	// Enabled checks if this rule should be executed
	Enabled() bool

	// Enable enables the rule
	Enable()

	// Disable disables the rule
	Disable()
}

ValidationRule represents a single validation rule that can be applied to GraphQL queries

func CombineRules added in v1.1.1

func CombineRules(ruleSets ...[]ValidationRule) []ValidationRule

CombineRules combines multiple rule sets into one

Example:

rules := CombineRules(
    SecurityRules,
    []ValidationRule{NewRequireAuthRule("mutation")},
)

func DefaultValidationRules added in v1.1.1

func DefaultValidationRules() []ValidationRule

DefaultValidationRules returns the default validation rules Equivalent to EnableValidation=true

func DevelopmentValidationRules added in v1.1.1

func DevelopmentValidationRules() []ValidationRule

DevelopmentValidationRules returns lenient development rules

func NewBlockedFieldsRule added in v1.1.1

func NewBlockedFieldsRule(fields ...string) ValidationRule

NewBlockedFieldsRule creates a new blocked fields rule

Example:

NewBlockedFieldsRule("internalUsers", "deprecatedField")

func NewMaxAliasesRule added in v1.1.1

func NewMaxAliasesRule(maxAliases int) ValidationRule

NewMaxAliasesRule creates a new max aliases validation rule

func NewMaxComplexityRule added in v1.1.1

func NewMaxComplexityRule(maxComplexity int) ValidationRule

NewMaxComplexityRule creates a new max complexity validation rule

func NewMaxDepthRule added in v1.1.1

func NewMaxDepthRule(maxDepth int) ValidationRule

NewMaxDepthRule creates a new max depth validation rule

func NewMaxTokensRule added in v1.1.1

func NewMaxTokensRule(maxTokens int) ValidationRule

NewMaxTokensRule creates a new max tokens validation rule

func NewNoIntrospectionRule added in v1.1.1

func NewNoIntrospectionRule() ValidationRule

NewNoIntrospectionRule creates a new no introspection validation rule

func NewPermissionRule added in v1.1.1

func NewPermissionRule(field string, permissions ...string) ValidationRule

NewPermissionRule creates a new permission validation rule for a single field

Example:

NewPermissionRule("sensitiveData", "read:sensitive")
NewPermissionRule("exportData", "export:data", "admin:all")

func NewPermissionRules added in v1.1.1

func NewPermissionRules(config map[string][]string) ValidationRule

NewPermissionRules creates a batch permission validation rule config maps field names to required permissions

Example:

NewPermissionRules(map[string][]string{
    "sensitiveData": {"read:sensitive"},
    "exportData":    {"export:data"},
    "adminPanel":    {"admin:access"},
})

func NewRateLimitRule added in v1.1.1

func NewRateLimitRule(opts ...RateLimitOption) ValidationRule

NewRateLimitRule creates a new rate limiting rule with optional configuration

Example:

NewRateLimitRule(
    WithBudgetFunc(getBudgetFromRedis),
    WithCostPerUnit(2),
    WithBypassRoles("admin", "service"),
)

func NewRequireAuthRule added in v1.1.1

func NewRequireAuthRule(targets ...string) ValidationRule

NewRequireAuthRule creates a new require authentication rule Targets can be operation types ("mutation", "subscription", "query") or field names

Example:

NewRequireAuthRule("mutation", "subscription")  // Require auth for all mutations and subscriptions
NewRequireAuthRule("deleteUser", "updateUser")  // Require auth for specific fields

func NewRoleRule added in v1.1.1

func NewRoleRule(field string, roles ...string) ValidationRule

NewRoleRule creates a new role validation rule for a single field

Example:

NewRoleRule("deleteUser", "admin")
NewRoleRule("viewAuditLog", "admin", "auditor")

func NewRoleRules added in v1.1.1

func NewRoleRules(config map[string][]string) ValidationRule

NewRoleRules creates a batch role validation rule config maps field names to required roles

Example:

NewRoleRules(map[string][]string{
    "deleteUser":    {"admin"},
    "viewAuditLog":  {"admin", "auditor"},
    "approveOrder":  {"admin", "manager"},
})

func ProductionValidationRules added in v1.1.1

func ProductionValidationRules() []ValidationRule

ProductionValidationRules returns recommended production rules

type WSMessage added in v1.1.0

type WSMessage struct {
	ID      string                 `json:"id,omitempty"`
	Type    string                 `json:"type"`
	Payload map[string]interface{} `json:"payload,omitempty"`
}

WSMessage represents a GraphQL WebSocket Protocol message. This follows the graphql-ws protocol specification.

type WebSocketManager added in v1.1.0

type WebSocketManager struct {
	// contains filtered or unexported fields
}

WebSocketManager manages WebSocket connections for GraphQL subscriptions. It handles connection lifecycle, authentication, and message routing.

func (*WebSocketManager) CloseAllConnections added in v1.1.0

func (m *WebSocketManager) CloseAllConnections()

CloseAllConnections closes all active WebSocket connections. This is useful for graceful shutdown.

func (*WebSocketManager) HandleWebSocket added in v1.1.0

func (m *WebSocketManager) HandleWebSocket(w http.ResponseWriter, r *http.Request)

HandleWebSocket upgrades HTTP connections to WebSocket and manages the connection lifecycle.

type WebSocketParams added in v1.1.0

type WebSocketParams struct {
	// Schema: The GraphQL schema with subscription fields
	Schema *graphql.Schema

	// PubSub: The PubSub system for event distribution
	PubSub PubSub

	// CheckOrigin: Function to check WebSocket upgrade origin
	// If nil, all origins are allowed (development only!)
	CheckOrigin func(r *http.Request) bool

	// AuthFn: Authentication function to extract user details from request
	// Called during connection_init phase
	AuthFn func(r *http.Request) (interface{}, error)

	// RootObjectFn: Custom function to set up root object for each connection
	// Similar to HTTP handler's RootObjectFn
	RootObjectFn func(ctx context.Context, r *http.Request) map[string]interface{}

	// PingInterval: Interval for sending ping messages (default: 30 seconds)
	// Set to 0 to disable automatic pinging
	PingInterval time.Duration

	// ConnectionTimeout: Timeout for connection_init message (default: 10 seconds)
	ConnectionTimeout time.Duration
}

WebSocketParams configures the WebSocket handler for subscriptions.

Directories

Path Synopsis
subscription command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL