Skip to main content

GraphQL API & Subscriptions

Easy AppServer exposes a GraphQL API for querying and manipulating platform resources, with real-time subscriptions powered by WebSockets and the event bus.

Overview

The GraphQL API provides a flexible, type-safe interface for frontend applications to interact with the platform. It supports queries (read), mutations (write), and subscriptions (real-time updates) over HTTP and WebSocket transports.

Code Reference: pkg/v2/presentation/graphql/handler.go:30

Transport Protocols

HTTP (Queries & Mutations)

Standard HTTP POST for queries and mutations:

Endpoint: POST /graphql
Content-Type: application/json

{
"query": "query GetApps { apps { name version } }",
"variables": {}
}

WebSocket (Subscriptions)

WebSocket connection for real-time subscriptions:

Endpoint: WS /graphql
Protocol: graphql-ws
Keep-Alive: 10s ping interval

Configuration:

graphqlHandler.AddTransport(transport.Websocket{
KeepAlivePingInterval: 10 * time.Second,
Upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
},
})

Schema Overview

The GraphQL schema is generated from schema files using gqlgen.

Queries

Apps:

type Query {
# List apps with pagination support
apps(filter: AppFilter, first: Int, after: String): AppConnection!

# Get single app by name
app(name: String!): App

# Asset queries
asset(appName: String!, assetName: String!): Asset

# System queries
health: HealthStatus! # ⚠️ Placeholder implementation
metrics: Metrics! # ⚠️ Placeholder implementation
}
Implementation Status

System Queries: The health and metrics queries are defined in the schema but return placeholder data with TODOs (pkg/v2/presentation/graphql/resolvers/queries.resolvers.go:214-258). These endpoints do not currently provide real health or metrics data.

Production-ready queries: apps, app, asset, settings, settingValue

Settings:

type Query {
# Get all settings for an app
settings(appID: ID!): Settings

# Get specific setting value
settingValue(appID: ID!, key: String!): SettingValue
}

Mutations

App Lifecycle:

type Mutation {
# Install app (registration handled via gRPC)
installApp(name: String!): AppMutationResult!

# Uninstall app
uninstallApp(name: String!): AppMutationResult!
}

Settings:

type Mutation {
# Update settings
updateSettings(appID: ID!, settings: JSON!): SettingsMutationResult!

# Delete settings
deleteSettings(appID: ID!): SettingsMutationResult!

# Trigger hook
triggerHook(name: String!, data: JSON): HookTriggerResult!
}

Subscriptions

Real-Time Updates:

type Subscription {
# Subscribe to app state changes (✅ IMPLEMENTED)
appStateChanged(filter: AppStateFilter): App!

# Hook events (❌ NOT IMPLEMENTED - returns panic)
hookTriggered(hookName: String): HookEvent!

# System events (❌ NOT IMPLEMENTED - returns panic)
systemEvent(types: [SystemEventType!]): SystemEvent!
}

Code Reference: pkg/v2/presentation/graphql/schema/subscriptions.graphql:3

Subscription Implementation

Subscriptions bridge the event bus to GraphQL clients:

Code Reference: pkg/v2/presentation/graphql/resolvers/subscriptions.resolvers.go:20

AppStateChanged Subscription

func (r *subscriptionResolver) AppStateChanged(
ctx context.Context,
filter *generated.AppStateFilter,
) (<-chan *generated.App, error) {
// Create channel for app updates
appChan := make(chan *generated.App, 10)

// Subscribe to relevant event types
eventTypes := []string{
event.EventTypeAppRegistered,
event.EventTypeAppInstalled,
event.EventTypeAppUninstalled,
}

// Event handler
handler := func(ctx context.Context, evt event.Event) error {
// Extract app data from event
appName := extractAppName(evt)

// Apply filter
if !matchesFilter(appName, filter) {
return nil
}

// Fetch full app data
app, err := r.marketplaceService.GetApp(ctx, appName)
if err != nil {
return err
}

// Send to GraphQL client
select {
case appChan <- convertToGraphQL(app):
case <-ctx.Done():
return ctx.Err()
}

return nil
}

// Subscribe to event bus
for _, eventType := range eventTypes {
r.eventBus.Subscribe(eventType, handler)
}

// Cleanup on context cancellation
go func() {
<-ctx.Done()
close(appChan)
}()

return appChan, nil
}

Subscription Flow

Client Subscribes:

GraphQL opens WebSocket

Resolver subscribes to event bus

Event published (e.g., app.installed)

Event handler receives event

Apply filters

Fetch full data from service

Send to GraphQL channel

WebSocket pushes to client

Filtering

Subscriptions support filtering to reduce noise:

subscription {
appStateChanged(filter: {
appNames: ["de.easy-m.todos", "de.easy-m.notes"],
states: [INSTALLED, UNINSTALLED]
}) {
name
state
version
}
}

Client Usage

Query Example

const GET_APPS = gql`
query GetApps($first: Int, $after: String) {
apps(first: $first, after: $after) {
edges {
node {
name
state
installedAt
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;

const { data, loading, error } = useQuery(GET_APPS, {
variables: { first: 10 }
});

Mutation Example

const UPDATE_SETTINGS = gql`
mutation UpdateSettings($appID: ID!, $settings: JSON!) {
updateSettings(appID: $appID, settings: $settings) {
success
settings {
values
}
error {
code
message
}
}
}
`;

const [updateSettings] = useMutation(UPDATE_SETTINGS);

await updateSettings({
variables: {
appID: "uuid",
settings: {
max_items: 250
}
}
});

Subscription Example

const APP_STATE_SUBSCRIPTION = gql`
subscription OnAppStateChanged {
appStateChanged {
name
state
version
}
}
`;

const { data } = useSubscription(APP_STATE_SUBSCRIPTION, {
onData: ({ data }) => {
console.log('App state changed:', data.appStateChanged);
}
});

Resolver Composition

Resolvers delegate to application services:

type Resolver struct {
marketplaceService marketplace.Service
hooksService hooks.Service
uiService ui.Service
settingsService settings.Service
eventBus event.Bus
logger telemetry.Logger
}

Example Resolver:

func (r *queryResolver) Apps(
ctx context.Context,
filter *generated.AppFilter,
) ([]*generated.App, error) {
// Delegate to marketplace service
apps, err := r.marketplaceService.ListApps(ctx, convertFilter(filter))
if err != nil {
return nil, err
}

// Convert domain models to GraphQL types
return convertAppsToGraphQL(apps), nil
}

Error Handling

GraphQL returns structured errors:

{
"data": null,
"errors": [
{
"message": "App not found",
"path": ["app"],
"extensions": {
"code": "NOT_FOUND",
"appName": "de.easy-m.nonexistent"
}
}
]
}

Error Codes

NOT_FOUND: Resource doesn't exist
VALIDATION_ERROR: Input validation failed
PERMISSION_DENIED: Unauthorized
INTERNAL_ERROR: Server error

Playground

The GraphQL Playground provides interactive schema exploration:

Endpoint: GET /playground

Features:

  • Schema browser
  • Query editor with autocomplete
  • Query history
  • Subscription testing

Configuration:

playgroundHandler := playground.Handler("GraphQL Playground", "/graphql")

Can be disabled in production:

NewServer(..., playgroundEnabled: false)

Performance Considerations

Batching

Use DataLoader pattern to batch database queries:

const appLoader = new DataLoader(async (names) => {
return await fetchAppsByNames(names);
});

// Multiple resolvers can use same loader
const app1 = await appLoader.load('app1');
const app2 = await appLoader.load('app2');
// Only 1 database query for both

Query Complexity

Limit query depth and complexity to prevent abuse:

graphqlHandler.Use(extension.FixedComplexityLimit(1000))

Caching

Leverage application service caching:

GraphQL Query → Resolver → Service (checks cache) → Database

Services handle caching transparently.

Security

Authentication

Note: GraphQL currently operates without authentication middleware. Authentication is primarily handled at the gRPC layer for app-to-platform communication.

Future Enhancement: Add authentication middleware:

graphqlHandler.Use(authMiddleware)

Authorization

Resolvers check permissions before returning data:

func (r *queryResolver) App(ctx context.Context, name string) (*generated.App, error) {
// Check if user can view this app
if !hasPermission(ctx, "app:read", name) {
return nil, errors.New("permission denied")
}

return r.marketplaceService.GetApp(ctx, name)
}

Rate Limiting

(Future) Implement per-client rate limiting:

graphqlHandler.Use(rateLimitMiddleware)

Best Practices

For Frontend Developers

Use Fragments:

fragment AppDetails on App {
name
version
state
}

query GetApps {
apps {
...AppDetails
}
}

Handle Loading & Errors:

if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;

Optimize Subscriptions:

// Unsubscribe when component unmounts
useEffect(() => {
const subscription = subscribe();
return () => subscription.unsubscribe();
}, []);

For Backend Developers

Keep Resolvers Thin:

// Good: Delegate to service
func (r *queryResolver) Apps(ctx context.Context) ([]*App, error) {
return r.marketplaceService.ListApps(ctx)
}

// Bad: Business logic in resolver
func (r *queryResolver) Apps(ctx context.Context) ([]*App, error) {
// Complex filtering logic here...
}

Use Context for Request Scoping:

func (r *queryResolver) Settings(ctx context.Context, appID uuid.UUID) (*Settings, error) {
// Extract user from context
user := getUserFromContext(ctx)

return r.settingsService.GetSettings(ctx, appID, user)
}

Further Reading