# Design: Middleware **Author**: gak **Status**: draft / ==in-review== / accepted / implemented / rejected ## Description Middleware, interceptors, hooks, decorators, wrappers are different terms describing this functionality. It is a way to have some code be automatically run before and after a selected set of verbs, as if they were written inside the verb function. You can apply middleware to verbs across the project, just a module, or specific verbs. ## Motivation Reducing repetitive boilerplate code and copy pasta bugs. Potential uses: - Sanitization - Serialization - Auth - Encryption - Tracing - Performance - Logging - Error reporting - Rate limiting - Features/AB testing ## Goals - Apply at different scopes: project, module, verb. - Stack multiple middleware, in a clear stack order: - Biggest scope first - No ordering overriding (yet). - Middleware options/arguments (eg a group that is allowed) that can be set differently to different verbs/scopes. These are common for middleware, however are good to have explicitly listed: - Pass through original response values. - Error handling: - Ability to abort the chain of middleware before or after the verb is called. - Ability to handle errors from a verb (or other middleware), and continue the chain without error. - Modify/replace argument content. - Modify/replace return content. ### Non-Goals - Middleware to not apply to async calls. - Regex scopes: - Because now we have 2 problems, and there is little reason to use it when you can just add them to each verb explicitly. - Type scopes. (Later e.g. via `//ftl:onlyApplyToRequestType HttpRequest[Any]`) - Accept generic types. - "binary middleware" to handle []byte rather than the verb type. - We don't want to expose internal FTL data. ## Design Middleware functions are just verbs, however (statically checked) they: - ~~Must call the "next" verb in the chain via `ftl.CallNext(ctx, in.In)`.~~ Adds extra work for little benefit, e.g. if the call is not actually in the method body. - Must use the type `MiddlewareIn[O]` where `O` describes middleware options. This is declared by FTL to be used as the middleware verb argument: ```go type MiddlewareIn[O any] struct { /// In is the first argument of the wrapped verb, in JSONish values. /// `map[string]interface{}`? In any // Options that can be passed into the middleware. // Use Unit for no args. Options O // Verb that is being being wrapped. Verb ftl.Ref // InRef is the module/name of the input type InRef ftl.Ref // OutRef is the module/name of the output type OutRef ftl.Ref } ``` ### Simple example Define the middleware: ```go // EnterpriseDelay adds a few seconds to this verb. //ftl:verb func EnterpriseDelay(ctx context.Context, in MiddlewareIn[Unit]) (any, error) { // This will let us charge more $. time.Sleep(2 * time.Second) // Call the next middleware in the chain or finally the wrapped verb. out, err := ftl.CallNext(ctx, in.In) if err != nil { // An error in the chain or wrapped verb, pass it along. return nil, fmt.Errorf("enterprise delay middleware: %s %w", in.Verb, err) } return out, nil } ``` Use the middleware on this verb: ```go //ftl:verb //ftl:middleware EnterpriseDelay func Echo(ctx context.Context, in EchoRequest) (EchoResponse, error) { // ... } ``` ### Complex ingress example ```go // AllowOptions defines what roles a verb can run with. type AllowOptions struct { Roles []string } //ftl:verb func AllowMiddleware(ctx context.Context, in MiddlewareIn[AllowOptions]) (any, error) { if len(in.Options.Roles) == 0 { return nil, fmt.Errorf("roles is a required option in AllowMiddlware") } // Preprocessing logic before the verb will be called. authz, ok := in.In["headers"]["Authorization"] if !ok { return nil, ... } db := db.Get(ctx) if !db.AuthAllowed(authz, in.Options.Roles) { // Early exit due to middleware check. return nil, ... } // Call the next middleware in the chain or finally the wrapped verb. out, err := ftl.CallNext(ctx, in.In) if err != nil { // An error in the chain or wrapped verb, pass it along. return nil, fmt.Errorf("allow middleware: %s %w", in.Verb, err) } // Postprocessing code after the verb has been called. logging.Debugf("Verb %s finished calling after giving %s access.", in.Verb, options.Role) // Modify the verbs return value if required. out["headers"]["X-granted"] = fmt.Sprintf("%s", in.Options.Roles) // Pass on the successful and modified output from the chain or wrapped verb. return out, nil } ``` Using the middleware: ```go type User struct{} type CreateUserResponse struct { id string } //ftl:ingress http POST /user //ftl:middleware AllowMiddleware(roles=["user:create"]) func CreateUser(ctx context.Context, req builtin.HttpRequest[User]) (builtin.HttpResponse[CreateUserResponse, ErrorResponse], error) { return builtin.HttpResponse[CreateUserResponse, string]{ Body: ftl.Some(CreateUserResponse { id: new_user(ctx, req.Body) }), }, nil } ### Schema module one { // uses ... // not this, put all of the middleware above the verb export verb authzMiddleware(builtin.Middleware[Authz]) Out export verb one(Empty) Empty middleware two.errorReporter, one.authzMiddleware("admin"), one.cancelMillis(timeout=250, etc="hi"), one.sanitizedLogger("password"), // TODO change this too? +calls two.two, three.three, } ``` ### Scopes Verb level scopes can be defined at the verb itself with: ``` //ftl:middleware [MiddlewareVerb(args)] ``` Otherwise for global and module level scopes, define them in `ftl-project.toml`: ```toml name = "awesome" module-dirs = ["time", "bingo"] ftl-min-version = "" [global] middleware = [ "time.EnterpriseDelay(2000)", "bingo.BingoMiddleware", ] [global.configuration] key = "inline://InZhbHVlIg" [modules] [modules.bingo] milddleware = [ "bingo.RandomlyFail", ] ``` ### Required changes - `//ftl:middleware` directive. - ~~Static check for `ftl.CallNext()` if used as a middleware.~~ - Static check for `//ftl:middleware(options)` is calling middleware options correctly. - Only apply to sync verbs: - Verbs - Ingress - Sink - Scopes: - Global and module scopes defined in ftl-project.toml - Verb "middleware" scope. - Ordering from global first, then module, finally verb level (`//ftl:middleware`). - Schema syntax changes: - "middleware" - Change "+calls" to line up arguments similar to middlware for consistency. - Chain middleware when a verb is called via `ftl.CallNext()`. - Check for middleware before a verb call, call middleware instead while keeping track of the chain and return. - This might need some DB state otherwise the request itself could contain info on the chain state? - Follow schema order of chained middleware to be called init `ftl.CallNext()`` - And finally `ftl.CallNext()` to call the verb when there is no middleware left. ## Rejected Alternatives ### Extra argument for options ```go //ftl:middleware func PerformanceTimer(ctx Context, q Q, timeout int) (A, error) {} ``` Doesn't line up with a standard verb signature. ### Python decorator style ```go //ftl:verb func PerformanceTimer(ctx ctx.Context, options ...) (func(...), error) { var verb := ... return func(ctx: context.Context, req Q) (A, error) error { var logger = ctx.GetLogger() var start = time.Now() a, err := f(q) var taken = time.Now() - start logger.Debug("Taken %s", taken) if taken > time.Second { return A{}, fmt.Errorf("Operation took too long in function %s and argument %s", f, q) } return a, err } } ``` Overly complex. ### Generics Generics added too much complexity to both the user verb and internal code, which lead to the `InMiddleware` struct.