Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interceptors Proposal #3614

Open
raphael opened this issue Nov 21, 2024 · 6 comments
Open

Interceptors Proposal #3614

raphael opened this issue Nov 21, 2024 · 6 comments

Comments

@raphael
Copy link
Member

raphael commented Nov 21, 2024

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
@tchssk
Copy link
Member

tchssk commented Dec 6, 2024

I'm trying #3616.

Is this possible?

    // Create interceptors
    interceptors := &genservice.ServerInterceptors{
        AuditInterceptor: AuditInterceptor,
        RetryInterceptor: RetryInterceptor,
    }

    // Create endpoints with interceptors
    endpoints := genservice.NewEndpoints(svc, interceptors)

@raphael
Copy link
Member Author

raphael commented Dec 8, 2024

Good catch, that's not how interceptors ended up being implemented. Instead there is a single generated interface that exposes all the interceptors. The end user provides the implementation for this interface and Goa calls the right interceptors at the right time. I've added an example: https://github.com/goadesign/examples/tree/features/interceptors/interceptors.

@douglaswth
Copy link
Member

I'm working on adding streaming support for interceptors by adding ReadStreamingPayload, WriteStreamingPayload, ReadStreamingResult, and WriteStreamingResult to the DSL as well.

@raphael
Copy link
Member Author

raphael commented Jan 24, 2025

Just to help with that: maybe you could create a branch in the goa.design/examples repo, add streaming endpoints to the interceptors example and write the code that should be generated. That way we can review what the output ought to be and the example can be used as a reference when working on the code generation. Also https://github.com/goadesign/goa/blob/v3/codegen/service/interceptors.md contains the details of how the current interceptors were implemented.

@douglaswth
Copy link
Member

In addition to adding the ReadStreamingPayload, WriteStreamingPayload, ReadStreamingResult, and WriteStreamingResult to the DSL and supporting it with generated code, my streaming interceptor work changed the goa.InterceptorInfo type from being a struct to an interface that the generated interceptor info struct types automatically implement.

I also have a proposed addition to Clue of a generic interceptor for tracing individual messages on a stream with OpenTelemetry that works with Goa interceptors: goadesign/clue#520.

@raphael
Copy link
Member Author

raphael commented Feb 23, 2025

I updated the proposal with the final design and implementation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants