gRPC API
Complete reference for the Easy AppServer gRPC services for app integration.
Overview
The AppServer exposes four primary gRPC services for app lifecycle management, event-driven communication, and configuration:
- Marketplace Service - App registration, installation, and lifecycle notifications
- Hooks Service - Event-driven communication via named hooks
- Activity Service - Request-response activities between apps
- Settings Service - App configuration and settings management
All services support bidirectional streaming for real-time, low-latency communication.
Base URL:
- Local:
localhost:9090 - Production: Configured via
APPSERVER_GRPC_PORT
Proto Definitions:
- Location:
easy.proto/v2/protos/services.proto - Package:
easy.v2 - Go package:
bitbucket.org/easymarketinggmbh/easy.proto/v2/go/services
Authentication
All gRPC methods require app authentication using RSA signatures.
App Signature Authentication
Apps must include these metadata headers:
x-app-name: myapp
x-app-signature: <base64-encoded-rsa-signature>
x-request-timestamp: 2024-11-18T12:00:00Z
Signature Generation:
- Concatenate:
method + service + timestamp - Sign with app's private RSA key (SHA-256)
- Base64 encode signature
- Include in
x-app-signaturemetadata
Verification:
- Signature verified against app's public key from certificate
- Timestamp checked for replay protection (5-minute window)
- Clock skew tolerance: 30 seconds
Implementation: pkg/v2/presentation/grpc/interceptors/auth.go
Bootstrap Registration
New apps that haven't registered yet can call Marketplace.Subscribe with a provisional auth context to register themselves.
Common Patterns
Interceptors
All gRPC methods pass through these interceptors:
- Logging - Structured logging with method, duration, status
- Metrics - Prometheus metrics collection
- Tracing - OpenTelemetry distributed tracing
- Recovery - Panic recovery with graceful error responses
- Validation - Request message validation
- Auth - Authentication extraction and verification
Error Handling
gRPC uses standard status codes:
| Code | Status | Description |
|---|---|---|
| 0 | OK | Success |
| 1 | CANCELED | Client cancelled request |
| 3 | INVALID_ARGUMENT | Invalid request parameters |
| 4 | DEADLINE_EXCEEDED | Request timeout |
| 5 | NOT_FOUND | Resource not found |
| 6 | ALREADY_EXISTS | Duplicate resource |
| 7 | PERMISSION_DENIED | Insufficient permissions |
| 9 | FAILED_PRECONDITION | Invalid state transition |
| 13 | INTERNAL | Server internal error |
| 14 | UNAVAILABLE | Service unavailable |
| 16 | UNAUTHENTICATED | Authentication required or failed |
Error Details:
google.rpc.Status {
int32 code = 1;
string message = 2;
repeated google.protobuf.Any details = 3;
}
Streaming Best Practices
- Keep Streams Alive - Send periodic heartbeats or messages
- Handle Reconnection - Implement exponential backoff on disconnect
- Correlation IDs - Use unique IDs to match requests/responses
- Graceful Shutdown - Close streams cleanly on application exit
- Error Recovery - Don't break streams on single errors, log and continue
Service 1: Marketplace Service
App registration, installation, and lifecycle management.
Package: easy.v2.Marketplace
ValidateSignature
Validate an app's RSA signature (useful for testing authentication).
Request
message AppSignatureRequest {
string app_name = 1; // App identifier
bytes signature = 2; // RSA signature to validate
}
Response
message AppSignatureResponse {
bool valid = 1; // True if signature is valid
}
Example
req := &pbservices.AppSignatureRequest{
AppName: "myapp",
Signature: signatureBytes,
}
resp, err := client.ValidateSignature(ctx, req)
if err != nil {
log.Fatal(err)
}
if resp.Valid {
fmt.Println("Signature is valid")
}
Authorization: None required (used for testing)
Subscribe (Bidirectional Stream)
Register an app and receive real-time lifecycle notifications.
Client → Server Messages
message StreamingAppRequest {
string id = 1; // Request correlation ID
oneof message {
AppRegisterRequest register_request = 2;
google.rpc.Status error = 3;
}
}
message AppRegisterRequest {
AppManifest manifest = 1; // Complete app manifest
}
Server → Client Messages
message StreamingAppResponse {
string id = 1; // Response correlation ID
oneof message {
AppStateResponse app_state_response = 2;
google.rpc.Status error = 3;
}
}
message AppStateResponse {
bool registered = 1;
bool installed = 2;
bool uninstalled = 3;
}
Flow
- App opens bidirectional stream with signature authentication
- Server sends initial
AppStateResponsewith current state - App sends
AppRegisterRequestwith complete manifest - Server validates manifest, creates/updates app, sends state update
- Server pushes lifecycle events (installed, uninstalled) via stream
- Stream remains open for continuous notifications
Example
stream, err := client.Subscribe(ctx)
if err != nil {
log.Fatal(err)
}
// Send registration request
req := &pbservices.StreamingAppRequest{
Id: uuid.New().String(),
Message: &pbservices.StreamingAppRequest_RegisterRequest{
RegisterRequest: &pbservices.AppRegisterRequest{
Manifest: manifest,
},
},
}
if err := stream.Send(req); err != nil {
log.Fatal(err)
}
// Receive state updates
go func() {
for {
resp, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
log.Printf("Stream error: %v", err)
break
}
if state := resp.GetAppStateResponse(); state != nil {
fmt.Printf("State: registered=%v, installed=%v\n",
state.Registered, state.Installed)
}
}
}()
Security:
- Apps can only register themselves (checked by app name in auth context)
- Bootstrap registration allowed for new apps
- Event filtering per app (only receives own lifecycle events)
Implementation: pkg/v2/presentation/grpc/marketplace_server.go:79
InstallApp
Install a registered app.
Request
message AppInstallRequest {
string app_name = 1; // App to install
}
Response
message AppStateResponse {
bool registered = 1;
bool installed = 2;
bool uninstalled = 3;
}
Example
req := &pbservices.AppInstallRequest{
AppName: "todos-app",
}
resp, err := client.InstallApp(ctx, req)
if err != nil {
log.Fatal(err)
}
if resp.Installed {
fmt.Println("App installed successfully")
}
Authorization: Requires install_app permission
UninstallApp
Uninstall an installed app.
Request
message UninstallAppRequest {
string app_name = 1; // App to uninstall
}
Response
message AppStateResponse {
bool registered = 1;
bool installed = 2;
bool uninstalled = 3;
}
Example
req := &pbservices.UninstallAppRequest{
AppName: "todos-app",
}
resp, err := client.UninstallApp(ctx, req)
if err != nil {
log.Fatal(err)
}
if resp.Uninstalled {
fmt.Println("App uninstalled successfully")
}
Authorization: Requires uninstall_app permission
Service 2: Hooks Service
Event-driven communication via named hooks (pub/sub pattern).
Package: easy.v2.Hooks
Concepts
- Hooks: Named event channels (e.g., "order.created", "user.updated")
- Listeners: Apps register to receive hook triggers
- Triggers: Invoke all listeners for a specific hook
- Execution Models:
FIRST_MATCH- Return first successful responseALL_MUST_SUCCEED- All listeners must succeedBEST_EFFORT- Continue on errors, collect all results
On (Bidirectional Stream)
Register hook listeners and receive/respond to triggers.
Client → Server Messages
message StreamingHookListenerRequest {
string id = 1; // Correlation ID
oneof message {
HookListenerRequest listener_request = 2; // Register listener
HookTriggerResponse trigger_response = 3; // Respond to trigger
google.rpc.Status error = 4;
}
}
message HookListenerRequest {
string name = 1; // Hook name (e.g., "order.created")
bool async = 2; // Process asynchronously (default: false)
bytes data = 3; // Configuration data (optional)
}
Server → Client Messages
message StreamingHookListenerResponse {
string id = 1; // Correlation ID
oneof message {
HookListenerResponse listener_response = 2; // Listener confirmation
HookTriggerRequest trigger_request = 3; // Hook trigger
google.rpc.Status error = 4;
}
}
Flow
- App opens bidirectional stream (app auth required)
- App sends
HookListenerRequestfor each hook to listen to - Server validates per-hook
subscribepermission - Server sends
HookListenerResponseconfirming registration - When hook triggered, server sends
HookTriggerRequestto app - App processes event and sends
HookTriggerResponseback - Server aggregates responses based on execution model
Example
stream, err := client.On(ctx)
if err != nil {
log.Fatal(err)
}
// Register listener for "order.created" hook
registerReq := &pbservices.StreamingHookListenerRequest{
Id: uuid.New().String(),
Message: &pbservices.StreamingHookListenerRequest_ListenerRequest{
ListenerRequest: &pbservices.HookListenerRequest{
Name: "order.created",
Async: false,
},
},
}
if err := stream.Send(registerReq); err != nil {
log.Fatal(err)
}
// Receive triggers and respond
go func() {
for {
resp, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
log.Printf("Stream error: %v", err)
break
}
if trigger := resp.GetTriggerRequest(); trigger != nil {
// Process trigger
result := processOrderCreated(trigger.Data)
// Send response
response := &pbservices.StreamingHookListenerRequest{
Id: trigger.Name, // Use trigger ID for correlation
Message: &pbservices.StreamingHookListenerRequest_TriggerResponse{
TriggerResponse: &pbservices.HookTriggerResponse{
Name: trigger.Name,
Data: result,
Success: true,
},
},
}
stream.Send(response)
}
}
}()
Authorization:
- Per-hook
subscribepermission required for registration - Checked during listener registration
Implementation: pkg/v2/presentation/grpc/hooks_server.go:44
TriggerHook
Trigger a hook for all registered listeners (unary RPC).
Request
message HookTriggerRequest {
string name = 1; // Hook name
bytes data = 2; // Event data
ExecutionModel execution_model = 3; // Execution strategy
int64 timeout_ms = 4; // Request timeout (default: 30000)
map<string, string> metadata = 5; // Additional metadata
}
enum ExecutionModel {
EXECUTION_MODEL_UNSPECIFIED = 0;
FIRST_MATCH = 1; // Return first success
ALL_MUST_SUCCEED = 2; // All must succeed
BEST_EFFORT = 3; // Collect all results
}
Response
message HookTriggerResponse {
string name = 1; // Hook name
bytes data = 2; // Response data (deprecated)
string trigger_id = 3; // Unique trigger ID
bool success = 4; // Overall success
string error = 5; // Error message if failed
int64 total_duration_ms = 6; // Total execution time
repeated ListenerResult results = 7; // Per-listener results
}
message ListenerResult {
string listener_id = 1;
string app_name = 2;
bytes response_data = 3;
bool success = 4;
string error = 5;
int64 duration_ms = 6;
}
Example
req := &pbservices.HookTriggerRequest{
Name: "order.created",
Data: orderJSON,
ExecutionModel: pbservices.ExecutionModel_BEST_EFFORT,
TimeoutMs: 5000,
Metadata: map[string]string{
"user_id": "user-123",
},
}
resp, err := client.TriggerHook(ctx, req)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Trigger %s: success=%v, duration=%dms\n",
resp.TriggerId, resp.Success, resp.TotalDurationMs)
for _, result := range resp.Results {
fmt.Printf(" %s: success=%v, duration=%dms\n",
result.AppName, result.Success, result.DurationMs)
}
Authorization: Per-hook trigger permission required
Implementation: pkg/v2/presentation/grpc/hooks_server.go:104
Service 3: Activity Service
Request-response activities between apps (like RPC but more flexible).
Package: easy.v2.Activity
Concepts
- Activities: Named handlers for request-response operations (e.g., "calculateShipping")
- Handlers: Apps register to handle activity requests
- Routing Strategies:
SINGLE_NODE- Route to one handler (default)BROADCAST- Send to all handlers
- Execution Models: Same as hooks (FIRST_MATCH, ALL_MUST_SUCCEED, BEST_EFFORT)
RegisterActivity (Bidirectional Stream)
Register activity handlers and process requests.
Client → Server Messages
message StreamingActivityRequest {
string id = 1; // Correlation ID
oneof message {
ActivityRegisterRequest register_request = 2; // Register handler
ActivityResponse activity_response = 3; // Response to request
google.rpc.Status error = 4;
}
}
message ActivityRegisterRequest {
string name = 1; // Activity name (e.g., "calculateShipping")
}
Server → Client Messages
message StreamingActivityResponse {
string id = 1; // Correlation ID
oneof message {
ActivityRegisterResponse register_response = 2; // Registration confirmation
ActivityRequest activity_request = 3; // Activity request
google.rpc.Status error = 4;
}
}
Flow
- App opens bidirectional stream (app auth required)
- App sends
ActivityRegisterRequestfor each activity to handle - Server validates per-activity
registerpermission - Server sends
ActivityRegisterResponseconfirming registration - When activity requested, server sends
ActivityRequestto handler - Handler processes request and sends
ActivityResponseback - Server correlates response with original requester
Example
stream, err := client.RegisterActivity(ctx)
if err != nil {
log.Fatal(err)
}
// Register handler for "calculateShipping" activity
registerReq := &pbservices.StreamingActivityRequest{
Id: uuid.New().String(),
Message: &pbservices.StreamingActivityRequest_RegisterRequest{
RegisterRequest: &pbservices.ActivityRegisterRequest{
Name: "calculateShipping",
},
},
}
if err := stream.Send(registerReq); err != nil {
log.Fatal(err)
}
// Receive requests and respond
go func() {
for {
resp, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
log.Printf("Stream error: %v", err)
break
}
if req := resp.GetActivityRequest(); req != nil {
// Process request
result := calculateShippingCost(req.Data)
// Send response
response := &pbservices.StreamingActivityRequest{
Id: req.RequestId, // Use request ID for correlation
Message: &pbservices.StreamingActivityRequest_ActivityResponse{
ActivityResponse: &pbservices.ActivityResponse{
RequestId: req.RequestId,
Success: true,
Results: []*pbservices.HandlerResult{
{
HandlerId: "handler-1",
AppName: "shipping-app",
Data: [][]byte{result},
Success: true,
},
},
},
},
}
stream.Send(response)
}
}
}()
Authorization: Per-activity register permission required
Implementation: pkg/v2/presentation/grpc/activity_server.go:44
RequestActivity
Request execution of a registered activity (unary RPC).
Request
message ActivityRequest {
string name = 1; // Activity name
bytes data = 2; // Request data
string request_id = 3; // Auto-generated if empty
RoutingStrategy routing_strategy = 4; // Routing strategy
ExecutionModel execution_model = 5; // Execution model
int64 timeout_ms = 6; // Request timeout (default: 30000)
map<string, string> metadata = 7; // Additional metadata
}
enum RoutingStrategy {
ROUTING_STRATEGY_UNSPECIFIED = 0;
SINGLE_NODE = 1; // Route to one handler
BROADCAST = 2; // Send to all handlers
}
Response
message ActivityResponse {
string request_id = 1;
bool success = 2;
string error = 3;
int64 total_duration_ms = 4;
repeated HandlerResult results = 5;
oneof result {
ActivityResult activity_result = 10; // Success case
google.rpc.Status error_status = 11; // Error case
}
}
message HandlerResult {
string handler_id = 1;
string app_name = 2;
repeated bytes data = 3; // Multiple responses supported
bool success = 4;
string error = 5;
int64 duration_ms = 6;
}
Example
req := &pbservices.ActivityRequest{
Name: "calculateShipping",
Data: orderJSON,
RoutingStrategy: pbservices.RoutingStrategy_SINGLE_NODE,
ExecutionModel: pbservices.ExecutionModel_FIRST_MATCH,
TimeoutMs: 5000,
}
resp, err := client.RequestActivity(ctx, req)
if err != nil {
log.Fatal(err)
}
if resp.Success {
for _, result := range resp.Results {
fmt.Printf("Handler %s: %s\n", result.AppName, result.Data[0])
}
}
Authorization: Per-activity request permission required
Implementation: pkg/v2/presentation/grpc/activity_server.go:345
Service 4: Settings Service
App configuration and settings management with encryption.
Package: easy.v2.Settings
RegisterSettings
Register settings schema from app manifest.
Request
message RegisterSettingsRequest {
string app_id = 1;
string app_name = 2;
SettingsSchema settings_schema = 3;
}
Response
message RegisterSettingsResponse {
int32 definition_count = 1; // Number of settings registered
}
Example
req := &pbservices.RegisterSettingsRequest{
AppId: appID,
AppName: "myapp",
SettingsSchema: &pbmanifest.SettingsSchema{
Definitions: []*pbmanifest.SettingDefinition{
{
Key: "api_key",
DisplayName: "API Key",
Type: pbmanifest.SettingType_STRING,
Required: true,
Sensitive: true,
},
},
},
}
resp, err := client.RegisterSettings(ctx, req)
fmt.Printf("Registered %d settings\n", resp.DefinitionCount)
Authorization: App can only register own settings
UpdateSettings
Update setting values (encrypted if sensitive).
Request
message UpdateSettingsRequest {
string app_id = 1;
string app_name = 2;
map<string, string> settings = 3; // Key → JSON-encoded value
string updated_by = 4; // User/app identifier
}
Response
message UpdateSettingsResponse {
bool success = 1;
repeated string changed_keys = 2;
repeated SettingError errors = 3;
}
message SettingError {
string key = 1;
string error = 2;
}
Example
req := &pbservices.UpdateSettingsRequest{
AppId: appID,
AppName: "myapp",
Settings: map[string]string{
"api_key": `"sk-1234567890"`,
"max_retries": `5`,
"enabled": `true`,
},
UpdatedBy: userID,
}
resp, err := client.UpdateSettings(ctx, req)
if resp.Success {
fmt.Printf("Updated settings: %v\n", resp.ChangedKeys)
}
for _, err := range resp.Errors {
fmt.Printf("Error for %s: %s\n", err.Key, err.Error)
}
Authorization: App can update own settings, or requires manage_settings permission
Encryption: Sensitive settings (marked with sensitive: true) are encrypted with AES-256-GCM
GetSettings
Retrieve all settings (definitions + values).
Request
message GetSettingsRequest {
string app_id = 1;
}
Response
message GetSettingsResponse {
repeated SettingDefinition definitions = 1;
repeated SettingValue values = 2;
}
message SettingValue {
string id = 1;
string app_id = 2;
string key = 3;
string value = 4; // JSON-encoded, masked if sensitive
string updated_by = 5;
string updated_at = 6; // RFC3339 format
bool is_masked = 7; // True if value is masked
}
Example
req := &pbservices.GetSettingsRequest{
AppId: appID,
}
resp, err := client.GetSettings(ctx, req)
for _, def := range resp.Definitions {
fmt.Printf("Setting: %s (%s)\n", def.Key, def.DisplayName)
}
for _, val := range resp.Values {
if val.IsMasked {
fmt.Printf("%s = ******* (masked)\n", val.Key)
} else {
fmt.Printf("%s = %s\n", val.Key, val.Value)
}
}
Authorization: Requires read_settings permission
Masking: Sensitive values are masked as ******* unless requester has special permission
GetSettingValue
Retrieve a specific setting value.
Request
message GetSettingValueRequest {
string app_id = 1;
string key = 2;
}
Response
message GetSettingValueResponse {
SettingValue value = 1; // Null if not found
}
Example
req := &pbservices.GetSettingValueRequest{
AppId: appID,
Key: "api_key",
}
resp, err := client.GetSettingValue(ctx, req)
if resp.Value != nil {
fmt.Printf("Value: %s\n", resp.Value.Value)
}
ValidateRequiredSettings
Check if all required settings have values.
Request
message ValidateRequiredSettingsRequest {
string app_id = 1;
}
Response
message ValidateRequiredSettingsResponse {
bool valid = 1;
repeated string missing_keys = 2;
}
Example
req := &pbservices.ValidateRequiredSettingsRequest{
AppId: appID,
}
resp, err := client.ValidateRequiredSettings(ctx, req)
if !resp.Valid {
fmt.Printf("Missing required settings: %v\n", resp.MissingKeys)
}
DeleteSettings
Delete all settings for an app.
Request
message DeleteSettingsRequest {
string app_id = 1;
}
Response
message DeleteSettingsResponse {
bool success = 1;
}
Example
req := &pbservices.DeleteSettingsRequest{
AppId: appID,
}
resp, err := client.DeleteSettings(ctx, req)
if resp.Success {
fmt.Println("Settings deleted")
}
Authorization: Requires manage_settings permission
Testing with grpcurl
Install grpcurl for testing:
brew install grpcurl
List Services
grpcurl -plaintext localhost:9090 list
Output:
easy.v2.Activity
easy.v2.Hooks
easy.v2.Marketplace
easy.v2.Settings
grpc.health.v1.Health
grpc.reflection.v1alpha.ServerReflection
List Methods
grpcurl -plaintext localhost:9090 list easy.v2.Marketplace
Output:
easy.v2.Marketplace.InstallApp
easy.v2.Marketplace.Subscribe
easy.v2.Marketplace.UninstallApp
easy.v2.Marketplace.ValidateSignature
Call Unary Method
grpcurl -plaintext \
-d '{"app_name": "myapp", "signature": "dGVzdA=="}' \
localhost:9090 easy.v2.Marketplace/ValidateSignature
Call with Authentication
# Generate signature
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
MESSAGE="Marketplace.ValidateSignature${TIMESTAMP}"
SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -sign app-key.pem | base64)
grpcurl -plaintext \
-H "x-app-name: myapp" \
-H "x-app-signature: $SIGNATURE" \
-H "x-request-timestamp: $TIMESTAMP" \
-d '{"app_name": "myapp"}' \
localhost:9090 easy.v2.Marketplace/ValidateSignature
Client Libraries
Go
import (
pbservices "bitbucket.org/easymarketinggmbh/easy.proto/v2/go/services"
"google.golang.org/grpc"
)
conn, err := grpc.Dial("localhost:9090", grpc.WithInsecure())
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// Create clients
marketplaceClient := pbservices.NewMarketplaceClient(conn)
hooksClient := pbservices.NewHooksClient(conn)
activityClient := pbservices.NewActivityClient(conn)
settingsClient := pbservices.NewSettingsClient(conn)
// Call methods
resp, err := marketplaceClient.ValidateSignature(ctx, &pbservices.AppSignatureRequest{
AppName: "myapp",
Signature: signature,
})
Node.js
Available via SDK: @easy/appserver-client-ts
import { createAppServerClient } from '@easy/appserver-client-ts';
const client = createAppServerClient({
grpcUrl: 'localhost:9090',
appName: 'myapp',
privateKey: privateKeyPEM,
});
// Call methods
const response = await client.marketplace.validateSignature({
appName: 'myapp',
signature: signatureBuffer,
});
Observability
The gRPC services integrate with the telemetry stack:
Metrics (Prometheus)
grpc_server_handled_total{service, method, code}- Total requestsgrpc_server_handling_seconds{service, method}- Request durationgrpc_server_msg_received_total{service, method}- Messages receivedgrpc_server_msg_sent_total{service, method}- Messages sentgrpc_server_started_total{service, method}- Streams started
Grafana: http://localhost:3000
Tracing (OpenTelemetry)
All gRPC methods are traced with:
- Span name:
{service}/{method} - Attributes: service, method, status_code, app_name
- Parent/child relationships for downstream calls
Tempo: http://localhost:3200
Logging
Structured logs for each RPC:
{
"level": "info",
"time": "2024-11-18T12:00:00Z",
"msg": "gRPC request",
"service": "easy.v2.Marketplace",
"method": "ValidateSignature",
"status": "OK",
"duration_ms": 5.2,
"app_name": "myapp"
}
Loki: Accessible via Grafana
Code References
- easy.proto/v2/protos/services.proto - Proto definitions
- pkg/v2/presentation/grpc/marketplace_server.go - Marketplace service implementation
- pkg/v2/presentation/grpc/hooks_server.go - Hooks service implementation
- pkg/v2/presentation/grpc/activity_server.go - Activity service implementation
- pkg/v2/presentation/grpc/settings_server.go - Settings service implementation
- pkg/v2/presentation/grpc/interceptors/ - Interceptor implementations
Related Topics
- HTTP REST API - HTTP endpoints
- GraphQL Schema - GraphQL API
- Marketplace - App lifecycle overview
- Hooks and Activity - Event-driven patterns
- Settings Service - Configuration management