diff --git a/.gitignore b/.gitignore index 3708dea41..f02f5ca7d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ .vscode /vendor coverage.out -.opencode \ No newline at end of file +.opencode +/tmp diff --git a/ai/console/agent_make_command.go b/ai/console/agent_make_command.go index 08e8614a3..f15f42549 100644 --- a/ai/console/agent_make_command.go +++ b/ai/console/agent_make_command.go @@ -33,6 +33,7 @@ func (r *AgentMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the agent even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/ai/console/agent_make_command_test.go b/ai/console/agent_make_command_test.go index 40f5333d7..5309b6f12 100644 --- a/ai/console/agent_make_command_test.go +++ b/ai/console/agent_make_command_test.go @@ -28,7 +28,7 @@ func TestAgentMakeCommand(t *testing.T) { extend := agentMakeCommand.Extend() assert.Equal(t, "make", extend.Category) - if assert.Len(t, extend.Flags, 1) { + if assert.Len(t, extend.Flags, 2) { flag, ok := extend.Flags[0].(*command.BoolFlag) assert.True(t, ok) if ok { @@ -44,17 +44,20 @@ func TestAgentMakeCommand(t *testing.T) { assert.NoError(t, agentMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"UserAgent"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Agent created successfully").Once() assert.NoError(t, agentMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/ai/agents/user_agent.go")) mockContext.EXPECT().Arguments().Return([]string{"UserAgent"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the agent already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, agentMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"user/SupportAgent"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Agent created successfully").Once() assert.NoError(t, agentMakeCommand.Handle(mockContext)) @@ -67,6 +70,7 @@ func TestAgentMakeCommand(t *testing.T) { support.Config.Paths.Agents = "custom/agents" mockContext.EXPECT().Arguments().Return([]string{"BillingAgent"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Agent created successfully").Once() assert.NoError(t, agentMakeCommand.Handle(mockContext)) @@ -81,6 +85,7 @@ func TestAgentMakeCommand_MultipleNames(t *testing.T) { }() mockContext.EXPECT().Arguments().Return([]string{"SupportAgent", "BillingAgent"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Agent created successfully").Times(2) assert.NoError(t, agentMakeCommand.Handle(mockContext)) diff --git a/ai/console/tool_make_command.go b/ai/console/tool_make_command.go index bc55c5d82..70595bf66 100644 --- a/ai/console/tool_make_command.go +++ b/ai/console/tool_make_command.go @@ -34,6 +34,7 @@ func (r *ToolMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the tool even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/ai/console/tool_make_command_test.go b/ai/console/tool_make_command_test.go index a8bd13a17..55a363c9e 100644 --- a/ai/console/tool_make_command_test.go +++ b/ai/console/tool_make_command_test.go @@ -28,7 +28,7 @@ func TestToolMakeCommand(t *testing.T) { extend := toolMakeCommand.Extend() assert.Equal(t, "make", extend.Category) - if assert.Len(t, extend.Flags, 1) { + if assert.Len(t, extend.Flags, 2) { flag, ok := extend.Flags[0].(*command.BoolFlag) assert.True(t, ok) if ok { @@ -44,17 +44,20 @@ func TestToolMakeCommand(t *testing.T) { assert.NoError(t, toolMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"WeatherTool"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Tool created successfully").Once() assert.NoError(t, toolMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/ai/tools/weather_tool.go")) mockContext.EXPECT().Arguments().Return([]string{"WeatherTool"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the tool already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, toolMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"user/WeatherTool"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Tool created successfully").Once() assert.NoError(t, toolMakeCommand.Handle(mockContext)) @@ -71,6 +74,7 @@ func TestToolMakeCommand(t *testing.T) { support.Config.Paths.Tools = "custom/tools" mockContext.EXPECT().Arguments().Return([]string{"CurrencyTool"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Tool created successfully").Once() assert.NoError(t, toolMakeCommand.Handle(mockContext)) @@ -85,6 +89,7 @@ func TestToolMakeCommand_MultipleNames(t *testing.T) { }() mockContext.EXPECT().Arguments().Return([]string{"WeatherTool", "CurrencyTool"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Tool created successfully").Times(2) assert.NoError(t, toolMakeCommand.Handle(mockContext)) diff --git a/auth/console/module_flag_test.go b/auth/console/module_flag_test.go new file mode 100644 index 000000000..1654a35e7 --- /dev/null +++ b/auth/console/module_flag_test.go @@ -0,0 +1,28 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + NewPolicyMakeCommand(), + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/auth/console/policy_make_command.go b/auth/console/policy_make_command.go index 7a07ae5d6..e281fae57 100644 --- a/auth/console/policy_make_command.go +++ b/auth/console/policy_make_command.go @@ -37,6 +37,7 @@ func (r *PolicyMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the policy even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/auth/console/policy_make_command_test.go b/auth/console/policy_make_command_test.go index 8b2813b29..a01c70e7c 100644 --- a/auth/console/policy_make_command_test.go +++ b/auth/console/policy_make_command_test.go @@ -20,17 +20,20 @@ func TestPolicyMakeCommand(t *testing.T) { assert.Nil(t, policyMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"UserPolicy"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Policy created successfully").Once() assert.Nil(t, policyMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/policies/user_policy.go")) mockContext.EXPECT().Arguments().Return([]string{"UserPolicy"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the policy already exists. Use the --force or -f flag to overwrite").Once() assert.Nil(t, policyMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"User/AuthPolicy"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Policy created successfully").Once() assert.Nil(t, policyMakeCommand.Handle(mockContext)) @@ -50,6 +53,7 @@ func TestPolicyMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"PostPolicy", "CommentPolicy"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Policy created successfully").Times(2) assert.Nil(t, policyMakeCommand.Handle(mockContext)) diff --git a/broadcasting/console/channel_make_command.go b/broadcasting/console/channel_make_command.go index 882569090..fa2df69cb 100644 --- a/broadcasting/console/channel_make_command.go +++ b/broadcasting/console/channel_make_command.go @@ -33,6 +33,7 @@ func (r *ChannelMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the channel even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/broadcasting/console/channel_make_command_test.go b/broadcasting/console/channel_make_command_test.go index 2867a3b0b..9253b10b5 100644 --- a/broadcasting/console/channel_make_command_test.go +++ b/broadcasting/console/channel_make_command_test.go @@ -30,6 +30,7 @@ func TestChannelMakeCommand(t *testing.T) { t.Run("success", func(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"TestChannel"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Channel created successfully").Once() assert.Nil(t, cmd.Handle(mockContext)) @@ -51,6 +52,7 @@ func TestChannelMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"OrderChannel", "UserChannel"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Channel created successfully").Times(2) assert.Nil(t, cmd.Handle(mockContext)) diff --git a/broadcasting/console/module_flag_test.go b/broadcasting/console/module_flag_test.go new file mode 100644 index 000000000..be71ac93b --- /dev/null +++ b/broadcasting/console/module_flag_test.go @@ -0,0 +1,28 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + NewChannelMakeCommand(), + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/console/console/make_command.go b/console/console/make_command.go index 594c36b53..d3e3afb23 100644 --- a/console/console/make_command.go +++ b/console/console/make_command.go @@ -46,6 +46,7 @@ func (r *MakeCommand) Extend() command.Extend { Usage: "Create the command even if it already exists", DisableDefaultText: true, }, + supportconsole.ModuleFlag(), }, } } @@ -69,7 +70,9 @@ func (r *MakeCommand) makeOne(ctx console.Context, name string) error { ctx.Success("Console command created successfully") - if env.IsBootstrapSetup() { + if dir := make.GetModuleDir(); dir != "" { + err = modify.AddToModule(dir, "Commands", make.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", make.GetPackageName(), make.GetStructName())) + } else if env.IsBootstrapSetup() { err = modify.AddCommand(make.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", make.GetPackageName(), make.GetStructName())) } else { err = r.registerInKernel(make) diff --git a/console/console/make_command_hook.go b/console/console/make_command_hook.go index e9e161a5a..93d65721e 100644 --- a/console/console/make_command_hook.go +++ b/console/console/make_command_hook.go @@ -48,6 +48,7 @@ func (r *MakeCommandHook) Extend() command.Extend { Aliases: []string{"c"}, Usage: "The command signature or '*' pattern the hook runs for, default is '*'", }, + supportconsole.ModuleFlag(), }, } } diff --git a/console/console/make_command_hook_test.go b/console/console/make_command_hook_test.go index 7df268e44..79921312a 100644 --- a/console/console/make_command_hook_test.go +++ b/console/console/make_command_hook_test.go @@ -118,6 +118,7 @@ func TestMakeCommandHook(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Option("command").Return("").Once() mockContext.EXPECT().Arguments().Return([]string{"AuditLog"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the command hook already exists. Use the --force or -f flag to overwrite").Once() @@ -130,6 +131,7 @@ func TestMakeCommandHook(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Option("command").Return("*").Once() mockContext.EXPECT().Arguments().Return([]string{"AuditLog"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(true).Once() mockContext.EXPECT().Success("Command hook created successfully").Once() mockContext.EXPECT().Warning(mock.Anything).Once() @@ -145,6 +147,7 @@ func TestMakeCommandHook(t *testing.T) { mockContext.EXPECT().Option("command").Return("").Once() mockContext.EXPECT().Arguments().Return([]string{"AuditLog"}).Once() mockContext.EXPECT().Ask("Enter the command signature the hook runs for", mock.Anything).Return("*", nil).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Command hook created successfully").Once() mockContext.EXPECT().Warning("Command hook created but not registered, add it to WithCommandHooks in bootstrap/app.go manually").Once() @@ -168,6 +171,7 @@ func TestMakeCommandHook(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Option("command").Return("migrate").Once() mockContext.EXPECT().Arguments().Return([]string{"AuditLog"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Command hook created successfully").Once() mockContext.EXPECT().Success("Command hook registered successfully").Once() @@ -191,6 +195,7 @@ func TestMakeCommandHook(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Option("command").Return("migrate").Once() mockContext.EXPECT().Arguments().Return([]string{"AuditLog"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Command hook created successfully").Once() mockContext.EXPECT().Success("Command hook registered successfully").Once() @@ -217,6 +222,7 @@ func TestMakeCommandHook(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Option("command").Return("migrate").Once() mockContext.EXPECT().Arguments().Return([]string{"AuditLog"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Command hook created successfully").Once() mockContext.EXPECT().Success("Command hook registered successfully").Once() @@ -242,6 +248,7 @@ func TestMakeCommandHook(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Option("command").Return("migrate").Once() mockContext.EXPECT().Arguments().Return([]string{"AuditLog"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Command hook created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(message string) bool { @@ -259,6 +266,7 @@ func TestMakeCommandHook(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Option("command").Return("migrate").Once() mockContext.EXPECT().Arguments().Return([]string{"AuditLog", "SlowQuery"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Command hook created successfully").Times(2) mockContext.EXPECT().Success("Command hook registered successfully").Times(2) diff --git a/console/console/make_command_test.go b/console/console/make_command_test.go index 5b0e690d2..9d23305fc 100644 --- a/console/console/make_command_test.go +++ b/console/console/make_command_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/mock" mocksconsole "github.com/goravel/framework/mocks/console" + "github.com/goravel/framework/modular" "github.com/goravel/framework/support/file" ) @@ -70,6 +71,7 @@ func (kernel Kernel) Schedule() []schedule.Event { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"CleanCache"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Console command created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -85,6 +87,7 @@ func (kernel Kernel) Schedule() []schedule.Event { t.Run("command already exists", func(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"CleanCache"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the command already exists. Use the --force or -f flag to overwrite").Once() assert.Nil(t, makeCommand.Handle(mockContext)) @@ -95,6 +98,7 @@ func (kernel Kernel) Schedule() []schedule.Event { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"Goravel/CleanCache"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Console command created successfully").Once() mockContext.EXPECT().Success("Console command registered successfully").Once() @@ -123,6 +127,7 @@ func TestMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"Goravel/CleanCache", "Bar/SyncUsers"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Console command created successfully").Times(2) mockContext.EXPECT().Success("Console command registered successfully").Times(2) @@ -166,6 +171,7 @@ func Boot() contractsfoundation.Application { // Create mock context mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"CleanCache"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Console command created successfully").Once() mockContext.EXPECT().Success("Console command registered successfully").Once() @@ -218,3 +224,52 @@ func Commands() []console.Command { ` assert.Equal(t, expectedCommandsContent, commandsContent) } + +// blogModule sets its own directory, so it is a module of the application. +type blogModule struct{} + +func (r *blogModule) Name() string { return "blog" } +func (r *blogModule) Path() string { return "modules/blog" } + +// useBlogModule registers blogModule with a module.go on disk for the test. +func useBlogModule(t *testing.T) { + registry := modular.NewRegistry("") + assert.NoError(t, registry.Register(&blogModule{})) + modular.SetDefault(registry) + assert.NoError(t, file.PutContent("modules/blog/module.go", `package blog + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} +`)) + + t.Cleanup(func() { + modular.SetDefault(nil) + assert.NoError(t, file.Remove("modules")) + }) +} + +func TestMakeCommand_Module(t *testing.T) { + useBlogModule(t) + + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Arguments().Return([]string{"SyncPosts"}).Once() + mockContext.EXPECT().Option("module").Return("blog").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Success("Console command created successfully").Once() + mockContext.EXPECT().Success("Console command registered successfully").Once() + + assert.NoError(t, NewMakeCommand().Handle(mockContext)) + assert.True(t, file.Exists("modules/blog/console/commands/sync_posts.go")) + + content, err := file.GetContent("modules/blog/module.go") + assert.NoError(t, err) + assert.Contains(t, content, `/modules/blog/console/commands"`) + assert.Contains(t, content, `func (m *Module) Commands() []console.Command { + return []console.Command{ + &commands.SyncPosts{}, + } +}`) +} diff --git a/console/console/module_flag_test.go b/console/console/module_flag_test.go new file mode 100644 index 000000000..324052918 --- /dev/null +++ b/console/console/module_flag_test.go @@ -0,0 +1,29 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + NewMakeCommand(), + NewMakeCommandHook(), + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/contracts/binding/binding.go b/contracts/binding/binding.go index 9064b970f..420d71d12 100644 --- a/contracts/binding/binding.go +++ b/contracts/binding/binding.go @@ -17,6 +17,7 @@ const ( Lang = "goravel.lang" Log = "goravel.log" Mail = "goravel.mail" + Modular = "goravel.modular" Notification = "goravel.notification" Orm = "goravel.orm" Process = "goravel.process" diff --git a/contracts/database/migration/migrator.go b/contracts/database/migration/migrator.go index 4a927964e..00512ec36 100644 --- a/contracts/database/migration/migrator.go +++ b/contracts/database/migration/migrator.go @@ -12,8 +12,8 @@ type Status struct { } type Migrator interface { - // Create a new migration file. - Create(name string, modelName string) (string, error) + // Create a new migration file, in the joined dir (relative to the project root) if given, else the migrations path. + Create(name string, modelName string, dir ...string) (string, error) // Fresh drops all tables and re-runs all migrations from scratch. Fresh() error // Reset the migrations. diff --git a/contracts/facades/facades.go b/contracts/facades/facades.go index b12255e31..401593099 100644 --- a/contracts/facades/facades.go +++ b/contracts/facades/facades.go @@ -19,6 +19,7 @@ const ( Lang = "Lang" Log = "Log" Mail = "Mail" + Modular = "Modular" Notification = "Notification" Process = "Process" Orm = "Orm" @@ -54,6 +55,7 @@ var FacadeToBinding = map[string]string{ Lang: binding.Lang, Log: binding.Log, Mail: binding.Mail, + Modular: binding.Modular, Notification: binding.Notification, Orm: binding.Orm, Process: binding.Process, diff --git a/contracts/foundation/application.go b/contracts/foundation/application.go index 11a90909e..a667e8124 100644 --- a/contracts/foundation/application.go +++ b/contracts/foundation/application.go @@ -23,6 +23,7 @@ import ( "github.com/goravel/framework/contracts/http/client" "github.com/goravel/framework/contracts/log" "github.com/goravel/framework/contracts/mail" + "github.com/goravel/framework/contracts/modular" "github.com/goravel/framework/contracts/notification" "github.com/goravel/framework/contracts/process" "github.com/goravel/framework/contracts/queue" @@ -180,6 +181,8 @@ type Application interface { MakeLog() log.Log // MakeMail resolves the mail instance. MakeMail() mail.Mail + // MakeModular resolves the module registry; nil without foundation.Setup(). + MakeModular() modular.Registry // MakeNotification resolves the notification instance. MakeNotification() notification.Manager // MakeOrm resolves the orm instance. diff --git a/contracts/foundation/application_builder.go b/contracts/foundation/application_builder.go index 4cd2b2802..eeaaa60c1 100644 --- a/contracts/foundation/application_builder.go +++ b/contracts/foundation/application_builder.go @@ -12,6 +12,7 @@ import ( "github.com/goravel/framework/contracts/database/seeder" "github.com/goravel/framework/contracts/event" "github.com/goravel/framework/contracts/foundation/configuration" + "github.com/goravel/framework/contracts/modular" "github.com/goravel/framework/contracts/queue" "github.com/goravel/framework/contracts/schedule" "github.com/goravel/framework/contracts/validation" @@ -72,6 +73,9 @@ type ApplicationBuilder interface { WithMiddleware(func(handler configuration.Middleware)) ApplicationBuilder // WithMigrations registers the database migrations. WithMigrations(func() []schema.Migration) ApplicationBuilder + // WithModules registers the modules (bootstrap/modules.go). Their contributions + // are merged into each Build() step after the application's own. + WithModules(func() []modular.Module) ApplicationBuilder // WithPaths sets custom paths for the application. WithPaths(func(paths configuration.Paths)) ApplicationBuilder // WithProviders registers and boots custom service providers. diff --git a/contracts/foundation/module.go b/contracts/foundation/module.go new file mode 100644 index 000000000..5bc761883 --- /dev/null +++ b/contracts/foundation/module.go @@ -0,0 +1,7 @@ +package foundation + +// ProvidesServices is a module capability, declared here to keep contracts/modular +// free of this package. +type ProvidesServices interface { + ServiceProviders() []ServiceProvider +} diff --git a/contracts/modular/modular.go b/contracts/modular/modular.go new file mode 100644 index 000000000..f337b60ca --- /dev/null +++ b/contracts/modular/modular.go @@ -0,0 +1,145 @@ +package modular + +import ( + "io/fs" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/database/schema" + "github.com/goravel/framework/contracts/database/seeder" + "github.com/goravel/framework/contracts/event" + "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/contracts/queue" + "github.com/goravel/framework/contracts/route" + "github.com/goravel/framework/contracts/schedule" +) + +// Module is an application feature in one Go package. Its capabilities are the +// optional interfaces below it implements; providers come from +// foundation.ProvidesServices. +type Module interface { + // Name is unique and kebab-case, e.g. "blog": views "blog/...", translation + // groups "blog/...", config "modules.blog", Vite alias "@blog". + Name() string +} + +// Located sets the module directory, relative to the project root. Without it, +// the directory is derived from the module's Go package (see Registry.Register). +type Located interface { + Path() string +} + +// Ordered sets the module order: lower first, negative last, ties by registration. +// The default is 0. +type Ordered interface { + Order() int +} + +// ProvidesConfig contributes the config stored under "modules.". +type ProvidesConfig interface { + Config() map[string]any +} + +// ProvidesGlobalMiddleware contributes global HTTP middleware, added before WithMiddleware runs. +type ProvidesGlobalMiddleware interface { + GlobalMiddleware() []http.Middleware +} + +// ProvidesRoutes registers web routes in the modular.routes.web group. +type ProvidesRoutes interface { + Routes(router route.Router) +} + +// ProvidesApiRoutes registers API routes in the modular.routes.api group. +type ProvidesApiRoutes interface { + ApiRoutes(router route.Router) +} + +// ProvidesMigrations contributes database migrations. +type ProvidesMigrations interface { + Migrations() []schema.Migration +} + +// ProvidesSeeders contributes database seeders. +type ProvidesSeeders interface { + Seeders() []seeder.Seeder +} + +// ProvidesCommands contributes Artisan commands. +type ProvidesCommands interface { + Commands() []console.Command +} + +// ProvidesJobs contributes queue jobs. +type ProvidesJobs interface { + Jobs() []queue.Job +} + +// ProvidesEvents contributes listeners, merged per event with WithEvents. +type ProvidesEvents interface { + Events() map[event.Event][]event.Listener +} + +// ProvidesSchedule contributes scheduled events. +type ProvidesSchedule interface { + Schedule() []schedule.Event +} + +// ProvidesViews contributes views: "index.tmpl" becomes "/index.tmpl". +type ProvidesViews interface { + Views() fs.FS +} + +// ProvidesTranslations contributes translations: "en/messages.json" is the group +// "/messages". The "*" group (en.json) is not supported. +type ProvidesTranslations interface { + Translations() fs.FS +} + +// ProvidesViteEntries lists Vite entries relative to the module directory, built +// even when no view references them. +type ProvidesViteEntries interface { + ViteEntries() []string +} + +// ProvidesViteAlias contributes Vite aliases, e.g. "@blog-assets" -> "resources/assets" +// (relative to the module directory). Its own "@" key replaces the default. +type ProvidesViteAlias interface { + ViteAliases() map[string]string +} + +// ProvidesSources ships files of a third-party module that modular:publish copies +// into its directory for Vite and Tailwind, e.g. "resources/js/app.js". Go files +// (*.go, go.mod, go.sum) are skipped. +type ProvidesSources interface { + Sources() fs.FS +} + +// ProvidesStatic serves files as-is: URL prefix -> Path. +type ProvidesStatic interface { + Static() map[string]Path +} + +// Path is a static directory: Dir inside FS, or on disk relative to the module +// directory when FS is nil. Dir must stay inside the module. +type Path struct { + FS fs.FS + Dir string +} + +// Registry holds the application's modules. +type Registry interface { + // Register adds modules and resolves their directories: Located, else the + // package path inside the app, else "/" for another Go + // module. Duplicate names and unresolvable directories are errors. + Register(modules ...Module) error + // All returns the modules sorted by Order. + All() []Module + // Find returns the module named name. + Find(name string) (Module, bool) + // Has reports whether name is registered. + Has(name string) bool + // Path returns a module's directory relative to the project root. + Path(name string) (string, bool) + // Capabilities reports which optional interfaces the module implements, by label. + Capabilities(module Module) map[string]bool +} diff --git a/database/console/factory_make_command.go b/database/console/factory_make_command.go index 36215950c..e918e457f 100644 --- a/database/console/factory_make_command.go +++ b/database/console/factory_make_command.go @@ -37,6 +37,7 @@ func (r *FactoryMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the factory even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/database/console/factory_make_command_test.go b/database/console/factory_make_command_test.go index 924a812d0..e31acb722 100644 --- a/database/console/factory_make_command_test.go +++ b/database/console/factory_make_command_test.go @@ -20,6 +20,7 @@ func TestFactoryMakeCommand(t *testing.T) { assert.NoError(t, factoryMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"UserFactory"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Factory created successfully").Once() assert.NoError(t, factoryMakeCommand.Handle(mockContext)) @@ -28,12 +29,14 @@ func TestFactoryMakeCommand(t *testing.T) { assert.True(t, file.Contain("database/factories/user_factory.go", "type UserFactory struct")) mockContext.EXPECT().Arguments().Return([]string{"UserFactory"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the factory already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, factoryMakeCommand.Handle(mockContext)) assert.NoError(t, file.Remove("database")) mockContext.EXPECT().Arguments().Return([]string{"subdir/DemoFactory"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Factory created successfully").Once() assert.NoError(t, factoryMakeCommand.Handle(mockContext)) @@ -48,6 +51,7 @@ func TestFactoryMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"UserFactory", "PostFactory"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Factory created successfully").Times(2) assert.NoError(t, factoryMakeCommand.Handle(mockContext)) diff --git a/database/console/migration/migrate_make_command.go b/database/console/migration/migrate_make_command.go index 361fe0a87..6e22218c3 100644 --- a/database/console/migration/migrate_make_command.go +++ b/database/console/migration/migrate_make_command.go @@ -45,6 +45,7 @@ func (r *MigrateMakeCommand) Extend() command.Extend { Aliases: []string{"m"}, Usage: "The model name to use for generating the migration schema", }, + supportconsole.ModuleFlag(), }, } } @@ -69,7 +70,14 @@ func (r *MigrateMakeCommand) makeOne(ctx console.Context, name, modelName string return err } - fileName, err := r.migrator.Create(makeMigration.GetName(), modelName) + moduleDir := makeMigration.GetModuleDir() + + var dir []string + if moduleDir != "" { + dir = append(dir, supportconsole.ModuleRoot(moduleDir, support.Config.Paths.Migrations)) + } + + fileName, err := r.migrator.Create(makeMigration.GetName(), modelName, dir...) if err != nil { return errors.MigrationCreateFailed.Args(err) } @@ -77,7 +85,9 @@ func (r *MigrateMakeCommand) makeOne(ctx console.Context, name, modelName string ctx.Success(fmt.Sprintf("Created Migration: %s", makeMigration.GetName())) structName := str.Of(fileName).Prepend("m_").Studly().String() - if env.IsBootstrapSetup() { + if moduleDir != "" { + err = modify.AddToModule(moduleDir, "Migrations", makeMigration.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", makeMigration.GetPackageName(), structName)) + } else if env.IsBootstrapSetup() { err = modify.AddMigration(makeMigration.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", makeMigration.GetPackageName(), structName)) } else { err = r.registerInKernel(makeMigration.GetPackageImportPath(), structName) diff --git a/database/console/migration/migrate_make_command_test.go b/database/console/migration/migrate_make_command_test.go index 2df1933b2..7e7685a48 100644 --- a/database/console/migration/migrate_make_command_test.go +++ b/database/console/migration/migrate_make_command_test.go @@ -11,6 +11,8 @@ import ( mocksconsole "github.com/goravel/framework/mocks/console" mocksmigration "github.com/goravel/framework/mocks/database/migration" mocksfoundation "github.com/goravel/framework/mocks/foundation" + "github.com/goravel/framework/modular" + "github.com/goravel/framework/support/env" "github.com/goravel/framework/support/file" ) @@ -36,6 +38,7 @@ func TestMigrateMakeCommand(t *testing.T) { setup: func() { mockContext.EXPECT().Arguments().Return([]string{""}).Once() mockContext.EXPECT().Ask("Enter the migration name", mock.Anything).Return("create_users_table", nil).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Option("model").Return("").Once() mockMigrator.EXPECT().Create("create_users_table", "").Return("20240915060148_create_users_table", nil).Once() @@ -50,6 +53,7 @@ func TestMigrateMakeCommand(t *testing.T) { name: "Happy path - name is not empty", setup: func() { mockContext.EXPECT().Arguments().Return([]string{"create_users_table"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Option("model").Return("").Once() mockMigrator.EXPECT().Create("create_users_table", "").Return("20240915060148_create_users_table", nil).Once() @@ -65,6 +69,7 @@ func TestMigrateMakeCommand(t *testing.T) { setup: func() { mockContext.EXPECT().Option("model").Return("Product").Once() mockContext.EXPECT().Arguments().Return([]string{"create_products_table"}).Times(2) + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockMigrator.EXPECT().Create("create_products_table", "Product").Return("20240915060148_create_products_table", nil).Once() mockContext.EXPECT().Success("Created Migration: create_products_table").Once() @@ -87,6 +92,7 @@ func TestMigrateMakeCommand(t *testing.T) { name: "Sad path - failed to create", setup: func() { mockContext.EXPECT().Arguments().Return([]string{"create_users_table"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Option("model").Return("").Once() mockMigrator.EXPECT().Create("create_users_table", "").Return("", assert.AnError).Once() @@ -98,6 +104,7 @@ func TestMigrateMakeCommand(t *testing.T) { setup: func() { mockContext.EXPECT().Option("model").Return("NonExistentModel").Once() mockContext.EXPECT().Arguments().Return([]string{"create_products_table"}).Times(2) + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockMigrator.EXPECT().Create("create_products_table", "NonExistentModel").Return("", errors.SchemaModelNotFound.Args("NonExistentModel")).Once() mockContext.EXPECT().Error(errors.MigrationCreateFailed.Args(errors.SchemaModelNotFound.Args("NonExistentModel")).Error()).Once() @@ -107,6 +114,7 @@ func TestMigrateMakeCommand(t *testing.T) { name: "Register success", setup: func() { mockContext.EXPECT().Arguments().Return([]string{"create_users_table"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Option("model").Return("").Once() mockMigrator.EXPECT().Create("create_users_table", "").Return("20240915060148_create_users_table", nil).Once() @@ -188,6 +196,7 @@ func Boot() contractsfoundation.Application { mockApp := mocksfoundation.NewApplication(t) mockContext.EXPECT().Arguments().Return([]string{"create_posts_table"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Option("model").Return("").Once() mockMigrator.EXPECT().Create("create_posts_table", "").Return("20240915060148_create_posts_table", nil).Once() @@ -224,6 +233,7 @@ func Boot() { mockApp := mocksfoundation.NewApplication(t) mockContext.EXPECT().Arguments().Return([]string{"create_comments_table"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Option("model").Return("").Once() mockMigrator.EXPECT().Create("create_comments_table", "").Return("20240915060149_create_comments_table", nil).Once() @@ -239,6 +249,53 @@ func Boot() { }) } +// blogModule sets its own directory, so it is a module of the application. +type blogModule struct{} + +func (r *blogModule) Name() string { return "blog" } +func (r *blogModule) Path() string { return "modules/blog" } + +func TestMigrateMakeCommand_Module(t *testing.T) { + registry := modular.NewRegistry("") + assert.NoError(t, registry.Register(&blogModule{})) + modular.SetDefault(registry) + defer modular.SetDefault(nil) + defer func() { + assert.NoError(t, file.Remove("modules")) + }() + + assert.NoError(t, file.PutContent("modules/blog/module.go", `package blog + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} +`)) + + mockContext := mocksconsole.NewContext(t) + mockMigrator := mocksmigration.NewMigrator(t) + + mockContext.EXPECT().Arguments().Return([]string{"create_posts_table"}).Once() + mockContext.EXPECT().Option("model").Return("").Once() + mockContext.EXPECT().Option("module").Return("blog").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockMigrator.EXPECT().Create("create_posts_table", "", "modules/blog/database/migrations").Return("20240915060148_create_posts_table", nil).Once() + mockContext.EXPECT().Success("Created Migration: create_posts_table").Once() + mockContext.EXPECT().Success("Migration registered successfully").Once() + + assert.NoError(t, NewMigrateMakeCommand(mocksfoundation.NewApplication(t), mockMigrator).Handle(mockContext)) + + content, err := file.GetContent("modules/blog/module.go") + assert.NoError(t, err) + assert.Contains(t, content, env.MainPath()+"/modules/blog/database/migrations") + assert.Contains(t, content, `func (m *Module) Migrations() []schema.Migration { + return []schema.Migration{ + &migrations.M20240915060148CreatePostsTable{}, + } +}`) +} + func TestMigrateMakeCommand_MultipleNames(t *testing.T) { t.Cleanup(func() { _ = file.Remove("database") @@ -265,6 +322,7 @@ func (kernel Kernel) Migrations() []schema.Migration { mockContext.EXPECT().Option("model").Return("").Once() mockContext.EXPECT().Arguments().Return([]string{"create_users_table", "create_posts_table"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockMigrator.EXPECT().Create("create_users_table", "").Return("20240915060148_create_users_table", nil).Once() mockMigrator.EXPECT().Create("create_posts_table", "").Return("20240915060149_create_posts_table", nil).Once() diff --git a/database/console/migration/module_flag_test.go b/database/console/migration/module_flag_test.go new file mode 100644 index 000000000..8fc951a2b --- /dev/null +++ b/database/console/migration/module_flag_test.go @@ -0,0 +1,28 @@ +package migration + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + NewMigrateMakeCommand(nil, nil), + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/database/console/model_make_command.go b/database/console/model_make_command.go index 79fc0e63e..d24d48cde 100644 --- a/database/console/model_make_command.go +++ b/database/console/model_make_command.go @@ -67,6 +67,7 @@ func (r *ModelMakeCommand) Extend() command.Extend { Aliases: []string{"t"}, Usage: "Create the model from existing table schema", }, + supportconsole.ModuleFlag(), }, } } diff --git a/database/console/model_make_command_test.go b/database/console/model_make_command_test.go index bbb1a2ddb..5732100b5 100644 --- a/database/console/model_make_command_test.go +++ b/database/console/model_make_command_test.go @@ -33,6 +33,7 @@ func TestModelMakeCommand(t *testing.T) { // Test: Create model successfully mockContext.EXPECT().Arguments().Return([]string{"User"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Option("table").Return("").Once() mockContext.EXPECT().Success("Model created successfully").Once() @@ -57,12 +58,14 @@ type User struct { // Test: Model already exists mockContext.EXPECT().Option("table").Return("").Once() mockContext.EXPECT().Arguments().Return([]string{"User"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the model already exists. Use the --force or -f flag to overwrite").Once() assert.Nil(t, modelMakeCommand.Handle(mockContext)) // Test: Create model in subdirectory mockContext.EXPECT().Arguments().Return([]string{"User/Phone"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Option("table").Return("").Once() mockContext.EXPECT().Success("Model created successfully").Once() @@ -89,6 +92,7 @@ type Phone struct { // Test: Create model from table schema mockContext.EXPECT().Option("table").Return("products").Once() mockContext.EXPECT().Arguments().Return([]string{"Product"}).Times(2) + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() // Table exists @@ -556,6 +560,7 @@ func TestModelMakeCommand_MultipleNames(t *testing.T) { mockContext.EXPECT().Option("table").Return("").Once() mockContext.EXPECT().Arguments().Return([]string{"User", "Post"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Model created successfully").Times(2) assert.Nil(t, modelMakeCommand.Handle(mockContext)) @@ -598,12 +603,14 @@ func TestModelMakeCommand_MultipleNamesPartialFailure(t *testing.T) { mockContext.EXPECT().Option("table").Return("").Once() mockContext.EXPECT().Arguments().Return([]string{"User"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Model created successfully").Once() assert.Nil(t, modelMakeCommand.Handle(mockContext)) mockContext.EXPECT().Option("table").Return("").Once() mockContext.EXPECT().Arguments().Return([]string{"User", "Post"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Error("the model already exists. Use the --force or -f flag to overwrite").Once() mockContext.EXPECT().Success("Model created successfully").Once() diff --git a/database/console/module_flag_test.go b/database/console/module_flag_test.go new file mode 100644 index 000000000..672a4c69c --- /dev/null +++ b/database/console/module_flag_test.go @@ -0,0 +1,31 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + NewFactoryMakeCommand(), + NewModelMakeCommand(nil, nil), + NewObserverMakeCommand(), + NewSeederMakeCommand(nil), + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/database/console/observer_make_command.go b/database/console/observer_make_command.go index caa6f753e..9f9cc00ea 100644 --- a/database/console/observer_make_command.go +++ b/database/console/observer_make_command.go @@ -37,6 +37,7 @@ func (r *ObserverMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the observer even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/database/console/observer_make_command_test.go b/database/console/observer_make_command_test.go index d7a3b185f..a58e2f0ec 100644 --- a/database/console/observer_make_command_test.go +++ b/database/console/observer_make_command_test.go @@ -21,17 +21,20 @@ func TestObserverMakeCommand(t *testing.T) { assert.False(t, file.Exists("app/observers/user_observer.go")) mockContext.EXPECT().Arguments().Return([]string{"UserObserver"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Observer created successfully").Once() assert.Nil(t, observerMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/observers/user_observer.go")) mockContext.EXPECT().Arguments().Return([]string{"UserObserver"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the observer already exists. Use the --force or -f flag to overwrite").Once() assert.Nil(t, observerMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"User/PhoneObserver"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Observer created successfully").Once() assert.Nil(t, observerMakeCommand.Handle(mockContext)) @@ -51,6 +54,7 @@ func TestObserverMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"UserObserver", "PostObserver"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Observer created successfully").Times(2) assert.NoError(t, observerMakeCommand.Handle(mockContext)) diff --git a/database/console/seeder_make_command.go b/database/console/seeder_make_command.go index 9a140d9bf..b44bf4d4b 100644 --- a/database/console/seeder_make_command.go +++ b/database/console/seeder_make_command.go @@ -46,6 +46,7 @@ func (r *SeederMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the seeder even if it already exists", }, + supportconsole.ModuleFlag(), }, } } @@ -69,7 +70,9 @@ func (r *SeederMakeCommand) makeOne(ctx console.Context, name string) error { ctx.Success("Seeder created successfully") - if env.IsBootstrapSetup() { + if dir := make.GetModuleDir(); dir != "" { + err = modify.AddToModule(dir, "Seeders", make.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", make.GetPackageName(), make.GetStructName())) + } else if env.IsBootstrapSetup() { err = modify.AddSeeder(make.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", make.GetPackageName(), make.GetStructName())) } else { err = r.registerInKernel(make) diff --git a/database/console/seeder_make_command_test.go b/database/console/seeder_make_command_test.go index c5ae47244..2aad6d914 100644 --- a/database/console/seeder_make_command_test.go +++ b/database/console/seeder_make_command_test.go @@ -10,6 +10,7 @@ import ( mocksconsole "github.com/goravel/framework/mocks/console" mocksfoundation "github.com/goravel/framework/mocks/foundation" + "github.com/goravel/framework/modular" "github.com/goravel/framework/support/file" ) @@ -42,6 +43,7 @@ func TestSeederMakeCommand(t *testing.T) { assert.Nil(t, seederMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"UserSeeder"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockApp.EXPECT().DatabasePath("kernel.go").Return("database/kernel.go").Once() mockContext.EXPECT().Success("Seeder created successfully").Once() @@ -55,12 +57,14 @@ func TestSeederMakeCommand(t *testing.T) { assert.True(t, file.Contain("database/kernel.go", "&seeders.UserSeeder{}")) mockContext.EXPECT().Arguments().Return([]string{"UserSeeder"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the seeder already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, seederMakeCommand.Handle(mockContext)) assert.NoError(t, file.Remove("database")) mockContext.EXPECT().Arguments().Return([]string{"subdir/DemoSeeder"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Seeder created successfully").Once() mockApp.EXPECT().DatabasePath("kernel.go").Return("database/kernel.go").Once() @@ -108,6 +112,7 @@ func Boot() contractsfoundation.Application { mockApp = mocksfoundation.NewApplication(t) mockContext.EXPECT().Arguments().Return([]string{"UserSeeder"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Seeder created successfully").Once() mockContext.EXPECT().Success("Seeder registered successfully").Once() @@ -145,6 +150,7 @@ func Boot() { mockApp = mocksfoundation.NewApplication(t) mockContext.EXPECT().Arguments().Return([]string{"PostSeeder"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Seeder created successfully").Once() mockContext.EXPECT().Warning(mock.MatchedBy(func(msg string) bool { @@ -173,6 +179,7 @@ func TestSeederMakeCommand_MultipleNames(t *testing.T) { assert.NoError(t, file.PutContent("database/kernel.go", databaseKernel)) mockContext.EXPECT().Arguments().Return([]string{"UserSeeder", "PostSeeder"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockApp.EXPECT().DatabasePath("kernel.go").Return("database/kernel.go").Times(2) mockContext.EXPECT().Success("Seeder created successfully").Times(2) @@ -184,3 +191,52 @@ func TestSeederMakeCommand_MultipleNames(t *testing.T) { assert.True(t, file.Contain("database/kernel.go", "&seeders.UserSeeder{}")) assert.True(t, file.Contain("database/kernel.go", "&seeders.PostSeeder{}")) } + +// blogModule sets its own directory, so it is a module of the application. +type blogModule struct{} + +func (r *blogModule) Name() string { return "blog" } +func (r *blogModule) Path() string { return "modules/blog" } + +// useBlogModule registers blogModule with a module.go on disk for the test. +func useBlogModule(t *testing.T) { + registry := modular.NewRegistry("") + assert.NoError(t, registry.Register(&blogModule{})) + modular.SetDefault(registry) + assert.NoError(t, file.PutContent("modules/blog/module.go", `package blog + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} +`)) + + t.Cleanup(func() { + modular.SetDefault(nil) + assert.NoError(t, file.Remove("modules")) + }) +} + +func TestSeederMakeCommand_Module(t *testing.T) { + useBlogModule(t) + + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Arguments().Return([]string{"PostSeeder"}).Once() + mockContext.EXPECT().Option("module").Return("blog").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Success("Seeder created successfully").Once() + mockContext.EXPECT().Success("Seeder registered successfully").Once() + + assert.NoError(t, NewSeederMakeCommand(mocksfoundation.NewApplication(t)).Handle(mockContext)) + assert.True(t, file.Exists("modules/blog/database/seeders/post_seeder.go")) + + content, err := file.GetContent("modules/blog/module.go") + assert.NoError(t, err) + assert.Contains(t, content, `/modules/blog/database/seeders"`) + assert.Contains(t, content, `func (m *Module) Seeders() []seeder.Seeder { + return []seeder.Seeder{ + &seeders.PostSeeder{}, + } +}`) +} diff --git a/database/migration/migrator.go b/database/migration/migrator.go index 28a5d3115..73c21b223 100644 --- a/database/migration/migrator.go +++ b/database/migration/migrator.go @@ -2,6 +2,7 @@ package migration import ( "fmt" + "path/filepath" "slices" "github.com/goravel/framework/contracts/console" @@ -14,6 +15,7 @@ import ( "github.com/goravel/framework/support/color" "github.com/goravel/framework/support/env" supportfile "github.com/goravel/framework/support/file" + "github.com/goravel/framework/support/path" "github.com/goravel/framework/support/str" ) @@ -33,7 +35,8 @@ func NewMigrator(artisan console.Artisan, schema contractsschema.Schema, table s } } -func (r *Migrator) Create(name string, modelName string) (string, error) { +// Create writes the migration to the joined dir instead of the migrations path when given. +func (r *Migrator) Create(name string, modelName string, dir ...string) (string, error) { table, create := TableGuesser{}.Guess(name) var schemaFields []string @@ -54,15 +57,21 @@ func (r *Migrator) Create(name string, modelName string) (string, error) { // Prepend timestamp to the file name. fileName := r.creator.GetFileName(name) - facadesImport := packages.Paths().Facades().Import() - if !env.IsBootstrapSetup() { - facadesImport = "github.com/goravel/framework/facades" + // The app's facades only when its Schema facade exists, else the generated file would not compile. + facadesImport, facadesPackage := packages.Paths().Facades().Import(), packages.Paths().Facades().Package() + if !env.IsBootstrapSetup() || !supportfile.Exists(path.Facade("schema.go")) { + facadesImport, facadesPackage = "github.com/goravel/framework/facades", "facades" + } + + filePath, pkg := r.creator.GetPath(fileName), packages.Paths().Migrations().Package() + if d := filepath.Join(dir...); d != "" { + filePath, pkg = path.Base(d, fileName+".go"), filepath.Base(d) } templateData := StubData{ - FacadesPackage: packages.Paths().Facades().Package(), + FacadesPackage: facadesPackage, FacadesImport: facadesImport, - Package: packages.Paths().Migrations().Package(), + Package: pkg, SchemaFields: schemaFields, Signature: fileName, StructName: str.Of(fileName).Prepend("m_").Studly().String(), @@ -74,7 +83,7 @@ func (r *Migrator) Create(name string, modelName string) (string, error) { return "", err } - if err := supportfile.PutContent(r.creator.GetPath(fileName), content); err != nil { + if err := supportfile.PutContent(filePath, content); err != nil { return "", err } diff --git a/database/migration/migrator_test.go b/database/migration/migrator_test.go index 93e6acf0c..91a55367a 100644 --- a/database/migration/migrator_test.go +++ b/database/migration/migrator_test.go @@ -18,6 +18,7 @@ import ( mocksmigration "github.com/goravel/framework/mocks/database/migration" mocksorm "github.com/goravel/framework/mocks/database/orm" mocksschema "github.com/goravel/framework/mocks/database/schema" + "github.com/goravel/framework/support" "github.com/goravel/framework/support/carbon" "github.com/goravel/framework/support/color" "github.com/goravel/framework/support/file" @@ -68,6 +69,51 @@ func (s *MigratorSuite) TestCreate() { s.True(file.Exists(filepath.Join(path, "20240817214501_"+name+".go"))) }) + s.Run("in a directory", func() { + defer func() { + s.NoError(file.Remove("modules")) + }() + + name := "create_posts_table" + fileName, err := s.migrator.Create(name, "", "modules/blog/database/migrations") + s.NoError(err) + s.Equal("20240817214501_"+name, fileName) + s.True(file.Contain(filepath.Join(pwd, "modules", "blog", "database", "migrations", fileName+".go"), "package migrations")) + s.False(file.Exists(filepath.Join(path, fileName+".go"))) + }) + + s.Run("facades import", func() { + defer func() { + s.NoError(file.Remove("bootstrap")) + s.NoError(file.Remove("app")) + s.NoError(file.Remove("modules")) + }() + s.NoError(file.PutContent(filepath.Join("bootstrap", "app.go"), "package bootstrap\n\nfunc Boot() { foundation.Setup().Create() }\n")) + + // Without the app's Schema facade the generated file falls back to the framework's facades. + fileName, err := s.migrator.Create("create_posts_table", "", "modules/blog/database/migrations") + s.NoError(err) + migration := filepath.Join(pwd, "modules", "blog", "database", "migrations", fileName+".go") + s.True(file.Contain(migration, `"github.com/goravel/framework/facades"`)) + s.NoError(file.Remove(migration)) + + // The package name falls back with the import, also when the app's is named differently. + facadesPath := support.Config.Paths.Facades + support.Config.Paths.Facades = "app/support/f" + fileName, err = s.migrator.Create("create_posts_table", "", "modules/blog/database/migrations") + support.Config.Paths.Facades = facadesPath + s.NoError(err) + migration = filepath.Join(pwd, "modules", "blog", "database", "migrations", fileName+".go") + s.True(file.Contain(migration, "facades.Schema()")) + s.False(file.Contain(migration, "f.Schema()")) + s.NoError(file.Remove(migration)) + + s.NoError(file.PutContent(filepath.Join("app", "facades", "schema.go"), "package facades\n")) + fileName, err = s.migrator.Create("create_posts_table", "", "modules/blog", "database/migrations") + s.NoError(err) + s.True(file.Contain(filepath.Join(pwd, "modules", "blog", "database", "migrations", fileName+".go"), `/app/facades"`)) + }) + s.Run("model not found", func() { s.mockSchema.EXPECT().GetModel("NonExistentModel").Return(nil).Once() diff --git a/errors/list.go b/errors/list.go index a60435388..b5f906c7d 100644 --- a/errors/list.go +++ b/errors/list.go @@ -220,6 +220,33 @@ var ( MigrationResetFailed = New("migration reset failed: %v") MigrationRollbackFailed = New("migration rollback failed: %v") + ModularModuleDuplicate = New("module %q is registered more than once").SetModule(ModuleModular) + ModularModuleExists = New("module %s already exists at %s, use --force to overwrite it").SetModule(ModuleModular) + ModularModuleImportConflict = New("module %s: package name %q is already used by the import %q in bootstrap/modules.go, choose another name").SetModule(ModuleModular) + ModularModuleNameEmpty = New("the module name cannot be empty").SetModule(ModuleModular) + ModularModuleNameForbidden = New("module name %q is reserved, Go cannot import a package named %q").SetModule(ModuleModular) + ModularModuleNameInvalid = New("module name %q is invalid, use letters, digits and dashes, starting with a letter").SetModule(ModuleModular) + ModularModuleNameNotKebab = New("module %T is named %q, a module name is kebab case like blog or blog-posts").SetModule(ModuleModular) + ModularModuleNotFound = New("module %s is not registered").SetModule(ModuleModular) + ModularModuleNotLocal = New("module %s belongs to another Go module, files can only be generated in the application's own modules").SetModule(ModuleModular) + ModularModuleNotPublished = New("module %s (%s) is not published to %s, run `go generate` or `artisan modular:publish`").SetModule(ModuleModular) + ModularModulePathInvalid = New("module %s: directory %q must be a relative path inside the project").SetModule(ModuleModular) + ModularModulePathUnknown = New("module %s: cannot derive its directory from package %q, implement Path() to set it").SetModule(ModuleModular) + ModularModulePublishStale = New("module %s: the files published to %s differ from %s, run `go generate` or `artisan modular:publish`").SetModule(ModuleModular) + ModularOptionsConflict = New("the --%s and --%s options cannot be used together").SetModule(ModuleModular) + ModularPublishFailed = New("modular:publish failed for %d of %d modules").SetModule(ModuleModular) + ModularPublishTargetNotOwned = New("module %s: %s exists and was not published from %s, move it or set the module's Path()").SetModule(ModuleModular) + ModularRegistryNotSet = New("the module registry is not set, build the application with foundation.Setup()").SetModule(ModuleModular) + ModularRequiresBootstrapSetup = New("%s needs bootstrap/app.go to build the application with foundation.Setup()").SetModule(ModuleModular) + ModularStaticDirInvalid = New("module %s: static directory %q must be a relative path inside the module").SetModule(ModuleModular) + ModularStaticDirUnreadable = New("module %s: static directory %q is not a readable directory: %v").SetModule(ModuleModular) + ModularStaticDuplicate = New("static path %s of module %s is already served by module %s").SetModule(ModuleModular) + ModularStaticPrefixInvalid = New("module %s: static prefix %q is invalid, use a path below /").SetModule(ModuleModular) + ModularViewsFSRequired = New("module %s returned a nil views filesystem").SetModule(ModuleModular) + ModularViteAliasDuplicate = New("vite alias %s of module %s is already used by module %s").SetModule(ModuleModular) + ModularViteAliasInvalid = New("module %s: vite alias %q -> %q is invalid, use a key like @name and a relative directory inside the module").SetModule(ModuleModular) + ModularViteEntryInvalid = New("module %s: vite entry %q is invalid, use a relative file path inside the module").SetModule(ModuleModular) + NotificationChannelNotFound = New("notification channel not found: %s").SetModule(ModuleNotification) NotificationChannelNotQueueable = New("notification channel %q does not support queued dispatch (does not implement ResolvableChannel)").SetModule(ModuleNotification) NotificationInvalidQueuePayload = New("notification queue payload is missing or malformed").SetModule(ModuleNotification) @@ -270,8 +297,12 @@ var ( PackageGoModDownloadFailed = New("failed to download go.mod dependencies: %s") PackageJobsFileExists = New("jobs.go already exists but WithJobs is not registered in foundation.Setup(). The jobs.go file should only be created when adding WithJobs to Setup()") PackageMigrationsFileExists = New("migrations.go already exists but WithMigrations is not registered in foundation.Setup(). The migrations.go file should only be created when adding WithMigrations to Setup()") + PackageModuleMethodUnsupported = New("module method %s cannot be edited, use Commands, Jobs, Migrations, Seeders or ServiceProviders") + PackageModuleStructNotFound = New("no module struct found in %s, a type with a Name() method is required") + PackageModulesFileExists = New("modules.go already exists but WithModules is not registered in foundation.Setup(). Add .WithModules(Modules) to Setup()") PackageProvidersFileExists = New("providers.go already exists but WithProviders is not registered in foundation.Setup(). The providers.go file should only be created when adding WithProviders to Setup()") PackageRulesFileExists = New("rules.go already exists but WithRules is not registered in foundation.Setup(). The rules.go file should only be created when adding WithRules to Setup()") + PackageSetupNotFound = New("foundation.Setup() chain not found as a statement of the Boot function in bootstrap/app.go") PackageSeedersFileExists = New("seeders.go already exists but WithSeeders is not registered in foundation.Setup(). The seeders.go file should only be created when adding WithSeeders to Setup()") PluralizerLanguageNotFound = New("language %s not found").SetModule(ModulePluralizer) @@ -396,11 +427,13 @@ var ( ViewSourceWalkFailed = New("failed to walk view source %s: %v") ViewTemplateNotExist = New("view template %s does not exist") - ViteAssetOutsideBuild = New("vite asset %s resolves outside the build directory").SetModule(ModuleVite) - ViteAssetReadFailed = New("failed to read vite asset %s: %v").SetModule(ModuleVite) - ViteFacadeNotSet = New("vite facade is not initialized").SetModule(ModuleVite) - ViteEntryNotFound = New("unable to locate file in vite manifest: %s").SetModule(ModuleVite) - ViteManifestInvalid = New("vite manifest %s is invalid: %v").SetModule(ModuleVite) - ViteManifestNotFound = New("vite manifest not found at %s, run `npm run build` or start the dev server").SetModule(ModuleVite) - ViteManifestReadFailed = New("failed to read vite manifest %s: %v").SetModule(ModuleVite) + ViteAliasNotFound = New("no vite alias matches %s; aliases come from goravel-vite-plugin through the manifest (build) or the hot file (dev)").SetModule(ModuleVite) + ViteAssetOutsideBuild = New("vite asset %s resolves outside the build directory").SetModule(ModuleVite) + ViteAssetReadFailed = New("failed to read vite asset %s: %v").SetModule(ModuleVite) + ViteFacadeNotSet = New("vite facade is not initialized").SetModule(ModuleVite) + ViteEntryNotFound = New("unable to locate file in vite manifest: %s").SetModule(ModuleVite) + ViteEntryOutsideProject = New("vite entry %s resolves to %s, outside the project").SetModule(ModuleVite) + ViteManifestInvalid = New("vite manifest %s is invalid: %v").SetModule(ModuleVite) + ViteManifestNotFound = New("vite manifest not found at %s, run `npm run build` or start the dev server").SetModule(ModuleVite) + ViteManifestReadFailed = New("failed to read vite manifest %s: %v").SetModule(ModuleVite) ) diff --git a/errors/modules.go b/errors/modules.go index 97574bbba..958eed871 100644 --- a/errors/modules.go +++ b/errors/modules.go @@ -17,6 +17,7 @@ var ( ModuleLog = "log" ModuleMail = "mail" ModuleMigration = "migration" + ModuleModular = "modular" ModuleNotification = "notification" ModuleOrm = "orm" ModulePackages = "packages" diff --git a/event/console/event_make_command.go b/event/console/event_make_command.go index e267bae12..54186c21c 100644 --- a/event/console/event_make_command.go +++ b/event/console/event_make_command.go @@ -41,6 +41,7 @@ func (r *EventMakeCommand) Extend() command.Extend { Name: "now", Usage: "Generate an event that also implements broadcasting.ShouldBroadcastNow", }, + supportconsole.ModuleFlag(), }, } } diff --git a/event/console/event_make_command_test.go b/event/console/event_make_command_test.go index 0a79bbe35..3524fe85c 100644 --- a/event/console/event_make_command_test.go +++ b/event/console/event_make_command_test.go @@ -21,18 +21,21 @@ func TestEventMakeCommand(t *testing.T) { mockContext.EXPECT().Arguments().Return([]string{"GoravelEvent"}).Once() mockContext.EXPECT().OptionBool("broadcast").Return(false).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Event created successfully").Once() assert.Nil(t, eventMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/events/goravel_event.go")) mockContext.EXPECT().Arguments().Return([]string{"GoravelEvent"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the event already exists. Use the --force or -f flag to overwrite").Once() assert.Nil(t, eventMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"Goravel/Event"}).Once() mockContext.EXPECT().OptionBool("broadcast").Return(false).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Event created successfully").Once() assert.Nil(t, eventMakeCommand.Handle(mockContext)) @@ -43,6 +46,7 @@ func TestEventMakeCommand(t *testing.T) { mockContext.EXPECT().Arguments().Return([]string{"GoravelBroadcastEvent"}).Once() mockContext.EXPECT().OptionBool("broadcast").Return(true).Once() mockContext.EXPECT().OptionBool("now").Return(false).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Event created successfully").Once() assert.Nil(t, eventMakeCommand.Handle(mockContext)) @@ -58,6 +62,7 @@ func TestEventMakeCommand(t *testing.T) { mockContext.EXPECT().Arguments().Return([]string{"GoravelBroadcastEventNow"}).Once() mockContext.EXPECT().OptionBool("broadcast").Return(true).Once() mockContext.EXPECT().OptionBool("now").Return(true).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Event created successfully").Once() assert.Nil(t, eventMakeCommand.Handle(mockContext)) @@ -84,6 +89,7 @@ func TestEventMakeCommand_MultipleNames(t *testing.T) { mockContext.EXPECT().Arguments().Return([]string{"OrderShipped", "OrderPaid"}).Once() mockContext.EXPECT().OptionBool("broadcast").Return(false).Times(2) + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Event created successfully").Times(2) assert.Nil(t, eventMakeCommand.Handle(mockContext)) diff --git a/event/console/listener_make_command.go b/event/console/listener_make_command.go index 465018c80..e6d26e300 100644 --- a/event/console/listener_make_command.go +++ b/event/console/listener_make_command.go @@ -34,6 +34,7 @@ func (r *ListenerMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the listener even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/event/console/listener_make_command_test.go b/event/console/listener_make_command_test.go index 1750657f8..9533ae157 100644 --- a/event/console/listener_make_command_test.go +++ b/event/console/listener_make_command_test.go @@ -20,17 +20,20 @@ func TestListenerMakeCommand(t *testing.T) { assert.Nil(t, listenerMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"GoravelListen"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Listener created successfully").Once() assert.Nil(t, listenerMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/listeners/goravel_listen.go")) mockContext.EXPECT().Arguments().Return([]string{"GoravelListen"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the listener already exists. Use the --force or -f flag to overwrite").Once() assert.Nil(t, listenerMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"Goravel/Listen"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Listener created successfully").Once() assert.Nil(t, listenerMakeCommand.Handle(mockContext)) @@ -45,6 +48,7 @@ func TestListenerMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"SendOrderEmail", "SendReceiptEmail"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Listener created successfully").Times(2) assert.Nil(t, listenerMakeCommand.Handle(mockContext)) diff --git a/event/console/module_flag_test.go b/event/console/module_flag_test.go new file mode 100644 index 000000000..bddb5ad98 --- /dev/null +++ b/event/console/module_flag_test.go @@ -0,0 +1,29 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + &EventMakeCommand{}, + &ListenerMakeCommand{}, + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/facades/facades.go b/facades/facades.go index 7ef5e6ecb..41c649b33 100644 --- a/facades/facades.go +++ b/facades/facades.go @@ -24,6 +24,7 @@ import ( "github.com/goravel/framework/contracts/http/client" "github.com/goravel/framework/contracts/log" "github.com/goravel/framework/contracts/mail" + "github.com/goravel/framework/contracts/modular" "github.com/goravel/framework/contracts/notification" "github.com/goravel/framework/contracts/process" "github.com/goravel/framework/contracts/queue" @@ -112,6 +113,11 @@ func Mail() mail.Mail { return App().MakeMail() } +// Modular returns the module registry; nil without foundation.Setup(). +func Modular() modular.Registry { + return App().MakeModular() +} + func Notification() notification.Manager { return App().MakeNotification() } diff --git a/foundation/application.go b/foundation/application.go index 332b2c3e2..db85509ac 100644 --- a/foundation/application.go +++ b/foundation/application.go @@ -4,7 +4,9 @@ import ( "context" "flag" "fmt" + "io/fs" "maps" + "net/http" "os" "os/signal" stdpath "path" @@ -23,14 +25,25 @@ import ( "github.com/goravel/framework/config" frameworkconsole "github.com/goravel/framework/console" "github.com/goravel/framework/contracts/binding" + contractsconfig "github.com/goravel/framework/contracts/config" contractsconsole "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/database/schema" + "github.com/goravel/framework/contracts/database/seeder" + "github.com/goravel/framework/contracts/event" "github.com/goravel/framework/contracts/foundation" + contractshttp "github.com/goravel/framework/contracts/http" + contractsmodular "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/contracts/queue" + "github.com/goravel/framework/contracts/route" + "github.com/goravel/framework/contracts/schedule" "github.com/goravel/framework/contracts/validation" contractsview "github.com/goravel/framework/contracts/view" "github.com/goravel/framework/errors" "github.com/goravel/framework/foundation/configuration" "github.com/goravel/framework/foundation/console" "github.com/goravel/framework/foundation/json" + "github.com/goravel/framework/modular" + modularconsole "github.com/goravel/framework/modular/console" "github.com/goravel/framework/process" "github.com/goravel/framework/support" "github.com/goravel/framework/support/carbon" @@ -79,6 +92,7 @@ type Application struct { bootedRunners []string commandsFilter []string commandHooks map[string][]contractsconsole.CommandHook + modules []contractsmodular.Module runnerWg sync.WaitGroup runnersToRun []*RunnerWithInfo } @@ -116,6 +130,8 @@ func (r *Application) Build() foundation.Application { r.setTimezone() r.configurePaths() r.configureCustomConfig() + r.configureModules() + r.configureModuleConfig() if r.builder.commandsFilter != nil { r.commandsFilter = r.builder.commandsFilter() } @@ -126,6 +142,7 @@ func (r *Application) Build() foundation.Application { r.providerRepository.Register(r) r.configureMiddleware() r.providerRepository.Boot(r) + r.registerModuleResources() r.configureViews() r.configureEventListeners() r.configureCommands() @@ -484,10 +501,16 @@ func (r *Application) configureCallback() { } func (r *Application) configureCommands() { + var commands []contractsconsole.Command if r.builder.commands != nil { - if commands := r.builder.commands(); len(commands) > 0 { - r.registerCommands(commands) - } + commands = r.builder.commands() + } + for _, module := range modular.Providing[contractsmodular.ProvidesCommands](r.modules) { + commands = append(commands, module.Commands()...) + } + + if len(commands) > 0 { + r.registerCommands(commands) } } @@ -497,18 +520,63 @@ func (r *Application) configureCustomConfig() { } } +// configureModuleConfig sets "modules." (replacing WithConfig's) and wraps +// app.lang_fs with module translations. +func (r *Application) configureModuleConfig() { + translations := make(map[string]fs.FS) + var configs []contractsmodular.Module + for _, module := range r.modules { + if _, ok := module.(contractsmodular.ProvidesConfig); ok { + configs = append(configs, module) + } + if p, ok := module.(contractsmodular.ProvidesTranslations); ok { + if fsys := p.Translations(); fsys != nil { + translations[module.Name()] = fsys + } + } + } + if len(configs) == 0 && len(translations) == 0 { + return + } + + configFacade := r.MakeConfig() + if configFacade == nil { + color.Errorln(errors.ConfigFacadeNotSet.SetModule(errors.ModuleModular)) + return + } + + for _, module := range configs { + configFacade.Add("modules."+module.Name(), module.(contractsmodular.ProvidesConfig).Config()) + } + + if len(translations) > 0 { + fallback, _ := configFacade.Get("app.lang_fs").(fs.FS) + configFacade.Add("app.lang_fs", modular.NewLangFS(translations, fallback)) + } +} + func (r *Application) configureEventListeners() { + // A copy: WithEvents may return the same map on every build. + eventToListeners := make(map[event.Event][]event.Listener) if r.builder.eventToListeners != nil { - if eventToListeners := r.builder.eventToListeners(); len(eventToListeners) > 0 { - eventFacade := r.MakeEvent() - if eventFacade == nil { - color.Errorln("Event facade not found, please install it first: ./artisan package:install Event") - } else { - // WithEvents carries the deprecated event.Listener, Register is - // the only entry accepting it. - //nolint:staticcheck - eventFacade.Register(eventToListeners) - } + maps.Copy(eventToListeners, r.builder.eventToListeners()) + } + for _, module := range modular.Providing[contractsmodular.ProvidesEvents](r.modules) { + // Concat keeps every listener of a shared event and never writes into the app's slice. + for e, listeners := range module.Events() { + eventToListeners[e] = slices.Concat(eventToListeners[e], listeners) + } + } + + if len(eventToListeners) > 0 { + eventFacade := r.MakeEvent() + if eventFacade == nil { + color.Errorln("Event facade not found, please install it first: ./artisan package:install Event") + } else { + // WithEvents carries the deprecated event.Listener, Register is + // the only entry accepting it. + //nolint:staticcheck + eventFacade.Register(eventToListeners) } } } @@ -577,29 +645,40 @@ func (r *Application) configureGrpc() { } func (r *Application) configureJobs() { + var jobs []queue.Job if r.builder.jobs != nil { - jobs := r.builder.jobs() + jobs = r.builder.jobs() + } + for _, module := range modular.Providing[contractsmodular.ProvidesJobs](r.modules) { + jobs = append(jobs, module.Jobs()...) + } - if len(jobs) > 0 { - queueFacade := r.MakeQueue() - if queueFacade == nil { - color.Errorln("Queue facade not found, please install it first: ./artisan package:install Queue") - } else { - queueFacade.Register(jobs) - } + if len(jobs) > 0 { + queueFacade := r.MakeQueue() + if queueFacade == nil { + color.Errorln("Queue facade not found, please install it first: ./artisan package:install Queue") + } else { + queueFacade.Register(jobs) } } } +// configureMiddleware: driver defaults, then modules, then WithMiddleware (final say). func (r *Application) configureMiddleware() { - if r.builder.middleware != nil { + modules := modular.Providing[contractsmodular.ProvidesGlobalMiddleware](r.modules) + if r.builder.middleware != nil || len(modules) > 0 { routeFacade := r.MakeRoute() if routeFacade == nil { color.Errorln("Route facade not found, please install it first: ./artisan package:install Route") } else { defaultGlobalMiddleware := routeFacade.GetGlobalMiddleware() middleware := configuration.NewMiddleware(defaultGlobalMiddleware) - r.builder.middleware(middleware) + for _, module := range modules { + middleware.Append(module.GlobalMiddleware()...) + } + if r.builder.middleware != nil { + r.builder.middleware(middleware) + } routeFacade.SetGlobalMiddleware(middleware.GetGlobalMiddleware()) if recoveryHandler := middleware.GetRecover(); recoveryHandler != nil { @@ -610,16 +689,48 @@ func (r *Application) configureMiddleware() { } func (r *Application) configureMigrations() { + var migrations []schema.Migration if r.builder.migrations != nil { - if migrations := r.builder.migrations(); len(migrations) > 0 { - schemaFacade := r.MakeSchema() - if schemaFacade == nil { - color.Errorln("Schema facade not found, please install it first: ./artisan package:install Schema") - } else { - schemaFacade.Register(migrations) - } + migrations = r.builder.migrations() + } + for _, module := range modular.Providing[contractsmodular.ProvidesMigrations](r.modules) { + migrations = append(migrations, module.Migrations()...) + } + + if len(migrations) > 0 { + schemaFacade := r.MakeSchema() + if schemaFacade == nil { + color.Errorln("Schema facade not found, please install it first: ./artisan package:install Schema") + } else { + schemaFacade.Register(migrations) + } + } +} + +// configureModules binds a fresh registry and keeps the sorted modules. It runs +// after WithConfig (modular.path) and before providers. Errors are reported. +func (r *Application) configureModules() { + directory := modular.DefaultDirectory + var modules []contractsmodular.Module + if r.builder.modules != nil { + modules = r.builder.modules() + } + if len(modules) > 0 { + if config := r.MakeConfig(); config != nil { + directory = config.GetString("modular.path", modular.DefaultDirectory) } } + + registry := modular.NewRegistry(directory) + if err := registry.Register(modules...); err != nil { + color.Errorln(err) + } + + // Make caches instances; drop the previous registry on a rebuild. + r.Fresh(binding.Modular) + r.Instance(binding.Modular, registry) + modular.SetDefault(registry) + r.modules = registry.All() } func (r *Application) configurePaths() { @@ -629,10 +740,84 @@ func (r *Application) configurePaths() { } } +// configureRoutes runs WithRouting, then module static files on the root router +// (no group middleware), then module routes in the modular.routes.* groups. func (r *Application) configureRoutes() { if r.builder.routes != nil { r.builder.routes() } + + statics := modular.Providing[contractsmodular.ProvidesStatic](r.modules) + web := modular.Providing[contractsmodular.ProvidesRoutes](r.modules) + api := modular.Providing[contractsmodular.ProvidesApiRoutes](r.modules) + if len(statics) == 0 && len(web) == 0 && len(api) == 0 { + return + } + + routeFacade := r.MakeRoute() + if routeFacade == nil { + color.Errorln("Route facade not found, please install it first: ./artisan package:install Route") + return + } + + r.registerModuleStatic(routeFacade) + + configFacade := r.MakeConfig() + webPrefix, webMiddleware := moduleRouteGroup(configFacade, "modular.routes.web", "") + apiPrefix, apiMiddleware := moduleRouteGroup(configFacade, "modular.routes.api", "api") + for _, module := range web { + routeFacade.Prefix(webPrefix).Middleware(webMiddleware...).Group(module.Routes) + } + for _, module := range api { + routeFacade.Prefix(apiPrefix).Middleware(apiMiddleware...).Group(module.ApiRoutes) + } +} + +// registerModuleStatic serves static paths in module, then prefix, order. Duplicate +// or invalid ones are reported and skipped. +func (r *Application) registerModuleStatic(routeFacade route.Route) { + registry := r.MakeModular() + served := make(map[string]string) + for _, module := range r.modules { + p, ok := module.(contractsmodular.ProvidesStatic) + if !ok { + continue + } + + paths := p.Static() + for _, prefix := range slices.Sorted(maps.Keys(paths)) { + cleaned, ok := modular.CleanPrefix(prefix) + if !ok { + color.Errorln(errors.ModularStaticPrefixInvalid.Args(module.Name(), prefix)) + continue + } + if owner, ok := served[cleaned]; ok { + color.Errorln(errors.ModularStaticDuplicate.Args(cleaned, module.Name(), owner)) + continue + } + + root, _ := registry.Path(module.Name()) + fsys, err := modular.StaticFS(module.Name(), root, paths[prefix]) + if err != nil { + color.Errorln(err) + continue + } + + served[cleaned] = module.Name() + routeFacade.StaticFS(cleaned, http.FS(fsys)) + } + } +} + +// moduleRouteGroup reads a route group's prefix and middleware. +func moduleRouteGroup(configFacade contractsconfig.Config, key, defaultPrefix string) (string, []contractshttp.Middleware) { + if configFacade == nil { + return defaultPrefix, nil + } + + middleware, _ := configFacade.Get(key + ".middleware").([]contractshttp.Middleware) + + return configFacade.GetString(key+".prefix", defaultPrefix), middleware } func (r *Application) configureRunners() { @@ -691,37 +876,85 @@ func (r *Application) isRunnerDisabled(signature string, disabledRunners []strin } func (r *Application) configureSchedule() { + var events []schedule.Event if r.builder.schedule != nil { - if events := r.builder.schedule(); len(events) > 0 { - scheduleFacade := r.MakeSchedule() - if scheduleFacade == nil { - color.Errorln("Schedule facade not found, please install it first: ./artisan package:install Schedule") - } else { - scheduleFacade.Register(events) - } + events = r.builder.schedule() + } + for _, module := range modular.Providing[contractsmodular.ProvidesSchedule](r.modules) { + events = append(events, module.Schedule()...) + } + + if len(events) > 0 { + scheduleFacade := r.MakeSchedule() + if scheduleFacade == nil { + color.Errorln("Schedule facade not found, please install it first: ./artisan package:install Schedule") + } else { + scheduleFacade.Register(events) } } } func (r *Application) configureSeeders() { + var seeders []seeder.Seeder if r.builder.seeders != nil { - if seeders := r.builder.seeders(); len(seeders) > 0 { - seederFacade := r.MakeSeeder() - if seederFacade == nil { - color.Errorln("Seeder facade not found, please install it first: ./artisan package:install Seeder") - } else { - seederFacade.Register(seeders) - } + seeders = r.builder.seeders() + } + for _, module := range modular.Providing[contractsmodular.ProvidesSeeders](r.modules) { + seeders = append(seeders, module.Seeders()...) + } + + if len(seeders) > 0 { + seederFacade := r.MakeSeeder() + if seederFacade == nil { + color.Errorln("Seeder facade not found, please install it first: ./artisan package:install Seeder") + } else { + seederFacade.Register(seeders) } } } func (r *Application) configureServiceProviders() { + var providers []foundation.ServiceProvider if r.builder.configuredServiceProviders != nil { - configuredServiceProviders := r.builder.configuredServiceProviders() - if len(configuredServiceProviders) > 0 { - r.providerRepository.Add(configuredServiceProviders) + providers = r.builder.configuredServiceProviders() + } + for _, module := range modular.Providing[foundation.ProvidesServices](r.modules) { + providers = append(providers, module.ServiceProviders()...) + } + + if len(providers) > 0 { + r.providerRepository.Add(providers) + } +} + +// registerModuleResources registers module views under "/", after providers +// boot and before configureViews compiles them. +func (r *Application) registerModuleResources() { + modules := modular.Providing[contractsmodular.ProvidesViews](r.modules) + if len(modules) == 0 { + return + } + + viewFacade := r.MakeView() + if viewFacade == nil { + color.Errorln("View facade not found, please install it first: ./artisan package:install View") + return + } + + for _, module := range r.modules { + p, ok := module.(contractsmodular.ProvidesViews) + if !ok { + continue } + + fsys := p.Views() + if fsys == nil { + // LoadViewsFromFS panics on a nil filesystem. + color.Errorln(errors.ModularViewsFSRequired.Args(module.Name())) + continue + } + + viewFacade.LoadViewsFromFS(modular.NewPrefixFS(module.Name(), fsys), ".") } } @@ -800,6 +1033,10 @@ func (r *Application) defaultCommands() []contractsconsole.Command { console.NewPackageInstallCommand(binding.Bindings, r.MakeProcess(), r.Json()), console.NewPackageUninstallCommand(binding.Bindings, r.MakeProcess(), r.Json()), console.NewVendorPublishCommand(r.publishes, r.publishGroups), + modularconsole.NewInitCommand(r.MakeConfig()), + modularconsole.NewListCommand(r.MakeModular()), + modularconsole.NewModuleMakeCommand(r.MakeConfig()), + modularconsole.NewPublishCommand(r.MakeModular()), } if r.MakeRoute() != nil { diff --git a/foundation/application_builder.go b/foundation/application_builder.go index b2038afef..16ec515d8 100644 --- a/foundation/application_builder.go +++ b/foundation/application_builder.go @@ -13,6 +13,7 @@ import ( "github.com/goravel/framework/contracts/event" "github.com/goravel/framework/contracts/foundation" contractsconfiguration "github.com/goravel/framework/contracts/foundation/configuration" + "github.com/goravel/framework/contracts/modular" "github.com/goravel/framework/contracts/queue" "github.com/goravel/framework/contracts/schedule" "github.com/goravel/framework/contracts/validation" @@ -41,6 +42,7 @@ type ApplicationBuilder struct { jobs func() []queue.Job middleware func(middleware contractsconfiguration.Middleware) migrations func() []schema.Migration + modules func() []modular.Module paths func(paths contractsconfiguration.Paths) routes func() rules func() []validation.Rule @@ -179,6 +181,12 @@ func (r *ApplicationBuilder) WithMigrations(fn func() []schema.Migration) founda return r } +func (r *ApplicationBuilder) WithModules(fn func() []modular.Module) foundation.ApplicationBuilder { + r.modules = fn + + return r +} + func (r *ApplicationBuilder) WithPaths(fn func(paths contractsconfiguration.Paths)) foundation.ApplicationBuilder { r.paths = fn diff --git a/foundation/application_builder_test.go b/foundation/application_builder_test.go index 3d17a1ae4..e814d7960 100644 --- a/foundation/application_builder_test.go +++ b/foundation/application_builder_test.go @@ -18,6 +18,7 @@ import ( contractsevent "github.com/goravel/framework/contracts/event" contractsfoundation "github.com/goravel/framework/contracts/foundation" contractsconfiguration "github.com/goravel/framework/contracts/foundation/configuration" + "github.com/goravel/framework/contracts/modular" "github.com/goravel/framework/contracts/queue" "github.com/goravel/framework/contracts/schedule" "github.com/goravel/framework/contracts/validation" @@ -25,6 +26,7 @@ import ( mocksschema "github.com/goravel/framework/mocks/database/schema" mocksseeder "github.com/goravel/framework/mocks/database/seeder" mocksfoundation "github.com/goravel/framework/mocks/foundation" + mocksmodular "github.com/goravel/framework/mocks/modular" mocksqueue "github.com/goravel/framework/mocks/queue" mocksschedule "github.com/goravel/framework/mocks/schedule" mocksvalidation "github.com/goravel/framework/mocks/validation" @@ -127,6 +129,18 @@ func (s *ApplicationBuilderTestSuite) TestWithPaths() { s.NotNil(s.builder.paths) } +func (s *ApplicationBuilderTestSuite) TestWithModules() { + mockModule := mocksmodular.NewModule(s.T()) + + builder := s.builder.WithModules(func() []modular.Module { + return []modular.Module{mockModule} + }) + + s.NotNil(builder) + s.Require().NotNil(s.builder.modules) + s.Equal([]modular.Module{mockModule}, s.builder.modules()) +} + func (s *ApplicationBuilderTestSuite) TestWithMigrations() { mockMigration := mocksschema.NewMigration(s.T()) diff --git a/foundation/application_modules_test.go b/foundation/application_modules_test.go new file mode 100644 index 000000000..10c8ca049 --- /dev/null +++ b/foundation/application_modules_test.go @@ -0,0 +1,520 @@ +package foundation + +import ( + "io" + "io/fs" + "net/http" + "slices" + "testing/fstest" + + "github.com/stretchr/testify/mock" + + "github.com/goravel/framework/contracts/binding" + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/database/schema" + "github.com/goravel/framework/contracts/database/seeder" + "github.com/goravel/framework/contracts/event" + "github.com/goravel/framework/contracts/foundation" + contractsconfiguration "github.com/goravel/framework/contracts/foundation/configuration" + contractshttp "github.com/goravel/framework/contracts/http" + contractsmodular "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/contracts/queue" + "github.com/goravel/framework/contracts/route" + "github.com/goravel/framework/contracts/schedule" + "github.com/goravel/framework/errors" + mocksconfig "github.com/goravel/framework/mocks/config" + mocksconsole "github.com/goravel/framework/mocks/console" + mocksschema "github.com/goravel/framework/mocks/database/schema" + mocksseeder "github.com/goravel/framework/mocks/database/seeder" + mocksevent "github.com/goravel/framework/mocks/event" + mocksqueue "github.com/goravel/framework/mocks/queue" + mocksroute "github.com/goravel/framework/mocks/route" + mocksschedule "github.com/goravel/framework/mocks/schedule" + mocksview "github.com/goravel/framework/mocks/view" + "github.com/goravel/framework/support/color" +) + +// testModule has only a name; tests embed it to add capabilities. +type testModule struct{ name string } + +func (m *testModule) Name() string { return m.name } + +type orderedTestModule struct { + testModule + order int +} + +func (m *orderedTestModule) Order() int { return m.order } + +func moduleNames(modules []contractsmodular.Module) []string { + var names []string + for _, module := range modules { + names = append(names, module.Name()) + } + + return names +} + +func (s *ApplicationTestSuite) TestConfigureModules() { + s.Run("without WithModules an empty registry is bound", func() { + s.SetupTest() + s.app.builder = NewApplicationBuilder(s.app) + + s.app.configureModules() + + registry := s.app.MakeModular() + s.Require().NotNil(registry) + s.Empty(registry.All()) + s.Empty(s.app.modules) + }) + + s.Run("modules are registered and kept in order", func() { + s.SetupTest() + builder := NewApplicationBuilder(s.app) + builder.modules = func() []contractsmodular.Module { + return []contractsmodular.Module{ + &orderedTestModule{testModule{"last"}, -1}, + &testModule{"blog"}, + &orderedTestModule{testModule{"shop"}, 5}, + } + } + s.app.builder = builder + + s.app.configureModules() + + s.Equal([]string{"blog", "shop", "last"}, moduleNames(s.app.modules)) + s.True(s.app.MakeModular().Has("blog")) + instance, err := s.app.Make(binding.Modular) + s.NoError(err) + s.Same(s.app.MakeModular(), instance) + }) + + s.Run("module directories are resolved, third-party ones under modular.path", func() { + s.SetupTest() + mockConfig := mocksconfig.NewConfig(s.T()) + mockConfig.EXPECT().GetString("modular.path", "modules").Return("app/modules").Once() + s.app.Instance(binding.Config, mockConfig) + builder := NewApplicationBuilder(s.app) + builder.modules = func() []contractsmodular.Module { + return []contractsmodular.Module{&testModule{"blog"}} + } + s.app.builder = builder + + s.app.configureModules() + + // Under go test the main module is the framework. + dir, ok := s.app.MakeModular().Path("blog") + s.True(ok) + s.Equal("foundation", dir) + }) + + s.Run("a duplicate name is reported and the modules before it are kept", func() { + s.SetupTest() + builder := NewApplicationBuilder(s.app) + builder.modules = func() []contractsmodular.Module { + return []contractsmodular.Module{&testModule{"blog"}, &testModule{"blog"}, &testModule{"shop"}} + } + s.app.builder = builder + + s.app.configureModules() + + s.Equal([]string{"blog"}, moduleNames(s.app.modules)) + }) + + s.Run("a rebuild binds a fresh registry", func() { + s.SetupTest() + builder := NewApplicationBuilder(s.app) + builder.modules = func() []contractsmodular.Module { + return []contractsmodular.Module{&testModule{"blog"}} + } + s.app.builder = builder + + s.app.configureModules() + first := s.app.MakeModular() + s.app.configureModules() + + s.NotSame(first, s.app.MakeModular()) + s.Len(s.app.MakeModular().All(), 1) + }) + + s.Run("without Build there is no registry", func() { + s.SetupTest() + + s.Nil(s.app.MakeModular()) + }) +} + +// richModule implements every capability with test-set values. +type richModule struct { + testModule + config map[string]any + translation fs.FS + views fs.FS + middleware []contractshttp.Middleware + providers []foundation.ServiceProvider + commands []console.Command + migrations []schema.Migration + seeders []seeder.Seeder + jobs []queue.Job + schedule []schedule.Event + events map[event.Event][]event.Listener + routed *[]string +} + +func (m *richModule) Config() map[string]any { return m.config } +func (m *richModule) Translations() fs.FS { return m.translation } +func (m *richModule) Views() fs.FS { return m.views } +func (m *richModule) GlobalMiddleware() []contractshttp.Middleware { return m.middleware } +func (m *richModule) ServiceProviders() []foundation.ServiceProvider { return m.providers } +func (m *richModule) Commands() []console.Command { return m.commands } +func (m *richModule) Migrations() []schema.Migration { return m.migrations } +func (m *richModule) Seeders() []seeder.Seeder { return m.seeders } +func (m *richModule) Jobs() []queue.Job { return m.jobs } +func (m *richModule) Schedule() []schedule.Event { return m.schedule } +func (m *richModule) Events() map[event.Event][]event.Listener { return m.events } +func (m *richModule) Routes(route.Router) { *m.routed = append(*m.routed, m.name+":web") } +func (m *richModule) ApiRoutes(route.Router) { *m.routed = append(*m.routed, m.name+":api") } + +// withModules points the builder at modules and runs configureModules. +func (s *ApplicationTestSuite) withModules(builder *ApplicationBuilder, modules ...contractsmodular.Module) { + builder.modules = func() []contractsmodular.Module { return modules } + s.app.builder = builder + s.app.configureModules() +} + +type fakeMiddleware struct{ signature string } + +func (m *fakeMiddleware) Handle(contractshttp.Context) {} +func (m *fakeMiddleware) Signature() string { return m.signature } + +func middlewareSignatures(middleware []contractshttp.Middleware) []string { + var out []string + for _, m := range middleware { + out = append(out, m.Signature()) + } + + return out +} + +func (s *ApplicationTestSuite) TestConfigureModuleConfig() { + s.Run("module config and translations, app lang FS kept as fallback", func() { + s.SetupTest() + appLang := fstest.MapFS{"en/app.json": {Data: []byte(`{"name":"App"}`)}} + blogLang := fstest.MapFS{"en/messages.json": {Data: []byte(`{"title":"Blog"}`)}} + + mockConfig := mocksconfig.NewConfig(s.T()) + mockConfig.EXPECT().GetString("modular.path", "modules").Return("modules").Once() + mockConfig.EXPECT().Add("modules.blog", map[string]any{"enabled": true}).Return().Once() + mockConfig.EXPECT().Add("modules.shop", map[string]any{"currency": "EUR"}).Return().Once() + mockConfig.EXPECT().Get("app.lang_fs").Return(appLang).Once() + mockConfig.EXPECT().Add("app.lang_fs", mock.MatchedBy(func(fsys fs.FS) bool { + module, err := fs.ReadFile(fsys, "en/blog/messages.json") + app, appErr := fs.ReadFile(fsys, "en/app.json") + + return err == nil && appErr == nil && string(module) == `{"title":"Blog"}` && string(app) == `{"name":"App"}` + })).Return().Once() + s.app.Instance(binding.Config, mockConfig) + + s.withModules(NewApplicationBuilder(s.app), + &richModule{testModule: testModule{"blog"}, config: map[string]any{"enabled": true}, translation: blogLang}, + &richModule{testModule: testModule{"shop"}, config: map[string]any{"currency": "EUR"}}, + ) + s.app.configureModuleConfig() + }) + + s.Run("without modules the config facade is not touched", func() { + s.SetupTest() + s.withModules(NewApplicationBuilder(s.app), &testModule{"plain"}) + s.app.configureModuleConfig() // no config bound: must not be needed + }) +} + +func (s *ApplicationTestSuite) TestConfigureServiceProviders_Modules() { + s.SetupTest() + s.app.providerRepository = NewProviderRepository() + appProvider := &AServiceProvider{} + moduleProvider := &BServiceProvider{} + + builder := NewApplicationBuilder(s.app) + builder.configuredServiceProviders = func() []foundation.ServiceProvider { return []foundation.ServiceProvider{appProvider} } + s.withModules(builder, &richModule{testModule: testModule{"blog"}, providers: []foundation.ServiceProvider{moduleProvider}}) + s.app.configureServiceProviders() + + repository := s.app.providerRepository.(*ProviderRepository) + s.Equal([]foundation.ServiceProvider{appProvider, moduleProvider}, repository.providers) +} + +func (s *ApplicationTestSuite) TestConfigureMiddleware_Modules() { + driver := &fakeMiddleware{"driver"} + module := &fakeMiddleware{"module"} + app := &fakeMiddleware{"app"} + + s.Run("module middleware without WithMiddleware", func() { + s.SetupTest() + mockRoute := mocksroute.NewRoute(s.T()) + mockRoute.EXPECT().GetGlobalMiddleware().Return([]contractshttp.Middleware{driver}).Once() + mockRoute.EXPECT().SetGlobalMiddleware(mock.MatchedBy(func(m []contractshttp.Middleware) bool { + return slices.Equal(middlewareSignatures(m), []string{"driver", "module"}) + })).Return().Once() + s.app.Instance(binding.Route, mockRoute) + + s.withModules(NewApplicationBuilder(s.app), &richModule{testModule: testModule{"blog"}, middleware: []contractshttp.Middleware{module}}) + s.app.configureMiddleware() + }) + + s.Run("WithMiddleware sees module middleware and runs last", func() { + s.SetupTest() + mockRoute := mocksroute.NewRoute(s.T()) + mockRoute.EXPECT().GetGlobalMiddleware().Return([]contractshttp.Middleware{driver}).Once() + mockRoute.EXPECT().SetGlobalMiddleware(mock.MatchedBy(func(m []contractshttp.Middleware) bool { + return slices.Equal(middlewareSignatures(m), []string{"driver", "module", "app"}) + })).Return().Once() + s.app.Instance(binding.Route, mockRoute) + + builder := NewApplicationBuilder(s.app) + builder.middleware = func(handler contractsconfiguration.Middleware) { + s.Len(handler.GetGlobalMiddleware(), 2) + handler.Append(app) + } + s.withModules(builder, &richModule{testModule: testModule{"blog"}, middleware: []contractshttp.Middleware{module}}) + s.app.configureMiddleware() + }) +} + +func (s *ApplicationTestSuite) TestRegisterModuleResources() { + s.Run("views are registered under the module name", func() { + s.SetupTest() + mockView := mocksview.NewView(s.T()) + mockView.EXPECT().LoadViewsFromFS(mock.MatchedBy(func(fsys fs.FS) bool { + _, err := fs.Stat(fsys, "blog/index.tmpl") + + return err == nil + }), ".").Return().Once() + s.app.Instance(binding.View, mockView) + + s.withModules(NewApplicationBuilder(s.app), + &richModule{testModule: testModule{"blog"}, views: fstest.MapFS{"index.tmpl": {Data: []byte("x")}}}, + &richModule{testModule: testModule{"broken"}}, // nil views: reported, not registered + &testModule{"plain"}, + ) + s.app.registerModuleResources() + }) + + s.Run("without view modules the view facade is not needed", func() { + s.SetupTest() + s.withModules(NewApplicationBuilder(s.app), &testModule{"plain"}) + s.app.registerModuleResources() + }) +} + +func (s *ApplicationTestSuite) TestConfigureRegistrations_Modules() { + s.SetupTest() + appCommand, moduleCommand := &fakeCommand{signature: "app:run"}, &fakeCommand{signature: "blog:run"} + appMigration, moduleMigration := mocksschema.NewMigration(s.T()), mocksschema.NewMigration(s.T()) + appSeeder, moduleSeeder := mocksseeder.NewSeeder(s.T()), mocksseeder.NewSeeder(s.T()) + appJob, moduleJob := mocksqueue.NewJob(s.T()), mocksqueue.NewJob(s.T()) + appEvent, moduleEvent := mocksschedule.NewEvent(s.T()), mocksschedule.NewEvent(s.T()) + + mockArtisan := mocksconsole.NewArtisan(s.T()) + mockArtisan.EXPECT().Register([]console.Command{appCommand, moduleCommand}).Return().Once() + s.app.Instance(binding.Artisan, mockArtisan) + mockSchema := mocksschema.NewSchema(s.T()) + mockSchema.EXPECT().Register([]schema.Migration{appMigration, moduleMigration}).Return().Once() + s.app.Instance(binding.Schema, mockSchema) + mockSeeder := mocksseeder.NewFacade(s.T()) + mockSeeder.EXPECT().Register([]seeder.Seeder{appSeeder, moduleSeeder}).Return().Once() + s.app.Instance(binding.Seeder, mockSeeder) + mockQueue := mocksqueue.NewQueue(s.T()) + mockQueue.EXPECT().Register([]queue.Job{appJob, moduleJob}).Return().Once() + s.app.Instance(binding.Queue, mockQueue) + mockSchedule := mocksschedule.NewSchedule(s.T()) + mockSchedule.EXPECT().Register([]schedule.Event{appEvent, moduleEvent}).Return().Once() + s.app.Instance(binding.Schedule, mockSchedule) + + builder := NewApplicationBuilder(s.app) + builder.commands = func() []console.Command { return []console.Command{appCommand} } + builder.migrations = func() []schema.Migration { return []schema.Migration{appMigration} } + builder.seeders = func() []seeder.Seeder { return []seeder.Seeder{appSeeder} } + builder.jobs = func() []queue.Job { return []queue.Job{appJob} } + builder.schedule = func() []schedule.Event { return []schedule.Event{appEvent} } + s.withModules(builder, &richModule{ + testModule: testModule{"blog"}, + commands: []console.Command{moduleCommand}, + migrations: []schema.Migration{moduleMigration}, + seeders: []seeder.Seeder{moduleSeeder}, + jobs: []queue.Job{moduleJob}, + schedule: []schedule.Event{moduleEvent}, + }) + + s.app.configureCommands() + s.app.configureMigrations() + s.app.configureSeeders() + s.app.configureJobs() + s.app.configureSchedule() +} + +func (s *ApplicationTestSuite) TestConfigureRegistrations_ModulesOnly() { + s.SetupTest() + moduleCommand := &fakeCommand{signature: "blog:run"} + mockArtisan := mocksconsole.NewArtisan(s.T()) + mockArtisan.EXPECT().Register([]console.Command{moduleCommand}).Return().Once() + s.app.Instance(binding.Artisan, mockArtisan) + + s.withModules(NewApplicationBuilder(s.app), &richModule{testModule: testModule{"blog"}, commands: []console.Command{moduleCommand}}) + s.app.configureCommands() +} + +func (s *ApplicationTestSuite) TestConfigureEventListeners_Modules() { + s.SetupTest() + shared, moduleOnly := mocksevent.NewEvent(s.T()), mocksevent.NewEvent(s.T()) + appListener, moduleListener, otherListener := mocksevent.NewListener(s.T()), mocksevent.NewListener(s.T()), mocksevent.NewListener(s.T()) + + mockEvent := mocksevent.NewInstance(s.T()) + mockEvent.EXPECT().Register(map[event.Event][]event.Listener{ + shared: {appListener, moduleListener}, + moduleOnly: {otherListener}, + }).Return().Twice() + s.app.Instance(binding.Event, mockEvent) + + // One map for every build, its slice with spare capacity: neither may be written to. + appListeners := make([]event.Listener, 1, 4) + appListeners[0] = appListener + appEvents := map[event.Event][]event.Listener{shared: appListeners} + + builder := NewApplicationBuilder(s.app) + builder.eventToListeners = func() map[event.Event][]event.Listener { + return appEvents + } + s.withModules(builder, &richModule{testModule: testModule{"blog"}, events: map[event.Event][]event.Listener{ + shared: {moduleListener}, + moduleOnly: {otherListener}, + }}) + s.app.configureEventListeners() + s.app.configureEventListeners() + + s.Equal(map[event.Event][]event.Listener{shared: {appListener}}, appEvents) + s.Equal([]event.Listener{appListener, nil}, appListeners[:2]) +} + +func (s *ApplicationTestSuite) TestConfigureRoutes_Modules() { + s.Run("module routes go into the configured groups, after the app's", func() { + s.SetupTest() + var routed []string + webMiddleware := &fakeMiddleware{"web"} + + mockConfig := mocksconfig.NewConfig(s.T()) + mockConfig.EXPECT().GetString("modular.path", "modules").Return("modules").Once() + mockConfig.EXPECT().GetString("modular.routes.web.prefix", "").Return("").Once() + mockConfig.EXPECT().Get("modular.routes.web.middleware").Return([]contractshttp.Middleware{webMiddleware}).Once() + mockConfig.EXPECT().GetString("modular.routes.api.prefix", "api").Return("api").Once() + mockConfig.EXPECT().Get("modular.routes.api.middleware").Return(nil).Once() + s.app.Instance(binding.Config, mockConfig) + + webRouter, apiRouter := mocksroute.NewRouter(s.T()), mocksroute.NewRouter(s.T()) + mockRoute := mocksroute.NewRoute(s.T()) + mockRoute.EXPECT().Prefix("").Return(webRouter).Twice() + webRouter.EXPECT().Middleware(mock.Anything).Return(webRouter).Twice() + webRouter.EXPECT().Group(mock.Anything).Run(func(handler route.GroupFunc) { handler(webRouter) }).Return().Twice() + mockRoute.EXPECT().Prefix("api").Return(apiRouter).Once() + apiRouter.EXPECT().Middleware().Return(apiRouter).Once() + apiRouter.EXPECT().Group(mock.Anything).Run(func(handler route.GroupFunc) { handler(apiRouter) }).Return().Once() + s.app.Instance(binding.Route, mockRoute) + + builder := NewApplicationBuilder(s.app) + builder.routes = func() { routed = append(routed, "app") } + s.withModules(builder, + &richModule{testModule: testModule{"blog"}, routed: &routed}, + &webOnlyModule{testModule{"shop"}, &routed}, + ) + s.app.configureRoutes() + + s.Equal([]string{"app", "blog:web", "shop:web", "blog:api"}, routed) + }) + + s.Run("without module routes or static files the route facade is not needed", func() { + s.SetupTest() + s.withModules(NewApplicationBuilder(s.app), &testModule{"plain"}) + s.app.configureRoutes() + }) +} + +type webOnlyModule struct { + testModule + routed *[]string +} + +func (m *webOnlyModule) Routes(route.Router) { *m.routed = append(*m.routed, m.name+":web") } + +type staticModule struct { + testModule + static map[string]contractsmodular.Path +} + +func (m *staticModule) Static() map[string]contractsmodular.Path { return m.static } + +func (s *ApplicationTestSuite) TestConfigureRoutes_ModuleStatic() { + s.SetupTest() + public := fstest.MapFS{"public/robots.txt": {Data: []byte("x")}} + + mockRoute := mocksroute.NewRoute(s.T()) + mockRoute.EXPECT().StaticFS("/blog/static", mock.MatchedBy(func(fsys http.FileSystem) bool { + file, err := fsys.Open("/robots.txt") + if err != nil { + return false + } + _ = file.Close() + _, err = fsys.Open("/") + + return err != nil // directories are not listed + })).Return(mocksroute.NewAction(s.T())).Once() + s.app.Instance(binding.Route, mockRoute) + + s.withModules(NewApplicationBuilder(s.app), + &staticModule{testModule: testModule{"blog"}, static: map[string]contractsmodular.Path{ + "blog/static/": {FS: public, Dir: "public"}, + "/": {FS: public, Dir: "public"}, // invalid prefix: reported + "/blog/secret": {FS: public, Dir: "../outside"}, // escapes the module: reported + }}, + &staticModule{testModule: testModule{"shop"}, static: map[string]contractsmodular.Path{ + "/blog/static": {FS: public, Dir: "public"}, // taken by blog: reported + }}, + ) + s.app.configureRoutes() +} + +// Capabilities whose facade is not installed are reported, not fatal. +func (s *ApplicationTestSuite) TestModules_FacadesNotInstalled() { + s.SetupTest() + routed := []string{} + s.withModules(NewApplicationBuilder(s.app), + &richModule{ + testModule: testModule{"blog"}, + config: map[string]any{"enabled": true}, + translation: fstest.MapFS{"en/messages.json": {Data: []byte(`{}`)}}, + views: fstest.MapFS{"index.tmpl": {Data: []byte("x")}}, + migrations: []schema.Migration{mocksschema.NewMigration(s.T())}, + seeders: []seeder.Seeder{mocksseeder.NewSeeder(s.T())}, + jobs: []queue.Job{mocksqueue.NewJob(s.T())}, + schedule: []schedule.Event{mocksschedule.NewEvent(s.T())}, + events: map[event.Event][]event.Listener{mocksevent.NewEvent(s.T()): {mocksevent.NewListener(s.T())}}, + routed: &routed, + }, + ) + + output := color.CaptureOutput(func(io.Writer) { + s.app.configureModuleConfig() + s.app.configureEventListeners() + s.app.configureJobs() + s.app.configureMigrations() + s.app.configureRoutes() + s.app.configureSchedule() + s.app.configureSeeders() + s.app.registerModuleResources() + }) + + s.Contains(output, errors.ConfigFacadeNotSet.SetModule(errors.ModuleModular).Error()) + for _, facade := range []string{"Event", "Queue", "Schema", "Route", "Schedule", "Seeder", "View"} { + s.Contains(output, facade+" facade not found") + } + s.Empty(routed) +} diff --git a/foundation/console/provider_make_command.go b/foundation/console/provider_make_command.go index b8586b69d..b221b29d1 100644 --- a/foundation/console/provider_make_command.go +++ b/foundation/console/provider_make_command.go @@ -41,6 +41,7 @@ func (r *ProviderMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the provider even if it already exists", }, + supportconsole.ModuleFlag(), }, } } @@ -66,14 +67,20 @@ func (r *ProviderMakeCommand) makeOne(ctx console.Context, name string) error { ctx.Success("Provider created successfully") - if env.IsBootstrapSetup() { - if err := modify.AddProvider(make.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", make.GetPackageName(), make.GetStructName())); err != nil { - return errors.ProviderRegisterFailed.Args(make.GetStructName(), err) - } - - ctx.Success("Provider registered successfully") + provider := fmt.Sprintf("&%s.%s{}", make.GetPackageName(), make.GetStructName()) + if dir := make.GetModuleDir(); dir != "" { + err = modify.AddToModule(dir, "ServiceProviders", make.GetPackageImportPath(), provider) + } else if env.IsBootstrapSetup() { + err = modify.AddProvider(make.GetPackageImportPath(), provider) + } else { + return nil + } + if err != nil { + return errors.ProviderRegisterFailed.Args(make.GetStructName(), err) } + ctx.Success("Provider registered successfully") + return nil } diff --git a/foundation/console/provider_make_command_test.go b/foundation/console/provider_make_command_test.go index 96307ffc2..d2d6c506a 100644 --- a/foundation/console/provider_make_command_test.go +++ b/foundation/console/provider_make_command_test.go @@ -2,14 +2,19 @@ package console import ( "errors" + "path/filepath" "reflect" + "strings" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" "github.com/goravel/framework/contracts/console/command" mocksconsole "github.com/goravel/framework/mocks/console" + "github.com/goravel/framework/modular" + "github.com/goravel/framework/support" "github.com/goravel/framework/support/file" ) @@ -45,7 +50,7 @@ func (s *ProviderMakeCommandTestSuite) TestExtend() { }) s.Run("should have correct number of flags", func() { - s.Require().Len(extend.Flags, 1) + s.Require().Len(extend.Flags, 2) }) if len(extend.Flags) > 0 { @@ -86,6 +91,7 @@ func (s *ProviderMakeCommandTestSuite) TestHandle() { // Test successful creation mockContext.EXPECT().Arguments().Return([]string{"UserServiceProvider"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Provider created successfully").Once() s.NoError(cmd.Handle(mockContext)) @@ -93,12 +99,14 @@ func (s *ProviderMakeCommandTestSuite) TestHandle() { // Test file already exists without force mockContext.EXPECT().Arguments().Return([]string{"UserServiceProvider"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the provider already exists. Use the --force or -f flag to overwrite").Once() s.Nil(cmd.Handle(mockContext)) // Test file already exists with force mockContext.EXPECT().Arguments().Return([]string{"UserServiceProvider"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(true).Once() mockContext.EXPECT().Success("Provider created successfully").Once() s.NoError(cmd.Handle(mockContext)) @@ -106,6 +114,7 @@ func (s *ProviderMakeCommandTestSuite) TestHandle() { // Test nested provider creation mockContext.EXPECT().Arguments().Return([]string{"auth/AuthServiceProvider"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Provider created successfully").Once() s.NoError(cmd.Handle(mockContext)) @@ -127,6 +136,7 @@ func (s *ProviderMakeCommandTestSuite) TestHandleMultipleNames() { mockContext := mocksconsole.NewContext(s.T()) mockContext.EXPECT().Arguments().Return([]string{"AuthServiceProvider", "EventServiceProvider"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Provider created successfully").Times(2) s.NoError(cmd.Handle(mockContext)) @@ -134,3 +144,106 @@ func (s *ProviderMakeCommandTestSuite) TestHandleMultipleNames() { s.FileExists("app/providers/auth_service_provider.go") s.FileExists("app/providers/event_service_provider.go") } + +// blogModule sets its own directory, so it is a module of the application. +type blogModule struct{} + +func (r *blogModule) Name() string { return "blog" } +func (r *blogModule) Path() string { return "modules/blog" } + +// useBlogModule registers blogModule with a module.go on disk for the test. +func useBlogModule(t *testing.T) { + registry := modular.NewRegistry("") + assert.NoError(t, registry.Register(&blogModule{})) + modular.SetDefault(registry) + assert.NoError(t, file.PutContent("modules/blog/module.go", `package blog + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} +`)) + + t.Cleanup(func() { + modular.SetDefault(nil) + assert.NoError(t, file.Remove("modules")) + }) +} + +func TestProviderMakeCommand_Module(t *testing.T) { + useBlogModule(t) + + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Arguments().Return([]string{"BlogServiceProvider"}).Once() + mockContext.EXPECT().Option("module").Return("blog").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Success("Provider created successfully").Once() + mockContext.EXPECT().Success("Provider registered successfully").Once() + + assert.NoError(t, NewProviderMakeCommand().Handle(mockContext)) + assert.True(t, file.Exists("modules/blog/providers/blog_service_provider.go")) + + content, err := file.GetContent("modules/blog/module.go") + assert.NoError(t, err) + assert.Contains(t, content, `/modules/blog/providers"`) + assert.Contains(t, content, `func (m *Module) ServiceProviders() []foundation.ServiceProvider { + return []foundation.ServiceProvider{ + &providers.BlogServiceProvider{}, + } +}`) +} + +func TestProviderMakeCommand_ModuleRegistrationError(t *testing.T) { + useBlogModule(t) + // ServiceProviders() does not return a slice literal, so it cannot be edited. + assert.NoError(t, file.PutContent("modules/blog/providers.go", `package blog + +import "github.com/goravel/framework/contracts/foundation" + +func (m *Module) ServiceProviders() []foundation.ServiceProvider { + return nil +} +`)) + + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Arguments().Return([]string{"BlogServiceProvider"}).Once() + mockContext.EXPECT().Option("module").Return("blog").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Success("Provider created successfully").Once() + mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, "BlogServiceProvider") + })).Once() + + assert.NoError(t, NewProviderMakeCommand().Handle(mockContext)) + assert.True(t, file.Exists("modules/blog/providers/blog_service_provider.go")) +} + +func TestProviderMakeCommand_BootstrapSetup(t *testing.T) { + bootstrapDir := support.Config.Paths.Bootstrap + assert.NoError(t, file.PutContent(filepath.Join(bootstrapDir, "app.go"), `package bootstrap + +import ( + contractsfoundation "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/foundation" +) + +func Boot() contractsfoundation.Application { + return foundation.Setup().Start() +} +`)) + t.Cleanup(func() { + assert.NoError(t, file.Remove(bootstrapDir)) + assert.NoError(t, file.Remove("app")) + }) + + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Arguments().Return([]string{"UserServiceProvider"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Success("Provider created successfully").Once() + mockContext.EXPECT().Success("Provider registered successfully").Once() + + assert.NoError(t, NewProviderMakeCommand().Handle(mockContext)) + assert.True(t, file.Contain(filepath.Join(bootstrapDir, "providers.go"), "&providers.UserServiceProvider{}")) +} diff --git a/foundation/console/test_make_command.go b/foundation/console/test_make_command.go index 6b5be7810..b56a895d7 100644 --- a/foundation/console/test_make_command.go +++ b/foundation/console/test_make_command.go @@ -40,6 +40,7 @@ func (r *TestMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the test even if it already exists", }, + supportconsole.ModuleFlag(), }, } } @@ -60,7 +61,8 @@ func (r *TestMakeCommand) makeOne(ctx console.Context, filePath string) error { stub := r.getStub() var testCase, testsImport string - if str.Of(filePath).Contains("/", "\\") { + // A module has no TestCase of its own, it uses the application's. + if m.GetModuleDir() != "" || str.Of(filePath).Contains("/", "\\") { testCase = fmt.Sprintf("%s.TestCase", packages.Paths().Tests().Package()) testsImport = fmt.Sprintf(`"%s"`, packages.Paths().Tests().Import()) } else { diff --git a/foundation/console/test_make_command_test.go b/foundation/console/test_make_command_test.go index 2e70dbd3c..64d151fdf 100644 --- a/foundation/console/test_make_command_test.go +++ b/foundation/console/test_make_command_test.go @@ -5,6 +5,7 @@ import ( "reflect" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" @@ -78,17 +79,20 @@ func (s *TestMakeCommandTestSuite) TestTestHandle() { s.Nil(cmd.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"UserTest"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Test created successfully").Once() s.NoError(cmd.Handle(mockContext)) s.True(file.Exists("tests/user_test.go")) mockContext.EXPECT().Arguments().Return([]string{"UserTest"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the test already exists. Use the --force or -f flag to overwrite").Once() s.Nil(cmd.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"user/UserTest"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Test created successfully").Once() s.NoError(cmd.Handle(mockContext)) @@ -110,6 +114,7 @@ func (s *TestMakeCommandTestSuite) TestHandleMultipleNames() { mockContext := mocksconsole.NewContext(s.T()) mockContext.EXPECT().Arguments().Return([]string{"UserTest", "PostTest"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Test created successfully").Times(2) s.NoError(cmd.Handle(mockContext)) @@ -117,3 +122,19 @@ func (s *TestMakeCommandTestSuite) TestHandleMultipleNames() { s.FileExists("tests/user_test.go") s.FileExists("tests/post_test.go") } + +func TestTestMakeCommand_Module(t *testing.T) { + useBlogModule(t) + + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Arguments().Return([]string{"PostTest"}).Once() + mockContext.EXPECT().Option("module").Return("blog").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Success("Test created successfully").Once() + + assert.NoError(t, (&TestMakeCommand{}).Handle(mockContext)) + assert.True(t, file.Contain("modules/blog/tests/post_test.go", "package tests")) + // The module has no TestCase, the application's is embedded. + assert.True(t, file.Contain("modules/blog/tests/post_test.go", "tests.TestCase")) + assert.True(t, file.Contain("modules/blog/tests/post_test.go", "/tests\"")) +} diff --git a/foundation/container.go b/foundation/container.go index 60a07ce84..f2eaed5ed 100644 --- a/foundation/container.go +++ b/foundation/container.go @@ -28,6 +28,7 @@ import ( contractshttpclient "github.com/goravel/framework/contracts/http/client" contractslog "github.com/goravel/framework/contracts/log" contractsmail "github.com/goravel/framework/contracts/mail" + contractsmodular "github.com/goravel/framework/contracts/modular" contractsnotification "github.com/goravel/framework/contracts/notification" contractsprocess "github.com/goravel/framework/contracts/process" contractsqueue "github.com/goravel/framework/contracts/queue" @@ -283,6 +284,17 @@ func (r *Container) MakeMail() contractsmail.Mail { return instance.(contractsmail.Mail) } + +func (r *Container) MakeModular() contractsmodular.Registry { + instance, err := r.Make(facades.FacadeToBinding[facades.Modular]) + if err != nil { + logMakeErrorIfNeeded(err) + return nil + } + + return instance.(contractsmodular.Registry) +} + func (r *Container) MakeNotification() contractsnotification.Manager { instance, err := r.Make(facades.FacadeToBinding[facades.Notification]) if err != nil { diff --git a/foundation/provider_repository.go b/foundation/provider_repository.go index b2f2d9404..c176b9a45 100644 --- a/foundation/provider_repository.go +++ b/foundation/provider_repository.go @@ -3,6 +3,7 @@ package foundation import ( "fmt" "maps" + "reflect" "slices" "strings" @@ -41,7 +42,7 @@ func (r *ProviderRepository) Add(providers []foundation.ServiceProvider) { } for _, provider := range providers { - key := r.getProviderName(provider) + key := r.providerKey(provider) if _, exists := r.states[key]; exists { continue @@ -60,7 +61,7 @@ func (r *ProviderRepository) Boot(app foundation.Application) { providers := r.getProviders() for _, provider := range providers { - key := r.getProviderName(provider) + key := r.providerKey(provider) state, exists := r.states[key] if !exists || !state.registered || state.booted { @@ -107,7 +108,7 @@ func (r *ProviderRepository) Register(app foundation.Application) []foundation.S processed := make([]foundation.ServiceProvider, 0, len(providers)) for _, provider := range providers { - key := r.getProviderName(provider) + key := r.providerKey(provider) state, exists := r.states[key] if !exists { state = &ProviderState{provider: provider} @@ -152,10 +153,30 @@ func (r *ProviderRepository) getRelationship(provider foundation.ServiceProvider return binding.Relationship{} } +// getProviderName is the short type name of a provider, e.g. "*console.ServiceProvider", +// used in messages. It is not unique: providers are keyed by providerKey. func (r *ProviderRepository) getProviderName(provider foundation.ServiceProvider) string { return fmt.Sprintf("%T", provider) } +// providerKey identifies a provider by its full type path, e.g. +// "*github.com/goravel/framework/console.ServiceProvider", so two packages that +// both name their provider ServiceProvider do not collide and drop one another. +// Unnamed types (no package path) fall back to the %T form. +func (r *ProviderRepository) providerKey(provider foundation.ServiceProvider) string { + t := reflect.TypeOf(provider) + prefix := "" + for t.Kind() == reflect.Pointer { + prefix += "*" + t = t.Elem() + } + if t.PkgPath() == "" || t.Name() == "" { + return fmt.Sprintf("%T", provider) + } + + return prefix + t.PkgPath() + "." + t.Name() +} + // sort performs a topological sort on a list of providers to ensure // providers with dependencies are registered and booted *after* // the providers they depend on. diff --git a/foundation/provider_repository_test.go b/foundation/provider_repository_test.go index bcf6df895..4a9109837 100644 --- a/foundation/provider_repository_test.go +++ b/foundation/provider_repository_test.go @@ -35,11 +35,40 @@ func (s *ProviderRepositoryTestSuite) TestAdd() { s.Equal(providers, s.repository.providers) s.Len(s.repository.states, 2) - s.Contains(s.repository.states, s.repository.getProviderName(providerA)) - s.Contains(s.repository.states, s.repository.getProviderName(providerB)) + s.Contains(s.repository.states, s.repository.providerKey(providerA)) + s.Contains(s.repository.states, s.repository.providerKey(providerB)) s.False(s.repository.sortedValid) } +// ServiceProvider shares its short type name, *foundation.ServiceProvider, with the +// generated mock in mocks/foundation. +type ServiceProvider struct{} + +func (r *ServiceProvider) Register(foundation.Application) {} +func (r *ServiceProvider) Boot(foundation.Application) {} + +func (s *ProviderRepositoryTestSuite) TestAdd_SameTypeNameInDifferentPackages() { + // Both are *foundation.ServiceProvider by %T; each must keep its own state. + localProvider := &ServiceProvider{} + mockProvider := mocksfoundation.NewServiceProvider(s.T()) + s.Require().Equal(s.repository.getProviderName(localProvider), s.repository.getProviderName(mockProvider)) + + s.repository.Add([]foundation.ServiceProvider{localProvider, mockProvider}) + + s.Len(s.repository.providers, 2) + s.Len(s.repository.states, 2) + s.Equal("*github.com/goravel/framework/foundation.ServiceProvider", s.repository.providerKey(localProvider)) + s.Equal("*github.com/goravel/framework/mocks/foundation.ServiceProvider", s.repository.providerKey(mockProvider)) +} + +// A type without a name of its own is keyed by its %T form. +func (s *ProviderRepositoryTestSuite) TestProviderKey_UnnamedType() { + provider := struct{ foundation.ServiceProvider }{} + + s.Equal("struct { foundation.ServiceProvider }", s.repository.providerKey(provider)) + s.Equal("*struct { foundation.ServiceProvider }", s.repository.providerKey(&provider)) +} + func (s *ProviderRepositoryTestSuite) TestAdd_Duplicates() { providerA := &AServiceProvider{} @@ -63,7 +92,7 @@ func (s *ProviderRepositoryTestSuite) TestBoot() { mockProvider.EXPECT().Boot(s.mockApp).Return().Once() s.repository.Boot(s.mockApp) - s.True(s.repository.states[s.repository.getProviderName(mockProvider)].booted) + s.True(s.repository.states[s.repository.providerKey(mockProvider)].booted) } func (s *ProviderRepositoryTestSuite) TestBoot_Idempotency() { @@ -78,7 +107,7 @@ func (s *ProviderRepositoryTestSuite) TestBoot_Idempotency() { s.repository.Boot(s.mockApp) s.repository.Boot(s.mockApp) - s.True(s.repository.states[s.repository.getProviderName(mockProvider)].booted) + s.True(s.repository.states[s.repository.providerKey(mockProvider)].booted) mockProvider.AssertExpectations(s.T()) } @@ -88,8 +117,8 @@ func (s *ProviderRepositoryTestSuite) TestGetBooted() { s.repository.Add([]foundation.ServiceProvider{providerA, providerB}) - keyA := s.repository.getProviderName(providerA) - keyB := s.repository.getProviderName(providerB) + keyA := s.repository.providerKey(providerA) + keyB := s.repository.providerKey(providerB) s.NotEqual(keyA, keyB) @@ -162,8 +191,8 @@ func (s *ProviderRepositoryTestSuite) TestRegister() { processed := s.repository.Register(s.mockApp) s.Equal([]foundation.ServiceProvider{mockProvider}, processed) - s.True(s.repository.states[s.repository.getProviderName(mockProvider)].registered) - s.False(s.repository.states[s.repository.getProviderName(mockProvider)].booted) + s.True(s.repository.states[s.repository.providerKey(mockProvider)].registered) + s.False(s.repository.states[s.repository.providerKey(mockProvider)].booted) } func (s *ProviderRepositoryTestSuite) TestRegister_Idempotency() { @@ -175,7 +204,7 @@ func (s *ProviderRepositoryTestSuite) TestRegister_Idempotency() { s.repository.Register(s.mockApp) s.repository.Register(s.mockApp) - s.True(s.repository.states[s.repository.getProviderName(mockProvider)].registered) + s.True(s.repository.states[s.repository.providerKey(mockProvider)].registered) mockProvider.AssertExpectations(s.T()) } diff --git a/http/console/controller_make_command.go b/http/console/controller_make_command.go index 8575f26ee..154acde20 100644 --- a/http/console/controller_make_command.go +++ b/http/console/controller_make_command.go @@ -38,6 +38,7 @@ func (r *ControllerMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the controller even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/http/console/controller_make_command_test.go b/http/console/controller_make_command_test.go index fbd19fe30..8af351019 100644 --- a/http/console/controller_make_command_test.go +++ b/http/console/controller_make_command_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/mock" mocksconsole "github.com/goravel/framework/mocks/console" + "github.com/goravel/framework/modular" "github.com/goravel/framework/support/file" ) @@ -21,18 +22,21 @@ func TestControllerMakeCommand(t *testing.T) { mockContext.EXPECT().Arguments().Return([]string{"UsersController"}).Once() mockContext.EXPECT().OptionBool("resource").Return(false).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Controller created successfully").Once() assert.Nil(t, controllerMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/http/controllers/users_controller.go")) mockContext.EXPECT().Arguments().Return([]string{"UsersController"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the controller already exists. Use the --force or -f flag to overwrite").Once() assert.Nil(t, controllerMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"User/AuthController"}).Once() mockContext.EXPECT().OptionBool("resource").Return(false).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Controller created successfully").Once() assert.Nil(t, controllerMakeCommand.Handle(mockContext)) @@ -49,6 +53,7 @@ func TestControllerMakeCommand_MultipleNames(t *testing.T) { mockContext.EXPECT().Arguments().Return([]string{"UserController", "OrderController"}).Once() mockContext.EXPECT().OptionBool("resource").Return(false).Times(2) + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Controller created successfully").Times(2) @@ -71,6 +76,7 @@ func TestControllerMakeCommand_MultipleNamesPartialFailure(t *testing.T) { // alongside a new one. mockContext.EXPECT().Arguments().Return([]string{"UserController"}).Once() mockContext.EXPECT().OptionBool("resource").Return(false).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Controller created successfully").Once() assert.Nil(t, controllerMakeCommand.Handle(mockContext)) @@ -79,6 +85,7 @@ func TestControllerMakeCommand_MultipleNamesPartialFailure(t *testing.T) { // UserController: NewMake checks force, then fails because the file exists, // so it never reaches the resource flag. OrderController: checks force, then // resource, then succeeds. + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().OptionBool("resource").Return(false).Once() mockContext.EXPECT().Error("the controller already exists. Use the --force or -f flag to overwrite").Once() @@ -94,6 +101,7 @@ func TestResourceControllerMakeCommand(t *testing.T) { controllerMakeCommand := &ControllerMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"User/AuthController"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().OptionBool("resource").Return(true).Once() mockContext.EXPECT().Success("Controller created successfully").Once() @@ -108,3 +116,34 @@ func TestResourceControllerMakeCommand(t *testing.T) { assert.True(t, file.Contain("app/http/controllers/User/auth_controller.go", "func (r *AuthController) Destroy(ctx http.Context) http.Response {")) assert.Nil(t, file.Remove("app")) } + +// blogModule sets its own directory, so it is a module of the application. +type blogModule struct{} + +func (r *blogModule) Name() string { return "blog" } +func (r *blogModule) Path() string { return "modules/blog" } + +func TestControllerMakeCommand_Module(t *testing.T) { + registry := modular.NewRegistry("") + assert.NoError(t, registry.Register(&blogModule{})) + modular.SetDefault(registry) + defer modular.SetDefault(nil) + defer func() { assert.Nil(t, file.Remove("modules")) }() + + controllerMakeCommand := &ControllerMakeCommand{} + mockContext := mocksconsole.NewContext(t) + + mockContext.EXPECT().Arguments().Return([]string{"admin/PostController"}).Once() + mockContext.EXPECT().OptionBool("resource").Return(false).Once() + mockContext.EXPECT().Option("module").Return("Blog").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Success("Controller created successfully").Once() + assert.Nil(t, controllerMakeCommand.Handle(mockContext)) + assert.True(t, file.Contain("modules/blog/http/controllers/admin/post_controller.go", "package admin")) + assert.False(t, file.Exists("app")) + + mockContext.EXPECT().Arguments().Return([]string{"PostController"}).Once() + mockContext.EXPECT().Option("module").Return("news").Once() + mockContext.EXPECT().Error("[modular] module news is not registered").Once() + assert.Nil(t, controllerMakeCommand.Handle(mockContext)) +} diff --git a/http/console/middleware_make_command.go b/http/console/middleware_make_command.go index 80bce5c0f..8ebe9673e 100644 --- a/http/console/middleware_make_command.go +++ b/http/console/middleware_make_command.go @@ -33,6 +33,7 @@ func (r *MiddlewareMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the middleware even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/http/console/middleware_make_command_test.go b/http/console/middleware_make_command_test.go index a24b1e80a..8c2eac5d3 100644 --- a/http/console/middleware_make_command_test.go +++ b/http/console/middleware_make_command_test.go @@ -20,17 +20,20 @@ func TestMiddlewareMakeCommand(t *testing.T) { assert.NoError(t, middlewareMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"VerifyCsrfToken"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Middleware created successfully").Once() assert.NoError(t, middlewareMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/http/middleware/verify_csrf_token.go")) mockContext.EXPECT().Arguments().Return([]string{"VerifyCsrfToken"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the middleware already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, middlewareMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"User/Auth"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Middleware created successfully").Once() assert.NoError(t, middlewareMakeCommand.Handle(mockContext)) @@ -45,6 +48,7 @@ func TestMiddlewareMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"Authenticate", "Throttle"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Middleware created successfully").Times(2) diff --git a/http/console/module_flag_test.go b/http/console/module_flag_test.go new file mode 100644 index 000000000..71117ece5 --- /dev/null +++ b/http/console/module_flag_test.go @@ -0,0 +1,30 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + &ControllerMakeCommand{}, + &MiddlewareMakeCommand{}, + &RequestMakeCommand{}, + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/http/console/request_make_command.go b/http/console/request_make_command.go index 63b5bf061..ed8199914 100644 --- a/http/console/request_make_command.go +++ b/http/console/request_make_command.go @@ -33,6 +33,7 @@ func (r *RequestMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the request even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/http/console/request_make_command_test.go b/http/console/request_make_command_test.go index 237017cf4..a60b9a112 100644 --- a/http/console/request_make_command_test.go +++ b/http/console/request_make_command_test.go @@ -20,17 +20,20 @@ func TestRequestMakeCommand(t *testing.T) { assert.NoError(t, requestMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"CreateUser"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Request created successfully").Once() assert.NoError(t, requestMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/http/requests/create_user.go")) mockContext.EXPECT().Arguments().Return([]string{"CreateUser"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the request already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, requestMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"User/Auth"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Request created successfully").Once() assert.NoError(t, requestMakeCommand.Handle(mockContext)) @@ -45,6 +48,7 @@ func TestRequestMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"StoreUser", "UpdateUser"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Request created successfully").Times(2) diff --git a/mail/console/mail_make_command.go b/mail/console/mail_make_command.go index c6b8600c2..519d3b341 100644 --- a/mail/console/mail_make_command.go +++ b/mail/console/mail_make_command.go @@ -37,6 +37,7 @@ func (r *MailMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the mail even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/mail/console/mail_make_command_test.go b/mail/console/mail_make_command_test.go index bd9d4618c..be521ceff 100644 --- a/mail/console/mail_make_command_test.go +++ b/mail/console/mail_make_command_test.go @@ -21,17 +21,20 @@ func TestMailMakeCommand(t *testing.T) { assert.False(t, file.Exists("app/mails/new_user.go")) mockContext.EXPECT().Arguments().Return([]string{"NewUser"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Mail created successfully").Once() assert.NoError(t, modelMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/mails/new_user.go")) mockContext.EXPECT().Arguments().Return([]string{"NewUser"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the mail already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, modelMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"User/VerifyEmail"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Mail created successfully").Once() assert.NoError(t, modelMakeCommand.Handle(mockContext)) @@ -47,6 +50,7 @@ func TestMailMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"VerifyEmail", "ResetPassword"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Mail created successfully").Times(2) assert.NoError(t, mailMakeCommand.Handle(mockContext)) diff --git a/mail/console/module_flag_test.go b/mail/console/module_flag_test.go new file mode 100644 index 000000000..959f197ec --- /dev/null +++ b/mail/console/module_flag_test.go @@ -0,0 +1,28 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + NewMailMakeCommand(), + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/mocks/database/migration/Migrator.go b/mocks/database/migration/Migrator.go index 8f1e92902..c1d9f90d3 100644 --- a/mocks/database/migration/Migrator.go +++ b/mocks/database/migration/Migrator.go @@ -20,9 +20,16 @@ func (_m *Migrator) EXPECT() *Migrator_Expecter { return &Migrator_Expecter{mock: &_m.Mock} } -// Create provides a mock function with given fields: name, modelName -func (_m *Migrator) Create(name string, modelName string) (string, error) { - ret := _m.Called(name, modelName) +// Create provides a mock function with given fields: name, modelName, dir +func (_m *Migrator) Create(name string, modelName string, dir ...string) (string, error) { + _va := make([]interface{}, len(dir)) + for _i := range dir { + _va[_i] = dir[_i] + } + var _ca []interface{} + _ca = append(_ca, name, modelName) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) if len(ret) == 0 { panic("no return value specified for Create") @@ -30,17 +37,17 @@ func (_m *Migrator) Create(name string, modelName string) (string, error) { var r0 string var r1 error - if rf, ok := ret.Get(0).(func(string, string) (string, error)); ok { - return rf(name, modelName) + if rf, ok := ret.Get(0).(func(string, string, ...string) (string, error)); ok { + return rf(name, modelName, dir...) } - if rf, ok := ret.Get(0).(func(string, string) string); ok { - r0 = rf(name, modelName) + if rf, ok := ret.Get(0).(func(string, string, ...string) string); ok { + r0 = rf(name, modelName, dir...) } else { r0 = ret.Get(0).(string) } - if rf, ok := ret.Get(1).(func(string, string) error); ok { - r1 = rf(name, modelName) + if rf, ok := ret.Get(1).(func(string, string, ...string) error); ok { + r1 = rf(name, modelName, dir...) } else { r1 = ret.Error(1) } @@ -56,13 +63,21 @@ type Migrator_Create_Call struct { // Create is a helper method to define mock.On call // - name string // - modelName string -func (_e *Migrator_Expecter) Create(name interface{}, modelName interface{}) *Migrator_Create_Call { - return &Migrator_Create_Call{Call: _e.mock.On("Create", name, modelName)} +// - dir ...string +func (_e *Migrator_Expecter) Create(name interface{}, modelName interface{}, dir ...interface{}) *Migrator_Create_Call { + return &Migrator_Create_Call{Call: _e.mock.On("Create", + append([]interface{}{name, modelName}, dir...)...)} } -func (_c *Migrator_Create_Call) Run(run func(name string, modelName string)) *Migrator_Create_Call { +func (_c *Migrator_Create_Call) Run(run func(name string, modelName string, dir ...string)) *Migrator_Create_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(string), args[1].(string)) + variadicArgs := make([]string, len(args)-2) + for i, a := range args[2:] { + if a != nil { + variadicArgs[i] = a.(string) + } + } + run(args[0].(string), args[1].(string), variadicArgs...) }) return _c } @@ -72,7 +87,7 @@ func (_c *Migrator_Create_Call) Return(_a0 string, _a1 error) *Migrator_Create_C return _c } -func (_c *Migrator_Create_Call) RunAndReturn(run func(string, string) (string, error)) *Migrator_Create_Call { +func (_c *Migrator_Create_Call) RunAndReturn(run func(string, string, ...string) (string, error)) *Migrator_Create_Call { _c.Call.Return(run) return _c } diff --git a/mocks/foundation/Application.go b/mocks/foundation/Application.go index 407065206..fd4684370 100644 --- a/mocks/foundation/Application.go +++ b/mocks/foundation/Application.go @@ -42,6 +42,8 @@ import ( mock "github.com/stretchr/testify/mock" + modular "github.com/goravel/framework/contracts/modular" + notification "github.com/goravel/framework/contracts/notification" orm "github.com/goravel/framework/contracts/database/orm" @@ -1989,6 +1991,53 @@ func (_c *Application_MakeMail_Call) RunAndReturn(run func() mail.Mail) *Applica return _c } +// MakeModular provides a mock function with no fields +func (_m *Application) MakeModular() modular.Registry { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for MakeModular") + } + + var r0 modular.Registry + if rf, ok := ret.Get(0).(func() modular.Registry); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(modular.Registry) + } + } + + return r0 +} + +// Application_MakeModular_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MakeModular' +type Application_MakeModular_Call struct { + *mock.Call +} + +// MakeModular is a helper method to define mock.On call +func (_e *Application_Expecter) MakeModular() *Application_MakeModular_Call { + return &Application_MakeModular_Call{Call: _e.mock.On("MakeModular")} +} + +func (_c *Application_MakeModular_Call) Run(run func()) *Application_MakeModular_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Application_MakeModular_Call) Return(_a0 modular.Registry) *Application_MakeModular_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Application_MakeModular_Call) RunAndReturn(run func() modular.Registry) *Application_MakeModular_Call { + _c.Call.Return(run) + return _c +} + // MakeNotification provides a mock function with no fields func (_m *Application) MakeNotification() notification.Manager { ret := _m.Called() diff --git a/mocks/foundation/ApplicationBuilder.go b/mocks/foundation/ApplicationBuilder.go index 91ee8b164..bc6603445 100644 --- a/mocks/foundation/ApplicationBuilder.go +++ b/mocks/foundation/ApplicationBuilder.go @@ -16,6 +16,8 @@ import ( mock "github.com/stretchr/testify/mock" + modular "github.com/goravel/framework/contracts/modular" + queue "github.com/goravel/framework/contracts/queue" schedule "github.com/goravel/framework/contracts/schedule" @@ -859,6 +861,54 @@ func (_c *ApplicationBuilder_WithMigrations_Call) RunAndReturn(run func(func() [ return _c } +// WithModules provides a mock function with given fields: _a0 +func (_m *ApplicationBuilder) WithModules(_a0 func() []modular.Module) foundation.ApplicationBuilder { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for WithModules") + } + + var r0 foundation.ApplicationBuilder + if rf, ok := ret.Get(0).(func(func() []modular.Module) foundation.ApplicationBuilder); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(foundation.ApplicationBuilder) + } + } + + return r0 +} + +// ApplicationBuilder_WithModules_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WithModules' +type ApplicationBuilder_WithModules_Call struct { + *mock.Call +} + +// WithModules is a helper method to define mock.On call +// - _a0 func() []modular.Module +func (_e *ApplicationBuilder_Expecter) WithModules(_a0 interface{}) *ApplicationBuilder_WithModules_Call { + return &ApplicationBuilder_WithModules_Call{Call: _e.mock.On("WithModules", _a0)} +} + +func (_c *ApplicationBuilder_WithModules_Call) Run(run func(_a0 func() []modular.Module)) *ApplicationBuilder_WithModules_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(func() []modular.Module)) + }) + return _c +} + +func (_c *ApplicationBuilder_WithModules_Call) Return(_a0 foundation.ApplicationBuilder) *ApplicationBuilder_WithModules_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ApplicationBuilder_WithModules_Call) RunAndReturn(run func(func() []modular.Module) foundation.ApplicationBuilder) *ApplicationBuilder_WithModules_Call { + _c.Call.Return(run) + return _c +} + // WithPaths provides a mock function with given fields: _a0 func (_m *ApplicationBuilder) WithPaths(_a0 func(configuration.Paths)) foundation.ApplicationBuilder { ret := _m.Called(_a0) diff --git a/mocks/foundation/ProvidesServices.go b/mocks/foundation/ProvidesServices.go new file mode 100644 index 000000000..f0e5ac775 --- /dev/null +++ b/mocks/foundation/ProvidesServices.go @@ -0,0 +1,82 @@ +// Code generated by mockery. DO NOT EDIT. + +package foundation + +import ( + foundation "github.com/goravel/framework/contracts/foundation" + mock "github.com/stretchr/testify/mock" +) + +// ProvidesServices is an autogenerated mock type for the ProvidesServices type +type ProvidesServices struct { + mock.Mock +} + +type ProvidesServices_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesServices) EXPECT() *ProvidesServices_Expecter { + return &ProvidesServices_Expecter{mock: &_m.Mock} +} + +// ServiceProviders provides a mock function with no fields +func (_m *ProvidesServices) ServiceProviders() []foundation.ServiceProvider { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ServiceProviders") + } + + var r0 []foundation.ServiceProvider + if rf, ok := ret.Get(0).(func() []foundation.ServiceProvider); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]foundation.ServiceProvider) + } + } + + return r0 +} + +// ProvidesServices_ServiceProviders_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServiceProviders' +type ProvidesServices_ServiceProviders_Call struct { + *mock.Call +} + +// ServiceProviders is a helper method to define mock.On call +func (_e *ProvidesServices_Expecter) ServiceProviders() *ProvidesServices_ServiceProviders_Call { + return &ProvidesServices_ServiceProviders_Call{Call: _e.mock.On("ServiceProviders")} +} + +func (_c *ProvidesServices_ServiceProviders_Call) Run(run func()) *ProvidesServices_ServiceProviders_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesServices_ServiceProviders_Call) Return(_a0 []foundation.ServiceProvider) *ProvidesServices_ServiceProviders_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesServices_ServiceProviders_Call) RunAndReturn(run func() []foundation.ServiceProvider) *ProvidesServices_ServiceProviders_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesServices creates a new instance of ProvidesServices. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesServices(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesServices { + mock := &ProvidesServices{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/Located.go b/mocks/modular/Located.go new file mode 100644 index 000000000..822aa0e50 --- /dev/null +++ b/mocks/modular/Located.go @@ -0,0 +1,77 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import mock "github.com/stretchr/testify/mock" + +// Located is an autogenerated mock type for the Located type +type Located struct { + mock.Mock +} + +type Located_Expecter struct { + mock *mock.Mock +} + +func (_m *Located) EXPECT() *Located_Expecter { + return &Located_Expecter{mock: &_m.Mock} +} + +// Path provides a mock function with no fields +func (_m *Located) Path() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Path") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// Located_Path_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Path' +type Located_Path_Call struct { + *mock.Call +} + +// Path is a helper method to define mock.On call +func (_e *Located_Expecter) Path() *Located_Path_Call { + return &Located_Path_Call{Call: _e.mock.On("Path")} +} + +func (_c *Located_Path_Call) Run(run func()) *Located_Path_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Located_Path_Call) Return(_a0 string) *Located_Path_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Located_Path_Call) RunAndReturn(run func() string) *Located_Path_Call { + _c.Call.Return(run) + return _c +} + +// NewLocated creates a new instance of Located. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewLocated(t interface { + mock.TestingT + Cleanup(func()) +}) *Located { + mock := &Located{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/Module.go b/mocks/modular/Module.go new file mode 100644 index 000000000..14c56ae75 --- /dev/null +++ b/mocks/modular/Module.go @@ -0,0 +1,77 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import mock "github.com/stretchr/testify/mock" + +// Module is an autogenerated mock type for the Module type +type Module struct { + mock.Mock +} + +type Module_Expecter struct { + mock *mock.Mock +} + +func (_m *Module) EXPECT() *Module_Expecter { + return &Module_Expecter{mock: &_m.Mock} +} + +// Name provides a mock function with no fields +func (_m *Module) Name() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Name") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// Module_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name' +type Module_Name_Call struct { + *mock.Call +} + +// Name is a helper method to define mock.On call +func (_e *Module_Expecter) Name() *Module_Name_Call { + return &Module_Name_Call{Call: _e.mock.On("Name")} +} + +func (_c *Module_Name_Call) Run(run func()) *Module_Name_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Module_Name_Call) Return(_a0 string) *Module_Name_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Module_Name_Call) RunAndReturn(run func() string) *Module_Name_Call { + _c.Call.Return(run) + return _c +} + +// NewModule creates a new instance of Module. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewModule(t interface { + mock.TestingT + Cleanup(func()) +}) *Module { + mock := &Module{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/Ordered.go b/mocks/modular/Ordered.go new file mode 100644 index 000000000..5c12a1db6 --- /dev/null +++ b/mocks/modular/Ordered.go @@ -0,0 +1,77 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import mock "github.com/stretchr/testify/mock" + +// Ordered is an autogenerated mock type for the Ordered type +type Ordered struct { + mock.Mock +} + +type Ordered_Expecter struct { + mock *mock.Mock +} + +func (_m *Ordered) EXPECT() *Ordered_Expecter { + return &Ordered_Expecter{mock: &_m.Mock} +} + +// Order provides a mock function with no fields +func (_m *Ordered) Order() int { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Order") + } + + var r0 int + if rf, ok := ret.Get(0).(func() int); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(int) + } + + return r0 +} + +// Ordered_Order_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Order' +type Ordered_Order_Call struct { + *mock.Call +} + +// Order is a helper method to define mock.On call +func (_e *Ordered_Expecter) Order() *Ordered_Order_Call { + return &Ordered_Order_Call{Call: _e.mock.On("Order")} +} + +func (_c *Ordered_Order_Call) Run(run func()) *Ordered_Order_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Ordered_Order_Call) Return(_a0 int) *Ordered_Order_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Ordered_Order_Call) RunAndReturn(run func() int) *Ordered_Order_Call { + _c.Call.Return(run) + return _c +} + +// NewOrdered creates a new instance of Ordered. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewOrdered(t interface { + mock.TestingT + Cleanup(func()) +}) *Ordered { + mock := &Ordered{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesApiRoutes.go b/mocks/modular/ProvidesApiRoutes.go new file mode 100644 index 000000000..ee0860dfb --- /dev/null +++ b/mocks/modular/ProvidesApiRoutes.go @@ -0,0 +1,69 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + mock "github.com/stretchr/testify/mock" + + route "github.com/goravel/framework/contracts/route" +) + +// ProvidesApiRoutes is an autogenerated mock type for the ProvidesApiRoutes type +type ProvidesApiRoutes struct { + mock.Mock +} + +type ProvidesApiRoutes_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesApiRoutes) EXPECT() *ProvidesApiRoutes_Expecter { + return &ProvidesApiRoutes_Expecter{mock: &_m.Mock} +} + +// ApiRoutes provides a mock function with given fields: router +func (_m *ProvidesApiRoutes) ApiRoutes(router route.Router) { + _m.Called(router) +} + +// ProvidesApiRoutes_ApiRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApiRoutes' +type ProvidesApiRoutes_ApiRoutes_Call struct { + *mock.Call +} + +// ApiRoutes is a helper method to define mock.On call +// - router route.Router +func (_e *ProvidesApiRoutes_Expecter) ApiRoutes(router interface{}) *ProvidesApiRoutes_ApiRoutes_Call { + return &ProvidesApiRoutes_ApiRoutes_Call{Call: _e.mock.On("ApiRoutes", router)} +} + +func (_c *ProvidesApiRoutes_ApiRoutes_Call) Run(run func(router route.Router)) *ProvidesApiRoutes_ApiRoutes_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(route.Router)) + }) + return _c +} + +func (_c *ProvidesApiRoutes_ApiRoutes_Call) Return() *ProvidesApiRoutes_ApiRoutes_Call { + _c.Call.Return() + return _c +} + +func (_c *ProvidesApiRoutes_ApiRoutes_Call) RunAndReturn(run func(route.Router)) *ProvidesApiRoutes_ApiRoutes_Call { + _c.Run(run) + return _c +} + +// NewProvidesApiRoutes creates a new instance of ProvidesApiRoutes. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesApiRoutes(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesApiRoutes { + mock := &ProvidesApiRoutes{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesCommands.go b/mocks/modular/ProvidesCommands.go new file mode 100644 index 000000000..a60d67a27 --- /dev/null +++ b/mocks/modular/ProvidesCommands.go @@ -0,0 +1,82 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + console "github.com/goravel/framework/contracts/console" + mock "github.com/stretchr/testify/mock" +) + +// ProvidesCommands is an autogenerated mock type for the ProvidesCommands type +type ProvidesCommands struct { + mock.Mock +} + +type ProvidesCommands_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesCommands) EXPECT() *ProvidesCommands_Expecter { + return &ProvidesCommands_Expecter{mock: &_m.Mock} +} + +// Commands provides a mock function with no fields +func (_m *ProvidesCommands) Commands() []console.Command { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Commands") + } + + var r0 []console.Command + if rf, ok := ret.Get(0).(func() []console.Command); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]console.Command) + } + } + + return r0 +} + +// ProvidesCommands_Commands_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Commands' +type ProvidesCommands_Commands_Call struct { + *mock.Call +} + +// Commands is a helper method to define mock.On call +func (_e *ProvidesCommands_Expecter) Commands() *ProvidesCommands_Commands_Call { + return &ProvidesCommands_Commands_Call{Call: _e.mock.On("Commands")} +} + +func (_c *ProvidesCommands_Commands_Call) Run(run func()) *ProvidesCommands_Commands_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesCommands_Commands_Call) Return(_a0 []console.Command) *ProvidesCommands_Commands_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesCommands_Commands_Call) RunAndReturn(run func() []console.Command) *ProvidesCommands_Commands_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesCommands creates a new instance of ProvidesCommands. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesCommands(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesCommands { + mock := &ProvidesCommands{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesConfig.go b/mocks/modular/ProvidesConfig.go new file mode 100644 index 000000000..45892fb31 --- /dev/null +++ b/mocks/modular/ProvidesConfig.go @@ -0,0 +1,79 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import mock "github.com/stretchr/testify/mock" + +// ProvidesConfig is an autogenerated mock type for the ProvidesConfig type +type ProvidesConfig struct { + mock.Mock +} + +type ProvidesConfig_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesConfig) EXPECT() *ProvidesConfig_Expecter { + return &ProvidesConfig_Expecter{mock: &_m.Mock} +} + +// Config provides a mock function with no fields +func (_m *ProvidesConfig) Config() map[string]interface{} { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Config") + } + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func() map[string]interface{}); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + return r0 +} + +// ProvidesConfig_Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Config' +type ProvidesConfig_Config_Call struct { + *mock.Call +} + +// Config is a helper method to define mock.On call +func (_e *ProvidesConfig_Expecter) Config() *ProvidesConfig_Config_Call { + return &ProvidesConfig_Config_Call{Call: _e.mock.On("Config")} +} + +func (_c *ProvidesConfig_Config_Call) Run(run func()) *ProvidesConfig_Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesConfig_Config_Call) Return(_a0 map[string]interface{}) *ProvidesConfig_Config_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesConfig_Config_Call) RunAndReturn(run func() map[string]interface{}) *ProvidesConfig_Config_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesConfig creates a new instance of ProvidesConfig. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesConfig(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesConfig { + mock := &ProvidesConfig{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesEvents.go b/mocks/modular/ProvidesEvents.go new file mode 100644 index 000000000..82e7e09c6 --- /dev/null +++ b/mocks/modular/ProvidesEvents.go @@ -0,0 +1,82 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + event "github.com/goravel/framework/contracts/event" + mock "github.com/stretchr/testify/mock" +) + +// ProvidesEvents is an autogenerated mock type for the ProvidesEvents type +type ProvidesEvents struct { + mock.Mock +} + +type ProvidesEvents_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesEvents) EXPECT() *ProvidesEvents_Expecter { + return &ProvidesEvents_Expecter{mock: &_m.Mock} +} + +// Events provides a mock function with no fields +func (_m *ProvidesEvents) Events() map[event.Event][]event.Listener { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Events") + } + + var r0 map[event.Event][]event.Listener + if rf, ok := ret.Get(0).(func() map[event.Event][]event.Listener); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[event.Event][]event.Listener) + } + } + + return r0 +} + +// ProvidesEvents_Events_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Events' +type ProvidesEvents_Events_Call struct { + *mock.Call +} + +// Events is a helper method to define mock.On call +func (_e *ProvidesEvents_Expecter) Events() *ProvidesEvents_Events_Call { + return &ProvidesEvents_Events_Call{Call: _e.mock.On("Events")} +} + +func (_c *ProvidesEvents_Events_Call) Run(run func()) *ProvidesEvents_Events_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesEvents_Events_Call) Return(_a0 map[event.Event][]event.Listener) *ProvidesEvents_Events_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesEvents_Events_Call) RunAndReturn(run func() map[event.Event][]event.Listener) *ProvidesEvents_Events_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesEvents creates a new instance of ProvidesEvents. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesEvents(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesEvents { + mock := &ProvidesEvents{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesGlobalMiddleware.go b/mocks/modular/ProvidesGlobalMiddleware.go new file mode 100644 index 000000000..c103e1179 --- /dev/null +++ b/mocks/modular/ProvidesGlobalMiddleware.go @@ -0,0 +1,82 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + http "github.com/goravel/framework/contracts/http" + mock "github.com/stretchr/testify/mock" +) + +// ProvidesGlobalMiddleware is an autogenerated mock type for the ProvidesGlobalMiddleware type +type ProvidesGlobalMiddleware struct { + mock.Mock +} + +type ProvidesGlobalMiddleware_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesGlobalMiddleware) EXPECT() *ProvidesGlobalMiddleware_Expecter { + return &ProvidesGlobalMiddleware_Expecter{mock: &_m.Mock} +} + +// GlobalMiddleware provides a mock function with no fields +func (_m *ProvidesGlobalMiddleware) GlobalMiddleware() []http.Middleware { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GlobalMiddleware") + } + + var r0 []http.Middleware + if rf, ok := ret.Get(0).(func() []http.Middleware); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]http.Middleware) + } + } + + return r0 +} + +// ProvidesGlobalMiddleware_GlobalMiddleware_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalMiddleware' +type ProvidesGlobalMiddleware_GlobalMiddleware_Call struct { + *mock.Call +} + +// GlobalMiddleware is a helper method to define mock.On call +func (_e *ProvidesGlobalMiddleware_Expecter) GlobalMiddleware() *ProvidesGlobalMiddleware_GlobalMiddleware_Call { + return &ProvidesGlobalMiddleware_GlobalMiddleware_Call{Call: _e.mock.On("GlobalMiddleware")} +} + +func (_c *ProvidesGlobalMiddleware_GlobalMiddleware_Call) Run(run func()) *ProvidesGlobalMiddleware_GlobalMiddleware_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesGlobalMiddleware_GlobalMiddleware_Call) Return(_a0 []http.Middleware) *ProvidesGlobalMiddleware_GlobalMiddleware_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesGlobalMiddleware_GlobalMiddleware_Call) RunAndReturn(run func() []http.Middleware) *ProvidesGlobalMiddleware_GlobalMiddleware_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesGlobalMiddleware creates a new instance of ProvidesGlobalMiddleware. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesGlobalMiddleware(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesGlobalMiddleware { + mock := &ProvidesGlobalMiddleware{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesJobs.go b/mocks/modular/ProvidesJobs.go new file mode 100644 index 000000000..85215af12 --- /dev/null +++ b/mocks/modular/ProvidesJobs.go @@ -0,0 +1,83 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + mock "github.com/stretchr/testify/mock" + + queue "github.com/goravel/framework/contracts/queue" +) + +// ProvidesJobs is an autogenerated mock type for the ProvidesJobs type +type ProvidesJobs struct { + mock.Mock +} + +type ProvidesJobs_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesJobs) EXPECT() *ProvidesJobs_Expecter { + return &ProvidesJobs_Expecter{mock: &_m.Mock} +} + +// Jobs provides a mock function with no fields +func (_m *ProvidesJobs) Jobs() []queue.Job { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Jobs") + } + + var r0 []queue.Job + if rf, ok := ret.Get(0).(func() []queue.Job); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]queue.Job) + } + } + + return r0 +} + +// ProvidesJobs_Jobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Jobs' +type ProvidesJobs_Jobs_Call struct { + *mock.Call +} + +// Jobs is a helper method to define mock.On call +func (_e *ProvidesJobs_Expecter) Jobs() *ProvidesJobs_Jobs_Call { + return &ProvidesJobs_Jobs_Call{Call: _e.mock.On("Jobs")} +} + +func (_c *ProvidesJobs_Jobs_Call) Run(run func()) *ProvidesJobs_Jobs_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesJobs_Jobs_Call) Return(_a0 []queue.Job) *ProvidesJobs_Jobs_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesJobs_Jobs_Call) RunAndReturn(run func() []queue.Job) *ProvidesJobs_Jobs_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesJobs creates a new instance of ProvidesJobs. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesJobs(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesJobs { + mock := &ProvidesJobs{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesMigrations.go b/mocks/modular/ProvidesMigrations.go new file mode 100644 index 000000000..e2ffa69be --- /dev/null +++ b/mocks/modular/ProvidesMigrations.go @@ -0,0 +1,83 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + mock "github.com/stretchr/testify/mock" + + schema "github.com/goravel/framework/contracts/database/schema" +) + +// ProvidesMigrations is an autogenerated mock type for the ProvidesMigrations type +type ProvidesMigrations struct { + mock.Mock +} + +type ProvidesMigrations_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesMigrations) EXPECT() *ProvidesMigrations_Expecter { + return &ProvidesMigrations_Expecter{mock: &_m.Mock} +} + +// Migrations provides a mock function with no fields +func (_m *ProvidesMigrations) Migrations() []schema.Migration { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Migrations") + } + + var r0 []schema.Migration + if rf, ok := ret.Get(0).(func() []schema.Migration); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]schema.Migration) + } + } + + return r0 +} + +// ProvidesMigrations_Migrations_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Migrations' +type ProvidesMigrations_Migrations_Call struct { + *mock.Call +} + +// Migrations is a helper method to define mock.On call +func (_e *ProvidesMigrations_Expecter) Migrations() *ProvidesMigrations_Migrations_Call { + return &ProvidesMigrations_Migrations_Call{Call: _e.mock.On("Migrations")} +} + +func (_c *ProvidesMigrations_Migrations_Call) Run(run func()) *ProvidesMigrations_Migrations_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesMigrations_Migrations_Call) Return(_a0 []schema.Migration) *ProvidesMigrations_Migrations_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesMigrations_Migrations_Call) RunAndReturn(run func() []schema.Migration) *ProvidesMigrations_Migrations_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesMigrations creates a new instance of ProvidesMigrations. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesMigrations(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesMigrations { + mock := &ProvidesMigrations{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesRoutes.go b/mocks/modular/ProvidesRoutes.go new file mode 100644 index 000000000..5d3aca3e0 --- /dev/null +++ b/mocks/modular/ProvidesRoutes.go @@ -0,0 +1,69 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + mock "github.com/stretchr/testify/mock" + + route "github.com/goravel/framework/contracts/route" +) + +// ProvidesRoutes is an autogenerated mock type for the ProvidesRoutes type +type ProvidesRoutes struct { + mock.Mock +} + +type ProvidesRoutes_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesRoutes) EXPECT() *ProvidesRoutes_Expecter { + return &ProvidesRoutes_Expecter{mock: &_m.Mock} +} + +// Routes provides a mock function with given fields: router +func (_m *ProvidesRoutes) Routes(router route.Router) { + _m.Called(router) +} + +// ProvidesRoutes_Routes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Routes' +type ProvidesRoutes_Routes_Call struct { + *mock.Call +} + +// Routes is a helper method to define mock.On call +// - router route.Router +func (_e *ProvidesRoutes_Expecter) Routes(router interface{}) *ProvidesRoutes_Routes_Call { + return &ProvidesRoutes_Routes_Call{Call: _e.mock.On("Routes", router)} +} + +func (_c *ProvidesRoutes_Routes_Call) Run(run func(router route.Router)) *ProvidesRoutes_Routes_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(route.Router)) + }) + return _c +} + +func (_c *ProvidesRoutes_Routes_Call) Return() *ProvidesRoutes_Routes_Call { + _c.Call.Return() + return _c +} + +func (_c *ProvidesRoutes_Routes_Call) RunAndReturn(run func(route.Router)) *ProvidesRoutes_Routes_Call { + _c.Run(run) + return _c +} + +// NewProvidesRoutes creates a new instance of ProvidesRoutes. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesRoutes(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesRoutes { + mock := &ProvidesRoutes{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesSchedule.go b/mocks/modular/ProvidesSchedule.go new file mode 100644 index 000000000..30c14ec8b --- /dev/null +++ b/mocks/modular/ProvidesSchedule.go @@ -0,0 +1,83 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + mock "github.com/stretchr/testify/mock" + + schedule "github.com/goravel/framework/contracts/schedule" +) + +// ProvidesSchedule is an autogenerated mock type for the ProvidesSchedule type +type ProvidesSchedule struct { + mock.Mock +} + +type ProvidesSchedule_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesSchedule) EXPECT() *ProvidesSchedule_Expecter { + return &ProvidesSchedule_Expecter{mock: &_m.Mock} +} + +// Schedule provides a mock function with no fields +func (_m *ProvidesSchedule) Schedule() []schedule.Event { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Schedule") + } + + var r0 []schedule.Event + if rf, ok := ret.Get(0).(func() []schedule.Event); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]schedule.Event) + } + } + + return r0 +} + +// ProvidesSchedule_Schedule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Schedule' +type ProvidesSchedule_Schedule_Call struct { + *mock.Call +} + +// Schedule is a helper method to define mock.On call +func (_e *ProvidesSchedule_Expecter) Schedule() *ProvidesSchedule_Schedule_Call { + return &ProvidesSchedule_Schedule_Call{Call: _e.mock.On("Schedule")} +} + +func (_c *ProvidesSchedule_Schedule_Call) Run(run func()) *ProvidesSchedule_Schedule_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesSchedule_Schedule_Call) Return(_a0 []schedule.Event) *ProvidesSchedule_Schedule_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesSchedule_Schedule_Call) RunAndReturn(run func() []schedule.Event) *ProvidesSchedule_Schedule_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesSchedule creates a new instance of ProvidesSchedule. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesSchedule(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesSchedule { + mock := &ProvidesSchedule{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesSeeders.go b/mocks/modular/ProvidesSeeders.go new file mode 100644 index 000000000..c94984267 --- /dev/null +++ b/mocks/modular/ProvidesSeeders.go @@ -0,0 +1,83 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + mock "github.com/stretchr/testify/mock" + + seeder "github.com/goravel/framework/contracts/database/seeder" +) + +// ProvidesSeeders is an autogenerated mock type for the ProvidesSeeders type +type ProvidesSeeders struct { + mock.Mock +} + +type ProvidesSeeders_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesSeeders) EXPECT() *ProvidesSeeders_Expecter { + return &ProvidesSeeders_Expecter{mock: &_m.Mock} +} + +// Seeders provides a mock function with no fields +func (_m *ProvidesSeeders) Seeders() []seeder.Seeder { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Seeders") + } + + var r0 []seeder.Seeder + if rf, ok := ret.Get(0).(func() []seeder.Seeder); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]seeder.Seeder) + } + } + + return r0 +} + +// ProvidesSeeders_Seeders_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Seeders' +type ProvidesSeeders_Seeders_Call struct { + *mock.Call +} + +// Seeders is a helper method to define mock.On call +func (_e *ProvidesSeeders_Expecter) Seeders() *ProvidesSeeders_Seeders_Call { + return &ProvidesSeeders_Seeders_Call{Call: _e.mock.On("Seeders")} +} + +func (_c *ProvidesSeeders_Seeders_Call) Run(run func()) *ProvidesSeeders_Seeders_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesSeeders_Seeders_Call) Return(_a0 []seeder.Seeder) *ProvidesSeeders_Seeders_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesSeeders_Seeders_Call) RunAndReturn(run func() []seeder.Seeder) *ProvidesSeeders_Seeders_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesSeeders creates a new instance of ProvidesSeeders. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesSeeders(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesSeeders { + mock := &ProvidesSeeders{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesSources.go b/mocks/modular/ProvidesSources.go new file mode 100644 index 000000000..81979d092 --- /dev/null +++ b/mocks/modular/ProvidesSources.go @@ -0,0 +1,83 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + fs "io/fs" + + mock "github.com/stretchr/testify/mock" +) + +// ProvidesSources is an autogenerated mock type for the ProvidesSources type +type ProvidesSources struct { + mock.Mock +} + +type ProvidesSources_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesSources) EXPECT() *ProvidesSources_Expecter { + return &ProvidesSources_Expecter{mock: &_m.Mock} +} + +// Sources provides a mock function with no fields +func (_m *ProvidesSources) Sources() fs.FS { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Sources") + } + + var r0 fs.FS + if rf, ok := ret.Get(0).(func() fs.FS); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(fs.FS) + } + } + + return r0 +} + +// ProvidesSources_Sources_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sources' +type ProvidesSources_Sources_Call struct { + *mock.Call +} + +// Sources is a helper method to define mock.On call +func (_e *ProvidesSources_Expecter) Sources() *ProvidesSources_Sources_Call { + return &ProvidesSources_Sources_Call{Call: _e.mock.On("Sources")} +} + +func (_c *ProvidesSources_Sources_Call) Run(run func()) *ProvidesSources_Sources_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesSources_Sources_Call) Return(_a0 fs.FS) *ProvidesSources_Sources_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesSources_Sources_Call) RunAndReturn(run func() fs.FS) *ProvidesSources_Sources_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesSources creates a new instance of ProvidesSources. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesSources(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesSources { + mock := &ProvidesSources{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesStatic.go b/mocks/modular/ProvidesStatic.go new file mode 100644 index 000000000..c53f98806 --- /dev/null +++ b/mocks/modular/ProvidesStatic.go @@ -0,0 +1,82 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + modular "github.com/goravel/framework/contracts/modular" + mock "github.com/stretchr/testify/mock" +) + +// ProvidesStatic is an autogenerated mock type for the ProvidesStatic type +type ProvidesStatic struct { + mock.Mock +} + +type ProvidesStatic_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesStatic) EXPECT() *ProvidesStatic_Expecter { + return &ProvidesStatic_Expecter{mock: &_m.Mock} +} + +// Static provides a mock function with no fields +func (_m *ProvidesStatic) Static() map[string]modular.Path { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Static") + } + + var r0 map[string]modular.Path + if rf, ok := ret.Get(0).(func() map[string]modular.Path); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]modular.Path) + } + } + + return r0 +} + +// ProvidesStatic_Static_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Static' +type ProvidesStatic_Static_Call struct { + *mock.Call +} + +// Static is a helper method to define mock.On call +func (_e *ProvidesStatic_Expecter) Static() *ProvidesStatic_Static_Call { + return &ProvidesStatic_Static_Call{Call: _e.mock.On("Static")} +} + +func (_c *ProvidesStatic_Static_Call) Run(run func()) *ProvidesStatic_Static_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesStatic_Static_Call) Return(_a0 map[string]modular.Path) *ProvidesStatic_Static_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesStatic_Static_Call) RunAndReturn(run func() map[string]modular.Path) *ProvidesStatic_Static_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesStatic creates a new instance of ProvidesStatic. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesStatic(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesStatic { + mock := &ProvidesStatic{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesTranslations.go b/mocks/modular/ProvidesTranslations.go new file mode 100644 index 000000000..761366f3a --- /dev/null +++ b/mocks/modular/ProvidesTranslations.go @@ -0,0 +1,83 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + fs "io/fs" + + mock "github.com/stretchr/testify/mock" +) + +// ProvidesTranslations is an autogenerated mock type for the ProvidesTranslations type +type ProvidesTranslations struct { + mock.Mock +} + +type ProvidesTranslations_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesTranslations) EXPECT() *ProvidesTranslations_Expecter { + return &ProvidesTranslations_Expecter{mock: &_m.Mock} +} + +// Translations provides a mock function with no fields +func (_m *ProvidesTranslations) Translations() fs.FS { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Translations") + } + + var r0 fs.FS + if rf, ok := ret.Get(0).(func() fs.FS); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(fs.FS) + } + } + + return r0 +} + +// ProvidesTranslations_Translations_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Translations' +type ProvidesTranslations_Translations_Call struct { + *mock.Call +} + +// Translations is a helper method to define mock.On call +func (_e *ProvidesTranslations_Expecter) Translations() *ProvidesTranslations_Translations_Call { + return &ProvidesTranslations_Translations_Call{Call: _e.mock.On("Translations")} +} + +func (_c *ProvidesTranslations_Translations_Call) Run(run func()) *ProvidesTranslations_Translations_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesTranslations_Translations_Call) Return(_a0 fs.FS) *ProvidesTranslations_Translations_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesTranslations_Translations_Call) RunAndReturn(run func() fs.FS) *ProvidesTranslations_Translations_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesTranslations creates a new instance of ProvidesTranslations. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesTranslations(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesTranslations { + mock := &ProvidesTranslations{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesViews.go b/mocks/modular/ProvidesViews.go new file mode 100644 index 000000000..3ea191259 --- /dev/null +++ b/mocks/modular/ProvidesViews.go @@ -0,0 +1,83 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + fs "io/fs" + + mock "github.com/stretchr/testify/mock" +) + +// ProvidesViews is an autogenerated mock type for the ProvidesViews type +type ProvidesViews struct { + mock.Mock +} + +type ProvidesViews_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesViews) EXPECT() *ProvidesViews_Expecter { + return &ProvidesViews_Expecter{mock: &_m.Mock} +} + +// Views provides a mock function with no fields +func (_m *ProvidesViews) Views() fs.FS { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Views") + } + + var r0 fs.FS + if rf, ok := ret.Get(0).(func() fs.FS); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(fs.FS) + } + } + + return r0 +} + +// ProvidesViews_Views_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Views' +type ProvidesViews_Views_Call struct { + *mock.Call +} + +// Views is a helper method to define mock.On call +func (_e *ProvidesViews_Expecter) Views() *ProvidesViews_Views_Call { + return &ProvidesViews_Views_Call{Call: _e.mock.On("Views")} +} + +func (_c *ProvidesViews_Views_Call) Run(run func()) *ProvidesViews_Views_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesViews_Views_Call) Return(_a0 fs.FS) *ProvidesViews_Views_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesViews_Views_Call) RunAndReturn(run func() fs.FS) *ProvidesViews_Views_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesViews creates a new instance of ProvidesViews. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesViews(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesViews { + mock := &ProvidesViews{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesViteAlias.go b/mocks/modular/ProvidesViteAlias.go new file mode 100644 index 000000000..6dc6b53cd --- /dev/null +++ b/mocks/modular/ProvidesViteAlias.go @@ -0,0 +1,79 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import mock "github.com/stretchr/testify/mock" + +// ProvidesViteAlias is an autogenerated mock type for the ProvidesViteAlias type +type ProvidesViteAlias struct { + mock.Mock +} + +type ProvidesViteAlias_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesViteAlias) EXPECT() *ProvidesViteAlias_Expecter { + return &ProvidesViteAlias_Expecter{mock: &_m.Mock} +} + +// ViteAliases provides a mock function with no fields +func (_m *ProvidesViteAlias) ViteAliases() map[string]string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ViteAliases") + } + + var r0 map[string]string + if rf, ok := ret.Get(0).(func() map[string]string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]string) + } + } + + return r0 +} + +// ProvidesViteAlias_ViteAliases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ViteAliases' +type ProvidesViteAlias_ViteAliases_Call struct { + *mock.Call +} + +// ViteAliases is a helper method to define mock.On call +func (_e *ProvidesViteAlias_Expecter) ViteAliases() *ProvidesViteAlias_ViteAliases_Call { + return &ProvidesViteAlias_ViteAliases_Call{Call: _e.mock.On("ViteAliases")} +} + +func (_c *ProvidesViteAlias_ViteAliases_Call) Run(run func()) *ProvidesViteAlias_ViteAliases_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesViteAlias_ViteAliases_Call) Return(_a0 map[string]string) *ProvidesViteAlias_ViteAliases_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesViteAlias_ViteAliases_Call) RunAndReturn(run func() map[string]string) *ProvidesViteAlias_ViteAliases_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesViteAlias creates a new instance of ProvidesViteAlias. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesViteAlias(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesViteAlias { + mock := &ProvidesViteAlias{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesViteEntries.go b/mocks/modular/ProvidesViteEntries.go new file mode 100644 index 000000000..7fe81c189 --- /dev/null +++ b/mocks/modular/ProvidesViteEntries.go @@ -0,0 +1,79 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import mock "github.com/stretchr/testify/mock" + +// ProvidesViteEntries is an autogenerated mock type for the ProvidesViteEntries type +type ProvidesViteEntries struct { + mock.Mock +} + +type ProvidesViteEntries_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesViteEntries) EXPECT() *ProvidesViteEntries_Expecter { + return &ProvidesViteEntries_Expecter{mock: &_m.Mock} +} + +// ViteEntries provides a mock function with no fields +func (_m *ProvidesViteEntries) ViteEntries() []string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ViteEntries") + } + + var r0 []string + if rf, ok := ret.Get(0).(func() []string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + return r0 +} + +// ProvidesViteEntries_ViteEntries_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ViteEntries' +type ProvidesViteEntries_ViteEntries_Call struct { + *mock.Call +} + +// ViteEntries is a helper method to define mock.On call +func (_e *ProvidesViteEntries_Expecter) ViteEntries() *ProvidesViteEntries_ViteEntries_Call { + return &ProvidesViteEntries_ViteEntries_Call{Call: _e.mock.On("ViteEntries")} +} + +func (_c *ProvidesViteEntries_ViteEntries_Call) Run(run func()) *ProvidesViteEntries_ViteEntries_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesViteEntries_ViteEntries_Call) Return(_a0 []string) *ProvidesViteEntries_ViteEntries_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesViteEntries_ViteEntries_Call) RunAndReturn(run func() []string) *ProvidesViteEntries_ViteEntries_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesViteEntries creates a new instance of ProvidesViteEntries. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesViteEntries(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesViteEntries { + mock := &ProvidesViteEntries{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/Registry.go b/mocks/modular/Registry.go new file mode 100644 index 000000000..2769bf3ad --- /dev/null +++ b/mocks/modular/Registry.go @@ -0,0 +1,349 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + modular "github.com/goravel/framework/contracts/modular" + mock "github.com/stretchr/testify/mock" +) + +// Registry is an autogenerated mock type for the Registry type +type Registry struct { + mock.Mock +} + +type Registry_Expecter struct { + mock *mock.Mock +} + +func (_m *Registry) EXPECT() *Registry_Expecter { + return &Registry_Expecter{mock: &_m.Mock} +} + +// All provides a mock function with no fields +func (_m *Registry) All() []modular.Module { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for All") + } + + var r0 []modular.Module + if rf, ok := ret.Get(0).(func() []modular.Module); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]modular.Module) + } + } + + return r0 +} + +// Registry_All_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'All' +type Registry_All_Call struct { + *mock.Call +} + +// All is a helper method to define mock.On call +func (_e *Registry_Expecter) All() *Registry_All_Call { + return &Registry_All_Call{Call: _e.mock.On("All")} +} + +func (_c *Registry_All_Call) Run(run func()) *Registry_All_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Registry_All_Call) Return(_a0 []modular.Module) *Registry_All_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Registry_All_Call) RunAndReturn(run func() []modular.Module) *Registry_All_Call { + _c.Call.Return(run) + return _c +} + +// Capabilities provides a mock function with given fields: module +func (_m *Registry) Capabilities(module modular.Module) map[string]bool { + ret := _m.Called(module) + + if len(ret) == 0 { + panic("no return value specified for Capabilities") + } + + var r0 map[string]bool + if rf, ok := ret.Get(0).(func(modular.Module) map[string]bool); ok { + r0 = rf(module) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]bool) + } + } + + return r0 +} + +// Registry_Capabilities_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Capabilities' +type Registry_Capabilities_Call struct { + *mock.Call +} + +// Capabilities is a helper method to define mock.On call +// - module modular.Module +func (_e *Registry_Expecter) Capabilities(module interface{}) *Registry_Capabilities_Call { + return &Registry_Capabilities_Call{Call: _e.mock.On("Capabilities", module)} +} + +func (_c *Registry_Capabilities_Call) Run(run func(module modular.Module)) *Registry_Capabilities_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(modular.Module)) + }) + return _c +} + +func (_c *Registry_Capabilities_Call) Return(_a0 map[string]bool) *Registry_Capabilities_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Registry_Capabilities_Call) RunAndReturn(run func(modular.Module) map[string]bool) *Registry_Capabilities_Call { + _c.Call.Return(run) + return _c +} + +// Find provides a mock function with given fields: name +func (_m *Registry) Find(name string) (modular.Module, bool) { + ret := _m.Called(name) + + if len(ret) == 0 { + panic("no return value specified for Find") + } + + var r0 modular.Module + var r1 bool + if rf, ok := ret.Get(0).(func(string) (modular.Module, bool)); ok { + return rf(name) + } + if rf, ok := ret.Get(0).(func(string) modular.Module); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(modular.Module) + } + } + + if rf, ok := ret.Get(1).(func(string) bool); ok { + r1 = rf(name) + } else { + r1 = ret.Get(1).(bool) + } + + return r0, r1 +} + +// Registry_Find_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Find' +type Registry_Find_Call struct { + *mock.Call +} + +// Find is a helper method to define mock.On call +// - name string +func (_e *Registry_Expecter) Find(name interface{}) *Registry_Find_Call { + return &Registry_Find_Call{Call: _e.mock.On("Find", name)} +} + +func (_c *Registry_Find_Call) Run(run func(name string)) *Registry_Find_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Registry_Find_Call) Return(_a0 modular.Module, _a1 bool) *Registry_Find_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Registry_Find_Call) RunAndReturn(run func(string) (modular.Module, bool)) *Registry_Find_Call { + _c.Call.Return(run) + return _c +} + +// Has provides a mock function with given fields: name +func (_m *Registry) Has(name string) bool { + ret := _m.Called(name) + + if len(ret) == 0 { + panic("no return value specified for Has") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(name) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// Registry_Has_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Has' +type Registry_Has_Call struct { + *mock.Call +} + +// Has is a helper method to define mock.On call +// - name string +func (_e *Registry_Expecter) Has(name interface{}) *Registry_Has_Call { + return &Registry_Has_Call{Call: _e.mock.On("Has", name)} +} + +func (_c *Registry_Has_Call) Run(run func(name string)) *Registry_Has_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Registry_Has_Call) Return(_a0 bool) *Registry_Has_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Registry_Has_Call) RunAndReturn(run func(string) bool) *Registry_Has_Call { + _c.Call.Return(run) + return _c +} + +// Path provides a mock function with given fields: name +func (_m *Registry) Path(name string) (string, bool) { + ret := _m.Called(name) + + if len(ret) == 0 { + panic("no return value specified for Path") + } + + var r0 string + var r1 bool + if rf, ok := ret.Get(0).(func(string) (string, bool)); ok { + return rf(name) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(name) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) bool); ok { + r1 = rf(name) + } else { + r1 = ret.Get(1).(bool) + } + + return r0, r1 +} + +// Registry_Path_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Path' +type Registry_Path_Call struct { + *mock.Call +} + +// Path is a helper method to define mock.On call +// - name string +func (_e *Registry_Expecter) Path(name interface{}) *Registry_Path_Call { + return &Registry_Path_Call{Call: _e.mock.On("Path", name)} +} + +func (_c *Registry_Path_Call) Run(run func(name string)) *Registry_Path_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Registry_Path_Call) Return(_a0 string, _a1 bool) *Registry_Path_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Registry_Path_Call) RunAndReturn(run func(string) (string, bool)) *Registry_Path_Call { + _c.Call.Return(run) + return _c +} + +// Register provides a mock function with given fields: modules +func (_m *Registry) Register(modules ...modular.Module) error { + _va := make([]interface{}, len(modules)) + for _i := range modules { + _va[_i] = modules[_i] + } + var _ca []interface{} + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for Register") + } + + var r0 error + if rf, ok := ret.Get(0).(func(...modular.Module) error); ok { + r0 = rf(modules...) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Registry_Register_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Register' +type Registry_Register_Call struct { + *mock.Call +} + +// Register is a helper method to define mock.On call +// - modules ...modular.Module +func (_e *Registry_Expecter) Register(modules ...interface{}) *Registry_Register_Call { + return &Registry_Register_Call{Call: _e.mock.On("Register", + append([]interface{}{}, modules...)...)} +} + +func (_c *Registry_Register_Call) Run(run func(modules ...modular.Module)) *Registry_Register_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]modular.Module, len(args)-0) + for i, a := range args[0:] { + if a != nil { + variadicArgs[i] = a.(modular.Module) + } + } + run(variadicArgs...) + }) + return _c +} + +func (_c *Registry_Register_Call) Return(_a0 error) *Registry_Register_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Registry_Register_Call) RunAndReturn(run func(...modular.Module) error) *Registry_Register_Call { + _c.Call.Return(run) + return _c +} + +// NewRegistry creates a new instance of Registry. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewRegistry(t interface { + mock.TestingT + Cleanup(func()) +}) *Registry { + mock := &Registry{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/modular/console/console.go b/modular/console/console.go new file mode 100644 index 000000000..71ecbc25f --- /dev/null +++ b/modular/console/console.go @@ -0,0 +1,58 @@ +// Package console holds the module Artisan commands. It never imports foundation. +package console + +import ( + "github.com/goravel/framework/contracts/config" + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/modular" + "github.com/goravel/framework/packages" + "github.com/goravel/framework/support/file" + "github.com/goravel/framework/support/path" +) + +const ( + statusCreated = "CREATED" + statusExists = "EXISTS" + statusManual = "MANUAL" + + statusPublished = "PUBLISHED" + statusUnchanged = "UNCHANGED" + statusFailed = "FAILED" + statusSkippedLocal = "SKIPPED (local)" + statusSkippedNone = "SKIPPED (nothing to publish)" +) + +// modulesDirectory is modular.path, also before config/modular.go exists. +func modulesDirectory(config config.Config) string { + if config == nil { + return modular.DefaultDirectory + } + + return config.GetString("modular.path", modular.DefaultDirectory) +} + +// facadesImport returns the app's facades package, or the framework's when the app has none. +func facadesImport() (importPath, pkg string) { + if file.Exists(path.Facade()) { + facades := packages.Paths().Facades() + + return facades.Import(), facades.Package() + } + + return "github.com/goravel/framework/facades", "facades" +} + +// putIfMissing writes content to file unless it exists and prints a row named label. +func putIfMissing(ctx console.Context, label, filePath, content string) error { + if file.Exists(filePath) { + ctx.TwoColumnDetail(label, statusExists) + + return nil + } + if err := file.PutContent(filePath, content); err != nil { + return err + } + ctx.TwoColumnDetail(label, statusCreated) + + return nil +} diff --git a/modular/console/init_command.go b/modular/console/init_command.go new file mode 100644 index 000000000..3a3d643ae --- /dev/null +++ b/modular/console/init_command.go @@ -0,0 +1,143 @@ +package console + +import ( + "path/filepath" + "strings" + + "github.com/goravel/framework/contracts/config" + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" + "github.com/goravel/framework/errors" + "github.com/goravel/framework/packages" + "github.com/goravel/framework/packages/modify" + "github.com/goravel/framework/support" + "github.com/goravel/framework/support/env" + "github.com/goravel/framework/support/file" + "github.com/goravel/framework/support/path" +) + +type InitCommand struct { + config config.Config +} + +func NewInitCommand(config config.Config) *InitCommand { + return &InitCommand{config: config} +} + +// Signature The name and signature of the console command. +func (r *InitCommand) Signature() string { + return "modular:init" +} + +// Description The console command description. +func (r *InitCommand) Description() string { + return "Prepare the application for modules" +} + +// Extend The console command extend. +func (r *InitCommand) Extend() command.Extend { + return command.Extend{ + Category: "modular", + } +} + +// Handle Execute the console command. Running it again only reports what exists. +func (r *InitCommand) Handle(ctx console.Context) error { + if !env.IsBootstrapSetup() { + return errors.ModularRequiresBootstrapSetup.Args(r.Signature()) + } + + directory := modulesDirectory(r.config) + if err := putIfMissing(ctx, filepath.ToSlash(filepath.Join(directory, ".gitkeep")), path.Base(directory, ".gitkeep"), ""); err != nil { + return err + } + + facadesImport, facadesPackage := facadesImport() + configStub := Stubs{}.Config(packages.Paths().Config().Package(), facadesImport, facadesPackage) + if err := putIfMissing(ctx, filepath.ToSlash(packages.Paths().Config().String("modular.go")), path.Config("modular.go"), configStub); err != nil { + return err + } + + if err := r.wireModules(ctx); err != nil { + return err + } + + return r.addGenerate(ctx) +} + +// generateDirective makes go generate publish third-party modules. +const generateDirective = "//go:generate go run . artisan modular:publish" + +// addGenerate adds generateDirective to main.go, above func main. +func (r *InitCommand) addGenerate(ctx console.Context) error { + label := "main.go go:generate modular:publish" + mainFile := path.Base("main.go") + + content, err := file.GetContent(mainFile) + if err != nil { + ctx.TwoColumnDetail(label, statusManual) + ctx.Warning("Add " + generateDirective + " to the main package") + + return nil + } + if strings.Contains(content, generateDirective) { + ctx.TwoColumnDetail(label, statusExists) + + return nil + } + + if before, after, found := strings.Cut(content, "\nfunc main("); found { + content = before + "\n" + generateDirective + "\n\nfunc main(" + after + } else { + content = strings.TrimRight(content, "\n") + "\n\n" + generateDirective + "\n" + } + if err := file.PutContent(mainFile, content); err != nil { + return err + } + ctx.TwoColumnDetail(label, statusCreated) + + return nil +} + +// wireModules creates bootstrap/modules.go and adds WithModules(Modules) to foundation.Setup(). +func (r *InitCommand) wireModules(ctx console.Context) error { + bootstrap := support.Config.Paths.Bootstrap + modulesLabel := filepath.ToSlash(filepath.Join(bootstrap, "modules.go")) + appLabel := filepath.ToSlash(filepath.Join(bootstrap, "app.go")) + " WithModules(Modules)" + + if file.Contain(path.Bootstrap("app.go"), "WithModules") { + if file.Exists(path.Bootstrap("modules.go")) { + ctx.TwoColumnDetail(modulesLabel, statusExists) + } + ctx.TwoColumnDetail(appLabel, statusExists) + + return nil + } + + err := modify.InitModules() + if err == nil { + ctx.TwoColumnDetail(modulesLabel, statusCreated) + ctx.TwoColumnDetail(appLabel, statusCreated) + + return nil + } + if !isManualWiring(err) { + return err + } + + // The chain could not be edited: keep modules.go ready and leave app.go to the user. + if err := putIfMissing(ctx, modulesLabel, path.Bootstrap("modules.go"), modify.ModulesStub()); err != nil { + return err + } + ctx.TwoColumnDetail(appLabel, statusManual) + ctx.Warning("Add .WithModules(Modules) to foundation.Setup() in " + filepath.ToSlash(filepath.Join(bootstrap, "app.go"))) + + return nil +} + +func isManualWiring(err error) bool { + return errors.Is(err, errors.PackageSetupNotFound) || + errors.Is(err, errors.PackageModulesFileExists) || + errors.Is(err, errors.PackageMatchGoNodeFail) || + errors.Is(err, errors.PackageModifyGoFileFail) +} diff --git a/modular/console/init_command_test.go b/modular/console/init_command_test.go new file mode 100644 index 000000000..9851e3c5c --- /dev/null +++ b/modular/console/init_command_test.go @@ -0,0 +1,276 @@ +package console + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/goravel/framework/errors" + mocksconfig "github.com/goravel/framework/mocks/config" + mocksconsole "github.com/goravel/framework/mocks/console" + "github.com/goravel/framework/packages/modify" + "github.com/goravel/framework/support/file" +) + +const chainedApp = `package bootstrap + +import ( + contractsfoundation "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/foundation" +) + +func Boot() contractsfoundation.Application { + return foundation.Setup().Create() +} +` + +const assignedApp = `package bootstrap + +import ( + contractsfoundation "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/foundation" +) + +func Boot() contractsfoundation.Application { + builder := foundation.Setup().WithConfig(nil) + + return builder.Create() +} +` + +// mainFile has a build tag so the go tool ignores it in this package. +const mainFile = `//go:build ignore + +package main + +import "goravel/bootstrap" + +func main() { + bootstrap.Boot().Start() +} +` + +type InitCommandTestSuite struct { + suite.Suite +} + +func TestInitCommandTestSuite(t *testing.T) { + suite.Run(t, new(InitCommandTestSuite)) +} + +func (s *InitCommandTestSuite) TearDownTest() { + removeScaffold(s.T()) +} + +func (s *InitCommandTestSuite) TestSignature() { + cmd := NewInitCommand(nil) + + s.Equal("modular:init", cmd.Signature()) + s.Equal("Prepare the application for modules", cmd.Description()) + s.Equal("modular", cmd.Extend().Category) +} + +func (s *InitCommandTestSuite) TestRequiresBootstrapSetup() { + err := NewInitCommand(nil).Handle(mocksconsole.NewContext(s.T())) + + s.Equal(errors.ModularRequiresBootstrapSetup.Args("modular:init"), err) + s.False(file.Exists("modules")) +} + +func (s *InitCommandTestSuite) TestReturnsUnparsableApp() { + s.Require().NoError(file.PutContent("bootstrap/app.go", "package bootstrap\n\nvar app = foundation.Setup().Create(\n")) + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail("modules/.gitkeep", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("config/modular.go", statusCreated).Once() + + err := NewInitCommand(nil).Handle(ctx) + + s.Error(err) + s.False(isManualWiring(err)) + s.False(file.Exists("bootstrap/modules.go")) +} + +func (s *InitCommandTestSuite) TestCreatesThenReportsExisting() { + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + s.Require().NoError(file.PutContent("main.go", mainFile)) + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail("modules/.gitkeep", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("config/modular.go", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/modules.go", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/app.go WithModules(Modules)", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("main.go go:generate modular:publish", statusCreated).Once() + + s.NoError(NewInitCommand(nil).Handle(ctx)) + + s.True(file.Exists("modules/.gitkeep")) + s.True(file.Contain("bootstrap/app.go", "WithModules(Modules)")) + modules, err := file.GetContent("bootstrap/modules.go") + s.NoError(err) + s.Equal(modify.ModulesStub(), modules) + + // No app/facades here, so the config file uses the framework facades. + config, err := file.GetContent("config/modular.go") + s.NoError(err) + s.Contains(config, "package config") + s.Contains(config, `"github.com/goravel/framework/facades"`) + s.Contains(config, `facades.Config()`) + s.Contains(config, `config.Add("modular", map[string]any{`) + + app, err := file.GetContent("bootstrap/app.go") + s.NoError(err) + + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail("modules/.gitkeep", statusExists).Once() + ctx.EXPECT().TwoColumnDetail("config/modular.go", statusExists).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/modules.go", statusExists).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/app.go WithModules(Modules)", statusExists).Once() + ctx.EXPECT().TwoColumnDetail("main.go go:generate modular:publish", statusExists).Once() + + s.NoError(NewInitCommand(nil).Handle(ctx)) + + again, err := file.GetContent("bootstrap/app.go") + s.NoError(err) + s.Equal(app, again) + + main, err := file.GetContent("main.go") + s.NoError(err) + s.Equal(strings.Replace(mainFile, "\nfunc main(", "\n"+generateDirective+"\n\nfunc main(", 1), main) +} + +func (s *InitCommandTestSuite) TestGenerateDirective() { + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + rows := func(status string) *mocksconsole.Context { + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail(mock.Anything, mock.Anything).Times(4) + ctx.EXPECT().TwoColumnDetail("main.go go:generate modular:publish", status).Once() + + return ctx + } + + // No main.go: left to the user. + ctx := rows(statusManual) + ctx.EXPECT().Warning("Add " + generateDirective + " to the main package").Once() + s.NoError(NewInitCommand(nil).Handle(ctx)) + s.False(file.Exists("main.go")) + + // No func main in main.go: appended. + s.Require().NoError(file.PutContent("main.go", "//go:build ignore\n\npackage main\n")) + s.NoError(NewInitCommand(nil).Handle(rows(statusCreated))) + main, err := file.GetContent("main.go") + s.NoError(err) + s.Equal("//go:build ignore\n\npackage main\n\n"+generateDirective+"\n", main) + + s.NoError(NewInitCommand(nil).Handle(rows(statusExists))) +} + +func (s *InitCommandTestSuite) TestAppFacadesAndConfiguredPath() { + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + s.Require().NoError(file.PutContent("main.go", mainFile)) + s.Require().NoError(file.PutContent("app/facades/config.go", "package facades\n")) + + config := mocksconfig.NewConfig(s.T()) + config.EXPECT().GetString("modular.path", "modules").Return("src/modules").Once() + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail("src/modules/.gitkeep", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("config/modular.go", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/modules.go", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/app.go WithModules(Modules)", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("main.go go:generate modular:publish", statusCreated).Once() + + s.NoError(NewInitCommand(config).Handle(ctx)) + + s.True(file.Exists("src/modules/.gitkeep")) + s.True(file.Contain("config/modular.go", `"github.com/goravel/framework/app/facades"`)) +} + +func (s *InitCommandTestSuite) TestAssignedChainIsManual() { + s.Require().NoError(file.PutContent("bootstrap/app.go", assignedApp)) + s.Require().NoError(file.PutContent("main.go", mainFile)) + + warning := "Add .WithModules(Modules) to foundation.Setup() in bootstrap/app.go" + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail("modules/.gitkeep", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("config/modular.go", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/modules.go", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/app.go WithModules(Modules)", statusManual).Once() + ctx.EXPECT().TwoColumnDetail("main.go go:generate modular:publish", statusCreated).Once() + ctx.EXPECT().Warning(warning).Once() + + s.NoError(NewInitCommand(nil).Handle(ctx)) + + s.True(file.Exists("bootstrap/modules.go")) + app, err := file.GetContent("bootstrap/app.go") + s.NoError(err) + s.Equal(assignedApp, app) + + // modules.go without WithModules: the same manual row again. + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail("modules/.gitkeep", statusExists).Once() + ctx.EXPECT().TwoColumnDetail("config/modular.go", statusExists).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/modules.go", statusExists).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/app.go WithModules(Modules)", statusManual).Once() + ctx.EXPECT().TwoColumnDetail("main.go go:generate modular:publish", statusExists).Once() + ctx.EXPECT().Warning(warning).Once() + + s.NoError(NewInitCommand(nil).Handle(ctx)) +} + +// removeScaffold deletes what the commands write into the package directory. +func removeScaffold(t *testing.T) { + t.Helper() + + for _, dir := range []string{"app", "bootstrap", "config", "modules", "src", "tests", "main.go"} { + if err := file.Remove(dir); err != nil { + t.Fatal(err) + } + } +} + +func (s *InitCommandTestSuite) TestWriteFailures() { + s.Run("the modules directory is a file", func() { + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + s.Require().NoError(file.PutContent("modules", "file")) + + s.Error(NewInitCommand(nil).Handle(mocksconsole.NewContext(s.T()))) + s.False(file.Exists("config/modular.go")) + removeScaffold(s.T()) + }) + + s.Run("the config directory is a file", func() { + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + s.Require().NoError(file.PutContent("config", "file")) + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail("modules/.gitkeep", statusCreated).Once() + + s.Error(NewInitCommand(nil).Handle(ctx)) + s.False(file.Exists("bootstrap/modules.go")) + removeScaffold(s.T()) + }) + + s.Run("main.go is read-only", func() { + if os.Geteuid() == 0 { + s.T().Skip("root writes read-only files") + } + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + s.Require().NoError(file.PutContent("main.go", mainFile)) + s.Require().NoError(os.Chmod("main.go", 0o444)) + s.T().Cleanup(func() { _ = os.Chmod("main.go", 0o644) }) + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail("modules/.gitkeep", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("config/modular.go", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/modules.go", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/app.go WithModules(Modules)", statusCreated).Once() + + s.Error(NewInitCommand(nil).Handle(ctx)) + s.False(file.Contain("main.go", generateDirective)) + s.Require().NoError(os.Chmod("main.go", 0o644)) + }) +} diff --git a/modular/console/list_command.go b/modular/console/list_command.go new file mode 100644 index 000000000..4c76e1a2c --- /dev/null +++ b/modular/console/list_command.go @@ -0,0 +1,168 @@ +package console + +import ( + "encoding/json" + "fmt" + "io" + "maps" + "os" + "path/filepath" + "runtime/debug" + "slices" + "strings" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" + contractsmodular "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + "github.com/goravel/framework/modular" + "github.com/goravel/framework/support/path" +) + +type ListCommand struct { + registry contractsmodular.Registry + buildInfo func() (*debug.BuildInfo, bool) + // out receives --json as is: ctx.Line would wrap it in colour codes. + out io.Writer +} + +func NewListCommand(registry contractsmodular.Registry) *ListCommand { + return &ListCommand{registry: registry, buildInfo: debug.ReadBuildInfo, out: os.Stdout} +} + +// moduleInfo is one module in the --json output. +type moduleInfo struct { + Name string `json:"name"` + Path string `json:"path"` + Order int `json:"order"` + Publish string `json:"publish"` + Capabilities map[string]bool `json:"capabilities"` + ViteEntries []string `json:"vite_entries"` + ViteAliases map[string]string `json:"vite_aliases"` + Static []string `json:"static"` +} + +// Signature The name and signature of the console command. +func (r *ListCommand) Signature() string { + return "modular:list" +} + +// Description The console command description. +func (r *ListCommand) Description() string { + return "List the registered modules" +} + +// Extend The console command extend. +func (r *ListCommand) Extend() command.Extend { + return command.Extend{ + Category: "modular", + Flags: []command.Flag{ + &command.BoolFlag{ + Name: "json", + Usage: "Output the modules as JSON", + }, + }, + } +} + +// Handle Execute the console command. +func (r *ListCommand) Handle(ctx console.Context) error { + if r.registry == nil { + return errors.ModularRegistryNotSet + } + + info, _ := r.buildInfo() + modules := make([]moduleInfo, 0) + for _, module := range r.registry.All() { + modules = append(modules, r.describe(module, info)) + } + + if ctx.OptionBool("json") { + content, err := json.Marshal(modules) + if err != nil { + return err + } + _, err = fmt.Fprintln(r.out, string(content)) + + return err + } + + if len(modules) == 0 { + ctx.Warning("No modules are registered. List them in bootstrap/modules.go.") + + return nil + } + + for _, module := range modules { + ctx.TwoColumnDetail( + fmt.Sprintf("%s %s", module.Name, module.Path), + fmt.Sprintf("%s, order %d", module.Publish, module.Order), + ) + for _, detail := range module.details() { + ctx.Line(" " + detail + "") + } + } + + return nil +} + +func (r *ListCommand) describe(module contractsmodular.Module, info *debug.BuildInfo) moduleInfo { + dir, _ := r.registry.Path(module.Name()) + described := moduleInfo{ + Name: module.Name(), + Path: dir, + Publish: modular.PublishLocal, + Capabilities: r.registry.Capabilities(module), + ViteEntries: []string{}, + ViteAliases: map[string]string{}, + Static: []string{}, + } + + if dependency, ok := modular.DependencyOf(module, info); ok { + described.Publish = modular.PublishState(module, dependency, path.Base(filepath.FromSlash(dir))) + } + if p, ok := module.(contractsmodular.Ordered); ok { + described.Order = p.Order() + } + if p, ok := module.(contractsmodular.ProvidesViteEntries); ok { + described.ViteEntries = append(described.ViteEntries, p.ViteEntries()...) + } + if p, ok := module.(contractsmodular.ProvidesViteAlias); ok { + maps.Copy(described.ViteAliases, p.ViteAliases()) + } + if p, ok := module.(contractsmodular.ProvidesStatic); ok { + described.Static = slices.Sorted(maps.Keys(p.Static())) + } + + return described +} + +// details are the lines under a module row: its capabilities and what they declare. +func (r moduleInfo) details() []string { + var capabilities []string + for _, label := range slices.Sorted(maps.Keys(r.Capabilities)) { + if r.Capabilities[label] { + capabilities = append(capabilities, label) + } + } + + var details []string + if len(capabilities) > 0 { + details = append(details, strings.Join(capabilities, ", ")) + } + if len(r.ViteEntries) > 0 { + details = append(details, "vite entries: "+strings.Join(r.ViteEntries, ", ")) + } + if len(r.ViteAliases) > 0 { + var aliases []string + for _, alias := range slices.Sorted(maps.Keys(r.ViteAliases)) { + aliases = append(aliases, alias+" -> "+r.ViteAliases[alias]) + } + details = append(details, "vite aliases: "+strings.Join(aliases, ", ")) + } + if len(r.Static) > 0 { + details = append(details, "static: "+strings.Join(r.Static, ", ")) + } + + return details +} diff --git a/modular/console/list_command_test.go b/modular/console/list_command_test.go new file mode 100644 index 000000000..158f63014 --- /dev/null +++ b/modular/console/list_command_test.go @@ -0,0 +1,143 @@ +package console + +import ( + "bytes" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/suite" + + contractsmodular "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + mocksconsole "github.com/goravel/framework/mocks/console" + mocksmodular "github.com/goravel/framework/mocks/modular" + "github.com/goravel/framework/modular" + "github.com/goravel/framework/support/file" +) + +type richModule struct{ testModule } + +func (m *richModule) Order() int { return -1 } +func (m *richModule) ViteEntries() []string { return []string{"resources/js/app.js"} } +func (m *richModule) ViteAliases() map[string]string { + return map[string]string{"@blog-assets": "resources/assets"} +} +func (m *richModule) Static() map[string]contractsmodular.Path { + return map[string]contractsmodular.Path{"/blog/static": {Dir: "public"}, "/blog/docs": {Dir: "docs"}} +} + +type ListCommandTestSuite struct { + suite.Suite +} + +func TestListCommandTestSuite(t *testing.T) { + suite.Run(t, new(ListCommandTestSuite)) +} + +func (s *ListCommandTestSuite) TearDownTest() { + removeScaffold(s.T()) +} + +func (s *ListCommandTestSuite) TestSignature() { + cmd := NewListCommand(nil) + + s.Equal("modular:list", cmd.Signature()) + s.Equal("List the registered modules", cmd.Description()) + s.Equal("modular", cmd.Extend().Category) + s.Len(cmd.Extend().Flags, 1) +} + +func (s *ListCommandTestSuite) TestRegistryNotSet() { + s.Equal(errors.ModularRegistryNotSet, NewListCommand(nil).Handle(mocksconsole.NewContext(s.T()))) +} + +func (s *ListCommandTestSuite) TestEmpty() { + registry := mocksmodular.NewRegistry(s.T()) + registry.EXPECT().All().Return(nil) + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().OptionBool("json").Return(false).Once() + ctx.EXPECT().Warning("No modules are registered. List them in bootstrap/modules.go.").Once() + s.NoError(NewListCommand(registry).Handle(ctx)) + + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().OptionBool("json").Return(true).Once() + var output bytes.Buffer + cmd := NewListCommand(registry) + cmd.out = &output + s.NoError(cmd.Handle(ctx)) + s.Equal("[]\n", output.String()) +} + +func (s *ListCommandTestSuite) TestList() { + blog := &richModule{testModule{"blog"}} + billing := &sourcesModule{testModule{"billing"}, fstest.MapFS{"app.js": {Data: []byte("app")}}} + + registry := mocksmodular.NewRegistry(s.T()) + registry.EXPECT().All().Return([]contractsmodular.Module{blog, billing}) + registry.EXPECT().Path("blog").Return("modules/blog", true) + registry.EXPECT().Path("billing").Return("modules/billing", true) + registry.EXPECT().Capabilities(blog).Return(map[string]bool{"vite-entries": true, "static": true, "views": false}) + registry.EXPECT().Capabilities(billing).Return(map[string]bool{"sources": true}) + + // Both are modules of this application: nothing is published. + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().OptionBool("json").Return(false).Once() + ctx.EXPECT().TwoColumnDetail("blog modules/blog", "local, order -1").Once() + ctx.EXPECT().Line(" static, vite-entries").Once() + ctx.EXPECT().Line(" vite entries: resources/js/app.js").Once() + ctx.EXPECT().Line(" vite aliases: @blog-assets -> resources/assets").Once() + ctx.EXPECT().Line(" static: /blog/docs, /blog/static").Once() + ctx.EXPECT().TwoColumnDetail("billing modules/billing", "local, order 0").Once() + ctx.EXPECT().Line(" sources").Once() + + s.NoError(NewListCommand(registry).Handle(ctx)) + + // As third-party modules: billing has sources that are not published yet. + var output bytes.Buffer + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().OptionBool("json").Return(true).Once() + + cmd := NewListCommand(registry) + cmd.buildInfo = thirdPartyBuild + cmd.out = &output + s.NoError(cmd.Handle(ctx)) + s.JSONEq(`[ + { + "name": "blog", "path": "modules/blog", "order": -1, "publish": "none", + "capabilities": {"vite-entries": true, "static": true, "views": false}, + "vite_entries": ["resources/js/app.js"], + "vite_aliases": {"@blog-assets": "resources/assets"}, + "static": ["/blog/docs", "/blog/static"] + }, + { + "name": "billing", "path": "modules/billing", "order": 0, "publish": "missing", + "capabilities": {"sources": true}, + "vite_entries": [], "vite_aliases": {}, "static": [] + } + ]`, output.String()) + + // Published by modular:publish. + publishCtx := mocksconsole.NewContext(s.T()) + publishCtx.EXPECT().Option("module").Return("billing").Once() + publishCtx.EXPECT().TwoColumnDetail("billing", statusPublished).Once() + registry.EXPECT().Find("billing").Return(billing, true).Once() + publish := NewPublishCommand(registry) + publish.buildInfo = thirdPartyBuild + s.NoError(publish.Handle(publishCtx)) + s.True(file.Exists("modules/billing/app.js")) + + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().OptionBool("json").Return(true).Once() + output.Reset() + s.NoError(cmd.Handle(ctx)) + s.Contains(output.String(), `"name":"billing","path":"modules/billing","order":0,"publish":"published"`) + + // Something else in the directory of a third-party module is a conflict. + s.Require().NoError(file.Remove("modules/billing/" + modular.PublishMarker)) + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().OptionBool("json").Return(true).Once() + output.Reset() + s.NoError(cmd.Handle(ctx)) + s.Contains(output.String(), `"name":"billing","path":"modules/billing","order":0,"publish":"conflict"`) +} diff --git a/modular/console/module_make_command.go b/modular/console/module_make_command.go new file mode 100644 index 000000000..a7b3d45be --- /dev/null +++ b/modular/console/module_make_command.go @@ -0,0 +1,313 @@ +package console + +import ( + "go/parser" + "go/token" + "maps" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/goravel/framework/contracts/config" + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" + "github.com/goravel/framework/errors" + "github.com/goravel/framework/modular" + "github.com/goravel/framework/packages" + "github.com/goravel/framework/packages/modify" + "github.com/goravel/framework/support" + supportconsole "github.com/goravel/framework/support/console" + "github.com/goravel/framework/support/env" + "github.com/goravel/framework/support/file" + "github.com/goravel/framework/support/path" + "github.com/goravel/framework/support/str" +) + +var ( + // moduleInputPattern is what make:module accepts; the kebab name it derives must pass modular.ValidName. + moduleInputPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9 _-]*$`) + + // reservedModulePackages are packages Go refuses to import. + reservedModulePackages = map[string]bool{"main": true, "init": true, "internal": true, "vendor": true} +) + +type ModuleMakeCommand struct { + config config.Config +} + +func NewModuleMakeCommand(config config.Config) *ModuleMakeCommand { + return &ModuleMakeCommand{config: config} +} + +// Signature The name and signature of the console command. +func (r *ModuleMakeCommand) Signature() string { + return "make:module" +} + +// Description The console command description. +func (r *ModuleMakeCommand) Description() string { + return "Create a new module" +} + +// Extend The console command extend. +func (r *ModuleMakeCommand) Extend() command.Extend { + return command.Extend{ + ArgsUsage: " ...", + Category: "make", + Flags: []command.Flag{ + &command.BoolFlag{ + Name: "api", + Usage: "Create an API module, without views and web routes", + }, + &command.BoolFlag{ + Name: "plain", + Usage: "Create only module.go", + }, + &command.IntFlag{ + Name: "order", + Usage: "The module order: lower first, negative last", + }, + &command.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Usage: "Create the module even if it already exists", + }, + }, + } +} + +// Handle Execute the console command. +func (r *ModuleMakeCommand) Handle(ctx console.Context) error { + supportconsole.HandleEach(ctx, r.makeOne) + + return nil +} + +func (r *ModuleMakeCommand) makeOne(ctx console.Context, name string) error { + if !env.IsBootstrapSetup() { + return errors.ModularRequiresBootstrapSetup.Args(r.Signature()) + } + if ctx.OptionBool("api") && ctx.OptionBool("plain") { + return errors.ModularOptionsConflict.Args("api", "plain") + } + + if name == "" { + var err error + name, err = ctx.Ask("Enter the module name", console.AskOption{ + Validate: func(s string) error { + if s == "" { + return errors.ModularModuleNameEmpty + } + + return nil + }, + }) + if err != nil { + return err + } + } + + stubs, err := newModuleStubs(name, modulesDirectory(r.config)) + if err != nil { + return err + } + stubs.order = ctx.OptionInt("order") + + root := path.Base(filepath.FromSlash(stubs.dir)) + if file.Exists(root) && !ctx.OptionBool("force") { + return errors.ModularModuleExists.Args(stubs.kebab, stubs.dir) + } + + registered, err := stubs.registered() + if err != nil { + return err + } + + files := stubs.files(ctx.OptionBool("api"), ctx.OptionBool("plain")) + kept, err := removeStaleStubs( + root, + []map[string]string{ + stubs.files(false, false), + stubs.files(true, false), + stubs.files(false, true), + }, + files, + ) + if err != nil { + return err + } + for name, content := range files { + if err := file.PutContent(filepath.Join(root, filepath.FromSlash(name)), content); err != nil { + return err + } + } + + if !registered { + if err := modify.AddModule(stubs.importSpec(), "&"+stubs.pkg+".Module{}"); err != nil { + return err + } + } + + ctx.Success("Module created successfully: " + stubs.dir) + for _, file := range kept { + ctx.Warning("Kept " + stubs.dir + "/" + file + ": it was edited, remove it if the module no longer needs it") + } + + // An assigned chain (builder := foundation.Setup()...) cannot be edited: the user wires it. + if !file.Contain(path.Bootstrap("app.go"), "WithModules") { + ctx.Warning("Add .WithModules(Modules) to foundation.Setup() in " + filepath.ToSlash(filepath.Join(support.Config.Paths.Bootstrap, "app.go"))) + } + + return nil +} + +// removeStaleStubs deletes the stubs of an earlier --force run that this one no longer writes, +// unless they were edited; it returns the names of the edited ones it kept. +func removeStaleStubs(root string, variants []map[string]string, current map[string]string) ([]string, error) { + stubs := make(map[string][]string) + for _, variant := range variants { + for name, content := range variant { + if _, ok := current[name]; !ok { + stubs[name] = append(stubs[name], content) + } + } + } + + var kept []string + for _, name := range slices.Sorted(maps.Keys(stubs)) { + stale := filepath.Join(root, filepath.FromSlash(name)) + content, err := os.ReadFile(stale) + if os.IsNotExist(err) { + continue + } + if err != nil { + return nil, err + } + if !slices.Contains(stubs[name], string(content)) { + kept = append(kept, name) + continue + } + + if err := os.Remove(stale); err != nil { + return nil, err + } + // Remove the directories the stub leaves empty; os.Remove refuses the others. + for dir := filepath.Dir(stale); dir != root && os.Remove(dir) == nil; dir = filepath.Dir(dir) { + } + } + + return kept, nil +} + +// moduleStubs holds the names derived from a module name, e.g. "BlogPosts". +type moduleStubs struct { + kebab string // blog-posts: module name and directory + pkg string // blogposts: Go package + studly string // BlogPosts + snake string // blog_posts + title string // Blog Posts + dir string // modules/blog-posts + module string //
/modules/blog-posts + order int +} + +func newModuleStubs(name, directory string) (*moduleStubs, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.ModularModuleNameEmpty + } + + kebab := str.Of(name).Kebab().String() + pkg := strings.ReplaceAll(kebab, "-", "") + if !moduleInputPattern.MatchString(name) || !modular.ValidName(kebab) { + return nil, errors.ModularModuleNameInvalid.Args(name) + } + if token.IsKeyword(pkg) || reservedModulePackages[pkg] { + return nil, errors.ModularModuleNameForbidden.Args(name, pkg) + } + + dir := filepath.ToSlash(filepath.Join(directory, kebab)) + + return &moduleStubs{ + kebab: kebab, + pkg: pkg, + studly: str.Of(kebab).Studly().String(), + snake: strings.ReplaceAll(kebab, "-", "_"), + title: str.Of(kebab).Headline().String(), + dir: dir, + module: packages.Paths().Main().Import() + "/" + dir, + }, nil +} + +// importSpec is the import of the module package, aliased when the directory is not the package name. +func (r *moduleStubs) importSpec() string { + if r.pkg == r.kebab { + return r.module + } + + return r.pkg + " " + r.module +} + +// registered reports whether bootstrap/modules.go lists the module, and fails +// when another import there already uses the module's package name. +func (r *moduleStubs) registered() (bool, error) { + modulesFile := path.Bootstrap("modules.go") + source := modify.ModulesStub() + if file.Exists(modulesFile) { + content, err := file.GetContent(modulesFile) + if err != nil { + return false, err + } + source = content + } + + parsed, err := parser.ParseFile(token.NewFileSet(), modulesFile, source, parser.ImportsOnly) + if err != nil { + return false, err + } + + imported := false + for _, spec := range parsed.Imports { + importPath := strings.Trim(spec.Path.Value, "`\"") + name := importPath[strings.LastIndex(importPath, "/")+1:] + if spec.Name != nil { + name = spec.Name.Name + } + if importPath == r.module { + imported = true + } else if name == r.pkg { + return false, errors.ModularModuleImportConflict.Args(r.kebab, r.pkg, importPath) + } + } + + return imported && strings.Contains(source, "&"+r.pkg+".Module{}"), nil +} + +// files returns the module files by slash path relative to the module directory. +func (r *moduleStubs) files(api, plain bool) map[string]string { + if plain { + return map[string]string{"module.go": r.Module(false, false, false)} + } + + files := map[string]string{ + "module.go": r.Module(!api, true, true), + "providers/service_provider.go": r.ServiceProvider(), + "routes/api.go": r.ApiRoutes(), + "http/controllers/" + r.snake + "_controller.go": r.Controller(!api), + "database/migrations/.gitkeep": "", + "database/seeders/.gitkeep": "", + "models/.gitkeep": "", + "lang/en/messages.json": r.Lang(), + } + if !api { + files["routes/web.go"] = r.WebRoutes() + files["resources/views/index.tmpl"] = r.View() + } + if file.Exists(path.Test("test_case.go")) { + files["tests/feature/"+r.snake+"_test.go"] = r.Test() + } + + return files +} diff --git a/modular/console/module_make_command_test.go b/modular/console/module_make_command_test.go new file mode 100644 index 000000000..81d3c21a2 --- /dev/null +++ b/modular/console/module_make_command_test.go @@ -0,0 +1,495 @@ +package console + +import ( + "go/format" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/errors" + mocksconfig "github.com/goravel/framework/mocks/config" + mocksconsole "github.com/goravel/framework/mocks/console" + "github.com/goravel/framework/packages/modify" + "github.com/goravel/framework/support/file" +) + +type ModuleMakeCommandTestSuite struct { + suite.Suite +} + +func TestModuleMakeCommandTestSuite(t *testing.T) { + suite.Run(t, new(ModuleMakeCommandTestSuite)) +} + +func (s *ModuleMakeCommandTestSuite) SetupTest() { + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) +} + +func (s *ModuleMakeCommandTestSuite) TearDownTest() { + removeScaffold(s.T()) +} + +// context mocks one make:module call. +func (s *ModuleMakeCommandTestSuite) context(name string, api, plain, force bool, order int) *mocksconsole.Context { + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().Arguments().Return([]string{name}).Once() + ctx.EXPECT().OptionBool("api").Return(api).Maybe() + ctx.EXPECT().OptionBool("plain").Return(plain).Maybe() + ctx.EXPECT().OptionBool("force").Return(force).Maybe() + ctx.EXPECT().OptionInt("order").Return(order).Maybe() + + return ctx +} + +// files lists the module's files and checks every Go file is gofmt-formatted. +func (s *ModuleMakeCommandTestSuite) files(root string) []string { + var files []string + s.Require().NoError(filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + + rel, err := filepath.Rel(root, path) + s.Require().NoError(err) + files = append(files, filepath.ToSlash(rel)) + + if strings.HasSuffix(path, ".go") { + content, err := os.ReadFile(path) + s.Require().NoError(err) + formatted, err := format.Source(content) + s.Require().NoError(err, rel) + s.Equal(string(formatted), string(content), rel) + } + + return nil + })) + + return files +} + +func (s *ModuleMakeCommandTestSuite) TestSignature() { + cmd := NewModuleMakeCommand(nil) + + s.Equal("make:module", cmd.Signature()) + s.Equal("Create a new module", cmd.Description()) + s.Equal("make", cmd.Extend().Category) + s.Len(cmd.Extend().Flags, 4) +} + +func (s *ModuleMakeCommandTestSuite) TestDefault() { + s.Require().NoError(file.PutContent("tests/test_case.go", "package tests\n")) + + ctx := s.context("Blog", false, false, false, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.ElementsMatch([]string{ + "module.go", + "providers/service_provider.go", + "routes/web.go", + "routes/api.go", + "http/controllers/blog_controller.go", + "resources/views/index.tmpl", + "lang/en/messages.json", + "database/migrations/.gitkeep", + "database/seeders/.gitkeep", + "models/.gitkeep", + "tests/feature/blog_test.go", + }, s.files("modules/blog")) + + module, err := file.GetContent("modules/blog/module.go") + s.NoError(err) + s.Contains(module, "package blog\n") + s.Contains(module, "//go:embed resources/views\nvar views embed.FS") + s.Contains(module, "//go:embed lang\nvar lang embed.FS") + s.Contains(module, `"github.com/goravel/framework/modules/blog/providers"`) + s.Contains(module, "return \"blog\"\n") + for _, method := range []string{"ServiceProviders()", "Routes(router route.Router)", "ApiRoutes(router route.Router)", "Config()", "Views()", "Translations()", "ViteEntries()"} { + s.Contains(module, "func (m *Module) "+method) + } + s.NotContains(module, "Order()") + + // No app/facades here, so the controller uses the framework facades. + controller, err := file.GetContent("modules/blog/http/controllers/blog_controller.go") + s.NoError(err) + s.Contains(controller, `"github.com/goravel/framework/facades"`) + s.Contains(controller, `Make("blog/index.tmpl"`) + s.Contains(controller, `facades.Lang(ctx).Get("blog/messages.title")`) + + s.True(file.Contain("modules/blog/routes/web.go", `router.Get("/blog", controllers.NewBlogController().Index)`)) + s.True(file.Contain("modules/blog/routes/api.go", `router.Get("/blog", controllers.NewBlogController().Show)`)) + s.True(file.Contain("modules/blog/lang/en/messages.json", `"title": "Blog"`)) + s.False(file.Contain("modules/blog/resources/views/index.tmpl", "define")) + s.True(file.Contain("modules/blog/tests/feature/blog_test.go", `"github.com/goravel/framework/tests"`)) + s.True(file.Contain("modules/blog/tests/feature/blog_test.go", "tests.TestCase")) + + s.True(file.Contain("bootstrap/app.go", "WithModules(Modules)")) + s.True(file.Contain("bootstrap/modules.go", `"github.com/goravel/framework/modules/blog"`)) + s.True(file.Contain("bootstrap/modules.go", "&blog.Module{},")) +} + +func (s *ModuleMakeCommandTestSuite) TestApi() { + ctx := s.context("billing", true, false, false, 0) + ctx.EXPECT().Success("Module created successfully: modules/billing").Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.ElementsMatch([]string{ + "module.go", + "providers/service_provider.go", + "routes/api.go", + "http/controllers/billing_controller.go", + "lang/en/messages.json", + "database/migrations/.gitkeep", + "database/seeders/.gitkeep", + "models/.gitkeep", + }, s.files("modules/billing")) + + module, err := file.GetContent("modules/billing/module.go") + s.NoError(err) + s.Contains(module, "func (m *Module) ApiRoutes(router route.Router)") + s.Contains(module, "//go:embed lang\nvar lang embed.FS") + s.Contains(module, "func (m *Module) Translations() fs.FS") + s.True(file.Contain("modules/billing/lang/en/messages.json", `"title": "Billing"`)) + for _, absent := range []string{"resources/views", "Routes(router route.Router) {\n\troutes.Web", "Views()", "ViteEntries()"} { + s.NotContains(module, absent) + } + + controller, err := file.GetContent("modules/billing/http/controllers/billing_controller.go") + s.NoError(err) + s.NotContains(controller, "Index") + s.NotContains(controller, "facades") + s.Contains(controller, `"module": "billing"`) +} + +func (s *ModuleMakeCommandTestSuite) TestPlainWithOrder() { + ctx := s.context("Blog", false, true, false, 5) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.Equal([]string{"module.go"}, s.files("modules/blog")) + module, err := file.GetContent("modules/blog/module.go") + s.NoError(err) + s.Equal(`package blog + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} + +func (m *Module) Order() int { + return 5 +} +`, module) + s.True(file.Contain("bootstrap/modules.go", "&blog.Module{},")) +} + +func (s *ModuleMakeCommandTestSuite) TestKebabNameAndConfiguredPath() { + config := mocksconfig.NewConfig(s.T()) + config.EXPECT().GetString("modular.path", "modules").Return("src/modules").Once() + + ctx := s.context("BlogPosts", false, false, false, 0) + ctx.EXPECT().Success("Module created successfully: src/modules/blog-posts").Once() + + s.NoError(NewModuleMakeCommand(config).Handle(ctx)) + + s.Contains(s.files("src/modules/blog-posts"), "http/controllers/blog_posts_controller.go") + module, err := file.GetContent("src/modules/blog-posts/module.go") + s.NoError(err) + s.Contains(module, "package blogposts\n") + s.Contains(module, "return \"blog-posts\"\n") + s.True(file.Contain("src/modules/blog-posts/http/controllers/blog_posts_controller.go", "type BlogPostsController struct{}")) + s.True(file.Contain("src/modules/blog-posts/lang/en/messages.json", `"title": "Blog Posts"`)) + s.True(file.Contain("bootstrap/modules.go", `blogposts "github.com/goravel/framework/src/modules/blog-posts"`)) + s.True(file.Contain("bootstrap/modules.go", "&blogposts.Module{},")) +} + +func (s *ModuleMakeCommandTestSuite) TestAppFacadesWithoutLang() { + s.Require().NoError(file.PutContent("app/facades/config.go", "package facades\n")) + + ctx := s.context("Blog", false, false, false, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + controller, err := file.GetContent("modules/blog/http/controllers/blog_controller.go") + s.NoError(err) + s.NotContains(controller, "facades") + s.Contains(controller, `"title": "Blog",`) + s.files("modules/blog") + + // With the app's Lang facade, the controller uses it. + s.Require().NoError(file.PutContent("app/facades/lang.go", "package facades\n")) + ctx = s.context("Blog", false, false, true, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.True(file.Contain("modules/blog/http/controllers/blog_controller.go", `"github.com/goravel/framework/app/facades"`)) + s.files("modules/blog") +} + +func (s *ModuleMakeCommandTestSuite) TestExistingModulesFileWithoutWithModules() { + s.Require().NoError(file.PutContent("bootstrap/app.go", assignedApp)) + s.Require().NoError(file.PutContent("bootstrap/modules.go", modify.ModulesStub())) + + warning := "Add .WithModules(Modules) to foundation.Setup() in bootstrap/app.go" + ctx := s.context("Blog", false, true, false, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + ctx.EXPECT().Warning(warning).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.True(file.Contain("bootstrap/modules.go", "&blog.Module{},")) + app, err := file.GetContent("bootstrap/app.go") + s.NoError(err) + s.Equal(assignedApp, app) +} + +// An assigned chain cannot be edited: modules.go is written and the user is told to wire it. +func (s *ModuleMakeCommandTestSuite) TestAssignedChainWithoutModulesFile() { + s.Require().NoError(file.PutContent("bootstrap/app.go", assignedApp)) + + ctx := s.context("Blog", false, true, false, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + ctx.EXPECT().Warning("Add .WithModules(Modules) to foundation.Setup() in bootstrap/app.go").Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.True(file.Contain("bootstrap/modules.go", "&blog.Module{},")) + app, err := file.GetContent("bootstrap/app.go") + s.NoError(err) + s.Equal(assignedApp, app) +} + +// --force removes the stubs the new variant does not write, keeps other files and registers once. +func (s *ModuleMakeCommandTestSuite) TestForceRemovesStaleStubs() { + ctx := s.context("Blog", false, false, false, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.Require().NoError(file.PutContent("modules/blog/resources/js/app.js", "")) + s.Require().NoError(file.PutContent("modules/blog/models/post.go", "package models\n")) + + ctx = s.context("Blog", true, false, true, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.ElementsMatch([]string{ + "module.go", + "providers/service_provider.go", + "routes/api.go", + "http/controllers/blog_controller.go", + "lang/en/messages.json", + "database/migrations/.gitkeep", + "database/seeders/.gitkeep", + "models/.gitkeep", + "models/post.go", + "resources/js/app.js", + }, s.files("modules/blog")) + s.False(file.Exists("modules/blog/resources/views")) + + ctx = s.context("Blog", false, true, true, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.ElementsMatch([]string{"module.go", "models/post.go", "resources/js/app.js"}, s.files("modules/blog")) + + modules, err := file.GetContent("bootstrap/modules.go") + s.NoError(err) + s.Equal(1, strings.Count(modules, "&blog.Module{},")) +} + +// --force keeps a stale stub that was edited and warns about it. +func (s *ModuleMakeCommandTestSuite) TestForceKeepsEditedStubs() { + ctx := s.context("Blog", false, false, false, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.Require().NoError(file.PutContent("modules/blog/routes/web.go", "package routes\n\n// edited\n")) + s.Require().NoError(file.PutContent("modules/blog/http/controllers/blog_controller.go", "package controllers\n")) + + ctx = s.context("Blog", false, true, true, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + ctx.EXPECT().Warning("Kept modules/blog/http/controllers/blog_controller.go: it was edited, remove it if the module no longer needs it").Once() + ctx.EXPECT().Warning("Kept modules/blog/routes/web.go: it was edited, remove it if the module no longer needs it").Once() + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.ElementsMatch([]string{"module.go", "routes/web.go", "http/controllers/blog_controller.go"}, s.files("modules/blog")) +} + +func (s *ModuleMakeCommandTestSuite) TestErrors() { + s.Run("exists without force", func() { + s.Require().NoError(file.PutContent("modules/blog/module.go", "package blog\n")) + + ctx := s.context("Blog", false, false, false, 0) + ctx.EXPECT().Error(errors.ModularModuleExists.Args("blog", "modules/blog").Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.Equal([]string{"module.go"}, s.files("modules/blog")) + s.False(file.Exists("bootstrap/modules.go")) + }) + + s.Run("api and plain", func() { + ctx := s.context("Shop", true, true, false, 0) + ctx.EXPECT().Error(errors.ModularOptionsConflict.Args("api", "plain").Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.False(file.Exists("modules/shop")) + }) + + for name, pkg := range map[string]string{"type": "type", "Go": "go", "main": "main", "Init": "init", "internal": "internal", "vendor": "vendor"} { + s.Run("reserved name "+name, func() { + ctx := s.context(name, false, false, false, 0) + ctx.EXPECT().Error(errors.ModularModuleNameForbidden.Args(name, pkg).Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.False(file.Exists("modules/" + pkg)) + }) + } + + s.Run("package name of the contracts import", func() { + ctx := s.context("Modular", false, false, false, 0) + ctx.EXPECT().Error(errors.ModularModuleImportConflict.Args("modular", "modular", "github.com/goravel/framework/contracts/modular").Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.False(file.Exists("modules/modular")) + s.False(file.Exists("bootstrap/modules.go")) + }) + + s.Run("package name of another module", func() { + ctx := s.context("BlogPosts", false, true, false, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog-posts").Once() + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + ctx = s.context("Blogposts", false, true, false, 0) + ctx.EXPECT().Error(errors.ModularModuleImportConflict.Args("blogposts", "blogposts", "github.com/goravel/framework/modules/blog-posts").Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.False(file.Exists("modules/blogposts")) + s.Equal(1, strings.Count(mustContent(s.T(), "bootstrap/modules.go"), "blogposts"+" ")) + removeScaffold(s.T()) + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + }) + + for _, name := range []string{"1blog", "blog/posts", "blog.posts", "-"} { + s.Run("invalid name "+name, func() { + ctx := s.context(name, false, false, false, 0) + ctx.EXPECT().Error(errors.ModularModuleNameInvalid.Args(name).Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + }) + } + + s.Run("empty name", func() { + ctx := s.context("", false, false, false, 0) + ctx.EXPECT().Ask("Enter the module name", mock.Anything).Return("", errors.ModularModuleNameEmpty).Once() + ctx.EXPECT().Error(errors.ModularModuleNameEmpty.Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + }) + + s.Run("legacy bootstrap", func() { + s.Require().NoError(file.PutContent("bootstrap/app.go", "package bootstrap\n")) + + ctx := s.context("Shop", false, false, false, 0) + ctx.EXPECT().Error(errors.ModularRequiresBootstrapSetup.Args("make:module").Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.False(file.Exists("modules/shop")) + }) +} + +func mustContent(t *testing.T, path string) string { + content, err := file.GetContent(path) + if err != nil { + t.Fatal(err) + } + + return content +} + +func (s *ModuleMakeCommandTestSuite) TestAsksForName() { + ctx := s.context("", false, true, false, 0) + ctx.EXPECT().Ask("Enter the module name", mock.Anything).RunAndReturn(func(_ string, options ...console.AskOption) (string, error) { + s.Require().Len(options, 1) + s.Equal(errors.ModularModuleNameEmpty, options[0].Validate("")) + s.NoError(options[0].Validate("Blog")) + + return "Blog", nil + }).Once() + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.Equal([]string{"module.go"}, s.files("modules/blog")) +} + +func (s *ModuleMakeCommandTestSuite) TestFailures() { + // failed runs make:module and expects one error, of any text. + failed := func(name string, plain, force bool) { + ctx := s.context(name, false, plain, force, 0) + ctx.EXPECT().Error(mock.Anything).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + } + reset := func() { + removeScaffold(s.T()) + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + } + + s.Run("blank name", func() { + ctx := s.context(" ", false, false, false, 0) + ctx.EXPECT().Error(errors.ModularModuleNameEmpty.Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + }) + + s.Run("bootstrap/modules.go is a directory", func() { + s.Require().NoError(file.PutContent("bootstrap/modules.go/keep", "")) + + failed("Blog", false, false) + s.False(file.Exists("modules/blog")) + reset() + }) + + s.Run("bootstrap/modules.go is not Go", func() { + s.Require().NoError(file.PutContent("bootstrap/modules.go", "not go\n")) + + failed("Blog", false, false) + s.False(file.Exists("modules/blog")) + reset() + }) + + s.Run("bootstrap/modules.go cannot be edited", func() { + // The imports parse, the rest does not. + s.Require().NoError(file.PutContent("bootstrap/modules.go", "package bootstrap\n\nfunc Modules( {\n")) + + failed("Blog", true, false) + s.True(file.Exists("modules/blog/module.go")) + reset() + }) + + s.Run("a stale stub cannot be removed", func() { + // --force --plain removes routes/api.go, here a directory with content. + s.Require().NoError(file.PutContent("modules/blog/routes/api.go/keep", "")) + + failed("Blog", true, true) + s.False(file.Exists("modules/blog/module.go")) + reset() + }) + + s.Run("a stub cannot be written", func() { + s.Require().NoError(file.PutContent("modules/blog/module.go/keep", "")) + + failed("Blog", true, true) + s.False(file.Exists("bootstrap/modules.go")) + reset() + }) +} diff --git a/modular/console/publish_command.go b/modular/console/publish_command.go new file mode 100644 index 000000000..194a9c910 --- /dev/null +++ b/modular/console/publish_command.go @@ -0,0 +1,108 @@ +package console + +import ( + "path/filepath" + "runtime/debug" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" + contractsmodular "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + "github.com/goravel/framework/modular" + "github.com/goravel/framework/support/path" + "github.com/goravel/framework/support/str" +) + +type PublishCommand struct { + registry contractsmodular.Registry + // buildInfo names the Go module and version of third-party modules. + buildInfo func() (*debug.BuildInfo, bool) +} + +func NewPublishCommand(registry contractsmodular.Registry) *PublishCommand { + return &PublishCommand{registry: registry, buildInfo: debug.ReadBuildInfo} +} + +// Signature The name and signature of the console command. +func (r *PublishCommand) Signature() string { + return "modular:publish" +} + +// Description The console command description. +func (r *PublishCommand) Description() string { + return "Copy the sources of third-party modules into the project" +} + +// Extend The console command extend. +func (r *PublishCommand) Extend() command.Extend { + return command.Extend{ + Category: "modular", + Flags: []command.Flag{ + &command.StringFlag{ + Name: "module", + Usage: "Publish only the given module", + }, + }, + } +} + +// Handle Execute the console command. It publishes every module and fails when one could not be. +func (r *PublishCommand) Handle(ctx console.Context) error { + if r.registry == nil { + return errors.ModularRegistryNotSet + } + + modules := r.registry.All() + if name := ctx.Option("module"); name != "" { + // As given, then in kebab case, like the --module option of make:*. + module, ok := r.registry.Find(name) + if !ok { + module, ok = r.registry.Find(str.Of(name).Kebab().String()) + } + if !ok { + return errors.ModularModuleNotFound.Args(name) + } + modules = []contractsmodular.Module{module} + } + + info, _ := r.buildInfo() + + failed := 0 + for _, module := range modules { + status, err := r.publish(module, info) + if err != nil { + status = statusFailed + ctx.Error(err.Error()) + failed++ + } + ctx.TwoColumnDetail(module.Name(), status) + } + if failed > 0 { + return errors.ModularPublishFailed.Args(failed, len(modules)) + } + + return nil +} + +func (r *PublishCommand) publish(module contractsmodular.Module, info *debug.BuildInfo) (string, error) { + dependency, ok := modular.DependencyOf(module, info) + if !ok { + return statusSkippedLocal, nil + } + + dir, _ := r.registry.Path(module.Name()) + changed, err := modular.Publish(module, dependency, path.Base(filepath.FromSlash(dir))) + switch { + case errors.Is(err, errors.ModularPublishTargetNotOwned): + // Name the directory as the other messages do, relative to the project. + return "", errors.ModularPublishTargetNotOwned.Args(module.Name(), dir, dependency.Path) + case err != nil: + return "", err + case changed: + return statusPublished, nil + case modular.PublishState(module, dependency, path.Base(filepath.FromSlash(dir))) == modular.PublishNone: + return statusSkippedNone, nil + default: + return statusUnchanged, nil + } +} diff --git a/modular/console/publish_command_test.go b/modular/console/publish_command_test.go new file mode 100644 index 000000000..7deeadd28 --- /dev/null +++ b/modular/console/publish_command_test.go @@ -0,0 +1,177 @@ +package console + +import ( + "io/fs" + "runtime/debug" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + contractsmodular "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + mocksconsole "github.com/goravel/framework/mocks/console" + mocksmodular "github.com/goravel/framework/mocks/modular" + "github.com/goravel/framework/modular" + "github.com/goravel/framework/support/file" + "github.com/goravel/framework/support/path" +) + +type testModule struct{ name string } + +func (m *testModule) Name() string { return m.name } + +type sourcesModule struct { + testModule + sources fs.FS +} + +func (m *sourcesModule) Sources() fs.FS { return m.sources } + +type locatedModule struct{ sourcesModule } + +func (m *locatedModule) Path() string { return "modules/local" } + +// thirdPartyBuild makes this package's test modules third-party: the app is another Go module. +func thirdPartyBuild() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{ + Main: debug.Module{Path: "example.com/app"}, + Deps: []*debug.Module{{Path: "github.com/goravel/framework", Version: "v1.3.0", Sum: "h1:abc"}}, + }, true +} + +type PublishCommandTestSuite struct { + suite.Suite +} + +func TestPublishCommandTestSuite(t *testing.T) { + suite.Run(t, new(PublishCommandTestSuite)) +} + +func (s *PublishCommandTestSuite) TearDownTest() { + removeScaffold(s.T()) +} + +func (s *PublishCommandTestSuite) command(registry contractsmodular.Registry) *PublishCommand { + cmd := NewPublishCommand(registry) + cmd.buildInfo = thirdPartyBuild + + return cmd +} + +func (s *PublishCommandTestSuite) TestSignature() { + cmd := NewPublishCommand(nil) + + s.Equal("modular:publish", cmd.Signature()) + s.Equal("Copy the sources of third-party modules into the project", cmd.Description()) + s.Equal("modular", cmd.Extend().Category) + s.Len(cmd.Extend().Flags, 1) +} + +func (s *PublishCommandTestSuite) TestRegistryNotSet() { + s.Equal(errors.ModularRegistryNotSet, NewPublishCommand(nil).Handle(mocksconsole.NewContext(s.T()))) +} + +func (s *PublishCommandTestSuite) TestPublishesThirdPartyModules() { + billing := &sourcesModule{testModule{"billing"}, fstest.MapFS{"resources/js/app.js": {Data: []byte("app")}}} + plain := &testModule{"plain"} + local := &locatedModule{sourcesModule{testModule{"local"}, fstest.MapFS{"app.js": {Data: []byte("app")}}}} + + registry := mocksmodular.NewRegistry(s.T()) + registry.EXPECT().All().Return([]contractsmodular.Module{billing, plain, local}) + registry.EXPECT().Path("billing").Return("modules/billing", true) + registry.EXPECT().Path("plain").Return("modules/plain", true) + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().Option("module").Return("").Once() + ctx.EXPECT().TwoColumnDetail("billing", statusPublished).Once() + ctx.EXPECT().TwoColumnDetail("plain", statusSkippedNone).Once() + ctx.EXPECT().TwoColumnDetail("local", statusSkippedLocal).Once() + + s.NoError(s.command(registry).Handle(ctx)) + + s.True(file.Exists("modules/billing/resources/js/app.js")) + s.True(file.Contain("modules/billing/"+modular.PublishMarker, `"version": "v1.3.0"`)) + s.False(file.Exists("modules/plain")) + s.False(file.Exists("modules/local")) + s.Equal(modular.PublishPublished, modular.PublishState(billing, modular.Dependency{Path: "github.com/goravel/framework", Version: "v1.3.0", Sum: "h1:abc"}, path.Base("modules", "billing"))) + + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().Option("module").Return("").Once() + ctx.EXPECT().TwoColumnDetail("billing", statusUnchanged).Once() + ctx.EXPECT().TwoColumnDetail("plain", statusSkippedNone).Once() + ctx.EXPECT().TwoColumnDetail("local", statusSkippedLocal).Once() + + s.NoError(s.command(registry).Handle(ctx)) +} + +func (s *PublishCommandTestSuite) TestModuleOption() { + billing := &sourcesModule{testModule{"billing"}, fstest.MapFS{"app.js": {Data: []byte("app")}}} + + registry := mocksmodular.NewRegistry(s.T()) + registry.EXPECT().All().Return([]contractsmodular.Module{billing, &testModule{"plain"}}) + registry.EXPECT().Find("billing").Return(billing, true).Twice() + registry.EXPECT().Find("Billing").Return(nil, false).Once() + // Not found as given nor in kebab case, which is the same name here. + registry.EXPECT().Find("shop").Return(nil, false).Twice() + registry.EXPECT().Path("billing").Return("modules/billing", true) + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().Option("module").Return("billing").Once() + ctx.EXPECT().TwoColumnDetail("billing", statusPublished).Once() + + s.NoError(s.command(registry).Handle(ctx)) + + // The kebab-case name is tried next, like make:* --module does. + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().Option("module").Return("Billing").Once() + ctx.EXPECT().TwoColumnDetail("billing", statusUnchanged).Once() + + s.NoError(s.command(registry).Handle(ctx)) + + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().Option("module").Return("shop").Once() + + s.Equal(errors.ModularModuleNotFound.Args("shop"), s.command(registry).Handle(ctx)) +} + +func (s *PublishCommandTestSuite) TestFailureDoesNotStopOtherModules() { + billing := &sourcesModule{testModule{"billing"}, fstest.MapFS{"app.js": {Data: []byte("app")}}} + shop := &sourcesModule{testModule{"shop"}, fstest.MapFS{"app.js": {Data: []byte("app")}}} + s.Require().NoError(file.PutContent("modules/billing/module.go", "package billing\n")) + + registry := mocksmodular.NewRegistry(s.T()) + registry.EXPECT().All().Return([]contractsmodular.Module{billing, shop}) + registry.EXPECT().Path("billing").Return("modules/billing", true) + registry.EXPECT().Path("shop").Return("modules/shop", true) + + notOwned := errors.ModularPublishTargetNotOwned.Args("billing", "modules/billing", "github.com/goravel/framework") + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().Option("module").Return("").Once() + ctx.EXPECT().Error(notOwned.Error()).Once() + ctx.EXPECT().TwoColumnDetail("billing", statusFailed).Once() + ctx.EXPECT().TwoColumnDetail("shop", statusPublished).Once() + + s.Equal(errors.ModularPublishFailed.Args(1, 2), s.command(registry).Handle(ctx)) + s.False(file.Exists("modules/billing/app.js")) + s.True(file.Exists("modules/shop/app.js")) +} + +func (s *PublishCommandTestSuite) TestWriteFailure() { + billing := &sourcesModule{testModule{"billing"}, fstest.MapFS{"resources/js/app.js": {Data: []byte("app")}}} + // An earlier publish owns the directory, but a file is where resources/ goes. + s.Require().NoError(file.PutContent("modules/billing/"+modular.PublishMarker, `{"module": "github.com/goravel/framework"}`)) + s.Require().NoError(file.PutContent("modules/billing/resources", "file")) + + registry := mocksmodular.NewRegistry(s.T()) + registry.EXPECT().All().Return([]contractsmodular.Module{billing}) + registry.EXPECT().Path("billing").Return("modules/billing", true) + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().Option("module").Return("").Once() + ctx.EXPECT().Error(mock.Anything).Once() + ctx.EXPECT().TwoColumnDetail("billing", statusFailed).Once() + + s.Equal(errors.ModularPublishFailed.Args(1, 1), s.command(registry).Handle(ctx)) +} diff --git a/modular/console/stubs.go b/modular/console/stubs.go new file mode 100644 index 000000000..a208fad49 --- /dev/null +++ b/modular/console/stubs.go @@ -0,0 +1,308 @@ +package console + +import ( + "fmt" + "strings" + + "github.com/goravel/framework/packages" + "github.com/goravel/framework/support/file" + "github.com/goravel/framework/support/path" +) + +type Stubs struct{} + +func (r Stubs) Config(pkg, facadesImport, facadesPackage string) string { + content := `package DummyPackage + +import ( + "github.com/goravel/framework/contracts/http" + + "DummyFacadesImport" +) + +func init() { + config := DummyFacadesPackage.Config() + config.Add("modular", map[string]any{ + // Directory of the application's own modules, relative to the project root. + "path": "modules", + + // Route groups wrapping what modules register with Routes() and ApiRoutes(). + "routes": map[string]any{ + "web": map[string]any{"prefix": "", "middleware": []http.Middleware{}}, + "api": map[string]any{"prefix": "api", "middleware": []http.Middleware{}}, + }, + }) +} +` + + return strings.NewReplacer( + "DummyPackage", pkg, + "DummyFacadesImport", facadesImport, + "DummyFacadesPackage", facadesPackage, + ).Replace(content) +} + +// langFacade returns the facades package exposing Lang(ctx): the app's, the +// framework's when the app has no facades, or ok=false when the app's lacks it. +func langFacade() (importPath, pkg string, ok bool) { + if file.Exists(path.Facade("lang.go")) { + facades := packages.Paths().Facades() + + return facades.Import(), facades.Package(), true + } + if file.Exists(path.Facade()) { + return "", "", false + } + + return "github.com/goravel/framework/facades", "facades", true +} + +func (r *moduleStubs) replace(content string) string { + return strings.NewReplacer( + "DummyModule", r.module, + "DummyPackage", r.pkg, + "DummyStudly", r.studly, + "DummySnake", r.snake, + "DummyTitle", r.title, + "DummyName", r.kebab, + ).Replace(content) +} + +// Module is module.go: web adds views, web routes and Vite entries, lang the +// translations, full the provider, API routes and config. +func (r *moduleStubs) Module(web, lang, full bool) string { + var imports, vars, methods []string + + if web || lang { + imports = append(imports, "\t\"embed\"\n\t\"io/fs\"\n") + } + if web { + vars = append(vars, "//go:embed resources/views\nvar views embed.FS\n\n") + } + if lang { + vars = append(vars, "//go:embed lang\nvar lang embed.FS\n\n") + } + if full { + imports = append(imports, ` "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/contracts/route" +`, ` "DummyModule/providers" + "DummyModule/routes" +`) + methods = append(methods, ` +func (m *Module) ServiceProviders() []foundation.ServiceProvider { + return []foundation.ServiceProvider{ + &providers.ServiceProvider{}, + } +} +`) + } + if web { + methods = append(methods, ` +func (m *Module) Routes(router route.Router) { + routes.Web(router) +} +`) + } + if full { + methods = append(methods, ` +func (m *Module) ApiRoutes(router route.Router) { + routes.Api(router) +} + +// Config is available as "modules.DummyName". +func (m *Module) Config() map[string]any { + return map[string]any{ + "enabled": true, + } +} +`) + } + if web { + methods = append(methods, ` +// Views are named "DummyName/", e.g. "DummyName/index.tmpl". +func (m *Module) Views() fs.FS { + sub, _ := fs.Sub(views, "resources/views") + + return sub +} +`) + } + if lang { + methods = append(methods, ` +// Translations are grouped as "DummyName/", e.g. "DummyName/messages.title". +func (m *Module) Translations() fs.FS { + sub, _ := fs.Sub(lang, "lang") + + return sub +} +`) + } + if web { + methods = append(methods, ` +// ViteEntries are relative to the module directory, e.g. "resources/js/app.js". +// ViteAliases() and Static() are optional too, see contracts/modular. +func (m *Module) ViteEntries() []string { + return []string{} +} +`) + } + if r.order != 0 { + methods = append(methods, fmt.Sprintf(` +func (m *Module) Order() int { + return %d +} +`, r.order)) + } + + content := "package DummyPackage\n\n" + if len(imports) > 0 { + content += "import (\n" + strings.Join(imports, "\n") + ")\n\n" + } + content += strings.Join(vars, "") + `type Module struct{} + +func (m *Module) Name() string { + return "DummyName" +} +` + strings.Join(methods, "") + + return r.replace(content) +} + +func (r *moduleStubs) ServiceProvider() string { + return `package providers + +import ( + "github.com/goravel/framework/contracts/foundation" +) + +type ServiceProvider struct{} + +// Register service bindings here. +func (r *ServiceProvider) Register(app foundation.Application) { +} + +// Boot runs after all service providers have been registered. +func (r *ServiceProvider) Boot(app foundation.Application) { +} +` +} + +func (r *moduleStubs) WebRoutes() string { + return r.replace(`package routes + +import ( + "github.com/goravel/framework/contracts/route" + + "DummyModule/http/controllers" +) + +func Web(router route.Router) { + router.Get("/DummyName", controllers.NewDummyStudlyController().Index) +} +`) +} + +func (r *moduleStubs) ApiRoutes() string { + return r.replace(`package routes + +import ( + "github.com/goravel/framework/contracts/route" + + "DummyModule/http/controllers" +) + +func Api(router route.Router) { + router.Get("/DummyName", controllers.NewDummyStudlyController().Show) +} +`) +} + +// Controller has Show for the API route and, with web, Index rendering the module view. +func (r *moduleStubs) Controller(web bool) string { + imports := "import (\n\t\"github.com/goravel/framework/contracts/http\"\n)\n" + index := "" + if web { + title := `"DummyTitle"` + if facadesImport, facadesPackage, ok := langFacade(); ok { + imports = "import (\n\t\"github.com/goravel/framework/contracts/http\"\n\n\t\"" + facadesImport + "\"\n)\n" + title = facadesPackage + `.Lang(ctx).Get("DummyName/messages.title")` + } + index = ` +func (r *DummyStudlyController) Index(ctx http.Context) http.Response { + return ctx.Response().View().Make("DummyName/index.tmpl", map[string]any{ + "title": ` + title + `, + }) +} +` + } + + return r.replace(`package controllers + +` + imports + ` +type DummyStudlyController struct{} + +func NewDummyStudlyController() *DummyStudlyController { + return &DummyStudlyController{} +} +` + index + ` +func (r *DummyStudlyController) Show(ctx http.Context) http.Response { + return ctx.Response().Success().Json(http.Json{ + "module": "DummyName", + }) +} +`) +} + +// View has no {{ define }}: define names are global and would collide across modules. +func (r *moduleStubs) View() string { + return ` + + + + + {{ .title }} + + +

{{ .title }}

+ + +` +} + +func (r *moduleStubs) Lang() string { + return r.replace(`{ + "title": "DummyTitle" +} +`) +} + +func (r *moduleStubs) Test() string { + tests := packages.Paths().Tests() + + return r.replace(strings.NewReplacer( + "DummyTestsImport", tests.Import(), + "DummyTestsPackage", tests.Package(), + ).Replace(`package feature + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "DummyTestsImport" +) + +type DummyStudlyTestSuite struct { + suite.Suite + DummyTestsPackage.TestCase +} + +func TestDummyStudlyTestSuite(t *testing.T) { + suite.Run(t, new(DummyStudlyTestSuite)) +} + +func (s *DummyStudlyTestSuite) TestIndex() { + // TODO +} +`)) +} diff --git a/modular/lang_fs.go b/modular/lang_fs.go new file mode 100644 index 000000000..7c4a8e8ef --- /dev/null +++ b/modular/lang_fs.go @@ -0,0 +1,72 @@ +package modular + +import ( + "errors" + "io/fs" + "strings" +) + +var _ fs.ReadFileFS = (*langFS)(nil) + +// langFS maps "en/blog/messages.json" to the blog module's "en/messages.json". +// The app's own lang FS (fallback) is asked first, so it can override modules. +type langFS struct { + modules map[string]fs.FS + fallback fs.FS +} + +// NewLangFS combines module translations, keyed by name, with an optional fallback. +// A langFS fallback is unwrapped: its modules are replaced and its own fallback kept. +func NewLangFS(modules map[string]fs.FS, fallback fs.FS) fs.FS { + if prev, ok := fallback.(*langFS); ok { + fallback = prev.fallback + } + + return &langFS{modules: modules, fallback: fallback} +} + +func (r *langFS) Open(name string) (fs.File, error) { + return open(r, "open", name, func(fsys fs.FS, name string) (fs.File, error) { + return fsys.Open(name) + }) +} + +func (r *langFS) ReadFile(name string) ([]byte, error) { + return open(r, "readfile", name, fs.ReadFile) +} + +// open asks the fallback, then the owning module. +func open[T any](r *langFS, op, name string, read func(fs.FS, string) (T, error)) (T, error) { + var zero T + if !fs.ValidPath(name) { + return zero, &fs.PathError{Op: op, Path: name, Err: fs.ErrInvalid} + } + + module, inner, owned := r.owner(name) + if r.fallback != nil { + result, err := read(r.fallback, name) + if err == nil || !owned || !errors.Is(err, fs.ErrNotExist) { + return result, err + } + } + if !owned { + return zero, &fs.PathError{Op: op, Path: name, Err: fs.ErrNotExist} + } + + return read(module, inner) +} + +// owner splits "//". +func (r *langFS) owner(name string) (fs.FS, string, bool) { + parts := strings.SplitN(name, "/", 3) + if len(parts) < 3 { + return nil, "", false + } + + module, ok := r.modules[parts[1]] + if !ok || module == nil { + return nil, "", false + } + + return module, parts[0] + "/" + parts[2], true +} diff --git a/modular/lang_fs_test.go b/modular/lang_fs_test.go new file mode 100644 index 000000000..652424199 --- /dev/null +++ b/modular/lang_fs_test.go @@ -0,0 +1,115 @@ +package modular + +import ( + "io/fs" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/goravel/framework/errors" + "github.com/goravel/framework/foundation/json" + "github.com/goravel/framework/translation" +) + +func TestLangFS(t *testing.T) { + modules := map[string]fs.FS{ + "blog": fstest.MapFS{ + "en/messages.json": {Data: []byte(`{"title": "Blog"}`)}, + "en/admin/posts.json": {Data: []byte(`{"title": "Posts"}`)}, + "en/overridden.json": {Data: []byte(`{"title": "Module"}`)}, + "fr/messages.json": {Data: []byte(`{"title": "Blogue"}`)}, + }, + } + fallback := fstest.MapFS{ + "en.json": {Data: []byte(`{"hello": "Hello"}`)}, + "en/app.json": {Data: []byte(`{"name": "App"}`)}, + "en/blog/overridden.json": {Data: []byte(`{"title": "App"}`)}, + } + + loader := translation.NewFSLoader(NewLangFS(modules, fallback), json.New()) + + tests := []struct { + locale, group string + expected map[string]any + }{ + {"en", "blog/messages", map[string]any{"title": "Blog"}}, + {"fr", "blog/messages", map[string]any{"title": "Blogue"}}, + {"en", "blog/admin/posts", map[string]any{"title": "Posts"}}, + {"en", "blog/overridden", map[string]any{"title": "App"}}, + {"en", "app", map[string]any{"name": "App"}}, + {"en", "*", map[string]any{"hello": "Hello"}}, + } + for _, tt := range tests { + values, err := loader.Load(tt.locale, tt.group) + require.NoError(t, err, tt.group) + assert.Equal(t, tt.expected, values, tt.group) + } + + for _, group := range []string{"blog/missing", "shop/messages", "missing"} { + _, err := loader.Load("en", group) + assert.ErrorIs(t, err, errors.LangFileNotExist, group) + } + _, err := loader.Load("de", "blog/messages") + assert.ErrorIs(t, err, errors.LangFileNotExist) +} + +func TestLangFS_WithoutFallback(t *testing.T) { + fsys := NewLangFS(map[string]fs.FS{"blog": fstest.MapFS{"en/messages.json": {Data: []byte(`{}`)}}}, nil) + + _, err := fs.ReadFile(fsys, "en/blog/messages.json") + assert.NoError(t, err) + + for _, name := range []string{"en.json", "en/app.json", "en/shop/messages.json"} { + _, err := fs.ReadFile(fsys, name) + assert.ErrorIs(t, err, fs.ErrNotExist, name) + + _, err = fsys.Open(name) + assert.ErrorIs(t, err, fs.ErrNotExist, name) + } + + _, err = fsys.Open("../en.json") + assert.ErrorIs(t, err, fs.ErrInvalid) +} + +func TestLangFS_UnwrapsPrevious(t *testing.T) { + fallback := fstest.MapFS{"en/app.json": {Data: []byte(`{"name": "App"}`)}} + first := NewLangFS(map[string]fs.FS{ + "blog": fstest.MapFS{"en/messages.json": {Data: []byte(`{"title": "Old"}`)}}, + "shop": fstest.MapFS{"en/messages.json": {Data: []byte(`{}`)}}, + }, fallback) + second := NewLangFS(map[string]fs.FS{ + "blog": fstest.MapFS{"en/messages.json": {Data: []byte(`{"title": "New"}`)}}, + }, first) + + assert.Equal(t, fs.FS(fallback), second.(*langFS).fallback) + + data, err := fs.ReadFile(second, "en/blog/messages.json") + require.NoError(t, err) + assert.JSONEq(t, `{"title": "New"}`, string(data)) + + _, err = fs.ReadFile(second, "en/app.json") + assert.NoError(t, err) + + _, err = fs.ReadFile(second, "en/shop/messages.json") + assert.ErrorIs(t, err, fs.ErrNotExist) +} + +func TestLangFS_Open(t *testing.T) { + fsys := NewLangFS(map[string]fs.FS{ + "blog": fstest.MapFS{"en/messages.json": {Data: []byte(`{"title": "Blog"}`)}}, + }, nil) + + opened, err := fsys.Open("en/blog/messages.json") + require.NoError(t, err) + info, err := opened.Stat() + require.NoError(t, err) + assert.Equal(t, "messages.json", info.Name()) + assert.NoError(t, opened.Close()) + + _, err = fsys.Open("en/shop/messages.json") + assert.ErrorIs(t, err, fs.ErrNotExist) + _, err = fsys.Open("../en/blog/messages.json") + assert.ErrorIs(t, err, fs.ErrInvalid) +} diff --git a/modular/path.go b/modular/path.go new file mode 100644 index 000000000..b7c2b13b2 --- /dev/null +++ b/modular/path.go @@ -0,0 +1,66 @@ +package modular + +import ( + stdpath "path" + "reflect" + "strings" + + "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + "github.com/goravel/framework/support/path" +) + +// DefaultDirectory holds modules, including published third-party ones. +const DefaultDirectory = "modules" + +// ResolvePath returns the module directory relative to the project root: Located, +// else the package path relative to mainModule, else "/" for a +// package of another Go module. +func ResolvePath(module modular.Module, mainModule, directory string) (string, error) { + name := module.Name() + if located, ok := module.(modular.Located); ok { + dir, ok := path.CleanRelative(located.Path()) + if !ok || strings.TrimSpace(located.Path()) == "" { + return "", errors.ModularModulePathInvalid.Args(name, located.Path()) + } + + return dir, nil + } + + return derivePath(name, packagePath(module), mainModule, directory) +} + +func derivePath(name, pkg, mainModule, directory string) (string, error) { + if pkg == "" || pkg == "main" || mainModule == "" { + return "", errors.ModularModulePathUnknown.Args(name, pkg) + } + + if pkg == mainModule { + return ".", nil + } + if rel, ok := strings.CutPrefix(pkg, mainModule+"/"); ok { + return rel, nil + } + + // Another Go module: one element under directory. + base, ok := path.CleanRelative(directory) + if !ok { + return "", errors.ModularModulePathInvalid.Args(name, directory) + } + dir := stdpath.Join(base, name) + if stdpath.Dir(dir) != base || stdpath.Base(dir) != name { + return "", errors.ModularModulePathInvalid.Args(name, dir) + } + + return dir, nil +} + +// packagePath is the import path of module's type, "" when unnamed. +func packagePath(module modular.Module) string { + t := reflect.TypeOf(module) + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + return t.PkgPath() +} diff --git a/modular/path_test.go b/modular/path_test.go new file mode 100644 index 000000000..e9857ff82 --- /dev/null +++ b/modular/path_test.go @@ -0,0 +1,81 @@ +package modular + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/goravel/framework/errors" +) + +func TestResolvePath(t *testing.T) { + t.Run("a package inside the application is its directory", func(t *testing.T) { + dir, err := ResolvePath(&plainModule{"blog"}, "github.com/goravel/framework", DefaultDirectory) + require.NoError(t, err) + assert.Equal(t, "modular", dir) + }) + + t.Run("a package of another Go module goes under the module directory", func(t *testing.T) { + dir, err := ResolvePath(&plainModule{"blog"}, "example.com/app", DefaultDirectory) + require.NoError(t, err) + assert.Equal(t, "modules/blog", dir) + }) + + t.Run("Located wins and is cleaned", func(t *testing.T) { + for given, expected := range map[string]string{ + "modules/shop": "modules/shop", + "modules/shop/": "modules/shop", + "modules/../app/shop": "app/shop", + "./packages/shop/../x/": "packages/x", + } { + dir, err := ResolvePath(&locatedModule{plainModule{"shop"}, given}, "", DefaultDirectory) + require.NoError(t, err, given) + assert.Equal(t, expected, dir, given) + } + }) + + t.Run("Located outside the project is refused", func(t *testing.T) { + for _, given := range []string{"", " ", "..", "../shop", "modules/../../shop", "/srv/shop"} { + _, err := ResolvePath(&locatedModule{plainModule{"shop"}, given}, "example.com/app", DefaultDirectory) + assert.ErrorIs(t, err, errors.ModularModulePathInvalid, given) + } + }) +} + +func TestDerivePath(t *testing.T) { + const main = "example.com/app" + + tests := []struct { + name, pkg, mainModule, directory, expected string + err error + }{ + {"blog", "example.com/app/modules/blog", main, "modules", "modules/blog", nil}, + {"blog", "example.com/app/features/blog-posts", main, "modules", "features/blog-posts", nil}, + {"root", "example.com/app", main, "modules", ".", nil}, + {"billing", "github.com/acme/billing", main, "modules", "modules/billing", nil}, + {"billing", "github.com/acme/billing/module", main, "app/modules/", "app/modules/billing", nil}, + // A prefix match must end at a path separator. + {"billing", "example.com/application/billing", main, "modules", "modules/billing", nil}, + {"blog", "main", main, "modules", "", errors.ModularModulePathUnknown}, + {"blog", "", main, "modules", "", errors.ModularModulePathUnknown}, + {"blog", "example.com/app/blog", "", "modules", "", errors.ModularModulePathUnknown}, + {"a/b", "github.com/acme/billing", main, "modules", "", errors.ModularModulePathInvalid}, + {"..", "github.com/acme/billing", main, "modules", "", errors.ModularModulePathInvalid}, + {"", "github.com/acme/billing", main, "modules", "", errors.ModularModulePathInvalid}, + {"billing", "github.com/acme/billing", main, "../modules", "", errors.ModularModulePathInvalid}, + } + for _, tt := range tests { + dir, err := derivePath(tt.name, tt.pkg, tt.mainModule, tt.directory) + if tt.err != nil { + assert.ErrorIs(t, err, tt.err, tt.pkg) + + continue + } + require.NoError(t, err, tt.pkg) + assert.Equal(t, tt.expected, dir, tt.pkg) + } + + _, err := derivePath("blog", "main", main, "modules") + assert.EqualError(t, err, `[modular] module blog: cannot derive its directory from package "main", implement Path() to set it`) +} diff --git a/modular/prefix_fs.go b/modular/prefix_fs.go new file mode 100644 index 000000000..3e4f16db7 --- /dev/null +++ b/modular/prefix_fs.go @@ -0,0 +1,218 @@ +package modular + +import ( + "io" + "io/fs" + "strings" + "time" +) + +var ( + _ fs.ReadDirFS = (*prefixFS)(nil) + _ fs.ReadFileFS = (*prefixFS)(nil) + _ fs.StatFS = (*prefixFS)(nil) +) + +// prefixFS mounts inner at name: "blog/index.tmpl" is inner's "index.tmpl". +type prefixFS struct { + name string + inner fs.FS +} + +// NewPrefixFS mounts inner at name, a single path element. +func NewPrefixFS(name string, inner fs.FS) fs.FS { + return &prefixFS{name: name, inner: inner} +} + +func (r *prefixFS) Open(name string) (fs.File, error) { + inner, kind, err := r.resolve("open", name) + if err != nil { + return nil, err + } + + switch kind { + case pathRoot: + info, err := r.mountInfo() + if err != nil { + return nil, &fs.PathError{Op: "open", Path: name, Err: err} + } + + return &rootDir{info: rootInfo{modTime: info.ModTime()}, entries: []fs.DirEntry{fs.FileInfoToDirEntry(info)}}, nil + case pathMount: + file, err := r.inner.Open(".") + if err != nil { + return nil, &fs.PathError{Op: "open", Path: name, Err: err} + } + + return &renamedFile{File: file, name: r.name}, nil + default: + return r.inner.Open(inner) + } +} + +func (r *prefixFS) ReadDir(name string) ([]fs.DirEntry, error) { + inner, kind, err := r.resolve("readdir", name) + if err != nil { + return nil, err + } + + if kind == pathRoot { + info, err := r.mountInfo() + if err != nil { + return nil, &fs.PathError{Op: "readdir", Path: name, Err: err} + } + + return []fs.DirEntry{fs.FileInfoToDirEntry(info)}, nil + } + + return fs.ReadDir(r.inner, inner) +} + +func (r *prefixFS) ReadFile(name string) ([]byte, error) { + inner, kind, err := r.resolve("readfile", name) + if err != nil { + return nil, err + } + if kind != pathInner { + return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrInvalid} + } + + return fs.ReadFile(r.inner, inner) +} + +func (r *prefixFS) Stat(name string) (fs.FileInfo, error) { + inner, kind, err := r.resolve("stat", name) + if err != nil { + return nil, err + } + + switch kind { + case pathRoot: + info, err := r.mountInfo() + if err != nil { + return nil, &fs.PathError{Op: "stat", Path: name, Err: err} + } + + return rootInfo{modTime: info.ModTime()}, nil + case pathMount: + return r.mountInfo() + default: + return fs.Stat(r.inner, inner) + } +} + +type pathKind int + +const ( + pathRoot pathKind = iota // "." + pathMount // name + pathInner // name/x +) + +// resolve classifies name and returns inner's path for pathInner. +func (r *prefixFS) resolve(op, name string) (string, pathKind, error) { + if !fs.ValidPath(name) { + return "", 0, &fs.PathError{Op: op, Path: name, Err: fs.ErrInvalid} + } + + switch { + case name == ".": + return "", pathRoot, nil + case name == r.name: + return ".", pathMount, nil + case strings.HasPrefix(name, r.name+"/"): + return strings.TrimPrefix(name, r.name+"/"), pathInner, nil + default: + return "", 0, &fs.PathError{Op: op, Path: name, Err: fs.ErrNotExist} + } +} + +// mountInfo is inner's root, renamed to the mount point. +func (r *prefixFS) mountInfo() (fs.FileInfo, error) { + info, err := fs.Stat(r.inner, ".") + if err != nil { + return nil, err + } + + return renamedInfo{FileInfo: info, name: r.name}, nil +} + +// rootInfo describes the synthetic root. +type rootInfo struct{ modTime time.Time } + +func (i rootInfo) Name() string { return "." } +func (i rootInfo) Size() int64 { return 0 } +func (i rootInfo) Mode() fs.FileMode { return fs.ModeDir | 0o555 } +func (i rootInfo) ModTime() time.Time { return i.modTime } +func (i rootInfo) IsDir() bool { return true } +func (i rootInfo) Sys() any { return nil } + +// rootDir lists only the mount point. +type rootDir struct { + info rootInfo + entries []fs.DirEntry + offset int +} + +func (d *rootDir) Stat() (fs.FileInfo, error) { return d.info, nil } + +func (d *rootDir) Read([]byte) (int, error) { + return 0, &fs.PathError{Op: "read", Path: ".", Err: fs.ErrInvalid} +} + +func (d *rootDir) Close() error { return nil } + +func (d *rootDir) ReadDir(n int) ([]fs.DirEntry, error) { + return readDirEntries(d.entries, &d.offset, n) +} + +// renamedInfo overrides a file's name. +type renamedInfo struct { + fs.FileInfo + name string +} + +func (i renamedInfo) Name() string { return i.name } + +// renamedFile is inner's root, named after the mount point. +type renamedFile struct { + fs.File + name string +} + +func (f *renamedFile) Stat() (fs.FileInfo, error) { + info, err := f.File.Stat() + if err != nil { + return nil, err + } + + return renamedInfo{FileInfo: info, name: f.name}, nil +} + +func (f *renamedFile) ReadDir(n int) ([]fs.DirEntry, error) { + dir, ok := f.File.(fs.ReadDirFile) + if !ok { + return nil, &fs.PathError{Op: "readdir", Path: f.name, Err: fs.ErrInvalid} + } + + return dir.ReadDir(n) +} + +// readDirEntries implements ReadDir over a fixed list. +func readDirEntries(entries []fs.DirEntry, offset *int, n int) ([]fs.DirEntry, error) { + rest := entries[*offset:] + if n <= 0 { + *offset = len(entries) + + return rest, nil + } + if len(rest) == 0 { + return nil, io.EOF + } + if n > len(rest) { + n = len(rest) + } + *offset += n + + return rest[:n], nil +} diff --git a/modular/prefix_fs_test.go b/modular/prefix_fs_test.go new file mode 100644 index 000000000..e15ad3ec2 --- /dev/null +++ b/modular/prefix_fs_test.go @@ -0,0 +1,171 @@ +package modular_test + +import ( + "bytes" + "io" + "io/fs" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/goravel/framework/modular" + "github.com/goravel/framework/support" + "github.com/goravel/framework/view" +) + +func blogViews() fstest.MapFS { + return fstest.MapFS{ + "index.tmpl": {Data: []byte(`{{ template "blog/layouts/base.tmpl" . }}`)}, + "layouts/base.tmpl": {Data: []byte(`

{{ .title }}

`)}, + } +} + +func TestPrefixFS_TestFS(t *testing.T) { + assert.NoError(t, fstest.TestFS(modular.NewPrefixFS("blog", blogViews()), "blog/index.tmpl", "blog/layouts/base.tmpl")) +} + +func TestPrefixFS_Paths(t *testing.T) { + fsys := modular.NewPrefixFS("blog", blogViews()) + + entries, err := fs.ReadDir(fsys, ".") + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, "blog", entries[0].Name()) + assert.True(t, entries[0].IsDir()) + + info, err := fs.Stat(fsys, "blog") + require.NoError(t, err) + assert.Equal(t, "blog", info.Name()) + assert.True(t, info.IsDir()) + + content, err := fs.ReadFile(fsys, "blog/layouts/base.tmpl") + require.NoError(t, err) + assert.Equal(t, `

{{ .title }}

`, string(content)) + + for _, name := range []string{"index.tmpl", "shop/index.tmpl", "blogs/index.tmpl", "blog/missing.tmpl"} { + _, err := fsys.Open(name) + assert.ErrorIs(t, err, fs.ErrNotExist, name) + } + + _, err = fsys.Open("../blog") + assert.ErrorIs(t, err, fs.ErrInvalid) +} + +// Views load and compile from the mounted FS, named under the module. +func TestPrefixFS_Views(t *testing.T) { + relativePath := support.RelativePath + t.Cleanup(func() { support.RelativePath = relativePath }) + support.RelativePath = t.TempDir() + + fsys := modular.NewPrefixFS("blog", blogViews()) + + registry := view.NewView() + assert.NotPanics(t, func() { registry.LoadViewsFromFS(fsys, ".") }) + assert.True(t, registry.Exists("blog/index.tmpl")) + assert.False(t, registry.Exists("blog")) + assert.False(t, registry.Exists("index.tmpl")) + + tmpl, err := view.Compile(view.CompileOptions{FS: registry.RegisteredViewFS()}) + require.NoError(t, err) + + var out bytes.Buffer + require.NoError(t, tmpl.ExecuteTemplate(&out, "blog/index.tmpl", map[string]any{"title": "Blog"})) + assert.Equal(t, "

Blog

", out.String()) +} + +// stubFS opens file for every name, or fails with err. +type stubFS struct { + file fs.File + err error +} + +func (r stubFS) Open(string) (fs.File, error) { return r.file, r.err } + +// stubFile is a file that cannot be listed; Stat fails with err. +type stubFile struct{ err error } + +func (r stubFile) Stat() (fs.FileInfo, error) { return nil, r.err } +func (r stubFile) Read([]byte) (int, error) { return 0, io.EOF } +func (r stubFile) Close() error { return nil } + +func TestPrefixFS_Root(t *testing.T) { + fsys := modular.NewPrefixFS("blog", blogViews()) + + root, err := fsys.Open(".") + require.NoError(t, err) + t.Cleanup(func() { assert.NoError(t, root.Close()) }) + + info, err := root.Stat() + require.NoError(t, err) + assert.Equal(t, ".", info.Name()) + assert.Zero(t, info.Size()) + assert.Equal(t, fs.ModeDir|0o555, info.Mode()) + assert.True(t, info.IsDir()) + assert.Nil(t, info.Sys()) + + mount, err := fs.Stat(fsys, "blog") + require.NoError(t, err) + assert.Equal(t, mount.ModTime(), info.ModTime()) + + _, err = root.Read(make([]byte, 1)) + assert.ErrorIs(t, err, fs.ErrInvalid) + + // More entries are asked for than the single mount point. + dir, ok := root.(fs.ReadDirFile) + require.True(t, ok) + entries, err := dir.ReadDir(5) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, "blog", entries[0].Name()) + _, err = dir.ReadDir(5) + assert.ErrorIs(t, err, io.EOF) +} + +func TestPrefixFS_InvalidPaths(t *testing.T) { + fsys := modular.NewPrefixFS("blog", blogViews()) + + _, err := fs.ReadDir(fsys, "../blog") + assert.ErrorIs(t, err, fs.ErrInvalid) + + _, err = fs.ReadFile(fsys, "../blog/index.tmpl") + assert.ErrorIs(t, err, fs.ErrInvalid) + + // Directories are not files. + for _, name := range []string{".", "blog"} { + _, err = fs.ReadFile(fsys, name) + assert.ErrorIs(t, err, fs.ErrInvalid, name) + } +} + +func TestPrefixFS_UnreadableInner(t *testing.T) { + fsys := modular.NewPrefixFS("blog", stubFS{err: fs.ErrPermission}) + + _, err := fsys.Open(".") + assert.ErrorIs(t, err, fs.ErrPermission) + _, err = fsys.Open("blog") + assert.ErrorIs(t, err, fs.ErrPermission) + _, err = fs.ReadDir(fsys, ".") + assert.ErrorIs(t, err, fs.ErrPermission) + _, err = fs.Stat(fsys, ".") + assert.ErrorIs(t, err, fs.ErrPermission) + _, err = fs.Stat(fsys, "blog") + assert.ErrorIs(t, err, fs.ErrPermission) +} + +func TestPrefixFS_MountFile(t *testing.T) { + // The inner root cannot be described or listed. + fsys := modular.NewPrefixFS("blog", stubFS{file: stubFile{err: fs.ErrPermission}}) + + mount, err := fsys.Open("blog") + require.NoError(t, err) + + _, err = mount.Stat() + assert.ErrorIs(t, err, fs.ErrPermission) + + dir, ok := mount.(fs.ReadDirFile) + require.True(t, ok) + _, err = dir.ReadDir(-1) + assert.ErrorIs(t, err, fs.ErrInvalid) +} diff --git a/modular/publish.go b/modular/publish.go new file mode 100644 index 000000000..3fa837e04 --- /dev/null +++ b/modular/publish.go @@ -0,0 +1,279 @@ +package modular + +import ( + "bytes" + "encoding/json" + "io/fs" + "maps" + "os" + stdpath "path" + "path/filepath" + "runtime/debug" + "slices" + "strings" + + "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" +) + +const ( + // PublishMarker records what modular:publish wrote into a module directory. + PublishMarker = ".modular-publish.json" + // PublishNotice tells readers the directory is generated. + PublishNotice = "DO-NOT-EDIT.md" + + // Publish states of a module, as shown by modular:list. + PublishLocal = "local" // in the project, nothing is copied + PublishNone = "none" // third-party without sources or views + PublishPublished = "published" // copied from the linked version + PublishStale = "stale" // differs from the linked version + PublishMissing = "missing" // not copied yet + PublishConflict = "conflict" // the directory holds something else +) + +// Dependency is the Go module a third-party module is linked from. +type Dependency struct { + Path string `json:"module"` + Version string `json:"version"` + Sum string `json:"sum"` +} + +type publishMarker struct { + Dependency + Files []string `json:"files"` +} + +// DependencyOf returns the Go module of a third-party module. ok is false for a +// module of the application and for one that sets its own Path(). +func DependencyOf(module modular.Module, info *debug.BuildInfo) (Dependency, bool) { + if _, located := module.(modular.Located); located || info == nil { + return Dependency{}, false + } + + pkg := packagePath(module) + if pkg == "" || pkg == "main" || within(pkg, info.Main.Path) { + return Dependency{}, false + } + + // Without a matching dependency (workspace builds) the package stands in for it. + dependency := Dependency{Path: pkg} + found := "" + for _, dep := range info.Deps { + if dep == nil || !within(pkg, dep.Path) || len(dep.Path) <= len(found) { + continue + } + + found = dep.Path + linked := dep + if dep.Replace != nil { + linked = dep.Replace + } + dependency = Dependency{Path: dep.Path, Version: linked.Version, Sum: linked.Sum} + } + + return dependency, true +} + +// PublishState reports whether dir holds the copy of module published from dependency. +func PublishState(module modular.Module, dependency Dependency, dir string) string { + files, err := publishFiles(module, dependency) + if err != nil || len(files) == 0 { + return PublishNone + } + + marker, owned := readPublishMarker(dir) + if !owned || marker.Path != dependency.Path { + if emptyDir(dir) { + return PublishMissing + } + + return PublishConflict + } + if marker.Dependency != dependency || !slices.Equal(marker.Files, slices.Sorted(maps.Keys(files))) { + return PublishStale + } + // The version alone misses a replaced module and edited copies: compare the files too. + for name, content := range files { + current, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(name))) + if err != nil || !bytes.Equal(current, content) { + return PublishStale + } + } + + return PublishPublished +} + +// Publish copies the module's sources, and its views unless the sources hold +// them, into dir. It reports whether anything was written or removed. +func Publish(module modular.Module, dependency Dependency, dir string) (bool, error) { + files, err := publishFiles(module, dependency) + if err != nil || len(files) == 0 { + return false, err + } + + marker, owned := readPublishMarker(dir) + if owned && marker.Path != dependency.Path { + owned = false + } + if !owned && !emptyDir(dir) { + return false, errors.ModularPublishTargetNotOwned.Args(module.Name(), dir, dependency.Path) + } + + names := slices.Sorted(maps.Keys(files)) + + changed := marker.Dependency != dependency || !slices.Equal(marker.Files, names) + for _, name := range names { + target := filepath.Join(dir, filepath.FromSlash(name)) + if current, err := os.ReadFile(target); err == nil && bytes.Equal(current, files[name]) { + continue + } + + changed = true + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return false, err + } + if err := os.WriteFile(target, files[name], 0o644); err != nil { + return false, err + } + } + + // Remove what an earlier publish wrote and the module no longer ships. + for _, name := range marker.Files { + if _, ok := files[name]; ok { + continue + } + if clean := stdpath.Clean(name); !fs.ValidPath(clean) || clean == "." { + continue + } + + target := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.Remove(target); err != nil && !os.IsNotExist(err) { + return false, err + } + removeEmptyDirs(filepath.Dir(target), dir) + } + + if !changed { + return false, nil + } + + content, err := json.MarshalIndent(publishMarker{Dependency: dependency, Files: names}, "", " ") + if err != nil { + return false, err + } + + return true, os.WriteFile(filepath.Join(dir, PublishMarker), append(content, '\n'), 0o644) +} + +// publishFiles returns the files to write by slash path, empty when the module ships none. +func publishFiles(module modular.Module, dependency Dependency) (map[string][]byte, error) { + files := make(map[string][]byte) + + if p, ok := module.(modular.ProvidesSources); ok { + if err := readFiles(p.Sources(), "", files); err != nil { + return nil, err + } + } + + if p, ok := module.(modular.ProvidesViews); ok && !hasPrefix(files, viewsDirectory+"/") { + if err := readFiles(p.Views(), viewsDirectory, files); err != nil { + return nil, err + } + } + + if len(files) == 0 { + return files, nil + } + delete(files, PublishMarker) + files[PublishNotice] = []byte(publishNotice(module.Name(), dependency)) + + return files, nil +} + +// viewsDirectory is where a module's views live inside its directory. +const viewsDirectory = "resources/views" + +func readFiles(fsys fs.FS, prefix string, files map[string][]byte) error { + if fsys == nil { + return nil + } + + return fs.WalkDir(fsys, ".", func(name string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || isGoSource(name) { + return err + } + + content, err := fs.ReadFile(fsys, name) + if err != nil { + return err + } + files[stdpath.Join(prefix, name)] = content + + return nil + }) +} + +// isGoSource reports files that would be compiled into the application, or cut the +// directory out of it: they are never published. +func isGoSource(name string) bool { + base := stdpath.Base(name) + + return strings.HasSuffix(base, ".go") || base == "go.mod" || base == "go.sum" +} + +func readPublishMarker(dir string) (publishMarker, bool) { + var marker publishMarker + content, err := os.ReadFile(filepath.Join(dir, PublishMarker)) + if err != nil || json.Unmarshal(content, &marker) != nil { + return publishMarker{}, false + } + + return marker, true +} + +func publishNotice(name string, dependency Dependency) string { + source := dependency.Path + if dependency.Version != "" { + source += " " + dependency.Version + } + + return "# Do not edit\n\n" + + "This directory is generated by `artisan modular:publish` from the module `" + name + "` (" + source + ").\n" + + "Changes are overwritten by the next publish; run `go generate` after upgrading the module.\n" + + "The files are here for tools that read sources (Vite, Tailwind); views are rendered from the copy embedded in the module.\n" +} + +// within reports whether pkg is the Go module root or a package inside it. +func within(pkg, root string) bool { + return root != "" && (pkg == root || strings.HasPrefix(pkg, root+"/")) +} + +func hasPrefix(files map[string][]byte, prefix string) bool { + for name := range files { + if strings.HasPrefix(name, prefix) { + return true + } + } + + return false +} + +// emptyDir reports whether dir is missing or has no entries. +func emptyDir(dir string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return os.IsNotExist(err) + } + + return len(entries) == 0 +} + +// removeEmptyDirs removes dir and its empty parents, stopping at root. +func removeEmptyDirs(dir, root string) { + for dir != root && strings.HasPrefix(dir, root) { + if os.Remove(dir) != nil { + return + } + dir = filepath.Dir(dir) + } +} diff --git a/modular/publish_test.go b/modular/publish_test.go new file mode 100644 index 000000000..adb684bb2 --- /dev/null +++ b/modular/publish_test.go @@ -0,0 +1,343 @@ +package modular + +import ( + "encoding/json" + "io/fs" + "os" + "path/filepath" + "runtime/debug" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/goravel/framework/errors" +) + +// Test modules live in this package, so an app with another main module sees them as third-party. +const publishPackage = "github.com/goravel/framework/modular" + +type sourcesModule struct { + plainModule + sources fs.FS + views fs.FS +} + +func (m *sourcesModule) Sources() fs.FS { return m.sources } +func (m *sourcesModule) Views() fs.FS { return m.views } + +type locatedSourcesModule struct{ sourcesModule } + +func (m *locatedSourcesModule) Path() string { return "vendor/billing" } + +func buildInfo(deps ...*debug.Module) *debug.BuildInfo { + return &debug.BuildInfo{Main: debug.Module{Path: "example.com/app"}, Deps: deps} +} + +func TestDependencyOf(t *testing.T) { + module := &sourcesModule{plainModule: plainModule{"billing"}} + framework := &debug.Module{Path: "github.com/goravel/framework", Version: "v1.3.0", Sum: "h1:abc"} + + t.Run("third-party", func(t *testing.T) { + dependency, ok := DependencyOf(module, buildInfo(&debug.Module{Path: "github.com/goravel"}, framework, &debug.Module{Path: "github.com/goravel/framework/mod"})) + + assert.True(t, ok) + assert.Equal(t, Dependency{Path: "github.com/goravel/framework", Version: "v1.3.0", Sum: "h1:abc"}, dependency) + }) + + t.Run("replaced", func(t *testing.T) { + replaced := &debug.Module{Path: "github.com/goravel/framework", Version: "v1.3.0", Replace: &debug.Module{Path: "../framework", Version: "(devel)"}} + dependency, ok := DependencyOf(module, buildInfo(replaced)) + + assert.True(t, ok) + assert.Equal(t, Dependency{Path: "github.com/goravel/framework", Version: "(devel)"}, dependency) + }) + + t.Run("no matching dependency", func(t *testing.T) { + dependency, ok := DependencyOf(module, buildInfo()) + + assert.True(t, ok) + assert.Equal(t, Dependency{Path: publishPackage}, dependency) + }) + + t.Run("application module", func(t *testing.T) { + _, ok := DependencyOf(module, &debug.BuildInfo{Main: debug.Module{Path: "github.com/goravel/framework"}}) + assert.False(t, ok) + }) + + t.Run("located module", func(t *testing.T) { + _, ok := DependencyOf(&locatedSourcesModule{}, buildInfo(framework)) + assert.False(t, ok) + }) + + t.Run("no build info", func(t *testing.T) { + _, ok := DependencyOf(module, nil) + assert.False(t, ok) + }) +} + +func TestPublish(t *testing.T) { + dependency := Dependency{Path: "github.com/acme/billing", Version: "v1.2.0", Sum: "h1:abc"} + read := func(t *testing.T, dir, name string) string { + content, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(name))) + require.NoError(t, err) + + return string(content) + } + + t.Run("writes sources, views, notice and marker", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "modules", "billing") + module := &sourcesModule{ + plainModule: plainModule{"billing"}, + sources: fstest.MapFS{ + "resources/js/app.js": {Data: []byte("app")}, + "resources/js/legacy.js": {Data: []byte("legacy")}, + }, + views: fstest.MapFS{"layouts/base.tmpl": {Data: []byte("base")}}, + } + assert.Equal(t, PublishMissing, PublishState(module, dependency, dir)) + + changed, err := Publish(module, dependency, dir) + require.NoError(t, err) + assert.True(t, changed) + assert.Equal(t, "app", read(t, dir, "resources/js/app.js")) + assert.Equal(t, "base", read(t, dir, "resources/views/layouts/base.tmpl")) + assert.Contains(t, read(t, dir, PublishNotice), "`billing` (github.com/acme/billing v1.2.0)") + assert.JSONEq(t, `{ + "module": "github.com/acme/billing", "version": "v1.2.0", "sum": "h1:abc", + "files": ["DO-NOT-EDIT.md", "resources/js/app.js", "resources/js/legacy.js", "resources/views/layouts/base.tmpl"] + }`, read(t, dir, PublishMarker)) + assert.Equal(t, PublishPublished, PublishState(module, dependency, dir)) + + // Unchanged the second time. + changed, err = Publish(module, dependency, dir) + require.NoError(t, err) + assert.False(t, changed) + + // A new version drops a file, changes one, and keeps files it did not write. + require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("mine"), 0o644)) + upgraded := Dependency{Path: dependency.Path, Version: "v1.3.0", Sum: "h1:def"} + module.sources = fstest.MapFS{"resources/js/app.js": {Data: []byte("app v2")}} + module.views = nil + assert.Equal(t, PublishStale, PublishState(module, upgraded, dir)) + + changed, err = Publish(module, upgraded, dir) + require.NoError(t, err) + assert.True(t, changed) + assert.Equal(t, "app v2", read(t, dir, "resources/js/app.js")) + assert.Equal(t, "mine", read(t, dir, "notes.txt")) + assert.NoFileExists(t, filepath.Join(dir, "resources/js/legacy.js")) + assert.NoDirExists(t, filepath.Join(dir, "resources/views")) + assert.Contains(t, read(t, dir, PublishMarker), `"version": "v1.3.0"`) + assert.Equal(t, PublishPublished, PublishState(module, upgraded, dir)) + }) + + t.Run("views inside the sources are not published twice", func(t *testing.T) { + dir := t.TempDir() + module := &sourcesModule{ + plainModule: plainModule{"billing"}, + sources: fstest.MapFS{"resources/views/index.tmpl": {Data: []byte("source")}}, + views: fstest.MapFS{"index.tmpl": {Data: []byte("embedded")}, "extra.tmpl": {Data: []byte("extra")}}, + } + + changed, err := Publish(module, dependency, dir) + require.NoError(t, err) + assert.True(t, changed) + assert.Equal(t, "source", read(t, dir, "resources/views/index.tmpl")) + assert.NoFileExists(t, filepath.Join(dir, "resources/views/extra.tmpl")) + }) + + t.Run("nothing to publish", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "billing") + module := &sourcesModule{plainModule: plainModule{"billing"}} + + changed, err := Publish(module, dependency, dir) + require.NoError(t, err) + assert.False(t, changed) + assert.NoDirExists(t, dir) + assert.Equal(t, PublishNone, PublishState(module, dependency, dir)) + }) + + t.Run("refuses a directory it does not own", func(t *testing.T) { + module := &sourcesModule{plainModule: plainModule{"billing"}, sources: fstest.MapFS{"app.js": {Data: []byte("app")}}} + + // Someone's code without a marker. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "module.go"), []byte("package billing"), 0o644)) + _, err := Publish(module, dependency, dir) + assert.Equal(t, errors.ModularPublishTargetNotOwned.Args("billing", dir, dependency.Path), err) + assert.NoFileExists(t, filepath.Join(dir, "app.js")) + + // Published from another Go module. + dir = t.TempDir() + _, err = Publish(module, Dependency{Path: "github.com/other/billing"}, dir) + require.NoError(t, err) + _, err = Publish(module, dependency, dir) + assert.Equal(t, errors.ModularPublishTargetNotOwned.Args("billing", dir, dependency.Path), err) + assert.Equal(t, PublishConflict, PublishState(module, dependency, dir)) + }) + + t.Run("state follows the files, not only the version", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "billing") + module := &sourcesModule{plainModule: plainModule{"billing"}, sources: fstest.MapFS{"app.js": {Data: []byte("app")}}} + replaced := Dependency{Path: dependency.Path, Version: "(devel)"} + + _, err := Publish(module, replaced, dir) + require.NoError(t, err) + assert.Equal(t, PublishPublished, PublishState(module, replaced, dir)) + + // The module changed without a new version. + module.sources = fstest.MapFS{"app.js": {Data: []byte("app v2")}} + assert.Equal(t, PublishStale, PublishState(module, replaced, dir)) + changed, err := Publish(module, replaced, dir) + require.NoError(t, err) + assert.True(t, changed) + assert.Equal(t, PublishPublished, PublishState(module, replaced, dir)) + + // The copy was edited or lost a file. + require.NoError(t, os.WriteFile(filepath.Join(dir, "app.js"), []byte("edited"), 0o644)) + assert.Equal(t, PublishStale, PublishState(module, replaced, dir)) + require.NoError(t, os.Remove(filepath.Join(dir, "app.js"))) + assert.Equal(t, PublishStale, PublishState(module, replaced, dir)) + + // The module ships another file. + module.sources = fstest.MapFS{"app.js": {Data: []byte("app v2")}, "extra.js": {Data: []byte("extra")}} + _, err = Publish(module, replaced, dir) + require.NoError(t, err) + module.sources = fstest.MapFS{"app.js": {Data: []byte("app v2")}} + assert.Equal(t, PublishStale, PublishState(module, replaced, dir)) + }) + + t.Run("conflict when the directory is not a publish of the module", func(t *testing.T) { + module := &sourcesModule{plainModule: plainModule{"billing"}, sources: fstest.MapFS{"app.js": {Data: []byte("app")}}} + + dir := t.TempDir() + assert.Equal(t, PublishMissing, PublishState(module, dependency, dir)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "module.go"), []byte("package billing"), 0o644)) + assert.Equal(t, PublishConflict, PublishState(module, dependency, dir)) + + require.NoError(t, os.WriteFile(filepath.Join(dir, PublishMarker), []byte("{"), 0o644)) + assert.Equal(t, PublishConflict, PublishState(module, dependency, dir)) + }) + + t.Run("go files are never published", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "billing") + module := &sourcesModule{ + plainModule: plainModule{"billing"}, + sources: fstest.MapFS{ + "module.go": {Data: []byte("package billing")}, + "go.mod": {Data: []byte("module x")}, + "go.sum": {Data: []byte("")}, + "http/controller.go": {Data: []byte("package http")}, + "resources/js/app.js": {Data: []byte("app")}, + "resources/go.md": {Data: []byte("kept")}, + }, + } + + _, err := Publish(module, dependency, dir) + require.NoError(t, err) + for _, name := range []string{"module.go", "go.mod", "go.sum", "http/controller.go", "http"} { + assert.NoFileExists(t, filepath.Join(dir, name)) + assert.NoDirExists(t, filepath.Join(dir, name)) + } + assert.Equal(t, "app", read(t, dir, "resources/js/app.js")) + assert.Equal(t, "kept", read(t, dir, "resources/go.md")) + assert.NotContains(t, read(t, dir, PublishMarker), ".go\"") + + // Only Go files: nothing to publish. + onlyGo := &sourcesModule{plainModule: plainModule{"billing"}, sources: fstest.MapFS{"module.go": {Data: []byte("package billing")}}} + assert.Equal(t, PublishNone, PublishState(onlyGo, dependency, t.TempDir())) + }) +} + +// unreadableFS lists inner but fails to open the file named fail. +type unreadableFS struct { + inner fs.FS + fail string +} + +func (r unreadableFS) Open(name string) (fs.File, error) { + if name == r.fail { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrPermission} + } + + return r.inner.Open(name) +} + +func TestPublish_Failures(t *testing.T) { + dependency := Dependency{Path: "github.com/acme/billing", Version: "v1.2.0"} + app := fstest.MapFS{"resources/js/app.js": {Data: []byte("app")}} + // An owned directory: published before, so Publish may write into it. + owned := func(t *testing.T, files ...string) string { + dir := t.TempDir() + content, err := json.Marshal(publishMarker{Dependency: dependency, Files: files}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, PublishMarker), content, 0o644)) + + return dir + } + + t.Run("unreadable sources", func(t *testing.T) { + module := &sourcesModule{ + plainModule: plainModule{"billing"}, + sources: unreadableFS{inner: app, fail: "resources/js/app.js"}, + } + + _, err := Publish(module, dependency, t.TempDir()) + assert.ErrorIs(t, err, fs.ErrPermission) + assert.Equal(t, PublishNone, PublishState(module, dependency, t.TempDir())) + }) + + t.Run("unreadable views", func(t *testing.T) { + module := &sourcesModule{ + plainModule: plainModule{"billing"}, + views: unreadableFS{inner: fstest.MapFS{"index.tmpl": {Data: []byte("index")}}, fail: "index.tmpl"}, + } + + _, err := Publish(module, dependency, t.TempDir()) + assert.ErrorIs(t, err, fs.ErrPermission) + }) + + t.Run("a file is in the way of a directory", func(t *testing.T) { + dir := owned(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "resources"), []byte("file"), 0o644)) + + _, err := Publish(&sourcesModule{plainModule: plainModule{"billing"}, sources: app}, dependency, dir) + assert.Error(t, err) + }) + + t.Run("a directory is in the way of a file", func(t *testing.T) { + dir := owned(t) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "resources", "js", "app.js"), 0o755)) + + _, err := Publish(&sourcesModule{plainModule: plainModule{"billing"}, sources: app}, dependency, dir) + assert.Error(t, err) + }) + + t.Run("a dropped file became a directory with content", func(t *testing.T) { + dir := owned(t, "legacy") + require.NoError(t, os.MkdirAll(filepath.Join(dir, "legacy"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "legacy", "mine.txt"), []byte("mine"), 0o644)) + + _, err := Publish(&sourcesModule{plainModule: plainModule{"billing"}, sources: app}, dependency, dir) + assert.Error(t, err) + assert.FileExists(t, filepath.Join(dir, "legacy", "mine.txt")) + }) + + t.Run("marker paths outside the directory are ignored", func(t *testing.T) { + parent := t.TempDir() + dir := filepath.Join(parent, "billing") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(parent, "outside.txt"), []byte("keep"), 0o644)) + content, err := json.Marshal(publishMarker{Dependency: dependency, Files: []string{"../outside.txt", "."}}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, PublishMarker), content, 0o644)) + + changed, err := Publish(&sourcesModule{plainModule: plainModule{"billing"}, sources: app}, dependency, dir) + require.NoError(t, err) + assert.True(t, changed) + assert.FileExists(t, filepath.Join(parent, "outside.txt")) + assert.DirExists(t, dir) + }) +} diff --git a/modular/registry.go b/modular/registry.go new file mode 100644 index 000000000..8692cd6ae --- /dev/null +++ b/modular/registry.go @@ -0,0 +1,205 @@ +// Package modular implements the module registry and module filesystems. It +// imports no framework implementation, so foundation can import it. +package modular + +import ( + "regexp" + "runtime/debug" + "slices" + "sync" + + "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + "github.com/goravel/framework/support/env" +) + +var _ modular.Registry = (*Registry)(nil) + +// namePattern is a kebab-case module name, e.g. "blog" or "blog-posts". +var namePattern = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`) + +// ValidName reports whether name is a kebab-case module name. +func ValidName(name string) bool { + return namePattern.MatchString(name) +} + +var ( + defaultMu sync.RWMutex + defaultRegistry modular.Registry +) + +// SetDefault records the application's registry for code that cannot reach the +// container (support/console). Build() calls it. +func SetDefault(registry modular.Registry) { + defaultMu.Lock() + defer defaultMu.Unlock() + + defaultRegistry = registry +} + +// Default returns the application's registry, nil before Build(). +func Default() modular.Registry { + defaultMu.RLock() + defer defaultMu.RUnlock() + + return defaultRegistry +} + +// Local reports whether module's sources are in the application, not in a dependency. +func Local(module modular.Module) bool { + info, _ := debug.ReadBuildInfo() + _, thirdParty := DependencyOf(module, info) + + return !thirdParty +} + +// Registry keeps modules in registration order. +type Registry struct { + modules []modular.Module + byName map[string]modular.Module + paths map[string]string + + // mainModule is the app's Go module path; directory holds third-party modules. + mainModule string + directory string +} + +// NewRegistry places third-party modules under directory (modular.path), "" for +// DefaultDirectory. +func NewRegistry(directory string) *Registry { + if directory == "" { + directory = DefaultDirectory + } + + return &Registry{ + byName: make(map[string]modular.Module), + paths: make(map[string]string), + mainModule: env.MainPath(), + directory: directory, + } +} + +// Register stops at the first error; earlier modules stay registered. +func (r *Registry) Register(modules ...modular.Module) error { + for _, module := range modules { + if module == nil { + continue + } + + name := module.Name() + if !ValidName(name) { + return errors.ModularModuleNameNotKebab.Args(module, name) + } + if _, ok := r.byName[name]; ok { + return errors.ModularModuleDuplicate.Args(name) + } + + dir, err := ResolvePath(module, r.mainModule, r.directory) + if err != nil { + return err + } + + r.byName[name] = module + r.paths[name] = dir + r.modules = append(r.modules, module) + } + + return nil +} + +// All sorts non-negative orders, then negative ones, ascending; ties keep registration order. +func (r *Registry) All() []modular.Module { + sorted := slices.Clone(r.modules) + slices.SortStableFunc(sorted, func(a, b modular.Module) int { + return compareOrder(order(a), order(b)) + }) + + return sorted +} + +func (r *Registry) Find(name string) (modular.Module, bool) { + module, ok := r.byName[name] + + return module, ok +} + +func (r *Registry) Has(name string) bool { + _, ok := r.byName[name] + + return ok +} + +func (r *Registry) Path(name string) (string, bool) { + dir, ok := r.paths[name] + + return dir, ok +} + +func (r *Registry) Capabilities(module modular.Module) map[string]bool { + return map[string]bool{ + "services": implements[foundation.ProvidesServices](module), + "config": implements[modular.ProvidesConfig](module), + "middleware": implements[modular.ProvidesGlobalMiddleware](module), + "routes": implements[modular.ProvidesRoutes](module), + "api": implements[modular.ProvidesApiRoutes](module), + "migrations": implements[modular.ProvidesMigrations](module), + "seeders": implements[modular.ProvidesSeeders](module), + "commands": implements[modular.ProvidesCommands](module), + "jobs": implements[modular.ProvidesJobs](module), + "events": implements[modular.ProvidesEvents](module), + "schedule": implements[modular.ProvidesSchedule](module), + "views": implements[modular.ProvidesViews](module), + "translations": implements[modular.ProvidesTranslations](module), + "vite-entries": implements[modular.ProvidesViteEntries](module), + "vite-aliases": implements[modular.ProvidesViteAlias](module), + "static": implements[modular.ProvidesStatic](module), + "sources": implements[modular.ProvidesSources](module), + } +} + +// Providing returns the modules implementing T, as T. +func Providing[T any](modules []modular.Module) []T { + var out []T + for _, module := range modules { + if p, ok := module.(T); ok { + out = append(out, p) + } + } + + return out +} + +func implements[T any](module modular.Module) bool { + _, ok := module.(T) + + return ok +} + +func order(module modular.Module) int { + if o, ok := module.(modular.Ordered); ok { + return o.Order() + } + + return 0 +} + +// compareOrder sorts non-negative orders before negative ones, each ascending. +func compareOrder(a, b int) int { + if (a < 0) != (b < 0) { + if a < 0 { + return 1 + } + + return -1 + } + + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} diff --git a/modular/registry_test.go b/modular/registry_test.go new file mode 100644 index 000000000..dd26e93b8 --- /dev/null +++ b/modular/registry_test.go @@ -0,0 +1,168 @@ +package modular + +import ( + "runtime/debug" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/contracts/route" + "github.com/goravel/framework/errors" +) + +type plainModule struct{ name string } + +func (m *plainModule) Name() string { return m.name } + +type locatedModule struct { + plainModule + path string +} + +func (m *locatedModule) Path() string { return m.path } + +type orderedModule struct { + plainModule + order int +} + +func (m *orderedModule) Order() int { return m.order } + +type routedModule struct{ plainModule } + +func (m *routedModule) Routes(route.Router) {} +func (m *routedModule) ServiceProviders() []foundation.ServiceProvider { return nil } + +func names(modules []modular.Module) []string { + var out []string + for _, m := range modules { + out = append(out, m.Name()) + } + + return out +} + +func TestRegistry_All(t *testing.T) { + registry := NewRegistry("") + require.NoError(t, registry.Register( + &orderedModule{plainModule{"last"}, -1}, + &orderedModule{plainModule{"ten"}, 10}, + &plainModule{"zero-a"}, + &orderedModule{plainModule{"before-last"}, -5}, + &plainModule{"zero-b"}, + )) + + assert.Equal(t, []string{"zero-a", "zero-b", "ten", "before-last", "last"}, names(registry.All())) +} + +func TestRegistry_Register(t *testing.T) { + registry := NewRegistry("") + require.NoError(t, registry.Register(&plainModule{"blog"}, nil)) + + err := registry.Register(&plainModule{"shop"}, &plainModule{"blog"}, &plainModule{"forum"}) + assert.ErrorIs(t, err, errors.ModularModuleDuplicate) + assert.EqualError(t, err, `[modular] module "blog" is registered more than once`) + + assert.Equal(t, []string{"blog", "shop"}, names(registry.All())) +} + +func TestRegistry_RegisterInvalidName(t *testing.T) { + for _, name := range []string{"", "Blog", "blog.admin", "blog_posts", "-blog", "blog--posts", "1blog"} { + err := NewRegistry("").Register(&plainModule{name}) + assert.ErrorIs(t, err, errors.ModularModuleNameNotKebab, name) + } + assert.EqualError(t, NewRegistry("").Register(&plainModule{"Blog"}), + `[modular] module *modular.plainModule is named "Blog", a module name is kebab case like blog or blog-posts`) + assert.NoError(t, NewRegistry("").Register(&plainModule{"blog-posts2"})) +} + +func TestRegistry_Path(t *testing.T) { + registry := NewRegistry("") + require.NoError(t, registry.Register(&plainModule{"blog"}, &locatedModule{plainModule{"shop"}, "app/shop"})) + + // Under go test the main module is the framework. + dir, ok := registry.Path("blog") + assert.True(t, ok) + assert.Equal(t, "modular", dir) + + dir, ok = registry.Path("shop") + assert.True(t, ok) + assert.Equal(t, "app/shop", dir) + + _, ok = registry.Path("forum") + assert.False(t, ok) + + err := registry.Register(&locatedModule{plainModule{"forum"}, "../forum"}, &plainModule{"news"}) + assert.ErrorIs(t, err, errors.ModularModulePathInvalid) + assert.False(t, registry.Has("forum")) + assert.False(t, registry.Has("news")) +} + +func TestRegistry_FindHas(t *testing.T) { + registry := NewRegistry("") + blog := &plainModule{"blog"} + require.NoError(t, registry.Register(blog)) + + found, ok := registry.Find("blog") + assert.True(t, ok) + assert.Same(t, blog, found) + assert.True(t, registry.Has("blog")) + + _, ok = registry.Find("shop") + assert.False(t, ok) + assert.False(t, registry.Has("shop")) +} + +func TestRegistry_Capabilities(t *testing.T) { + registry := NewRegistry("") + + capabilities := registry.Capabilities(&routedModule{plainModule{"blog"}}) + assert.Len(t, capabilities, 17) + for label, ok := range capabilities { + assert.Equal(t, label == "routes" || label == "services", ok, label) + } + + for label, ok := range registry.Capabilities(&plainModule{"plain"}) { + assert.False(t, ok, label) + } +} + +func TestProviding(t *testing.T) { + routed := &routedModule{plainModule{"blog"}} + modules := []modular.Module{&plainModule{"plain"}, routed} + + assert.Equal(t, []modular.ProvidesRoutes{routed}, Providing[modular.ProvidesRoutes](modules)) + assert.Empty(t, Providing[modular.ProvidesViews](modules)) +} + +func TestDefault(t *testing.T) { + t.Cleanup(func() { SetDefault(nil) }) + assert.Nil(t, Default()) + + registry := NewRegistry("") + SetDefault(registry) + assert.Same(t, registry, Default()) +} + +func TestLocal(t *testing.T) { + // A module that sets its own path is in the application. + assert.True(t, Local(&locatedModule{plainModule{"blog"}, "modules/blog"})) + + info, ok := debug.ReadBuildInfo() + require.True(t, ok) + _, thirdParty := DependencyOf(&plainModule{"blog"}, info) + assert.Equal(t, !thirdParty, Local(&plainModule{"blog"})) +} + +func TestCompareOrder(t *testing.T) { + assert.Equal(t, -1, compareOrder(1, 2)) + assert.Equal(t, 1, compareOrder(2, 1)) + assert.Equal(t, 0, compareOrder(2, 2)) + assert.Equal(t, -1, compareOrder(-2, -1)) + assert.Equal(t, 1, compareOrder(-1, -2)) + assert.Equal(t, -1, compareOrder(5, -1), "negative orders go last") + assert.Equal(t, 1, compareOrder(-1, 0)) +} diff --git a/modular/static.go b/modular/static.go new file mode 100644 index 000000000..50a35d69c --- /dev/null +++ b/modular/static.go @@ -0,0 +1,82 @@ +package modular + +import ( + "io/fs" + "os" + stdpath "path" + "strings" + + "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + "github.com/goravel/framework/support/path" +) + +// StaticFS returns p.Dir inside p.FS, or on disk under root (the module directory) +// when p.FS is nil. Directories are not listable, so drivers answer 404. +func StaticFS(name, root string, p modular.Path) (fs.FS, error) { + dir, ok := path.CleanRelative(p.Dir) + if !ok { + return nil, errors.ModularStaticDirInvalid.Args(name, p.Dir) + } + + var fsys fs.FS + if p.FS != nil { + sub, err := fs.Sub(p.FS, dir) + if err != nil { + return nil, errors.ModularStaticDirUnreadable.Args(name, p.Dir, err) + } + fsys = sub + } else { + base, ok := path.CleanRelative(root) + if !ok { + return nil, errors.ModularStaticDirInvalid.Args(name, root) + } + fsys = os.DirFS(path.Base(base, dir)) + } + + info, err := fs.Stat(fsys, ".") + if err != nil { + return nil, errors.ModularStaticDirUnreadable.Args(name, p.Dir, err) + } + if !info.IsDir() { + return nil, errors.ModularStaticDirUnreadable.Args(name, p.Dir, fs.ErrInvalid) + } + + return noListingFS{fsys}, nil +} + +// CleanPrefix normalises a URL prefix to "/a/b"; "/" is refused. +func CleanPrefix(prefix string) (string, bool) { + cleaned := stdpath.Clean("/" + strings.TrimSpace(prefix)) + if cleaned == "/" { + return "", false + } + + return cleaned, true +} + +// noListingFS refuses to open directories. +type noListingFS struct { + fs.FS +} + +func (r noListingFS) Open(name string) (fs.File, error) { + file, err := r.FS.Open(name) + if err != nil { + return nil, err + } + + info, err := file.Stat() + if err != nil { + _ = file.Close() + + return nil, err + } + if info.IsDir() { + _ = file.Close() + + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} + } + + return file, nil +} diff --git a/modular/static_test.go b/modular/static_test.go new file mode 100644 index 000000000..be086589f --- /dev/null +++ b/modular/static_test.go @@ -0,0 +1,116 @@ +package modular + +import ( + "io/fs" + "os" + "path/filepath" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + contractsmodular "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + "github.com/goravel/framework/support" +) + +func TestStaticFS_Embedded(t *testing.T) { + embedded := fstest.MapFS{ + "public/robots.txt": {Data: []byte("User-agent: *")}, + "public/img/logo.png": {Data: []byte("png")}, + "resources/secret.txt": {Data: []byte("secret")}, + } + + fsys, err := StaticFS("blog", "modules/blog", contractsmodular.Path{FS: embedded, Dir: "public"}) + require.NoError(t, err) + + content, err := fs.ReadFile(fsys, "robots.txt") + require.NoError(t, err) + assert.Equal(t, "User-agent: *", string(content)) + + _, err = fs.ReadFile(fsys, "img/logo.png") + assert.NoError(t, err) + + for _, name := range []string{".", "img"} { + _, err := fsys.Open(name) + assert.ErrorIs(t, err, fs.ErrNotExist, "directory %s must not be listable", name) + } + + _, err = fsys.Open("../resources/secret.txt") + assert.Error(t, err) +} + +func TestStaticFS_Disk(t *testing.T) { + relativePath := support.RelativePath + t.Cleanup(func() { support.RelativePath = relativePath }) + support.RelativePath = t.TempDir() + + dir := filepath.Join(support.RelativePath, "modules", "blog", "public") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "robots.txt"), []byte("disk"), 0o644)) + + fsys, err := StaticFS("blog", "modules/blog", contractsmodular.Path{Dir: "public"}) + require.NoError(t, err) + + content, err := fs.ReadFile(fsys, "robots.txt") + require.NoError(t, err) + assert.Equal(t, "disk", string(content)) + + _, err = StaticFS("blog", "modules/blog", contractsmodular.Path{Dir: "missing"}) + assert.ErrorIs(t, err, errors.ModularStaticDirUnreadable) +} + +func TestStaticFS_Invalid(t *testing.T) { + embedded := fstest.MapFS{"public/robots.txt": {Data: []byte("x")}} + + for _, dir := range []string{"..", "../shop", "public/../../x", "/etc"} { + _, err := StaticFS("blog", "modules/blog", contractsmodular.Path{FS: embedded, Dir: dir}) + assert.ErrorIs(t, err, errors.ModularStaticDirInvalid, dir) + } + + _, err := StaticFS("blog", "../blog", contractsmodular.Path{Dir: "public"}) + assert.ErrorIs(t, err, errors.ModularStaticDirInvalid, "the module directory must stay inside the project") + + _, err = StaticFS("blog", "modules/blog", contractsmodular.Path{FS: embedded, Dir: "public/robots.txt"}) + assert.ErrorIs(t, err, errors.ModularStaticDirUnreadable, "a file is not a directory") + + _, err = StaticFS("blog", "modules/blog", contractsmodular.Path{FS: embedded, Dir: "missing"}) + assert.ErrorIs(t, err, errors.ModularStaticDirUnreadable) +} + +func TestCleanPrefix(t *testing.T) { + tests := []struct { + prefix, expected string + ok bool + }{ + {"blog", "/blog", true}, + {"/blog/", "/blog", true}, + {"/blog/static", "/blog/static", true}, + {"/blog/../shop", "/shop", true}, + {"/", "", false}, + {"", "", false}, + {"..", "", false}, + } + for _, tt := range tests { + cleaned, ok := CleanPrefix(tt.prefix) + assert.Equal(t, tt.ok, ok, tt.prefix) + assert.Equal(t, tt.expected, cleaned, tt.prefix) + } +} + +// statFailFS opens files that cannot be described. +type statFailFS struct{} + +func (statFailFS) Open(string) (fs.File, error) { return statFailFile{}, nil } + +type statFailFile struct{} + +func (statFailFile) Stat() (fs.FileInfo, error) { return nil, fs.ErrPermission } +func (statFailFile) Read([]byte) (int, error) { return 0, fs.ErrPermission } +func (statFailFile) Close() error { return nil } + +func TestNoListingFS_StatFails(t *testing.T) { + _, err := noListingFS{statFailFS{}}.Open("robots.txt") + assert.ErrorIs(t, err, fs.ErrPermission) +} diff --git a/notification/console/make_command.go b/notification/console/make_command.go index 0f0c30717..21bb4d87e 100644 --- a/notification/console/make_command.go +++ b/notification/console/make_command.go @@ -37,6 +37,7 @@ func (r *NotificationMakeCommand) Extend() command.Extend { Name: "database", Usage: "Generate a notification template for the database channel instead of mail", }, + supportconsole.ModuleFlag(), }, } } diff --git a/notification/console/make_command_test.go b/notification/console/make_command_test.go index fdb5d9ebd..e453a5e46 100644 --- a/notification/console/make_command_test.go +++ b/notification/console/make_command_test.go @@ -22,6 +22,7 @@ func TestNotificationMakeCommand(t *testing.T) { assert.False(t, file.Exists("app/notifications/invoice_paid.go")) mockContext.EXPECT().Arguments().Return([]string{"InvoicePaid"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().OptionBool("database").Return(false).Once() mockContext.EXPECT().Success("Notification created successfully").Once() @@ -30,11 +31,13 @@ func TestNotificationMakeCommand(t *testing.T) { assert.True(t, file.Contain("app/notifications/invoice_paid.go", "func (r *InvoicePaid) ToMail")) mockContext.EXPECT().Arguments().Return([]string{"InvoicePaid"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the notification already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, notificationMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"Billing/InvoicePaid"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().OptionBool("database").Return(false).Once() mockContext.EXPECT().Success("Notification created successfully").Once() @@ -50,6 +53,7 @@ func TestNotificationMakeCommand_DatabaseFlag_GeneratesDatabaseTemplate(t *testi notificationMakeCommand := &NotificationMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"InvoicePaid"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().OptionBool("database").Return(true).Once() mockContext.EXPECT().Success("Notification created successfully").Once() @@ -69,7 +73,7 @@ func TestNotificationMakeCommand_Metadata(t *testing.T) { extend := cmd.Extend() assert.Equal(t, "make", extend.Category) - assert.Len(t, extend.Flags, 2) + assert.Len(t, extend.Flags, 3) assert.Equal(t, "force", extend.Flags[0].(*command.BoolFlag).Name) assert.Equal(t, "database", extend.Flags[1].(*command.BoolFlag).Name) } @@ -79,6 +83,7 @@ func TestNotificationMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"InvoicePaid", "InvoiceRefunded"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().OptionBool("database").Return(false).Times(2) mockContext.EXPECT().Success("Notification created successfully").Times(2) diff --git a/packages/match/helper.go b/packages/match/helper.go index 74141510b..a93792d48 100644 --- a/packages/match/helper.go +++ b/packages/match/helper.go @@ -231,6 +231,39 @@ func Migrations() []match.GoNode { } } +// Modules matches the Modules() function returning a []modular.Module literal. +func Modules() []match.GoNode { + return []match.GoNode{ + Func(Ident("Modules")), + TypeOf(&dst.ReturnStmt{}), + CompositeLit( + ArrayType( + SelectorExpr( + Ident("modular"), + Ident("Module"), + ), + AnyNode(), + ), + ), + } +} + +// ModuleMethod matches the slice literal returned by the method named method, e.g. +// func (m *Module) Migrations() []schema.Migration { return []schema.Migration{} }. +func ModuleMethod(method string) []match.GoNode { + return []match.GoNode{ + GoNode{ + match: func(n dst.Node) bool { + fn, ok := n.(*dst.FuncDecl) + + return ok && fn.Recv != nil && fn.Name.Name == method + }, + }, + TypeOf(&dst.ReturnStmt{}), + CompositeLit(ArrayType(AnyNode(), AnyNode())), + } +} + // Providers matches the Providers() function that returns a slice of service providers. // It looks for a function returning []foundation.ServiceProvider composite literals. // This is the recommended way to register service providers. diff --git a/packages/match/helper_test.go b/packages/match/helper_test.go index 1c7fc17d5..7d57b8a8a 100644 --- a/packages/match/helper_test.go +++ b/packages/match/helper_test.go @@ -8,6 +8,8 @@ import ( "github.com/dave/dst" "github.com/dave/dst/decorator" "github.com/dave/dst/dstutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/goravel/framework/contracts/packages/match" @@ -15,15 +17,15 @@ import ( type MatchHelperTestSuite struct { suite.Suite - configChained *dst.File - configVariable *dst.File - providerVariable *dst.File - providerInConfig *dst.File - console *dst.File - database *dst.File - jobs *dst.File - serviceProvider *dst.File - validation *dst.File + configChained *dst.File + configVariable *dst.File + providerVariable *dst.File + providerInConfig *dst.File + console *dst.File + database *dst.File + jobs *dst.File + serviceProvider *dst.File + validation *dst.File } func (s *MatchHelperTestSuite) SetupTest() { @@ -96,6 +98,12 @@ func (kernel Kernel) Seeders() []seeder.Seeder { return []seeder.Seeder{ &seeders.DatabaseSeeder{}, } +} + +func Modules() []modular.Module { + return []modular.Module{ + &blog.Module{}, + } }`) s.Require().NoError(err) s.validation, err = decorator.Parse(`package providers @@ -309,6 +317,20 @@ func (s *MatchHelperTestSuite) TestHelper() { s.Len(node.(*dst.CompositeLit).Elts, 2) }, }, + { + name: "match modules", + file: s.database, + matchers: Modules(), + assert: func(node dst.Node) { + s.True(CompositeLit(EqualNode(&dst.ArrayType{ + Elt: &dst.SelectorExpr{ + X: &dst.Ident{Name: "modular"}, + Sel: &dst.Ident{Name: "Module"}, + }, + })).MatchNode(node)) + s.Len(node.(*dst.CompositeLit).Elts, 1) + }, + }, { name: "match seeders", file: s.database, @@ -429,3 +451,46 @@ func (s *MatchHelperTestSuite) match(source *dst.File, matchers []match.GoNode) return } + +func TestModuleMethod(t *testing.T) { + source := `package blog + +func Migrations() []string { + return []string{"function"} +} + +func (m *Module) Other() []string { + return []string{"other"} +} + +func (m *Module) Migrations() []schema.Migration { + return []schema.Migration{&migrations.Posts{}} +} +` + file, err := decorator.Parse(source) + require.NoError(t, err) + + find := func(matchers []match.GoNode) (matched dst.Node) { + var current int + dstutil.Apply(file, func(cursor *dstutil.Cursor) bool { + if current < len(matchers) && matchers[current].MatchCursor(cursor) { + current++ + if current == len(matchers) { + matched = cursor.Node() + } + } + + return matched == nil + }, nil) + + return + } + + lit, ok := find(ModuleMethod("Migrations")).(*dst.CompositeLit) + require.True(t, ok) + require.Len(t, lit.Elts, 1) + _, isUnary := lit.Elts[0].(*dst.UnaryExpr) + assert.True(t, isUnary) + + assert.Nil(t, find(ModuleMethod("Seeders"))) +} diff --git a/packages/modify/stubs.go b/packages/modify/stubs.go index 24a9fa96a..f73e23412 100644 --- a/packages/modify/stubs.go +++ b/packages/modify/stubs.go @@ -46,6 +46,19 @@ func Migrations() []schema.Migration { return replaceDummyPackage(content) } +func modules() string { + content := `package DummyPackage + +import "github.com/goravel/framework/contracts/modular" + +func Modules() []modular.Module { + return []modular.Module{} +} +` + + return replaceDummyPackage(content) +} + func providers() string { content := `package DummyPackage diff --git a/packages/modify/utils.go b/packages/modify/utils.go index b63bd7364..abce14729 100644 --- a/packages/modify/utils.go +++ b/packages/modify/utils.go @@ -1,15 +1,23 @@ package modify import ( + "cmp" + "go/ast" + "go/parser" + "go/token" "path/filepath" "slices" + "strconv" "strings" "github.com/dave/dst" "github.com/dave/dst/decorator" + "github.com/dave/dst/dstutil" + "github.com/goravel/framework/contracts/packages/modify" "github.com/goravel/framework/errors" "github.com/goravel/framework/packages/match" + "github.com/goravel/framework/support/env" supportfile "github.com/goravel/framework/support/file" "github.com/goravel/framework/support/path" ) @@ -147,6 +155,68 @@ func AddMigration(pkg, migration string) error { return handler.AddItem(pkg, migration) } +// AddModule adds module to bootstrap/modules.go, wiring WithModules(Modules) when missing. +// An existing modules.go is the list, even before WithModules is wired by hand. +func AddModule(pkg, module string) error { + handler := newWithSliceHandler(moduleConfig()) + if handler.fileExists { + return handler.addItemToFile(pkg, module) + } + + return handler.AddItem(pkg, module) +} + +// moduleMethods are the module capabilities AddToModule can edit: method -> slice type. +var moduleMethods = map[string]struct{ typeName, typeImportPath string }{ + "Commands": {"console.Command", "github.com/goravel/framework/contracts/console"}, + "Jobs": {"queue.Job", "github.com/goravel/framework/contracts/queue"}, + "Migrations": {"schema.Migration", "github.com/goravel/framework/contracts/database/schema"}, + "Seeders": {"seeder.Seeder", "github.com/goravel/framework/contracts/database/seeder"}, + "ServiceProviders": {"foundation.ServiceProvider", "github.com/goravel/framework/contracts/foundation"}, +} + +// AddToModule adds item to the slice returned by method of the module struct in dir +// (relative to the project root), appending the method when the struct lacks it. +func AddToModule(dir, method, pkg, item string) error { + spec, ok := moduleMethods[method] + if !ok { + return errors.PackageModuleMethodUnsupported.Args(method) + } + + target, receiver, exists, err := findModuleMethod(dir, method) + if err != nil { + return err + } + + if !exists { + stub := "\nfunc " + receiver + " " + method + "() []" + spec.typeName + " {\n\treturn []" + spec.typeName + "{}\n}\n" + if err := supportfile.PutContent(target, stub, supportfile.WithAppend()); err != nil { + return err + } + if err := addImportsToFile(target, spec.typeImportPath); err != nil { + return err + } + } + + if err := addImportsToFile(target, pkg); err != nil { + return err + } + + // The application's Go module is what pkg has before the module directory. + main := env.MainPath() + if _, importPath, found := strings.Cut(pkg, " "); found { + pkg = importPath + } + if before, _, found := strings.Cut(pkg, "/"+strings.Trim(filepath.ToSlash(dir), "/")+"/"); found { + main = before + } + + return GoFile(target). + Find(match.ModuleMethod(method)).Modify(Register(item)). + Find(match.Imports()).Modify(groupImports(main)). + Apply() +} + // AddProvider adds service provider to the foundation.Setup() chain in the Boot function. func AddProvider(pkg, provider string) error { config := withSliceConfig{ @@ -263,6 +333,30 @@ func ExprIndex(x []dst.Expr, y dst.Expr) int { }) } +// InitModules creates bootstrap/modules.go and wires WithModules(Modules), with no module listed. +func InitModules() error { + return newWithSliceHandler(moduleConfig()).EnsureWithMethod() +} + +// ModulesStub is the content of an empty bootstrap/modules.go. +func ModulesStub() string { + return modules() +} + +func moduleConfig() withSliceConfig { + return withSliceConfig{ + fileName: "modules.go", + withMethodName: "WithModules", + helperFuncName: "Modules", + typePackage: "modular", + typeName: "Module", + typeImportPath: "github.com/goravel/framework/contracts/modular", + fileExistsError: errors.PackageModulesFileExists, + stubTemplate: modules, + matcherFunc: match.Modules, + } +} + // IsUsingImport checks if an imported package is actually used in the file. // It inspects the AST for selector expressions that reference the package. // @@ -420,6 +514,11 @@ func RemoveMigration(pkg, migration string) error { return handler.RemoveItem(pkg, migration) } +// RemoveModule removes module from the Modules() list. +func RemoveModule(pkg, module string) error { + return newWithSliceHandler(moduleConfig()).RemoveItem(pkg, module) +} + // RemoveProvider removes a service provider from the foundation.Setup() chain in the Boot function. func RemoveProvider(pkg, provider string) error { config := withSliceConfig{ @@ -487,6 +586,125 @@ func WrapNewline[T dst.Node](node T) T { return node } +// groupImports sorts the matched import block into standard library, third-party +// and application (inside the Go module main) groups, separated by an empty line. +func groupImports(main string) modify.Action { + return func(cursor *dstutil.Cursor) { + groupImportSpecs(cursor.Node().(*dst.GenDecl), main) + } +} + +func groupImportSpecs(node *dst.GenDecl, main string) { + importPath := func(spec dst.Spec) string { + value, _ := strconv.Unquote(spec.(*dst.ImportSpec).Path.Value) + + return value + } + group := func(spec dst.Spec) int { + value := importPath(spec) + switch { + case value == main || strings.HasPrefix(value, main+"/"): + return 2 + case isThirdParty(value): + return 1 + default: + return 0 + } + } + + slices.SortStableFunc(node.Specs, func(a, b dst.Spec) int { + return cmp.Or(cmp.Compare(group(a), group(b)), cmp.Compare(importPath(a), importPath(b))) + }) + for i, spec := range node.Specs { + decs := spec.Decorations() + decs.Before, decs.After = dst.NewLine, dst.NewLine + if i > 0 && group(spec) != group(node.Specs[i-1]) { + decs.Before = dst.EmptyLine + } + } +} + +// findModuleMethod returns the file declaring method on the module struct of dir. When +// no file does, it returns the file declaring Name() and the receiver to declare it with. +func findModuleMethod(dir, method string) (target, receiver string, exists bool, err error) { + root := path.Base(filepath.FromSlash(dir)) + files, err := filepath.Glob(filepath.Join(root, "*.go")) + if err != nil { + return "", "", false, err + } + + // module.go first: it wins when several types have a Name() method. + slices.SortStableFunc(files, func(a, b string) int { + return cmp.Compare(moduleFileRank(a), moduleFileRank(b)) + }) + + type moduleFunc struct{ file, name, typeName, receiver string } + var methods []moduleFunc + for _, file := range files { + if strings.HasSuffix(file, "_test.go") { + continue + } + + parsed, err := parser.ParseFile(token.NewFileSet(), file, nil, parser.SkipObjectResolution) + if err != nil { + return "", "", false, err + } + for _, decl := range parsed.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv == nil || len(fn.Recv.List) != 1 { + continue + } + + typeName, text := receiverOf(fn.Recv.List[0]) + if typeName != "" { + methods = append(methods, moduleFunc{file: file, name: fn.Name.Name, typeName: typeName, receiver: text}) + } + } + } + + nameIndex := slices.IndexFunc(methods, func(m moduleFunc) bool { return m.name == "Name" }) + if nameIndex < 0 { + return "", "", false, errors.PackageModuleStructNotFound.Args(dir) + } + + module := methods[nameIndex] + for _, m := range methods { + if m.name == method && m.typeName == module.typeName { + return m.file, m.receiver, true, nil + } + } + + return module.file, module.receiver, false, nil +} + +func moduleFileRank(file string) int { + if filepath.Base(file) == "module.go" { + return 0 + } + + return 1 +} + +// receiverOf returns the type name of a method receiver and its source, e.g. "(m *Module)". +func receiverOf(field *ast.Field) (typeName, text string) { + expr, pointer := field.Type, "" + if star, ok := expr.(*ast.StarExpr); ok { + expr, pointer = star.X, "*" + } + + ident, ok := expr.(*ast.Ident) + if !ok { + return "", "" + } + + name := "" + if len(field.Names) == 1 { + name = field.Names[0].Name + " " + } + + return ident.Name, "(" + name + pointer + ident.Name + ")" +} + // addImportsToFile adds the specified import to the file, creating the import block if needed. func addImportsToFile(filePath, pkg string) error { importMatchers := match.Imports() diff --git a/packages/modify/utils_test.go b/packages/modify/utils_test.go index d627db9a2..87395af24 100644 --- a/packages/modify/utils_test.go +++ b/packages/modify/utils_test.go @@ -3,6 +3,7 @@ package modify import ( "bytes" "go/token" + "os" "path/filepath" "strconv" "strings" @@ -4960,3 +4961,321 @@ var _ = map[string]any{ `, buf.String()) } + +func TestModules(t *testing.T) { + bootstrapDir := support.Config.Paths.Bootstrap + appFile := filepath.Join(bootstrapDir, "app.go") + modulesFile := filepath.Join(bootstrapDir, "modules.go") + defer func() { + assert.NoError(t, supportfile.Remove(bootstrapDir)) + }() + + assert.NoError(t, supportfile.PutContent(appFile, `package bootstrap + +import ( + contractsfoundation "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/foundation" + "goravel/config" +) + +func Boot() contractsfoundation.Application { + return foundation.Setup().WithConfig(config.Boot).Start() +} +`)) + + assert.NoError(t, InitModules()) + content, err := supportfile.GetContent(modulesFile) + assert.NoError(t, err) + assert.Equal(t, ModulesStub(), content) + assert.True(t, supportfile.Contain(appFile, "WithModules(Modules)")) + + assert.NoError(t, AddModule("goravel/modules/blog", "&blog.Module{}")) + assert.NoError(t, AddModule("blogposts goravel/modules/blog-posts", "&blogposts.Module{}")) + // Adding a listed module again is a no-op. + assert.NoError(t, AddModule("goravel/modules/blog", "&blog.Module{}")) + + content, err = supportfile.GetContent(modulesFile) + assert.NoError(t, err) + assert.Equal(t, `package bootstrap + +import ( + "github.com/goravel/framework/contracts/modular" + + "goravel/modules/blog" + blogposts "goravel/modules/blog-posts" +) + +func Modules() []modular.Module { + return []modular.Module{ + &blog.Module{}, + &blogposts.Module{}, + } +} +`, content) + + assert.NoError(t, RemoveModule("goravel/modules/blog", "&blog.Module{}")) + content, err = supportfile.GetContent(modulesFile) + assert.NoError(t, err) + assert.Equal(t, `package bootstrap + +import ( + "github.com/goravel/framework/contracts/modular" + + blogposts "goravel/modules/blog-posts" +) + +func Modules() []modular.Module { + return []modular.Module{ + &blogposts.Module{}, + } +} +`, content) +} + +func TestAddToModule(t *testing.T) { + dir := "modules/blog" + moduleFile := filepath.Join(dir, "module.go") + defer func() { + assert.NoError(t, supportfile.Remove("modules")) + }() + + assert.ErrorIs(t, AddToModule(dir, "Migrations", "goravel/modules/blog/database/migrations", "&migrations.Posts{}"), errors.PackageModuleStructNotFound) + + assert.NoError(t, supportfile.PutContent(moduleFile, `package blog + +import ( + "embed" + "io/fs" + + "goravel/modules/blog/routes" +) + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} +`)) + + assert.ErrorIs(t, AddToModule(dir, "Rules", "goravel/modules/blog/rules", "&rules.Slug{}"), errors.PackageModuleMethodUnsupported) + + // The method is appended, then extended; a duplicate item is a no-op. + assert.NoError(t, AddToModule(dir, "Migrations", "goravel/modules/blog/database/migrations", "&migrations.Posts{}")) + assert.NoError(t, AddToModule(dir, "Migrations", "goravel/modules/blog/database/migrations", "&migrations.Comments{}")) + assert.NoError(t, AddToModule(dir, "Migrations", "goravel/modules/blog/database/migrations", "&migrations.Posts{}")) + assert.NoError(t, AddToModule(dir, "ServiceProviders", "goravel/modules/blog/providers", "&providers.BlogServiceProvider{}")) + + content, err := supportfile.GetContent(moduleFile) + assert.NoError(t, err) + assert.Equal(t, `package blog + +import ( + "embed" + "io/fs" + + "github.com/goravel/framework/contracts/database/schema" + "github.com/goravel/framework/contracts/foundation" + + "goravel/modules/blog/database/migrations" + "goravel/modules/blog/providers" + "goravel/modules/blog/routes" +) + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} + +func (m *Module) Migrations() []schema.Migration { + return []schema.Migration{ + &migrations.Posts{}, + &migrations.Comments{}, + } +} + +func (m *Module) ServiceProviders() []foundation.ServiceProvider { + return []foundation.ServiceProvider{ + &providers.BlogServiceProvider{}, + } +} +`, content) + + // A method declared in another file of the package is edited there. + commandsFile := filepath.Join(dir, "commands.go") + assert.NoError(t, supportfile.PutContent(commandsFile, `package blog + +import ( + "github.com/goravel/framework/contracts/console" +) + +func (m *Module) Commands() []console.Command { + return []console.Command{} +} +`)) + assert.NoError(t, AddToModule(dir, "Commands", "goravel/modules/blog/console/commands", "&commands.Publish{}")) + + content, err = supportfile.GetContent(commandsFile) + assert.NoError(t, err) + assert.Equal(t, `package blog + +import ( + "github.com/goravel/framework/contracts/console" + + "goravel/modules/blog/console/commands" +) + +func (m *Module) Commands() []console.Command { + return []console.Command{ + &commands.Publish{}, + } +} +`, content) + assert.False(t, supportfile.Contain(moduleFile, "Commands()")) + + // A method that does not return a slice literal cannot be edited. + assert.NoError(t, supportfile.PutContent(filepath.Join(dir, "jobs.go"), `package blog + +import "github.com/goravel/framework/contracts/queue" + +func (m *Module) Jobs() []queue.Job { + return nil +} +`)) + assert.Error(t, AddToModule(dir, "Jobs", "goravel/modules/blog/jobs", "&jobs.Notify{}")) +} + +func TestModules_Failures(t *testing.T) { + bootstrapDir := support.Config.Paths.Bootstrap + appFile := filepath.Join(bootstrapDir, "app.go") + modulesFile := filepath.Join(bootstrapDir, "modules.go") + defer func() { + assert.NoError(t, supportfile.Remove(bootstrapDir)) + }() + + // Without bootstrap/app.go there is nothing to wire. + assert.Error(t, InitModules()) + assert.False(t, supportfile.Exists(modulesFile)) + + // A file that is not Go cannot be edited, and no modules.go is left behind. + assert.NoError(t, supportfile.PutContent(appFile, "package bootstrap\n\nfunc Boot() { return foundation.Setup().Start( }\n")) + assert.Error(t, InitModules()) + assert.False(t, supportfile.Exists(modulesFile)) + + // AddModule creates modules.go and wires it when neither exists. + assert.NoError(t, supportfile.PutContent(appFile, `package bootstrap + +import ( + contractsfoundation "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/foundation" +) + +func Boot() contractsfoundation.Application { + return foundation.Setup().Start() +} +`)) + assert.NoError(t, AddModule("goravel/modules/blog", "&blog.Module{}")) + assert.True(t, supportfile.Contain(appFile, "WithModules(Modules)")) + assert.True(t, supportfile.Contain(modulesFile, "&blog.Module{},")) +} + +func TestAddToModule_Failures(t *testing.T) { + defer func() { + assert.NoError(t, supportfile.Remove("modules")) + }() + add := func(dir string) error { + return AddToModule(dir, "Jobs", "goravel/"+dir+"/jobs", "&jobs.Notify{}") + } + + // The directory name is not a valid glob pattern. + assert.ErrorIs(t, add("modules/[blog"), filepath.ErrBadPattern) + + // Test files are skipped, so their Name() does not make a module. + assert.NoError(t, supportfile.PutContent("modules/tested/module_test.go", `package tested + +type Module struct{} + +func (m *Module) Name() string { + return "tested" +} +`)) + assert.ErrorIs(t, add("modules/tested"), errors.PackageModuleStructNotFound) + + // Only methods of a plain named type are considered. + assert.NoError(t, supportfile.PutContent("modules/generic/module.go", `package generic + +type Module[T any] struct{} + +func (m *Module[T]) Name() string { + return "generic" +} +`)) + assert.ErrorIs(t, add("modules/generic"), errors.PackageModuleStructNotFound) + + // A file that does not parse stops the search. + assert.NoError(t, supportfile.PutContent("modules/broken/module.go", "package broken\n\nfunc (m *Module) Name( {\n")) + assert.Error(t, add("modules/broken")) +} + +func TestAddToModule_AliasedImport(t *testing.T) { + dir := "modules/blog" + moduleFile := filepath.Join(dir, "module.go") + defer func() { + assert.NoError(t, supportfile.Remove("modules")) + }() + + assert.NoError(t, supportfile.PutContent(moduleFile, `package blog + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} +`)) + assert.NoError(t, AddToModule(dir, "Jobs", "blogjobs goravel/modules/blog/jobs", "&blogjobs.Notify{}")) + + content, err := supportfile.GetContent(moduleFile) + assert.NoError(t, err) + assert.Equal(t, `package blog + +import ( + "github.com/goravel/framework/contracts/queue" + + blogjobs "goravel/modules/blog/jobs" +) + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} + +func (m *Module) Jobs() []queue.Job { + return []queue.Job{ + &blogjobs.Notify{}, + } +} +`, content) +} + +func TestAddToModule_ReadOnlyFile(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root writes read-only files") + } + + dir := "modules/blog" + moduleFile := filepath.Join(dir, "module.go") + source := "package blog\n\ntype Module struct{}\n\nfunc (m *Module) Name() string {\n\treturn \"blog\"\n}\n" + defer func() { + assert.NoError(t, os.Chmod(moduleFile, 0o644)) + assert.NoError(t, supportfile.Remove("modules")) + }() + + assert.NoError(t, supportfile.PutContent(moduleFile, source)) + assert.NoError(t, os.Chmod(moduleFile, 0o444)) + + assert.Error(t, AddToModule(dir, "Jobs", "goravel/modules/blog/jobs", "&jobs.Notify{}")) + content, err := supportfile.GetContent(moduleFile) + assert.NoError(t, err) + assert.Equal(t, source, content) +} diff --git a/packages/modify/with_slice_actions.go b/packages/modify/with_slice_actions.go index 464d7023c..02688e1aa 100644 --- a/packages/modify/with_slice_actions.go +++ b/packages/modify/with_slice_actions.go @@ -185,6 +185,43 @@ func (r *withSliceHandler) AddItem(pkg, item string) error { return GoFile(r.appFilePath).Find(match.FoundationSetup()).Modify(r.setupInline(item)).Apply() } +// EnsureWithMethod wires WithMethod(HelperFunc) into foundation.Setup() with an empty helper file. +// It edits app.go first, so a failed match leaves no helper file behind. +func (r *withSliceHandler) EnsureWithMethod() error { + hasFoundationSetup, err := r.containsFoundationSetupInAppFile() + if err != nil { + return err + } + if !hasFoundationSetup { + return errors.PackageSetupNotFound + } + + withMethodExists, err := r.checkWithMethodExists() + if err != nil { + return err + } + if withMethodExists { + return nil + } + if r.fileExists { + return r.config.fileExistsError + } + + if err := GoFile(r.appFilePath).Find(match.FoundationSetup()).Modify(r.setupWithFunction()).Apply(); err != nil { + return err + } + + // A chain that is not a statement of its own (builder := foundation.Setup()...) is left untouched. + if withMethodExists, err = r.checkWithMethodExists(); err != nil { + return err + } + if !withMethodExists { + return errors.PackageSetupNotFound + } + + return r.createFile() +} + // RemoveItem removes an item from the slice in foundation.Setup() chain. // // In always-inline mode: Removes the item from the inline array in app.go and cleans up imports. @@ -891,21 +928,10 @@ func containsFoundationSetup(node dst.Node) bool { return foundSetup } +// createImport adds an empty import block as the first declaration of the file. func createImport(node dst.Node) error { - importDecl := &dst.GenDecl{ - Tok: token.IMPORT, - } - f := node.(*dst.File) - - newDecls := make([]dst.Decl, 0, len(f.Decls)+1) - newDecls = append(newDecls, f.Decls[0], importDecl) // package and import - - if len(f.Decls) > 1 { - newDecls = append(newDecls, f.Decls[1:]...) // others - } - - f.Decls = newDecls + f.Decls = append([]dst.Decl{&dst.GenDecl{Tok: token.IMPORT}}, f.Decls...) return nil } diff --git a/packages/modify/with_slice_actions_test.go b/packages/modify/with_slice_actions_test.go index 9bf9c3b45..d7f709f30 100644 --- a/packages/modify/with_slice_actions_test.go +++ b/packages/modify/with_slice_actions_test.go @@ -344,6 +344,71 @@ func Boot() contractsfoundation.Application { s.Contains(appResult, "&commands.NewCommand{}") } +func (s *WithSliceHandlerTestSuite) TestEnsureWithMethod() { + modulesFile := filepath.Join(s.bootstrapDir, "modules.go") + app := func(body string) string { + return `package bootstrap + +import ( + contractsfoundation "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/foundation" + "goravel/config" +) + +func Boot() contractsfoundation.Application { +` + body + ` +} +` + } + + s.Run("creates the helper file and wires WithMethod", func() { + s.Require().NoError(supportfile.PutContent(s.appFile, app("\treturn foundation.Setup().WithConfig(config.Boot).Start()"))) + + s.NoError(newWithSliceHandler(moduleConfig()).EnsureWithMethod()) + + appResult, err := supportfile.GetContent(s.appFile) + s.NoError(err) + s.Contains(appResult, "WithModules(Modules)") + + modulesResult, err := supportfile.GetContent(modulesFile) + s.NoError(err) + s.Equal(modules(), modulesResult) + + // A second run changes nothing. + s.NoError(newWithSliceHandler(moduleConfig()).EnsureWithMethod()) + again, err := supportfile.GetContent(s.appFile) + s.NoError(err) + s.Equal(appResult, again) + s.NoError(supportfile.Remove(s.bootstrapDir)) + }) + + s.Run("helper file exists without WithMethod", func() { + s.Require().NoError(supportfile.PutContent(s.appFile, app("\treturn foundation.Setup().WithConfig(config.Boot).Start()"))) + s.Require().NoError(supportfile.PutContent(modulesFile, modules())) + + s.Equal(errors.PackageModulesFileExists, newWithSliceHandler(moduleConfig()).EnsureWithMethod()) + s.NoError(supportfile.Remove(s.bootstrapDir)) + }) + + s.Run("no foundation.Setup()", func() { + s.Require().NoError(supportfile.PutContent(s.appFile, app("\treturn nil"))) + + s.Equal(errors.PackageSetupNotFound, newWithSliceHandler(moduleConfig()).EnsureWithMethod()) + s.False(supportfile.Exists(modulesFile)) + s.NoError(supportfile.Remove(s.bootstrapDir)) + }) + + s.Run("assigned chain is left untouched and leaves no helper file", func() { + s.Require().NoError(supportfile.PutContent(s.appFile, app("\tbuilder := foundation.Setup().WithConfig(config.Boot)\n\n\treturn builder.Start()"))) + + err := newWithSliceHandler(moduleConfig()).EnsureWithMethod() + + s.Equal(errors.PackageSetupNotFound, err) + s.False(supportfile.Exists(modulesFile)) + s.False(supportfile.Contain(s.appFile, "WithModules")) + }) +} + func (s *WithSliceHandlerTestSuite) TestCheckWithMethodExists() { config := withSliceConfig{ fileName: "commands.go", diff --git a/queue/console/job_make_command.go b/queue/console/job_make_command.go index 9e0dd92c5..ba0562c7a 100644 --- a/queue/console/job_make_command.go +++ b/queue/console/job_make_command.go @@ -40,6 +40,7 @@ func (r *JobMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the job even if it already exists", }, + supportconsole.ModuleFlag(), }, } } @@ -63,7 +64,9 @@ func (r *JobMakeCommand) makeOne(ctx console.Context, name string) error { ctx.Success("Job created successfully") - if env.IsBootstrapSetup() { + if dir := make.GetModuleDir(); dir != "" { + err = modify.AddToModule(dir, "Jobs", make.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", make.GetPackageName(), make.GetStructName())) + } else if env.IsBootstrapSetup() { err = modify.AddJob(make.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", make.GetPackageName(), make.GetStructName())) } else { err = r.registerInKernel(make) diff --git a/queue/console/job_make_command_test.go b/queue/console/job_make_command_test.go index 8576cc2b5..61c083264 100644 --- a/queue/console/job_make_command_test.go +++ b/queue/console/job_make_command_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/mock" mocksconsole "github.com/goravel/framework/mocks/console" + "github.com/goravel/framework/modular" "github.com/goravel/framework/support" "github.com/goravel/framework/support/file" "github.com/goravel/framework/support/str" @@ -90,6 +91,7 @@ func TestJobMakeCommand(t *testing.T) { assert.NoError(t, jobMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"GoravelJob"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Job created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -99,11 +101,13 @@ func TestJobMakeCommand(t *testing.T) { assert.True(t, file.Exists("app/jobs/goravel_job.go")) mockContext.On("Arguments").Return([]string{"GoravelJob"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.On("OptionBool", "force").Return(false).Once() mockContext.EXPECT().Error("the job already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, jobMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"Goravel/Job"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Job created successfully").Once() mockContext.EXPECT().Success("Job registered successfully").Once() @@ -174,6 +178,7 @@ func TestJobMakeCommand_WithBootstrapSetup(t *testing.T) { jobMakeCommand := &JobMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{tt.jobName}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Job created successfully").Once() @@ -252,6 +257,7 @@ func TestJobMakeCommand_WithNonBootstrapSetup(t *testing.T) { jobMakeCommand := &JobMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{tt.jobName}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Job created successfully").Once() @@ -311,6 +317,7 @@ func TestJobMakeCommand_RegistrationError(t *testing.T) { jobMakeCommand := &JobMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"FailJob"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Job created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -336,6 +343,7 @@ func TestJobMakeCommand_RegistrationError(t *testing.T) { jobMakeCommand := &JobMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"ErrorJob"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Job created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -357,6 +365,7 @@ func TestJobMakeCommand_MultipleNames(t *testing.T) { assert.NoError(t, file.PutContent("app/providers/queue_service_provider.go", queueServiceProvider)) mockContext.EXPECT().Arguments().Return([]string{"SendEmail", "ProcessImage"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Job created successfully").Times(2) mockContext.EXPECT().Success("Job registered successfully").Times(2) @@ -367,3 +376,52 @@ func TestJobMakeCommand_MultipleNames(t *testing.T) { assert.True(t, file.Contain("app/providers/queue_service_provider.go", "&jobs.SendEmail{}")) assert.True(t, file.Contain("app/providers/queue_service_provider.go", "&jobs.ProcessImage{}")) } + +// blogModule sets its own directory, so it is a module of the application. +type blogModule struct{} + +func (r *blogModule) Name() string { return "blog" } +func (r *blogModule) Path() string { return "modules/blog" } + +// useBlogModule registers blogModule with a module.go on disk for the test. +func useBlogModule(t *testing.T) { + registry := modular.NewRegistry("") + assert.NoError(t, registry.Register(&blogModule{})) + modular.SetDefault(registry) + assert.NoError(t, file.PutContent("modules/blog/module.go", `package blog + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} +`)) + + t.Cleanup(func() { + modular.SetDefault(nil) + assert.NoError(t, file.Remove("modules")) + }) +} + +func TestJobMakeCommand_Module(t *testing.T) { + useBlogModule(t) + + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Arguments().Return([]string{"NotifyAuthor"}).Once() + mockContext.EXPECT().Option("module").Return("blog").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Success("Job created successfully").Once() + mockContext.EXPECT().Success("Job registered successfully").Once() + + assert.NoError(t, (&JobMakeCommand{}).Handle(mockContext)) + assert.True(t, file.Exists("modules/blog/jobs/notify_author.go")) + + content, err := file.GetContent("modules/blog/module.go") + assert.NoError(t, err) + assert.Contains(t, content, `/modules/blog/jobs"`) + assert.Contains(t, content, `func (m *Module) Jobs() []queue.Job { + return []queue.Job{ + &jobs.NotifyAuthor{}, + } +}`) +} diff --git a/queue/console/module_flag_test.go b/queue/console/module_flag_test.go new file mode 100644 index 000000000..193d108b0 --- /dev/null +++ b/queue/console/module_flag_test.go @@ -0,0 +1,28 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + &JobMakeCommand{}, + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/support/console/console.go b/support/console/console.go index 053a83048..9fbfa9010 100644 --- a/support/console/console.go +++ b/support/console/console.go @@ -2,6 +2,7 @@ package console import ( "fmt" + "path" "path/filepath" "runtime/debug" "strings" @@ -10,8 +11,11 @@ import ( "github.com/pterm/pterm" "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" "github.com/goravel/framework/errors" + "github.com/goravel/framework/modular" "github.com/goravel/framework/packages/paths" + "github.com/goravel/framework/support" "github.com/goravel/framework/support/color" "github.com/goravel/framework/support/file" "github.com/goravel/framework/support/str" @@ -20,6 +24,66 @@ import ( type Make struct { name string root string + // moduleDir is the directory of the --module target, "" for the application. + moduleDir string +} + +// ModuleFlag is the --module option of the make:* commands. +func ModuleFlag() command.Flag { + return &command.StringFlag{ + Name: "module", + Usage: "The module to create the file in", + } +} + +// ModuleDir returns the directory of the module given with --module, relative to +// the project root, or "" without the option. Third-party modules are refused. +func ModuleDir(ctx console.Context) (string, error) { + _, dir, err := Module(ctx) + + return dir, err +} + +// Module is ModuleDir with the registered name of the module. +func Module(ctx console.Context) (name, dir string, err error) { + name = ctx.Option("module") + if name == "" { + return "", "", nil + } + + registry := modular.Default() + if registry == nil { + return "", "", errors.ModularRegistryNotSet + } + + module, ok := registry.Find(name) + if !ok { + module, ok = registry.Find(str.Of(name).Kebab().String()) + } + if !ok { + return "", "", errors.ModularModuleNotFound.Args(name) + } + if !modular.Local(module) { + return "", "", errors.ModularModuleNotLocal.Args(module.Name()) + } + + dir, _ = registry.Path(module.Name()) + + return module.Name(), dir, nil +} + +// ModuleRoot moves root into the module directory, dropping the application +// directory: "app/http/controllers" becomes "/http/controllers". +func ModuleRoot(dir, root string) string { + root = strings.Trim(filepath.ToSlash(root), "/") + app := strings.Trim(filepath.ToSlash(support.Config.Paths.App), "/") + if root == app { + root = "" + } else if app != "" { + root = strings.TrimPrefix(root, app+"/") + } + + return path.Join(dir, root) } // MakeNames returns every name passed to a make:* command. When no positional @@ -61,9 +125,18 @@ func NewMake(ctx console.Context, ttype, name, root string) (*Make, error) { } } + moduleDir, err := ModuleDir(ctx) + if err != nil { + return nil, err + } + if moduleDir != "" { + root = ModuleRoot(moduleDir, root) + } + m := &Make{ - name: name, - root: root, + name: name, + root: root, + moduleDir: moduleDir, } if !ctx.OptionBool("force") && file.Exists(m.GetFilePath()) { @@ -77,6 +150,11 @@ func (m *Make) GetName() string { return m.name } +// GetModuleDir returns the directory of the --module target, "" for the application. +func (m *Make) GetModuleDir() string { + return m.moduleDir +} + func (m *Make) GetFilePath() string { root := strings.Split(m.root, "/") filePaths := append(root, m.GetFolderPath(), str.Of(m.GetStructName()).Snake().String()+".go") diff --git a/support/console/console_test.go b/support/console/console_test.go index ce453cf06..6a805275b 100644 --- a/support/console/console_test.go +++ b/support/console/console_test.go @@ -1,6 +1,7 @@ package console import ( + "flag" "os" "path/filepath" "strings" @@ -12,9 +13,13 @@ import ( "github.com/stretchr/testify/suite" "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" + contractsmodular "github.com/goravel/framework/contracts/modular" "github.com/goravel/framework/errors" mocksconsole "github.com/goravel/framework/mocks/console" + "github.com/goravel/framework/modular" "github.com/goravel/framework/support/color" + "github.com/goravel/framework/support/env" "github.com/goravel/framework/support/file" ) @@ -140,6 +145,7 @@ func TestNewMake(t *testing.T) { setup: func() { name = "Uppercase" assert.Nil(t, file.PutContent(filepath.Join(root, "uppercase.go"), "")) + mockCtx.EXPECT().Option("module").Return("").Once() mockCtx.EXPECT().OptionBool("force").Return(false).Once() }, expectMake: nil, @@ -150,6 +156,7 @@ func TestNewMake(t *testing.T) { setup: func() { name = "Uppercase" assert.Nil(t, file.PutContent(filepath.Join(root, "uppercase.go"), "")) + mockCtx.EXPECT().Option("module").Return("").Once() mockCtx.EXPECT().OptionBool("force").Return(true).Once() }, expectMake: &Make{name: "Lowercase", root: root}, @@ -159,6 +166,7 @@ func TestNewMake(t *testing.T) { name: "Happy path - name is not empty", setup: func() { name = "Lowercase" + mockCtx.EXPECT().Option("module").Return("").Once() mockCtx.On("OptionBool", "force").Return(false).Once() }, expectMake: &Make{name: "Lowercase", root: root}, @@ -181,6 +189,75 @@ func TestNewMake(t *testing.T) { } } +// testModule sets its own directory, so it is a module of the application. +type testModule struct { + name string + path string +} + +func (r *testModule) Name() string { return r.name } +func (r *testModule) Path() string { return r.path } + +func TestNewMakeInModule(t *testing.T) { + registry := modular.NewRegistry("") + // *flag.FlagSet has Name() and lives outside the application: a third-party module. + assert.NoError(t, registry.Register( + &testModule{name: "blog-posts", path: "modules/blog-posts"}, + flag.NewFlagSet("shop", flag.ContinueOnError), + )) + + tests := []struct { + name string + module string + registry contractsmodular.Registry + root string + expectRoot string + expectError error + }{ + {name: "app directory is dropped", module: "blog-posts", registry: registry, root: "app/http/controllers", expectRoot: "modules/blog-posts/http/controllers"}, + {name: "name is converted to kebab case", module: "BlogPosts", registry: registry, root: "app/models", expectRoot: "modules/blog-posts/models"}, + {name: "other directories keep their shape", module: "blog-posts", registry: registry, root: "database/migrations", expectRoot: "modules/blog-posts/database/migrations"}, + {name: "unknown module", module: "news", registry: registry, root: "app/models", expectError: errors.ModularModuleNotFound.Args("news")}, + {name: "third-party module", module: "shop", registry: registry, root: "app/models", expectError: errors.ModularModuleNotLocal.Args("shop")}, + {name: "no registry", module: "blog-posts", root: "app/models", expectError: errors.ModularRegistryNotSet}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + modular.SetDefault(test.registry) + defer modular.SetDefault(nil) + + mockCtx := mocksconsole.NewContext(t) + mockCtx.EXPECT().Option("module").Return(test.module).Once() + if test.expectError == nil { + mockCtx.EXPECT().OptionBool("force").Return(false).Once() + } + + m, err := NewMake(mockCtx, "model", "admin/User", test.root) + if test.expectError != nil { + assert.Equal(t, test.expectError, err) + assert.Nil(t, m) + + return + } + + pwd, _ := os.Getwd() + assert.NoError(t, err) + assert.Equal(t, "modules/blog-posts", m.GetModuleDir()) + assert.Equal(t, filepath.Join(pwd, filepath.FromSlash(test.expectRoot), "admin", "user.go"), m.GetFilePath()) + assert.Equal(t, env.MainPath()+"/"+test.expectRoot+"/admin", m.GetPackageImportPath()) + assert.Equal(t, "admin", m.GetPackageName()) + }) + } +} + +func TestModuleRoot(t *testing.T) { + assert.Equal(t, "modules/blog/http/controllers", ModuleRoot("modules/blog", "app/http/controllers")) + assert.Equal(t, "modules/blog", ModuleRoot("modules/blog", "app")) + assert.Equal(t, "modules/blog/application/models", ModuleRoot("modules/blog", "application/models")) + assert.Equal(t, "modules/blog/tests", ModuleRoot("modules/blog", filepath.Join("tests"))) +} + func TestHandleEach(t *testing.T) { tests := []struct { name string @@ -303,3 +380,11 @@ func TestTwoColumnDetail(t *testing.T) { }) } } + +func TestModuleFlag(t *testing.T) { + moduleFlag, ok := ModuleFlag().(*command.StringFlag) + + assert.True(t, ok) + assert.Equal(t, "module", moduleFlag.Name) + assert.NotEmpty(t, moduleFlag.Usage) +} diff --git a/support/path/path.go b/support/path/path.go index 90d921d42..0963fc751 100644 --- a/support/path/path.go +++ b/support/path/path.go @@ -1,6 +1,10 @@ package path import ( + stdpath "path" + "path/filepath" + "strings" + "github.com/goravel/framework/packages" packagespaths "github.com/goravel/framework/packages/paths" ) @@ -17,6 +21,24 @@ func Bootstrap(paths ...string) string { return packages.Paths().Bootstrap().Abs(paths...) } +// CleanRelative cleans p as a slash-separated path relative to some root and +// reports whether it stays inside that root. "a/./b" and "a/../b" become "a/b" +// and "b"; an absolute path, ".." and anything starting with "../" are refused. +// "" and "." clean to ".", the root itself. +func CleanRelative(p string) (string, bool) { + p = filepath.ToSlash(p) + if stdpath.IsAbs(p) || filepath.IsAbs(filepath.FromSlash(p)) { + return "", false + } + + cleaned := stdpath.Clean(p) + if cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return "", false + } + + return cleaned, true +} + func Config(paths ...string) string { return packages.Paths().Config().Abs(paths...) } diff --git a/support/path/path_test.go b/support/path/path_test.go new file mode 100644 index 000000000..8544b8d2b --- /dev/null +++ b/support/path/path_test.go @@ -0,0 +1,38 @@ +package path + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCleanRelative(t *testing.T) { + tests := []struct { + name string + path string + expected string + ok bool + }{ + {name: "empty is the root", path: "", expected: ".", ok: true}, + {name: "dot is the root", path: ".", expected: ".", ok: true}, + {name: "plain path", path: "public", expected: "public", ok: true}, + {name: "nested path", path: "resources/assets", expected: "resources/assets", ok: true}, + {name: "dot segment", path: "a/./b", expected: "a/b", ok: true}, + {name: "parent segment inside the root", path: "a/../b", expected: "b", ok: true}, + {name: "leaves a subdirectory, stays inside", path: "resources/js/../assets/x.jpg", expected: "resources/assets/x.jpg", ok: true}, + {name: "trailing slash", path: "public/", expected: "public", ok: true}, + {name: "parent", path: "..", ok: false}, + {name: "starts with parent", path: "../shop", ok: false}, + {name: "escapes after cleaning", path: "a/../../x", ok: false}, + {name: "deep escape", path: "modules/blog/resources/js/../../../../../.env", ok: false}, + {name: "absolute", path: "/etc/passwd", ok: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleaned, ok := CleanRelative(tt.path) + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.expected, cleaned) + }) + } +} diff --git a/tests/go.mod b/tests/go.mod index 550ab21a5..923ea6747 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -103,7 +103,7 @@ require ( go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.58.0 // indirect - golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect + golang.org/x/exp v0.0.0-20261106192419-47ce03d25ef0 // indirect golang.org/x/net v0.61.0 // indirect golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect @@ -111,7 +111,7 @@ require ( golang.org/x/text v0.43.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect - google.golang.org/grpc v1.86.0-dev.0.20261029074820-bc90736a6fad // indirect + google.golang.org/grpc v1.86.0-dev.0.20261104142343-cbf30c413099 // indirect google.golang.org/protobuf v1.36.12 // indirect gorm.io/driver/mysql v1.6.0 // indirect gorm.io/driver/postgres v1.6.0 // indirect diff --git a/tests/go.sum b/tests/go.sum index f9200d666..071e7574f 100644 --- a/tests/go.sum +++ b/tests/go.sum @@ -307,8 +307,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= -golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A= -golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= +golang.org/x/exp v0.0.0-20261106192419-47ce03d25ef0 h1:1GO4fJiyGakz0Liu2Ve1qPAwQYPkKlG1Q8iO2CYjtFA= +golang.org/x/exp v0.0.0-20261106192419-47ce03d25ef0/go.mod h1:J3U+E8RzytcGvwz+uJuAP4zULJ2TkAQBnmPZMexpdXs= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -405,8 +405,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= -golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= +golang.org/x/tools v0.52.1-0.20261106210606-4f7264d11672 h1:VfrTmiWA9+TdDrL9VQr7aeGonnRLaEBwN3/D7jzL25Y= +golang.org/x/tools v0.52.1-0.20261106210606-4f7264d11672/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= @@ -414,8 +414,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1: google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= -google.golang.org/grpc v1.86.0-dev.0.20261029074820-bc90736a6fad h1:UYg8dzXN2/IA54SnxVYwWjd5VQm3PFP3xn4aBz+RDJ8= -google.golang.org/grpc v1.86.0-dev.0.20261029074820-bc90736a6fad/go.mod h1:Zr6uoNHvZlKOMmDEULT/cdZrN8qcSkXRXPkw4ybjoxI= +google.golang.org/grpc v1.86.0-dev.0.20261104142343-cbf30c413099 h1:GMGAZI0ozNRMHMV+etj3fihG3abcnkIkZl12Ut8vjH4= +google.golang.org/grpc v1.86.0-dev.0.20261104142343-cbf30c413099/go.mod h1:Zr6uoNHvZlKOMmDEULT/cdZrN8qcSkXRXPkw4ybjoxI= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/validation/console/filter_make_command.go b/validation/console/filter_make_command.go index b1f4df2f4..95f7fceb0 100644 --- a/validation/console/filter_make_command.go +++ b/validation/console/filter_make_command.go @@ -40,6 +40,7 @@ func (r *FilterMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the filter even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/validation/console/filter_make_command_test.go b/validation/console/filter_make_command_test.go index 5f2fefc4a..1c0a6490f 100644 --- a/validation/console/filter_make_command_test.go +++ b/validation/console/filter_make_command_test.go @@ -99,6 +99,7 @@ func TestFilterMakeCommand(t *testing.T) { assert.NoError(t, filterMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"Uppercase"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Filter created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -108,11 +109,13 @@ func TestFilterMakeCommand(t *testing.T) { assert.True(t, file.Exists("app/filters/uppercase.go")) mockContext.On("Arguments").Return([]string{"Uppercase"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.On("OptionBool", "force").Return(false).Once() mockContext.EXPECT().Error("the filter already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, filterMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"User/Phone"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Filter created successfully").Once() mockContext.EXPECT().Success("Filter registered successfully").Once() @@ -183,6 +186,7 @@ func TestFilterMakeCommand_WithBootstrapSetup(t *testing.T) { filterMakeCommand := &FilterMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{tt.filterName}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Filter created successfully").Once() @@ -261,6 +265,7 @@ func TestFilterMakeCommand_WithNonBootstrapSetup(t *testing.T) { filterMakeCommand := &FilterMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{tt.filterName}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Filter created successfully").Once() @@ -320,6 +325,7 @@ func TestFilterMakeCommand_RegistrationError(t *testing.T) { filterMakeCommand := &FilterMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"FailFilter"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Filter created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -345,6 +351,7 @@ func TestFilterMakeCommand_RegistrationError(t *testing.T) { filterMakeCommand := &FilterMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"ErrorFilter"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Filter created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -366,6 +373,7 @@ func TestFilterMakeCommand_MultipleNames(t *testing.T) { assert.NoError(t, file.PutContent("app/providers/validation_service_provider.go", filterValidationServiceProvider)) mockContext.EXPECT().Arguments().Return([]string{"Uppercase", "Lowercase"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Filter created successfully").Times(2) mockContext.EXPECT().Success("Filter registered successfully").Times(2) @@ -389,6 +397,7 @@ func TestFilterMakeCommand_MultipleNamesPartialFailure(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"Uppercase", "Lowercase"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Filter created successfully").Times(2) mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { diff --git a/validation/console/module_flag_test.go b/validation/console/module_flag_test.go new file mode 100644 index 000000000..262bdf7bd --- /dev/null +++ b/validation/console/module_flag_test.go @@ -0,0 +1,29 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + &FilterMakeCommand{}, + &RuleMakeCommand{}, + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/validation/console/rule_make_command.go b/validation/console/rule_make_command.go index b26e07228..9cc348142 100644 --- a/validation/console/rule_make_command.go +++ b/validation/console/rule_make_command.go @@ -40,6 +40,7 @@ func (r *RuleMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the rule even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/validation/console/rule_make_command_test.go b/validation/console/rule_make_command_test.go index 067dc4d79..bc8eb21ca 100644 --- a/validation/console/rule_make_command_test.go +++ b/validation/console/rule_make_command_test.go @@ -92,6 +92,7 @@ func TestRuleMakeCommand(t *testing.T) { assert.NoError(t, ruleMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"Uppercase"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Rule created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -101,11 +102,13 @@ func TestRuleMakeCommand(t *testing.T) { assert.True(t, file.Exists("app/rules/uppercase.go")) mockContext.On("Arguments").Return([]string{"Uppercase"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.On("OptionBool", "force").Return(false).Once() mockContext.EXPECT().Error("the rule already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, ruleMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"User/Phone"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Rule created successfully").Once() mockContext.EXPECT().Success("Rule registered successfully").Once() @@ -176,6 +179,7 @@ func TestRuleMakeCommand_WithBootstrapSetup(t *testing.T) { ruleMakeCommand := &RuleMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{tt.ruleName}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Rule created successfully").Once() @@ -254,6 +258,7 @@ func TestRuleMakeCommand_WithNonBootstrapSetup(t *testing.T) { ruleMakeCommand := &RuleMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{tt.ruleName}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Rule created successfully").Once() @@ -313,6 +318,7 @@ func TestRuleMakeCommand_RegistrationError(t *testing.T) { ruleMakeCommand := &RuleMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"FailRule"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Rule created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -338,6 +344,7 @@ func TestRuleMakeCommand_RegistrationError(t *testing.T) { ruleMakeCommand := &RuleMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"ErrorRule"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Rule created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -359,6 +366,7 @@ func TestRuleMakeCommand_MultipleNames(t *testing.T) { assert.NoError(t, file.PutContent("app/providers/validation_service_provider.go", ruleValidationServiceProvider)) mockContext.EXPECT().Arguments().Return([]string{"Uppercase", "Lowercase"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Rule created successfully").Times(2) mockContext.EXPECT().Success("Rule registered successfully").Times(2) diff --git a/view/console/view_make_command.go b/view/console/view_make_command.go index ac92dbf1c..e7251b2ae 100644 --- a/view/console/view_make_command.go +++ b/view/console/view_make_command.go @@ -1,12 +1,14 @@ package console import ( + "path/filepath" "strings" "github.com/goravel/framework/contracts/config" "github.com/goravel/framework/contracts/console" "github.com/goravel/framework/contracts/console/command" "github.com/goravel/framework/errors" + "github.com/goravel/framework/support" supportconsole "github.com/goravel/framework/support/console" "github.com/goravel/framework/support/file" "github.com/goravel/framework/support/path" @@ -46,6 +48,7 @@ func (r *ViewMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the view even if it already exists", }, + supportconsole.ModuleFlag(), }, } } @@ -70,7 +73,16 @@ func (r *ViewMakeCommand) makeOne(ctx console.Context, viewName string) error { viewName = viewName + viewExtension } - filePath := path.View(viewName) + // A module view "index.tmpl" is rendered as "/index.tmpl". + filePath, definition := path.View(viewName), viewName + moduleName, moduleDir, err := supportconsole.Module(ctx) + if err != nil { + return err + } + if moduleDir != "" { + filePath = path.Base(filepath.FromSlash(supportconsole.ModuleRoot(moduleDir, support.Config.Paths.Views)), viewName) + definition = moduleName + "/" + viewName + } // Check if file already exists if file.Exists(filePath) && !ctx.OptionBool("force") { @@ -79,7 +91,7 @@ func (r *ViewMakeCommand) makeOne(ctx console.Context, viewName string) error { // Create the view file stub := r.getStub() - content := r.populateStub(stub, viewName) + content := r.populateStub(stub, definition) if err := file.PutContent(filePath, content); err != nil { return err diff --git a/view/console/view_make_command_test.go b/view/console/view_make_command_test.go index 6bd6687b7..1af14519f 100644 --- a/view/console/view_make_command_test.go +++ b/view/console/view_make_command_test.go @@ -7,7 +7,9 @@ import ( "github.com/stretchr/testify/suite" "github.com/goravel/framework/contracts/console/command" + "github.com/goravel/framework/errors" mocksconsole "github.com/goravel/framework/mocks/console" + "github.com/goravel/framework/modular" "github.com/goravel/framework/support/file" ) @@ -34,7 +36,7 @@ func (s *ViewMakeCommandTestSuite) TestExtend() { extend := cmd.Extend() s.Equal("make", extend.Category) - s.Len(extend.Flags, 1) + s.Len(extend.Flags, 2) // Test BoolFlag boolFlag, ok := extend.Flags[0].(*command.BoolFlag) @@ -58,6 +60,7 @@ func (s *ViewMakeCommandTestSuite) TestCreateSuccess() { mockContext := mocksconsole.NewContext(s.T()) mockContext.EXPECT().Arguments().Return([]string{"welcome"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() expectedPath := filepath.Join("resources", "views", "welcome.tmpl") mockContext.EXPECT().Success("View created successfully").Once() @@ -123,9 +126,49 @@ func (s *ViewMakeCommandTestSuite) TestHandleMultipleNames() { mockContext := mocksconsole.NewContext(s.T()) mockContext.EXPECT().Arguments().Return([]string{"welcome", "dashboard"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().Success("View created successfully").Times(2) s.Nil(viewMakeCommand.Handle(mockContext)) s.FileExists(filepath.Join("resources", "views", "welcome.tmpl")) s.FileExists(filepath.Join("resources", "views", "dashboard.tmpl")) } + +// blogModule sets its own directory, so it is a module of the application. +type blogModule struct{} + +func (r *blogModule) Name() string { return "blog" } +func (r *blogModule) Path() string { return "modules/blog" } + +func (s *ViewMakeCommandTestSuite) TestModule() { + registry := modular.NewRegistry("") + s.NoError(registry.Register(&blogModule{})) + modular.SetDefault(registry) + s.T().Cleanup(func() { + modular.SetDefault(nil) + _ = file.Remove("modules") + }) + + mockContext := mocksconsole.NewContext(s.T()) + mockContext.EXPECT().Arguments().Return([]string{"posts/index"}).Once() + mockContext.EXPECT().Option("module").Return("Blog").Once() + mockContext.EXPECT().Success("View created successfully").Once() + + s.Nil((&ViewMakeCommand{}).Handle(mockContext)) + s.True(file.Contain(filepath.Join("modules", "blog", "resources", "views", "posts", "index.tmpl"), `{{ define "blog/posts/index.tmpl" }}`)) + s.NoDirExists("resources") +} + +func (s *ViewMakeCommandTestSuite) TestModuleNotFound() { + modular.SetDefault(modular.NewRegistry("")) + s.T().Cleanup(func() { modular.SetDefault(nil) }) + + mockContext := mocksconsole.NewContext(s.T()) + mockContext.EXPECT().Arguments().Return([]string{"posts/index"}).Once() + mockContext.EXPECT().Option("module").Return("shop").Once() + mockContext.EXPECT().Error(errors.ModularModuleNotFound.Args("shop").Error()).Once() + + s.Nil((&ViewMakeCommand{}).Handle(mockContext)) + s.NoDirExists("resources") + s.NoDirExists("modules") +} diff --git a/vite/aliases_test.go b/vite/aliases_test.go new file mode 100644 index 000000000..005fdd28d --- /dev/null +++ b/vite/aliases_test.go @@ -0,0 +1,159 @@ +package vite + +import ( + "html/template" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/goravel/framework/errors" +) + +const aliasManifest = `{ + "goravel:aliases": {"@": "resources/js", "@blog": "modules/blog/resources/js", "@blog-assets": "modules/blog/resources/assets"}, + "resources/js/app.js": {"file": "assets/app-1.js", "src": "resources/js/app.js", "isEntry": true}, + "modules/blog/resources/js/app.js": {"file": "assets/blog-2.js", "src": "modules/blog/resources/js/app.js", "isEntry": true}, + "modules/blog/resources/assets/x.jpg": {"file": "assets/x-3.jpg", "src": "modules/blog/resources/assets/x.jpg"} +}` + +var testAliases = map[string]string{"@": "resources/js", "@blog": "modules/blog/resources/js"} + +func TestResolveEntry(t *testing.T) { + tests := []struct { + entry string + expected string + err error + }{ + {entry: "a/./b", expected: "a/b"}, + {entry: "/resources/js/app.js", expected: "resources/js/app.js"}, + {entry: "@/app.js", expected: "resources/js/app.js"}, + {entry: "@blog/app.js", expected: "modules/blog/resources/js/app.js"}, + {entry: "/@blog/app.js", expected: "modules/blog/resources/js/app.js"}, + {entry: "@blog", expected: "modules/blog/resources/js"}, + {entry: "@blog/../assets/x.jpg", expected: "modules/blog/resources/assets/x.jpg"}, + {entry: "..", err: errors.ViteEntryOutsideProject.Args("..", "..")}, + {entry: "../x", err: errors.ViteEntryOutsideProject.Args("../x", "../x")}, + {entry: "@blog/../../../../../.env", err: errors.ViteEntryOutsideProject.Args("@blog/../../../../../.env", "../.env")}, + {entry: "@/../../../x", err: errors.ViteEntryOutsideProject.Args("@/../../../x", "../x")}, + // Leaving the alias directory is fine while the path stays in the project. + {entry: "@/../../x", expected: "x"}, + // "@" only matches at a path boundary, so it never captures another alias. + {entry: "@shop/app.js", err: errors.ViteAliasNotFound.Args("@shop/app.js")}, + {entry: "@blogs/app.js", err: errors.ViteAliasNotFound.Args("@blogs/app.js")}, + } + + for _, test := range tests { + t.Run(test.entry, func(t *testing.T) { + resolved, err := resolveEntry(test.entry, testAliases) + if test.err != nil { + assert.EqualError(t, err, test.err.Error()) + return + } + + require.NoError(t, err) + assert.Equal(t, test.expected, resolved) + }) + } + + // Without a table plain paths still resolve. + resolved, err := resolveEntry("resources/js/app.js", nil) + require.NoError(t, err) + assert.Equal(t, "resources/js/app.js", resolved) + + _, err = resolveEntry("@/app.js", nil) + assert.EqualError(t, err, errors.ViteAliasNotFound.Args("@/app.js").Error()) +} + +func TestAliases_Build(t *testing.T) { + public := setupProject(t) + writeFile(t, filepath.Join(public, "build", ".vite", "manifest.json"), aliasManifest) + writeFile(t, filepath.Join(public, "build", "assets", "blog-2.js"), "blog()") + vite := newTestVite(t, "") + + tags, err := vite.Tags("@/app.js", "@blog/app.js", "modules/blog/resources/js/app.js") + require.NoError(t, err) + assert.Equal(t, template.HTML( + ``+ + ``, + ), tags) + + for _, asset := range []string{"@blog-assets/x.jpg", "@blog/../assets/x.jpg", "modules/blog/resources/assets/x.jpg"} { + url, err := vite.Asset(asset) + require.NoError(t, err) + assert.Equal(t, "/build/assets/x-3.jpg", url) + } + + content, err := vite.Content("@blog/app.js") + require.NoError(t, err) + assert.Equal(t, "blog()", content) + + _, err = vite.Tags("@shop/app.js") + assert.EqualError(t, err, errors.ViteAliasNotFound.Args("@shop/app.js").Error()) + _, err = vite.Asset("@blog/../../../../../.env") + assert.EqualError(t, err, errors.ViteEntryOutsideProject.Args("@blog/../../../../../.env", "../.env").Error()) + _, err = vite.Content("../x") + assert.EqualError(t, err, errors.ViteEntryOutsideProject.Args("../x", "../x").Error()) + + // The table is not a chunk. + _, err = vite.Asset("goravel:aliases") + assert.EqualError(t, err, errors.ViteEntryNotFound.Args("goravel:aliases").Error()) +} + +func TestAliases_ManifestWithoutTable(t *testing.T) { + public := setupProject(t) + writeFile(t, filepath.Join(public, "build", ".vite", "manifest.json"), sampleManifest) + vite := newTestVite(t, "") + + url, err := vite.Asset("resources/images/logo.svg") + require.NoError(t, err) + assert.Equal(t, "/build/assets/logo-11223344.svg", url) + + _, err = vite.Tags("@/app.js") + assert.EqualError(t, err, errors.ViteAliasNotFound.Args("@/app.js").Error()) +} + +func TestAliases_ManifestInvalidTable(t *testing.T) { + public := setupProject(t) + writeFile(t, filepath.Join(public, "build", ".vite", "manifest.json"), `{"goravel:aliases": ["@"]}`) + + _, err := newTestVite(t, "").Tags("resources/js/app.js") + require.Error(t, err) + assert.Contains(t, err.Error(), "is invalid") +} + +func TestAliases_Hot(t *testing.T) { + public := setupProject(t) + vite := newTestVite(t, "") + writeFile(t, filepath.Join(public, "hot"), `{"url":"http://localhost:5173/","aliases":{"@":"resources/js","@blog":"modules/blog/resources/js"}}`+"\n") + + tags, err := vite.Tags("@blog/app.js", "@/app.js", "resources/css/app.css") + require.NoError(t, err) + assert.Equal(t, template.HTML( + ``+ + ``+ + ``+ + ``, + ), tags) + + url, err := vite.Asset("@blog/../assets/x.jpg") + require.NoError(t, err) + assert.Equal(t, "http://localhost:5173/modules/blog/resources/assets/x.jpg", url) + + _, err = vite.Asset("@blog/../../../../../.env") + assert.EqualError(t, err, errors.ViteEntryOutsideProject.Args("@blog/../../../../../.env", "../.env").Error()) + _, err = vite.Tags("@shop/app.js") + assert.EqualError(t, err, errors.ViteAliasNotFound.Args("@shop/app.js").Error()) + + assert.Contains(t, string(vite.ReactRefresh()), "'http://localhost:5173/@react-refresh'") + assert.True(t, vite.IsRunningHot()) + + // A bare URL, as an older plugin writes it, has no aliases. + writeFile(t, filepath.Join(public, "hot"), "http://localhost:5173") + url, err = vite.Asset("resources/images/logo.svg") + require.NoError(t, err) + assert.Equal(t, "http://localhost:5173/resources/images/logo.svg", url) + _, err = vite.Asset("@/app.js") + assert.EqualError(t, err, errors.ViteAliasNotFound.Args("@/app.js").Error()) +} diff --git a/vite/console/inputs_command.go b/vite/console/inputs_command.go index 49374d5de..ff3daf361 100644 --- a/vite/console/inputs_command.go +++ b/vite/console/inputs_command.go @@ -2,18 +2,25 @@ package console import ( "encoding/json" + "maps" + stdpath "path" "path/filepath" + "regexp" + "runtime/debug" "slices" "strings" contractsconfig "github.com/goravel/framework/contracts/config" "github.com/goravel/framework/contracts/console" "github.com/goravel/framework/contracts/console/command" + contractsmodular "github.com/goravel/framework/contracts/modular" contractsview "github.com/goravel/framework/contracts/view" contractsvite "github.com/goravel/framework/contracts/vite" "github.com/goravel/framework/errors" + "github.com/goravel/framework/modular" "github.com/goravel/framework/packages/paths" "github.com/goravel/framework/support" + "github.com/goravel/framework/support/path" "github.com/goravel/framework/view" ) @@ -25,13 +32,23 @@ const ( // Inputs is what goravel-vite-plugin reads: the entries passed to vite and the // files passed to viteAsset, each once and sorted, the calls with computed -// arguments, and the configured paths. The entries and assets include vite.inputs -// and vite.assets from config, so a build tool need not reach into config itself. +// arguments, the configured paths and the modules. The entries and assets include +// vite.inputs and vite.assets from config, so a build tool need not reach into config itself. type Inputs struct { - Inputs []string `json:"inputs"` - Assets []string `json:"assets"` - Dynamic []DynamicCall `json:"dynamic"` - Paths Paths `json:"paths"` + Inputs []string `json:"inputs"` + Assets []string `json:"assets"` + Dynamic []DynamicCall `json:"dynamic"` + Paths Paths `json:"paths"` + Modules []ModuleInputs `json:"modules"` +} + +// ModuleInputs is a module as the plugin needs it: its directory, entries and +// alias targets, all relative to the project root. +type ModuleInputs struct { + Name string `json:"name"` + Path string `json:"path"` + Entries []string `json:"entries"` + Aliases map[string]string `json:"aliases"` } // Paths is the vite configuration as the plugin needs it: the public directory @@ -54,13 +71,16 @@ type DynamicCall struct { // InputsCommand prints the entries, assets and build paths the views and config // declare, so a build tool need not repeat them. type InputsCommand struct { - view contractsview.View - vite contractsvite.Vite - config contractsconfig.Config + view contractsview.View + vite contractsvite.Vite + config contractsconfig.Config + modules contractsmodular.Registry + buildInfo func() (*debug.BuildInfo, bool) } -func NewInputsCommand(views contractsview.View, vite contractsvite.Vite, config contractsconfig.Config) *InputsCommand { - return &InputsCommand{view: views, vite: vite, config: config} +// NewInputsCommand takes the module registry, nil when the application has none. +func NewInputsCommand(views contractsview.View, vite contractsvite.Vite, config contractsconfig.Config, modules contractsmodular.Registry) *InputsCommand { + return &InputsCommand{view: views, vite: vite, config: config, modules: modules, buildInfo: debug.ReadBuildInfo} } // Signature The name and signature of the console command. @@ -89,6 +109,11 @@ func (r *InputsCommand) Handle(ctx console.Context) error { return errors.ViteFacadeNotSet } + modules, err := r.moduleInputs() + if err != nil { + return err + } + tmpl, err := view.Compile(view.CompileOptions{ FuncMap: r.view.Funcs(), Paths: r.view.RegisteredViews(), @@ -103,6 +128,7 @@ func (r *InputsCommand) Handle(ctx console.Context) error { Assets: r.configuredEntries("vite.assets"), Dynamic: []DynamicCall{}, Paths: r.paths(), + Modules: modules, } collect(view.Calls(tmpl, tagsFunction), tagsFunction, &inputs.Inputs, &inputs.Dynamic) collect(view.Calls(tmpl, assetFunction), assetFunction, &inputs.Assets, &inputs.Dynamic) @@ -133,6 +159,92 @@ func (r *InputsCommand) paths() Paths { } } +// aliasKey is a Vite alias a module may declare: "@" and a name without slashes. +var aliasKey = regexp.MustCompile(`^@[A-Za-z0-9][A-Za-z0-9_-]*$`) + +// moduleInputs lists every module with its entries and aliases made relative to the +// project root. It fails on an invalid or duplicate alias and on a third-party +// module whose published copy is missing or differs from the linked version. +func (r *InputsCommand) moduleInputs() ([]ModuleInputs, error) { + inputs := []ModuleInputs{} + if r.modules == nil { + return inputs, nil + } + + all := r.modules.All() + info, _ := r.buildInfo() + + // "@" belongs to its module, and every other key to the first module declaring it. + owners := make(map[string]string, len(all)) + for _, module := range all { + owners["@"+module.Name()] = module.Name() + } + + for _, module := range all { + name := module.Name() + dir, _ := r.modules.Path(name) + if err := checkPublished(module, info, dir); err != nil { + return nil, err + } + + input := ModuleInputs{Name: name, Path: dir, Entries: []string{}, Aliases: map[string]string{}} + if p, ok := module.(contractsmodular.ProvidesViteEntries); ok { + for _, entry := range p.ViteEntries() { + // Checked like aliases: Vite would build a file outside the module otherwise. + cleaned, inside := path.CleanRelative(entry) + if !inside || cleaned == "." { + return nil, errors.ModularViteEntryInvalid.Args(name, entry) + } + input.Entries = append(input.Entries, stdpath.Join(dir, cleaned)) + } + } + if p, ok := module.(contractsmodular.ProvidesViteAlias); ok { + aliases := p.ViteAliases() + for _, key := range slices.Sorted(maps.Keys(aliases)) { + target, inside := path.CleanRelative(aliases[key]) + if !aliasKey.MatchString(key) || !inside { + return nil, errors.ModularViteAliasInvalid.Args(name, key, aliases[key]) + } + if owner, taken := owners[key]; taken && owner != name { + return nil, errors.ModularViteAliasDuplicate.Args(key, name, owner) + } + + owners[key] = name + input.Aliases[key] = stdpath.Join(dir, target) + } + } + + inputs = append(inputs, input) + } + + return inputs, nil +} + +// checkPublished fails for a third-party module whose files are not in dir as +// modular:publish writes them: Vite would build without them, or with old ones. +func checkPublished(module contractsmodular.Module, info *debug.BuildInfo, dir string) error { + dependency, ok := modular.DependencyOf(module, info) + if !ok { + return nil + } + + source := dependency.Path + if dependency.Version != "" { + source += " " + dependency.Version + } + + switch modular.PublishState(module, dependency, path.Base(filepath.FromSlash(dir))) { + case modular.PublishMissing: + return errors.ModularModuleNotPublished.Args(module.Name(), source, dir) + case modular.PublishStale: + return errors.ModularModulePublishStale.Args(module.Name(), dir, source) + case modular.PublishConflict: + return errors.ModularPublishTargetNotOwned.Args(module.Name(), dir, dependency.Path) + } + + return nil +} + // configuredEntries returns vite.inputs or vite.assets as []string; empty when the // key is unset or no config is bound, so the JSON is always [] and never null. The // slice is cloned so sorting and compacting never rewrites the config's own value. diff --git a/vite/console/inputs_command_modules_test.go b/vite/console/inputs_command_modules_test.go new file mode 100644 index 000000000..a8564d67b --- /dev/null +++ b/vite/console/inputs_command_modules_test.go @@ -0,0 +1,207 @@ +package console + +import ( + "html/template" + "io/fs" + "os" + "path/filepath" + "runtime/debug" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + contractsmodular "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + mocksconsole "github.com/goravel/framework/mocks/console" + mocksmodular "github.com/goravel/framework/mocks/modular" + mocksview "github.com/goravel/framework/mocks/view" + "github.com/goravel/framework/modular" + "github.com/goravel/framework/support" +) + +type testModule struct{ name string } + +func (m *testModule) Name() string { return m.name } + +type viteModule struct { + testModule + entries []string + aliases map[string]string +} + +func (m *viteModule) ViteEntries() []string { return m.entries } +func (m *viteModule) ViteAliases() map[string]string { return m.aliases } + +type sourcesModule struct { + testModule + sources fs.FS +} + +func (m *sourcesModule) Sources() fs.FS { return m.sources } + +// localBuild makes this package's test modules part of the application. +func localBuild() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{Main: debug.Module{Path: "github.com/goravel/framework"}}, true +} + +// thirdPartyBuild makes them third-party, linked at version. +func thirdPartyBuild(version string) func() (*debug.BuildInfo, bool) { + return func() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{ + Main: debug.Module{Path: "example.com/app"}, + Deps: []*debug.Module{{Path: "github.com/goravel/framework", Version: version, Sum: "h1:abc"}}, + }, true + } +} + +// moduleRegistry answers for modules placed at modules/. +func moduleRegistry(t *testing.T, modules ...contractsmodular.Module) *mocksmodular.Registry { + t.Helper() + + registry := mocksmodular.NewRegistry(t) + registry.EXPECT().All().Return(modules) + for _, module := range modules { + registry.EXPECT().Path(module.Name()).Return("modules/"+module.Name(), true).Maybe() + } + + return registry +} + +// moduleCommand builds a command over the given views; compiled is false when the +// command is expected to fail before it compiles them. +func moduleCommand(t *testing.T, registry contractsmodular.Registry, views fs.FS, compiled bool) *InputsCommand { + t.Helper() + + view := mocksview.NewView(t) + vite := defaultVite(t) + if compiled { + var registered []fs.FS + if views != nil { + registered = []fs.FS{views} + } + view.EXPECT().Funcs().Return(template.FuncMap{"vite": func(...string) template.HTML { return "" }}).Once() + view.EXPECT().RegisteredViews().Return(nil).Once() + view.EXPECT().RegisteredViewFS().Return(registered).Once() + } else { + vite.ExpectedCalls = nil + } + + cmd := NewInputsCommand(view, vite, nil, registry) + cmd.buildInfo = localBuild + + return cmd +} + +func TestInputsCommand_Modules(t *testing.T) { + setupAppViews(t, nil) + + blog := &viteModule{ + testModule: testModule{"blog"}, + entries: []string{"resources/js/app.js", "./resources/css/app.css"}, + aliases: map[string]string{"@blog-assets": "resources/assets", "@blog": "./resources"}, + } + shop := &testModule{"shop"} + views := fstest.MapFS{"blog/index.tmpl": {Data: []byte(`{{ vite "@blog/app.js" }}`)}} + + ctx := mocksconsole.NewContext(t) + ctx.EXPECT().Line(`{"inputs":["@blog/app.js"],"assets":[],"dynamic":[],"paths":{"public_directory":"public","build_directory":"build","hot_file":"public/hot","manifest":".vite/manifest.json"},` + + `"modules":[{"name":"blog","path":"modules/blog","entries":["modules/blog/resources/js/app.js","modules/blog/resources/css/app.css"],"aliases":{"@blog":"modules/blog/resources","@blog-assets":"modules/blog/resources/assets"}},` + + `{"name":"shop","path":"modules/shop","entries":[],"aliases":{}}]}`).Once() + + assert.NoError(t, moduleCommand(t, moduleRegistry(t, blog, shop), views, true).Handle(ctx)) +} + +func TestInputsCommand_ModuleAliasErrors(t *testing.T) { + setupAppViews(t, nil) + + module := func(name string, aliases map[string]string) *viteModule { + return &viteModule{testModule: testModule{name}, aliases: aliases} + } + + tests := []struct { + name string + modules []contractsmodular.Module + err error + }{ + {"escaping target", []contractsmodular.Module{module("blog", map[string]string{"@x": "../shop"})}, errors.ModularViteAliasInvalid.Args("blog", "@x", "../shop")}, + {"absolute target", []contractsmodular.Module{module("blog", map[string]string{"@x": "/etc"})}, errors.ModularViteAliasInvalid.Args("blog", "@x", "/etc")}, + {"bare @", []contractsmodular.Module{module("blog", map[string]string{"@": "."})}, errors.ModularViteAliasInvalid.Args("blog", "@", ".")}, + {"no @", []contractsmodular.Module{module("blog", map[string]string{"bad": "."})}, errors.ModularViteAliasInvalid.Args("blog", "bad", ".")}, + {"slash in key", []contractsmodular.Module{module("blog", map[string]string{"@a/b": "."})}, errors.ModularViteAliasInvalid.Args("blog", "@a/b", ".")}, + { + "two modules, one key", + []contractsmodular.Module{module("blog", map[string]string{"@ui": "ui"}), module("shop", map[string]string{"@ui": "ui"})}, + errors.ModularViteAliasDuplicate.Args("@ui", "shop", "blog"), + }, + { + "another module's implicit alias", + []contractsmodular.Module{module("blog", map[string]string{"@shop": "resources"}), module("shop", nil)}, + errors.ModularViteAliasDuplicate.Args("@shop", "blog", "shop"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := moduleCommand(t, moduleRegistry(t, test.modules...), nil, false) + + assert.EqualError(t, cmd.Handle(mocksconsole.NewContext(t)), test.err.Error()) + }) + } +} + +func TestInputsCommand_ModuleEntryErrors(t *testing.T) { + setupAppViews(t, nil) + + for _, entry := range []string{"../shop/app.js", "resources/../../shop/app.js", "/etc/app.js", "", "."} { + t.Run(entry, func(t *testing.T) { + blog := &viteModule{testModule: testModule{"blog"}, entries: []string{"resources/js/app.js", entry}} + cmd := moduleCommand(t, moduleRegistry(t, blog), nil, false) + + assert.EqualError(t, cmd.Handle(mocksconsole.NewContext(t)), errors.ModularViteEntryInvalid.Args("blog", entry).Error()) + }) + } +} + +func TestInputsCommand_ModulePublished(t *testing.T) { + setupAppViews(t, nil) + + billing := &sourcesModule{testModule{"billing"}, fstest.MapFS{"resources/js/app.js": {Data: []byte("app")}}} + plain := &testModule{"plain"} + dir := filepath.Join(support.RelativePath, "modules", "billing") + dependency := func(version string) modular.Dependency { + return modular.Dependency{Path: "github.com/goravel/framework", Version: version, Sum: "h1:abc"} + } + run := func(version string, compiled bool) error { + ctx := mocksconsole.NewContext(t) + if compiled { + ctx.EXPECT().Line(`{"inputs":[],"assets":[],"dynamic":[],"paths":{"public_directory":"public","build_directory":"build","hot_file":"public/hot","manifest":".vite/manifest.json"},` + + `"modules":[{"name":"plain","path":"modules/plain","entries":[],"aliases":{}},{"name":"billing","path":"modules/billing","entries":[],"aliases":{}}]}`).Once() + } + cmd := moduleCommand(t, moduleRegistry(t, plain, billing), nil, compiled) + cmd.buildInfo = thirdPartyBuild(version) + + return cmd.Handle(ctx) + } + + // Not published yet; a module without sources is never checked. + assert.EqualError(t, run("v1.2.0", false), errors.ModularModuleNotPublished.Args("billing", "github.com/goravel/framework v1.2.0", "modules/billing").Error()) + + _, err := modular.Publish(billing, dependency("v1.2.0"), dir) + require.NoError(t, err) + assert.NoError(t, run("v1.2.0", true)) + + // Another version is linked than the one published. + assert.EqualError(t, run("v1.3.0", false), errors.ModularModulePublishStale.Args("billing", "modules/billing", "github.com/goravel/framework v1.3.0").Error()) + + // The directory holds something modular:publish did not write. + require.NoError(t, os.Remove(filepath.Join(dir, modular.PublishMarker))) + assert.EqualError(t, run("v1.2.0", false), errors.ModularPublishTargetNotOwned.Args("billing", "modules/billing", "github.com/goravel/framework").Error()) + + // In the application itself nothing is published. + ctx := mocksconsole.NewContext(t) + ctx.EXPECT().Line(mock.Anything).Once() + assert.NoError(t, moduleCommand(t, moduleRegistry(t, billing), nil, true).Handle(ctx)) +} diff --git a/vite/console/inputs_command_test.go b/vite/console/inputs_command_test.go index 851eb3f4a..744aebf32 100644 --- a/vite/console/inputs_command_test.go +++ b/vite/console/inputs_command_test.go @@ -46,10 +46,10 @@ func defaultVite(t *testing.T) *mocksvite.Vite { return vite } -const defaultPaths = `"paths":{"public_directory":"public","build_directory":"build","hot_file":"public/hot","manifest":".vite/manifest.json"}` +const defaultPaths = `"paths":{"public_directory":"public","build_directory":"build","hot_file":"public/hot","manifest":".vite/manifest.json"},"modules":[]` func TestInputsCommand_Metadata(t *testing.T) { - cmd := NewInputsCommand(nil, nil, nil) + cmd := NewInputsCommand(nil, nil, nil, nil) assert.Equal(t, "vite:inputs", cmd.Signature()) assert.Equal(t, "Print the Vite entry points and assets the views and config declare, and the build paths, as JSON", cmd.Description()) assert.Equal(t, "vite", cmd.Extend().Category) @@ -77,7 +77,7 @@ func TestInputsCommand_Handle(t *testing.T) { ctx := mocksconsole.NewContext(t) ctx.EXPECT().Line(`{"inputs":["resources/css/app.css","resources/inertia/app.ts","resources/js/admin.js","resources/js/app.js","resources/js/mixed.js"],"assets":["resources/images/logo.svg"],"dynamic":[{"template":"dyn.tmpl","function":"vite"},{"template":"dyn.tmpl","function":"vite"}],` + defaultPaths + `}`).Once() - assert.NoError(t, NewInputsCommand(view, defaultVite(t), config).Handle(ctx)) + assert.NoError(t, NewInputsCommand(view, defaultVite(t), config, nil).Handle(ctx)) } func TestInputsCommand_Paths(t *testing.T) { @@ -99,8 +99,8 @@ func TestInputsCommand_Paths(t *testing.T) { vite.EXPECT().ManifestFilename().Return("manifest.json").Once() ctx := mocksconsole.NewContext(t) - ctx.EXPECT().Line(`{"inputs":[],"assets":[],"dynamic":[],"paths":{"public_directory":"public","build_directory":"assets","hot_file":"storage/vite.hot","manifest":"manifest.json"}}`).Once() - assert.NoError(t, NewInputsCommand(view, vite, config).Handle(ctx)) + ctx.EXPECT().Line(`{"inputs":[],"assets":[],"dynamic":[],"paths":{"public_directory":"public","build_directory":"assets","hot_file":"storage/vite.hot","manifest":"manifest.json"},"modules":[]}`).Once() + assert.NoError(t, NewInputsCommand(view, vite, config, nil).Handle(ctx)) // A hot file outside the project root stays absolute. outside := filepath.Join(t.TempDir(), "hot") @@ -110,8 +110,8 @@ func TestInputsCommand_Paths(t *testing.T) { vite.EXPECT().ManifestFilename().Return(".vite/manifest.json").Once() ctx = mocksconsole.NewContext(t) - ctx.EXPECT().Line(`{"inputs":[],"assets":[],"dynamic":[],"paths":{"public_directory":"public","build_directory":"build","hot_file":"` + filepath.ToSlash(outside) + `","manifest":".vite/manifest.json"}}`).Once() - assert.NoError(t, NewInputsCommand(view, vite, config).Handle(ctx)) + ctx.EXPECT().Line(`{"inputs":[],"assets":[],"dynamic":[],"paths":{"public_directory":"public","build_directory":"build","hot_file":"` + filepath.ToSlash(outside) + `","manifest":".vite/manifest.json"},"modules":[]}`).Once() + assert.NoError(t, NewInputsCommand(view, vite, config, nil).Handle(ctx)) } func TestInputsCommand_NoViews(t *testing.T) { @@ -129,7 +129,7 @@ func TestInputsCommand_NoViews(t *testing.T) { ctx := mocksconsole.NewContext(t) ctx.EXPECT().Line(`{"inputs":[],"assets":[],"dynamic":[],` + defaultPaths + `}`).Once() - assert.NoError(t, NewInputsCommand(view, defaultVite(t), config).Handle(ctx)) + assert.NoError(t, NewInputsCommand(view, defaultVite(t), config, nil).Handle(ctx)) } func TestInputsCommand_ConfiguredEntries(t *testing.T) { @@ -148,7 +148,7 @@ func TestInputsCommand_ConfiguredEntries(t *testing.T) { ctx := mocksconsole.NewContext(t) ctx.EXPECT().Line(`{"inputs":["resources/inertia/app.ts","resources/js/app.ts"],"assets":["resources/images/logo.svg"],"dynamic":[],` + defaultPaths + `}`).Once() - assert.NoError(t, NewInputsCommand(view, defaultVite(t), config).Handle(ctx)) + assert.NoError(t, NewInputsCommand(view, defaultVite(t), config, nil).Handle(ctx)) // The command sorts its own copy, never the value the config stores. assert.Equal(t, []string{"resources/inertia/app.ts", "resources/inertia/app.ts", "resources/js/app.ts"}, inputs) @@ -165,13 +165,13 @@ func TestInputsCommand_NilConfig(t *testing.T) { ctx := mocksconsole.NewContext(t) ctx.EXPECT().Line(`{"inputs":[],"assets":[],"dynamic":[],` + defaultPaths + `}`).Once() - assert.NoError(t, NewInputsCommand(view, defaultVite(t), nil).Handle(ctx)) + assert.NoError(t, NewInputsCommand(view, defaultVite(t), nil, nil).Handle(ctx)) } func TestInputsCommand_Errors(t *testing.T) { ctx := mocksconsole.NewContext(t) - assert.EqualError(t, NewInputsCommand(nil, nil, nil).Handle(ctx), errors.ViewFacadeNotSet.SetModule(errors.ModuleVite).Error()) - assert.EqualError(t, NewInputsCommand(mocksview.NewView(t), nil, nil).Handle(ctx), errors.ViteFacadeNotSet.Error()) + assert.EqualError(t, NewInputsCommand(nil, nil, nil, nil).Handle(ctx), errors.ViewFacadeNotSet.SetModule(errors.ModuleVite).Error()) + assert.EqualError(t, NewInputsCommand(mocksview.NewView(t), nil, nil, nil).Handle(ctx), errors.ViteFacadeNotSet.Error()) // A view that does not parse, here because vite is not registered, fails the command. setupAppViews(t, map[string]string{"broken.tmpl": `{{ vite "a" }}`}) @@ -180,7 +180,7 @@ func TestInputsCommand_Errors(t *testing.T) { view.EXPECT().RegisteredViews().Return(nil).Once() view.EXPECT().RegisteredViewFS().Return(nil).Once() - err := NewInputsCommand(view, mocksvite.NewVite(t), nil).Handle(ctx) + err := NewInputsCommand(view, mocksvite.NewVite(t), nil, nil).Handle(ctx) require.Error(t, err) assert.Contains(t, err.Error(), "[vite] failed to compile views") assert.Contains(t, err.Error(), `function "vite" not defined`) diff --git a/vite/service_provider.go b/vite/service_provider.go index 722b041d9..50e924f08 100644 --- a/vite/service_provider.go +++ b/vite/service_provider.go @@ -52,6 +52,6 @@ func (r *ServiceProvider) Boot(app foundation.Application) { } app.Commands([]contractsconsole.Command{ - console.NewInputsCommand(view, vite, app.MakeConfig()), + console.NewInputsCommand(view, vite, app.MakeConfig(), app.MakeModular()), }) } diff --git a/vite/service_provider_test.go b/vite/service_provider_test.go index 2bd3af2d4..d9506b945 100644 --- a/vite/service_provider_test.go +++ b/vite/service_provider_test.go @@ -70,6 +70,7 @@ func (s *ServiceProviderTestSuite) TestBoot() { s.app.EXPECT().MakeView().Return(view).Once() s.app.EXPECT().MakeVite().Return(NewVite(nil)).Once() s.app.EXPECT().MakeConfig().Return(mocksconfig.NewConfig(s.T())).Once() + s.app.EXPECT().MakeModular().Return(nil).Once() view.EXPECT().AddFuncs(mock.Anything).Run(func(funcs template.FuncMap) { s.Len(funcs, 3) s.Contains(funcs, "vite") @@ -95,6 +96,7 @@ func (s *ServiceProviderTestSuite) TestBoot() { s.app.EXPECT().MakeView().Return(view).Once() s.app.EXPECT().MakeVite().Return(nil).Once() s.app.EXPECT().MakeConfig().Return(mocksconfig.NewConfig(s.T())).Once() + s.app.EXPECT().MakeModular().Return(nil).Once() s.expectInputsCommand() output := color.CaptureOutput(func(io.Writer) { diff --git a/vite/vite.go b/vite/vite.go index dd79459cc..117ef5dbb 100644 --- a/vite/vite.go +++ b/vite/vite.go @@ -21,6 +21,7 @@ import ( "github.com/goravel/framework/errors" "github.com/goravel/framework/packages/paths" "github.com/goravel/framework/support" + supportpath "github.com/goravel/framework/support/path" ) const ( @@ -28,6 +29,9 @@ const ( defaultHotFilename = "hot" // DefaultManifestFilename is where Vite writes the manifest, relative to the build directory. DefaultManifestFilename = ".vite/manifest.json" + // aliasesKey is the manifest key under which goravel-vite-plugin writes the alias + // table, e.g. {"@": "resources/js", "@blog": "modules/blog/resources/js"}. + aliasesKey = "goravel:aliases" ) // DefaultBuildDirectory returns the default build directory, inside the public @@ -56,7 +60,8 @@ type chunk struct { // manifest is a parsed manifest.json with the size and mtime of its file, so a // rebuild is picked up without a restart. type manifest struct { - chunks map[string]chunk + chunks map[string]chunk + aliases map[string]string // importsOf is each chunk's transitive imports in preload order, computed once // when the manifest is parsed so a render does not re-walk the graph. importsOf map[string][]string @@ -66,8 +71,8 @@ type manifest struct { } // newManifest indexes the parsed chunks. -func newManifest(chunks map[string]chunk, size, modTime int64, manifestHash string) *manifest { - m := &manifest{chunks: chunks, importsOf: make(map[string][]string, len(chunks)), manifestHash: manifestHash, size: size, modTime: modTime} +func newManifest(chunks map[string]chunk, aliases map[string]string, size, modTime int64, manifestHash string) *manifest { + m := &manifest{chunks: chunks, aliases: aliases, importsOf: make(map[string][]string, len(chunks)), manifestHash: manifestHash, size: size, modTime: modTime} for name := range chunks { m.importsOf[name] = m.imports(name, nil) } @@ -139,8 +144,12 @@ func Funcs(v contractsvite.Vite) template.FuncMap { } func (r *Vite) Asset(asset string) (string, error) { - asset = normalizeEntry(asset) - if url, ok := r.hotURL(); ok { + if url, aliases, ok := r.hot(); ok { + asset, err := resolveEntry(asset, aliases) + if err != nil { + return "", err + } + return url + "/" + asset, nil } @@ -149,6 +158,11 @@ func (r *Vite) Asset(asset string) (string, error) { return "", err } + asset, err = resolveEntry(asset, m.aliases) + if err != nil { + return "", err + } + c, ok := m.chunks[asset] if !ok { return "", errors.ViteEntryNotFound.Args(asset) @@ -158,12 +172,16 @@ func (r *Vite) Asset(asset string) (string, error) { } func (r *Vite) Content(asset string) (string, error) { - asset = normalizeEntry(asset) m, err := r.manifest() if err != nil { return "", err } + asset, err = resolveEntry(asset, m.aliases) + if err != nil { + return "", err + } + c, ok := m.chunks[asset] if !ok { return "", errors.ViteEntryNotFound.Args(asset) @@ -212,7 +230,7 @@ func (r *Vite) IsRunningHot() bool { } func (r *Vite) ReactRefresh() template.HTML { - url, ok := r.hotURL() + url, _, ok := r.hot() if !ok { return "" } @@ -226,14 +244,13 @@ func (r *Vite) ReactRefresh() template.HTML { } func (r *Vite) Tags(entries ...string) (template.HTML, error) { - normalized := make([]string, len(entries)) - for i, entry := range entries { - normalized[i] = normalizeEntry(entry) - } - entries = normalized + if url, aliases, ok := r.hot(); ok { + resolved, err := resolveEntries(entries, aliases) + if err != nil { + return "", err + } - if url, ok := r.hotURL(); ok { - return r.hotTags(url, entries), nil + return r.hotTags(url, resolved), nil } m, err := r.manifest() @@ -241,7 +258,12 @@ func (r *Vite) Tags(entries ...string) (template.HTML, error) { return "", err } - return r.buildTags(m, r.assetBase(), entries) + resolved, err := resolveEntries(entries, m.aliases) + if err != nil { + return "", err + } + + return r.buildTags(m, r.assetBase(), resolved) } // UseBuildDirectory sets the build directory, given relative to the public @@ -286,6 +308,45 @@ func normalizeEntry(entry string) string { return strings.TrimPrefix(entry, "/") } +// resolveEntry turns an entry as written into the project-relative path the manifest +// and the dev server know: a leading "@alias" is replaced by its directory (longest +// alias first), then the path is cleaned and must stay inside the project. +func resolveEntry(entry string, aliases map[string]string) (string, error) { + resolved := normalizeEntry(entry) + if strings.HasPrefix(resolved, "@") { + found := "" + for alias := range aliases { + if len(alias) > len(found) && (resolved == alias || strings.HasPrefix(resolved, alias+"/")) { + found = alias + } + } + if found == "" { + return "", errors.ViteAliasNotFound.Args(entry) + } + + resolved = aliases[found] + resolved[len(found):] + } + + cleaned, ok := supportpath.CleanRelative(resolved) + if !ok { + return "", errors.ViteEntryOutsideProject.Args(entry, path.Clean(resolved)) + } + + return cleaned, nil +} + +func resolveEntries(entries []string, aliases map[string]string) ([]string, error) { + resolved := make([]string, len(entries)) + for i, entry := range entries { + var err error + if resolved[i], err = resolveEntry(entry, aliases); err != nil { + return nil, err + } + } + + return resolved, nil +} + // hotTags loads the Vite client, then every entry from the dev server. func (r *Vite) hotTags(url string, entries []string) template.HTML { var b strings.Builder @@ -377,14 +438,28 @@ func (m *manifest) imports(name string, visited map[string]struct{}) []string { return out } -// hotURL returns the dev server URL while the hot file exists. -func (r *Vite) hotURL() (string, bool) { +// hot returns the dev server URL and alias table while the hot file exists. The +// file is {"url": "...", "aliases": {...}}, or the bare URL of an older plugin. +func (r *Vite) hot() (string, map[string]string, bool) { content, err := os.ReadFile(r.HotFile()) if err != nil { - return "", false + return "", nil, false + } + + text := strings.TrimSpace(string(content)) + if !strings.HasPrefix(text, "{") { + return strings.TrimRight(text, "/"), nil, true + } + + var hot struct { + URL string `json:"url"` + Aliases map[string]string `json:"aliases"` + } + if err := json.Unmarshal([]byte(text), &hot); err != nil || hot.URL == "" { + return "", nil, false } - return strings.TrimRight(strings.TrimSpace(string(content)), "/"), true + return strings.TrimRight(hot.URL, "/"), hot.Aliases, true } // assetBase is the public URL of the build directory, prefixed with app.asset_url. @@ -445,13 +520,21 @@ func (r *Vite) manifest() (*manifest, error) { return nil, errors.ViteManifestReadFailed.Args(manifestPath, err) } + // The alias table sits among the chunks under a key no source path can have. + var table struct { + Aliases map[string]string `json:"goravel:aliases"` + } chunks := make(map[string]chunk) + if err := json.Unmarshal(content, &table); err != nil { + return nil, errors.ViteManifestInvalid.Args(manifestPath, err) + } if err := json.Unmarshal(content, &chunks); err != nil { return nil, errors.ViteManifestInvalid.Args(manifestPath, err) } + delete(chunks, aliasesKey) sum := md5.Sum(content) - m := newManifest(chunks, info.Size(), info.ModTime().UnixNano(), hex.EncodeToString(sum[:])) + m := newManifest(chunks, table.Aliases, info.Size(), info.ModTime().UnixNano(), hex.EncodeToString(sum[:])) r.manifests[manifestPath] = m return m, nil diff --git a/vite/vite_test.go b/vite/vite_test.go index d38e27285..5ec724b6f 100644 --- a/vite/vite_test.go +++ b/vite/vite_test.go @@ -713,3 +713,16 @@ func TestIsCSS(t *testing.T) { assert.False(t, isCSS(name), name) } } + +func TestHotFile_InvalidJSON(t *testing.T) { + public := setupProject(t) + vite := newTestVite(t, "") + + // The file exists, but names no dev server. + for _, content := range []string{`{"url": `, `{"aliases": {"@blog": "modules/blog/resources/js"}}`} { + writeFile(t, filepath.Join(public, "hot"), content) + + assert.True(t, vite.IsRunningHot()) + assert.Empty(t, vite.ReactRefresh(), content) + } +}