Skip to main content

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:

  1. Marketplace Service - App registration, installation, and lifecycle notifications
  2. Hooks Service - Event-driven communication via named hooks
  3. Activity Service - Request-response activities between apps
  4. 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:

  1. Concatenate: method + service + timestamp
  2. Sign with app's private RSA key (SHA-256)
  3. Base64 encode signature
  4. Include in x-app-signature metadata

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:

  1. Logging - Structured logging with method, duration, status
  2. Metrics - Prometheus metrics collection
  3. Tracing - OpenTelemetry distributed tracing
  4. Recovery - Panic recovery with graceful error responses
  5. Validation - Request message validation
  6. Auth - Authentication extraction and verification

Error Handling

gRPC uses standard status codes:

CodeStatusDescription
0OKSuccess
1CANCELEDClient cancelled request
3INVALID_ARGUMENTInvalid request parameters
4DEADLINE_EXCEEDEDRequest timeout
5NOT_FOUNDResource not found
6ALREADY_EXISTSDuplicate resource
7PERMISSION_DENIEDInsufficient permissions
9FAILED_PRECONDITIONInvalid state transition
13INTERNALServer internal error
14UNAVAILABLEService unavailable
16UNAUTHENTICATEDAuthentication required or failed

Error Details:

google.rpc.Status {
int32 code = 1;
string message = 2;
repeated google.protobuf.Any details = 3;
}

Streaming Best Practices

  1. Keep Streams Alive - Send periodic heartbeats or messages
  2. Handle Reconnection - Implement exponential backoff on disconnect
  3. Correlation IDs - Use unique IDs to match requests/responses
  4. Graceful Shutdown - Close streams cleanly on application exit
  5. 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

  1. App opens bidirectional stream with signature authentication
  2. Server sends initial AppStateResponse with current state
  3. App sends AppRegisterRequest with complete manifest
  4. Server validates manifest, creates/updates app, sends state update
  5. Server pushes lifecycle events (installed, uninstalled) via stream
  6. 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 response
    • ALL_MUST_SUCCEED - All listeners must succeed
    • BEST_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

  1. App opens bidirectional stream (app auth required)
  2. App sends HookListenerRequest for each hook to listen to
  3. Server validates per-hook subscribe permission
  4. Server sends HookListenerResponse confirming registration
  5. When hook triggered, server sends HookTriggerRequest to app
  6. App processes event and sends HookTriggerResponse back
  7. 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 subscribe permission 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

  1. App opens bidirectional stream (app auth required)
  2. App sends ActivityRegisterRequest for each activity to handle
  3. Server validates per-activity register permission
  4. Server sends ActivityRegisterResponse confirming registration
  5. When activity requested, server sends ActivityRequest to handler
  6. Handler processes request and sends ActivityResponse back
  7. 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 requests
  • grpc_server_handling_seconds{service, method} - Request duration
  • grpc_server_msg_received_total{service, method} - Messages received
  • grpc_server_msg_sent_total{service, method} - Messages sent
  • grpc_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