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
}
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)
}
Related Concepts
- Event-Driven Architecture - Event bus integration
- Settings Management - Settings GraphQL API
- Application Lifecycle - App mutations
- gRPC Services - Alternative API
- Developer Platform & SDK - GraphQL client usage
Further Reading
- gqlgen Documentation - GraphQL code generator
- GraphQL Best Practices - Official guide
- Apollo Client - Frontend client library