-
-
Notifications
You must be signed in to change notification settings - Fork 561
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
Middleware in design? #3484
Comments
That makes a lot of sense! A goal of the Goa design is to ensure that design code is never used at runtime, the DSL is only used for code generation. This makes it possible to ensure that the generated code follows an expected pattern and that in particular there is clear layer enforcements between transport and non-transport code. So applying this to middlewares there could be a couple of non-mutually exclusive approaches: There could be a new "Middleware" DSL that can be applied to non-transport or transport specific DSL, something like: var _ = Service("calc", func() {
Middleware("ErrorHandler", "The ErrorHandler middleware reports unexpected errors.")
}) This would cause Goa to generate hooks for every service method that the user provides the implementation to (something akin to how the security middlewares work today). the func ErrorHandler(goa.Endpoint) goa.Endpoint Similarly the var _ = Service("calc", func() {
HTTP(func() {
Middleware("ErrorHandler", "The ErrorHandler middleware reports unexpected errors.")
})
GRPC(func() {
Middleware("ErrorHandler", "The ErrorHandler middleware reports unexpected errors.")
})
}) In this case the hooks would be transport specific: func HTTPErrorHandler(http.Handler) http.Handler
func GRPCErrorHandler() grpc.UnaryServerInterceptor // (or grpc.StreamServerInterceptor) Alternatively (or complementary) there could be a set of "known" middlewares that have specific DSL, or a generic DSL that identifies the middleware: var _ = Service("calc", func() {
Middleware(ErrorHandler)
}) These specific middleware implementations might have different hook signatures, for example in this case: func ErrorHandler(err error) There might be other approaches, this is what comes to mind first :) |
This is exactly what I had in mind - and now that you mention it the security middleware is already really close to this! I like the idea of having several places to put the middleware (API, Service and Method). While we are dreaming: (1) I currently have some middleware that adds values to the context, which is sometimes error prone (forgetting the middleware leads to nils..) maybe we could design the middleware function to produce a result-type? This could then be passed to the method in a type safe manner? var FooExtractor = Middleware("fooExtractor",func(){
// Use a result to signal that the middleware has an output
Result(func(){
// Field numbers are probably not necessary, since the result is not exposed to GRPC directly
Field(0,"extractedFoo",String)
})
Error("noFoo","No foo was found in the payload")
})
Method("foo",func() {
Middleware(FooExtractor)
}) which could result in func (s *ExampleService) Foo(ctx context.Context, *p example.FooPayload) (res *example.FooResult, err error){
extractedFoo := example.FooExtractorResult(ctx)
} alternatively, for an even cleaner experience the result of the middleware could be merged into the payload type under the name of the middleware. func (s *ExampleService) Foo(ctx context.Context, *p example.FooPayload) (res *example.FooResult, err error){
extractedFoo := p.fooExtractor.extractedFoo
} (2)If we wanted to go even further one could add a mechanism for making parameters in middlewares possible. This would require that a middleware can accept parameters (making it now very close in charachteristics to a method itself) var FooExtractor = Middleware("fooExtractor",func(){
Payload(func(){
Attribute("fooSeed",String)
Required("fooSeed")
})
// Use a result to signal that the middleware has an output
Result(func(){
// Field numbers are probably not necessary, since the result is not exposed to GRPC directly
Attribute("extractedFoo",String)
})
Error("noFoo","No foo was found in the payload")
}) This would then require a check that any method using the This kind of feature would make many crud application a lot simpler because any endpoint that handles single item operations ( (3) Since now a middleware shares basically all mechanics of a method we could also use methods directly so instead of var FooExtractor = Middleware(...)
var FooExtractor = Method(....) and then implement the The checks on type compatibility would still need to apply (meaning that the parameters of the middleware have to be a subset of the method using the middleware). This would fit very nicely in the already existing DSL in my opinion 🥳 curious about your thoughts |
This is great, considering only the "transport-agnostic" scenario for a moment the use cases that this would need to support are:
So we would need DSL that make it possible to:
The The new
Similarly the new There could be both a Putting it all together: var SomeRequestInterceptor = RequestInterceptor("SomeRequestInterceptor", func() {
Payload(func() {
Attribute("a") // Read payload attribute `a` - must be a payload attribute
Attribute("b") // Read payload attribute `b`
})
// OR
// Payload("a") // Read payload attribute `a` and uses that as interceptor argument (instead of object)
// OR
// Payload(PayloadType) // Read entire payload type - must be the same as the method payload type
Result(func() {
Attribute("c") // Modifies payload attribute `c` - must be a payload attribute
})
})
var SomeResponseInterceptor = ResponseInterceptor("SomeResponseInterceptor", func() {
Payload(func() {
Attribute("a") // Read result attribute `a` - must be a result attribute
Attribute("b") // Read result attribute `b`
})
// OR
// Payload("a") // Read result attribute `a` and uses that as interceptor argument (instead of object)
// OR
// Payload(PayloadType) // Read entire result type - must be the same as the method result type
Result(func() {
Attribute("c") // Modifies result attribute `c` - must be a result attribute
})
}) The above would cause the following hooks to be generated: func SomeRequestInterceptor(ctx context.Context, payload RequestInterceptorPayloadType) (context.Context, RequestInterceptorResultType, error) Note that the interceptor returns a context that is then passed on to both the method and any response interceptor. If a request interceptor returns an error then the method is not invoked - instead the error is handled by Goa the same way a method error is (that is if it's a designed error then the corresponding status code is returned otherwise a 500 gets returned). func SomeResponseInterceptor(ctx context.Context, payload ResponseInterceptorPayloadType) ( ResponseInterceptorResultType, error) The response interceptor has a similar signature, there is just no context returned in this case. Additionally there can be multiple request and/or response interceptors defined on a single method. They get run in the order in which they are defined. |
This is looking really cool! Very good observation that my proposal was only covering a “slice” of what is considered middleware. What do you think of some mechanism to allow “type safe” interaction with context? Is there a need for interceptors that can pass errors to the following methods? Maybe one could have an error dsl that is non-critical that will not cancel the method from being called if the middleware fails. |
My knee-jerk reaction would be that this would be out-of-scope of Goa. Different use cases might call for different ways of doing this, in particular the actual data types are probably not generic and trying to build something that can cater to all use cases would add undue complexity (to Goa and to user code). But I could be missing something...
At the end of the day the middleware is either able to transform the request or response or cannot process the request which would be an exception. That is an expected error case should be communicated via the transform (or non-transform) of the payload. In the extreme case the payload could have a field that indicates the outcome although I would expect the need for this to be fairly rare? |
I was thinking of something like described above func (s *ExampleService) Foo(ctx context.Context, *p example.FooPayload) (res *example.FooResult, err error){
extractedFoo := p.fooExtractor.extractedFoo
} where goa directly injects the result from the middleware into the payload object func (s *ExampleService) Foo(ctx context.Context, *p example.FooPayload) (res *example.FooResult, err error){
extractedFoo := example.FooExtractorResult(ctx)
} where goa generates I would prefer the first option since then, a removal of a middleware or a change is directly visible in the implementation function (i.e. the property of the FooPayload does no longer exist)
Fair point. I think error DSLs in middleware make sense (e.g. a permission checking middleware will often throw 403s), but maybe non critical errors can be passed via the result (e.g. a cache miss). |
Coming back to this thread, to maybe flesh out the concept a little bit further. I think there is a couple of different use cases / or ways people use middlware. Looking at the different things:
Looking at the the first two things I think they are actually 4 use-cases. Modifying the request payload/result could mean a run-time change of the values or a design-time change of the signature. Both of these are, I think, equally interesting, as the ability to have a middleware definition extend or change a methods payload would be a cool feature to have that would make composability of methods a lot easier. Programming in DSLs for generic methods?While thinking about uses cases which I would use middleware for I thought about pagination. It might be that this is a different problem from most middleware, but I think its such a common functionality (in the end we all build CRUD services mostly...). While trying to come up with way to generalize this behaviour, I was thinking about ways for a middleware to modify payloads and response types and found no really satisfying way to make clear how it interacts with the signatures of the methods. It would be great if that was possible in a more dry way. One way I could imagine achieving this with current goa to at least get dry type definitions, is to simply writing a function in the design code that calls the respective DSL methods to "dynamically" generate a method with the correct signature. Not sure if this is an anti pattern, current examples (AFAIK) never do this. It sacrifices a bit of readability for consistency. func PaginatedMethod(name string,resultType Type) {
Method(name, func () {
Payload(func() {
Attribute("page", Int)
Attribute("limit", Int)
})
Result(func() {
Attribute("data", ArrayOf(resultType))
Attribute("page", Int)
Attribute("total", Int)
})
})
} A common pattern in middleware functionality for frameworks is chaining, where the middleware has a common signature (req,res,next) the middleware is therefore able to read the request, write to the response and trigger the next execution step by calling This pattern would work for all use cases (modifying payload, response, both or none) but has some caveats as it introduces some potential pitfals:
I don't think these are big issues, but maybe we can be clever in the design to avoid or minimize these risks I think through goa's type system we are in a unique position to safely type middleware meaning that we can ensure the following:
Therefore if we wanted to use the chaining pattern we would need to have a way of defining when the middleware is ran in order to compute the types of the method correctly (and potentially adapter types) Pre-Method middleware: Say we have a Placing this middleware before the method, would extend the parameters passed to it by the output of the middleware. Post-Method middleware: A middleware that runs after the method, this could be adding some metadata to a response like an Wrapping middleware A middleware that runs before and after, basically wrapping the execution of the method. An example for this use case would a telemetry middleware that measures the execution time of a method. This order should be somehow intuitive when writing the design file. The first two would be quite simple. You could have a Adding wrapping middleware makes things considerably harder. one way I can think of is using a func argument and a new The middleware and a WrappingMiddleware DSL now return a function that can be called from the method declaration. The function returned from WrappingMiddleware accepts a function that contains the calls that happen in between. var PerfLogger = WrappingMiddleware(...)
var WithUser = Middleware(...)
Method("getFriends", func(){
PerfLogger(func(){
WithUser()
Execute()
})
}) In this case perf logger wrapps the execution of the WithUser and the implementation of the "getFriends" method. Calls are sequential meaning that the method has access to the result of As an aside: Calling execute in a method with no middlware has no effect. Method("noMiddleware", func(){
Execute()
}) Payload and Result of a middlewareWhile directly emitting types to the paramters and results of a method would be great (meaning that adding a middleware could automatically change the request and response types of a method). It would require some mechanisms to do type combinations, and also stable field numbering (for GRPC) would be nearly impossible. I would therefore simply go with an approach where the middleware can expect certain parameters to be present in the payload. If a middleware defines a result (like the var WithUser = Middleware(func(){
Payload(func(){
Attribute("userId",String)
})
Result(User)
}) When adding this Middleware to a method goa will check whether the attribute The middleware returns a User object which can then get passed to the method as well. func (s *svg) WithUser(ctx context.Context, p *WithUserPayload) (*User,*WithUserResponse,error) {
....
}
func (s *svc) GetFriends(ctx context.Context,withUserResult *User, p *GetFriendsPayload) (..., error) {
...
} There's still some things that need to be fleshed out here... will think about this more.. 😄 |
Hello! Great conversation, I spent a bit of time going through various options and here is a concrete proposal that would fit well with the current design of Goa. Goa Middleware and Interceptor ProposalThis proposal introduces a comprehensive approach to middleware in Goa that preserves type safety and explicit documentation while supporting both transport-level and business logic concerns. The goal is to provide clear mechanisms for adding cross-cutting functionality while maintaining Goa's core strengths of static typing and clear API contracts. Background and Key Principles
Middleware Use CasesServices typically use middlewares in two scenarios:
Proposed Solution: A Dual ApproachThe proposal introduces two complementary mechanisms that address different needs:
This separation allows us to maintain the benefits of both worlds:
1. Enhanced Middleware SupportMiddlewares are ideal for operations that:
Endpoint MiddlewareMethod("getFriends", func() {
// Can be applied at API, service, or method level
Middleware("CountRequests", "Count the number of times getFriends has been called.")
// ... Method definition
}) Generated code in func NewEndpoints(s Service, countRequests func(goa.Endpoint) goa.Endpoint) {
endpoints := // ... initialization
endpoints.Use(countRequests)
return endpoints
} Transport-Specific MiddlewareMethod("getFriends", func() {
HTTP(func() {
Middleware("RefreshAuthToken", "Extends the auth token TTL.")
// ... Method definition
})
}) Generated code in func New(
e *service.Endpoints,
mux goahttp.Muxer,
refreshAuthToken func(http.Handler) http.Handler,
decoder func(*http.Request) goahttp.Decoder,
encoder func(context.Context, http.ResponseWriter) goahttp.Encoder,
errhandler func(context.Context, http.ResponseWriter, error),
formatter func(ctx context.Context, err error) goahttp.Statuser,
) *Server {
server := // ... initialization
server.Use(refreshAuthToken)
return server
} gRPC is also supported, the example is omitted for brevity. 2. Interceptor FrameworkThe interceptor framework complements middlewares by providing:
This approach is particularly valuable when:
Interceptors make it possible to inject user code into the decoding and encoding process. For example combined with the Design Examplepackage design
import . "goa.design/goa/v3/dsl"
var LoadTenant = RequestInterceptor("LoadTenant", func() {
Description("Loads the tenant for the given friend ID, returns 404 if not found")
Reads(func() {
Attribute("Authorization") // Authorization HTTP header
})
Writes(func() {
Attribute("TenantID") // Service payload field
})
})
var DecodeULID = RequestInterceptor("DecodeULID", func() {
Description("Decode a string into a ULID")
Reads(func() {
Attribute("ULID") // Transport payload field of type string
})
Writes(func() {
Attribute("ULID") // Service payload field of type ulid.ULID
})
})
var EncodeULID = ResponseInterceptor("EncodeULID", func() {
Description("Encode a ULID into a string")
Reads(func() {
Attribute("ULID") // Service result field of type ulid.ULID
})
Writes(func() {
Attribute("ULID") // Transport result field of type string
})
})
var _ = Service("Friend", func() {
Method("GetFriend", func() {
RequestInterceptor(LoadTenant)
RequestInterceptor(DecodeULID)
ResponseInterceptor(EncodeULID)
Payload(func() {
Attribute("TenantID", Int, "Tenant ID from JWT token")
Attribute("ULID", String, "Universally Unique Lexicographically Sortable Identifier", func() {
Meta("struct:field:type", "ulid.ULID", "github.com/oklog/ulid") // Set service field type to ulid.ULID
})
})
Result(Friend)
Error("FriendNotFound")
HTTP(func() {
GET("/friends/{id}")
Header("Authorization")
Response("FriendNotFound", StatusNotFound)
})
})
}) Generated CodeIn package friend
type (
LoadTenantPayload struct {
Authorization string
}
LoadTenantResult struct {
TenantID int
}
DecodeULIDPayload struct {
Ulid string
}
DecodeULIDResult struct {
Ulid ulid.ULID
}
EncodeULIDPayload struct {
Ulid ulid.ULID
}
EncodeULIDResult struct {
Ulid string
}
)
// LoadTenant loads tenant for given friend ID
func LoadTenant(context.Context, *LoadTenantPayload) (*LoadTenantResult, error)
// Decode a string into a ULID
func DecodeULID(context.Context, *DecodeULIDPayload) (*DecodeULIDResult, error)
// Encode a ULID into a string
func EncodeULID(context.Context, *EncodeULIDPayload) (*EncodeULIDResult, error) Implementation Notes
SummaryThe combination of middlewares and interceptors provides a complete solution:
|
Another great angle.. I will get back to this soon. I didn't even think of the transport layers as a topic for this functionality.. :) |
How exactly do you plan to generate it? |
The same way it gets generated today. The point being that the data structure used by the OpenAPI generator would correctly reflect the transport level layout. |
I updated the proposal to illustrate a nice benefit of interceptors: they make is possible to inject user code in the marshalling/unmarshalling phase. This is pretty powerful as it has the potential to satisfy a number of use cases involving the need to use custom types at the service level. Specifically:
|
I don't fully understand interceptor yet, but I have some concerns about middleware.
|
Completely agree with all these points @tchssk! Let's iterate on the proposal to tackle these limitations, I have a few ideas. Also I'm thinking it would be better to break out these two proposals into separate issues. I've created a new issue for the interceptor proposal after iterating a few times over it and implementing a proof of concept: #3614 |
I'm really enjoying development with goa. ❤️
I wanted to move some repeating code to a middleware that is used by several methods. I was wondering why the middleware is not part of the design file? Currently the registration in the main.go feels a bit decoupled. Looking at the design or the implementation does not easily reveal which middleware is running for a specific endpoint.
My current workaround is just calling a method at the top of an endpoint, but this feels a bit against the "design first" philosophy.
The text was updated successfully, but these errors were encountered: