Skip to main content

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:

LayerPackagesKey Files
Presentationpkg/v2/presentation/graphql/handler.go, server.go
Resolverspkg/v2/presentation/graphql/resolvers/resolver.go, queries.resolvers.go, mutations.resolvers.go, subscriptions.resolvers.go, converters.go
Schemapkg/v2/presentation/graphql/schema/schema.graphql, types.graphql, queries.graphql, mutations.graphql, subscriptions.graphql
Generatedpkg/v2/presentation/graphql/generated/exec.go, models_gen.go (gqlgen generated)
Application Servicespkg/v2/application/marketplace, hooks, ui, settings services
Configurationpkg/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!]!
}

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 found
  • VALIDATION_ERROR - Invalid input
  • AUTHENTICATION_ERROR - Not authenticated
  • AUTHORIZATION_ERROR - Insufficient permissions
  • INTERNAL_ERROR - Server error
  • DEPENDENCY_ERROR - Dependency resolution failed
  • STATE_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:

  1. Extract arguments from GraphQL context
  2. Validate input
  3. Call application service
  4. Convert domain models to GraphQL types (via converters.go)
  5. 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:

  1. Extract arguments
  2. Build command object
  3. Execute via application service
  4. Handle errors with structured Error type
  5. 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:

  1. Subscribe to event bus
  2. Filter events based on subscription arguments
  3. Convert events to GraphQL types
  4. 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

ComponentFileLinesTests/VerificationDescription
GraphQLServergraphql/handler.go22-111graphql/server_test.goServer initialization and handler
NewServergraphql/handler.go31-89Server construction testService wiring
Handlergraphql/handler.go94-105HTTP handler testPlayground + GraphQL routing
Resolvergraphql/resolvers/resolver.go10-30graphql/resolvers/resolver_test.goRoot resolver with services
Query Resolversgraphql/resolvers/queries.resolvers.goFull fileintegration/graphql_integration_test.go:74-160App, hook, settings queries
Mutation Resolversgraphql/resolvers/mutations.resolvers.goFull fileGraphQL mutation testsInstall, uninstall, trigger, update
Subscription Resolversgraphql/resolvers/subscriptions.resolvers.goFull fileWebSocket subscription testsReal-time event streaming
Convertersgraphql/resolvers/converters.goFull fileConverter unit testsDomain → GraphQL type conversion
Schemagraphql/schema/*.graphqlAllSchema introspectionType definitions
Generated Modelsgraphql/generated/models_gen.goGeneratedN/Agqlgen generated types
Generated Executorgraphql/generated/exec.goGeneratedN/Agqlgen generated execution
Navigation GraphQLgraphql/navigation_graphql_test.goFull fileNavigation resolver testHierarchical navigation testing

Configuration

From handler.go:38:

# Enable GraphQL Playground (development only)
APPSERVER_GRAPHQL_PLAYGROUND_ENABLED=true