Skip to content

Interceptors Proposal #3614

Open
Open
@raphael

Description

Interceptors in Goa

Overview

This proposal introduces typed interceptors to Goa's design DSL. Interceptors provide a type-safe mechanism for injecting cross-cutting concerns into method execution. They support both server-side and client-side interception, with clean interfaces for reading and modifying payloads and results, including support for streaming operations.

Requirements

  • Interceptors must be defined in the design
  • Interceptors must be fully typed with generated Info types
  • Interceptors can read and modify specific payload fields
  • Interceptors can read and modify specific result fields
  • Interceptors support both unary and streaming operations
  • Interceptors can be applied at both server and client side
  • Multiple interceptors can be chained in a defined order

Design

DSL Example

The DSL allows developers to define interceptors with explicit read and write permissions for payloads and results. Here's an example showing different types of interceptors:

var JWTAuth = Interceptor("JWTAuth", func() {
    Description("Server-side interceptor that validates JWT token and tenant ID")
    
    ReadPayload(func() {
        Attribute("auth", String, "JWT auth token")
        Attribute("tenantID", String, "Tenant ID to validate against")
    })
})

var RequestAudit = Interceptor("RequestAudit", func() {
    Description("Server-side interceptor that provides request/response audit logging")
    
    ReadResult(func() {
        Attribute("status", Int, "Response status code")
    })
    
    WriteResult(func() {
        Attribute("processedAt", String, "Timestamp when processed")
        Attribute("duration", Int, "Processing duration in ms")
    })
})

For streaming operations, the DSL provides additional constructs to handle streaming payloads and results:

var TraceBidirectionalStream = Interceptor("TraceBidirectionalStream", func() {
    Description("Interceptor that adds trace context to stream payload")
    
    ReadStreamingPayload(func() {
        Attribute("traceID", String)
        Attribute("spanID", String)
    })
    WriteStreamingPayload(func() {
        Attribute("traceID", String)
        Attribute("spanID", String)
    })
    ReadStreamingResult(func() {
        Attribute("traceID", String)
        Attribute("spanID", String)
    })
    WriteStreamingResult(func() {
        Attribute("traceID", String)
        Attribute("spanID", String)
    })
})

Interceptors can be applied at both service and method levels:

var _ = Service("interceptors", func() {
    // Service-wide interceptors
    ServerInterceptor(JWTAuth)
    ServerInterceptor(SetDeadline)
    ClientInterceptor(EncodeTenant)

    Method("get", func() {
        // Method-specific interceptors
        ServerInterceptor(TraceRequest)
        ServerInterceptor(RequestAudit)
        ServerInterceptor(Cache)
        ClientInterceptor(Retry)
    })
})

Generated Code Structure and Implementation

Type-Safe Info Types

For each interceptor, Goa generates an Info type that provides context about the interception and safe access to the request data:

type JWTAuthInfo struct {
    service    string     // Name of the service
    method     string     // Name of the method
    callType   goa.InterceptorCallType  // Unary or streaming
    rawPayload any       // The underlying request payload
}

Accessor Interfaces

To ensure type-safe access to payload and result fields, Goa generates interfaces based on the read/write permissions defined in the design:

// Generated based on ReadPayload definitions
type JWTAuthPayload interface {
    Auth() string
    TenantID() UUID
}

// Generated based on ReadResult and WriteResult definitions
type RequestAuditResult interface {
    Status() int              // Read access
    SetProcessedAt(string)    // Write access
    SetDuration(int)          // Write access
}

Private Implementation Types

The actual implementation of these interfaces is handled by private types that safely wrap the underlying data:

// Private implementation of the JWTAuthPayload interface
type jwtAuthGetPayload struct {
    payload *GetPayload    // The actual payload type for the Get method
}

// Implementation of interface methods
func (p *jwtAuthGetPayload) Auth() string {
    return p.payload.Auth
}

Interceptor Interfaces

Goa generates separate interfaces for server and client interceptors, each with the appropriate methods based on the design:

type ServerInterceptors interface {
    // Each method corresponds to a server-side interceptor
    JWTAuth(context.Context, *JWTAuthInfo, goa.Endpoint) (any, error)
    RequestAudit(context.Context, *RequestAuditInfo, goa.Endpoint) (any, error)
}

type ClientInterceptors interface {
    // Each method corresponds to a client-side interceptor
    EncodeTenant(context.Context, *EncodeTenantInfo, goa.Endpoint) (any, error) 
    Retry(context.Context, *RetryInfo, goa.Endpoint) (any, error)
}

Code Generation and Implementation

The interceptor system is built using a set of template-driven code generators that work together to create a complete, type-safe interception framework. Let's walk through how this system works.

Generated Code Overview

At the heart of the system are three main types of generated code. First, the core interceptor interfaces are generated in separate files for client and server sides. These files define the fundamental contract that interceptors must implement, with distinct interfaces for client and server operations.

The second key component is the access types generation. This creates the interfaces and structs that enable safe access to payloads and results. Each interceptor gets its own Info struct that provides context about the current operation and safe access to request data. For example, a JWT authentication interceptor might get types like this:

type JWTAuthInfo struct {
    service    string
    method     string
    callType   goa.InterceptorCallType
    rawPayload any
}

type JWTAuthPayload interface {
    Auth() string
    TenantID() UUID
}

Finally, endpoint wrapper code is generated to handle the actual interception chain. These wrappers manage the order of interceptor execution, context propagation, and error handling. They ensure that interceptors run in the correct sequence and that data flows properly through the system.

The Data Model

Code generation is driven by a hierarchical data model that captures the complete interceptor configuration. The model starts at the service level and drills down to individual fields:

// Service-level data structure
type Data struct {
    ServerInterceptors []InterceptorData
    ClientInterceptors []InterceptorData
}

// Per-interceptor configuration
type InterceptorData struct {
    Name                    string
    Description            string
    HasPayloadAccess       bool
    HasResultAccess        bool
    ReadPayload           []AttributeData
    WritePayload          []AttributeData
    Methods              []MethodInterceptorData
}

// Method-specific configuration
type MethodInterceptorData struct {
    MethodName           string
    PayloadAccess        string
    ResultAccess         string
    PayloadRef          string
    ResultRef           string
}

// Field-level configuration
type AttributeData struct {
    Name     string
    TypeRef  string
    Type     string
}

This model drives the generation of type-safe interceptor code. For example, when defining a JWT authentication interceptor in the design:

var JWTAuth = Interceptor("JWTAuth", func() {
    ReadPayload(func() {
        Attribute("auth", String)
        Attribute("tenantID", UUID)
    })
})

Type Safety and Execution

The generated code creates a chain of type-safe interceptors. Here's how a server-side authentication and logging interceptor chain might look:

// Server interceptor implementation
type ServerInterceptors struct {}

func (i *ServerInterceptors) JWTAuth(ctx context.Context, info *JWTAuthInfo, next goa.Endpoint) (any, error) {
    // Access payload safely through generated interface
    p := info.Payload()
    if err := validateToken(p.Auth(), p.TenantID()); err != nil {
        return nil, err
    }
    return next(ctx, info.RawPayload())
}

func (i *ServerInterceptors) RequestAudit(ctx context.Context, info *RequestAuditInfo, next goa.Endpoint) (any, error) {
    start := time.Now()
    
    // Execute next in chain
    res, err := next(ctx, info.RawPayload())
    if err != nil {
        return nil, err
    }
    
    // Access and modify result safely
    r := info.Result(res)
    r.SetProcessedAt(time.Now().Format(time.RFC3339))
    r.SetDuration(int(time.Since(start).Milliseconds()))
    
    return res, nil
}

// Generated wrapper that applies interceptors in order
func WrapGetEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint {
    // Wrap in reverse order - innermost executes first
    endpoint = wrapGetRequestAudit(endpoint, i)
    endpoint = wrapGetJWTAuth(endpoint, i)
    return endpoint
}

For streaming operations, special wrapper types maintain type safety throughout the stream's lifetime:

type wrappedStreamServerStream struct {
    ctx             context.Context
    sendWithContext func(context.Context, *StreamResult) error
    recvWithContext func(context.Context) (*StreamStreamingPayload, error)
    stream          StreamServerStream
}

func (w *wrappedStreamServerStream) SendWithContext(ctx context.Context, res *StreamResult) error {
    info := &TraceBidirectionalStreamInfo{
        service:    "interceptors",
        method:     "Stream",
        callType:   goa.InterceptorStreamingSend,
        rawPayload: res,
    }
    // Apply streaming interceptors
    _, err := i.TraceBidirectionalStream(ctx, info, func(ctx context.Context, req any) (any, error) {
        return nil, w.stream.SendWithContext(ctx, res)
    })
    return err
}

All of this machinery works together to provide a seamless interception system that's both powerful and safe to use. Developers can focus on implementing their interceptor logic while the generated code handles all the complexity of type safety, proper execution order, and data flow management.

Key Features

  1. Type-Safe Access

    • Generated Info types with payload/result accessors
    • Clean interfaces for reading and writing fields
    • No type assertions needed in user code
    • Support for streaming operations
  2. Flexible Interception Points

    • Both server-side and client-side interception
    • Service-level and method-level interceptors
    • Ordered execution of multiple interceptors
    • Full access to request context
  3. Clean Integration

    • Explicit field access declarations in design
    • Generated helper types and interfaces
    • Simple implementation pattern
    • Natural integration with Goa endpoints
  4. Comprehensive Features

    • Support for unary and streaming methods
    • Payload and result modification
    • Error handling and propagation
    • Context manipulation

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions