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
-
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
-
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
-
Clean Integration
- Explicit field access declarations in design
- Generated helper types and interfaces
- Simple implementation pattern
- Natural integration with Goa endpoints
-
Comprehensive Features
- Support for unary and streaming methods
- Payload and result modification
- Error handling and propagation
- Context manipulation
Activity