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 · 2 comments
Open

Interceptors Proposal #3614

raphael opened this issue Nov 21, 2024 · 2 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, with clean interfaces for reading and modifying payloads and results. They can be defined at API, Service, and Method levels.

Requirements

  • Interceptors must be defined in the design
  • Interceptors must be fully typed
  • Interceptors can stop the request chain
  • Interceptors can modify the request payload
  • Interceptors can modify the response result
  • Interceptors can modify the request context
  • Interceptors can modify the error returned by the endpoint

Design

DSL Example

var AuditInterceptor = Interceptor("AuditInterceptor", func() {
    Description("Adds audit information to requests and tracks response status")
    
    // Define payload fields the interceptor needs to read
    ReadPayload(func() {
        Attribute("auth", String, "JWT auth token")
    })
    
    // Define payload fields the interceptor will write
    WritePayload(func() {
        Attribute("userID", String, "User ID extracted from auth token")
        Attribute("requestedAt", Time, "Timestamp when request was received")
    })
    
    // Define result fields the interceptor needs to read
    ReadResult(func() {
        Attribute("status", Int, "Response status code")
    })
    
    // Define result fields the interceptor will write
    WriteResult(func() {
        Attribute("processedBy", String, "Service instance ID")
        Attribute("processedAt", Time, "Timestamp when processed")
        Attribute("duration", String, "Processing duration")
    })
})

var RetryInterceptor = Interceptor("RetryInterceptor", func() {
    Description("Client-side interceptor which retries failed requests")
    
    // No payload or result modifications needed
})

// Interceptors can be used at API, Service, or Method level
var _ = API("ExampleAPI", func() {
    // API-wide interceptor
    ServerInterceptor(AuditInterceptor)
})

var _ = Service("Example", func() {
    // Service-wide interceptor (no-op in this example as already applied at API level)
    ServerInterceptor(AuditInterceptor)
    
    Method("Add", func() {
        // Method-specific interceptor
        ClientInterceptor(RetryInterceptor)
        Payload(func() {
            // Fields read by audit interceptor
            Attribute("auth", String, "JWT auth token")
            // Fields written by audit interceptor
            Attribute("userID", String, "User ID extracted from auth token")
            Attribute("requestedAt", Time, "Timestamp when request was received")   
            Required("auth"))
        })
        Result(func() {
            // Fields written by business logic and read by audit interceptor
            Attribute("status", Int, "Response status code")
            // Fields written by audit interceptor
            Attribute("processedBy", String, "Service instance ID")
            Attribute("processedAt", Time, "Timestamp when processed")
            Attribute("duration", Duration, "Processing duration")            
            Required("status")
        })
        HTTP(func() {
                POST("/")
                Header("auth:Authorization")
        })
    })
})

This proposal introduces the following new DSL:

  • Interceptor(name string, dsl func()) - Defines a new interceptor. Used at design package level to define interceptor types.
  • ServerInterceptor - Applies a server-side interceptor at API, Service or Method level.
  • ClientInterceptor - Applies a client-side interceptor at API, Service or Method level.
  • ReadPayload(dsl func()) - Defines the payload attributes that the interceptor needs to read. Used within Interceptor DSL.
  • WritePayload(dsl func()) - Defines the payload attributes that the interceptor will modify. Used within Interceptor DSL.
  • ReadResult(dsl func()) - Defines the result attributes that the interceptor needs to read. Used within Interceptor DSL.
  • WriteResult(dsl func()) - Defines the result attributes that the interceptor will modify. Used within Interceptor DSL.

Implementation Example

The user code implements the interceptors as follows:

// Server-side audit interceptor
func AuditInterceptor(ctx context.Context, payload *genservice.AuditPayload, info *genservice.AuditInterceptorInfo, 
    next goa.NextFunc) (any, error) {
    
    start := time.Now()
    
    // Get typed access directly
    pa := info.Payload()
    
    // Read from payload
    auth := pa.Auth()
    userID := extractUserID(auth) // user provided function
    
    // Apply payload modifications
    pa.SetUserID(userID)
    pa.SetRequestedAt(start)
    
    // Continue chain
    res, err := next(ctx)
    if err != nil {
        return nil, err
    }
    
    // Get typed result access
    ra := info.Result(res)
    
    // Read from result
    status := ra.Status()
    
    // Apply result modifications
    ra.SetProcessedBy(os.Getenv("SERVICE_INSTANCE"))
    ra.SetProcessedAt(time.Now())
    ra.SetDuration(time.Since(start).String())
    
    // Log audit information
    log.Printf("Request processed: status=%d, duration=%v", status, time.Since(start))
    
    return res, nil
}


// Server-side registration
package main

func main() {
    // Create service
    svc := genservice.NewService()

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

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

    // ... rest of server setup
}

The generated interceptor info object provides access to the service and method names as well as the current payload. It also exposes methods to retrieve the payload and result context objects which allow for type-safe access and modification.

// In Goa package
package goa

// InterceptorInfo provides context about the current interception
type InterceptorInfo struct {
    // Service name
    Service string
    // Method name
    Method string
    // Endpoint contains the current endpoint being executed
    Endpoint Endpoint
    // Payload contains the method payload
    Payload any
}

// In generated code
package example

// Type-safe interceptor info with generated methods
type AuditInfo struct {
    *goa.InterceptorInfo
    // Payload returns a type-safe access for reading and modifying the payload
    Payload() AuditPayloadAccess
    // Result returns a type-safe access for reading and modifying the result
    Result(res any) AuditResultAccess
}

There is also a new type defining the signature for the next function that is common to all interceptors:

// In goa package
type NextFunc func(context.Context) (any, error)

Client-side interceptors are also supported. They follow the same pattern as server-side interceptors. In this example RetryInterceptor does not need to read or modify the payload or result.

// Client-side retry interceptor
func RetryInterceptor(ctx context.Context, info *goa.InterceptorInfo, next goa.NextFunc) (any, error) {
    res, err := next(ctx)
    if err != nil {
        var gerr *goa.ServiceError
        if errors.As(err, &gerr) {
            if gerr.Temporary {
                time.Sleep(100 * time.Millisecond)
                return info.Endpoint(ctx, info.Payload) // leverage info.Endpoint to try again
            }
        }
    }
    return res, err
}


// Client-side registration
package main 

func main() {
    // Create client
    c := genservice.NewClient()

    // Create interceptors
    interceptors := &genservice.ClientInterceptors{
        Retry: RetryInterceptor,
    }

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

    // ... rest of client setup
}

Generated Interceptor Code

A new interceptors.go file is generated under gen/<name of service>/interceptors.go with the following content:

// Generated modification functions
type (
    // Payload access
    AuditPayloadAccess interface {
        // Read methods
        Auth() string

        // Write methods
        SetUserID(string)
        SetRequestedAt(time.Time)
    }

    // Result access
    AuditResultAccess interface {
        // Read methods
        Status() int

        // Write methods
        SetProcessedBy(string)
        SetProcessedAt(time.Time) 
        SetDuration(time.Duration)
    }
)

// ServerInterceptors interface
type ServerInterceptors interface {
    AuditInterceptor(context.Context, *AuditInterceptorInfo, goa.NextFunc) (any, error)
}

// ClientInterceptors interface
type ClientInterceptors interface {
    RetryInterceptor(context.Context, *goa.InterceptorInfo, goa.NextFunc) (any, error)
}

// Implementation types
type (
    // Internal implementations
    methodPayloadAccess struct {
        payload *MethodPayload
    }

    methodResultAccess struct {
        result *MethodResult
    }
)

// Internal implementations
func (c *methodPayloadAccess) Auth() string {
    return c.payload.Auth
}

func (c *methodPayloadAccess) SetUserID(id string) {
    c.payload.UserID = id
}

func (c *methodPayloadAccess) SetRequestedAt(t time.Time) {
    c.payload.RequestedAt = t
}

func (c *resultAccess) Status() int {
    return c.result.Status
}

func (c *methodResultAccess) SetProcessedBy(s string) {
    c.result.ProcessedBy = s
}

func (c *methodResultAccess) SetProcessedAt(t time.Time) {
    c.result.ProcessedAt = t
}

func (c *methodResultAccess) SetDuration(d string) {
    c.result.Duration = d
}

Generated Service Code

Additionally the generated endpoints.go file is modified to call the interceptors:

// Generated endpoints
package example

import (
    "context"
    goa "goa.design/goa/v3/pkg"
)

// Endpoints wraps the service endpoints.
type Endpoints struct {
    Method goa.Endpoint
}

// NewEndpoints wraps the methods of the service with endpoints.
func NewEndpoints(s Service, i ServerInterceptors) *Endpoints {
    return &Endpoints{
        Method: WrapMethodEndpoint(NewMethodEndpoint(s), i),
    }
}

// NewMethodEndpoint returns an endpoint function that calls the method.
func NewMethodEndpoint(s Service) goa.Endpoint {
    return func(ctx context.Context, req any) (any, error) {
        p := req.(*MethodPayload)
        return s.Method(ctx, p)
    }
}

// WrapMethodEndpoint wraps the Method endpoint with the interceptors defined in the design.
func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint {
    endpoint = methodAuditInterceptor(i)(endpoint)
    return endpoint
}

// methodAuditInterceptor returns a Goa endpoint middleware that applies
// the Audit interceptor to the Method endpoint.
func methodAuditInterceptor(in ServerInterceptors) func(goa.Endpoint) goa.Endpoint {
    return func(endpoint goa.Endpoint) goa.Endpoint {
        return func(ctx context.Context, req any) (any, error) {
            payload := req.(*MethodPayload)
            p := &AuditPayload{
                Auth: payload.Auth,
            }
            info := &goa.InterceptorInfo{
                Method:   "Method",
                Service:  "ServiceName",
                Endpoint: endpoint,
                Payload:  payload,
            }
            
            // Create type-safe info
            typedInfo := &AuditInfo{
                InterceptorInfo: info,
                Payload: func() AuditPayloadAccess {
                    return (*methodPayloadAccess)(payload)
                },
                ResultContext: func(res any) AuditResultAccess {
                    return (*methodResultAccess)(res.(*MethodResult))
                },
            }
            
            next := func(ctx context.Context) (any, error) {
                return endpoint(ctx, payload)
            }
            
            return in.Audit(ctx, p, typedInfo, next)
        }
    }
}

Generated Client Code

The generated service package client.go is updated to hook up the client side interceptors as follows:

// NewClient initializes a "example" service client given the endpoints.
func NewClient(endpoint goa.Endpoint, i ClientInterceptors) *Client {
	return &Client{
		GetRecordEndpoint: WrapGetRecordClientEndpoint(endpoint, i),
	}
}

// WrapMethodEndpoint wraps the Method endpoint with the interceptors defined in the design.
func WrapMethodEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint {
    endpoint = retryInterceptor(i)(endpoint)
    return endpoint
}

// retryInterceptor returns a Goa endpoint middleware that applies
// the Retry interceptor to the Method endpoint.
func retryInterceptor(in ClientInterceptors) func(goa.Endpoint) goa.Endpoint {
    return func(endpoint goa.Endpoint) goa.Endpoint {
        return func(ctx context.Context, req any) (any, error) {
            info := &goa.InterceptorInfo{
                Method:   "Method",
                Service:  "Example",
                Endpoint: endpoint,
                Payload:  req,
            }
            
            next := func(ctx context.Context) (any, error) {
                return endpoint(ctx, req)
            }
            
            return in.RetryInterceptor(ctx, info, next)
        }
    }
}

Key Features

  1. Type-Safe Access

    • Generated interfaces for payload and result modifications
    • Separate read and write methods
    • No type assertions needed in user code
    • Clear contract between generated and user code
  2. Clean Modification Pattern

    • Explicit read and write field definitions
    • Direct modification through method calls
    • Modifications can happen at any point
    • Implementation details hidden from user code
  3. Flexible Chain Control

    • Interceptors at API, Service, or Method level
    • Full access to context throughout chain
    • Error propagation halts chain
    • Clean modification of both request and response
  4. Design Integration

    • Explicit read and write field declarations
    • Generated modification interfaces
    • Clear documentation of capabilities
    • Compile-time verification of usage

/cc @tchssk @ElectricCookie

@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.

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

2 participants