-
Notifications
You must be signed in to change notification settings - Fork 70
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
20 changed files
with
727 additions
and
126 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.