Skip to content

feat(core): define module registry and lifecycle hooks #27

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

Open
wants to merge 1 commit into
base: topic/kaialang-eng-1063-open-core-module-system-v1
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 245 additions & 0 deletions router/core/hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
package core

import (
"context"

"github.com/wundergraph/cosmo/router/internal/utils"
)

// Application Lifecycle Hooks
type ApplicationLifecycleHook interface {
ApplicationStartHook
ApplicationStopHook
}

type ApplicationStartHook interface {
OnApplicationStart(ctx context.Context)
}

type ApplicationStopHook interface {
OnApplicationStop(ctx context.Context)
}

// GraphQL Server Lifecycle Hooks
type GraphQLServerLifecycleHook interface {
GraphQLServerStartHook
GraphQLServerStopHook
}

type GraphQLServerStartHook interface {
OnGraphQLServerStart(ctx context.Context)
}

type GraphQLServerStopHook interface {
OnGraphQLServerStop(ctx context.Context)
}

// Router Lifecycle Hooks
type RouterRequestHook interface {
OnRouterRequest(ctx context.Context)
}

type RouterResponseHook interface {
OnRouterResponse(ctx context.Context)
}

type RouterLifecycleHook interface {
RouterRequestHook
RouterResponseHook
}

// Subgraph Lifecycle Hooks
type SubgraphRequestHook interface {
OnSubgraphRequest(ctx context.Context)
}

type SubgraphResponseHook interface {
OnSubgraphResponse(ctx context.Context)
}

type SubgraphLifecycleHook interface {
SubgraphRequestHook
SubgraphResponseHook
}

// Operation Lifecycle Hooks
type OperationLifecycleHook interface {
OperationParseLifecycleHook
OperationNormalizeLifecycleHook
OperationValidateLifecycleHook
OperationPlanLifecycleHook
OperationExecuteLifecycleHook
}

type OperationParseLifecycleHook interface {
OperationPreParseHook
OperationPostParseHook
}

type OperationPreParseHook interface {
OnOperationPreParse(ctx context.Context)
}

type OperationPostParseHook interface {
OnOperationPostParse(ctx context.Context)
}

type OperationNormalizeLifecycleHook interface {
OperationPreNormalizeHook
OperationPostNormalizeHook
}

type OperationPreNormalizeHook interface {
OnOperationPreNormalize(ctx context.Context)
}

type OperationPostNormalizeHook interface {
OnOperationPostNormalize(ctx context.Context)
}

type OperationValidateLifecycleHook interface {
OperationPreValidateHook
OperationPostValidateHook
}

type OperationPreValidateHook interface {
OnOperationPreValidate(ctx context.Context)
}

type OperationPostValidateHook interface {
OnOperationPostValidate(ctx context.Context)
}

type OperationPlanLifecycleHook interface {
OperationPrePlanHook
OperationPostPlanHook
}

type OperationPrePlanHook interface {
OnOperationPrePlan(ctx context.Context)
}

type OperationPostPlanHook interface {
OnOperationPostPlan(ctx context.Context)
}

type OperationExecuteLifecycleHook interface {
OperationPreExecuteHook
OperationPostExecuteHook
}

type OperationPreExecuteHook interface {
OnOperationPreExecute(ctx context.Context)
}

type OperationPostExecuteHook interface {
OnOperationPostExecute(ctx context.Context)
}

// hookRegistry holds the list of hooks for each type.
type hookRegistry struct {
applicationStartHooks *utils.OrderedSet[ApplicationStartHook]
applicationStopHooks *utils.OrderedSet[ApplicationStopHook]

graphQLServerStartHooks *utils.OrderedSet[GraphQLServerStartHook]
graphQLServerStopHooks *utils.OrderedSet[GraphQLServerStopHook]

routerRequestHooks *utils.OrderedSet[RouterRequestHook]
routerResponseHooks *utils.OrderedSet[RouterResponseHook]

subgraphRequestHooks *utils.OrderedSet[SubgraphRequestHook]
subgraphResponseHooks *utils.OrderedSet[SubgraphResponseHook]

operationPreParseHooks *utils.OrderedSet[OperationPreParseHook]
operationPostParseHooks *utils.OrderedSet[OperationPostParseHook]

operationPreNormalizeHooks *utils.OrderedSet[OperationPreNormalizeHook]
operationPostNormalizeHooks *utils.OrderedSet[OperationPostNormalizeHook]

operationPreValidateHooks *utils.OrderedSet[OperationPreValidateHook]
operationPostValidateHooks *utils.OrderedSet[OperationPostValidateHook]

operationPrePlanHooks *utils.OrderedSet[OperationPrePlanHook]
operationPostPlanHooks *utils.OrderedSet[OperationPostPlanHook]

operationPreExecuteHooks *utils.OrderedSet[OperationPreExecuteHook]
operationPostExecuteHooks *utils.OrderedSet[OperationPostExecuteHook]
}

// newHookRegistry initializes with empty sets.
func newHookRegistry() *hookRegistry {
return &hookRegistry{
applicationStartHooks: utils.NewOrderedSet[ApplicationStartHook](),
applicationStopHooks: utils.NewOrderedSet[ApplicationStopHook](),

graphQLServerStartHooks: utils.NewOrderedSet[GraphQLServerStartHook](),
graphQLServerStopHooks: utils.NewOrderedSet[GraphQLServerStopHook](),

routerRequestHooks: utils.NewOrderedSet[RouterRequestHook](),
routerResponseHooks: utils.NewOrderedSet[RouterResponseHook](),

subgraphRequestHooks: utils.NewOrderedSet[SubgraphRequestHook](),
subgraphResponseHooks: utils.NewOrderedSet[SubgraphResponseHook](),

operationPreParseHooks: utils.NewOrderedSet[OperationPreParseHook](),
operationPostParseHooks: utils.NewOrderedSet[OperationPostParseHook](),

operationPreNormalizeHooks: utils.NewOrderedSet[OperationPreNormalizeHook](),
operationPostNormalizeHooks: utils.NewOrderedSet[OperationPostNormalizeHook](),

operationPreValidateHooks: utils.NewOrderedSet[OperationPreValidateHook](),
operationPostValidateHooks: utils.NewOrderedSet[OperationPostValidateHook](),

operationPrePlanHooks: utils.NewOrderedSet[OperationPrePlanHook](),
operationPostPlanHooks: utils.NewOrderedSet[OperationPostPlanHook](),

operationPreExecuteHooks: utils.NewOrderedSet[OperationPreExecuteHook](),
operationPostExecuteHooks: utils.NewOrderedSet[OperationPostExecuteHook](),
}
}

// registerHook is a helper to add any hook type if implemented.
func registerHook[H comparable](inst any, set *utils.OrderedSet[H]) {
if h, ok := inst.(H); ok {
set.Add(h)
}
}

// AddApplicationLifecycle wires up start/stop hooks.
func (hr *hookRegistry) AddApplicationLifecycle(inst any) {
registerHook(inst, hr.applicationStartHooks)
registerHook(inst, hr.applicationStopHooks)
}

// AddGraphQLServerLifecycle wires up GraphQL server start/stop hooks.
func (hr *hookRegistry) AddGraphQLServerLifecycle(inst any) {
registerHook(inst, hr.graphQLServerStartHooks)
registerHook(inst, hr.graphQLServerStopHooks)
}

// AddRouterLifecycle wires up router request/response hooks.
func (hr *hookRegistry) AddRouterLifecycle(inst any) {
registerHook(inst, hr.routerRequestHooks)
registerHook(inst, hr.routerResponseHooks)
}

// AddSubgraphLifecycle wires up subgraph request/response hooks.
func (hr *hookRegistry) AddSubgraphLifecycle(inst any) {
registerHook(inst, hr.subgraphRequestHooks)
registerHook(inst, hr.subgraphResponseHooks)
}

// AddOperationLifecycle wires up all operation lifecycle hooks.
func (hr *hookRegistry) AddOperationLifecycle(inst any) {
registerHook(inst, hr.operationPreParseHooks)
registerHook(inst, hr.operationPostParseHooks)
registerHook(inst, hr.operationPreNormalizeHooks)
registerHook(inst, hr.operationPostNormalizeHooks)
registerHook(inst, hr.operationPreValidateHooks)
registerHook(inst, hr.operationPostValidateHooks)
registerHook(inst, hr.operationPrePlanHooks)
registerHook(inst, hr.operationPostPlanHooks)
registerHook(inst, hr.operationPreExecuteHooks)
registerHook(inst, hr.operationPostExecuteHooks)
}

135 changes: 135 additions & 0 deletions router/core/modules_v1.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package core

import (
"fmt"
"math"
"sort"
"sync"
"context"
"go.uber.org/zap"
)

type moduleRegistry struct {
mu sync.RWMutex
modules map[string]MyModuleInfo
}
// NewModuleRegistry returns an empty, thread-safe module registry.
// Call this in tests (and anywhere you need isolation) instead of using the global.
func newModuleRegistry() *moduleRegistry {
return &moduleRegistry{
modules: make(map[string]MyModuleInfo),
}
}

// TODO: @kaialang discuss if we should push for dependency injection.
// defaultModuleRegistry is the package-level registry used by RegisterMyModule.
// For unit tests you should use newModuleRegistry() to get a fresh instance and avoid shared state.
var defaultModuleRegistry = newModuleRegistry()


type MyModuleInfo struct {
// ID is the unique identifier for a module, it must be unique across all modules.
ID string
// Priority decideds the order of execution of the module.
// The smaller the number, the higher the priority, the earlier the module is executed.
// For example, a priority of 0 is the highest priority.
// Modules with the same priority are executed in the order they are registered.
// If Priority is nil, the module is considered to have the lowest priority.
Priority *int
// New creates a new instance of the module.
New func() MyModule
}

type MyModule interface {
MyModule() MyModuleInfo
}

// RegisterMyModule registers a new MyModule instance.
// The registration order matters. Modules with the same priority
// are executed in the order they are registered.
// It panics if the module is already registered.
func RegisterMyModule(instance MyModule) {
defaultModuleRegistry.registerMyModule(instance)
}

func (r *moduleRegistry) registerMyModule(instance MyModule) {
m := instance.MyModule()

if m.ID == "" {
panic("MyModule.ID is required")
}
if val := m.New(); val == nil {
panic("MyModuleInfo.New must return a non-nil module instance")
}

r.mu.Lock()
defer r.mu.Unlock()

if _, ok := r.modules[m.ID]; ok {
panic(fmt.Sprintf("MyModule already registered: %s", m.ID))
}
r.modules[m.ID] = m
}

// sortMyModules sorts the modules by priority, 0 is the highest priority, is the first to be executed.
// If two modules have the same priority, they are sorted by registration order.
// If a module has no priority, it is considered to have the lowest priority.
func sortMyModules(modules []MyModuleInfo) []MyModuleInfo {
sort.Slice(modules, func(i, j int) bool {
var priorityI, priorityJ int = math.MaxInt, math.MaxInt
if modules[i].Priority != nil {
priorityI = *modules[i].Priority
}
if modules[j].Priority != nil {
priorityJ = *modules[j].Priority
}

return priorityI < priorityJ
})
return modules
}

// getMyModules returns all registered modules sorted by priority
func (r *moduleRegistry) getMyModules() []MyModuleInfo {
r.mu.RLock()
defer r.mu.RUnlock()

modules := make([]MyModuleInfo, 0, len(r.modules))
for _, m := range r.modules {
modules = append(modules, m)
}
return sortMyModules(modules)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My guess is that this will be called from each hook to get the list of modules to run?

I wonder if we can have a data structure that keeps lifecycle/hook to modules mapping (or update moduleRegistry to have that mapping), and sort the module order on register time so that we don't have to sort on hook runtime.

}

// coreModules manages module initialization and hook registration.
type coreModules struct {
hookRegistry *hookRegistry
logger *zap.Logger
}

// newCoreModules initializes with an empty registry.
func newCoreModules(logger *zap.Logger) *coreModules {
return &coreModules{
hookRegistry: newHookRegistry(),
logger: logger,
}
}

// initMyModules instantiates each module, registers any implemented hooks, and saves the hook registry.
func (c *coreModules) initMyModules(ctx context.Context, modules []MyModuleInfo) error {
hookRegistry := newHookRegistry()

for _, info := range modules {
moduleInstance := info.New()

hookRegistry.AddApplicationLifecycle(moduleInstance)
hookRegistry.AddGraphQLServerLifecycle(moduleInstance)
hookRegistry.AddRouterLifecycle(moduleInstance)
hookRegistry.AddSubgraphLifecycle(moduleInstance)
hookRegistry.AddOperationLifecycle(moduleInstance)
}

c.hookRegistry = hookRegistry

return nil
}
Comment on lines +119 to +135
Copy link

@yuzoonc1 yuzoonc1 May 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that all of my modules will be registered to all lifecycles? doesn't this mean that these modules will run on all hooks and there is no flexibility if I want to register my module on specific hook? Maybe MyModuleInfo should have a field that represents a list of hooks that you want this module to be registered and run on.

Let me know if I am not understanding things correctly.

Loading
Loading