From d7d042f848bfadbee7df2f1a16bdf94a1e571e6a Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Sun, 8 Dec 2024 12:57:57 -0800 Subject: [PATCH] Finish interceptors example --- .gitignore | 2 + interceptors/README.md | 363 +++++++++++++++--- interceptors/cmd/tokgen/main.go | 45 +++ interceptors/design/design.go | 6 + interceptors/gen/example/client.go | 11 +- interceptors/gen/example/endpoints.go | 5 +- interceptors/gen/example/interceptors.go | 87 +++-- interceptors/gen/example/service.go | 7 + interceptors/gen/grpc/cli/example/cli.go | 12 +- interceptors/gen/grpc/example/client/cli.go | 2 +- .../gen/http/example/client/encode_decode.go | 17 + interceptors/gen/http/example/client/types.go | 57 +++ .../gen/http/example/server/encode_decode.go | 29 ++ .../gen/http/example/server/server.go | 2 +- interceptors/gen/http/example/server/types.go | 32 ++ interceptors/gen/http/openapi.json | 2 +- interceptors/gen/http/openapi.yaml | 71 +++- interceptors/gen/http/openapi3.json | 2 +- interceptors/gen/http/openapi3.yaml | 83 +++- interceptors/interceptors.go | 18 +- 20 files changed, 727 insertions(+), 126 deletions(-) create mode 100644 interceptors/cmd/tokgen/main.go diff --git a/.gitignore b/.gitignore index 95828c4e..882b7882 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/interceptors/README.md b/interceptors/README.md index fe7a4b40..ebaab7a8 100644 --- a/interceptors/README.md +++ b/interceptors/README.md @@ -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 " --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 " --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 diff --git a/interceptors/cmd/tokgen/main.go b/interceptors/cmd/tokgen/main.go new file mode 100644 index 00000000..38d4aa3f --- /dev/null +++ b/interceptors/cmd/tokgen/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "flag" + "fmt" + "log" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func main() { + var ( + tenantID = flag.String("tenant", "456", "Tenant ID to include in token") + invalid = flag.Bool("invalid", false, "Generate token with mismatched tenant ID") + ) + flag.Parse() + + // If invalid flag is set, use a different tenant ID in the token + tokenTenantID := *tenantID + if *invalid { + tokenTenantID = *tenantID + "_invalid" + } + + // Create the Claims + claims := jwt.MapClaims{ + "tenantID": tokenTenantID, + "iat": time.Now().Unix(), + } + + // Create token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + // Sign and get the complete encoded token as a string + tokenString, err := token.SignedString([]byte("your-secret-key")) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Token for tenant %q: %s\n", tokenTenantID, tokenString) + if *invalid { + fmt.Printf("\nNote: This token has an invalid tenant ID (%q) that won't match the request tenant ID (%q)\n", + tokenTenantID, *tenantID) + } +} diff --git a/interceptors/design/design.go b/interceptors/design/design.go index 88109151..38e4ee97 100644 --- a/interceptors/design/design.go +++ b/interceptors/design/design.go @@ -21,6 +21,7 @@ var ValidateTenant = Interceptor("ValidateTenant", func() { ReadPayload(func() { Attribute("auth", String, "JWT auth token") + Attribute("tenantID", String, "Tenant ID to validate against") }) }) @@ -50,6 +51,8 @@ the service implementation relies on a tenant ID being encoded in the JWT contai The example uses interceptors to extract the tenant ID from the JWT and add it to the request payload. The example also uses interceptors to implement a transparent cache for the loaded records.`) + Error("invalid_token", ErrorResult, "JWT token is invalid") + Method("get_record", func() { ClientInterceptor(EncodeTenant, Retry) ServerInterceptor(SetDeadline, ValidateTenant, Cache) @@ -58,9 +61,12 @@ interceptors to implement a transparent cache for the loaded records.`) Result(Record) + Error("invalid_token") + HTTP(func() { GET("/get_record") Header("auth:Authorization") + Response("invalid_token", StatusUnauthorized) }) GRPC(func() {}) diff --git a/interceptors/gen/example/client.go b/interceptors/gen/example/client.go index 770b520d..00e8f279 100644 --- a/interceptors/gen/example/client.go +++ b/interceptors/gen/example/client.go @@ -19,16 +19,16 @@ type Client struct { } // NewClient initializes a "example" service client given the endpoints. -func NewClient( - getRecord goa.Endpoint, - ci ClientInterceptors, -) *Client { +func NewClient(getRecord goa.Endpoint, ci ClientInterceptors) *Client { return &Client{ GetRecordEndpoint: WrapGetRecordClientEndpoint(getRecord, ci), } } // GetRecord calls the "get_record" endpoint of the "example" service. +// GetRecord may return the following errors: +// - "invalid_token" (type *goa.ServiceError) +// - error: internal error func (c *Client) GetRecord(ctx context.Context, p *GetRecordPayload) (res *Record, err error) { var ires any ires, err = c.GetRecordEndpoint(ctx, p) @@ -38,7 +38,8 @@ func (c *Client) GetRecord(ctx context.Context, p *GetRecordPayload) (res *Recor return ires.(*Record), nil } -// WrapGetRecordClientEndpoint wraps the get_record endpoint with the client interceptors defined in the design. +// WrapGetRecordClientEndpoint wraps the get_record endpoint with the client +// interceptors defined in the design. func WrapGetRecordClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { endpoint = wrapClientEncodeTenant(endpoint, i, "get_record") endpoint = wrapClientRetry(endpoint, i, "get_record") diff --git a/interceptors/gen/example/endpoints.go b/interceptors/gen/example/endpoints.go index a9bafd0b..da423c62 100644 --- a/interceptors/gen/example/endpoints.go +++ b/interceptors/gen/example/endpoints.go @@ -20,11 +20,9 @@ type Endpoints struct { // NewEndpoints wraps the methods of the "example" service with endpoints. func NewEndpoints(s Service, si ServerInterceptors) *Endpoints { - // Initialize the endpoints struct endpoints := &Endpoints{ GetRecord: NewGetRecordEndpoint(s), } - // Wrap endpoints with interceptors where defined endpoints.GetRecord = WrapGetRecordEndpoint(endpoints.GetRecord, si) return endpoints } @@ -43,7 +41,8 @@ func NewGetRecordEndpoint(s Service) goa.Endpoint { } } -// WrapGetRecordEndpoint wraps the get_record endpoint with the server-side interceptors defined in the design. +// WrapGetRecordEndpoint wraps the get_record endpoint with the server-side +// interceptors defined in the design. func WrapGetRecordEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { endpoint = wrapSetDeadline(endpoint, i, "get_record") endpoint = wrapValidateTenant(endpoint, i, "get_record") diff --git a/interceptors/gen/example/interceptors.go b/interceptors/gen/example/interceptors.go index 02a83031..ee481ded 100644 --- a/interceptors/gen/example/interceptors.go +++ b/interceptors/gen/example/interceptors.go @@ -13,7 +13,10 @@ import ( goa "goa.design/goa/v3/pkg" ) -// ServerInterceptors contains the implementations for all server-side interceptors. +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the payload +// is sent to the service (request interceptors) or after the service returns and +// before the response is encoded (response interceptors). type ServerInterceptors interface { // Server-side interceptor which sets the context deadline for the request SetDeadline(context.Context, *SetDeadlineInfo, goa.NextFunc) (any, error) @@ -24,9 +27,10 @@ type ServerInterceptors interface { // Server-side interceptor which implements a transparent cache for the loaded // records Cache(context.Context, *CacheInfo, goa.NextFunc) (any, error) -} - -// ClientInterceptors contains the implementations for all client-side interceptors. +} // ClientInterceptors defines the interface for all client-side interceptors. +// Client interceptors execute after the payload is encoded and before the request +// is sent to the server (request interceptors) or after the response is decoded +// and before the result is returned to the client (response interceptors). type ClientInterceptors interface { // Client-side interceptor which writes the tenant ID to the signed JWT // contained in the Authorization header @@ -36,73 +40,109 @@ type ClientInterceptors interface { Retry(context.Context, *RetryInfo, goa.NextFunc) (any, error) } -// Interceptor payloads and results access interfaces +// Access interfaces for interceptor payloads and results type ( + // SetDeadlineInfo provides metadata about the current interception. + // It includes service name, method name, and access to the endpoint. + SetDeadlineInfo goa.InterceptorInfo + // ValidateTenantInfo provides metadata about the current interception. + // It includes service name, method name, and access to the endpoint. + ValidateTenantInfo goa.InterceptorInfo + + // ValidateTenantPayloadAccess provides type-safe access to the method payload. + // It allows reading and writing specific fields of the payload as defined + // in the design. ValidateTenantPayloadAccess interface { Auth() string + TenantID() string } + // CacheInfo provides metadata about the current interception. + // It includes service name, method name, and access to the endpoint. + CacheInfo goa.InterceptorInfo + + // CachePayloadAccess provides type-safe access to the method payload. + // It allows reading and writing specific fields of the payload as defined + // in the design. CachePayloadAccess interface { RecordID() string } + + // CacheResultAccess provides type-safe access to the method result. + // It allows reading and writing specific fields of the result as defined + // in the design. CacheResultAccess interface { SetCachedAt(string) } + // EncodeTenantInfo provides metadata about the current interception. + // It includes service name, method name, and access to the endpoint. + EncodeTenantInfo goa.InterceptorInfo + + // EncodeTenantPayloadAccess provides type-safe access to the method payload. + // It allows reading and writing specific fields of the payload as defined + // in the design. EncodeTenantPayloadAccess interface { TenantID() string SetAuth(string) } + // RetryInfo provides metadata about the current interception. + // It includes service name, method name, and access to the endpoint. + RetryInfo goa.InterceptorInfo ) -// Interceptor info types +// Private implementation types type ( - SetDeadlineInfo goa.InterceptorInfo - ValidateTenantInfo goa.InterceptorInfo - CacheInfo goa.InterceptorInfo - EncodeTenantInfo goa.InterceptorInfo - RetryInfo goa.InterceptorInfo -) - -// Interceptor payload access implementations -type ( - // validateTenantPayloadAccess is an implementation of ValidateTenantPayloadAccess validateTenantPayloadAccess struct { payload *GetRecordPayload } - // cachePayloadAccess is an implementation of CachePayloadAccess cachePayloadAccess struct { payload *GetRecordPayload } - // cacheResultAccess is an implementation of CacheResultAccess cacheResultAccess struct { result *Record } - // encodeTenantPayloadAccess is an implementation of EncodeTenantPayloadAccess encodeTenantPayloadAccess struct { payload *GetRecordPayload } ) -// Helper methods for interceptor info types +// Public accessor methods for Info types +// Payload returns a type-safe accessor for the method payload. func (info *ValidateTenantInfo) Payload() ValidateTenantPayloadAccess { return &validateTenantPayloadAccess{payload: info.RawPayload.(*GetRecordPayload)} } + +// Payload returns a type-safe accessor for the method payload. func (info *CacheInfo) Payload() CachePayloadAccess { return &cachePayloadAccess{payload: info.RawPayload.(*GetRecordPayload)} } + +// Result returns a type-safe accessor for the method result. func (info *CacheInfo) Result(res any) CacheResultAccess { return &cacheResultAccess{result: res.(*Record)} } + +// Payload returns a type-safe accessor for the method payload. func (info *EncodeTenantInfo) Payload() EncodeTenantPayloadAccess { return &encodeTenantPayloadAccess{payload: info.RawPayload.(*GetRecordPayload)} } -// Implementation of payload access interfaces +// Private implementation methods func (p *validateTenantPayloadAccess) Auth() string { return p.payload.Auth } +func (p *validateTenantPayloadAccess) TenantID() string { + if p.payload.TenantID == nil { + var zero string + return zero + } + return *p.payload.TenantID +} func (p *cachePayloadAccess) RecordID() string { return p.payload.RecordID } +func (r *cacheResultAccess) SetCachedAt(v string) { + r.result.CachedAt = v +} func (p *encodeTenantPayloadAccess) TenantID() string { if p.payload.TenantID == nil { var zero string @@ -113,8 +153,3 @@ func (p *encodeTenantPayloadAccess) TenantID() string { func (p *encodeTenantPayloadAccess) SetAuth(v string) { p.payload.Auth = v } - -// Implementation of result access interfaces -func (r *cacheResultAccess) SetCachedAt(v string) { - r.result.CachedAt = v -} diff --git a/interceptors/gen/example/service.go b/interceptors/gen/example/service.go index cae7f27b..61879c57 100644 --- a/interceptors/gen/example/service.go +++ b/interceptors/gen/example/service.go @@ -9,6 +9,8 @@ package example import ( "context" + + goa "goa.design/goa/v3/pkg" ) // The example service demonstrates how to use interceptors. In this example we @@ -65,3 +67,8 @@ type Record struct { // Time at which the record was cached in RFC3339 format CachedAt string } + +// MakeInvalidToken builds a goa.ServiceError from an error. +func MakeInvalidToken(err error) *goa.ServiceError { + return goa.NewServiceError(err, "invalid_token", false, false, false) +} diff --git a/interceptors/gen/grpc/cli/example/cli.go b/interceptors/gen/grpc/cli/example/cli.go index 445bda19..3c7b1a14 100644 --- a/interceptors/gen/grpc/cli/example/cli.go +++ b/interceptors/gen/grpc/cli/example/cli.go @@ -28,9 +28,9 @@ func UsageCommands() string { // UsageExamples produces an example of a valid invocation of the CLI tool. func UsageExamples() string { return os.Args[0] + ` example get-record --message '{ - "auth": "Inventore voluptates ut ea a.", - "recordID": "Inventore et.", - "tenantID": "Recusandae natus qui." + "auth": "Recusandae natus qui.", + "recordID": "Quas illum sunt quae itaque.", + "tenantID": "Inventore et." }'` + "\n" + "" } @@ -144,9 +144,9 @@ GetRecord implements get_record. Example: %[1]s example get-record --message '{ - "auth": "Inventore voluptates ut ea a.", - "recordID": "Inventore et.", - "tenantID": "Recusandae natus qui." + "auth": "Recusandae natus qui.", + "recordID": "Quas illum sunt quae itaque.", + "tenantID": "Inventore et." }' `, os.Args[0]) } diff --git a/interceptors/gen/grpc/example/client/cli.go b/interceptors/gen/grpc/example/client/cli.go index 9094ab7c..68b0af97 100644 --- a/interceptors/gen/grpc/example/client/cli.go +++ b/interceptors/gen/grpc/example/client/cli.go @@ -24,7 +24,7 @@ func BuildGetRecordPayload(exampleGetRecordMessage string) (*example.GetRecordPa if exampleGetRecordMessage != "" { err = json.Unmarshal([]byte(exampleGetRecordMessage), &message) if err != nil { - return nil, fmt.Errorf("invalid JSON for message, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"auth\": \"Inventore voluptates ut ea a.\",\n \"recordID\": \"Inventore et.\",\n \"tenantID\": \"Recusandae natus qui.\"\n }'") + return nil, fmt.Errorf("invalid JSON for message, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"auth\": \"Recusandae natus qui.\",\n \"recordID\": \"Quas illum sunt quae itaque.\",\n \"tenantID\": \"Inventore et.\"\n }'") } } } diff --git a/interceptors/gen/http/example/client/encode_decode.go b/interceptors/gen/http/example/client/encode_decode.go index c8121ea2..f180155d 100644 --- a/interceptors/gen/http/example/client/encode_decode.go +++ b/interceptors/gen/http/example/client/encode_decode.go @@ -56,6 +56,9 @@ func EncodeGetRecordRequest(encoder func(*http.Request) goahttp.Encoder) func(*h // DecodeGetRecordResponse returns a decoder for responses returned by the // example get_record endpoint. restoreBody controls whether the response body // should be restored after having been read. +// DecodeGetRecordResponse may return the following errors: +// - "invalid_token" (type *goa.ServiceError): http.StatusUnauthorized +// - error: internal error func DecodeGetRecordResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { return func(resp *http.Response) (any, error) { if restoreBody { @@ -86,6 +89,20 @@ func DecodeGetRecordResponse(decoder func(*http.Response) goahttp.Decoder, resto } res := NewGetRecordRecordOK(&body) return res, nil + case http.StatusUnauthorized: + var ( + body GetRecordInvalidTokenResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("example", "get_record", err) + } + err = ValidateGetRecordInvalidTokenResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("example", "get_record", err) + } + return nil, NewGetRecordInvalidToken(&body) default: body, _ := io.ReadAll(resp.Body) return nil, goahttp.ErrInvalidResponse("example", "get_record", resp.StatusCode, string(body)) diff --git a/interceptors/gen/http/example/client/types.go b/interceptors/gen/http/example/client/types.go index 44a5f53c..238005d1 100644 --- a/interceptors/gen/http/example/client/types.go +++ b/interceptors/gen/http/example/client/types.go @@ -38,6 +38,24 @@ type GetRecordResponseBody struct { CachedAt *string `form:"cachedAt,omitempty" json:"cachedAt,omitempty" xml:"cachedAt,omitempty"` } +// GetRecordInvalidTokenResponseBody is the type of the "example" service +// "get_record" endpoint HTTP response body for the "invalid_token" error. +type GetRecordInvalidTokenResponseBody struct { + // Name is the name of this class of errors. + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + // ID is a unique identifier for this particular occurrence of the problem. + ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` + // Is the error temporary? + Temporary *bool `form:"temporary,omitempty" json:"temporary,omitempty" xml:"temporary,omitempty"` + // Is the error a timeout? + Timeout *bool `form:"timeout,omitempty" json:"timeout,omitempty" xml:"timeout,omitempty"` + // Is the error a server-side fault? + Fault *bool `form:"fault,omitempty" json:"fault,omitempty" xml:"fault,omitempty"` +} + // NewGetRecordRequestBody builds the HTTP request body from the payload of the // "get_record" endpoint of the "example" service. func NewGetRecordRequestBody(p *example.GetRecordPayload) *GetRecordRequestBody { @@ -61,6 +79,21 @@ func NewGetRecordRecordOK(body *GetRecordResponseBody) *example.Record { return v } +// NewGetRecordInvalidToken builds a example service get_record endpoint +// invalid_token error. +func NewGetRecordInvalidToken(body *GetRecordInvalidTokenResponseBody) *goa.ServiceError { + v := &goa.ServiceError{ + Name: *body.Name, + ID: *body.ID, + Message: *body.Message, + Temporary: *body.Temporary, + Timeout: *body.Timeout, + Fault: *body.Fault, + } + + return v +} + // ValidateGetRecordResponseBody runs the validations defined on // get_record_response_body func ValidateGetRecordResponseBody(body *GetRecordResponseBody) (err error) { @@ -81,3 +114,27 @@ func ValidateGetRecordResponseBody(body *GetRecordResponseBody) (err error) { } return } + +// ValidateGetRecordInvalidTokenResponseBody runs the validations defined on +// get_record_invalid_token_response_body +func ValidateGetRecordInvalidTokenResponseBody(body *GetRecordInvalidTokenResponseBody) (err error) { + if body.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) + } + if body.ID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("id", "body")) + } + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + if body.Temporary == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("temporary", "body")) + } + if body.Timeout == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("timeout", "body")) + } + if body.Fault == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("fault", "body")) + } + return +} diff --git a/interceptors/gen/http/example/server/encode_decode.go b/interceptors/gen/http/example/server/encode_decode.go index a67ba0a0..4ce855ca 100644 --- a/interceptors/gen/http/example/server/encode_decode.go +++ b/interceptors/gen/http/example/server/encode_decode.go @@ -69,3 +69,32 @@ func DecodeGetRecordRequest(mux goahttp.Muxer, decoder func(*http.Request) goaht return payload, nil } } + +// EncodeGetRecordError returns an encoder for errors returned by the +// get_record example endpoint. +func EncodeGetRecordError(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, formatter func(ctx context.Context, err error) goahttp.Statuser) func(context.Context, http.ResponseWriter, error) error { + encodeError := goahttp.ErrorEncoder(encoder, formatter) + return func(ctx context.Context, w http.ResponseWriter, v error) error { + var en goa.GoaErrorNamer + if !errors.As(v, &en) { + return encodeError(ctx, w, v) + } + switch en.GoaErrorName() { + case "invalid_token": + var res *goa.ServiceError + errors.As(v, &res) + enc := encoder(ctx, w) + var body any + if formatter != nil { + body = formatter(ctx, res) + } else { + body = NewGetRecordInvalidTokenResponseBody(res) + } + w.Header().Set("goa-error", res.GoaErrorName()) + w.WriteHeader(http.StatusUnauthorized) + return enc.Encode(body) + default: + return encodeError(ctx, w, v) + } + } +} diff --git a/interceptors/gen/http/example/server/server.go b/interceptors/gen/http/example/server/server.go index b26a8f2f..a30c243b 100644 --- a/interceptors/gen/http/example/server/server.go +++ b/interceptors/gen/http/example/server/server.go @@ -101,7 +101,7 @@ func NewGetRecordHandler( var ( decodeRequest = DecodeGetRecordRequest(mux, decoder) encodeResponse = EncodeGetRecordResponse(encoder) - encodeError = goahttp.ErrorEncoder(encoder, formatter) + encodeError = EncodeGetRecordError(encoder, formatter) ) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := context.WithValue(r.Context(), goahttp.AcceptTypeKey, r.Header.Get("Accept")) diff --git a/interceptors/gen/http/example/server/types.go b/interceptors/gen/http/example/server/types.go index 5ea8abc7..bf899646 100644 --- a/interceptors/gen/http/example/server/types.go +++ b/interceptors/gen/http/example/server/types.go @@ -38,6 +38,24 @@ type GetRecordResponseBody struct { CachedAt string `form:"cachedAt" json:"cachedAt" xml:"cachedAt"` } +// GetRecordInvalidTokenResponseBody is the type of the "example" service +// "get_record" endpoint HTTP response body for the "invalid_token" error. +type GetRecordInvalidTokenResponseBody struct { + // Name is the name of this class of errors. + Name string `form:"name" json:"name" xml:"name"` + // ID is a unique identifier for this particular occurrence of the problem. + ID string `form:"id" json:"id" xml:"id"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message string `form:"message" json:"message" xml:"message"` + // Is the error temporary? + Temporary bool `form:"temporary" json:"temporary" xml:"temporary"` + // Is the error a timeout? + Timeout bool `form:"timeout" json:"timeout" xml:"timeout"` + // Is the error a server-side fault? + Fault bool `form:"fault" json:"fault" xml:"fault"` +} + // NewGetRecordResponseBody builds the HTTP response body from the result of // the "get_record" endpoint of the "example" service. func NewGetRecordResponseBody(res *example.Record) *GetRecordResponseBody { @@ -50,6 +68,20 @@ func NewGetRecordResponseBody(res *example.Record) *GetRecordResponseBody { return body } +// NewGetRecordInvalidTokenResponseBody builds the HTTP response body from the +// result of the "get_record" endpoint of the "example" service. +func NewGetRecordInvalidTokenResponseBody(res *goa.ServiceError) *GetRecordInvalidTokenResponseBody { + body := &GetRecordInvalidTokenResponseBody{ + Name: res.Name, + ID: res.ID, + Message: res.Message, + Temporary: res.Temporary, + Timeout: res.Timeout, + Fault: res.Fault, + } + return body +} + // NewGetRecordPayload builds a example service get_record endpoint payload. func NewGetRecordPayload(body *GetRecordRequestBody, auth string) *example.GetRecordPayload { v := &example.GetRecordPayload{ diff --git a/interceptors/gen/http/openapi.json b/interceptors/gen/http/openapi.json index 8e094f5e..cdb98a3b 100644 --- a/interceptors/gen/http/openapi.json +++ b/interceptors/gen/http/openapi.json @@ -1 +1 @@ -{"swagger":"2.0","info":{"title":"","version":"0.0.1"},"host":"localhost:80","consumes":["application/json","application/xml","application/gob"],"produces":["application/json","application/xml","application/gob"],"paths":{"/get_record":{"get":{"tags":["example"],"summary":"get_record example","operationId":"example#get_record","parameters":[{"name":"Authorization","in":"header","description":"Authorization header","required":true,"type":"string"},{"name":"get_record_request_body","in":"body","required":true,"schema":{"$ref":"#/definitions/GetRecordPayload","required":["recordID"]}}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/Record","required":["tenantID","recordID","fullName","cachedAt"]}}},"schemes":["http"]}}},"definitions":{"GetRecordPayload":{"title":"GetRecordPayload","type":"object","properties":{"recordID":{"type":"string","description":"The ID of the record to fetch extracted from the JWT contained in the Authorization header","example":"Maxime porro vitae velit libero."},"tenantID":{"type":"string","description":"The ID of the tenant where the record is located extracted from the JWT contained in the Authorization header","example":"Voluptatem officia quia cum aliquid."}},"example":{"recordID":"Et impedit doloremque velit praesentium enim dolores.","tenantID":"Non quisquam delectus ea minima nihil ut."},"required":["recordID"]},"Record":{"title":"Record","type":"object","properties":{"cachedAt":{"type":"string","description":"Time at which the record was cached in RFC3339 format","example":"1992-06-24T09:22:20Z","format":"date-time"},"fullName":{"type":"string","description":"Record full name","example":"Consequatur possimus."},"recordID":{"type":"string","description":"The ID of the record to fetch extracted from the JWT contained in the Authorization header","example":"Rerum accusantium sed et consectetur."},"tenantID":{"type":"string","description":"The ID of the tenant where the record is located extracted from the JWT contained in the Authorization header","example":"Pariatur voluptas earum eaque quo dolorem."}},"example":{"cachedAt":"2001-11-07T20:00:54Z","fullName":"Rerum eum.","recordID":"Voluptas perspiciatis quas iure odio.","tenantID":"Sint aut est similique nobis odit."},"required":["tenantID","recordID","fullName","cachedAt"]}}} \ No newline at end of file +{"swagger":"2.0","info":{"title":"","version":"0.0.1"},"host":"localhost:80","consumes":["application/json","application/xml","application/gob"],"produces":["application/json","application/xml","application/gob"],"paths":{"/get_record":{"get":{"tags":["example"],"summary":"get_record example","operationId":"example#get_record","parameters":[{"name":"Authorization","in":"header","description":"Authorization header","required":true,"type":"string"},{"name":"get_record_request_body","in":"body","required":true,"schema":{"$ref":"#/definitions/GetRecordPayload","required":["recordID"]}}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/Record","required":["tenantID","recordID","fullName","cachedAt"]}},"401":{"description":"Unauthorized response.","schema":{"$ref":"#/definitions/ExampleGetRecordInvalidTokenResponseBody"}}},"schemes":["http"]}}},"definitions":{"ExampleGetRecordInvalidTokenResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":false},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":true},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":false}},"description":"get_record_invalid_token_response_body result type (default view)","example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":false},"required":["name","id","message","temporary","timeout","fault"]},"GetRecordPayload":{"title":"GetRecordPayload","type":"object","properties":{"recordID":{"type":"string","description":"The ID of the record to fetch extracted from the JWT contained in the Authorization header","example":"Velit praesentium enim."},"tenantID":{"type":"string","description":"The ID of the tenant where the record is located extracted from the JWT contained in the Authorization header","example":"Minima nihil ut debitis et impedit."}},"example":{"recordID":"Velit eos ad quia quos.","tenantID":"Optio animi ut voluptatem qui."},"required":["recordID"]},"Record":{"title":"Record","type":"object","properties":{"cachedAt":{"type":"string","description":"Time at which the record was cached in RFC3339 format","example":"2015-03-18T06:48:39Z","format":"date-time"},"fullName":{"type":"string","description":"Record full name","example":"Voluptatem et quia."},"recordID":{"type":"string","description":"The ID of the record to fetch extracted from the JWT contained in the Authorization header","example":"Nulla consequatur possimus et ratione blanditiis laudantium."},"tenantID":{"type":"string","description":"The ID of the tenant where the record is located extracted from the JWT contained in the Authorization header","example":"Sed et."}},"example":{"cachedAt":"1995-09-21T01:26:24Z","fullName":"Nobis nesciunt fuga ratione error.","recordID":"Eum blanditiis cupiditate molestiae doloribus aliquam.","tenantID":"Odio repellendus."},"required":["tenantID","recordID","fullName","cachedAt"]}}} \ No newline at end of file diff --git a/interceptors/gen/http/openapi.yaml b/interceptors/gen/http/openapi.yaml index 96f45afa..93069d7a 100644 --- a/interceptors/gen/http/openapi.yaml +++ b/interceptors/gen/http/openapi.yaml @@ -41,9 +41,56 @@ paths: - recordID - fullName - cachedAt + "401": + description: Unauthorized response. + schema: + $ref: '#/definitions/ExampleGetRecordInvalidTokenResponseBody' schemes: - http definitions: + ExampleGetRecordInvalidTokenResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: false + description: get_record_invalid_token_response_body result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: false + timeout: false + required: + - name + - id + - message + - temporary + - timeout + - fault GetRecordPayload: title: GetRecordPayload type: object @@ -51,14 +98,14 @@ definitions: recordID: type: string description: The ID of the record to fetch extracted from the JWT contained in the Authorization header - example: Maxime porro vitae velit libero. + example: Velit praesentium enim. tenantID: type: string description: The ID of the tenant where the record is located extracted from the JWT contained in the Authorization header - example: Voluptatem officia quia cum aliquid. + example: Minima nihil ut debitis et impedit. example: - recordID: Et impedit doloremque velit praesentium enim dolores. - tenantID: Non quisquam delectus ea minima nihil ut. + recordID: Velit eos ad quia quos. + tenantID: Optio animi ut voluptatem qui. required: - recordID Record: @@ -68,25 +115,25 @@ definitions: cachedAt: type: string description: Time at which the record was cached in RFC3339 format - example: "1992-06-24T09:22:20Z" + example: "2015-03-18T06:48:39Z" format: date-time fullName: type: string description: Record full name - example: Consequatur possimus. + example: Voluptatem et quia. recordID: type: string description: The ID of the record to fetch extracted from the JWT contained in the Authorization header - example: Rerum accusantium sed et consectetur. + example: Nulla consequatur possimus et ratione blanditiis laudantium. tenantID: type: string description: The ID of the tenant where the record is located extracted from the JWT contained in the Authorization header - example: Pariatur voluptas earum eaque quo dolorem. + example: Sed et. example: - cachedAt: "2001-11-07T20:00:54Z" - fullName: Rerum eum. - recordID: Voluptas perspiciatis quas iure odio. - tenantID: Sint aut est similique nobis odit. + cachedAt: "1995-09-21T01:26:24Z" + fullName: Nobis nesciunt fuga ratione error. + recordID: Eum blanditiis cupiditate molestiae doloribus aliquam. + tenantID: Odio repellendus. required: - tenantID - recordID diff --git a/interceptors/gen/http/openapi3.json b/interceptors/gen/http/openapi3.json index ade7728c..a8ff2cdf 100644 --- a/interceptors/gen/http/openapi3.json +++ b/interceptors/gen/http/openapi3.json @@ -1 +1 @@ -{"openapi":"3.0.3","info":{"title":"Goa API","version":"0.0.1"},"servers":[{"url":"http://localhost:80","description":"Default server for example"}],"paths":{"/get_record":{"get":{"tags":["example"],"summary":"get_record example","operationId":"example#get_record","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetRecordPayload2"},"example":{"recordID":"Enim sint.","tenantID":"Consequatur occaecati excepturi eius exercitationem repellendus quidem."}}}},"responses":{"200":{"description":"OK response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Record"},"example":{"cachedAt":"1971-03-22T18:49:59Z","fullName":"Aut fuga quo occaecati.","recordID":"Et aliquid modi officia.","tenantID":"Et magni officia voluptatem voluptate est."}}}}}}}},"components":{"schemas":{"GetRecordPayload":{"type":"object","properties":{"auth":{"type":"string","description":"Authorization header","example":"Animi ut voluptatem qui atque velit eos."},"recordID":{"type":"string","description":"The ID of the record to fetch extracted from the JWT contained in the Authorization header","example":"Earum vitae rerum eius quis."},"tenantID":{"type":"string","description":"The ID of the tenant where the record is located extracted from the JWT contained in the Authorization header","example":"Quia quos optio quae repellat rerum harum."}},"example":{"auth":"Excepturi quo velit.","recordID":"Qui excepturi ab et illum magni velit.","tenantID":"Tempora consequatur atque."},"required":["auth","recordID"]},"GetRecordPayload2":{"type":"object","properties":{"recordID":{"type":"string","description":"The ID of the record to fetch extracted from the JWT contained in the Authorization header","example":"Sapiente ratione."},"tenantID":{"type":"string","description":"The ID of the tenant where the record is located extracted from the JWT contained in the Authorization header","example":"Aut et magni."}},"example":{"recordID":"Rerum consectetur officiis.","tenantID":"Culpa vitae et."},"required":["recordID"]},"Record":{"type":"object","properties":{"cachedAt":{"type":"string","description":"Time at which the record was cached in RFC3339 format","example":"1987-07-06T01:52:46Z","format":"date-time"},"fullName":{"type":"string","description":"Record full name","example":"Nihil omnis amet ex quia tenetur et."},"recordID":{"type":"string","description":"The ID of the record to fetch extracted from the JWT contained in the Authorization header","example":"Eos et mollitia animi eligendi sint tempore."},"tenantID":{"type":"string","description":"The ID of the tenant where the record is located extracted from the JWT contained in the Authorization header","example":"Vel nemo."}},"example":{"cachedAt":"2014-01-31T16:51:02Z","fullName":"Voluptatem aut doloremque at.","recordID":"Dolores dolorem incidunt ad ut aliquid sed.","tenantID":"Rerum beatae sit."},"required":["tenantID","recordID","fullName","cachedAt"]}}},"tags":[{"name":"example","description":"The example service demonstrates how to use interceptors. In this example we assume \nthe service implementation relies on a tenant ID being encoded in the JWT contained in the Authorization header.\nThe example uses interceptors to extract the tenant ID from the JWT and add it to the request payload. The example also uses\ninterceptors to implement a transparent cache for the loaded records."}]} \ No newline at end of file +{"openapi":"3.0.3","info":{"title":"Goa API","version":"0.0.1"},"servers":[{"url":"http://localhost:80","description":"Default server for example"}],"paths":{"/get_record":{"get":{"tags":["example"],"summary":"get_record example","operationId":"example#get_record","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetRecordPayload2"},"example":{"recordID":"Enim sint.","tenantID":"Consequatur occaecati excepturi eius exercitationem repellendus quidem."}}}},"responses":{"200":{"description":"OK response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Record"},"example":{"cachedAt":"1971-03-22T18:49:59Z","fullName":"Aut fuga quo occaecati.","recordID":"Et aliquid modi officia.","tenantID":"Et magni officia voluptatem voluptate est."}}}},"401":{"description":"invalid_token: Unauthorized response.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"schemas":{"Error":{"type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":true},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":true},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":false}},"example":{"fault":true,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":true,"timeout":true},"required":["name","id","message","temporary","timeout","fault"]},"GetRecordPayload":{"type":"object","properties":{"auth":{"type":"string","description":"Authorization header","example":"Quae repellat rerum harum eaque earum vitae."},"recordID":{"type":"string","description":"The ID of the record to fetch extracted from the JWT contained in the Authorization header","example":"Quo velit porro tempora consequatur atque."},"tenantID":{"type":"string","description":"The ID of the tenant where the record is located extracted from the JWT contained in the Authorization header","example":"Eius quis a."}},"example":{"auth":"Qui excepturi ab et illum magni velit.","recordID":"Eos et mollitia animi eligendi sint tempore.","tenantID":"Vel nemo."},"required":["auth","recordID"]},"GetRecordPayload2":{"type":"object","properties":{"recordID":{"type":"string","description":"The ID of the record to fetch extracted from the JWT contained in the Authorization header","example":"Illo velit."},"tenantID":{"type":"string","description":"The ID of the tenant where the record is located extracted from the JWT contained in the Authorization header","example":"Rerum consectetur officiis."}},"example":{"recordID":"Enim assumenda velit veritatis dolor.","tenantID":"Ipsa qui corporis."},"required":["recordID"]},"Record":{"type":"object","properties":{"cachedAt":{"type":"string","description":"Time at which the record was cached in RFC3339 format","example":"1974-10-09T20:02:06Z","format":"date-time"},"fullName":{"type":"string","description":"Record full name","example":"Possimus aliquam quam perspiciatis non."},"recordID":{"type":"string","description":"The ID of the record to fetch extracted from the JWT contained in the Authorization header","example":"Tenetur corporis quo."},"tenantID":{"type":"string","description":"The ID of the tenant where the record is located extracted from the JWT contained in the Authorization header","example":"Nihil omnis amet ex quia tenetur et."}},"example":{"cachedAt":"1986-09-12T19:12:35Z","fullName":"Sequi molestias culpa et est vel.","recordID":"Reprehenderit repellat eum modi.","tenantID":"Sed eum voluptatem aut doloremque."},"required":["tenantID","recordID","fullName","cachedAt"]}}},"tags":[{"name":"example","description":"The example service demonstrates how to use interceptors. In this example we assume \nthe service implementation relies on a tenant ID being encoded in the JWT contained in the Authorization header.\nThe example uses interceptors to extract the tenant ID from the JWT and add it to the request payload. The example also uses\ninterceptors to implement a transparent cache for the loaded records."}]} \ No newline at end of file diff --git a/interceptors/gen/http/openapi3.yaml b/interceptors/gen/http/openapi3.yaml index a827e028..1c3ac723 100644 --- a/interceptors/gen/http/openapi3.yaml +++ b/interceptors/gen/http/openapi3.yaml @@ -33,27 +33,74 @@ paths: fullName: Aut fuga quo occaecati. recordID: Et aliquid modi officia. tenantID: Et magni officia voluptatem voluptate est. + "401": + description: 'invalid_token: Unauthorized response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' components: schemas: + Error: + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: true + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: false + example: + fault: true + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: true + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault GetRecordPayload: type: object properties: auth: type: string description: Authorization header - example: Animi ut voluptatem qui atque velit eos. + example: Quae repellat rerum harum eaque earum vitae. recordID: type: string description: The ID of the record to fetch extracted from the JWT contained in the Authorization header - example: Earum vitae rerum eius quis. + example: Quo velit porro tempora consequatur atque. tenantID: type: string description: The ID of the tenant where the record is located extracted from the JWT contained in the Authorization header - example: Quia quos optio quae repellat rerum harum. + example: Eius quis a. example: - auth: Excepturi quo velit. - recordID: Qui excepturi ab et illum magni velit. - tenantID: Tempora consequatur atque. + auth: Qui excepturi ab et illum magni velit. + recordID: Eos et mollitia animi eligendi sint tempore. + tenantID: Vel nemo. required: - auth - recordID @@ -63,14 +110,14 @@ components: recordID: type: string description: The ID of the record to fetch extracted from the JWT contained in the Authorization header - example: Sapiente ratione. + example: Illo velit. tenantID: type: string description: The ID of the tenant where the record is located extracted from the JWT contained in the Authorization header - example: Aut et magni. + example: Rerum consectetur officiis. example: - recordID: Rerum consectetur officiis. - tenantID: Culpa vitae et. + recordID: Enim assumenda velit veritatis dolor. + tenantID: Ipsa qui corporis. required: - recordID Record: @@ -79,25 +126,25 @@ components: cachedAt: type: string description: Time at which the record was cached in RFC3339 format - example: "1987-07-06T01:52:46Z" + example: "1974-10-09T20:02:06Z" format: date-time fullName: type: string description: Record full name - example: Nihil omnis amet ex quia tenetur et. + example: Possimus aliquam quam perspiciatis non. recordID: type: string description: The ID of the record to fetch extracted from the JWT contained in the Authorization header - example: Eos et mollitia animi eligendi sint tempore. + example: Tenetur corporis quo. tenantID: type: string description: The ID of the tenant where the record is located extracted from the JWT contained in the Authorization header - example: Vel nemo. + example: Nihil omnis amet ex quia tenetur et. example: - cachedAt: "2014-01-31T16:51:02Z" - fullName: Voluptatem aut doloremque at. - recordID: Dolores dolorem incidunt ad ut aliquid sed. - tenantID: Rerum beatae sit. + cachedAt: "1986-09-12T19:12:35Z" + fullName: Sequi molestias culpa et est vel. + recordID: Reprehenderit repellat eum modi. + tenantID: Sed eum voluptatem aut doloremque. required: - tenantID - recordID diff --git a/interceptors/interceptors.go b/interceptors/interceptors.go index 8ef0c0df..883578a4 100644 --- a/interceptors/interceptors.go +++ b/interceptors/interceptors.go @@ -29,7 +29,7 @@ func (i *Interceptors) ValidateTenant(ctx context.Context, info *example.Validat auth := pa.Auth() if len(auth) < 7 || auth[:7] != "Bearer " { - return nil, errors.New("invalid authorization header format") + return nil, example.MakeInvalidToken(errors.New("invalid auth header")) } tokenString := auth[7:] // Remove "Bearer " @@ -45,13 +45,24 @@ func (i *Interceptors) ValidateTenant(ctx context.Context, info *example.Validat }) if err != nil { - // return nil, errors.New("invalid JWT token: " + err.Error()) + return nil, example.MakeInvalidToken(err) } if !token.Valid { - // return nil, errors.New("invalid JWT token") + return nil, example.MakeInvalidToken(errors.New("invalid auth token")) } + // 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) } @@ -75,6 +86,7 @@ func (i *Interceptors) Cache(ctx context.Context, info *example.CacheInfo, next if err != nil { return nil, err } + info.Result(result).SetCachedAt(time.Now().Format(time.RFC3339)) // Cache result i.mu.Lock()