diff --git a/README.md b/README.md index 9e38b76..65e650a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ HubProxy is a proxy for GitHub webhooks, built for people building with GitHub a - View event type statistics over time - Replay single events or event ranges - Filter and query capabilities for all operations +- **GraphQL API**: + - Alternative to REST API with the same functionality + - Flexible querying with precise field selection + - Interactive GraphiQL and Playground interfaces for testing + - Support for complex queries and mutations in a single request - **Monitoring**: - Provides metrics and logging for webhook delivery status and performance - Track event patterns and volume through API statistics @@ -296,7 +301,11 @@ sqlite3 .cache/hubproxy.db ## API Reference -HubProxy provides a REST API for querying and replaying webhook events. All API endpoints return JSON responses. +HubProxy provides both REST and GraphQL APIs for querying and replaying webhook events. + +### REST API + +All REST API endpoints return JSON responses. ### List Events @@ -469,15 +478,119 @@ Replays all webhook events within a specified time range. } ``` -**Notes:** -- Each replayed event uses GitHub's original delivery ID to ensure proper tracing -- The event is marked with a "replayed" status -- The original event remains unchanged in the database -- The webhook payload is preserved exactly as it was in the original event -- Range replay has a default limit of 100 events (can be overridden with `limit` parameter) +### GraphQL API -### Prometheus Metrics +HubProxy also provides a GraphQL API that mirrors the functionality of the REST API with more flexibility in querying. + +```http +POST /graphql +``` + +The GraphQL endpoint also serves an interactive GraphiQL interface when accessed from a browser, allowing you to explore and test queries. + +#### Queries + +##### List Events + +```graphql +query { + events( + type: "push", + repository: "owner/repo", + sender: "username", + status: "received", + since: "2024-02-01T00:00:00Z", + until: "2024-02-02T00:00:00Z", + limit: 10, + offset: 0 + ) { + events { + id + type + payload + createdAt + status + repository + sender + replayedFrom + originalTime + } + total + } +} +``` + +##### Get Single Event + +```graphql +query { + event(id: "d2a1f85a-delivery-id-123") { + id + type + payload + createdAt + status + repository + sender + } +} +``` + +##### Get Event Statistics +```graphql +query { + stats(since: "2024-02-01T00:00:00Z") { + type + count + } +} +``` + +#### Mutations + +##### Replay Single Event + +```graphql +mutation { + replayEvent(id: "d2a1f85a-delivery-id-123") { + replayedCount + events { + id + type + status + replayedFrom + originalTime + } + } +} +``` + +##### Replay Events by Time Range + +```graphql +mutation { + replayRange( + since: "2024-02-01T00:00:00Z", + until: "2024-02-02T00:00:00Z", + type: "push", + repository: "owner/repo", + sender: "username", + limit: 10 + ) { + replayedCount + events { + id + type + status + replayedFrom + originalTime + } + } +} +``` + +### Prometheus Metrics ``` GET /metrics ``` diff --git a/cmd/hubproxy/main.go b/cmd/hubproxy/main.go index 9d7c773..fe41e46 100644 --- a/cmd/hubproxy/main.go +++ b/cmd/hubproxy/main.go @@ -11,6 +11,7 @@ import ( "time" "hubproxy/internal/api" + "hubproxy/internal/graphql" "hubproxy/internal/metrics" "hubproxy/internal/security" "hubproxy/internal/storage" @@ -249,6 +250,12 @@ func run() error { apiHandler := api.NewHandler(store, logger) apiRouter := chi.NewRouter() + // Create GraphQL handler + graphqlHandler, err := graphql.NewHandler(store, logger) + if err != nil { + return fmt.Errorf("failed to create GraphQL handler: %w", err) + } + apiRouter.Use(metrics.Middleware) apiRouter.Use(middleware.RequestID) if viper.GetBool("trusted-proxy") { @@ -264,6 +271,9 @@ func run() error { apiRouter.Get("/api/replay", apiHandler.ReplayRange) apiRouter.Handle("/metrics", promhttp.Handler()) + // Add GraphQL endpoint + apiRouter.Handle("/graphql", graphqlHandler) + apiSrv := &http.Server{ Handler: apiRouter, ReadTimeout: 10 * time.Second, diff --git a/go.mod b/go.mod index 9a6d5d9..376f88c 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require ( github.com/go-chi/chi/v5 v5.2.1 github.com/go-sql-driver/mysql v1.8.1 github.com/google/uuid v1.6.0 + github.com/graphql-go/graphql v0.8.1 + github.com/graphql-go/handler v0.2.4 github.com/jackc/pgx/v5 v5.7.2 github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.24 diff --git a/go.sum b/go.sum index b552a5a..f713a04 100644 --- a/go.sum +++ b/go.sum @@ -103,6 +103,10 @@ github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 h1:fiJdrgVBkjZ5B1HJ github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/graphql-go/graphql v0.8.1 h1:p7/Ou/WpmulocJeEx7wjQy611rtXGQaAcXGqanuMMgc= +github.com/graphql-go/graphql v0.8.1/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ= +github.com/graphql-go/handler v0.2.4 h1:gz9q11TUHPNUpqzV8LMa+rkqM5NUuH/nkE3oF2LS3rI= +github.com/graphql-go/handler v0.2.4/go.mod h1:gsQlb4gDvURR0bgN8vWQEh+s5vJALM2lYL3n3cf6OxQ= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= diff --git a/internal/graphql/README.md b/internal/graphql/README.md new file mode 100644 index 0000000..270ea05 --- /dev/null +++ b/internal/graphql/README.md @@ -0,0 +1,130 @@ +# GraphQL Interface for HubProxy + +This package implements a GraphQL interface for HubProxy that mirrors the functionality of the REST API. + +## Endpoints + +The GraphQL interface is available at `/graphql` on the API server. + +## Queries + +### `events` - List webhook events + +```graphql +query { + events( + type: String + repository: String + sender: String + status: String + since: DateTime + until: DateTime + limit: Int + offset: Int + ) { + events { + id + type + payload + createdAt + status + error + repository + sender + replayedFrom + originalTime + } + total + } +} +``` + +### `event` - Get a single webhook event by ID + +```graphql +query { + event(id: "event-id") { + id + type + payload + createdAt + status + error + repository + sender + replayedFrom + originalTime + } +} +``` + +### `stats` - Get webhook event statistics + +```graphql +query { + stats(since: "2023-01-01T00:00:00Z") { + type + count + } +} +``` + +## Mutations + +### `replayEvent` - Replay a single webhook event + +```graphql +mutation { + replayEvent(id: "event-id") { + replayedCount + events { + id + type + payload + createdAt + status + repository + sender + replayedFrom + originalTime + } + } +} +``` + +### `replayRange` - Replay multiple webhook events within a time range + +```graphql +mutation { + replayRange( + since: "2023-01-01T00:00:00Z" + until: "2023-01-02T00:00:00Z" + type: String + repository: String + sender: String + limit: Int + ) { + replayedCount + events { + id + type + payload + createdAt + status + repository + sender + replayedFrom + originalTime + } + } +} +``` + +## Interactive Tools + +The GraphQL endpoint includes: + +- **GraphiQL**: An interactive in-browser GraphQL IDE +- **Playground**: An alternative GraphQL IDE + +These tools are available directly at the `/graphql` endpoint when accessed from a browser. diff --git a/internal/graphql/graphql_test.go b/internal/graphql/graphql_test.go new file mode 100644 index 0000000..461d5bd --- /dev/null +++ b/internal/graphql/graphql_test.go @@ -0,0 +1,320 @@ +package graphql + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "sort" + "strings" + "testing" + "time" + + "hubproxy/internal/storage" + "hubproxy/internal/testutil" + + "github.com/graphql-go/graphql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGraphQLQueries(t *testing.T) { + // Setup test environment + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + store := testutil.SetupTestDB(t) + + // Add test data + setupTestData(t, store) + + schema, err := NewSchema(store, logger) + require.NoError(t, err) + + // Test cases + testCases := []struct { + name string + query string + validate func(t *testing.T, result *graphql.Result) + }{ + { + name: "Query Events", + query: ` + query { + events { + events { + id + type + repository + sender + } + total + } + } + `, + validate: func(t *testing.T, result *graphql.Result) { + assert.Nil(t, result.Errors, "GraphQL query returned errors") + + data := result.Data.(map[string]interface{}) + eventsData := data["events"].(map[string]interface{}) + events := eventsData["events"].([]interface{}) + + // Convert total to int for consistent comparison + var total int + switch v := eventsData["total"].(type) { + case float64: + total = int(v) + case int: + total = v + default: + t.Fatalf("Unexpected type for total: %T", eventsData["total"]) + } + + // Verify total count + assert.Equal(t, 2, total) + + // Verify we have 2 events + assert.Len(t, events, 2) + + // Sort events by ID to ensure consistent ordering for validation + sort.Slice(events, func(i, j int) bool { + return events[i].(map[string]interface{})["id"].(string) < + events[j].(map[string]interface{})["id"].(string) + }) + + // Validate first event + event1 := events[0].(map[string]interface{}) + assert.Equal(t, "test-event-1", event1["id"]) + assert.Equal(t, "push", event1["type"]) + assert.Equal(t, "test-repo/test", event1["repository"]) + assert.Equal(t, "test-user", event1["sender"]) + + // Validate second event + event2 := events[1].(map[string]interface{}) + assert.Equal(t, "test-event-2", event2["id"]) + assert.Equal(t, "pull_request", event2["type"]) + assert.Equal(t, "test-repo/test", event2["repository"]) + assert.Equal(t, "test-user", event2["sender"]) + }, + }, + { + name: "Query Single Event", + query: ` + query { + event(id: "test-event-1") { + id + type + repository + sender + } + } + `, + validate: func(t *testing.T, result *graphql.Result) { + assert.Nil(t, result.Errors, "GraphQL query returned errors") + + data := result.Data.(map[string]interface{}) + event := data["event"].(map[string]interface{}) + + assert.Equal(t, "test-event-1", event["id"]) + assert.Equal(t, "push", event["type"]) + assert.Equal(t, "test-repo/test", event["repository"]) + assert.Equal(t, "test-user", event["sender"]) + }, + }, + { + name: "Query Stats", + query: ` + query { + stats { + type + count + } + } + `, + validate: func(t *testing.T, result *graphql.Result) { + assert.Nil(t, result.Errors, "GraphQL query returned errors") + + data := result.Data.(map[string]interface{}) + stats := data["stats"].([]interface{}) + + // Verify we have 2 stat entries + assert.Len(t, stats, 2) + + // Create a map of type to count for easier validation + statMap := make(map[string]int) + for _, stat := range stats { + s := stat.(map[string]interface{}) + statType := s["type"].(string) + + // Handle different numeric types + var count int + switch v := s["count"].(type) { + case float64: + count = int(v) + case int: + count = v + default: + t.Fatalf("Unexpected type for count: %T", s["count"]) + } + + statMap[statType] = count + } + + // Validate counts + assert.Equal(t, 1, statMap["push"]) + assert.Equal(t, 1, statMap["pull_request"]) + }, + }, + } + + // Run test cases + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := executeQuery(schema.schema, tc.query, nil) + tc.validate(t, result) + }) + } +} + +func TestGraphQLMutations(t *testing.T) { + // Setup test environment + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + store := testutil.SetupTestDB(t) + + // Add test data + setupTestData(t, store) + + schema, err := NewSchema(store, logger) + require.NoError(t, err) + + // Test cases for mutations + t.Run("Replay Event", func(t *testing.T) { + query := ` + mutation { + replayEvent(id: "test-event-1") { + replayedCount + events { + id + type + status + replayedFrom + } + } + } + ` + result := executeQuery(schema.schema, query, nil) + assert.Nil(t, result.Errors, "GraphQL mutation returned errors") + + data := result.Data.(map[string]interface{}) + replayEvent := data["replayEvent"].(map[string]interface{}) + + // Handle different numeric types for replayedCount + var replayedCount int + switch v := replayEvent["replayedCount"].(type) { + case float64: + replayedCount = int(v) + case int: + replayedCount = v + default: + t.Fatalf("Unexpected type for replayedCount: %T", replayEvent["replayedCount"]) + } + + // Verify count + assert.Equal(t, 1, replayedCount) + + // Check the events + events := replayEvent["events"].([]interface{}) + require.Len(t, events, 1, "Expected 1 replayed event") + + event := events[0].(map[string]interface{}) + assert.True(t, strings.HasPrefix(event["id"].(string), "test-event-1-replay-"), + "Replayed event ID should start with 'test-event-1-replay-'") + assert.Equal(t, "push", event["type"]) + assert.Equal(t, "replayed", event["status"]) + assert.Equal(t, "test-event-1", event["replayedFrom"]) + }) +} + +func TestGraphQLHandler(t *testing.T) { + // Setup test environment + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + store := testutil.SetupTestDB(t) + + // Add test data + setupTestData(t, store) + + // Create handler + handler, err := NewHandler(store, logger) + require.NoError(t, err) + + // Create test server + server := httptest.NewServer(handler) + defer server.Close() + + // Test query + query := `{"query": "{ events { total events { id type } } }"}` + resp, err := http.Post(server.URL, "application/json", bytes.NewBufferString(query)) + require.NoError(t, err) + defer resp.Body.Close() + + // Check response + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + + // Verify data exists and has no errors + assert.Contains(t, result, "data") + assert.NotContains(t, result, "errors") + + // Verify events data + data := result["data"].(map[string]interface{}) + events := data["events"].(map[string]interface{}) + assert.Equal(t, float64(2), events["total"]) + assert.Len(t, events["events"], 2) +} + +// Helper function to execute GraphQL queries +func executeQuery(schema graphql.Schema, query string, variables map[string]interface{}) *graphql.Result { + result := graphql.Do(graphql.Params{ + Schema: schema, + RequestString: query, + VariableValues: variables, + Context: context.Background(), + }) + return result +} + +// Helper function to set up test data +func setupTestData(t *testing.T, store storage.Storage) { + // Add test events + now := time.Now() + + event1 := &storage.Event{ + ID: "test-event-1", + Type: "push", + Payload: []byte(`{"ref": "refs/heads/main"}`), + CreatedAt: now.Add(-1 * time.Hour), + Status: "received", + Repository: "test-repo/test", + Sender: "test-user", + } + + event2 := &storage.Event{ + ID: "test-event-2", + Type: "pull_request", + Payload: []byte(`{"action": "opened"}`), + CreatedAt: now, + Status: "received", + Repository: "test-repo/test", + Sender: "test-user", + } + + err := store.StoreEvent(context.Background(), event1) + require.NoError(t, err) + + err = store.StoreEvent(context.Background(), event2) + require.NoError(t, err) +} diff --git a/internal/graphql/handler.go b/internal/graphql/handler.go new file mode 100644 index 0000000..6014e1d --- /dev/null +++ b/internal/graphql/handler.go @@ -0,0 +1,28 @@ +package graphql + +import ( + "log/slog" + "net/http" + + "hubproxy/internal/storage" + + "github.com/graphql-go/handler" +) + +// NewHandler creates a new GraphQL HTTP handler +func NewHandler(store storage.Storage, logger *slog.Logger) (http.Handler, error) { + schema, err := NewSchema(store, logger) + if err != nil { + return nil, err + } + + // Create a GraphQL HTTP handler + h := handler.New(&handler.Config{ + Schema: &schema.schema, + Pretty: true, + GraphiQL: true, // Enable GraphiQL interface for easy testing + Playground: true, // Enable Playground interface as an alternative to GraphiQL + }) + + return h, nil +} diff --git a/internal/graphql/resolvers.go b/internal/graphql/resolvers.go new file mode 100644 index 0000000..d76838a --- /dev/null +++ b/internal/graphql/resolvers.go @@ -0,0 +1,255 @@ +package graphql + +import ( + "fmt" + "time" + + "hubproxy/internal/storage" + + "github.com/google/uuid" + "github.com/graphql-go/graphql" +) + +// resolveEvents handles the events query +func (s *Schema) resolveEvents(p graphql.ResolveParams) (interface{}, error) { + if s.store == nil { + return nil, fmt.Errorf("storage not configured") + } + + // Parse query parameters + opts := storage.QueryOptions{ + Limit: 50, // Default limit + Offset: 0, // Default offset + } + + // Parse type filter + if t, ok := p.Args["type"].(string); ok && t != "" { + opts.Types = []string{t} // Single type for now + } + + // Parse other filters + if repo, ok := p.Args["repository"].(string); ok && repo != "" { + opts.Repository = repo + } + + if sender, ok := p.Args["sender"].(string); ok && sender != "" { + opts.Sender = sender + } + + if status, ok := p.Args["status"].(string); ok && status != "" { + opts.Status = status + } + + // Parse since/until + if since, ok := p.Args["since"].(time.Time); ok { + opts.Since = since + } + + if until, ok := p.Args["until"].(time.Time); ok { + opts.Until = until + } + + // Parse limit/offset + if limit, ok := p.Args["limit"].(int); ok && limit > 0 { + opts.Limit = limit + } + + if offset, ok := p.Args["offset"].(int); ok && offset >= 0 { + opts.Offset = offset + } + + // Get events + events, total, err := s.store.ListEvents(p.Context, opts) + if err != nil { + s.logger.Error("Error listing events", "error", err) + return nil, err + } + + return map[string]interface{}{ + "events": events, + "total": total, + }, nil +} + +// resolveEvent handles the event query +func (s *Schema) resolveEvent(p graphql.ResolveParams) (interface{}, error) { + if s.store == nil { + return nil, fmt.Errorf("storage not configured") + } + + id, ok := p.Args["id"].(string) + if !ok || id == "" { + return nil, fmt.Errorf("invalid event ID") + } + + event, err := s.store.GetEvent(p.Context, id) + if err != nil { + s.logger.Error("Error getting event", "error", err) + return nil, err + } + + if event == nil { + return nil, fmt.Errorf("event not found") + } + + return event, nil +} + +// resolveStats handles the stats query +func (s *Schema) resolveStats(p graphql.ResolveParams) (interface{}, error) { + if s.store == nil { + return nil, fmt.Errorf("storage not configured") + } + + var since time.Time + if sinceArg, ok := p.Args["since"].(time.Time); ok { + since = sinceArg + } + + statsMap, err := s.store.GetStats(p.Context, since) + if err != nil { + s.logger.Error("Error getting stats", "error", err) + return nil, err + } + + // Convert map to array of stats + stats := make([]map[string]interface{}, 0, len(statsMap)) + for eventType, count := range statsMap { + stats = append(stats, map[string]interface{}{ + "type": eventType, + "count": count, + }) + } + + return stats, nil +} + +// resolveReplayEvent handles the replayEvent mutation +func (s *Schema) resolveReplayEvent(p graphql.ResolveParams) (interface{}, error) { + if s.store == nil { + return nil, fmt.Errorf("storage not configured") + } + + id, ok := p.Args["id"].(string) + if !ok || id == "" { + return nil, fmt.Errorf("invalid event ID") + } + + // Get event from storage + event, err := s.store.GetEvent(p.Context, id) + if err != nil { + s.logger.Error("Error getting event", "error", err) + return nil, err + } + + if event == nil { + return nil, fmt.Errorf("event not found") + } + + // Create new event with same payload but new ID and timestamp + replayEvent := &storage.Event{ + ID: fmt.Sprintf("%s-replay-%s", event.ID, uuid.New().String()), // Format: original-id-replay-uuid + Type: event.Type, + Payload: event.Payload, + CreatedAt: time.Now(), + Status: "replayed", + Repository: event.Repository, + Sender: event.Sender, + ReplayedFrom: event.ID, + OriginalTime: event.CreatedAt, + } + + // Store the replayed event + if err := s.store.StoreEvent(p.Context, replayEvent); err != nil { + s.logger.Error("Error storing replayed event", "error", err) + return nil, err + } + + return map[string]interface{}{ + "replayedCount": 1, + "events": []*storage.Event{replayEvent}, + }, nil +} + +// resolveReplayRange handles the replayRange mutation +func (s *Schema) resolveReplayRange(p graphql.ResolveParams) (interface{}, error) { + if s.store == nil { + return nil, fmt.Errorf("storage not configured") + } + + // Parse query parameters for time range + opts := storage.QueryOptions{ + Limit: 100, // Default limit for replay + Offset: 0, + } + + // Parse limit if provided + if limit, ok := p.Args["limit"].(int); ok && limit > 0 { + opts.Limit = limit + } + + // Parse since/until (both required for range replay) + since, ok := p.Args["since"].(time.Time) + if !ok { + return nil, fmt.Errorf("missing since parameter") + } + opts.Since = since + + until, ok := p.Args["until"].(time.Time) + if !ok { + return nil, fmt.Errorf("missing until parameter") + } + opts.Until = until + + // Optional filters + if t, ok := p.Args["type"].(string); ok && t != "" { + opts.Types = []string{t} + } + + if repo, ok := p.Args["repository"].(string); ok && repo != "" { + opts.Repository = repo + } + + if sender, ok := p.Args["sender"].(string); ok && sender != "" { + opts.Sender = sender + } + + // Get events in range + events, _, err := s.store.ListEvents(p.Context, opts) + if err != nil { + s.logger.Error("Error listing events", "error", err) + return nil, err + } + + if len(events) == 0 { + return nil, fmt.Errorf("no events found in range") + } + + // Replay each event + replayedEvents := make([]*storage.Event, 0, len(events)) + for _, event := range events { + replayEvent := &storage.Event{ + ID: fmt.Sprintf("%s-replay-%s", event.ID, uuid.New().String()), // Format: original-id-replay-uuid + Type: event.Type, + Payload: event.Payload, + CreatedAt: time.Now(), + Status: "replayed", + Repository: event.Repository, + Sender: event.Sender, + ReplayedFrom: event.ID, + OriginalTime: event.CreatedAt, + } + + if err := s.store.StoreEvent(p.Context, replayEvent); err != nil { + s.logger.Error("Error storing replayed event", "error", err) + return nil, err + } + + replayedEvents = append(replayedEvents, replayEvent) + } + + return map[string]interface{}{ + "replayedCount": len(replayedEvents), + "events": replayedEvents, + }, nil +} diff --git a/internal/graphql/schema.go b/internal/graphql/schema.go new file mode 100644 index 0000000..fd416df --- /dev/null +++ b/internal/graphql/schema.go @@ -0,0 +1,230 @@ +package graphql + +import ( + "log/slog" + + "hubproxy/internal/storage" + + "github.com/graphql-go/graphql" +) + +// Schema defines the GraphQL schema and resolvers +type Schema struct { + schema graphql.Schema + store storage.Storage + logger *slog.Logger +} + +// NewSchema creates a new GraphQL schema with the given storage +func NewSchema(store storage.Storage, logger *slog.Logger) (*Schema, error) { + s := &Schema{ + store: store, + logger: logger, + } + + // Define Event type + eventType := graphql.NewObject(graphql.ObjectConfig{ + Name: "Event", + Fields: graphql.Fields{ + "id": &graphql.Field{ + Type: graphql.String, + }, + "type": &graphql.Field{ + Type: graphql.String, + }, + "payload": &graphql.Field{ + Type: graphql.String, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + if event, ok := p.Source.(*storage.Event); ok { + return string(event.Payload), nil + } + return nil, nil + }, + }, + "createdAt": &graphql.Field{ + Type: graphql.DateTime, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + if event, ok := p.Source.(*storage.Event); ok { + return event.CreatedAt, nil + } + return nil, nil + }, + }, + "status": &graphql.Field{ + Type: graphql.String, + }, + "error": &graphql.Field{ + Type: graphql.String, + }, + "repository": &graphql.Field{ + Type: graphql.String, + }, + "sender": &graphql.Field{ + Type: graphql.String, + }, + "replayedFrom": &graphql.Field{ + Type: graphql.String, + }, + "originalTime": &graphql.Field{ + Type: graphql.DateTime, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + if event, ok := p.Source.(*storage.Event); ok { + return event.OriginalTime, nil + } + return nil, nil + }, + }, + }, + }) + + // Define EventsResponse type + eventsResponseType := graphql.NewObject(graphql.ObjectConfig{ + Name: "EventsResponse", + Fields: graphql.Fields{ + "events": &graphql.Field{ + Type: graphql.NewList(eventType), + }, + "total": &graphql.Field{ + Type: graphql.Int, + }, + }, + }) + + // Define ReplayResponse type + replayResponseType := graphql.NewObject(graphql.ObjectConfig{ + Name: "ReplayResponse", + Fields: graphql.Fields{ + "replayedCount": &graphql.Field{ + Type: graphql.Int, + }, + "events": &graphql.Field{ + Type: graphql.NewList(eventType), + }, + }, + }) + + // Define Stats type + statType := graphql.NewObject(graphql.ObjectConfig{ + Name: "Stat", + Fields: graphql.Fields{ + "type": &graphql.Field{ + Type: graphql.String, + }, + "count": &graphql.Field{ + Type: graphql.Int, + }, + }, + }) + + // Define root query + rootQuery := graphql.NewObject(graphql.ObjectConfig{ + Name: "RootQuery", + Fields: graphql.Fields{ + "events": &graphql.Field{ + Type: eventsResponseType, + Args: graphql.FieldConfigArgument{ + "type": &graphql.ArgumentConfig{ + Type: graphql.String, + }, + "repository": &graphql.ArgumentConfig{ + Type: graphql.String, + }, + "sender": &graphql.ArgumentConfig{ + Type: graphql.String, + }, + "status": &graphql.ArgumentConfig{ + Type: graphql.String, + }, + "since": &graphql.ArgumentConfig{ + Type: graphql.DateTime, + }, + "until": &graphql.ArgumentConfig{ + Type: graphql.DateTime, + }, + "limit": &graphql.ArgumentConfig{ + Type: graphql.Int, + }, + "offset": &graphql.ArgumentConfig{ + Type: graphql.Int, + }, + }, + Resolve: s.resolveEvents, + }, + "event": &graphql.Field{ + Type: eventType, + Args: graphql.FieldConfigArgument{ + "id": &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.String), + }, + }, + Resolve: s.resolveEvent, + }, + "stats": &graphql.Field{ + Type: graphql.NewList(statType), + Args: graphql.FieldConfigArgument{ + "since": &graphql.ArgumentConfig{ + Type: graphql.DateTime, + }, + }, + Resolve: s.resolveStats, + }, + }, + }) + + // Define root mutation + rootMutation := graphql.NewObject(graphql.ObjectConfig{ + Name: "RootMutation", + Fields: graphql.Fields{ + "replayEvent": &graphql.Field{ + Type: replayResponseType, + Args: graphql.FieldConfigArgument{ + "id": &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.String), + }, + }, + Resolve: s.resolveReplayEvent, + }, + "replayRange": &graphql.Field{ + Type: replayResponseType, + Args: graphql.FieldConfigArgument{ + "since": &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.DateTime), + }, + "until": &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.DateTime), + }, + "type": &graphql.ArgumentConfig{ + Type: graphql.String, + }, + "repository": &graphql.ArgumentConfig{ + Type: graphql.String, + }, + "sender": &graphql.ArgumentConfig{ + Type: graphql.String, + }, + "limit": &graphql.ArgumentConfig{ + Type: graphql.Int, + }, + }, + Resolve: s.resolveReplayRange, + }, + }, + }) + + // Create schema + schema, err := graphql.NewSchema(graphql.SchemaConfig{ + Query: rootQuery, + Mutation: rootMutation, + }) + if err != nil { + return nil, err + } + + s.schema = schema + return s, nil +} + +// Schema returns the GraphQL schema +func (s *Schema) Schema() graphql.Schema { + return s.schema +}