GraphQL API
Real-time queries, mutations, and subscriptions for platform and application data using gqlgen-powered GraphQL server.
Scope
This document covers the following packages and their interfaces:
| Layer | Packages | Key Files |
|---|---|---|
| Presentation | pkg/v2/presentation/graphql/ | handler.go, server.go |
| Resolvers | pkg/v2/presentation/graphql/resolvers/ | resolver.go, queries.resolvers.go, mutations.resolvers.go, subscriptions.resolvers.go, converters.go |
| Schema | pkg/v2/presentation/graphql/schema/ | schema.graphql, types.graphql, queries.graphql, mutations.graphql, subscriptions.graphql |
| Generated | pkg/v2/presentation/graphql/generated/ | exec.go, models_gen.go (gqlgen generated) |
| Application Services | pkg/v2/application/ | marketplace, hooks, ui, settings services |
| Configuration | pkg/v2/config/ | config.go (GraphQL playground config) |
Overview
Based on pkg/v2/presentation/graphql/, Easy AppServer provides a comprehensive GraphQL API:
- gqlgen Framework: Type-safe GraphQL server with code generation
- Queries: Read application, hook, settings, asset, and system data
- Mutations: App lifecycle management, hook triggering, settings updates
- Subscriptions: Real-time updates via WebSocket (app state, hooks, system events)
- Playground: Interactive API explorer (configurable for development)
- Multi-Transport: HTTP POST for queries/mutations, WebSocket for subscriptions
GraphQL Endpoints
From handler.go:30-105:
Primary Endpoint:
URL: http://localhost:8080/graphql
WebSocket: ws://localhost:8080/graphql (for subscriptions)
Playground (if enabled):
URL: http://localhost:8080/graphql (GET request)
Enabled via: APPSERVER_GRAPHQL_PLAYGROUND_ENABLED=true
Server Architecture
Based on handler.go:22-89:
GraphQLServer Structure
type GraphQLServer struct {
handler http.Handler // gqlgen handler
playgroundEnabled bool // Playground toggle
resolver *resolvers.Resolver // Service integrations
logger telemetry.Logger
}
Initialization
From handler.go:31-88:
func NewServer(
marketplaceService marketplace.Service,
hooksService hooks.Service,
uiService ui.Service,
settingsService settings.Service,
eventBus event.Bus,
logger telemetry.Logger,
playgroundEnabled bool,
) *GraphQLServer
Dependencies & Interactions:
- → Marketplace Service: App queries, install/uninstall mutations
- → Hooks Service: Hook queries, trigger mutations, hook subscriptions
- → UI Service: Asset queries, UI configuration
- → Settings Service: Settings queries and mutations
- → Event Bus: Real-time subscriptions to domain events
Transport Configuration
From handler.go:58-73:
HTTP POST Transport (queries/mutations):
graphqlHandler.AddTransport(transport.POST{})
WebSocket Transport (subscriptions):
graphqlHandler.AddTransport(transport.Websocket{
KeepAlivePingInterval: 10 * time.Second,
Upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // TODO: Configure allowed origins
},
ReadBufferSize: 1024,
WriteBufferSize: 1024,
},
})
Schema Overview
Root Types
From schema/schema.graphql:
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
Core Domain Types
From schema/types.graphql:
- App - Application with state, dependencies, manifest, routes
- Hook - Event hooks with execution statistics
- Settings - App configuration with schema and values
- Asset - Frontend assets with SRI hashes
- Certificate - X.509 certificates for app authentication
- WebApp - UI and API configuration
- NavigationRoute - Hierarchical navigation (up to 3 levels)
Queries
From schema/queries.graphql:3-22:
Application Queries
Get Single App:
query GetApp($name: String!) {
app(name: $name) {
id
name
state
registeredAt
installedAt
dependencies {
app {
name
}
kind
versionConstraint
}
webApp {
ui {
mode
entryAssetName
navigation {
title
href
icon
requiredPermissions
children {
title
href
}
}
}
api {
basePath
upstreamBaseURL
routes {
pattern
methods
scopes
}
}
}
}
}
List Apps with Filtering & Pagination:
query ListApps(
$filter: AppFilter
$first: Int
$after: String
) {
apps(filter: $filter, first: $first, after: $after) {
edges {
node {
id
name
state
isInstalled
isRegistered
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
totalCount
}
}
Filter Options (AppFilter):
input AppFilter {
state: AppState # Filter by single state
states: [AppState!] # Filter by multiple states
nameContains: String # Partial name match
registeredAfter: DateTime # Registered after timestamp
installedAfter: DateTime # Installed after timestamp
}
enum AppState {
UNREGISTERED
REGISTERED
INSTALLED
UNINSTALLED
}
Hook Queries
Get Single Hook:
query GetHook($id: ID!) {
hook(id: $id) {
id
name
app {
name
}
async
totalExecutions
successRate
averageDurationMs
registeredAt
}
}
List Hooks with Filtering:
query ListHooks($appName: String, $filter: HookFilter) {
hooks(appName: $appName, filter: $filter) {
id
name
async
totalExecutions
successRate
}
}
Filter Options (HookFilter):
input HookFilter {
async: Boolean # Filter by async/sync
nameContains: String # Partial name match
}
Settings Queries
Get App Settings:
query GetSettings($appID: ID!) {
settings(appID: $appID) {
appID
schema # JSON schema definition
values # Current setting values (sensitive masked)
updatedAt
updatedBy
}
}
Get Single Setting Value:
query GetSettingValue($appID: ID!, $key: String!) {
settingValue(appID: $appID, key: $key) {
key
value # JSON value (sensitive masked)
}
}
Asset Queries
Get Asset:
query GetAsset($appName: String!, $assetName: String!) {
asset(appName: $appName, assetName: $assetName) {
name
mimeType
size
sha256 # Checksum
url # Public URL
}
}
System Queries
Health Status:
query GetHealth {
health {
status # HEALTHY | DEGRADED | UNHEALTHY
checks {
name
status
message
timestamp
}
timestamp
}
}
System Metrics:
query GetMetrics {
metrics {
apps {
totalApps
registeredApps
installedApps
uninstalledApps
}
hooks {
totalHooks
totalExecutions
successRate
averageDurationMs
}
proxy {
totalRequests
requestsPerSecond
averageLatencyMs
errorRate
}
system {
uptime
memoryUsage {
total
used
free
percentUsed
}
cpuUsage
}
}
}
Mutations
From schema/mutations.graphql:3-14:
App Lifecycle
Install App:
mutation InstallApp($name: String!) {
installApp(name: $name) {
success
app {
id
name
state
installedAt
}
error {
code
message
details
}
}
}
Uninstall App:
mutation UninstallApp($name: String!) {
uninstallApp(name: $name) {
success
app {
id
name
state
uninstalledAt
}
error {
code
message
}
}
}
Hook Operations
Trigger Hook:
mutation TriggerHook($name: String!, $data: JSON) {
triggerHook(name: $name, data: $data) {
success
results {
app {
name
}
data # Hook listener response
success
error {
code
message
}
}
error {
code
message
}
}
}
Settings Operations
Update Settings:
mutation UpdateSettings($appID: ID!, $settings: JSON!) {
updateSettings(appID: $appID, settings: $settings) {
success
settings {
appID
values
updatedAt
updatedBy
}
error {
code
message
details # Validation errors
}
}
}
Delete Settings:
mutation DeleteSettings($appID: ID!) {
deleteSettings(appID: $appID) {
success
error {
code
message
}
}
}
Mutation Result Types
From schema/mutations.graphql:16-46:
All mutations return structured results:
type AppMutationResult {
success: Boolean!
app: App
error: Error
}
type Error {
code: String! # Error code (e.g., "NOT_FOUND")
message: String! # Human-readable message
details: JSON # Additional error context
}
Subscriptions
From schema/subscriptions.graphql:3-12:
App State Changes
Subscribe to App State Changes:
subscription OnAppStateChanged($filter: AppStateFilter) {
appStateChanged(filter: $filter) {
id
name
state
installedAt
uninstalledAt
}
}
Filter Options (AppStateFilter):
input AppStateFilter {
states: [AppState!] # Only emit for these states
appNames: [String!] # Only emit for these apps
}
Example Usage:
# Subscribe to installations/uninstallations
subscription {
appStateChanged(filter: {
states: [INSTALLED, UNINSTALLED]
}) {
name
state
}
}
Hook Events
Subscribe to Hook Triggers:
subscription OnHookTriggered($hookName: String) {
hookTriggered(hookName: $hookName) {
hook {
name
app {
name
}
}
triggeredBy {
name
}
data # Hook payload
timestamp
}
}
System Events
Subscribe to System Events:
subscription OnSystemEvent($types: [SystemEventType!]) {
systemEvent(types: $types) {
type
message
data
timestamp
}
}
enum SystemEventType {
SERVER_STARTED
SERVER_STOPPING
ERROR_OCCURRED
CONFIG_CHANGED
}
Type System
Relay-Style Pagination
From types.graphql:3-6, 206-223:
Node Interface:
interface Node {
id: ID!
}
Connection Pattern:
type AppConnection {
edges: [AppEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type AppEdge {
node: App!
cursor: String! # Opaque cursor for pagination
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
App Type
From types.graphql:8-33:
type App implements Node {
id: ID!
name: String!
state: AppState!
dependencies: [AppDependency!]!
# Timestamps
registeredAt: DateTime!
installedAt: DateTime
uninstalledAt: DateTime
updatedAt: DateTime!
# Manifest data
certificate: Certificate
webApp: WebApp!
# Related data
hooks: [Hook!]!
settings: Settings
# Computed fields
isInstalled: Boolean!
isRegistered: Boolean!
routes: [Route!]!
}
WebApp Type
From types.graphql:50-54:
type WebApp {
assets: [Asset!]!
ui: UIConfig # Nullable - API-only apps
api: APIConfig # Nullable - UI-only apps
}
UIConfig Type
From types.graphql:64-71:
type UIConfig {
mode: UIMode! # FEDERATION | WEB_COMPONENT | ESM_COMPONENT
entryAssetName: String!
exposedModule: String
routeBase: String!
ssr: Boolean!
navigation: [NavigationRoute!]!
}
NavigationRoute Type
From types.graphql:79-99:
Hierarchical Navigation (supports up to 3 levels):
type NavigationRoute {
title: String! # Display title
href: String! # Route path (may include params like /projects/:id)
icon: String # SVG string or image URL
requiredPermissions: [String!]! # OpenFGA scopes
children: [NavigationRoute!]! # Recursive (max 3 levels)
metadata: [KeyValuePair!]! # Custom metadata
}
APIConfig Type
From types.graphql:106-117:
type APIConfig {
basePath: String!
upstreamBaseURL: String!
stripBasePath: Boolean!
timeoutMs: Int!
forwardHeaders: [String!]!
routes: [RouteSpec!]!
requiredPermissions: [String!]!
defaultRateLimit: RateLimit
healthCheck: HealthCheck
openAPIURL: String
}
RouteSpec Type
From types.graphql:119-125:
type RouteSpec {
pattern: String! # e.g., "/api/apps/todos/**"
methods: [HTTPMethod!]!
scopes: [String!]! # Permission scopes
timeoutMs: Int
rateLimit: RateLimit
}
Error Handling
Standard Error Format
From mutations.graphql:42-46:
type Error {
code: String! # Error code
message: String! # Human-readable message
details: JSON # Additional context
}
Error Codes
Common Error Codes:
NOT_FOUND- Resource not foundVALIDATION_ERROR- Invalid inputAUTHENTICATION_ERROR- Not authenticatedAUTHORIZATION_ERROR- Insufficient permissionsINTERNAL_ERROR- Server errorDEPENDENCY_ERROR- Dependency resolution failedSTATE_ERROR- Invalid state transition
GraphQL Error Response
Standard GraphQL error format:
{
"errors": [
{
"message": "Application not found",
"path": ["app"],
"extensions": {
"code": "NOT_FOUND",
"appName": "non-existent-app"
}
}
],
"data": {
"app": null
}
}
Resolver Implementation
Based on resolvers/resolver.go:
Resolver Structure
type Resolver struct {
marketplaceService marketplace.Service
hooksService hooks.Service
uiService ui.Service
settingsService settings.Service
eventBus event.Bus
logger telemetry.Logger
}
Query Resolvers
From resolvers/queries.resolvers.go:
Pattern:
- Extract arguments from GraphQL context
- Validate input
- Call application service
- Convert domain models to GraphQL types (via
converters.go) - Return typed response
Example (App query):
func (r *queryResolver) App(ctx context.Context, name string) (*model.App, error) {
// Call marketplace service
query := marketplace.GetAppQuery{Name: name}
appEntity, err := r.marketplaceService.GetApp(ctx, query)
if err != nil {
return nil, err
}
// Convert to GraphQL model
return converters.ConvertAppToGraphQL(appEntity), nil
}
Mutation Resolvers
From resolvers/mutations.resolvers.go:
Pattern:
- Extract arguments
- Build command object
- Execute via application service
- Handle errors with structured Error type
- Return mutation result
Example (InstallApp):
func (r *mutationResolver) InstallApp(ctx context.Context, name string) (*model.AppMutationResult, error) {
cmd := marketplace.InstallAppCommand{AppName: name}
resp, err := r.marketplaceService.InstallApp(ctx, cmd)
if err != nil {
return &model.AppMutationResult{
Success: false,
Error: &model.Error{
Code: "INSTALL_FAILED",
Message: err.Error(),
},
}, nil
}
return &model.AppMutationResult{
Success: true,
App: converters.ConvertAppToGraphQL(resp.App),
}, nil
}
Subscription Resolvers
From resolvers/subscriptions.resolvers.go:
Pattern:
- Subscribe to event bus
- Filter events based on subscription arguments
- Convert events to GraphQL types
- Stream to client via channel
Example (AppStateChanged):
func (r *subscriptionResolver) AppStateChanged(
ctx context.Context,
filter *model.AppStateFilter,
) (<-chan *model.App, error) {
ch := make(chan *model.App)
// Subscribe to app events
r.eventBus.Subscribe("app.*", func(evt event.Event) {
// Filter based on subscription args
if filter != nil && !matchesFilter(evt, filter) {
return
}
// Convert and send to channel
app := converters.ConvertAppToGraphQL(appFromEvent(evt))
select {
case ch <- app:
case <-ctx.Done():
close(ch)
return
}
})
return ch, nil
}
GraphQL Playground
From handler.go:94-104:
Access
Enabled in Development:
APPSERVER_GRAPHQL_PLAYGROUND_ENABLED=true
URL:
http://localhost:8080/graphql (GET request)
Features
- Interactive Query Editor: Syntax highlighting, autocomplete
- Schema Documentation: Auto-generated from schema introspection
- Query History: Recent queries saved locally
- Variable Editor: JSON variables for parameterized queries
- Response Viewer: Formatted JSON response
- Subscription Support: WebSocket subscription testing
Playground Handler Logic
func (s *GraphQLServer) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Serve playground for GET (except WebSocket upgrade)
if s.playgroundEnabled && r.Method == "GET" && r.Header.Get("Upgrade") != "websocket" {
playground.Handler("GraphQL Playground", r.URL.Path).ServeHTTP(w, r)
return
}
// Serve GraphQL for POST and WebSocket
s.handler.ServeHTTP(w, r)
})
}
Security Considerations
Authentication
Session-Based (from HTTP server middleware):
- Kratos session cookies validated
- User identity extracted from session
- Available in GraphQL context
TODO (from handler.go:75-81):
- Authorization middleware
- Field-level permissions
- Query complexity limits
- Rate limiting
Authorization
OpenFGA Integration:
- App-level permissions checked in resolvers
- Field-level permissions via directives (planned)
- Mutation authorization before service calls
Input Validation
GraphQL Schema Validation:
- Type checking enforced by gqlgen
- Required fields validated
- Enum values constrained
Application-Level Validation:
- Commands validated in application services
- Business rules enforced
- Structured errors returned
Performance Considerations
Subscription Scalability
WebSocket Configuration:
- Keep-alive ping: 10 seconds
- Read/write buffers: 1024 bytes
- Origin checking configurable
Event Bus Integration:
- Efficient event filtering
- Channel-based streaming
- Context cancellation support
Query Optimization
N+1 Prevention (via DataLoader pattern - TODO):
- Batch loading of related entities
- Caching within request scope
Pagination:
- Cursor-based (Relay specification)
- Prevents loading large datasets
- Total count available
Code Reference Table
| Component | File | Lines | Tests/Verification | Description |
|---|---|---|---|---|
| GraphQLServer | graphql/handler.go | 22-111 | graphql/server_test.go | Server initialization and handler |
| NewServer | graphql/handler.go | 31-89 | Server construction test | Service wiring |
| Handler | graphql/handler.go | 94-105 | HTTP handler test | Playground + GraphQL routing |
| Resolver | graphql/resolvers/resolver.go | 10-30 | graphql/resolvers/resolver_test.go | Root resolver with services |
| Query Resolvers | graphql/resolvers/queries.resolvers.go | Full file | integration/graphql_integration_test.go:74-160 | App, hook, settings queries |
| Mutation Resolvers | graphql/resolvers/mutations.resolvers.go | Full file | GraphQL mutation tests | Install, uninstall, trigger, update |
| Subscription Resolvers | graphql/resolvers/subscriptions.resolvers.go | Full file | WebSocket subscription tests | Real-time event streaming |
| Converters | graphql/resolvers/converters.go | Full file | Converter unit tests | Domain → GraphQL type conversion |
| Schema | graphql/schema/*.graphql | All | Schema introspection | Type definitions |
| Generated Models | graphql/generated/models_gen.go | Generated | N/A | gqlgen generated types |
| Generated Executor | graphql/generated/exec.go | Generated | N/A | gqlgen generated execution |
| Navigation GraphQL | graphql/navigation_graphql_test.go | Full file | Navigation resolver test | Hierarchical navigation testing |
Configuration
From handler.go:38:
# Enable GraphQL Playground (development only)
APPSERVER_GRAPHQL_PLAYGROUND_ENABLED=true
Related Topics
- API Gateway & Routing - HTTP endpoints and routing
- Application Marketplace - App lifecycle operations
- Hooks Architecture - Hook system details
- Settings Management - Settings operations
- UI Delivery & Microfrontends - Asset serving
- Event-Driven Architecture - Event bus integration
- Authentication & Authorization - Security model