Skip to content

Commit

Permalink
Finish interceptors example
Browse files Browse the repository at this point in the history
  • Loading branch information
raphael committed Dec 8, 2024
1 parent 56bdec0 commit d7d042f
Show file tree
Hide file tree
Showing 20 changed files with 727 additions and 126 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,5 @@ upload_download/upload_download
upload_download/upload_download-cli
basic/cmd/calc/calc
interceptors/cmd/example/example
.vscode/settings.json
interceptors/cmd/tokgen/tokgen
363 changes: 314 additions & 49 deletions interceptors/README.md
Original file line number Diff line number Diff line change
@@ -1,75 +1,340 @@
# Requirements
# Interceptors Example

* Interceptors are defined in the design
* Interceptors are typed
* Interceptors can stop the chain
* Interceptors can modify the request
* Interceptors can modify the response
* Interceptors can modify the context
* Interceptors can modify the error returned by the endpoint
This example demonstrates how to use Goa interceptors to implement cross-cutting concerns such as authentication, caching, and request timeouts. The example includes a complete implementation of JWT validation, request caching, and deadline management.

# Signature
## Design

`func(ctx context.Context, [payload *Payload], info *InterceptorInfo, processor func(ctx context.Context, [result *Result]) (any, error)) (any, error)`
* `[payload *Payload]` is the interceptor payload if any
* `info *InterceptorInfo` contains the service name, method name, endpoint function and method payload. It also contains the result and error for server-side result interceptors and client-side response interceptors.
* `processor` must be called by the interceptor with its result if any
* If the interceptor returns an error, the chain is halted and the error is propagated to the caller.
The example defines both client-side and server-side interceptors using Goa's design DSL. Each interceptor can specify what data it needs to read from the request payload and what data it can write to either the payload or result.

Definition of InterceptorInfo:
### Error Handling

The design defines a specific error type for invalid JWT tokens:

```go
type InterceptorInfo struct {
Service string
Method string
Endpoint Endpoint
Payload any
}
var InvalidToken = Type("InvalidToken", func() {
Description("Invalid JWT token error")
Field(1, "message", String, "Message describing the error")
Field(2, "token", String, "The invalid token")
Required("message")
})

var _ = Service("example", func() {
// Define the error at service level
Error("invalid_token", InvalidToken, "JWT token is invalid")

Method("get_record", func() {
// ... other method design ...
Error("invalid_token") // Make error available to this method

HTTP(func() {
// ... other HTTP settings ...
Response("invalid_token", StatusUnauthorized) // Return 401 for invalid tokens
})
})
})
```

Example Server-side request interceptor:
When the `ValidateTenant` interceptor detects an invalid token, it returns this error type, which results in a 401 Unauthorized response with detailed error information.

### Client-side Interceptors

Design:
```go
var DecodeTenant = Interceptor("DecodeTenant", func() {
Description("Server-side interceptor which extracts the tenant ID from the JWT contained in the request Authorization header.")
Read("auth")
Write("tenantID")
var EncodeTenant = Interceptor("EncodeTenant", func() {
Description("Client-side interceptor which writes the tenant ID to the signed JWT contained in the Authorization header")

ReadPayload(func() {
Attribute("tenantID", String, "Tenant ID to encode")
})

WritePayload(func() {
Attribute("auth", String, "Generated JWT auth token")
})
})

var Retry = Interceptor("Retry", func() {
Description("Client-side interceptor which retries the request if it fails in a retryable manner")
})
```

Implementation:
### Server-side Interceptors

```go
func DecodeTenant(ctx context.Context, payload *DecodeTenantPayload, info *goa.InterceptorInfo, processor func(ctx context.Context, result *DecodeTenantResult) (any, error)) (any, error) {
auth := payload.Auth
// ... Extract JWT from Authorization header
// ... compute tenant ID from JWT
result := &DecodeTenantResult{TenantID: tenantID}
return processor(ctx, result)
}
var ValidateTenant = Interceptor("ValidateTenant", func() {
Description("Server-side interceptor which extracts the tenant ID from the signed JWT contained in the request Authorization header")

ReadPayload(func() {
Attribute("auth", String, "JWT auth token")
})
})

var Cache = Interceptor("Cache", func() {
Description("Server-side interceptor which implements a transparent cache for the loaded records")

ReadPayload(func() {
Attribute("recordID", String, "Record ID to cache")
})

WriteResult(func() {
Attribute("cachedAt", String, "Time at which the record was cached in RFC3339 format")
})
})

var SetDeadline = Interceptor("SetDeadline", func() {
Description("Server-side interceptor which sets the context deadline for the request")
})
```

Example Client-side response interceptor:
### Method Design

The interceptors are attached to service methods in a specific order:

Design:
```go
var Retry = Interceptor("Retry", func() {
Description("Client-side interceptor which retries the request if it fails in a retryable manner")
Method("get_record", func() {
// Client-side interceptors run in order: EncodeTenant, then Retry
ClientInterceptor(EncodeTenant, Retry)

// Server-side interceptors run in order: SetDeadline, ValidateTenant, then Cache
ServerInterceptor(SetDeadline, ValidateTenant, Cache)

Payload(GetRecordPayload)
Result(Record)

HTTP(func() {
GET("/get_record")
Header("auth:Authorization")
})
})
```

Implementation:
The payload type includes fields that the interceptors need to read or write:

```go
func Retry(ctx context.Context, info *goa.InterceptorInfo, processor func(ctx context.Context) (any, error)) (any, error) {
if info.Error != nil {
var gerr *goa.ServiceError
if errors.As(info.Error, &gerr) {
if gerr.Temporary {
time.Sleep(100 * time.Millisecond)
return info.Endpoint(ctx, info.Payload)
}
var GetRecordPayload = Type("GetRecordPayload", func() {
Field(1, "auth", String, "Authorization header")
Field(2, "tenantID", String, "Tenant ID extracted from JWT")
Field(3, "recordID", String, "Record ID to fetch")
Required("auth", "recordID")
})

var Record = Type("Record", func() {
Field(1, "tenantID", String, "Tenant ID from JWT")
Field(2, "recordID", String, "Record ID")
Field(3, "fullName", String, "Record full name")
Field(4, "cachedAt", String, "Cache timestamp", func() {
Format(FormatDateTime)
})
Required("tenantID", "recordID", "fullName", "cachedAt")
})
```

## Implementation

### Server Interceptors

The interceptors are implemented in a struct that satisfies the generated `example.ServerInterceptors` interface:

```go
type Interceptors struct {
cache map[string]any
mu sync.RWMutex
}

func NewExampleInterceptors() example.ServerInterceptors {
return &Interceptors{
cache: make(map[string]any),
}
}
```

#### JWT Validation Interceptor

The `ValidateTenant` interceptor validates JWT tokens in the Authorization header:

```go
func (i *Interceptors) ValidateTenant(ctx context.Context, info *example.ValidateTenantInfo, next goa.NextFunc) (any, error) {
// Get typed access to payload
pa := info.Payload()
auth := pa.Auth()

if len(auth) < 7 || auth[:7] != "Bearer " {
return nil, example.MakeInvalidToken(errors.New("invalid auth header"))
}

tokenString := auth[7:] // Remove "Bearer "
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return []byte("your-secret-key"), nil
})

if err != nil {
return nil, example.MakeInvalidToken(err)
}

if !token.Valid {
return nil, example.MakeInvalidToken(errors.New("invalid auth token"))
}
return processor(ctx)

// Validate tenant ID
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, example.MakeInvalidToken(errors.New("missing JWT claims"))
}
tokenTenantID := claims["tenantID"].(string)
if tokenTenantID != pa.TenantID() {
return nil, example.MakeInvalidToken(errors.New("invalid tenant ID"))
}

// We're good
return next(ctx)
}
```

#### Caching Interceptor

The `Cache` interceptor implements a thread-safe in-memory cache:

```go
func (i *Interceptors) Cache(ctx context.Context, info *example.CacheInfo, next goa.NextFunc) (any, error) {
id := info.Payload().RecordID()
if id == "" {
return nil, errors.New("missing ID")
}

// Check cache
i.mu.RLock()
if record, ok := i.cache[id]; ok {
i.mu.RUnlock()
return record, nil
}
i.mu.RUnlock()

// Process request
result, err := next(ctx)
if err != nil {
return nil, err
}

// Cache result
i.mu.Lock()
i.cache[id] = result
i.mu.Unlock()

return result, nil
}
```

#### Request Timeout Interceptor

The `SetDeadline` interceptor adds a timeout to the request context:

```go
func (i *Interceptors) SetDeadline(ctx context.Context, info *example.SetDeadlineInfo, next goa.NextFunc) (any, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
return next(ctx)
}
```

## Usage

### Setting Up the Server

The server needs to be set up to use both the service and interceptors:

```go
package main

import (
"context"
"log"
"net/http"

exampleapi "goa.design/examples/interceptors"
example "goa.design/examples/interceptors/gen/example"
examplesvr "goa.design/examples/interceptors/gen/http/example/server"
goahttp "goa.design/goa/v3/http"
)

func main() {
// Initialize the service and interceptors
exampleSvc := exampleapi.NewExample()
exampleInterceptors := exampleapi.NewExampleInterceptors()

// Create endpoints with interceptors
endpoints := example.NewEndpoints(exampleSvc, exampleInterceptors)

// Create HTTP handler
mux := goahttp.NewMuxer()
server := examplesvr.New(endpoints, mux, goahttp.RequestDecoder,
goahttp.ResponseEncoder, nil, nil)

// Mount the server
server.Mount(mux)

// Start HTTP server
log.Fatal(http.ListenAndServe(":8080", mux))
}
```

### Running the Example

The commands below should be run from the root of the interceptors example.
First, generate the Goa code:

```bash
# Generate the Goa code
goa gen goa.design/examples/interceptors/design

# Generate the example implementation
goa example goa.design/examples/interceptors/design
```

Then build and run the server:

```bash
# Build the server
go build ./cmd/example

# Run the server (in terminal 1)
cd cmd/example && ./example --http-port=8080
```

Open a second terminal and run the client. First, let's create a valid JWT token using the same secret key as the server:

```bash
# Build the token generator
cd cmd/tokgen && go build

# Generate a valid token for tenant "456"
$ ./tokgen -tenant 456
Token for tenant "456": eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

# Make the request with matching tenant ID - this will succeed
$ cd ..
$ ./example-cli/example-cli get-record --auth "Bearer <token>" --tenant-id 456
Record retrieved successfully!

# Now let's try with an invalid token (mismatched tenant ID)
$ cd cmd/tokgen
$ ./tokgen -tenant 456 -invalid
Token for tenant "456_invalid": eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Note: This token has an invalid tenant ID ("456_invalid") that won't match the request tenant ID ("456")
# Make the request - this will fail with 401 Unauthorized
$ cd ..
$ ./example-cli/example-cli get-record --auth "Bearer <token>" --tenant-id 456
error: 401 Unauthorized: invalid tenant ID
```
The request will succeed only if the tenant ID in the JWT token matches the `tenant-id` parameter in the request. This demonstrates how the `ValidateTenant` interceptor ensures that clients can only access data for their authorized tenant.
The request flow:
1. Client-side:
- `EncodeTenant`: Adds tenant ID to JWT in Authorization header
- `Retry`: Retries failed requests
2. Server-side:
- `SetDeadline`: Adds 10s timeout to request context
- `ValidateTenant`: Validates the JWT token
- `Cache`: Checks cache before processing request
Loading

0 comments on commit d7d042f

Please sign in to comment.