From 4577f4a59e9ce0b500980c61267bfe45573db5ad Mon Sep 17 00:00:00 2001 From: kkumar-gcc Date: Sun, 15 Mar 2026 18:02:06 +0530 Subject: [PATCH 1/2] add ai related documentation --- .ai/AGENTS.md | 281 ++++++++ .ai/prompt/artisan.md | 324 +++++++++ .ai/prompt/auth.md | 390 ++++++++++ .ai/prompt/best-practices.md | 992 ++++++++++++++++++++++++++ .ai/prompt/bootstrap.md | 402 +++++++++++ .ai/prompt/cache.md | 254 +++++++ .ai/prompt/controller.md | 316 ++++++++ .ai/prompt/controllers.md | 343 +++++++++ .ai/prompt/event.md | 134 ++++ .ai/prompt/events.md | 201 ++++++ .ai/prompt/facades.md | 338 +++++++++ .ai/prompt/grpc.md | 269 +++++++ .ai/prompt/helpers.md | 348 +++++++++ .ai/prompt/http.md | 249 +++++++ .ai/prompt/localization.md | 154 ++++ .ai/prompt/log.md | 158 ++++ .ai/prompt/mail.md | 162 +++++ .ai/prompt/middleware.md | 217 ++++++ .ai/prompt/migration.md | 346 +++++++++ .ai/prompt/models.md | 920 ++++++++++++++++++++++++ .ai/prompt/orm.md | 474 ++++++++++++ .ai/prompt/process.md | 207 ++++++ .ai/prompt/queue.md | 180 +++++ .ai/prompt/queues.md | 260 +++++++ .ai/prompt/route.md | 360 ++++++++++ .ai/prompt/routing.md | 267 +++++++ .ai/prompt/session.md | 265 +++++++ .ai/prompt/storage.md | 213 ++++++ .ai/prompt/testing.md | 421 +++++++++++ .ai/prompt/validation.md | 461 ++++++++++++ .ai/prompt/view.md | 167 +++++ .ai/skills/.gitkeep | 0 .github/workflows/generate-agents.yml | 266 +++++++ scripts/generate-agents/config.json | 207 ++++++ 34 files changed, 10546 insertions(+) create mode 100644 .ai/AGENTS.md create mode 100644 .ai/prompt/artisan.md create mode 100644 .ai/prompt/auth.md create mode 100644 .ai/prompt/best-practices.md create mode 100644 .ai/prompt/bootstrap.md create mode 100644 .ai/prompt/cache.md create mode 100644 .ai/prompt/controller.md create mode 100644 .ai/prompt/controllers.md create mode 100644 .ai/prompt/event.md create mode 100644 .ai/prompt/events.md create mode 100644 .ai/prompt/facades.md create mode 100644 .ai/prompt/grpc.md create mode 100644 .ai/prompt/helpers.md create mode 100644 .ai/prompt/http.md create mode 100644 .ai/prompt/localization.md create mode 100644 .ai/prompt/log.md create mode 100644 .ai/prompt/mail.md create mode 100644 .ai/prompt/middleware.md create mode 100644 .ai/prompt/migration.md create mode 100644 .ai/prompt/models.md create mode 100644 .ai/prompt/orm.md create mode 100644 .ai/prompt/process.md create mode 100644 .ai/prompt/queue.md create mode 100644 .ai/prompt/queues.md create mode 100644 .ai/prompt/route.md create mode 100644 .ai/prompt/routing.md create mode 100644 .ai/prompt/session.md create mode 100644 .ai/prompt/storage.md create mode 100644 .ai/prompt/testing.md create mode 100644 .ai/prompt/validation.md create mode 100644 .ai/prompt/view.md create mode 100644 .ai/skills/.gitkeep create mode 100644 .github/workflows/generate-agents.yml create mode 100644 scripts/generate-agents/config.json diff --git a/.ai/AGENTS.md b/.ai/AGENTS.md new file mode 100644 index 000000000..fd819b759 --- /dev/null +++ b/.ai/AGENTS.md @@ -0,0 +1,281 @@ +# Goravel v1.17 — Agent Reference + + + +## Hard Rules (break code if violated) + +1. Import facades from `{module}/app/facades` where `{module}` is the Go module name from `go.mod`. Never use `github.com/goravel/framework/facades` — it does not exist. +2. Never hardcode directory paths (`app/`, `config/`, `routes/`, etc.) as fixed. All paths are configurable via `WithPaths` in `bootstrap/app.go`. +3. Every controller/route handler must have signature `func(ctx http.Context) http.Response`. Missing the return type is a compile error. +4. Do not call `facades.Grpc().Client()` — it is deprecated. Use `facades.Grpc().Connect("name")`. +5. `GlobalScopes()` on a model must return `map[string]func(orm.Query) orm.Query`, NOT `[]func(...)`. +6. `Sum(column string, dest any) error` — Sum no longer returns `(int64, error)`. +7. `facades.Validation().Make()` requires `ctx` as first argument: `Make(ctx, input, rules)`. +8. Custom Rule `Passes(ctx context.Context, data Data, val any, opts ...any) bool` and `Message(ctx context.Context) string`. +9. Custom Filter `Handle(ctx context.Context) any`. +10. Custom log driver `Handle(channel string) (Handler, error)` — NOT `(Hook, error)`. Use `log.HookToHandler(hook)` adapter for old hooks. +11. `grpc.clients` config key renamed to `grpc.servers`. +12. `Http.Request.Bind` is removed — use `response.Bind(&dest)` on the response object. +13. Machinery queue driver is removed. Migrate to `redis`, `database`, or `sync`. +14. Golang >= 1.24 required for v1.17. +15. Register new service providers `&process.ServiceProvider{}` and `&view.ServiceProvider{}`. +16. Middleware is a function returning `http.Middleware`, not a struct. +17. Service providers: only bind things in `Register`. Never register routes/events in `Register` — use `Boot` or `WithCallback`. +18. `Find(&model, id)` returns nil error even when record is not found. Use `FindOrFail` to error on missing. +19. Struct updates skip zero-value fields. Use `map[string]any` to set zero values. +20. `facades.Auth(ctx).User()` / `ID()` requires `Parse(token)` to be called first. + +--- + +## Laravel → Goravel Cheatsheet + +| Laravel | Goravel | +|---------|---------| +| `Route::get('/', [C::class, 'm'])` | `facades.Route().Get("/", controller.Method)` | +| `Route::group(['prefix'=>'api'], fn)` | `facades.Route().Prefix("api").Group(func(r route.Router){...})` | +| `Route::middleware(['auth'])` | `facades.Route().Middleware(middleware.Auth()).Get(...)` | +| `$request->input('name')` | `ctx.Request().Input("name")` | +| `$request->validate([...])` | `ctx.Request().Validate(map[string]string{...})` | +| `return response()->json([...])` | `return ctx.Response().Json(http.StatusOK, http.Json{...})` | +| `Auth::login($user)` | `facades.Auth(ctx).Login(&user)` | +| `Auth::user()` | `facades.Auth(ctx).User(&user)` | +| `Hash::make('password')` | `facades.Hash().Make(password)` | +| `Hash::check('plain', $hash)` | `facades.Hash().Check("plain", hash)` | +| `Crypt::encryptString($v)` | `facades.Crypt().EncryptString(v)` | +| `Gate::define('action', fn)` | `facades.Gate().Define("action", fn)` | +| `Gate::allows('action', $args)` | `facades.Gate().Allows("action", map[string]any{...})` | +| `User::find(1)` | `facades.Orm().Query().Find(&user, 1)` | +| `User::findOrFail(1)` | `facades.Orm().Query().FindOrFail(&user, 1)` | +| `User::where('name','tom')->first()` | `facades.Orm().Query().Where("name","tom").First(&user)` | +| `User::create([...])` | `facades.Orm().Query().Create(&user)` | +| `$user->save()` | `facades.Orm().Query().Save(&user)` | +| `$user->delete()` | `facades.Orm().Query().Delete(&user)` | +| `User::withTrashed()->find(1)` | `facades.Orm().Query().WithTrashed().Find(&user,1)` | +| `User::with('posts')->get()` | `facades.Orm().Query().With("Posts").Get(&users)` | +| `dispatch(new Job($args))` | `facades.Queue().Job(&jobs.MyJob{}, args).Dispatch()` | +| `event(new Foo($args))` | `facades.Event().Job(&events.Foo{}, args).Dispatch()` | +| `Schema::create('users', fn)` | `facades.Schema().Create("users", func(t schema.Blueprint){...})` | +| `Cache::put('k', $v, 60)` | `facades.Cache().Put("k", v, 60*time.Second)` | +| `Cache::remember('k', 60, fn)` | `facades.Cache().Remember("k", 60*time.Second, fn)` | +| `Storage::put('f', $c)` | `facades.Storage().Put("f", contents)` | +| `Mail::to([])->send(new M())` | `facades.Mail().To([...]).Content(...).Send()` | +| `Http::get(url)` | `facades.Http().Get(url)` | +| `Log::info('msg')` | `facades.Log().Info("msg")` | +| `__('key')` | `facades.Lang(ctx).Get("key")` | +| `php artisan make:model User` | `./artisan make:model User` | +| `php artisan migrate` | `./artisan migrate` | + +--- + +## Bootstrap Lifecycle — `bootstrap/app.go` + +```go +// bootstrap/app.go +package bootstrap + +import ( + "github.com/goravel/framework/foundation" + contractsfoundation "github.com/goravel/framework/contracts/foundation" + // ... other imports +) + +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithProviders(Providers). // bootstrap/providers.go + WithConfig(config.Boot). // registers config files + WithRouting(func() { // registers HTTP/gRPC routes + routes.Web() + routes.Grpc() + }). + WithMiddleware(func(handler configuration.Middleware) { + handler.Append(middleware.Custom()) + }). + WithCommands(Commands). // bootstrap/commands.go + WithEvents(func() map[event.Event][]event.Listener { + return map[event.Event][]event.Listener{ + events.NewOrderShipped(): {listeners.NewSendShipmentNotification()}, + } + }). + WithJobs(Jobs). // bootstrap/jobs.go + WithMigrations(Migrations). // bootstrap/migrations.go + WithSeeders(Seeders). // bootstrap/seeders.go + WithSchedule(func() []schedule.Event { + return []schedule.Event{ + facades.Schedule().Call(func() { ... }).Daily(), + } + }). + WithRules(Rules). // bootstrap/rules.go + WithFilters(Filters). // bootstrap/filters.go + WithRunners(func() []foundation.Runner { + return []foundation.Runner{NewCustomRunner()} + }). + WithPaths(func(paths configuration.Paths) { + paths.App("src") // optional: customize directories + }). + WithCallback(func() { + // runs after all providers Boot(); all facades available here + facades.Gate().Define(...) + facades.RateLimiter().For(...) + facades.Orm().Observe(...) + }). + Create() +} +``` + +**Boot order inside `Create()`:** +1. Load configuration (`WithConfig`) +2. Register all service providers (calls `Register` on each) +3. Boot all service providers (calls `Boot` on each) +4. Run `WithCallback` +5. Runners start (HTTP server, Queue worker, Schedule, gRPC, etc.) + +--- + +## Facade Import Pattern + +Facades live in `app/facades/` of your project. Import path depends on `go.mod` module name: + +```go +// go.mod: module github.com/mycompany/myapp + +import "github.com/mycompany/myapp/app/facades" + +facades.Route().Get("/", handler) +facades.Orm().Query().Find(&user, 1) +facades.Auth(ctx).Login(&user) +``` + +Available facades: `App`, `Artisan`, `Auth`, `Cache`, `Config`, `Crypt`, `DB`, `Event`, `Gate`, `Grpc`, `Hash`, `Http`, `Lang`, `Log`, `Mail`, `Orm`, `Process`, `Queue`, `RateLimiter`, `Route`, `Schedule`, `Schema`, `Seeder`, `Session`, `Storage`, `Validation`, `View`. + +--- + +## Directory Paths Are Configurable + +```go +WithPaths(func(paths configuration.Paths) { + paths.App("src") + paths.Config("configuration") + paths.Database("db") + paths.Routes("api/routes") + paths.Storage("data") + paths.Resources("views-root") +}) +``` + +Never assume `app/`, `config/`, etc. are fixed. + +--- + +## Artisan Commands + +```shell +# Code generation +./artisan make:controller UserController +./artisan make:controller --resource PhotoController +./artisan make:controller user/UserController +./artisan make:model User +./artisan make:model --table=users User +./artisan make:migration create_users_table +./artisan make:migration create_users_table -m User # v1.17: from model +./artisan make:seeder UserSeeder +./artisan make:factory UserFactory +./artisan make:command SendEmails +./artisan make:middleware Auth +./artisan make:middleware user/Auth +./artisan make:job ProcessPodcast +./artisan make:event OrderShipped +./artisan make:listener SendShipmentNotification +./artisan make:observer UserObserver +./artisan make:policy PostPolicy +./artisan make:request StorePostRequest +./artisan make:rule Uppercase +./artisan make:filter ToInt +./artisan make:mail OrderShipped +./artisan make:provider YourServiceProvider # v1.17 new +./artisan make:view welcome # v1.17 new + +# Database +./artisan migrate +./artisan migrate:status +./artisan migrate:rollback # v1.17: rolls back all of last batch +./artisan migrate:rollback --batch=2 +./artisan migrate:rollback --step=5 +./artisan migrate:reset +./artisan migrate:refresh +./artisan migrate:fresh +./artisan migrate:fresh --seed +./artisan db:seed +./artisan db:seed --seeder=UserSeeder +./artisan db:show +./artisan db:table users + +# Application +./artisan key:generate +./artisan jwt:secret +./artisan env:encrypt +./artisan env:decrypt +./artisan about +./artisan list +./artisan route:list +./artisan schedule:run +./artisan schedule:list +./artisan queue:failed +./artisan queue:retry {uuid} +./artisan queue:retry all + +# Build (v1.17: new flags) +./artisan build +./artisan build --arch=amd64 +./artisan build --static + +# Packages (Goravel Lite) +./artisan package:install Route +./artisan package:install --all +./artisan package:uninstall Route +``` + +--- + +## v1.17 Breaking Changes Summary + +| Area | Change | +|------|--------| +| Queue | Machinery driver completely removed | +| gRPC config | `grpc.clients` → `grpc.servers`; `Client()` deprecated, use `Connect()` | +| Log driver | `Handle(channel) (Handler, error)` replaces `(Hook, error)`; adapter: `log.HookToHandler(hook)` | +| migrate:rollback | Rolls back entire last batch by default (was 1 migration); use `--step` for old behavior | +| Http client | `Request.Bind()` removed; use `response.Bind(&dest)` | +| Validation | `Make(ctx, input, rules)` — ctx now required; Rule/Filter interfaces have `ctx context.Context` | +| ORM GlobalScopes | Return type changed: `map[string]func(orm.Query) orm.Query` (was `[]func(...)`) | +| ORM Sum | Signature: `Sum(column string, dest any) error` (was `(int64, error)`) | +| Package setup | `match.Providers()` → `match.ProvidersInConfig()` for old code structure | +| Golang | Minimum version: 1.24 (was 1.23) | + +--- + +## Prompt File Index + +- [prompt/bootstrap.md](prompt/bootstrap.md) — Service container, providers, runners, lifecycle +- [prompt/route.md](prompt/route.md) — Routing, rate limiting, CORS, static files +- [prompt/middleware.md](prompt/middleware.md) — Middleware definition, global/route, abort, CSRF +- [prompt/controller.md](prompt/controller.md) — Controllers, request input, responses +- [prompt/view.md](prompt/view.md) — View templates, CSRF, facades.View +- [prompt/session.md](prompt/session.md) — Session operations +- [prompt/validation.md](prompt/validation.md) — Validation rules, custom rules/filters +- [prompt/log.md](prompt/log.md) — Logging, channels, custom drivers +- [prompt/grpc.md](prompt/grpc.md) — gRPC server/client, interceptors +- [prompt/orm.md](prompt/orm.md) — ORM models, queries, relations, migrations, seeders, factories +- [prompt/auth.md](prompt/auth.md) — Auth (JWT/session), gates, hashing, encryption +- [prompt/artisan.md](prompt/artisan.md) — Console commands, scheduling +- [prompt/cache.md](prompt/cache.md) — Cache operations, atomic locks +- [prompt/event.md](prompt/event.md) — Events and listeners +- [prompt/queue.md](prompt/queue.md) — Queue jobs, dispatching, chaining +- [prompt/storage.md](prompt/storage.md) — Filesystem/storage operations +- [prompt/mail.md](prompt/mail.md) — Mail sending, templates, Mailable +- [prompt/http.md](prompt/http.md) — HTTP client facade +- [prompt/process.md](prompt/process.md) — Process facade (new in v1.17) +- [prompt/localization.md](prompt/localization.md) — Lang/translation +- [prompt/migration.md](prompt/migration.md) — Migration commands, auto-generation from model, all column types/modifiers/indexes +- [prompt/testing.md](prompt/testing.md) — HTTP tests, mocks, Docker testing, all assertions +- [prompt/helpers.md](prompt/helpers.md) — Path/carbon/debug/maps/convert/collect helpers, fluent str, color +- [prompt/best-practices.md](prompt/best-practices.md) — Naming conventions, ORM patterns, security, middleware, jobs, cache, performance diff --git a/.ai/prompt/artisan.md b/.ai/prompt/artisan.md new file mode 100644 index 000000000..866b775f2 --- /dev/null +++ b/.ai/prompt/artisan.md @@ -0,0 +1,324 @@ +# Goravel Artisan Console & Task Scheduling + +## Command Structure + +```go +package commands + +import ( + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +type SendEmails struct{} + +func (r *SendEmails) Signature() string { + return "send:emails" +} + +func (r *SendEmails) Description() string { + return "Send emails" +} + +func (r *SendEmails) Extend() command.Extend { + return command.Extend{ + Category: "mail", + } +} + +func (r *SendEmails) Handle(ctx console.Context) error { + return nil +} +``` + +Generate: + +```shell +./artisan make:command SendEmails +./artisan make:command user/SendEmails +``` + +Register in `bootstrap/app.go`: + +```go +WithCommands(Commands) +``` + +--- + +## Arguments (v1.17) + +```go +func (r *SendEmails) Extend() command.Extend { + return command.Extend{ + Arguments: []command.Argument{ + &command.ArgumentString{ + Name: "subject", + Usage: "subject of email", + Required: true, + }, + &command.ArgumentStringSlice{ + Name: "emails", + Usage: "target emails", + Min: 1, + Max: -1, // -1 = unlimited + }, + }, + } +} + +func (r *SendEmails) Handle(ctx console.Context) error { + subject := ctx.ArgumentString("subject") + emails := ctx.ArgumentStringSlice("emails") + + // Or by index + first := ctx.Argument(0) + all := ctx.Arguments() + + return nil +} +``` + +Supported argument types: `ArgumentString`, `ArgumentInt`, `ArgumentInt64`, `ArgumentFloat64`, `ArgumentUint`, `ArgumentTimestamp`, `ArgumentStringSlice`, `ArgumentIntSlice`, `ArgumentInt64Slice`, `ArgumentFloat64Slice`, `ArgumentUintSlice`, `ArgumentTimestampSlice`, and more. + +--- + +## Options (Flags) + +```go +func (r *ListCommand) Extend() command.Extend { + return command.Extend{ + Flags: []command.Flag{ + &command.StringFlag{ + Name: "lang", + Value: "default", + Aliases: []string{"l"}, + Usage: "language for the greeting", + }, + &command.BoolFlag{ + Name: "verbose", + Usage: "enable verbose output", + }, + }, + } +} + +func (r *ListCommand) Handle(ctx console.Context) error { + lang := ctx.Option("lang") + verbose := ctx.OptionBool("verbose") + return nil +} +``` + +Usage: + +```shell +./artisan emails --lang Chinese +./artisan emails -l Chinese +``` + +Other flag types: `StringSliceFlag`, `BoolFlag`, `Float64Flag`, `Float64SliceFlag`, `IntFlag`, `IntSliceFlag`, `Int64Flag`, `Int64SliceFlag`. + +--- + +## Interactive Input + +```go +// Ask (text input) +email, err := ctx.Ask("What is your email address?") +name, err := ctx.Ask("What is your name?", console.AskOption{ + Default: "Goravel", + Placeholder: "Enter name", + Limit: 100, + Validate: func(s string) error { return nil }, +}) + +// Secret (hidden input) +password, err := ctx.Secret("What is the password?", console.SecretOption{ + Validate: func(s string) error { + if len(s) < 8 { + return errors.New("password must be at least 8 characters") + } + return nil + }, +}) + +// Confirm +if ctx.Confirm("Do you wish to continue?") { + // ... +} +if ctx.Confirm("Do you wish to continue?", console.ConfirmOption{ + Default: true, Affirmative: "Yes", Negative: "No", +}) { + // ... +} + +// Single select +color, err := ctx.Choice("Favorite language?", []console.Choice{ + {Key: "go", Value: "Go"}, + {Key: "php", Value: "PHP", Selected: true}, +}) + +// Multi-select +colors, err := ctx.MultiSelect("Favorite languages?", []console.Choice{ + {Key: "go", Value: "Go"}, + {Key: "php", Value: "PHP"}, +}, console.MultiSelectOption{ + Default: []string{"go"}, + Filterable: true, + Limit: 3, +}) +``` + +--- + +## Output + +```go +ctx.Info("Info message") +ctx.Comment("Comment message") +ctx.Warning("Warning message") +ctx.Error("Error message") +ctx.Line("Line message") + +ctx.Green("green text") +ctx.Greenln("green line") +ctx.Red("red text") +ctx.Yellow("yellow text") +ctx.Black("black text") + +ctx.NewLine() +ctx.NewLine(2) +ctx.Divider() +ctx.Divider("=>") +``` + +### Progress Bars + +```go +items := []any{"item1", "item2", "item3"} +_, err := ctx.WithProgressBar(items, func(item any) error { + // process item + return nil +}) + +// Manual progress bar +bar := ctx.CreateProgressBar(len(items)) +bar.Start() +for _, item := range items { + // process + bar.Advance() + time.Sleep(50 * time.Millisecond) +} +bar.Finish() +``` + +### Spinner + +```go +err := ctx.Spinner("Loading...", console.SpinnerOption{ + Action: func() error { + time.Sleep(2 * time.Second) + return nil + }, +}) +``` + +--- + +## Call Artisan Commands Programmatically + +```go +facades.Artisan().Call("send:emails") +facades.Artisan().Call("send:emails --lang Chinese name") +``` + +--- + +## Disable Colors + +```shell +./artisan list --no-ansi +``` + +--- + +## Task Scheduling + +Define in `WithSchedule` in `bootstrap/app.go`: + +```go +WithSchedule(func() []schedule.Event { + return []schedule.Event{ + // Closure task + facades.Schedule().Call(func() { + facades.Orm().Query().Where("1 = 1").Delete(&models.User{}) + }).Daily(), + + // Artisan command task + facades.Schedule().Command("send:emails name").EveryMinute(), + + // Named closure for OnOneServer + facades.Schedule().Call(func() { + fmt.Println("goravel") + }).Daily().OnOneServer().Name("goravel"), + } +}) +``` + +### Frequency Methods + +| Method | Frequency | +|--------|-----------| +| `.Cron("* * * * *")` | Custom cron (minutes) | +| `.Cron("* * * * * *")` | Custom cron (seconds) | +| `.EverySecond()` | Every second | +| `.EveryTwoSeconds()` | Every 2 seconds | +| `.EveryFiveSeconds()` | Every 5 seconds | +| `.EveryTenSeconds()` | Every 10 seconds | +| `.EveryFifteenSeconds()` | Every 15 seconds | +| `.EveryThirtySeconds()` | Every 30 seconds | +| `.EveryMinute()` | Every minute | +| `.EveryTwoMinutes()` | Every 2 minutes | +| `.EveryFiveMinutes()` | Every 5 minutes | +| `.EveryTenMinutes()` | Every 10 minutes | +| `.EveryFifteenMinutes()` | Every 15 minutes | +| `.EveryThirtyMinutes()` | Every 30 minutes | +| `.Hourly()` | Every hour | +| `.HourlyAt(17)` | Every hour at :17 | +| `.EveryTwoHours()` | Every 2 hours | +| `.EverySixHours()` | Every 6 hours | +| `.Daily()` | Daily at midnight | +| `.DailyAt("13:00")` | Daily at 13:00 | +| `.Days(1, 3, 5)` | Mon, Wed, Fri | +| `.Weekdays()` | Mon–Fri | +| `.Weekends()` | Sat–Sun | +| `.Weekly()` | Weekly | +| `.Monthly()` | Monthly | +| `.Quarterly()` | Quarterly | +| `.Yearly()` | Yearly | + +### Overlap Prevention + +```go +facades.Schedule().Command("send:emails name").EveryMinute().SkipIfStillRunning() +facades.Schedule().Command("send:emails name").EveryMinute().DelayIfStillRunning() +``` + +### Single Server Execution + +Requires memcached, dynamodb, or redis as default cache driver: + +```go +facades.Schedule().Command("report:generate").Daily().OnOneServer() + +// Named closure for OnOneServer: +facades.Schedule().Call(func() {}).Daily().OnOneServer().Name("unique-name") +``` + +### Run Scheduler Manually + +```shell +./artisan schedule:run +./artisan schedule:list +``` diff --git a/.ai/prompt/auth.md b/.ai/prompt/auth.md new file mode 100644 index 000000000..f8fb06adf --- /dev/null +++ b/.ai/prompt/auth.md @@ -0,0 +1,390 @@ +# Goravel Authentication, Authorization, and Hashing + +## Authentication Setup + +Configure guards in `config/auth.go` and JWT parameters in `config/jwt.go`. + +Generate JWT secret: + +```shell +./artisan jwt:secret +``` + +--- + +## JWT Authentication + +### Login with model + +```go +import "goravel/app/facades" + +var user models.User +user.ID = 1 + +token, err := facades.Auth(ctx).Login(&user) +``` + +Model must have a primary key. If the model does not embed `orm.Model`, add a `primaryKey` tag: + +```go +type User struct { + ID uint `gorm:"primaryKey"` + Name string +} +``` + +### Login with ID + +```go +token, err := facades.Auth(ctx).LoginUsingID(1) +``` + +### Parse token + +```go +payload, err := facades.Auth(ctx).Parse(token) +``` + +`payload` fields: +- `Guard` - current guard name +- `Key` - user identifier +- `ExpireAt` - expiration time +- `IssuedAt` - issue time + +Check if token is expired: + +```go +import ( + "errors" + "github.com/goravel/framework/auth" +) + +if errors.Is(err, auth.ErrorTokenExpired) { + // token expired, allow refresh +} +``` + +Token can be passed with or without the `Bearer` prefix. + +### Get authenticated user + +Must call `Parse` first (typically in middleware): + +```go +var user models.User +err := facades.Auth(ctx).User(&user) + +id, err := facades.Auth(ctx).ID() +``` + +### Refresh token + +Requires a prior `Parse` call: + +```go +token, err := facades.Auth(ctx).Refresh() +``` + +### Logout + +```go +err := facades.Auth(ctx).Logout() +``` + +--- + +## Multiple Guards + +Configure guards in `config/auth.go`: + +```go +"guards": map[string]any{ + "user": map[string]any{ + "driver": "jwt", + }, + "admin": map[string]any{ + "driver": "jwt", + "ttl": 60, + "refresh_ttl": 0, + "secret": "admin-secret", + }, +}, +``` + +Use a specific guard (must call `Guard` before any other method when not using the default): + +```go +token, err := facades.Auth(ctx).Guard("admin").LoginUsingID(1) +err := facades.Auth(ctx).Guard("admin").Parse(token) + +var admin models.Admin +err := facades.Auth(ctx).Guard("admin").User(&admin) +``` + +--- + +## Auth Middleware Pattern + +```go +package middleware + +import ( + "strings" + + "github.com/goravel/framework/contracts/http" + "goravel/app/facades" +) + +func Auth() http.Middleware { + return func(ctx http.Context) { + header := ctx.Request().Header("Authorization", "") + token := strings.TrimPrefix(header, "Bearer ") + + payload, err := facades.Auth(ctx).Parse(token) + if err != nil { + ctx.Response().String(http.StatusUnauthorized, "unauthorized").Abort() + return + } + _ = payload + ctx.Request().Next() + } +} +``` + +--- + +## Custom Guard Driver + +Register in the `Boot` method of a service provider: + +```go +import ( + "github.com/goravel/framework/contracts/auth" + contractshttp "github.com/goravel/framework/contracts/http" +) + +func (receiver *AuthServiceProvider) Boot(app foundation.Application) { + facades.Auth().Extend("custom-driver", func(ctx contractshttp.Context, name string, userProvider auth.UserProvider) (auth.GuardDriver, error) { + return &CustomGuard{}, nil + }) +} +``` + +Reference in `config/auth.go`: + +```go +"guards": map[string]any{ + "api": map[string]any{ + "driver": "custom-driver", + "provider": "users", + }, +}, +``` + +--- + +## Custom UserProvider + +```go +facades.Auth().Provider("custom-provider", func(ctx contractshttp.Context) (auth.UserProvider, error) { + return &UserProvider{}, nil +}) +``` + +Reference in `config/auth.go`: + +```go +"providers": map[string]any{ + "users": map[string]any{ + "driver": "custom-provider", + }, +}, + +"guards": map[string]any{ + "api": map[string]any{ + "driver": "jwt", + "provider": "users", + }, +}, +``` + +--- + +## Authorization - Gates + +### Define a gate + +Gates are defined inside `WithCallback` in `bootstrap/app.go`: + +```go +import ( + "context" + "github.com/goravel/framework/auth/access" + contractsaccess "github.com/goravel/framework/contracts/auth/access" +) + +WithCallback(func() { + facades.Gate().Define("update-post", + func(ctx context.Context, arguments map[string]any) contractsaccess.Response { + user := ctx.Value("user").(models.User) + post := arguments["post"].(models.Post) + + if user.ID == post.UserID { + return access.NewAllowResponse() + } + return access.NewDenyResponse("You do not own this post.") + }, + ) +}) +``` + +### Check a gate + +```go +if facades.Gate().Allows("update-post", map[string]any{"post": post}) { + // authorized +} + +if facades.Gate().Denies("update-post", map[string]any{"post": post}) { + // denied +} + +// Check multiple abilities +if facades.Gate().Any([]string{"update-post", "delete-post"}, map[string]any{"post": post}) { + // can do at least one +} + +if facades.Gate().None([]string{"update-post", "delete-post"}, map[string]any{"post": post}) { + // can do none +} +``` + +### Full response + +```go +response := facades.Gate().Inspect("update-post", map[string]any{"post": post}) + +if response.Allowed() { + // authorized +} else { + fmt.Println(response.Message()) +} +``` + +### Inject context into gate + +```go +facades.Gate().WithContext(ctx).Allows("update-post", map[string]any{"post": post}) +``` + +### Before / After hooks + +```go +facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response { + user := ctx.Value("user").(models.User) + if isAdministrator(user) { + return access.NewAllowResponse() + } + return nil // nil means continue to next check +}) + +facades.Gate().After(func(ctx context.Context, ability string, arguments map[string]any, result contractsaccess.Response) contractsaccess.Response { + user := ctx.Value("user").(models.User) + if isAdministrator(user) { + return access.NewAllowResponse() + } + return nil +}) +``` + +Note: `After` result only applies when `Define` returns `nil`. + +--- + +## Authorization - Policies + +### Generate policy + +```shell +./artisan make:policy PostPolicy +./artisan make:policy user/PostPolicy +``` + +### Write policy + +```go +package policies + +import ( + "context" + + "github.com/goravel/framework/auth/access" + contractsaccess "github.com/goravel/framework/contracts/auth/access" + "goravel/app/models" +) + +type PostPolicy struct{} + +func NewPostPolicy() *PostPolicy { + return &PostPolicy{} +} + +func (r *PostPolicy) Update(ctx context.Context, arguments map[string]any) contractsaccess.Response { + user := ctx.Value("user").(models.User) + post := arguments["post"].(models.Post) + + if user.ID == post.UserID { + return access.NewAllowResponse() + } + return access.NewDenyResponse("You do not own this post.") +} +``` + +### Register policy + +Register by mapping the policy method to a gate name inside `WithCallback`: + +```go +WithCallback(func() { + facades.Gate().Define("update-post", policies.NewPostPolicy().Update) + facades.Gate().Define("delete-post", policies.NewPostPolicy().Delete) +}) +``` + +--- + +## Hashing + +### Hash a password + +```go +hashed, err := facades.Hash().Make(password) +``` + +### Verify a password + +```go +if facades.Hash().Check("plain-text-password", hashedPassword) { + // passwords match +} +``` + +### Check if rehash needed + +```go +if facades.Hash().NeedsRehash(hashed) { + hashed, err = facades.Hash().Make("plain-text-password") +} +``` + +Configure the hashing driver (argon2id or bcrypt) in `config/hashing.go`. + +--- + +## Gotchas + +- `facades.Auth(ctx).User(&user)` requires a prior `Parse(token)` call on the same context. Without parsing, the user struct remains empty. +- When using a non-default guard, always chain `.Guard("name")` before `Login`, `Parse`, `User`, `ID`, `Refresh`, and `Logout`. +- Gate `Before` returning `nil` does not deny the action; it allows other checks to proceed. Return a response from `access.NewDenyResponse()` to explicitly deny. +- Policy methods and gate closures receive the same arguments map. Keys must match what you pass to `Allows`. diff --git a/.ai/prompt/best-practices.md b/.ai/prompt/best-practices.md new file mode 100644 index 000000000..e1dc83dbb --- /dev/null +++ b/.ai/prompt/best-practices.md @@ -0,0 +1,992 @@ +# Goravel Best Practices + +These are required patterns for correct, idiomatic Goravel code. Violations produce bugs, security holes, or poor performance. + +--- + +## Naming Conventions + +| Thing | Convention | Example | +|-------|-----------|---------| +| Model struct | PascalCase singular | `User`, `OrderItem` | +| Table name | snake_case plural (auto-derived) | `users`, `order_items` | +| Foreign key | `{model_name}_id` snake_case | `user_id`, `order_item_id` | +| Relationship field | PascalCase matching model | `Posts []*Post`, `Author *Author` | +| Controller | PascalCase + Controller suffix | `UserController`, `OrderController` | +| Middleware | PascalCase function returning `http.Middleware` | `func Auth() http.Middleware` | +| Event | PascalCase past-tense noun | `OrderShipped`, `UserRegistered` | +| Listener | PascalCase action verb phrase | `SendShipmentNotification` | +| Job | PascalCase noun | `ProcessPodcast`, `SendWelcomeEmail` | +| Command signature | `category:action` kebab | `send:emails`, `report:generate` | +| Config key | dot-separated snake_case | `app.name`, `database.default` | +| Route name | `resource.action` dot-separated | `users.index`, `posts.show` | + +Model to table name rule: `UserOrder` -> `user_orders`. Goravel pluralizes automatically using the snake_case of the struct name. + +--- + +## Use Contract Interfaces, Not Concrete Types + +Goravel's `contracts/` package defines interfaces for every facade. Type against the interface so code is testable and decoupled from the implementation. + +```go +import ( + contractsorm "github.com/goravel/framework/contracts/database/orm" + contractshttp "github.com/goravel/framework/contracts/http" + contractslog "github.com/goravel/framework/contracts/log" +) + +// WRONG: depends on a concrete type, hard to mock +type UserService struct { + db *gorm.DB +} + +// CORRECT: depends on the Goravel ORM contract +type UserService struct { + orm contractsorm.Orm +} + +func NewUserService(orm contractsorm.Orm) *UserService { + return &UserService{orm: orm} +} +``` + +When resolving from the service container, use the typed Make helpers: + +```go +// In a service provider Boot or Register +orm := app.MakeOrm() // returns contracts/database/orm.Orm +config := app.MakeConfig() // returns contracts/config.Config +route := app.MakeRoute() // returns contracts/route.Route +auth := app.MakeAuth(ctx) // returns contracts/auth.Auth +cache := app.MakeCache() // returns contracts/cache.Cache +``` + +Common contract import paths: + +```go +contractsorm "github.com/goravel/framework/contracts/database/orm" +contractshttp "github.com/goravel/framework/contracts/http" +contractslog "github.com/goravel/framework/contracts/log" +contractsconfig "github.com/goravel/framework/contracts/config" +contractscache "github.com/goravel/framework/contracts/cache" +contractsqueue "github.com/goravel/framework/contracts/queue" +contractsevent "github.com/goravel/framework/contracts/event" +contractsauth "github.com/goravel/framework/contracts/auth" +contractsstorage "github.com/goravel/framework/contracts/filesystem" +contractsschedule "github.com/goravel/framework/contracts/schedule" +``` + +--- + +## ORM / Database + +### Always eager load to avoid N+1 + +```go +// WRONG: N+1 (1 query for books, 1 per book for author) +var books []models.Book +facades.Orm().Query().Find(&books) +for _, book := range books { + facades.Orm().Query().Find(&author, book.AuthorID) +} + +// CORRECT: 2 queries total +facades.Orm().Query().With("Author").Find(&books) + +// Multiple relationships +facades.Orm().Query().With("Author").With("Publisher").Find(&books) + +// Nested +facades.Orm().Query().With("Author.Contacts").Find(&books) + +// Constrained eager load +facades.Orm().Query().With("Author", func(query orm.Query) orm.Query { + return query.Where("active = ?", true) +}).Find(&books) +``` + +### Use FindOrFail when missing = error + +```go +// WRONG: Find returns nil error even when record not found +var user models.User +err := facades.Orm().Query().Find(&user, 1) +// err is nil even if no record; user is zero-value struct + +// CORRECT: FindOrFail errors when not found +err := facades.Orm().Query().FindOrFail(&user, 1) +if err != nil { + return ctx.Response().Json(http.StatusNotFound, http.Json{"error": "not found"}) +} +``` + +### Use map[string]any to update zero values + +```go +// WRONG: struct update skips zero-value fields (false, 0, "") +facades.Orm().Query().Save(&user) // won't clear fields set to zero + +// CORRECT: use map to explicitly set zero values +facades.Orm().Query().Model(&user).Update(map[string]any{ + "active": false, + "score": 0, + "name": "", +}) +``` + +### Always use transactions for multiple writes + +```go +// CORRECT: wrap related writes in a transaction +err := facades.Orm().Transaction(func(tx orm.Transaction) error { + if err := tx.Create(&order); err != nil { + return err // auto-rollback + } + if err := tx.Create(&orderItem); err != nil { + return err // auto-rollback + } + return nil // auto-commit +}) +``` + +### Use Chunk for large datasets: never load all rows + +```go +// WRONG: loads entire table into memory +var users []models.User +facades.Orm().Query().Find(&users) + +// CORRECT: process in batches +facades.Orm().Query().Chunk(100, func(users []models.User) bool { + for _, user := range users { + // process + } + return true // return false to stop +}) +``` + +### Index foreign keys and frequently filtered columns + +Every foreign key column must have a database index. Add in migration: + +```go +table.Index("user_id") +table.Index("status", "created_at") // composite for common filter+sort +table.Unique("email") +``` + +### Use soft deletes for recoverable data + +```go +type User struct { + orm.Model + Name string + orm.SoftDeletes // adds deleted_at; Delete() sets it, doesn't remove row +} + +// Query includes soft-deleted +facades.Orm().Query().WithTrashed().Find(&users) + +// Hard delete +facades.Orm().Query().ForceDelete(&user) +``` + +### GlobalScopes must return map, not slice + +```go +// WRONG +func (u *User) GlobalScopes() []func(orm.Query) orm.Query { ... } + +// CORRECT +func (u *User) GlobalScopes() map[string]func(orm.Query) orm.Query { + return map[string]func(orm.Query) orm.Query{ + "active": func(query orm.Query) orm.Query { + return query.Where("active = ?", true) + }, + } +} + +// Disable specific scope +facades.Orm().Query().WithoutGlobalScope("active").Find(&users) + +// Disable all scopes +facades.Orm().Query().WithoutGlobalScopes().Find(&users) +``` + +### Sum takes a destination pointer, not a return value + +```go +// WRONG (old) +total, err := facades.Orm().Query().Sum("amount") + +// CORRECT +var total float64 +err := facades.Orm().Query().Sum("amount", &total) +``` + +--- + +## Controllers / HTTP + +### Always return http.Response: never return nil + +```go +// WRONG: missing return, compile error +func (r *UserController) Show(ctx http.Context) http.Response { + // forgot return +} + +// CORRECT +func (r *UserController) Show(ctx http.Context) http.Response { + return ctx.Response().Json(http.StatusOK, http.Json{"id": 1}) +} +``` + +### Validate all input before using it + +```go +// CORRECT: validate first, use after +func (r *UserController) Store(ctx http.Context) http.Response { + validator, err := ctx.Request().Validate(map[string]string{ + "name": "required|max_len:100", + "email": "required|email", + }) + if err != nil { + return ctx.Response().Json(http.StatusBadRequest, http.Json{"error": err.Error()}) + } + if validator.Fails() { + return ctx.Response().Json(http.StatusUnprocessableEntity, validator.Errors().All()) + } + + var user models.User + if err := validator.Bind(&user); err != nil { + return ctx.Response().Json(http.StatusBadRequest, http.Json{"error": err.Error()}) + } + // use user safely +} +``` + +### Use response.Bind, not Request.Bind + +```go +// WRONG +resp, _ := facades.Http().Get(url) +resp.Request.Bind(&dest) // compile error + +// CORRECT +resp, err := facades.Http().Get(url) +if err != nil { + return +} +if err := resp.Bind(&dest); err != nil { + return +} +``` + +### Use typed input methods to avoid string parsing + +```go +// WRONG: manual type conversion +id := ctx.Request().Input("id") // string +idInt, _ := strconv.Atoi(id) + +// CORRECT: typed methods +id := ctx.Request().RouteInt("id") +page := ctx.Request().QueryInt("page", 1) +active := ctx.Request().InputBool("active") +``` + +### Do not leak internal errors to HTTP responses + +```go +// WRONG: exposes internal detail +if err := facades.Orm().Query().Find(&user, id); err != nil { + return ctx.Response().Json(500, http.Json{"error": err.Error()}) +} + +// CORRECT: log internally, return generic message +if err := facades.Orm().Query().FindOrFail(&user, id); err != nil { + facades.Log().WithContext(ctx.Context()).Error(err) + return ctx.Response().Json(http.StatusNotFound, http.Json{"error": "resource not found"}) +} +``` + +### Set custom panic recovery + +```go +// bootstrap/app.go +WithMiddleware(func(handler configuration.Middleware) { + handler.Append( + middleware.StartSession(), + ).Recover(func(ctx http.Context, err any) { + facades.Log().Error(err) + _ = ctx.Response().String(http.StatusInternalServerError, "internal server error").Abort() + }) +}) +``` + +--- + +## Security + +### Never store raw passwords + +```go +// WRONG +user.Password = req.Password + +// CORRECT +hashed, err := facades.Hash().Make(req.Password) +if err != nil { + return err +} +user.Password = hashed + +// Verify +valid := facades.Hash().Check(req.Password, user.Password) +``` + +### Call Parse before accessing Auth user + +```go +// WRONG: User/ID return empty if Parse not called +userID, _ := facades.Auth(ctx).ID() + +// CORRECT +token := ctx.Request().Header("Authorization") +payload, err := facades.Auth(ctx).Parse(token) +if err != nil { + return ctx.Response().Json(http.StatusUnauthorized, http.Json{"error": "invalid token"}) +} +userID, _ := facades.Auth(ctx).ID() +``` + +### Regenerate session ID after login (prevent session fixation) + +```go +func (r *AuthController) Login(ctx http.Context) http.Response { + // ... authenticate user ... + + facades.Auth(ctx).Login(&user) + + // Must regenerate session ID after login + ctx.Request().Session().Regenerate() + + return ctx.Response().Json(http.StatusOK, http.Json{"message": "logged in"}) +} +``` + +### Use rate limiting on auth endpoints + +```go +// bootstrap/app.go +WithCallback(func() { + facades.RateLimiter().For("login", func(ctx http.Context) http.Limit { + return limit.PerMinute(5).By(ctx.Request().Ip()) + }) +}) + +// routes +facades.Route().Middleware(middleware.Throttle("login")).Post("/login", authController.Login) +``` + +### Use CSRF for web (non-API) routes + +```go +import "github.com/goravel/framework/http/middleware" + +// Global for all web routes +handler.Append(middleware.VerifyCsrfToken([]string{ + "api/*", // except API routes + "webhook/*", // except webhooks +})) +``` + +### Never put secrets in config values directly: use .env + +```go +// WRONG +"api_key": "sk-abc123...", + +// CORRECT +"api_key": config.Env("STRIPE_API_KEY", ""), +``` + +--- + +## Service Container / Providers + +### Only bind in Register: never use facades in Register + +```go +// WRONG: facades not yet booted during Register +func (r *ServiceProvider) Register(app foundation.Application) { + facades.Log().Info("registering") // PANIC: Log not ready + app.Singleton("myservice", func(app foundation.Application) (any, error) { + return NewMyService(), nil + }) +} + +// CORRECT +func (r *ServiceProvider) Register(app foundation.Application) { + app.Singleton("myservice", func(app foundation.Application) (any, error) { + return NewMyService(app.MakeConfig()), nil // use app.Make*, not facades + }) +} + +func (r *ServiceProvider) Boot(app foundation.Application) { + facades.Log().Info("provider booted") // safe here + route := app.MakeRoute() + route.Get("/health", healthController.Check) +} +``` + +### Use Singleton for stateless, Bind for stateful + +```go +// Stateless service (DB connection, config reader) -> Singleton +app.Singleton("db", func(app foundation.Application) (any, error) { + return NewDB(app.MakeConfig()), nil +}) + +// Stateful / per-request -> Bind (new instance each call) +app.Bind("request.context", func(app foundation.Application) (any, error) { + return NewRequestContext(), nil +}) +``` + +### Use WithCallback for post-boot setup + +```go +// Gates, rate limiters, observers: all require facades to be ready +WithCallback(func() { + facades.Gate().Define("edit-post", func(ctx context.Context, args map[string]any) access.Response { + user := ctx.Value("user").(models.User) + post := args["post"].(models.Post) + if user.ID == post.UserID { + return access.NewAllowResponse() + } + return access.NewDenyResponse("forbidden") + }) + + facades.Orm().Observe(&models.User{}, &observers.UserObserver{}) + facades.RateLimiter().For("api", func(ctx http.Context) http.Limit { + return limit.PerMinute(60).By(ctx.Request().Ip()) + }) +}) +``` + +--- + +## Middleware + +### Middleware is a function type, not a struct + +```go +// WRONG +type AuthMiddleware struct{} +func (m *AuthMiddleware) Handle(ctx http.Context, next http.HandlerFunc) http.Response { ... } + +// CORRECT +func Auth() http.Middleware { + return func(ctx http.Context) { + token := ctx.Request().Header("Authorization", "") + if token == "" { + ctx.Request().AbortWithStatus(http.StatusUnauthorized) + return + } + // validate... + } +} +``` + +### Always call next or Abort: never skip both + +```go +func MyMiddleware() http.Middleware { + return func(ctx http.Context) { + if !valid(ctx) { + ctx.Request().AbortWithStatus(http.StatusForbidden) + return // stop here + } + // returning without AbortWithStatus lets the framework continue to the next handler + } +} +``` + +### Register global middleware for app-wide concerns + +```go +// bootstrap/app.go +WithMiddleware(func(handler configuration.Middleware) { + handler.Append( + middleware.Cors(), + sessionmiddleware.StartSession(), + middleware.VerifyCsrfToken([]string{"api/*"}), + ) +}) +``` + +--- + +## Queue / Jobs + +### Make jobs idempotent: safe to run multiple times + +```go +// CORRECT: check before acting +func (r *ProcessOrderJob) Handle(args ...any) error { + order := r.order + if order.Status == "processed" { + return nil // already done, skip + } + // ... process ... + order.Status = "processed" + return facades.Orm().Query().Save(&order) +} +``` + +### Use ShouldRetry to control retry behavior + +```go +func (r *SendEmailJob) ShouldRetry(attempts uint, err error) bool { + // Retry transient errors, not permanent failures + if errors.Is(err, ErrRateLimit) { + return true + } + return attempts < 3 +} +``` + +### Keep jobs focused: one responsibility per job + +```go +// WRONG: job doing too much +func (r *UserRegistrationJob) Handle(args ...any) error { + // send welcome email + create subscription + notify admin + update analytics +} + +// CORRECT: chain focused jobs +facades.Queue().Job(&jobs.SendWelcomeEmail{}, args). + Chain([]queue.Jobs{ + {Job: &jobs.CreateSubscription{}, Args: args}, + {Job: &jobs.NotifyAdmin{}, Args: args}, + }).Dispatch() +``` + +### Don't dispatch events inside open transactions + +```go +// WRONG: if transaction rolls back, event was already dispatched +facades.Orm().Transaction(func(tx orm.Transaction) error { + tx.Create(&order) + facades.Event().Job(&events.OrderCreated{}, args).Dispatch() // too early + return nil +}) + +// CORRECT: dispatch after commit +err := facades.Orm().Transaction(func(tx orm.Transaction) error { + return tx.Create(&order) +}) +if err == nil { + facades.Event().Job(&events.OrderCreated{}, args).Dispatch() +} +``` + +--- + +## Cache + +### Use Remember instead of manual Get + Put + +```go +// WRONG: race condition between Get and Put +val := facades.Cache().Get("key", nil) +if val == nil { + val = expensiveComputation() + facades.Cache().Put("key", val, 5*time.Minute) +} + +// CORRECT: atomic +val, err := facades.Cache().Remember("key", 5*time.Minute, func() (any, error) { + return expensiveComputation(), nil +}) +``` + +### Use atomic locks for distributed mutual exclusion + +```go +lock := facades.Cache().Lock("process:order:"+orderID, 30*time.Second) +if lock.Get() { + defer lock.Release() + // only one server runs this at a time + processOrder(orderID) +} else { + // already being processed elsewhere +} + +// Block and wait (up to 5 seconds) +if lock.Block(5 * time.Second) { + defer lock.Release() + processOrder(orderID) +} +``` + +### Use Store() to be explicit about which store + +```go +// When using multiple cache stores, be explicit +facades.Cache().Store("redis").Put("session:data", data, 1*time.Hour) +facades.Cache().Store("memory").Put("rate:limit:123", count, 1*time.Minute) +``` + +--- + +## Events + +### Keep event Args serializable + +```go +// CORRECT: use typed Args with Type and Value; must survive serialization for queued listeners +facades.Event().Job(&events.OrderShipped{}, []event.Arg{ + {Type: "int", Value: order.ID}, + {Type: "string", Value: order.Status}, +}).Dispatch() + +// WRONG for queued listeners: passing non-serializable objects (functions, channels, etc.) +``` + +### Queue non-critical listeners + +```go +// Sending email on order shipped; should not block the HTTP response +func (r *SendShipmentNotification) Queue(args ...any) event.Queue { + return event.Queue{ + Enable: true, + Connection: "redis", + Queue: "notifications", + } +} +``` + +--- + +## Error Handling + +### Return errors: never suppress them + +```go +// WRONG: swallowing errors hides bugs +result, _ := facades.Cache().Remember("key", ttl, fn) +facades.Orm().Query().Create(&user) // ignoring returned error + +// CORRECT +result, err := facades.Cache().Remember("key", ttl, fn) +if err != nil { + return err +} +if err := facades.Orm().Query().Create(&user); err != nil { + return err +} +``` + +### Log at the boundary: not deep in service layers + +```go +// WRONG: logging at every layer creates duplicate noise +func (s *UserService) Create(data UserData) error { + err := facades.Orm().Query().Create(&user) + facades.Log().Error(err) // logged here... + return err +} + +func (r *UserController) Store(ctx http.Context) http.Response { + err := s.Create(data) + facades.Log().Error(err) // ...and again here +} + +// CORRECT: log once at the outermost boundary (controller/command) +func (r *UserController) Store(ctx http.Context) http.Response { + if err := s.Create(data); err != nil { + facades.Log().WithContext(ctx.Context()). + With(log.Fields{"user": data}). + Error(err) + return ctx.Response().Json(http.StatusInternalServerError, ...) + } +} +``` + +--- + +## Session + +### Use Redis session driver for multi-server deployments + +File sessions are stored locally and won't work across multiple server instances. Use Redis: + +```go +// config/session.go +"default": "redis", +"drivers": map[string]any{ + "redis": map[string]any{ + "driver": "custom", + "connection": "default", + "via": func() (session.Driver, error) { + return redisfacades.Session("redis"), nil + }, + }, +}, +``` + +### Never store sensitive data in sessions + +```go +// WRONG: storing raw credentials or tokens in session +ctx.Request().Session().Put("password", password) +ctx.Request().Session().Put("api_secret", secret) + +// CORRECT: store only the user ID, look up sensitive data from DB/cache on each request +ctx.Request().Session().Put("user_id", user.ID) +``` + +--- + +## Configuration + +### Always use .env for environment-specific values + +```go +// config/app.go +"debug": config.Env("APP_DEBUG", false), +"key": config.Env("APP_KEY", ""), + +// Access in code +debug := facades.Config().GetBool("app.debug", false) +key := facades.Config().GetString("app.key", "") +``` + +### Never hardcode paths: use WithPaths or path helpers + +```go +// WRONG +file, err := os.Open("storage/uploads/photo.jpg") + +// CORRECT +import "github.com/goravel/framework/support/path" +file, err := os.Open(path.Storage("uploads/photo.jpg")) +``` + +### Use facades.Config().Add() to set runtime config + +```go +// Useful in tests or dynamic configuration +facades.Config().Add("service.api_url", "https://staging.api.example.com") +facades.Config().Add("feature.flags", map[string]any{"new_ui": true}) +``` + +--- + +## Testing + +### Use RefreshDatabase in SetupTest for clean state + +```go +func (s *UserTestSuite) SetupTest() { + s.RefreshDatabase() // wipe and re-migrate before each test +} +``` + +### Use Docker for parallel package tests + +```go +// tests/feature/main_test.go +func TestMain(m *testing.M) { + database, _ := facades.Testing().Docker().Database() + database.Build() + database.Ready() + database.Migrate() + facades.App().Restart() + exit := m.Run() + database.Shutdown() + os.Exit(exit) +} +``` + +### Use mock.Factory() in unit tests: never real facades + +```go +// CORRECT: no real DB/cache/mail calls in unit tests +func TestCreateUser(t *testing.T) { + mockFactory := mock.Factory() + mockOrm := mockFactory.Orm() + mockOrmQuery := mockFactory.OrmQuery() + mockOrm.On("Query").Return(mockOrmQuery) + mockOrmQuery.On("Create", mock.Anything).Return(nil).Once() + + err := userService.Create(UserData{Name: "test"}) + assert.Nil(t, err) + mockOrmQuery.AssertExpectations(t) +} +``` + +### Use s.Http() for full integration HTTP tests + +```go +func (s *UserTestSuite) TestStore() { + builder := http.NewBody().SetField("name", "goravel").SetField("email", "test@example.com") + body, _ := builder.Build() + + response, err := s.Http(s.T()). + WithHeader("Content-Type", body.ContentType()). + WithHeader("Accept", "application/json"). + Post("/users", body) + + s.Nil(err) + response.AssertCreated().AssertJson(map[string]any{"name": "goravel"}) +} +``` + +--- + +## Performance + +### Queue slow operations: never block the HTTP response + +```go +// WRONG: sending email synchronously blocks response for 2-5 seconds +func (r *OrderController) Store(ctx http.Context) http.Response { + facades.Mail().To([]string{user.Email}).Content(...).Send() // SLOW + return ctx.Response().Json(http.StatusCreated, order) +} + +// CORRECT: queue it +func (r *OrderController) Store(ctx http.Context) http.Response { + facades.Queue().Job(&jobs.SendOrderConfirmation{}, args).Dispatch() + return ctx.Response().Json(http.StatusCreated, order) +} +``` + +### Tune database connection pool + +```go +// config/database.go +"pool": map[string]any{ + "max_idle_conns": 10, // keep warm connections + "max_open_conns": 100, // cap total connections + "conn_max_idletime": 3600, // close idle after 1h + "conn_max_lifetime": 3600, // recycle connections after 1h +}, +"slow_threshold": 200, // log queries slower than 200ms +``` + +### Cache computed/aggregated data + +```go +// CORRECT: expensive aggregation behind cache +stats, err := facades.Cache().Remember("dashboard:stats", 5*time.Minute, func() (any, error) { + var result DashboardStats + facades.Orm().Query(). + Model(&models.Order{}). + Select("COUNT(*) as count, SUM(total) as revenue"). + Where("created_at > ?", time.Now().AddDate(0, -1, 0)). + First(&result) + return result, nil +}) +``` + +### Use pagination: never return all records in APIs + +```go +// WRONG +var users []models.User +facades.Orm().Query().Find(&users) +return ctx.Response().Json(200, users) // could be millions of rows + +// CORRECT +page := ctx.Request().QueryInt("page", 1) +perPage := ctx.Request().QueryInt("per_page", 20) +var users []models.User +var total int64 +facades.Orm().Query().Paginate(page, perPage, &users, &total) +return ctx.Response().Json(200, http.Json{ + "data": users, + "total": total, + "page": page, +}) +``` + +--- + +## Package Installation + +### Use artisan to install official packages + +```shell +# Installs package, registers service provider, updates config automatically +./artisan package:install github.com/goravel/redis +./artisan package:install github.com/goravel/gin +./artisan package:install github.com/goravel/fiber +./artisan package:install github.com/goravel/postgres +./artisan package:install github.com/goravel/mysql +./artisan package:install github.com/goravel/s3 +./artisan package:install github.com/goravel/minio + +# Publish package resources manually if needed +./artisan vendor:publish --package=github.com/goravel/example-package +./artisan vendor:publish --package=github.com/goravel/example-package --tag=config +./artisan vendor:publish --package=./packages/local-package --force +``` + +### Register process and view service providers + +```go +// bootstrap/providers.go +&process.ServiceProvider{}, // facades.Process() +&view.ServiceProvider{}, // facades.View() +``` + +--- + +## Compile / Deployment + +### Timezone data for non-UTC timezones + +Alpine-based Docker images and scratch containers have no timezone database. If your app uses any non-UTC timezone, provide timezone data using one of these methods: + +```dockerfile +# Option 1: install tzdata in the container image (works with time.LoadLocation) +RUN apk add --no-cache tzdata +``` + +```go +// Option 2: embed timezone data into the binary at compile time +import _ "time/tzdata" +``` + +```shell +# Option 3: equivalent to Option 2 using a build tag instead of an import +go build -tags timetzdata . +``` + +Options 2 and 3 do the same thing. Option 1 keeps the binary smaller but requires the OS package. Option 2/3 makes the binary self-contained. + +### Static compilation for containerless deployment + +```shell +go build --ldflags "-extldflags -static" -o main . + +# Or via artisan +./artisan build --static --os=linux +``` + +### Files required on deployment server + +``` +.env +./main # compiled binary +./public/ # if exists +./resources/ # if exists +``` + +Do not ship `database/migrations/`. Migrations run at startup via `./artisan migrate` or auto-run if configured. diff --git a/.ai/prompt/bootstrap.md b/.ai/prompt/bootstrap.md new file mode 100644 index 000000000..1c797b94f --- /dev/null +++ b/.ai/prompt/bootstrap.md @@ -0,0 +1,402 @@ +# Goravel Bootstrap, Service Container, and Service Providers + +## Bootstrap Entry Point + +```go +// main.go +package main + +import "goravel/bootstrap" + +func main() { + app := bootstrap.Boot() + app.Wait() +} +``` + +```go +// bootstrap/app.go +package bootstrap + +import ( + "github.com/goravel/framework/foundation" + contractsfoundation "github.com/goravel/framework/contracts/foundation" +) + +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithProviders(Providers). + WithConfig(config.Boot). + WithRouting(func() { + routes.Web() + routes.Grpc() + }). + WithMiddleware(func(handler configuration.Middleware) { + handler.Append(middleware.Custom()) + }). + WithCommands(Commands). + WithEvents(func() map[event.Event][]event.Listener { + return map[event.Event][]event.Listener{ + events.NewOrderShipped(): {listeners.NewSendShipmentNotification()}, + } + }). + WithJobs(Jobs). + WithMigrations(Migrations). + WithSeeders(Seeders). + WithSchedule(func() []schedule.Event { + return []schedule.Event{ + facades.Schedule().Call(func() {}).Daily(), + } + }). + WithRules(Rules). + WithFilters(Filters). + WithRunners(func() []foundation.Runner { + return []foundation.Runner{NewCustomRunner()} + }). + WithPaths(func(paths configuration.Paths) { + paths.App("src") // optional: customize directories + paths.Config("configuration") + paths.Database("db") + paths.Routes("api/routes") + paths.Storage("data") + paths.Resources("views-root") + }). + WithCallback(func() { + // runs after all providers Boot(); all facades available here + facades.Gate().Define("update-post", fn) + facades.RateLimiter().For("global", fn) + facades.Orm().Observe(&models.User{}, &observers.UserObserver{}) + }). + Create() +} +``` + +**Boot order inside `Create()`:** +1. Load configuration (`WithConfig`) +2. Register all service providers (calls `Register` on each) +3. Boot all service providers (calls `Boot` on each) +4. Run `WithCallback` +5. Runners start (HTTP server, Queue worker, Schedule, gRPC, etc.) + +--- + +## Directory Paths Are Configurable + +`WithPaths` customizes directory layout. Never assume `app/`, `config/`, etc. are fixed. + +```go +WithPaths(func(paths configuration.Paths) { + paths.App("src") + paths.Config("configuration") + paths.Database("db") + paths.Routes("api/routes") + paths.Storage("data") + paths.Resources("views-root") +}) +``` + +--- + +## Service Container + +### Bind (new instance each call) + +```go +func (r *ServiceProvider) Register(app foundation.Application) { + app.Bind("goravel.route", func(app foundation.Application) (any, error) { + return NewRoute(app.MakeConfig()), nil + }) +} +``` + +### Singleton (same instance every call) + +```go +app.Singleton("goravel.gin", func(app foundation.Application) (any, error) { + return NewGin(app.MakeConfig()), nil +}) +``` + +### Instance (existing object) + +```go +app.Instance("goravel.key", existingInstance) +``` + +### BindWith (bind with extra parameters) + +```go +app.BindWith("goravel.route", func(app foundation.Application, parameters map[string]any) (any, error) { + return NewRoute(app.MakeConfig()), nil +}) +``` + +### Make (resolve from container) + +```go +instance, err := app.Make("goravel.route") + +// from outside a service provider: +instance, err := facades.App().Make("goravel.route") +``` + +### MakeWith (resolve with parameters) + +```go +instance, err := app.MakeWith("goravel.route", map[string]any{"id": 1}) +``` + +### Convenience resolvers + +```go +app.MakeArtisan() +app.MakeAuth(ctx) +app.MakeCache() +app.MakeConfig() +app.MakeRoute() +// etc. +``` + +--- + +## Service Providers + +Create via artisan (auto-registered in `bootstrap/providers.go`): + +```shell +./artisan make:provider YourServiceProvider +``` + +```go +package providers + +import "github.com/goravel/framework/contracts/foundation" + +type YourServiceProvider struct{} + +// Register: only bind into the service container here. +// NEVER register routes, events, or listeners in Register. +func (r *YourServiceProvider) Register(app foundation.Application) { + app.Singleton("custom", func(app foundation.Application) (any, error) { + return New(), nil + }) +} + +// Boot: register routes, event listeners, or any startup logic here. +func (r *YourServiceProvider) Boot(app foundation.Application) {} +``` + +### Dependency Relationship + +```go +import "github.com/goravel/framework/contracts/foundation/binding" + +func (r *ServiceProvider) Relationship() binding.Relationship { + return binding.Relationship{ + Bindings: []string{ + "custom", + }, + Dependencies: []string{ + binding.Config, + }, + ProvideFor: []string{ + binding.Cache, + }, + } +} +``` + +Providers with `Relationship()` are registered in dependency order; those without are registered last. + +--- + +## Runners Interface (v1.17) + +Implement `Runners` in a service provider to auto-start/shutdown services: + +```go +// BREAKING v1.17: register new service providers &process.ServiceProvider{} and &view.ServiceProvider{} + +type Runner interface { + ShouldRun() bool + Run() error + Shutdown() error +} + +// In service provider: +func (r *ServiceProvider) Runners(app foundation.Application) []foundation.Runner { + return []foundation.Runner{NewMyRunner(app.MakeConfig())} +} + +// Runner implementation: +type MyRunner struct { + config config.Config + route route.Route +} + +func NewMyRunner(config config.Config, route route.Route) *MyRunner { + return &MyRunner{config: config, route: route} +} + +func (r *MyRunner) ShouldRun() bool { + return r.route != nil && r.config.GetString("http.default") != "" +} + +func (r *MyRunner) Run() error { + return r.route.Run() +} + +func (r *MyRunner) Shutdown() error { + return r.route.Shutdown() +} +``` + +Custom runners via `WithRunners`: + +```go +WithRunners(func() []foundation.Runner { + return []foundation.Runner{NewCustomRunner()} +}) +``` + +--- + +## Facade Import Pattern + +Facades live in `app/facades/` of your project. Import path depends on `go.mod` module name: + +```go +// go.mod: module github.com/mycompany/myapp + +import "github.com/mycompany/myapp/app/facades" + +facades.Route().Get("/", handler) +facades.Orm().Query().Find(&user, 1) +facades.Auth(ctx).Login(&user) +``` + +Available facades: `App`, `Artisan`, `Auth`, `Cache`, `Config`, `Crypt`, `DB`, `Event`, `Gate`, `Grpc`, `Hash`, `Http`, `Lang`, `Log`, `Mail`, `Orm`, `Process`, `Queue`, `RateLimiter`, `Route`, `Schedule`, `Schema`, `Seeder`, `Session`, `Storage`, `Validation`, `View`. + +Never import `github.com/goravel/framework/facades` — it does not exist. + +--- + +## HTTP Driver Configuration + +### Gin (default) + +```go +// config/http.go +import ( + "github.com/gin-gonic/gin/render" + "github.com/goravel/framework/contracts/route" + "github.com/goravel/gin" + ginfacades "github.com/goravel/gin/facades" +) + +config.Add("http", map[string]any{ + "default": "gin", + "drivers": map[string]any{ + "gin": map[string]any{ + "body_limit": 4096, // KB (default 4096) + "header_limit": 4096, + "route": func() (route.Route, error) { + return ginfacades.Route("gin"), nil + }, + "template": func() (render.HTMLRender, error) { + return gin.DefaultTemplate() + }, + }, + }, + "url": config.Env("APP_URL", "http://localhost"), + "host": config.Env("APP_HOST", "127.0.0.1"), + "port": config.Env("APP_PORT", "3000"), + "request_timeout": 3, // seconds + "tls": map[string]any{ + "host": config.Env("APP_HOST", "127.0.0.1"), + "port": config.Env("APP_PORT", "3000"), + "ssl": map[string]any{ + "cert": "", // path to .pem + "key": "", // path to .key + }, + }, + "default_client": config.Env("HTTP_CLIENT_DEFAULT", "default"), + "clients": map[string]any{ + "default": map[string]any{ + "base_url": config.Env("HTTP_CLIENT_BASE_URL", ""), + "timeout": config.Env("HTTP_CLIENT_TIMEOUT", "30s"), + "max_idle_conns": config.Env("HTTP_CLIENT_MAX_IDLE_CONNS", 100), + "max_idle_conns_per_host": config.Env("HTTP_CLIENT_MAX_IDLE_CONNS_PER_HOST", 2), + "max_conns_per_host": config.Env("HTTP_CLIENT_MAX_CONN_PER_HOST", 0), + "idle_conn_timeout": config.Env("HTTP_CLIENT_IDLE_CONN_TIMEOUT", "90s"), + }, + }, +}) +``` + +### Fiber + +```go +// config/http.go +import ( + "github.com/gofiber/fiber/v2" + "github.com/gofiber/template/html/v2" + "github.com/goravel/framework/contracts/route" + "github.com/goravel/framework/support/path" + fiberfacades "github.com/goravel/fiber/facades" +) + +config.Add("http", map[string]any{ + "default": "fiber", + "drivers": map[string]any{ + "fiber": map[string]any{ + "immutable": true, // zero-allocation mode; understand before disabling + "prefork": false, + "body_limit": 4096, + "header_limit": 4096, + "route": func() (route.Route, error) { + return fiberfacades.Route("fiber"), nil + }, + "template": func() (fiber.Views, error) { + return html.New(path.Resource("views"), ".tmpl"), nil + }, + }, + }, + // url/host/port/tls/clients same as Gin config +}) +``` + +Install drivers: + +```shell +./artisan package:install github.com/goravel/gin +./artisan package:install github.com/goravel/fiber +``` + +--- + +## WithCallback Pattern + +Use `WithCallback` for any code that requires all facades to be available: + +```go +WithCallback(func() { + // Gates + facades.Gate().Define("edit-post", func(ctx context.Context, args map[string]any) contractsaccess.Response { + user := ctx.Value("user").(models.User) + post := args["post"].(models.Post) + if user.ID == post.UserID { + return access.NewAllowResponse() + } + return access.NewDenyResponse("forbidden") + }) + + // Rate limiters + facades.RateLimiter().For("api", func(ctx contractshttp.Context) contractshttp.Limit { + return limit.PerMinute(60).By(ctx.Request().Ip()) + }) + + // ORM observers + facades.Orm().Observe(&models.User{}, &observers.UserObserver{}) +}) +``` diff --git a/.ai/prompt/cache.md b/.ai/prompt/cache.md new file mode 100644 index 000000000..4eec6e218 --- /dev/null +++ b/.ai/prompt/cache.md @@ -0,0 +1,254 @@ +# Goravel Cache + +## Configuration + +Full `config/cache.go`: + +```go +// config/cache.go +config.Add("cache", map[string]any{ + "default": "memory", // driver name to use + + // Cache stores + // Available built-in drivers: "memory" + // External: goravel/redis → "custom" + "stores": map[string]any{ + "memory": map[string]any{ + "driver": "memory", + }, + // Redis store (requires goravel/redis package): + // "redis": map[string]any{ + // "driver": "custom", + // "connection": "default", + // "via": func() (cache.Driver, error) { + // return redisfacades.Cache("redis"), nil + // }, + // }, + }, + + // Cache key prefix (must match: a-zA-Z0-9_-) + "prefix": config.GetString("APP_NAME", "goravel") + "_cache", +}) +``` + +### Redis Cache Driver + +Install `goravel/redis`: + +```shell +./artisan package:install github.com/goravel/redis +``` + +```go +// config/cache.go +import ( + "github.com/goravel/framework/contracts/cache" + redisfacades "github.com/goravel/redis/facades" +) + +"default": "redis", + +"stores": map[string]any{ + "redis": map[string]any{ + "driver": "custom", + "connection": "default", + "via": func() (cache.Driver, error) { + return redisfacades.Cache("redis"), nil + }, + }, +}, +``` + +Redis connection in `config/database.go`: + +```go +"redis": map[string]any{ + "default": map[string]any{ + "host": config.Env("REDIS_HOST", "127.0.0.1"), + "password": config.Env("REDIS_PASSWORD", ""), + "port": config.Env("REDIS_PORT", 6379), + "database": config.Env("REDIS_DB", 0), + }, +}, +``` + +Default driver: `memory`. Redis driver available via `github.com/goravel/redis`. + +--- + +## Basic Usage + +### Inject context + +```go +facades.Cache().WithContext(ctx) +``` + +### Multiple stores + +```go +value := facades.Cache().Store("redis").Get("foo") +``` + +--- + +## Get + +```go +value := facades.Cache().Get("goravel", "default") +value := facades.Cache().GetBool("goravel", true) +value := facades.Cache().GetInt("goravel", 1) +value := facades.Cache().GetString("goravel", "default") + +// Closure default +value = facades.Cache().Get("goravel", func() any { + return "computed-default" +}) +``` + +--- + +## Check + +```go +exists := facades.Cache().Has("goravel") +``` + +--- + +## Put (store) + +```go +// With TTL +err := facades.Cache().Put("goravel", "value", 5*time.Second) + +// Forever (TTL = 0 or use Forever) +err = facades.Cache().Put("goravel", "value", 0) +ok := facades.Cache().Forever("goravel", "value") +``` + +--- + +## Add (only if not present) + +```go +ok := facades.Cache().Add("goravel", "value", 5*time.Second) +// true if stored, false if key already existed +``` + +--- + +## Retrieve & Store + +```go +value, err := facades.Cache().Remember("goravel", 5*time.Second, func() (any, error) { + return "goravel", nil +}) + +value, err = facades.Cache().RememberForever("goravel", func() (any, error) { + return "default", nil +}) +``` + +--- + +## Pull (retrieve and delete) + +```go +value := facades.Cache().Pull("goravel", "default") +``` + +--- + +## Increment / Decrement + +```go +facades.Cache().Increment("key") +facades.Cache().Increment("key", 5) +facades.Cache().Decrement("key") +facades.Cache().Decrement("key", 5) +``` + +--- + +## Delete + +```go +ok := facades.Cache().Forget("goravel") +ok = facades.Cache().Flush() +``` + +--- + +## Atomic Locks + +```go +// Acquire lock +lock := facades.Cache().Lock("foo", 10*time.Second) + +if lock.Get() { + // lock acquired for 10 seconds + lock.Release() +} + +// Closure (auto-released) +facades.Cache().Lock("foo", 10*time.Second).Get(func() { + // lock held here, auto-released after +}) + +// Block (wait up to 5 seconds) +lock = facades.Cache().Lock("foo", 10*time.Second) +if lock.Block(5 * time.Second) { + lock.Release() +} + +// Block with closure +facades.Cache().Lock("foo", 10*time.Second).Block(5*time.Second, func() { + // runs when lock acquired +}) + +// Force release (regardless of owner) +facades.Cache().Lock("processing").ForceRelease() +``` + +--- + +## Custom Cache Driver + +```go +// config/cache.go +"stores": map[string]interface{}{ + "memory": map[string]any{ + "driver": "memory", + }, + "custom": map[string]interface{}{ + "driver": "custom", + "via": &MyDriver{}, + }, +}, +``` + +Implement `contracts/cache/Driver`: + +```go +type Driver interface { + Add(key string, value any, t time.Duration) bool + Decrement(key string, value ...int) (int, error) + Forever(key string, value any) bool + Forget(key string) bool + Flush() bool + Get(key string, def ...any) any + GetBool(key string, def ...bool) bool + GetInt(key string, def ...int) int + GetInt64(key string, def ...int64) int64 + GetString(key string, def ...string) string + Has(key string) bool + Increment(key string, value ...int) (int, error) + Lock(key string, t ...time.Duration) Lock + Put(key string, value any, t time.Duration) error + Pull(key string, def ...any) any + Remember(key string, ttl time.Duration, callback func() (any, error)) (any, error) + RememberForever(key string, callback func() (any, error)) (any, error) + WithContext(ctx context.Context) Driver +} +``` diff --git a/.ai/prompt/controller.md b/.ai/prompt/controller.md new file mode 100644 index 000000000..b4a1663cb --- /dev/null +++ b/.ai/prompt/controller.md @@ -0,0 +1,316 @@ +# Goravel Controllers, Requests, and Responses + +## Controller Structure + +```go +package controllers + +import ( + "github.com/goravel/framework/contracts/http" + + "goravel/app/facades" +) + +type UserController struct { + // inject services +} + +func NewUserController() *UserController { + return &UserController{} +} + +// Every handler must return http.Response +func (r *UserController) Show(ctx http.Context) http.Response { + return ctx.Response().Success().Json(http.Json{ + "Hello": "Goravel", + }) +} +``` + +Generate via artisan: + +```shell +./artisan make:controller UserController +./artisan make:controller user/UserController +./artisan make:controller --resource PhotoController +``` + +Register in routes: + +```go +package routes + +import ( + "goravel/app/facades" + "goravel/app/http/controllers" +) + +func Api() { + userController := controllers.NewUserController() + facades.Route().Get("/{id}", userController.Show) +} +``` + +--- + +## Resource Controllers + +```go +facades.Route().Resource("photos", controllers.NewPhotoController()) + +// Generated actions: +// GET /photos → Index +// POST /photos → Store +// GET /photos/{photo} → Show +// PUT /photos/{photo} → Update +// DELETE /photos/{photo} → Destroy +``` + +--- + +## Request Input + +### Route parameters + +```go +// /users/{id} +id := ctx.Request().Route("id") +id := ctx.Request().RouteInt("id") +id := ctx.Request().RouteInt64("id") +``` + +### Query string + +```go +// /users?name=goravel +name := ctx.Request().Query("name") +name := ctx.Request().Query("name", "default") +id := ctx.Request().QueryInt("id") +id := ctx.Request().QueryInt64("id") +flag := ctx.Request().QueryBool("flag") + +// /users?names=a&names=b +names := ctx.Request().QueryArray("names") + +// /users?names[a]=goravel1&names[b]=goravel2 +names := ctx.Request().QueryMap("names") + +queries := ctx.Request().Queries() +``` + +### JSON/form input + +```go +name := ctx.Request().Input("name") +name := ctx.Request().Input("name", "default") +name := ctx.Request().InputInt("name") +name := ctx.Request().InputInt64("name") +name := ctx.Request().InputBool("name") +name := ctx.Request().InputArray("name") +name := ctx.Request().InputMap("name") +name := ctx.Request().InputMapArray("name") + +// All input (json + form + query, priority: json > form > query) +data := ctx.Request().All() +``` + +### Bind JSON/form to struct + +```go +type User struct { + Name string `form:"name" json:"name"` +} + +var user User +err := ctx.Request().Bind(&user) + +var data map[string]any +err := ctx.Request().Bind(&data) +``` + +### Bind query to struct + +```go +type Filter struct { + ID string `form:"id"` +} +var filter Filter +err := ctx.Request().BindQuery(&filter) +``` + +--- + +## Request Metadata + +```go +path := ctx.Request().Path() // /users/1 +originPath := ctx.Request().OriginPath() // /users/{id} +url := ctx.Request().Url() // /users?name=Goravel +fullUrl := ctx.Request().FullUrl() // http://host/users?name=Goravel +host := ctx.Request().Host() +method := ctx.Request().Method() +ip := ctx.Request().Ip() +info := ctx.Request().Info() +name := ctx.Request().Name() +header := ctx.Request().Header("X-Header-Name", "default") +headers := ctx.Request().Headers() +``` + +--- + +## File Upload + +```go +file, err := ctx.Request().File("file") +files, err := ctx.Request().Files("file") + +// Save file +file.Store("./public") +``` + +--- + +## Context Data + +```go +// Set +ctx.WithValue("user", "Goravel") + +// Get +user := ctx.Value("user") + +// Get stdlib context +c := ctx.Context() +``` + +--- + +## Cookie + +```go +value := ctx.Request().Cookie("name") +value := ctx.Request().Cookie("name", "default") +``` + +--- + +## Response + +### String + +```go +return ctx.Response().String(http.StatusOK, "Hello Goravel") +``` + +### JSON + +```go +return ctx.Response().Json(http.StatusOK, http.Json{ + "Hello": "Goravel", +}) + +return ctx.Response().Json(http.StatusOK, struct { + ID uint `json:"id"` + Name string `json:"name"` +}{ID: 1, Name: "Goravel"}) +``` + +### Success shorthand (200) + +```go +return ctx.Response().Success().String("Hello Goravel") +return ctx.Response().Success().Json(http.Json{"Hello": "Goravel"}) +``` + +### Custom status + +```go +return ctx.Response().Status(http.StatusCreated).Json(http.Json{"id": 1}) +``` + +### Raw data + +```go +return ctx.Response().Data(http.StatusOK, "text/html; charset=utf-8", []byte("Goravel")) +``` + +### File & download + +```go +return ctx.Response().File("./public/logo.png") +return ctx.Response().Download("./public/logo.png", "logo.png") +``` + +### Header + +```go +return ctx.Response().Header("X-Custom", "value").String(http.StatusOK, "ok") +``` + +### Cookie + +```go +import "time" + +ctx.Response().Cookie(http.Cookie{ + Name: "name", + Value: "Goravel", + Path: "/", + Domain: "goravel.dev", + Expires: time.Now().Add(24 * time.Hour), + Secure: true, + HttpOnly: true, +}) + +ctx.Response().WithoutCookie("name") +``` + +### Stream (SSE / chunked) + +```go +return ctx.Response().Stream(http.StatusOK, func(w http.StreamWriter) error { + for _, item := range []string{"a", "b", "c"} { + if _, err := w.Write([]byte(item + "\n")); err != nil { + return err + } + if err := w.Flush(); err != nil { + return err + } + time.Sleep(1 * time.Second) + } + return nil +}) +``` + +### Redirect + +```go +return ctx.Response().Redirect(http.StatusMovedPermanently, "https://goravel.dev") +``` + +### No content + +```go +return ctx.Response().NoContent() +return ctx.Response().NoContent(http.StatusResetContent) +``` + +### Inspect response in middleware + +```go +origin := ctx.Response().Origin() +// origin.Body() — response bytes +// origin.Header() — headers +// origin.Status() — status code +// origin.Size() — body size +``` + +--- + +## Abort Request (in middleware/handler) + +```go +ctx.Request().Abort() +ctx.Request().Abort(http.StatusNotFound) +ctx.Response().String(http.StatusForbidden, "forbidden").Abort() +``` diff --git a/.ai/prompt/controllers.md b/.ai/prompt/controllers.md new file mode 100644 index 000000000..d667de484 --- /dev/null +++ b/.ai/prompt/controllers.md @@ -0,0 +1,343 @@ +# Goravel Controllers, Requests, and Responses + +## Controller Definition + +Controllers live in `app/http/controllers/`. + +```go +package controllers + +import ( + "github.com/goravel/framework/contracts/http" + + "goravel/app/facades" +) + +type UserController struct{} + +func NewUserController() *UserController { + return &UserController{} +} + +func (r *UserController) Show(ctx http.Context) http.Response { + return ctx.Response().Success().Json(http.Json{ + "Hello": "Goravel", + }) +} +``` + +Register in route: + +```go +package routes + +import ( + "goravel/app/facades" + "goravel/app/http/controllers" +) + +func Api() { + userController := controllers.NewUserController() + facades.Route().Get("/users/{id}", userController.Show) + facades.Route().Post("/users", userController.Store) +} +``` + +### Generate controller + +```shell +./artisan make:controller UserController +./artisan make:controller user/UserController +./artisan make:controller --resource PhotoController +``` + +--- + +## Request - Reading Input + +### Route parameters + +```go +// /users/{id} +id := ctx.Request().Route("id") +id := ctx.Request().RouteInt("id") +id := ctx.Request().RouteInt64("id") +``` + +### Query string + +```go +// /users?name=goravel +name := ctx.Request().Query("name") +name := ctx.Request().Query("name", "default") + +id := ctx.Request().QueryInt("id") +id := ctx.Request().QueryInt64("id") +flag := ctx.Request().QueryBool("flag") + +// /users?names=a&names=b +names := ctx.Request().QueryArray("names") + +// /users?names[a]=1&names[b]=2 +names := ctx.Request().QueryMap("names") + +all := ctx.Request().Queries() +``` + +### Body / form input + +Reads from JSON body or form data (priority: json, then form): + +```go +name := ctx.Request().Input("name") +name := ctx.Request().Input("name", "default") +age := ctx.Request().InputInt("age") +age := ctx.Request().InputInt64("age") +flag := ctx.Request().InputBool("flag") +tags := ctx.Request().InputArray("tags") +meta := ctx.Request().InputMap("meta") +meta := ctx.Request().InputMapArray("meta") +``` + +### All input + +```go +data := ctx.Request().All() // map[string]any combining json + form + query +``` + +### Bind to struct + +```go +type CreateUserRequest struct { + Name string `form:"name" json:"name"` + Email string `form:"email" json:"email"` +} + +var req CreateUserRequest +err := ctx.Request().Bind(&req) +``` + +```go +var data map[string]any +err := ctx.Request().Bind(&data) +``` + +### Bind query to struct + +```go +type SearchRequest struct { + Query string `form:"q"` + Page string `form:"page"` +} + +var req SearchRequest +err := ctx.Request().BindQuery(&req) +``` + +--- + +## Request - Metadata + +```go +path := ctx.Request().Path() // /users/1 +origin := ctx.Request().OriginPath() // /users/{id} +url := ctx.Request().Url() // /users?name=goravel +host := ctx.Request().Host() +fullUrl := ctx.Request().FullUrl() // http://example.com/users?name=goravel +method := ctx.Request().Method() +ip := ctx.Request().Ip() +header := ctx.Request().Header("X-Token", "default") +headers := ctx.Request().Headers() +name := ctx.Request().Name() // named route +``` + +--- + +## Request - Files + +```go +file, err := ctx.Request().File("avatar") +files, err := ctx.Request().Files("photos") + +// Save to disk +file.Store("./public/uploads") +``` + +--- + +## Request - Cookies + +```go +value := ctx.Request().Cookie("name") +value := ctx.Request().Cookie("name", "default") +``` + +--- + +## Request - Context Values + +```go +// Set +ctx.WithValue("user", userObj) + +// Get +user := ctx.Value("user") + +// Standard context +stdCtx := ctx.Context() +``` + +--- + +## Request - Abort + +```go +ctx.Request().Abort() +ctx.Request().Abort(http.StatusUnauthorized) +ctx.Request().AbortWithStatus(http.StatusForbidden) +``` + +--- + +## Response - String + +```go +return ctx.Response().String(http.StatusOK, "Hello Goravel") +``` + +## Response - JSON + +```go +return ctx.Response().Json(http.StatusOK, http.Json{ + "name": "Goravel", + "id": 1, +}) + +// Struct +return ctx.Response().Json(http.StatusOK, struct { + ID uint `json:"id"` + Name string `json:"name"` +}{ID: 1, Name: "Goravel"}) +``` + +## Response - Success shorthand (200) + +```go +return ctx.Response().Success().String("Hello") +return ctx.Response().Success().Json(http.Json{"key": "value"}) +``` + +## Response - Custom status + +```go +return ctx.Response().Status(http.StatusCreated).Json(http.Json{ + "id": 1, +}) +``` + +## Response - Custom data + +```go +return ctx.Response().Data(http.StatusOK, "text/html; charset=utf-8", []byte("Goravel")) +``` + +## Response - File / Download + +```go +return ctx.Response().File("./public/logo.png") +return ctx.Response().Download("./public/report.pdf", "report.pdf") +``` + +## Response - Headers + +```go +return ctx.Response().Header("X-Custom-Header", "value").Json(http.StatusOK, http.Json{}) +``` + +## Response - Cookie + +```go +import "time" + +return ctx.Response().Cookie(http.Cookie{ + Name: "session", + Value: "abc123", + Path: "/", + Domain: "example.com", + Expires: time.Now().Add(24 * time.Hour), + Secure: true, + HttpOnly: true, +}).Json(http.StatusOK, http.Json{}) +``` + +Remove a cookie: + +```go +return ctx.Response().WithoutCookie("session").String(http.StatusOK, "ok") +``` + +## Response - Stream + +```go +return ctx.Response().Stream(http.StatusOK, func(w http.StreamWriter) error { + items := []string{"a", "b", "c"} + for _, item := range items { + if _, err := w.Write([]byte(item + "\n")); err != nil { + return err + } + if err := w.Flush(); err != nil { + return err + } + time.Sleep(1 * time.Second) + } + return nil +}) +``` + +## Response - Redirect + +```go +return ctx.Response().Redirect(http.StatusMovedPermanently, "https://goravel.dev") +``` + +## Response - No Content + +```go +return ctx.Response().NoContent() +return ctx.Response().NoContent(http.StatusNoContent) +``` + +## Response - Abort inside middleware + +```go +return ctx.Response().String(http.StatusUnauthorized, "unauthorized").Abort() +``` + +--- + +## Custom Recovery (panic handler) + +Set in `bootstrap/app.go`: + +```go +import ( + contractshttp "github.com/goravel/framework/contracts/http" + configuration "github.com/goravel/framework/contracts/foundation/configuration" +) + +WithMiddleware(func(handler configuration.Middleware) { + handler.Recover(func(ctx contractshttp.Context, err any) { + facades.Log().Error(err) + _ = ctx.Response().String(contractshttp.StatusInternalServerError, "internal error").Abort() + }) +}) +``` + +--- + +## Gotchas + +- Always return `ctx.Response()...` from controller methods. Returning without calling `ctx.Response()` leaves the response empty. +- `Input()` reads JSON body or form; `Query()` reads query string only. They do not overlap. +- `Bind()` only binds JSON body or form data. Use `BindQuery()` for query strings. +- `form` fields are always `string` type when bound. Use JSON if you need non-string types. diff --git a/.ai/prompt/event.md b/.ai/prompt/event.md new file mode 100644 index 000000000..2a1fb0b5d --- /dev/null +++ b/.ai/prompt/event.md @@ -0,0 +1,134 @@ +# Goravel Events and Listeners + +## Registration + +Register events and listeners in `WithEvents` in `bootstrap/app.go`: + +```go +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithEvents(func() map[event.Event][]event.Listener { + return map[event.Event][]event.Listener{ + events.NewOrderShipped(): { + listeners.NewSendShipmentNotification(), + listeners.NewLogOrderShipped(), + }, + } + }). + WithConfig(config.Boot). + Create() +} +``` + +Generate via artisan: + +```shell +./artisan make:event OrderShipped +./artisan make:event user/OrderShipped + +./artisan make:listener SendShipmentNotification +./artisan make:listener user/SendShipmentNotification +``` + +--- + +## Define an Event + +```go +package events + +import "github.com/goravel/framework/contracts/event" + +type OrderShipped struct{} + +// Handle transforms args before passing to listeners +func (r *OrderShipped) Handle(args []event.Arg) ([]event.Arg, error) { + return args, nil +} +``` + +--- + +## Define a Listener + +```go +package listeners + +import "github.com/goravel/framework/contracts/event" + +type SendShipmentNotification struct{} + +func (r *SendShipmentNotification) Signature() string { + return "send_shipment_notification" +} + +// Queue controls async execution +func (r *SendShipmentNotification) Queue(args ...any) event.Queue { + return event.Queue{ + Enable: false, // true to run in queue + Connection: "", + Queue: "", + } +} + +// Handle receives args returned by event.Handle +func (r *SendShipmentNotification) Handle(args ...any) error { + name := args[0] + _ = name + return nil +} +``` + +Returning an error from `Handle` stops propagation to subsequent listeners. + +--- + +## Queued Listener + +```go +func (r *SendShipmentNotification) Queue(args ...any) event.Queue { + return event.Queue{ + Enable: true, + Connection: "redis", + Queue: "notifications", + } +} +``` + +--- + +## Dispatch an Event + +```go +import ( + "github.com/goravel/framework/contracts/event" + "goravel/app/events" + "goravel/app/facades" +) + +err := facades.Event().Job(&events.OrderShipped{}, []event.Arg{ + {Type: "string", Value: "Goravel"}, + {Type: "int", Value: 1}, +}).Dispatch() +``` + +--- + +## Supported `event.Arg.Type` Values + +``` +bool, int, int8, int16, int32, int64, +uint, uint8, uint16, uint32, uint64, +float32, float64, string, +[]bool, []int, []int8, []int16, []int32, []int64, +[]uint, []uint8, []uint16, []uint32, []uint64, +[]float32, []float64, []string +``` + +--- + +## Gotchas + +- Queued listeners run outside database transactions. If your listener reads data written in the same transaction, the transaction may not have committed yet when the listener runs. +- Returning an error from `Handle` in a listener stops propagation to subsequent listeners. +- Each `NewOrderShipped()` call creates a new event instance — the map key is the instance used for type matching. diff --git a/.ai/prompt/events.md b/.ai/prompt/events.md new file mode 100644 index 000000000..f682e31f4 --- /dev/null +++ b/.ai/prompt/events.md @@ -0,0 +1,201 @@ +# Goravel Events + +## Register Events and Listeners + +All events and listeners are registered in `bootstrap/app.go` via `WithEvents`: + +```go +import ( + "github.com/goravel/framework/contracts/event" + "goravel/app/events" + "goravel/app/listeners" +) + +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithEvents(func() map[event.Event][]event.Listener { + return map[event.Event][]event.Listener{ + events.NewOrderShipped(): { + listeners.NewSendShipmentNotification(), + listeners.NewUpdateInventory(), + }, + events.NewUserRegistered(): { + listeners.NewSendWelcomeEmail(), + }, + } + }). + WithConfig(config.Boot). + Create() +} +``` + +--- + +## Defining Events + +Events live in `app/events/`. An event holds and transforms data passed to listeners. + +```go +package events + +import "github.com/goravel/framework/contracts/event" + +type OrderShipped struct{} + +func NewOrderShipped() *OrderShipped { + return &OrderShipped{} +} + +func (r *OrderShipped) Handle(args []event.Arg) ([]event.Arg, error) { + // Transform args before passing to listeners, or return as-is + return args, nil +} +``` + +### Generate event + +```shell +./artisan make:event OrderShipped +./artisan make:event user/OrderShipped +``` + +--- + +## Defining Listeners + +Listeners live in `app/listeners/`. + +```go +package listeners + +import "github.com/goravel/framework/contracts/event" + +type SendShipmentNotification struct{} + +func NewSendShipmentNotification() *SendShipmentNotification { + return &SendShipmentNotification{} +} + +func (r *SendShipmentNotification) Signature() string { + return "send_shipment_notification" +} + +func (r *SendShipmentNotification) Queue(args ...any) event.Queue { + return event.Queue{ + Enable: false, + Connection: "", + Queue: "", + } +} + +func (r *SendShipmentNotification) Handle(args ...any) error { + // args are the []event.Arg values returned by the event's Handle method + return nil +} +``` + +### Generate listener + +```shell +./artisan make:listener SendShipmentNotification +./artisan make:listener user/SendShipmentNotification +``` + +--- + +## Dispatching Events + +```go +package controllers + +import ( + "github.com/goravel/framework/contracts/event" + contractshttp "github.com/goravel/framework/contracts/http" + + "goravel/app/events" + "goravel/app/facades" +) + +type OrderController struct{} + +func (r *OrderController) Ship(ctx contractshttp.Context) contractshttp.Response { + err := facades.Event().Job(&events.OrderShipped{}, []event.Arg{ + {Type: "string", Value: "order-123"}, + {Type: "int", Value: 1}, + }).Dispatch() + + if err != nil { + return ctx.Response().String(500, "dispatch failed") + } + + return ctx.Response().Success().Json(contractshttp.Json{"status": "shipped"}) +} +``` + +--- + +## Queued Listeners + +Set `Enable: true` in the `Queue` method to run the listener asynchronously via the queue: + +```go +func (r *SendShipmentNotification) Queue(args ...any) event.Queue { + return event.Queue{ + Enable: true, + Connection: "redis", + Queue: "notifications", + } +} +``` + +Use an empty string for `Connection` and `Queue` to use defaults. + +### Reading args in Handle + +Args are positional, matching the `[]event.Arg` slice dispatched: + +```go +func (r *SendShipmentNotification) Handle(args ...any) error { + orderID := args[0].(string) + userID := args[1].(int) + // send notification + return nil +} +``` + +--- + +## Stop Event Propagation + +Return an error from `Handle` to stop propagation to subsequent listeners: + +```go +func (r *SendShipmentNotification) Handle(args ...any) error { + // returning a non-nil error stops further listeners + return errors.New("stop propagation") +} +``` + +--- + +## Supported Arg Types + +``` +bool, int, int8, int16, int32, int64 +uint, uint8, uint16, uint32, uint64 +float32, float64 +string +[]bool, []int, []int8, []int16, []int32, []int64 +[]uint, []uint8, []uint16, []uint32, []uint64 +[]float32, []float64 +[]string +``` + +--- + +## Gotchas + +- Events must be registered in `WithEvents` before they can be dispatched. Dispatching an unregistered event silently does nothing. +- `Type` in `event.Arg` must be an exact string from the supported list. +- When a queued listener is dispatched inside a database transaction, the queue may process the listener before the transaction commits. Place event dispatches outside transactions if listeners read the data written by the transaction. +- Returning a non-nil error from a listener's `Handle` stops propagation to subsequent listeners for that event. diff --git a/.ai/prompt/facades.md b/.ai/prompt/facades.md new file mode 100644 index 000000000..66dcdf4de --- /dev/null +++ b/.ai/prompt/facades.md @@ -0,0 +1,338 @@ +# Goravel Facades, Service Container, and Service Providers + +## Facade Import Path + +Facades are defined in `app/facades/` inside your project. The import path is: + +```go +// go.mod: module goravel +import "goravel/app/facades" + +facades.Route().Get("/", handler) +facades.Orm().Query().Find(&user, 1) +facades.Config().GetString("app.name") +``` + +The module name in `go.mod` determines the import path. Never use `github.com/goravel/framework/app/facades`. + +--- + +## How Facades Work + +Each facade calls a `Make*` method on the application's service container to retrieve the registered instance: + +```go +// app/facades/route.go +package facades + +import "github.com/goravel/framework/contracts/route" + +func Route() route.Route { + return App().MakeRoute() +} +``` + +--- + +## Creating a Custom Facade + +1. Register a binding in a service provider +2. Create a facade function in `app/facades/` + +```go +// app/facades/payment.go +package facades + +import "goravel/app/contracts" + +func Payment() contracts.PaymentService { + instance, err := App().Make("goravel.payment") + if err != nil { + panic(err) + } + return instance.(contracts.PaymentService) +} +``` + +--- + +## Service Container + +The service container manages bindings and dependencies. + +### Bind (creates a new instance each call) + +```go +app.Bind("goravel.payment", func(app foundation.Application) (any, error) { + return NewPaymentService(app.MakeConfig()), nil +}) +``` + +### Singleton (creates once, returns same instance after) + +```go +app.Singleton("goravel.payment", func(app foundation.Application) (any, error) { + return NewPaymentService(app.MakeConfig()), nil +}) +``` + +### Instance (bind an already-created object) + +```go +app.Instance("goravel.payment", existingInstance) +``` + +### BindWith (bind with extra parameters) + +```go +app.BindWith("goravel.payment", func(app foundation.Application, parameters map[string]any) (any, error) { + return NewPaymentService(parameters["apiKey"].(string)), nil +}) +``` + +### Resolve + +```go +// Inside a service provider +instance, err := app.Make("goravel.payment") + +// Outside service providers (via App facade) +instance, err := facades.App().Make("goravel.payment") + +// With parameters (matches BindWith) +instance, err := app.MakeWith("goravel.payment", map[string]any{"apiKey": "sk-xxx"}) +``` + +### Framework convenience methods + +```go +app.MakeConfig() +app.MakeRoute() +app.MakeOrm() +app.MakeAuth(ctx) +app.MakeLog() +// and others +``` + +--- + +## Service Providers + +### Create a service provider + +```shell +./artisan make:provider PaymentServiceProvider +``` + +Generated providers auto-register in `bootstrap/providers.go`. + +### Provider structure + +```go +package providers + +import ( + "github.com/goravel/framework/contracts/foundation" + "goravel/app/facades" +) + +type PaymentServiceProvider struct{} + +func (r *PaymentServiceProvider) Register(app foundation.Application) { + // Only bind into the container here + // Never register routes, events, listeners here + app.Singleton("goravel.payment", func(app foundation.Application) (any, error) { + return NewPaymentService(app.MakeConfig()), nil + }) +} + +func (r *PaymentServiceProvider) Boot(app foundation.Application) { + // Runs after all providers are registered + // Safe to use facades and other bindings here + facades.Route().Get("/payment", paymentController.Index) +} +``` + +### Register providers + +Providers auto-register when created via artisan. Manual registration in `bootstrap/app.go`: + +```go +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithProviders(providers.Providers). + WithConfig(config.Boot). + Create() +} +``` + +--- + +## Dependency Relationships Between Providers + +Use the optional `Relationship` method to declare explicit dependencies: + +```go +import "github.com/goravel/framework/contracts/foundation/binding" + +type ServiceProvider struct{} + +func (r *ServiceProvider) Relationship() binding.Relationship { + return binding.Relationship{ + Bindings: []string{ + "custom", // what this provider registers + }, + Dependencies: []string{ + binding.Config, // must be registered before this + }, + ProvideFor: []string{ + binding.Cache, // this provider's bindings can be used by Cache + }, + } +} + +func (r *ServiceProvider) Register(app foundation.Application) { + app.Singleton("custom", func(app foundation.Application) (any, error) { + return New() + }) +} + +func (r *ServiceProvider) Boot(app foundation.Application) {} +``` + +Providers that implement `Relationship` are sorted by dependency graph. Providers without it run last. + +--- + +## Runners + +Service providers can implement `Runners` to start and stop background services: + +```go +type ServiceProvider struct{} + +func (r *ServiceProvider) Register(app foundation.Application) {} + +func (r *ServiceProvider) Boot(app foundation.Application) {} + +func (r *ServiceProvider) Runners(app foundation.Application) []foundation.Runner { + return []foundation.Runner{ + NewMyServiceRunner(app.MakeConfig()), + } +} +``` + +Runner interface: + +```go +type Runner interface { + ShouldRun() bool + Run() error + Shutdown() error +} +``` + +Example runner: + +```go +type MyServiceRunner struct { + config config.Config +} + +func NewMyServiceRunner(config config.Config) *MyServiceRunner { + return &MyServiceRunner{config: config} +} + +func (r *MyServiceRunner) ShouldRun() bool { + return r.config.GetBool("myservice.enabled") +} + +func (r *MyServiceRunner) Run() error { + // start the service + return nil +} + +func (r *MyServiceRunner) Shutdown() error { + // graceful shutdown + return nil +} +``` + +Add runners in `bootstrap/app.go`: + +```go +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithConfig(config.Boot). + WithRunners(func() []contractsfoundation.Runner { + return []contractsfoundation.Runner{ + NewMyServiceRunner(), + } + }). + Create() +} +``` + +--- + +## WithCallback + +Code in `WithCallback` runs after all providers have been registered and booted. All facades are available: + +```go +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithConfig(config.Boot). + WithCallback(func() { + // Register gates + facades.Gate().Define("update-post", policyFn) + + // Register rate limiters + facades.RateLimiter().For("api", func(ctx contractshttp.Context) contractshttp.Limit { + return limit.PerMinute(60) + }) + + // Register observers + facades.Orm().Observe(models.User{}, &observers.UserObserver{}) + }). + Create() +} +``` + +--- + +## Install and Uninstall Facades (goravel-lite) + +For `goravel/goravel-lite`, facades are installed selectively: + +```shell +./artisan package:install Route +./artisan package:install Cache +./artisan package:install --all +./artisan package:install --all --default + +./artisan package:uninstall Route +``` + +Note: when selecting facades interactively, press `x` to select an item, then `Enter` to confirm. Pressing `Enter` without `x` does not select. + +--- + +## Accessing the App Facade + +```go +// From anywhere +instance, err := facades.App().Make("goravel.payment") +facades.App().Bind("key", fn) +facades.App().Singleton("key", fn) +facades.App().Instance("key", obj) +``` + +--- + +## Gotchas + +- Never register routes, events, or other side effects inside `Register`. Use `Boot` or `WithCallback` instead. +- `Register` is called on all providers before `Boot` is called on any. Do not call `facades.*` inside `Register` - the binding may not exist yet. +- Facade functions return an interface. Always check for nil or use type assertions carefully. +- Providers without `Relationship` run after those with it. If your provider depends on a custom provider that does not declare relationships, ordering is not guaranteed. diff --git a/.ai/prompt/grpc.md b/.ai/prompt/grpc.md new file mode 100644 index 000000000..ffd518abf --- /dev/null +++ b/.ai/prompt/grpc.md @@ -0,0 +1,269 @@ +# Goravel gRPC + +## Configuration + +Configure in `config/grpc.go`: + +```go +// BREAKING v1.17: grpc.clients renamed to grpc.servers + +config.Add("grpc", map[string]any{ + "host": config.Env("GRPC_HOST", ""), + "port": config.Env("GRPC_PORT", ""), + + "servers": map[string]any{ // BREAKING v1.17: was "clients" + "user": map[string]any{ + "host": config.Env("GRPC_USER_HOST", ""), + "port": config.Env("GRPC_USER_PORT", ""), + "interceptors": []string{"default"}, + "stats_handlers": []string{"user"}, + }, + }, +}) +``` + +--- + +## gRPC Server Controller + +```go +// app/grpc/controllers/user_controller.go +package controllers + +import ( + "context" + "net/http" + + proto "github.com/goravel/example-proto" +) + +type UserController struct{} + +func NewUserController() *UserController { + return &UserController{} +} + +func (r *UserController) GetUser(ctx context.Context, req *proto.UserRequest) (*proto.UserResponse, error) { + return &proto.UserResponse{ + Code: http.StatusOK, + Data: &proto.User{ + Id: 1, + Name: "Goravel", + Token: req.GetToken(), + }, + }, nil +} +``` + +--- + +## Define gRPC Routes + +```go +// routes/grpc.go +package routes + +import ( + proto "github.com/goravel/example-proto" + "goravel/app/facades" + "goravel/app/grpc/controllers" +) + +func Grpc() { + proto.RegisterUserServiceServer(facades.Grpc().Server(), controllers.NewUserController()) +} +``` + +Register in `bootstrap/app.go`: + +```go +WithRouting(func() { + routes.Web() + routes.Grpc() +}) +``` + +--- + +## gRPC Client + +// BREAKING v1.17: facades.Grpc().Client() is deprecated — use facades.Grpc().Connect("name") + +```go +// app/http/controllers/grpc_controller.go +package controllers + +import ( + "fmt" + + proto "github.com/goravel/example-proto" + "github.com/goravel/framework/contracts/http" + "goravel/app/facades" +) + +type GrpcController struct { + userService proto.UserServiceClient +} + +func NewGrpcController() *GrpcController { + // BREAKING v1.17: use Connect instead of Client + client, err := facades.Grpc().Connect("user") + if err != nil { + facades.Log().Error(fmt.Sprintf("failed to connect: %+v", err)) + } + + return &GrpcController{ + userService: proto.NewUserServiceClient(client), + } +} + +func (r *GrpcController) User(ctx http.Context) http.Response { + resp, err := r.userService.GetUser(ctx, &proto.UserRequest{ + Token: ctx.Request().Input("token"), + }) + if err != nil { + return ctx.Response().String(http.StatusInternalServerError, fmt.Sprintf("err: %+v", err)) + } + return ctx.Response().Success().Json(resp.GetData()) +} +``` + +--- + +## Interceptors + +### Server Interceptor + +```go +// app/grpc/interceptors/auth_server.go +package interceptors + +import ( + "context" + "google.golang.org/grpc" +) + +func AuthServer(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) { + // pre-processing: auth check, logging, etc. + return handler(ctx, req) +} +``` + +### Client Interceptor + +```go +// app/grpc/interceptors/log_client.go +package interceptors + +import ( + "context" + "google.golang.org/grpc" +) + +func LogClient(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + // pre-processing: add metadata, logging, etc. + return invoker(ctx, method, req, reply, cc, opts...) +} +``` + +### Register Interceptors + +```go +// bootstrap/app.go +import ( + "google.golang.org/grpc" + "goravel/app/grpc/interceptors" +) + +foundation.Setup(). + WithGrpcServerInterceptors(func() []grpc.UnaryServerInterceptor { + return []grpc.UnaryServerInterceptor{ + interceptors.AuthServer, + } + }). + WithGrpcClientInterceptors(func() map[string][]grpc.UnaryClientInterceptor { + return map[string][]grpc.UnaryClientInterceptor{ + "default": {interceptors.LogClient}, + } + }). + Create() +``` + +The map key (`"default"`) is a group name referenced in `config/grpc.go` servers `interceptors` array. + +--- + +## Stats Handlers + +### Server Stats Handler + +```go +// app/grpc/stats/server_handler.go +package stats + +import ( + "context" + "google.golang.org/grpc/stats" +) + +type ServerStatsHandler struct{} + +func NewServerStatsHandler() stats.Handler { return &ServerStatsHandler{} } + +func (h *ServerStatsHandler) TagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context { + return ctx +} +func (h *ServerStatsHandler) HandleRPC(ctx context.Context, s stats.RPCStats) {} +func (h *ServerStatsHandler) TagConn(ctx context.Context, info *stats.ConnTagInfo) context.Context { + return ctx +} +func (h *ServerStatsHandler) HandleConn(ctx context.Context, s stats.ConnStats) {} +``` + +### Client Stats Handler + +```go +// app/grpc/stats/client_handler.go +package stats + +import ( + "context" + "google.golang.org/grpc/stats" +) + +type ClientStatsHandler struct{} + +func NewClientStatsHandler() stats.Handler { return &ClientStatsHandler{} } + +func (h *ClientStatsHandler) TagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context { + return ctx +} +func (h *ClientStatsHandler) HandleRPC(ctx context.Context, s stats.RPCStats) {} +func (h *ClientStatsHandler) TagConn(ctx context.Context, info *stats.ConnTagInfo) context.Context { + return ctx +} +func (h *ClientStatsHandler) HandleConn(ctx context.Context, s stats.ConnStats) {} +``` + +### Register Stats Handlers + +```go +// bootstrap/app.go +import ( + "google.golang.org/grpc/stats" + grpcstats "goravel/app/grpc/stats" +) + +foundation.Setup(). + WithGrpcServerStatsHandlers(func() []stats.Handler { + return []stats.Handler{grpcstats.NewServerStatsHandler()} + }). + WithGrpcClientStatsHandlers(func() map[string][]stats.Handler { + return map[string][]stats.Handler{ + "user": {grpcstats.NewClientStatsHandler()}, + } + }). + Create() +``` + +Map key (`"user"`) is referenced in `config/grpc.go` servers `stats_handlers` array. diff --git a/.ai/prompt/helpers.md b/.ai/prompt/helpers.md new file mode 100644 index 000000000..b3b2051e6 --- /dev/null +++ b/.ai/prompt/helpers.md @@ -0,0 +1,348 @@ +# Goravel Helpers, Strings, Color + +## Path Helpers + +```go +import "github.com/goravel/framework/support/path" + +path.App() // absolute path to app directory +path.App("http/controllers/controller.go") // path within app dir +path.Base() // project root +path.Base("vendor/bin") +path.Config() // config directory +path.Config("app.go") +path.Database() // database directory +path.Database("factories/user_factory.go") +path.Storage() // storage directory +path.Storage("app/file.txt") +path.Public() // public directory +path.Public("css/app.css") +path.Lang() // lang directory +path.Lang("en.json") +path.Resource() // resource directory +path.Resource("css/app.css") +``` + +--- + +## Carbon (Date/Time) + +```go +import "github.com/goravel/framework/support/carbon" +``` + +Wraps [dromara/carbon](https://github.com/dromara/carbon). + +```go +carbon.Now() +carbon.SetTimezone(carbon.UTC) +carbon.SetLocale("en") // see https://github.com/dromara/carbon/tree/master/lang + +// Test time control +carbon.SetTestNow(carbon.Now()) +carbon.CleanTestNow() +carbon.IsTestNow() + +// Parse +carbon.Parse("2020-08-05 13:14:15") +carbon.ParseByLayout("2020-08-05 13:14:15", carbon.DateTimeLayout) +carbon.ParseByLayout("2020|08|05 13|14|15", []string{"2006|01|02 15|04|05", "2006|1|2 3|4|5"}) +carbon.ParseByFormat("2020-08-05 13:14:15", carbon.DateTimeFormat) + +// From timestamp +carbon.FromTimestamp(1577836800) +carbon.FromTimestampMilli(1649735755999) +carbon.FromTimestampMicro(1649735755999999) +carbon.FromTimestampNano(1649735755999999999) + +// From components +carbon.FromDateTime(2020, 1, 1, 0, 0, 0) +carbon.FromDateTimeMilli(2020, 1, 1, 0, 0, 0, 999) +carbon.FromDateTimeMicro(2020, 1, 1, 0, 0, 0, 999999) +carbon.FromDateTimeNano(2020, 1, 1, 0, 0, 0, 999999999) +carbon.FromDate(2020, 1, 1) +carbon.FromDateMilli(2020, 1, 1, 999) +carbon.FromDateMicro(2020, 1, 1, 999999) +carbon.FromDateNano(2020, 1, 1, 999999999) +carbon.FromTime(13, 14, 15) +carbon.FromTimeMilli(13, 14, 15, 999) +carbon.FromTimeMicro(13, 14, 15, 999999) +carbon.FromTimeNano(13, 14, 15, 999999999) +carbon.FromStdTime(time.Now()) +``` + +--- + +## Debug + +```go +import "github.com/goravel/framework/support/debug" + +debug.Dump(myVar1, myVar2) // print to stdout +debug.SDump(myVar1, myVar2) // return as string +debug.FDump(someWriter, myVar1) // write to io.Writer +``` + +--- + +## Maps + +```go +import "github.com/goravel/framework/support/maps" + +mp := map[string]any{"name": "Goravel"} + +maps.Add(mp, "age", 22) // add only if key absent +maps.Exists(mp, "name") // true +maps.Forget(mp, "name", "age") // remove key(s) +maps.Get(mp, "name", "default") // get with default +maps.Has(mp, "name", "age") // all keys must exist +maps.HasAny(mp, "name", "email") // any key present +maps.Only(mp, "name") // subset map +maps.Pull(mp, "name") // get and remove +maps.Pull(mp, "missing", "default") // with default +maps.Set(mp, "language", "Go") // set value +maps.Where(mp, func(k string, v any) bool { // filter + return k == "name" +}) +``` + +--- + +## Convert + +```go +import "github.com/goravel/framework/support/convert" + +// Tap: pass to closure, return original value +convert.Tap("Goravel", func(value string) { + fmt.Println(value + " Framework") +}) +// → "Goravel" + +// Transform: convert using closure +convert.Transform(1, strconv.Itoa) // "1" +convert.Transform("foo", func(s string) *Foo { + return &Foo{Name: s} +}) + +// With: execute closure and return its result +convert.With("Goravel", func(value string) string { + return value + " Framework" +}) +// → "Goravel Framework" + +// Default: first non-zero value +convert.Default("", "foo") // "foo" +convert.Default("bar", "foo") // "bar" +convert.Default(0, 1) // 1 + +// Pointer: return pointer to value +convert.Pointer("foo") // *string +convert.Pointer(1) // *int +``` + +--- + +## Collect + +```go +import "github.com/goravel/framework/support/collect" + +collect.Count([]string{"a", "b"}) // 2 +collect.CountBy([]string{"a", "b"}, func(v string) bool { return v == "a" }) // 1 +collect.Each([]string{"a", "b"}, func(v string, i int) { fmt.Println(i, v) }) +collect.Filter([]string{"a", "b"}, func(v string) bool { return v == "a" }) // ["a"] +collect.GroupBy(slice, func(v T) string { return v.Key }) // map[string][]T +collect.Keys(map[string]string{"a": "1"}) // ["a"] +collect.Map([]string{"a"}, func(v string, i int) string { return strings.ToUpper(v) }) // ["A"] +collect.Max([]int{1, 2, 3}) // 3 +collect.Merge(map1, map2) // merged (map2 wins on conflict) +collect.Min([]int{1, 2, 3}) // 1 +collect.Reverse([]string{"a", "b"}) // ["b", "a"] +collect.Shuffle([]int{1, 2, 3}) // random order +collect.Split([]int{1, 2, 3, 4, 5}, 2) // [[1,2],[3,4],[5]] +collect.Sum([]int{1, 2, 3}) // 6 +collect.Unique([]string{"a", "b", "a"}) // ["a", "b"] (first occurrence kept) +collect.Values(map[string]string{"a": "1"}) // ["1"] +``` + +--- + +## Fluent Strings + +```go +import "github.com/goravel/framework/support/str" + +// Chain methods; call .String() to get final string value +str.Of(" Goravel ").Trim().Lower().UcFirst().String() // "Goravel" +``` + +### String Methods + +```go +str.Of("Hello World!").After("Hello").String() // " World!" +str.Of("docs.goravel.dev").AfterLast(".").String() // "dev" +str.Of("Bowen").Append(" Han").String() // "Bowen Han" +str.Of("framework/support/str").Basename().String() // "str" +str.Of("framework/support/str.go").Basename(".go").String() // "str" +str.Of("Hello World!").Before("World").String() // "Hello " +str.Of("docs.goravel.dev").BeforeLast(".").String() // "docs.goravel" +str.Of("[Hello] World!").Between("[", "]").String() // "Hello" +str.Of("[Hello] [World]!").BetweenFirst("[", "]").String() // "Hello" +str.Of("hello_world").Camel().String() // "helloWorld" +str.Of("Goravel").CharAt(1) // "o" +str.Of("https://goravel.com").ChopEnd(".com").String() // "https://goravel" +str.Of("https://goravel.dev").ChopStart("https://").String()// "goravel.dev" +str.Of("Goravel").Contains("Gor") // true +str.Of("Hello World").Contains("Gor", "Hello") // true (any) +str.Of("Hello World").ContainsAll("Hello", "World") // true (all) +str.Of("framework/support/str").Dirname().String() // "framework/support" +str.Of("framework/support/str").Dirname(2).String() // "framework" +str.Of("Goravel").EndsWith("vel") // true +str.Of("Goravel").EndsWith("vel", "lie") // true (any) +str.Of("Goravel").Exactly("Goravel") // true +str.Of("This is a beautiful morning").Except("beautiful", str.ExcerptOption{Radius: 5}).String() +// "...is a beautiful morn..." +str.Of("Hello World").Explode(" ") // []string{"Hello", "World"} +str.Of("framework").Finish("/").String() // "framework/" +str.Of("bowen_han").Headline().String() // "Bowen Han" +str.Of("foo123").Is("bar*", "foo*") // true +str.Of("").IsEmpty() // true +str.Of("Goravel").IsNotEmpty() // true +str.Of("Goravel").IsAscii() // true +str.Of(`[{"name":"a"}]`).IsSlice() // true +str.Of(`{"name":"a"}`).IsMap() // true +str.Of("01E5Z6Z1Z6Z1Z6Z1Z6Z1Z6Z1Z6").IsUlid() // true +str.Of("550e8400-e29b-41d4-a716-446655440000").IsUuid() // true +str.Of("GoravelFramework").Kebab().String() // "goravel-framework" +str.Of("Goravel Framework").LcFirst().String() // "goravel Framework" +str.Of("Goravel").Length() // 7 +str.Of("This is a beautiful morning").Limit(7).String() // "This is..." +str.Of("This is a beautiful morning").Limit(7, " (****)").String() // "This is (****)" +str.Of("GORAVEL").Lower().String() // "goravel" +str.Of(" Goravel ").LTrim().String() // "Goravel " +str.Of("/framework/").LTrim("/").String() // "framework/" +str.Of("krishan@email.com").Mask("*", 3).String() // "kri**************" +str.Of("krishan@email.com").Mask("*", -13, 3).String() // "kris***@email.com" +str.Of("This is a (test) string").Match(`\([^)]+\)`).String()// "(test)" +str.Of("abc123def456").MatchAll(`\d+`) // ["123", "456"] +str.Of("Hello, Goravel!").IsMatch(`(?i)goravel`) // true +str.Of("Goravel").NewLine(2).Append("Framework").String() // "Goravel\n\nFramework" +str.Of("Hello").PadBoth(10, "_").String() // "__Hello___" +str.Of("Hello").PadLeft(10, "_").String() // "_____Hello" +str.Of("Hello").PadRight(10, "_").String() // "Hello_____" +str.Of("Goravel").Pipe(func(s string) string { return s + " Framework" }).String() +str.Of("goose").Plural().String() // "geese" +str.Of("goose").Plural(1).String() // "goose" +str.Of("goose").Plural(2).String() // "geese" +str.Of("Framework").Prepend("Goravel ").String() // "Goravel Framework" +str.Of("Hello World").Remove("World").String() // "Hello " +str.Of("a").Repeat(2).String() // "aa" +str.Of("Hello World").Replace("World", "Krishan").String() // "Hello Krishan" +str.Of("Hello World").Replace("world", "Krishan", false).String() // case-insensitive +str.Of("Hello World").ReplaceEnd("World", "Goravel").String() +str.Of("Hello World").ReplaceFirst("World", "Goravel").String() +str.Of("Hello World").ReplaceLast("World", "Goravel").String() +str.Of("Hello, Goravel!").ReplaceMatches(`goravel!(.*)`, "Krishan") +str.Of("Hello World").ReplaceStart("Hello", "Goravel").String() +str.Of(" Goravel ").RTrim().String() // " Goravel" +str.Of("heroes").Singular().String() // "hero" +str.Of("GoravelFramework").Snake().String() // "goravel_framework" +str.Of("Hello World").Split(" ") // []string{"Hello", "World"} +str.Of("Hello World").Squish().String() // "Hello World" +str.Of("framework").Start("/").String() // "/framework" +str.Of("Goravel").StartsWith("Gor") // true +str.Of("Goravel").String() // "Goravel" +str.Of("goravel_framework").Studly().String() // "GoravelFramework" +str.Of("Goravel").Substr(1, 3) // "ora" +str.Of("Golang is awesome").Swap(map[string]string{"Golang": "Go", "awesome": "great"}).String() +str.Of("Goravel").Tap(func(s string) { fmt.Println(s) }).String() +str.Of("Hello, Goravel!").Test(`goravel!(.*)`) // true +str.Of("goravel framework").Title().String() // "Goravel Framework" +str.Of(" Goravel ").Trim().String() // "Goravel" +str.Of("/framework/").Trim("/").String() // "framework" +str.Of("goravel framework").UcFirst().String() // "Goravel framework" +str.Of("GoravelFramework").UcSplit() // ["Goravel", "Framework"] +str.Of("goravel").Upper().String() // "GORAVEL" +str.Of("Hello, World!").WordCount() // 2 +str.Of("Hello, World!").Words(1) // "Hello..." +str.Of("Hello, World!").Words(1, " (****)").String() // "Hello (****)" + +// Conditional chaining +str.Of("Bowen").When(true, func(s *str.String) *str.String { + return s.Append(" Han") +}).String() // "Bowen Han" + +str.Of("Hello Bowen").WhenContains("Hello", func(s *str.String) *str.String { + return s.Append(" Han") +}).String() + +str.Of("Hello Bowen").WhenContainsAll([]string{"Hello", "Bowen"}, func(s *str.String) *str.String { + return s.Append(" Han") +}).String() + +str.Of("").WhenEmpty(func(s *str.String) *str.String { + return s.Append("Goravel") +}).String() + +str.Of("Goravel").WhenIsAscii(func(s *str.String) *str.String { + return s.Append(" Framework") +}).String() + +str.Of("Goravel").WhenNotEmpty(func(s *str.String) *str.String { + return s.Append(" Framework") +}).String() + +str.Of("hello world").WhenStartsWith("hello", func(s *str.String) *str.String { + return s.Title() +}).String() + +str.Of("hello world").WhenEndsWith("world", func(s *str.String) *str.String { + return s.Title() +}).String() + +str.Of("Goravel").WhenExactly("Goravel", func(s *str.String) *str.String { + return s.Append(" Framework") +}).String() + +str.Of("foo/bar").WhenIs("foo/*", func(s *str.String) *str.String { + return s.Append("/baz") +}).String() + +str.Of("goravel framework").WhenTest(`goravel(.*)`, func(s *str.String) *str.String { + return s.Append(" is awesome") +}).String() + +// Unless: execute if condition is false +str.Of("Goravel").Unless(func(s *str.String) bool { + return false +}, func(s *str.String) *str.String { + return str.Of("Fallback Applied") +}).String() // "Fallback Applied" +``` + +--- + +## Color (Terminal Output) + +```go +import "github.com/goravel/framework/support/color" + +// Built-in colors +color.Red().Println("error") +color.Green().Printf("Hello, %s!", "Goravel") +color.Yellow().Print("warning") +color.Blue().Sprintln("info") // returns colored string +color.Magenta().Sprint("text") +color.Cyan().Sprintf("value: %d", 42) +color.White().Println("white") +color.Black().Println("black") +color.Gray().Println("gray") +color.Default().Println("default") + +// Printer interface methods: Print, Println, Printf, Sprint, Sprintln, Sprintf + +// Custom color +color.New(color.FgRed).Println("custom red") +``` diff --git a/.ai/prompt/http.md b/.ai/prompt/http.md new file mode 100644 index 000000000..b14a57cc9 --- /dev/null +++ b/.ai/prompt/http.md @@ -0,0 +1,249 @@ +# Goravel HTTP Client + +## Basic Requests + +```go +// Default client +response, err := facades.Http().Get("https://example.com") +response, err = facades.Http().Post("https://example.com/users", body) +response, err = facades.Http().Put("https://example.com/users/1", body) +response, err = facades.Http().Delete("https://example.com/users/1", nil) +response, err = facades.Http().Patch("https://example.com/users/1", body) +response, err = facades.Http().Head("https://example.com") + +// Named client (configured in config/http.go clients map) +response, err = facades.Http().Client("github").Get("https://api.github.com") +``` + +--- + +## Response Interface + +```go +// BREAKING v1.17: Http.Request.Bind() is removed — use response.Bind(&dest) + +var user User +err = response.Bind(&user) // bind JSON body to struct + +body, err := response.Body() // raw string body +json, err := response.Json() // map[string]any +status := response.Status() // HTTP status code +header := response.Header("X-Custom") +headers := response.Headers() +cookies := response.Cookies() +cookie := response.Cookie("session") + +// Status checks +response.Successful() // 2xx +response.Failed() // not 2xx +response.ClientError() // 4xx +response.ServerError() // 5xx +response.Redirect() // 3xx +response.OK() // 200 +response.Created() // 201 +response.NotFound() // 404 +response.UnprocessableEntity() // 422 +response.TooManyRequests() // 429 +``` + +--- + +## Headers + +```go +facades.Http().WithHeader("X-Custom", "value").Get(url) +facades.Http().WithHeaders(map[string]string{"Content-Type": "application/json"}).Get(url) +facades.Http().Accept("application/xml").Get(url) +facades.Http().AcceptJson().Get(url) +facades.Http().ReplaceHeaders(map[string]string{"Authorization": "Bearer token"}).Get(url) +facades.Http().WithoutHeader("X-Old-Header").Get(url) +facades.Http().FlushHeaders().Get(url) +``` + +--- + +## Authentication + +```go +facades.Http().WithBasicAuth("username", "password").Get(url) +facades.Http().WithToken("bearer_token").Get(url) +facades.Http().WithToken("custom_token", "Token").Get(url) +facades.Http().WithoutToken().Get(url) +``` + +--- + +## Query Parameters + +```go +facades.Http().WithQueryParameter("sort", "name").Get(url) +facades.Http().WithQueryParameters(map[string]string{"page": "2", "limit": "10"}).Get(url) +facades.Http().WithQueryString("filter=active&order=price").Get(url) +``` + +--- + +## URL Templates + +```go +facades.Http(). + WithUrlParameter("id", "123"). + Get("https://api.example.com/users/{id}") + +facades.Http(). + WithUrlParameters(map[string]string{"bookId": "456", "chapterNumber": "7"}). + Get("https://api.example.com/books/{bookId}/chapters/{chapterNumber}") +``` + +--- + +## Request Body + +```go +import "github.com/goravel/framework/support/http" + +builder := http.NewBody().SetField("name", "krishan") +body, err := builder.Build() +response, err := facades.Http(). + WithHeader("Content-Type", body.ContentType()). + Post("https://example.com/users", body.Reader()) +``` + +--- + +## Cookies + +```go +import "net/http" + +facades.Http().WithCookie(&http.Cookie{Name: "user_id", Value: "123"}).Get(url) +facades.Http().WithCookies([]*http.Cookie{ + {Name: "session_token", Value: "xyz"}, + {Name: "language", Value: "en"}, +}).Get(url) +facades.Http().WithoutCookie("language").Get(url) +``` + +--- + +## Context (timeout, cancellation) + +```go +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +defer cancel() +response, err := facades.Http().WithContext(ctx).Get(url) +``` + +--- + +## Bind Response Body + +```go +// BREAKING v1.17: use response.Bind(&dest) instead of Request.Bind() + +type User struct { + ID int `json:"id"` + Name string `json:"name"` +} + +var user User +response, err := facades.Http().AcceptJson().Get("https://api.example.com/users/1") +err = response.Bind(&user) +``` + +--- + +## Multiple Clients Configuration + +```go +// config/http.go +"clients": map[string]any{ + "github": map[string]any{ + "base_url": "https://api.github.com", + "timeout": 30, + }, +}, +``` + +Usage: + +```go +response, err := facades.Http().Client("github").Get("/repos/goravel/framework") +``` + +--- + +## Testing + +### Fake Responses + +```go +facades.Http().Fake(map[string]any{ + "https://github.com/goravel/framework": facades.Http().Response().Json(200, map[string]string{"foo": "bar"}), + "https://google.com/*": facades.Http().Response().String(200, "Hello World"), + "github": facades.Http().Response().OK(), // named client + "*": facades.Http().Response().Status(404), // fallback +}) + +// Implicit conversion shortcuts +facades.Http().Fake(map[string]any{ + "https://goravel.dev/*": "Hello World", // → 200 string body + "https://github.com/*": map[string]string{"a": "b"}, // → 200 JSON + "https://stripe.com/*": 500, // → 500 empty +}) +``` + +### Response Sequences + +```go +facades.Http().Fake(map[string]any{ + "github": facades.Http().Sequence(). + PushStatus(500). + PushString(429, "Rate Limit"). + PushStatus(200). + WhenEmpty(facades.Http().Response().Status(404)), +}) +``` + +### Assertions + +```go +facades.Http().AssertSent(func(req client.Request) bool { + return req.Url() == "https://api.example.com/users" && + req.Method() == "POST" && + req.Input("role") == "admin" +}) + +facades.Http().AssertNotSent(func(req client.Request) bool { + return req.Url() == "https://api.example.com/legacy" +}) + +facades.Http().AssertNothingSent() +facades.Http().AssertSentCount(3) +``` + +### Prevent Stray Requests + +```go +facades.Http().Fake(map[string]any{ + "github": facades.Http().Response().OK(), +}).PreventStrayRequests() + +// Allow specific strays +facades.Http().PreventStrayRequests().AllowStrayRequests([]string{ + "http://localhost:8080/*", +}) +``` + +### Reset State Between Tests + +```go +func TestExternalApi(t *testing.T) { + defer facades.Http().Reset() + + facades.Http().Fake(nil) + // ... test code +} +``` + +Do not run tests using `Fake` in parallel — it mutates global state. diff --git a/.ai/prompt/localization.md b/.ai/prompt/localization.md new file mode 100644 index 000000000..c4a03f168 --- /dev/null +++ b/.ai/prompt/localization.md @@ -0,0 +1,154 @@ +# Goravel Localization + +## Language File Structure + +``` +/lang + en.json + cn.json +``` + +Or categorized: + +``` +/lang + /en + user.json + role.json + /cn + user.json + role.json +``` + +--- + +## Translation File Format + +```json +// lang/en.json +{ + "name": "It's your name", + "required": { + "user_id": "UserID is required" + }, + "welcome": "Welcome, :name" +} +``` + +--- + +## Get Translation + +```go +// Simple key +facades.Lang(ctx).Get("name") + +// Nested key (dot notation) +facades.Lang(ctx).Get("required.user_id") + +// From categorized file (slash + dot notation) +facades.Lang(ctx).Get("role/user.name") +facades.Lang(ctx).Get("role/user.required.user_id") +``` + +--- + +## Replace Placeholders + +Placeholders are prefixed with `:`. + +```go +import "github.com/goravel/framework/translation" + +facades.Lang(ctx).Get("welcome", translation.Option{ + Replace: map[string]string{ + "name": "Goravel", + }, +}) +// → "Welcome, Goravel" +``` + +--- + +## Pluralization + +```json +// lang/en.json +{ + "apples": "There is one apple|There are many apples", + "items": "{0} There are none|[1,19] There are some|[20,*] There are many", + "minutes_ago": "{1} :value minute ago|[2,*] :value minutes ago" +} +``` + +```go +facades.Lang(ctx).Choice("apples", 1) // "There is one apple" +facades.Lang(ctx).Choice("apples", 5) // "There are many apples" +facades.Lang(ctx).Choice("items", 0) // "There are none" +facades.Lang(ctx).Choice("items", 10) // "There are some" + +facades.Lang(ctx).Choice("minutes_ago", 5, translation.Option{ + Replace: map[string]string{"value": "5"}, +}) +// → "5 minutes ago" +``` + +--- + +## Set Locale at Runtime + +```go +facades.App().SetLocale(ctx, "cn") +``` + +### Get / Check Current Locale + +```go +locale := facades.App().CurrentLocale(ctx) + +if facades.App().IsLocale(ctx, "en") { + // ... +} +``` + +--- + +## Default and Fallback Locale + +Configure in `config/app.go`: + +```go +"locale": "en", +"fallback_locale": "en", +``` + +--- + +## Embed Loading (compile lang files into binary) + +``` +/lang + en.json + cn.json + fs.go +``` + +```go +// lang/fs.go +package lang + +import "embed" + +//go:embed * +var FS embed.FS +``` + +```go +// config/app.go +import "goravel/lang" + +"lang_path": "lang", +"lang_fs": lang.FS, +``` + +When both file and embed exist, file takes priority; embed is the fallback. diff --git a/.ai/prompt/log.md b/.ai/prompt/log.md new file mode 100644 index 000000000..64582906c --- /dev/null +++ b/.ai/prompt/log.md @@ -0,0 +1,158 @@ +# Goravel Logging + +## Configuration + +Configure channels in `config/logging.go`. Default channel: `stack` (forwards to multiple channels). + +Available channel drivers: + +| Driver | Description | +|--------|-------------| +| `stack` | Multiple channels | +| `single` | Single log file | +| `daily` | One file per day | +| `custom` | Custom driver | + +--- + +## Write Log Messages + +```go +facades.Log().Debug("message") +facades.Log().Debugf("message: %s", arg) +facades.Log().Info("message") +facades.Log().Infof("message: %s", arg) +facades.Log().Warning("message") +facades.Log().Warningf("message: %s", arg) +facades.Log().Error("message") +facades.Log().Errorf("message: %s", arg) +facades.Log().Fatal("message") +facades.Log().Fatalf("message: %s", arg) +facades.Log().Panic("message") +facades.Log().Panicf("message: %s", arg) +``` + +--- + +## Write to Specific Channel + +```go +facades.Log().Channel("single").Info("message") +``` + +## Write to Multiple Channels + +```go +facades.Log().Stack([]string{"single", "slack"}).Info("message") +``` + +--- + +## Inject HTTP Context + +```go +facades.Log().WithContext(ctx).Info("message") +``` + +--- + +## Chain Methods + +```go +facades.Log(). + User("john@example.com"). + Code("ERR_AUTH_001"). + Hint("Check the token expiry"). + In("auth"). + Owner("backend-team"). + Tags("auth", "jwt"). + With(map[string]any{"userID": 123}). + WithTrace(). + Error("Authentication failed") +``` + +| Method | Description | +|--------|-------------| +| `Code(code)` | Error code or slug | +| `Hint(hint)` | Debugging hint | +| `In(category)` | Feature category or domain | +| `Owner(owner)` | Alert owner | +| `Request(req)` | Attach HTTP request | +| `Response(resp)` | Attach HTTP response | +| `Tags(tags...)` | Feature tags | +| `User(user)` | Associated user | +| `With(data)` | Key-value context pairs | +| `WithTrace()` | Include stack trace | + +--- + +## Custom Log Driver + +// BREAKING v1.17: Handle must return (Handler, error) not (Hook, error) +// Use log.HookToHandler(hook) adapter if you have an old Hook implementation + +```go +// config/logging.go +"custom": map[string]interface{}{ + "driver": "custom", + "via": &CustomLogger{}, +}, +``` + +Implement `contracts/log/Logger`: + +```go +// BREAKING v1.17: Handle returns (Handler, error) not (Hook, error) +package log + +type Logger interface { + Handle(channel string) (Handler, error) +} +``` + +Example implementation: + +```go +package extensions + +import ( + "github.com/goravel/framework/contracts/log" +) + +type CustomLogger struct{} + +func (c *CustomLogger) Handle(channel string) (log.Handler, error) { + return &CustomHandler{channel: channel}, nil +} + +type CustomHandler struct { + channel string +} + +func (h *CustomHandler) Handle(record log.Record) error { + // write record to your custom sink + return nil +} +``` + +### Adapter for old Hook implementations + +```go +import "github.com/goravel/framework/log" + +handler := log.HookToHandler(myOldHook) +``` + +--- + +## JSON Formatter (v1.17) + +```go +// config/logging.go +"single": map[string]any{ + "driver": "single", + "path": "storage/logs/goravel.log", + "level": "debug", + "formatter": "json", // v1.17: new option +}, +``` diff --git a/.ai/prompt/mail.md b/.ai/prompt/mail.md new file mode 100644 index 000000000..b3f2571cc --- /dev/null +++ b/.ai/prompt/mail.md @@ -0,0 +1,162 @@ +# Goravel Mail + +## Configuration + +Configure in `config/mail.go`. Set `MAIL_FROM_ADDRESS` and `MAIL_FROM_NAME` as global sender defaults. + +--- + +## Send Mail (Fluent) + +```go +import "github.com/goravel/framework/mail" + +err := facades.Mail(). + To([]string{"example@example.com"}). + Cc([]string{"cc@example.com"}). + Bcc([]string{"bcc@example.com"}). + From(mail.Address("from@example.com", "Sender Name")). + Attach([]string{"file.png"}). + Content(mail.Html("

Hello Goravel

")). + Headers(map[string]string{"X-Mailer": "Goravel"}). + Subject("Subject"). + Send() +``` + +--- + +## Send via Queue + +```go +import "github.com/goravel/framework/mail" + +// Default queue +err := facades.Mail(). + To([]string{"example@example.com"}). + Content(mail.Html("

Hello Goravel

")). + Subject("Subject"). + Queue() + +// Custom queue +err = facades.Mail(). + To([]string{"example@example.com"}). + Content(mail.Html("

Hello Goravel

")). + Subject("Subject"). + Queue(mail.Queue().Connection("redis").Queue("mail")) +``` + +--- + +## Mailable Struct + +Generate: + +```shell +./artisan make:mail OrderShipped +``` + +```go +package mails + +import "github.com/goravel/framework/contracts/mail" + +type OrderShipped struct{} + +func NewOrderShipped() *OrderShipped { + return &OrderShipped{} +} + +func (m *OrderShipped) Headers() map[string]string { + return map[string]string{"X-Mailer": "goravel"} +} + +func (m *OrderShipped) Attachments() []string { + return []string{"./logo.png"} +} + +func (m *OrderShipped) Content() *mail.Content { + return &mail.Content{Html: "

Hello Goravel

"} +} + +func (m *OrderShipped) Envelope() *mail.Envelope { + return &mail.Envelope{ + Bcc: []string{"bcc@goravel.dev"}, + Cc: []string{"cc@goravel.dev"}, + From: mail.From{Address: "from@goravel.dev", Name: "from"}, + Subject: "Goravel", + To: []string{"to@goravel.dev"}, + } +} + +// Optional: configure queue behavior +func (m *OrderShipped) Queue() *mail.Queue { + return &mail.Queue{ + Connection: "redis", + Queue: "mail", + } +} +``` + +Use Mailable: + +```go +err := facades.Mail().Send(mails.NewOrderShipped()) +err = facades.Mail().Queue(mails.NewOrderShipped()) +``` + +--- + +## Template Support (v1.17) + +Configure template engine in `config/mail.go`: + +```go +"template": map[string]any{ + "default": config.Env("MAIL_TEMPLATE_ENGINE", "html"), + "engines": map[string]any{ + "html": map[string]any{ + "driver": "html", + "path": config.Env("MAIL_VIEWS_PATH", "resources/views/mail"), + }, + }, +}, +``` + +Create template file (e.g., `resources/views/mail/welcome.html`): + +```html +

Welcome {{.Name}}!

+

Thank you for joining {{.AppName}}.

+``` + +Send with template: + +```go +facades.Mail(). + To([]string{"user@example.com"}). + Subject("Welcome"). + Content(mail.Content{ + View: "welcome.tmpl", + With: map[string]any{ + "Name": "John", + "AppName": "Goravel", + }, + }). + Send() +``` + +### Custom Template Engine + +```go +"template": map[string]any{ + "default": "blade", + "engines": map[string]any{ + "blade": map[string]any{ + "driver": "custom", + "via": func() (mail.Template, error) { + return NewBladeTemplateEngine(), nil + }, + }, + }, +}, +``` diff --git a/.ai/prompt/middleware.md b/.ai/prompt/middleware.md new file mode 100644 index 000000000..e376a94cf --- /dev/null +++ b/.ai/prompt/middleware.md @@ -0,0 +1,217 @@ +# Goravel Middleware + +## Middleware Signature + +Middleware is a function that returns `http.Middleware`. It is NOT a struct. + +```go +package middleware + +import "github.com/goravel/framework/contracts/http" + +func Auth() http.Middleware { + return func(ctx http.Context) { + // logic before handler + ctx.Request().Next() + // logic after handler + } +} +``` + +### Generate middleware + +```shell +./artisan make:middleware Auth +./artisan make:middleware user/Auth +``` + +Generated file is placed in `app/http/middleware/`. + +--- + +## Aborting a Request + +```go +func Auth() http.Middleware { + return func(ctx http.Context) { + token := ctx.Request().Header("Authorization", "") + if token == "" { + ctx.Request().Abort(http.StatusUnauthorized) + return + } + ctx.Request().Next() + } +} +``` + +Abort with a response body: + +```go +func Auth() http.Middleware { + return func(ctx http.Context) { + token := ctx.Request().Header("Authorization", "") + if token == "" { + ctx.Response().String(http.StatusUnauthorized, "unauthorized").Abort() + return + } + ctx.Request().Next() + } +} +``` + +Abort forms: + +```go +ctx.Request().Abort() +ctx.Request().Abort(http.StatusForbidden) +ctx.Request().AbortWithStatus(http.StatusForbidden) +ctx.Response().String(http.StatusForbidden, "forbidden").Abort() +``` + +--- + +## Passing Data Through Middleware + +Set a value on the context to pass to the next handler: + +```go +func Auth() http.Middleware { + return func(ctx http.Context) { + userID := resolveUserID(ctx) + ctx.WithValue("userID", userID) + ctx.Request().Next() + } +} +``` + +Read in controller: + +```go +func (r *UserController) Show(ctx http.Context) http.Response { + userID := ctx.Value("userID") + ... +} +``` + +--- + +## Global Middleware + +Applies to every HTTP request. Registered in `bootstrap/app.go`: + +```go +import ( + "github.com/goravel/framework/contracts/foundation/configuration" + "goravel/app/http/middleware" +) + +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithMiddleware(func(handler configuration.Middleware) { + handler.Append( + middleware.Auth(), + middleware.Custom(), + ) + }). + WithConfig(config.Boot). + Create() +} +``` + +`handler` methods: + +| Method | Effect | +|--------|--------| +| `Append(middlewares...)` | Add to end of middleware stack | +| `Prepend(middlewares...)` | Add to start of middleware stack | +| `Use(middlewares...)` | Replace entire middleware stack | +| `Recover(fn)` | Set panic recovery handler | +| `GetGlobalMiddleware()` | Return current global middleware list | + +--- + +## Route-Level Middleware + +Apply to specific routes or groups: + +```go +import ( + "goravel/app/facades" + "goravel/app/http/middleware" +) + +// Single route +facades.Route().Middleware(middleware.Auth()).Get("profile", profileController.Show) + +// Multiple middleware +facades.Route().Middleware(middleware.Auth(), middleware.Throttle("api")).Get("admin", adminController.Index) + +// Group +facades.Route().Middleware(middleware.Auth()).Group(func(router route.Router) { + router.Get("profile", profileController.Show) + router.Put("profile", profileController.Update) +}) + +// Prefix + middleware +facades.Route().Prefix("admin").Middleware(middleware.Auth()).Group(func(router route.Router) { + router.Get("dashboard", dashboardController.Index) +}) +``` + +--- + +## Framework-Provided Middleware + +```go +import "github.com/goravel/framework/http/middleware" + +middleware.Cors() // CORS headers +middleware.Throttle("limiterName") // rate limiting (limiter defined via facades.RateLimiter()) +``` + +--- + +## Custom Recovery (Panic Handler) + +```go +import ( + contractshttp "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/contracts/foundation/configuration" +) + +WithMiddleware(func(handler configuration.Middleware) { + handler. + Append(middleware.Cors()). + Recover(func(ctx contractshttp.Context, err any) { + facades.Log().Error(err) + _ = ctx.Response().String(contractshttp.StatusInternalServerError, "internal error").Abort() + }) +}) +``` + +--- + +## Reading Response Data in Middleware (After Next) + +```go +func Logger() http.Middleware { + return func(ctx http.Context) { + ctx.Request().Next() + + origin := ctx.Response().Origin() + // origin.Body() - response bytes + // origin.Header() - response headers + // origin.Status() - HTTP status code + // origin.Size() - response body size + } +} +``` + +--- + +## Gotchas + +- Middleware is a function returning `http.Middleware`, not a struct with a `Handle` method. +- Always call `ctx.Request().Next()` to pass control to the next middleware or handler. Omitting it silently stops the chain. +- Returning from the middleware function after `Abort` is required to stop execution. +- Global middleware registered with `WithMiddleware` runs on all HTTP routes. Use route-level middleware for scoped behavior. diff --git a/.ai/prompt/migration.md b/.ai/prompt/migration.md new file mode 100644 index 000000000..25a80469e --- /dev/null +++ b/.ai/prompt/migration.md @@ -0,0 +1,346 @@ +# Goravel Migrations + +## Configuration + +```go +// config/database.go +"migrations": map[string]any{ + "table": "migrations", // customize the migration tracking table name +}, +``` + +--- + +## Create Migration + +```shell +# Interactive (prompts for name) +./artisan make:migration + +# Named +./artisan make:migration create_users_table + +# From model struct (auto-generates columns from model definition) +# Model must be registered via facades.Schema().Extend() in WithCallback +./artisan make:migration create_users_table -m User +./artisan make:migration create_users_table --model=User +``` + +### Auto-generation Naming Rules + +The command detects intent from the migration name: + +| Pattern | Result | +|---------|--------| +| `^create_(\w+)_table$` or `^create_(\w+)$` | Creates table with infrastructure | +| `_(to\|from\|in)_(\w+)_table$` or `_(to\|from\|in)_(\w+)$` | Adds/removes columns on existing table | +| Anything else | Empty migration file | + +Examples: +- `create_users_table` → auto-creates `users` table with `id` + `timestamps` +- `add_avatar_to_users_table` → auto-adds column to `users` +- `remove_avatar_from_users` → auto-removes column from `users` + +### Register Models for -m Flag + +```go +// bootstrap/app.go +WithCallback(func() { + facades.Schema().Extend(schema.Extension{ + Models: []any{models.User{}}, + }) +}) +``` + +--- + +## Migration File Structure + +```go +package migrations + +import ( + "github.com/goravel/framework/contracts/database/schema" + "goravel/app/facades" +) + +type M20241207095921CreateUsersTable struct{} + +// Signature must be unique — timestamp + name +func (r *M20241207095921CreateUsersTable) Signature() string { + return "20241207095921_create_users_table" +} + +// Up: add tables/columns/indexes +func (r *M20241207095921CreateUsersTable) Up() error { + if !facades.Schema().HasTable("users") { + return facades.Schema().Create("users", func(table schema.Blueprint) { + table.ID() + table.String("name").Nullable() + table.String("email").Nullable() + table.Timestamps() + }) + } + return nil +} + +// Down: reverse the Up operation +func (r *M20241207095921CreateUsersTable) Down() error { + return facades.Schema().DropIfExists("users") +} + +// Optional: use a non-default DB connection +func (r *M20241207095921CreateUsersTable) Connection() string { + return "postgres" +} +``` + +--- + +## Registration + +Migrations created via `make:migration` are auto-registered in `bootstrap/migrations.go`. Manual migrations must be added by hand: + +```go +// bootstrap/app.go +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithMigrations(Migrations). + WithConfig(config.Boot). + Create() +} +``` + +--- + +## Run Migrations + +```shell +./artisan migrate # run all pending +./artisan migrate:status # show which have run +./artisan migrate:rollback # BREAKING v1.17: rolls back entire last batch +./artisan migrate:rollback --batch=2 # roll back specific batch number +./artisan migrate:rollback --step=5 # roll back last 5 migrations +./artisan migrate:reset # roll back all +./artisan migrate:refresh # roll back all + re-migrate +./artisan migrate:refresh --step=5 # roll back + re-migrate last 5 +./artisan migrate:fresh # drop all tables + migrate +``` + +--- + +## Tables + +```go +// Create +facades.Schema().Create("users", func(table schema.Blueprint) { + table.ID() + table.String("name").Nullable() + table.Timestamps() +}) + +// Check existence +facades.Schema().HasTable("users") +facades.Schema().HasColumn("users", "email") +facades.Schema().HasColumns("users", []string{"name", "email"}) +facades.Schema().HasIndex("users", "email_unique") + +// Use specific DB connection +facades.Schema().Connection("sqlite").Create("users", func(table schema.Blueprint) { + table.ID() +}) + +// Modify existing table +facades.Schema().Table("users", func(table schema.Blueprint) { + table.String("avatar").Nullable() +}) + +// Rename column +facades.Schema().Table("users", func(table schema.Blueprint) { + table.RenameColumn("old_name", "new_name") +}) + +// Add table comment +facades.Schema().Table("users", func(table schema.Blueprint) { + table.Comment("user table") +}) + +// Rename / Drop +facades.Schema().Rename("users", "new_users") +facades.Schema().Drop("users") +facades.Schema().DropIfExists("users") +``` + +--- + +## Column Types + +### Boolean +```go +table.Boolean("active") +``` + +### String & Text +```go +table.Char("code", 4) +table.String("name") // default 255 length +table.String("name", 100) +table.Text("bio") +table.TinyText("note") +table.MediumText("body") +table.LongText("content") +table.Json("metadata") +table.Uuid("uuid") +table.Ulid("ulid") +``` + +### Numeric +```go +table.ID() // alias for BigIncrements, column name "id" +table.ID("user_id") // custom name +table.BigIncrements("id") +table.Increments("id") // uint auto-increment +table.IntegerIncrements("id") +table.MediumIncrements("id") +table.SmallIncrements("id") +table.TinyIncrements("id") +table.BigInteger("views") +table.Integer("age") +table.MediumInteger("score") +table.SmallInteger("rating") +table.TinyInteger("status") +table.UnsignedBigInteger("user_id") +table.UnsignedInteger("count") +table.UnsignedMediumInteger("x") +table.UnsignedSmallInteger("y") +table.UnsignedTinyInteger("z") +table.Float("amount") +table.Double("price") +table.Decimal("total") +``` + +### Date & Time +```go +table.Date("birthday") +table.DateTime("scheduled_at") +table.DateTimeTz("scheduled_at") +table.Time("starts_at") +table.TimeTz("starts_at") +table.Timestamp("created_at") +table.TimestampTz("created_at") +table.Timestamps() // created_at + updated_at +table.TimestampsTz() +table.SoftDeletes() // deleted_at nullable TIMESTAMP +table.SoftDeletesTz() +``` + +### Enum +```go +// MySQL: actual ENUM type; Postgres/SQLite/Sqlserver: stored as string +table.Enum("difficulty", []any{"easy", "hard"}) +table.Enum("num", []any{1, 2}) +``` + +### Polymorphic Morphs +```go +table.Morphs("taggable") // taggable_id (uint) + taggable_type (string) +table.NullableMorphs("taggable") +table.NumericMorphs("taggable") +table.UuidMorphs("taggable") +table.UlidMorphs("taggable") +``` + +### Custom Column Type +```go +table.Column("geometry", "geometry") +``` + +--- + +## Column Modifiers + +Chain after any column type: + +| Modifier | Description | +|----------|-------------| +| `.Always()` | DB-generated always (PostgreSQL only) | +| `.AutoIncrement()` | Auto-increment primary key | +| `.After("column")` | Position after column (MySQL only) | +| `.Comment("text")` | Column comment (MySQL/PostgreSQL) | +| `.Change()` | Modify column structure (MySQL/PostgreSQL/Sqlserver) | +| `.Default(value)` | Default value | +| `.First()` | Place as first column (MySQL only) | +| `.GeneratedAs()` | DB-generated value (PostgreSQL only) | +| `.Nullable()` | Allow NULL | +| `.Unsigned()` | UNSIGNED integer (MySQL only) | +| `.UseCurrent()` | Default CURRENT_TIMESTAMP | +| `.UseCurrentOnUpdate()` | Update to CURRENT_TIMESTAMP on row update (MySQL only) | + +```go +table.String("name").Nullable().Default("anonymous").Comment("user name") +table.Timestamp("created_at").UseCurrent() +``` + +--- + +## Drop Columns + +```go +facades.Schema().Table("users", func(table schema.Blueprint) { + table.DropColumn("name") + table.DropColumn("name", "age") +}) +``` + +--- + +## Indexes + +```go +facades.Schema().Table("users", func(table schema.Blueprint) { + // Primary key + table.Primary("id") + table.Primary("id", "name") // composite + + // Unique index + table.Unique("name") + table.Unique("name", "age") // composite + + // Regular index + table.Index("name") + table.Index("name", "age") + + // Fulltext index + table.FullText("name") + table.FullText("name", "age") + + // Rename index + table.RenameIndex("users_name_index", "users_name") + + // Drop indexes + table.DropPrimary("id") + table.DropUnique("name") + table.DropUniqueByName("name_unique") + table.DropIndex("name") + table.DropIndexByName("name_index") + table.DropFullText("name") + table.DropFullTextByName("name_fulltext") +}) +``` + +--- + +## Foreign Keys + +```go +facades.Schema().Table("posts", func(table schema.Blueprint) { + table.UnsignedBigInteger("user_id") + table.Foreign("user_id").References("id").On("users") +}) + +// Drop foreign key +facades.Schema().Table("posts", func(table schema.Blueprint) { + table.DropForeign("user_id") + table.DropForeignByName("posts_user_id_foreign") +}) +``` diff --git a/.ai/prompt/models.md b/.ai/prompt/models.md new file mode 100644 index 000000000..a7120905c --- /dev/null +++ b/.ai/prompt/models.md @@ -0,0 +1,920 @@ +# Goravel ORM Models, Query Builder, Relationships, and Migrations + +## Model Definition + +Models live in `app/models/`. Model name is PascalCase; table name is plural snake_case. +`UserOrder` model maps to `user_orders` table. + +```go +package models + +import "github.com/goravel/framework/database/orm" + +type User struct { + orm.Model // adds id, created_at, updated_at + Name string + Avatar string + orm.SoftDeletes // adds deleted_at for soft delete support +} +``` + +### Custom table name + +```go +func (r *User) TableName() string { + return "goravel_user" +} +``` + +### Custom connection + +```go +func (r *User) Connection() string { + return "postgres" +} +``` + +### JSON field + +```go +import ( + "database/sql/driver" + "encoding/json" + "github.com/goravel/framework/database/orm" + "gorm.io/datatypes" +) + +type User struct { + orm.Model + Metadata datatypes.JSONMap `gorm:"type:json" json:"metadata"` + Profile *UserProfile `gorm:"type:json;serializer:json" json:"profile"` +} + +type UserProfile struct { + Bio string `json:"bio"` +} + +func (r *UserProfile) Value() (driver.Value, error) { + return json.Marshal(r) +} + +func (r *UserProfile) Scan(value any) error { + if data, ok := value.([]byte); ok && len(data) > 0 { + return json.Unmarshal(data, r) + } + return nil +} +``` + +### `any` field type + +```go +type User struct { + orm.Model + Detail any `gorm:"type:text"` +} +``` + +### Global scopes + +```go +import contractsorm "github.com/goravel/framework/contracts/database/orm" + +func (r *User) GlobalScopes() map[string]func(contractsorm.Query) contractsorm.Query { + return map[string]func(contractsorm.Query) contractsorm.Query{ + "active": func(query contractsorm.Query) contractsorm.Query { + return query.Where("active", true) + }, + } +} +``` + +Remove global scopes: + +```go +facades.Orm().Query().WithoutGlobalScopes().Get(&users) +facades.Orm().Query().WithoutGlobalScopes("active").Get(&users) +``` + +### Generate model + +```shell +./artisan make:model User +./artisan make:model user/User +./artisan make:model --table=users User +``` + +--- + +## Getting a Query Instance + +```go +facades.Orm().Query() +facades.Orm().Connection("mysql").Query() +facades.Orm().WithContext(ctx).Query() +``` + +--- + +## Querying + +### Find one by ID + +```go +var user models.User +facades.Orm().Query().Find(&user, 1) +// err is nil even when record does not exist +``` + +### Find or fail + +```go +var user models.User +err := facades.Orm().Query().FindOrFail(&user, 1) +// err is non-nil when record does not exist +``` + +### Find multiple by IDs + +```go +var users []models.User +facades.Orm().Query().Find(&users, []int{1, 2, 3}) +``` + +### First + +```go +var user models.User +facades.Orm().Query().First(&user) +// ordered by primary key, no error on missing record +``` + +### First or fail + +```go +import "github.com/goravel/framework/errors" + +var user models.User +err := facades.Orm().Query().FirstOrFail(&user) +if errors.Is(err, errors.OrmRecordNotFound) { + // not found +} +``` + +### First or default via callback + +```go +facades.Orm().Query().Where("name", "tom").FirstOr(&user, func() error { + user.Name = "default" + return nil +}) +``` + +### Get all matching + +```go +var users []models.User +facades.Orm().Query().Where("active", true).Get(&users) +``` + +--- + +## Where Conditions + +```go +facades.Orm().Query().Where("name", "tom") +facades.Orm().Query().Where("name = ?", "tom") +facades.Orm().Query().Where("age > ?", 18) + +facades.Orm().Query().WhereBetween("age", 1, 10) +facades.Orm().Query().WhereNotBetween("age", 1, 10) +facades.Orm().Query().WhereIn("name", []any{"a", "b"}) +facades.Orm().Query().WhereNotIn("name", []any{"a", "b"}) +facades.Orm().Query().WhereNull("deleted_at") + +facades.Orm().Query().OrWhere("name", "tim") +facades.Orm().Query().OrWhereIn("name", []any{"a", "b"}) +facades.Orm().Query().OrWhereNull("avatar") + +// All must match +facades.Orm().Query().WhereAll([]string{"weight", "height"}, "=", 200).Find(&products) + +// Any must match +facades.Orm().Query().WhereAny([]string{"name", "email"}, "=", "john").Find(&users) + +// None must match +facades.Orm().Query().WhereNone([]string{"age", "score"}, ">", 18).Find(&products) +``` + +### JSON column conditions + +```go +facades.Orm().Query().Where("preferences->dining->meal", "salad").First(&user) +facades.Orm().Query().WhereJsonContains("options->languages", "en").First(&user) +facades.Orm().Query().WhereJsonDoesntContain("options->languages", "en").First(&user) +facades.Orm().Query().WhereJsonContainsKey("contacts->personal->email").First(&user) +facades.Orm().Query().WhereJsonLength("options->languages", 1).First(&user) +``` + +--- + +## Ordering, Limit, Offset, Paginate + +```go +facades.Orm().Query().Order("name asc").Order("id desc").Get(&users) +facades.Orm().Query().OrderBy("name").Get(&users) // asc +facades.Orm().Query().OrderByDesc("name").Get(&users) // desc +facades.Orm().Query().InRandomOrder().Get(&users) + +facades.Orm().Query().Limit(10).Get(&users) +facades.Orm().Query().Offset(20).Limit(10).Get(&users) + +var total int64 +facades.Orm().Query().Paginate(1, 10, &users, &total) +``` + +--- + +## Select, Count, Aggregates + +```go +facades.Orm().Query().Select("name", "age").Get(&users) + +count, err := facades.Orm().Query().Model(&models.User{}).Count() +count, err := facades.Orm().Query().Table("users").Count() + +var sum int +facades.Orm().Query().Model(models.User{}).Sum("id", &sum) + +var avg float64 +facades.Orm().Query().Model(models.User{}).Average("age", &avg) + +var max int +facades.Orm().Query().Model(models.User{}).Max("age", &max) + +var min int +facades.Orm().Query().Model(models.User{}).Min("age", &min) +``` + +--- + +## Pluck, Distinct + +```go +var ages []int64 +facades.Orm().Query().Model(&models.User{}).Pluck("age", &ages) + +var users []models.User +facades.Orm().Query().Distinct("name").Find(&users) +``` + +--- + +## Group By / Having / Join + +```go +type Result struct { + Name string + Total int +} + +var result Result +facades.Orm().Query().Model(&models.User{}). + Select("name", "sum(age) as total"). + Group("name"). + Having("name = ?", "tom"). + Get(&result) +``` + +```go +facades.Orm().Query().Model(&models.User{}). + Select("users.name", "emails.email"). + Join("left join emails on emails.user_id = users.id"). + Scan(&result) +``` + +--- + +## Create + +```go +user := models.User{Name: "tom", Avatar: "avatar.png"} +err := facades.Orm().Query().Create(&user) +// user.ID is populated after create + +// Batch create +users := []models.User{{Name: "tom"}, {Name: "tim"}} +err := facades.Orm().Query().Create(&users) + +// Create via map (no model events) +err := facades.Orm().Query().Table("users").Create(map[string]any{"name": "Goravel"}) + +// Create via map (with model events) +err := facades.Orm().Query().Model(&models.User{}).Create(map[string]any{"name": "Goravel"}) +``` + +--- + +## Update + +```go +// Full save (updates all fields) +var user models.User +facades.Orm().Query().First(&user) +user.Name = "updated" +facades.Orm().Query().Save(&user) + +// Update single column +facades.Orm().Query().Model(&models.User{}).Where("name", "tom").Update("name", "hello") + +// Update via struct (non-zero fields only) +facades.Orm().Query().Model(&models.User{}).Where("id", 1).Update(models.User{Name: "hello"}) + +// Update via map (all fields including zeros) +facades.Orm().Query().Model(&models.User{}).Where("id", 1).Update(map[string]any{ + "name": "hello", + "age": 0, +}) + +// Update JSON field +facades.Orm().Query().Model(&models.User{}).Where("id", 1).Update("options->enabled", true) + +// Update or create +facades.Orm().Query().UpdateOrCreate(&user, models.User{Name: "tom"}, models.User{Avatar: "new.png"}) +``` + +--- + +## FirstOrCreate / FirstOrNew + +```go +// Find by conditions, create if not found +var user models.User +facades.Orm().Query().Where("gender", 1).FirstOrCreate(&user, models.User{Name: "tom"}) +facades.Orm().Query().Where("gender", 1).FirstOrCreate(&user, models.User{Name: "tom"}, models.User{Avatar: "avatar.png"}) + +// Like FirstOrCreate but does not save +facades.Orm().Query().Where("gender", 1).FirstOrNew(&user, models.User{Name: "tom"}) +// must call Save manually +facades.Orm().Query().Save(&user) +``` + +--- + +## Delete + +```go +var user models.User +facades.Orm().Query().Find(&user, 1) +res, err := facades.Orm().Query().Delete(&user) + +// Delete with conditions +res, err := facades.Orm().Query().Model(&models.User{}).Where("id", 1).Delete() + +// Force delete (skip soft delete) +facades.Orm().Query().Where("name", "tom").ForceDelete(&models.User{}) +facades.Orm().Query().Model(&models.User{}).Where("name", "tom").ForceDelete() + +num := res.RowsAffected +``` + +--- + +## Soft Delete + +```go +// Query including soft-deleted records +var user models.User +facades.Orm().Query().WithTrashed().First(&user) + +// Restore +facades.Orm().Query().WithTrashed().Restore(&models.User{ID: 1}) +facades.Orm().Query().Model(&models.User{ID: 1}).WithTrashed().Restore() +``` + +--- + +## Transactions + +```go +import "github.com/goravel/framework/contracts/database/orm" + +err := facades.Orm().Transaction(func(tx orm.Query) error { + var user models.User + if err := tx.Find(&user, 1); err != nil { + return err + } + return tx.Model(&user).Update("name", "updated") +}) +``` + +Manual transaction: + +```go +tx, err := facades.Orm().Query().BeginTransaction() +if err := tx.Create(&user); err != nil { + tx.Rollback() +} else { + tx.Commit() +} +``` + +--- + +## Raw SQL + +```go +type Result struct { + ID int + Name string +} + +var result Result +facades.Orm().Query().Raw("SELECT id, name FROM users WHERE name = ?", "tom").Scan(&result) + +// Raw update +res, err := facades.Orm().Query().Exec("DROP TABLE users") +num := res.RowsAffected +``` + +--- + +## Raw Expressions in Updates + +```go +import "github.com/goravel/framework/database/db" + +facades.Orm().Query().Model(&user).Update("age", db.Raw("age - ?", 1)) +``` + +--- + +## Scopes + +```go +import "github.com/goravel/framework/contracts/database/orm" + +func Paginator(page, limit int) func(orm.Query) orm.Query { + return func(query orm.Query) orm.Query { + offset := (page - 1) * limit + return query.Offset(offset).Limit(limit) + } +} + +facades.Orm().Query().Scopes(Paginator(2, 10)).Find(&users) +``` + +--- + +## Exists + +```go +exists, err := facades.Orm().Query().Model(&models.User{}).Where("name", "tom").Exists() +``` + +--- + +## Cursor (low-memory iteration) + +```go +cursor, err := facades.Orm().Query().Model(models.User{}).Cursor() +if err != nil { + return err +} +for row := range cursor { + var user models.User + if err := row.Scan(&user); err != nil { + return err + } +} +``` + +--- + +## Pessimistic Locking + +```go +facades.Orm().Query().Where("votes > ?", 100).SharedLock().Get(&users) +facades.Orm().Query().Where("votes > ?", 100).LockForUpdate().Get(&users) +``` + +--- + +## Relationships + +### One to One + +```go +type User struct { + orm.Model + Name string + Phone *Phone +} + +type Phone struct { + orm.Model + UserID uint + Name string +} +``` + +Custom foreign key: + +```go +type User struct { + orm.Model + Name string + Phone *Phone `gorm:"foreignKey:UserName"` +} + +type Phone struct { + orm.Model + UserName string + Name string +} +``` + +### One to Many + +```go +type Post struct { + orm.Model + Name string + Comments []*Comment +} + +type Comment struct { + orm.Model + PostID uint + Name string + Post *Post // inverse (belongs to) +} +``` + +### Many to Many + +```go +type User struct { + orm.Model + Name string + Roles []*Role `gorm:"many2many:role_user"` +} + +type Role struct { + orm.Model + Name string + Users []*User `gorm:"many2many:role_user"` +} +``` + +Custom pivot keys: + +```go +type User struct { + orm.Model + Name string + Roles []*Role `gorm:"many2many:role_user;joinForeignKey:UserName;joinReferences:RoleName"` +} +``` + +### Polymorphic + +```go +type Post struct { + orm.Model + Name string + Image *Image `gorm:"polymorphic:Imageable"` + Comments []*Comment `gorm:"polymorphic:Commentable"` +} + +type Image struct { + orm.Model + Name string + ImageableID uint + ImageableType string +} +``` + +Custom polymorphic value: + +```go +type Post struct { + orm.Model + Image *Image `gorm:"polymorphic:Imageable;polymorphicValue:master"` +} +``` + +--- + +## Eager Loading + +```go +// Eager load single relation +var books []models.Book +facades.Orm().Query().With("Author").Find(&books) + +// Multiple relations +facades.Orm().Query().With("Author").With("Publisher").Find(&book) + +// Nested +facades.Orm().Query().With("Author.Contacts").Find(&book) + +// With constraints +facades.Orm().Query().With("Author", "name = ?", "goravel").Find(&book) + +facades.Orm().Query().With("Author", func(query orm.Query) orm.Query { + return query.Where("active", true) +}).Find(&book) +``` + +### Lazy Eager Loading + +```go +var books []models.Book +facades.Orm().Query().Find(&books) + +for _, book := range books { + facades.Orm().Query().Load(&book, "Author") +} + +// With constraints +facades.Orm().Query().Load(&book, "Author", "name = ?", "goravel") + +// Only if not already loaded +facades.Orm().Query().LoadMissing(&book, "Author") +``` + +--- + +## Association Operations + +```go +var user models.User +facades.Orm().Query().Find(&user, 1) + +// Find all related +var posts []models.Post +facades.Orm().Query().Model(&user).Association("Posts").Find(&posts) + +// Append +facades.Orm().Query().Model(&user).Association("Posts").Append(&models.Post{Name: "new post"}) + +// Replace +facades.Orm().Query().Model(&user).Association("Posts").Replace([]*models.Post{post1, post2}) + +// Delete (removes relationship, not the record) +facades.Orm().Query().Model(&user).Association("Posts").Delete(post1) + +// Clear all +facades.Orm().Query().Model(&user).Association("Posts").Clear() + +// Count +count := facades.Orm().Query().Model(&user).Association("Posts").Count() +``` + +--- + +## ORM Events + +```go +import ( + contractsorm "github.com/goravel/framework/contracts/database/orm" + "github.com/goravel/framework/database/orm" +) + +type User struct { + orm.Model + Name string +} + +func (u *User) DispatchesEvents() map[contractsorm.EventType]func(contractsorm.Event) error { + return map[contractsorm.EventType]func(contractsorm.Event) error{ + contractsorm.EventCreating: func(event contractsorm.Event) error { + return nil + }, + contractsorm.EventCreated: func(event contractsorm.Event) error { + return nil + }, + contractsorm.EventUpdating: func(event contractsorm.Event) error { + return nil + }, + contractsorm.EventUpdated: func(event contractsorm.Event) error { + return nil + }, + contractsorm.EventDeleting: func(event contractsorm.Event) error { + return nil + }, + contractsorm.EventDeleted: func(event contractsorm.Event) error { + return nil + }, + } +} +``` + +### Observer + +```go +package observers + +import "github.com/goravel/framework/contracts/database/orm" + +type UserObserver struct{} + +func (u *UserObserver) Created(event orm.Event) error { return nil } +func (u *UserObserver) Updated(event orm.Event) error { return nil } +func (u *UserObserver) Deleted(event orm.Event) error { return nil } +``` + +Register observer in `WithCallback`: + +```go +WithCallback(func() { + facades.Orm().Observe(models.User{}, &observers.UserObserver{}) +}) +``` + +### Muting events + +```go +facades.Orm().Query().WithoutEvents().Find(&user, 1) +facades.Orm().Query().SaveQuietly(&user) +``` + +--- + +## Migrations + +### Create migration + +```shell +./artisan make:migration create_users_table +./artisan make:migration add_avatar_to_users_table +./artisan make:migration create_users_table -m User +``` + +### Migration struct + +```go +package migrations + +import ( + "github.com/goravel/framework/contracts/database/schema" + + "goravel/app/facades" +) + +type M20241207095921CreateUsersTable struct{} + +func (r *M20241207095921CreateUsersTable) Signature() string { + return "20241207095921_create_users_table" +} + +func (r *M20241207095921CreateUsersTable) Up() error { + if !facades.Schema().HasTable("users") { + return facades.Schema().Create("users", func(table schema.Blueprint) { + table.ID() + table.String("name").Nullable() + table.String("email").Nullable() + table.Timestamps() + table.SoftDeletes() + }) + } + return nil +} + +func (r *M20241207095921CreateUsersTable) Down() error { + return facades.Schema().DropIfExists("users") +} +``` + +Optional connection method: + +```go +func (r *M20241207095921CreateUsersTable) Connection() string { + return "postgres" +} +``` + +### Register migrations + +Generated migrations auto-register in `bootstrap/migrations.go`. Manual registration: + +```go +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithMigrations(migrations.Migrations). + WithConfig(config.Boot). + Create() +} +``` + +### Schema operations + +```go +// Create +facades.Schema().Create("users", func(table schema.Blueprint) { + table.ID() + table.String("name").Nullable() + table.String("email").Unique() + table.Timestamps() + table.SoftDeletes() +}) + +// Modify +facades.Schema().Table("users", func(table schema.Blueprint) { + table.String("avatar").Nullable() +}) + +// Rename column +facades.Schema().Table("users", func(table schema.Blueprint) { + table.RenameColumn("old_name", "new_name") +}) + +// Drop column +facades.Schema().Table("users", func(table schema.Blueprint) { + table.DropColumn("avatar") +}) + +// Drop table +facades.Schema().Drop("users") +facades.Schema().DropIfExists("users") +facades.Schema().Rename("users", "new_users") + +// Checks +facades.Schema().HasTable("users") +facades.Schema().HasColumn("users", "email") +facades.Schema().HasColumns("users", []string{"name", "email"}) +facades.Schema().HasIndex("users", "email_unique") +``` + +### Column types + +Boolean, Char, String, Text, MediumText, LongText, TinyText, Json, ID, BigIncrements, Integer, BigInteger, Decimal, Float, Double, Date, DateTime, Timestamp, Timestamps, SoftDeletes, Enum, Uuid, Ulid + +```go +table.ID() +table.String("name") +table.String("code", 10) +table.Text("body") +table.Integer("age") +table.BigInteger("views") +table.Boolean("active") +table.Decimal("price", 8, 2) +table.Timestamps() +table.SoftDeletes() +table.Enum("status", []any{"active", "inactive"}) +table.UnsignedBigInteger("user_id") +table.Column("geometry", "geometry") // custom type +``` + +### Column modifiers + +```go +table.String("name").Nullable() +table.String("role").Default("user") +table.Integer("sort").Default(0) +table.String("code").Unique() +table.Timestamp("published_at").UseCurrent() +``` + +### Indexes and foreign keys + +```go +// Indexes +table.Primary("id") +table.Unique("email") +table.Index("name") +table.FullText("body") + +// Foreign key +table.UnsignedBigInteger("user_id") +table.Foreign("user_id").References("id").On("users") + +// Drop index +table.DropUnique("email") +table.DropForeign("user_id") +``` + +### Migration commands + +```shell +./artisan migrate +./artisan migrate:rollback +./artisan migrate:rollback --step=5 +./artisan migrate:rollback --batch=2 +./artisan migrate:reset +./artisan migrate:refresh +./artisan migrate:fresh +./artisan migrate:status +``` + +--- + +## Gotchas + +- `Find` never errors on missing record. Always use `FindOrFail` when you need to confirm existence. +- `First` never errors on missing record. Use `FirstOrFail` instead. +- Struct-based `Update` silently skips zero values. Use `map[string]any` when zero values matter. +- Model events only fire when a model instance is passed to `Model()`. Batch operations without a model skip events. +- `WithTrashed()` must be chained before the query method to include soft-deleted records. +- Do not use `DispatchesEvents` and an observer on the same model: only `DispatchesEvents` applies when both are set. diff --git a/.ai/prompt/orm.md b/.ai/prompt/orm.md new file mode 100644 index 000000000..45951024a --- /dev/null +++ b/.ai/prompt/orm.md @@ -0,0 +1,474 @@ +# Goravel ORM + +## Model Definition + +```go +package models + +import "github.com/goravel/framework/database/orm" + +type User struct { + orm.Model // provides: ID, CreatedAt, UpdatedAt + Name string + Avatar string + Detail any `gorm:"type:text"` + orm.SoftDeletes // adds: DeletedAt (soft delete) +} + +// Optional: override table name +func (r *User) TableName() string { + return "goravel_user" +} + +// Optional: specify database connection +func (r *User) Connection() string { + return "postgres" +} +``` + +Convention: model `UserOrder` → table `user_orders`. + +Custom primary key (when not using `orm.Model`): + +```go +type User struct { + ID uint `gorm:"primaryKey"` + Name string +} +``` + +Generate model: + +```shell +./artisan make:model User +./artisan make:model --table=users User # generate from existing table +./artisan make:model --table=users -f User # force overwrite +``` + +### JSON fields + +```go +import ( + "database/sql/driver" + "encoding/json" + "gorm.io/datatypes" +) + +type User struct { + orm.Model + Json1 datatypes.JSONMap `gorm:"type:json" json:"json1"` + Json2 *UserData `gorm:"type:json;serializer:json" json:"json2"` +} +``` + +--- + +## Global Scopes (v1.17) + +// BREAKING v1.17: GlobalScopes() must return map[string]func(orm.Query) orm.Query, NOT []func(...) + +```go +import contractsorm "github.com/goravel/framework/contracts/database/orm" + +func (r *User) GlobalScopes() map[string]func(contractsorm.Query) contractsorm.Query { + return map[string]func(contractsorm.Query) contractsorm.Query{ + "active": func(query contractsorm.Query) contractsorm.Query { + return query.Where("active", 1) + }, + } +} +``` + +Remove global scopes in a query: + +```go +// Remove all global scopes +facades.Orm().Query().WithoutGlobalScopes().Get(&users) + +// Remove specific global scope by name +facades.Orm().Query().WithoutGlobalScopes("active").Get(&users) +``` + +--- + +## Query Operations + +### Get query instance + +```go +facades.Orm().Query() +facades.Orm().Connection("mysql").Query() +facades.Orm().WithContext(ctx).Query() +``` + +### Find by ID + +```go +var user models.User +facades.Orm().Query().Find(&user, 1) + +var users []models.User +facades.Orm().Query().Find(&users, []int{1, 2, 3}) +``` + +Note: `Find` returns nil error when record is not found. Use `FindOrFail` to error on missing. + +### FindOrFail (errors if not found) + +```go +// BREAKING: Find returns nil error even when not found +err := facades.Orm().Query().FindOrFail(&user, 1) +``` + +### First + +```go +var user models.User +facades.Orm().Query().First(&user) +facades.Orm().Query().Where("name", "tom").First(&user) + +// First or fail +err := facades.Orm().Query().FirstOrFail(&user) + +// First or execute closure +facades.Orm().Query().Where("name", "tom").FirstOr(&user, func() error { + user.Name = "default" + return nil +}) +``` + +### Get (multiple) + +```go +var users []models.User +facades.Orm().Query().Where("id in ?", []int{1, 2, 3}).Get(&users) +``` + +### FirstOrCreate / FirstOrNew + +```go +var user models.User +// Find by conditions, or create: +facades.Orm().Query().Where("gender", 1).FirstOrCreate(&user, models.User{Name: "tom"}) +facades.Orm().Query().Where("gender", 1).FirstOrCreate(&user, models.User{Name: "tom"}, models.User{Avatar: "avatar"}) + +// Find or return new (unsaved) model: +facades.Orm().Query().Where("gender", 1).FirstOrNew(&user, models.User{Name: "tom"}) +``` + +--- + +## Where Clauses + +```go +facades.Orm().Query().Where("name", "tom") +facades.Orm().Query().Where("name = ?", "tom") +facades.Orm().Query().WhereBetween("age", 1, 10) +facades.Orm().Query().WhereNotBetween("age", 1, 10) +facades.Orm().Query().WhereIn("name", []any{"a", "b"}) +facades.Orm().Query().WhereNotIn("name", []any{"a"}) +facades.Orm().Query().WhereNull("name") +facades.Orm().Query().OrWhere("name", "tim") +facades.Orm().Query().OrWhereIn("name", []any{"a"}) +facades.Orm().Query().OrWhereNull("name") + +// v1.17: new Where methods +facades.Orm().Query().WhereAll([]string{"weight", "height"}, "=", 200) // AND all columns match +facades.Orm().Query().WhereAny([]string{"name", "email"}, "=", "John") // OR any column matches +facades.Orm().Query().WhereNone([]string{"age", "score"}, ">", 18) // NOT any column matches +``` + +### JSON column queries + +```go +facades.Orm().Query().Where("preferences->dining->meal", "salad").First(&user) +facades.Orm().Query().WhereJsonContains("options->languages", "en").First(&user) +facades.Orm().Query().WhereJsonContainsKey("contacts->personal->email").First(&user) +facades.Orm().Query().WhereJsonLength("options->languages", 1).First(&user) +``` + +--- + +## Select, Order, Limit, Offset + +```go +facades.Orm().Query().Select("name", "age").Get(&users) +facades.Orm().Query().Order("sort asc").Order("id desc").Get(&users) +facades.Orm().Query().OrderBy("sort").Get(&users) +facades.Orm().Query().OrderByDesc("sort").Get(&users) +facades.Orm().Query().InRandomOrder().Get(&users) +facades.Orm().Query().Limit(10).Get(&users) +facades.Orm().Query().Offset(20).Limit(10).Get(&users) +``` + +### Paginate + +```go +var users []models.User +var total int64 +facades.Orm().Query().Paginate(1, 10, &users, &total) +``` + +### Count, Exists + +```go +count, err := facades.Orm().Query().Model(&models.User{}).Count() +exists, err := facades.Orm().Query().Model(&models.User{}).Where("name", "tom").Exists() +``` + +### Aggregates (v1.17) + +```go +// BREAKING v1.17: Sum signature changed — Sum(column string, dest any) error (was int64, error) +var sum int +err := facades.Orm().Query().Model(models.User{}).Sum("id", &sum) + +var avg float64 +err = facades.Orm().Query().Model(models.User{}).Average("age", &avg) + +var max, min int +err = facades.Orm().Query().Model(models.User{}).Max("age", &max) +err = facades.Orm().Query().Model(models.User{}).Min("age", &min) +``` + +### Group By / Having / Join + +```go +facades.Orm().Query().Model(&models.User{}). + Select("name", "sum(age) as total"). + Group("name"). + Having("name = ?", "tom"). + Get(&result) + +facades.Orm().Query().Model(&models.User{}). + Select("users.name", "emails.email"). + Join("left join emails on emails.user_id = users.id"). + Scan(&result) +``` + +### Pluck (single column) + +```go +var ages []int64 +facades.Orm().Query().Model(&models.User{}).Pluck("age", &ages) +``` + +### Cursor (memory-efficient iteration) + +```go +cursor, err := facades.Orm().Query().Model(models.User{}).Cursor() +for row := range cursor { + var user models.User + if err := row.Scan(&user); err != nil { + return err + } +} +``` + +### Raw SQL + +```go +facades.Orm().Query().Raw("SELECT id, name FROM users WHERE name = ?", "tom").Scan(&result) + +res, err := facades.Orm().Query().Exec("DROP TABLE users") +num := res.RowsAffected +``` + +--- + +## Create + +```go +user := models.User{Name: "tom", Age: 18} +err := facades.Orm().Query().Create(&user) + +// Batch create +users := []models.User{{Name: "tom"}, {Name: "tim"}} +err = facades.Orm().Query().Create(&users) + +// Create via map (no model events) +err = facades.Orm().Query().Table("users").Create(map[string]any{"name": "Goravel"}) + +// Create via map (with model events) +err = facades.Orm().Query().Model(&models.User{}).Create(map[string]any{"name": "Goravel"}) +``` + +--- + +## Update + +```go +// Update single column +facades.Orm().Query().Model(&models.User{}).Where("name", "tom").Update("name", "hello") + +// Update via struct (zero values are skipped) +facades.Orm().Query().Model(&models.User{}).Where("name", "tom").Update(models.User{Name: "hello"}) + +// Update via map (zero values ARE updated) +facades.Orm().Query().Model(&models.User{}).Where("name", "tom").Update(map[string]any{"name": "hello", "age": 0}) + +// Save (full update of existing model) +var user models.User +facades.Orm().Query().First(&user) +user.Name = "new-name" +facades.Orm().Query().Save(&user) + +// UpdateOrCreate +facades.Orm().Query().UpdateOrCreate(&user, models.User{Name: "name"}, models.User{Avatar: "avatar"}) + +// Raw expression in update +import "github.com/goravel/framework/database/db" +facades.Orm().Query().Model(&user).Update("age", db.Raw("age - ?", 1)) +``` + +--- + +## Delete + +```go +// Soft delete +facades.Orm().Query().Delete(&user) +facades.Orm().Query().Model(&models.User{}).Where("id", 1).Delete() + +// Force delete (bypass soft delete) +facades.Orm().Query().ForceDelete(&models.User{}, 1) +facades.Orm().Query().Model(&models.User{}).Where("name", "tom").ForceDelete() + +// Query with soft-deleted records +facades.Orm().Query().WithTrashed().First(&user) + +// Restore soft-deleted record +facades.Orm().Query().WithTrashed().Restore(&models.User{ID: 1}) +``` + +--- + +## Transactions + +```go +// Automatic (closure-based) +return facades.Orm().Transaction(func(tx orm.Query) error { + var user models.User + return tx.Find(&user, user.ID) +}) + +// Manual +tx, err := facades.Orm().Query().BeginTransaction() +if err := tx.Create(&user); err != nil { + tx.Rollback() +} else { + tx.Commit() +} +``` + +--- + +## Scopes + +```go +func Paginator(page, limit int) func(orm.Query) orm.Query { + return func(query orm.Query) orm.Query { + offset := (page - 1) * limit + return query.Offset(offset).Limit(limit) + } +} + +facades.Orm().Query().Scopes(Paginator(2, 10)).Find(&users) +``` + +--- + +## Pessimistic Locking + +```go +facades.Orm().Query().Where("votes > ?", 100).SharedLock().Get(&users) +facades.Orm().Query().Where("votes > ?", 100).LockForUpdate().Get(&users) +``` + +--- + +## Model Events (DispatchesEvents) + +```go +import contractsorm "github.com/goravel/framework/contracts/database/orm" + +func (u *User) DispatchesEvents() map[contractsorm.EventType]func(contractsorm.Event) error { + return map[contractsorm.EventType]func(contractsorm.Event) error{ + contractsorm.EventCreating: func(event contractsorm.Event) error { return nil }, + contractsorm.EventCreated: func(event contractsorm.Event) error { return nil }, + contractsorm.EventUpdating: func(event contractsorm.Event) error { return nil }, + contractsorm.EventUpdated: func(event contractsorm.Event) error { return nil }, + contractsorm.EventDeleting: func(event contractsorm.Event) error { return nil }, + contractsorm.EventDeleted: func(event contractsorm.Event) error { return nil }, + contractsorm.EventSaving: func(event contractsorm.Event) error { return nil }, + contractsorm.EventSaved: func(event contractsorm.Event) error { return nil }, + contractsorm.EventRetrieved: func(event contractsorm.Event) error { return nil }, + contractsorm.EventRestored: func(event contractsorm.Event) error { return nil }, + } +} +``` + +--- + +## Observers + +Generate: + +```shell +./artisan make:observer UserObserver +``` + +```go +package observers + +import "github.com/goravel/framework/contracts/database/orm" + +type UserObserver struct{} + +func (u *UserObserver) Created(event orm.Event) error { return nil } +func (u *UserObserver) Updated(event orm.Event) error { return nil } +func (u *UserObserver) Deleted(event orm.Event) error { return nil } +func (u *UserObserver) Restored(event orm.Event) error { return nil } +``` + +Register in `WithCallback`: + +```go +WithCallback(func() { + facades.Orm().Observe(models.User{}, &observers.UserObserver{}) +}) +``` + +Event parameter methods: `Context()`, `GetAttribute(key)`, `GetOriginal(key)`, `IsDirty(key)`, `IsClean(key)`, `Query()`, `SetAttribute(key, val)`. + +--- + +## Muting Events + +```go +facades.Orm().Query().WithoutEvents().Find(&user, 1) + +// Save without triggering events +facades.Orm().Query().SaveQuietly(&user) +``` + +--- + +## Connection Pool + +```go +db, err := facades.Orm().DB() +db.SetMaxIdleConns(10) +db.SetMaxOpenConns(100) +db.SetConnMaxLifetime(time.Hour) +``` + +--- + +## Gotchas + +- `Find(&model, id)` returns nil error even when no record is found. Use `FindOrFail` to get an error on missing. +- Struct updates with `Update(struct{})` skip zero-value fields. Use `map[string]any` to set zero values. +- `GlobalScopes()` must return `map[string]func(orm.Query) orm.Query` — not a slice. +- `Sum(column, &dest)` signature: `error` only (was `(int64, error)` in v1.16). +- Model events only trigger when operating on a model instance. Batch operations do not trigger events. diff --git a/.ai/prompt/process.md b/.ai/prompt/process.md new file mode 100644 index 000000000..909a0687e --- /dev/null +++ b/.ai/prompt/process.md @@ -0,0 +1,207 @@ +# Goravel Process Facade (v1.17) + +The Process facade provides a fluent API for executing external commands via `facades.Process()`. + +## Synchronous Execution + +```go +import "goravel/app/facades" + +// Run with separate args +result := facades.Process().Run("ls", "-la") + +// Run as shell string (spaces/&/| trigger /bin/sh -c on Linux, cmd /C on Windows) +result = facades.Process().Run("echo Hello, World!") + +if result.Failed() { + panic(result.Error()) +} +fmt.Println(result.Output()) +``` + +### Result Interface + +```go +result.Command() // string: original command +result.Output() // string: stdout +result.ErrorOutput() // string: stderr +result.ExitCode() // int: exit code +result.Failed() // bool: true if exit code != 0 +result.Error() // error: from command execution +result.SeeInOutput("go.mod") // bool +result.SeeInErrorOutput("error") // bool +``` + +--- + +## Process Options + +```go +facades.Process(). + Path("/var/www/html"). // working directory + Timeout(10 * time.Minute). // kill after duration + Env(map[string]string{ // add env vars (inherits system envs) + "FOO": "BAR", + "API_KEY": "secret", + }). + Input(strings.NewReader("stdin data")). // pipe stdin + Run("command", "arg1") +``` + +--- + +## Real-time Output (Streaming) + +```go +import "github.com/goravel/framework/contracts/process" + +result := facades.Process().OnOutput(func(typ process.OutputType, b []byte) { + fmt.Print(string(b)) +}).Run("ls", "-la") +``` + +--- + +## Suppress / Disable Output + +```go +// Captures output but doesn't print during execution +facades.Process().Quietly().Run("command") + +// Does not capture in memory (saves memory); use OnOutput to stream +facades.Process().DisableBuffering().OnOutput(func(typ process.OutputType, b []byte) { + // stream here +}).Run("command") +``` + +--- + +## Pipelines + +```go +import "github.com/goravel/framework/contracts/process" + +result := facades.Process().Pipe(func(pipe process.Pipe) { + pipe.Command("echo", "Hello, World!") + pipe.Command("grep World") // shell string + pipe.Command("tr", "a-z", "A-Z") +}).Run() + +// Options must be applied AFTER Pipe(), not before: +result = facades.Process().Pipe(func(pipe process.Pipe) { + pipe.Command("cat", "file.txt").As("source") + pipe.Command("grep error").As("filter") +}).Timeout(30 * time.Second).OnOutput(func(typ process.OutputType, line []byte, key string) { + fmt.Printf("[%s] %s", key, string(line)) +}).Run() +``` + +--- + +## Asynchronous Processes + +```go +running, err := facades.Process().Timeout(10 * time.Second).Start("sleep", "5") + +// Continue doing other work... + +result := running.Wait() // must always call Wait() + +// Non-blocking check via channel +select { +case <-running.Done(): + // finished +case <-time.After(1 * time.Second): + // still running +} +result = running.Wait() +``` + +### Inspect and Signal + +```go +running, err := facades.Process().Start("sleep", "60") + +pid := running.PID() +running.Running() // bool: process active? +running.Signal(os.Interrupt) // send specific signal +running.Stop(5 * time.Second) // SIGTERM, then SIGKILL if still alive after timeout +``` + +--- + +## Concurrent Processes (Pool) + +```go +import "github.com/goravel/framework/contracts/process" + +results, err := facades.Process().Pool(func(pool process.Pool) { + pool.Command("sleep", "1").As("first") + pool.Command("sleep 2").As("second") +}).Run() + +fmt.Println(results["first"].Output()) +fmt.Println(results["second"].Output()) +``` + +### Pool Options + +```go +facades.Process().Pool(func(pool process.Pool) { + for i := 0; i < 10; i++ { + pool.Command("worker", fmt.Sprintf("%d", i)).As(fmt.Sprintf("worker-%d", i)) + } +}). + Concurrency(3). // max 3 concurrent + Timeout(1 * time.Minute). // global timeout for entire pool + OnOutput(func(typ process.OutputType, line []byte, key string) { + fmt.Printf("[%s] %s", key, string(line)) + }). + Run() +``` + +### Per-process Options in Pool + +```go +pool.Command("find", "/", "-name", "*.log"). + As("search"). + Path("/var/www"). + Timeout(10 * time.Second). + Env(map[string]string{"DEBUG": "1"}). + Quietly(). + DisableBuffering() +``` + +### Async Pool + +```go +runningPool, err := facades.Process().Pool(func(pool process.Pool) { + pool.Command("sleep", "5").As("long_task") +}).Start() + +if runningPool.Running() { + fmt.Println("Pool active...") +} + +// Interact +pids := runningPool.PIDs() +runningPool.Signal(os.Interrupt) +runningPool.Stop(2 * time.Second) + +select { +case <-runningPool.Done(): + // all finished +case <-time.After(10 * time.Second): + runningPool.Stop(1 * time.Second) +} + +results := runningPool.Wait() +``` + +--- + +## Gotchas + +- Always call `running.Wait()` after `Start()` even if you use `Done()` — it reaps OS resources. +- Pool `OnOutput` callback is called from multiple goroutines; make it thread-safe. +- Process options (`Timeout`, `Env`, `Input`) set before `Pipe()` are ignored — apply them after `Pipe()`. diff --git a/.ai/prompt/queue.md b/.ai/prompt/queue.md new file mode 100644 index 000000000..80f6855b8 --- /dev/null +++ b/.ai/prompt/queue.md @@ -0,0 +1,180 @@ +# Goravel Queue + +## Configuration + +Configure in `config/queue.go`. Default driver: `sync` (runs inline, no queue). + +// BREAKING v1.17: Machinery driver is completely removed. Migrate to `redis`, `database`, or `sync`. + +Available drivers: `sync`, `database`, `redis` (external package), `custom`. + +--- + +## Define a Job + +```shell +./artisan make:job ProcessPodcast +./artisan make:job user/ProcessPodcast +``` + +```go +package jobs + +import "time" + +type ProcessPodcast struct{} + +// Signature is the unique identifier for the job +func (r *ProcessPodcast) Signature() string { + return "process_podcast" +} + +// Handle executes the job; args come from dispatch +func (r *ProcessPodcast) Handle(args ...any) error { + // process args + return nil +} + +// ShouldRetry (optional) controls retry behavior on failure +func (r *ProcessPodcast) ShouldRetry(err error, attempt int) (retryable bool, delay time.Duration) { + return true, 10 * time.Second +} +``` + +--- + +## Register Jobs + +Jobs created by `make:job` auto-register in `bootstrap/jobs.go`. Register in `bootstrap/app.go`: + +```go +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithJobs(Jobs). + WithConfig(config.Boot). + Create() +} +``` + +--- + +## Dispatch Jobs + +```go +import ( + "github.com/goravel/framework/contracts/queue" + "goravel/app/facades" + "goravel/app/jobs" +) + +// Default connection and queue +err := facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{ + {Type: "string", Value: "podcast.mp3"}, + {Type: "int", Value: 42}, +}).Dispatch() + +// Synchronous dispatch (runs immediately, no queue) +err = facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}).DispatchSync() + +// Specific queue +err = facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}).OnQueue("processing").Dispatch() + +// Specific connection +err = facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}).OnConnection("redis").Dispatch() + +// Connection + queue +err = facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}). + OnConnection("redis").OnQueue("processing").Dispatch() + +// Delayed dispatch +err = facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}). + Delay(time.Now().Add(100 * time.Second)).Dispatch() +``` + +--- + +## Job Chaining + +Jobs run in order; if one fails, remaining jobs are not executed: + +```go +err := facades.Queue().Chain([]queue.Jobs{ + { + Job: &jobs.ProcessPodcast{}, + Args: []queue.Arg{{Type: "int", Value: 1}}, + }, + { + Job: &jobs.NotifySubscribers{}, + Args: []queue.Arg{{Type: "int", Value: 2}}, + }, +}).Dispatch() +``` + +--- + +## Supported `queue.Arg.Type` Values + +``` +bool, int, int8, int16, int32, int64, +uint, uint8, uint16, uint32, uint64, +float32, float64, string, +[]bool, []int, []int8, []int16, []int32, []int64, +[]uint, []uint8, []uint16, []uint32, []uint64, +[]float32, []float64, []string +``` + +--- + +## Database Driver Setup + +For the `database` driver, create the jobs table using the migration at: +`database/migrations/20210101000002_create_jobs_table.go` + +--- + +## Custom Driver + +```go +// config/queue.go +"connections": map[string]any{ + "redis": map[string]any{ + "driver": "custom", + "connection": "default", + "queue": "default", + "via": func() (queue.Driver, error) { + return redisfacades.Queue("redis"), nil + }, + }, +}, +``` + +--- + +## Failed Jobs + +```shell +# View failed jobs +./artisan queue:failed + +# Retry a specific job (UUID from failed_jobs table) +./artisan queue:retry 4427387e-c75a-4295-afb3-2f3d0e410494 + +# Retry all failed jobs +./artisan queue:retry all + +# Retry by connection or queue +./artisan queue:retry --connection=redis +./artisan queue:retry --queue=processing +``` + +--- + +## Custom Queue Runner + +```go +WithRunners(func() []contractsfoundation.Runner { + return []contractsfoundation.Runner{ + YourCustomQueueRunner, + } +}) +``` diff --git a/.ai/prompt/queues.md b/.ai/prompt/queues.md new file mode 100644 index 000000000..016733a2a --- /dev/null +++ b/.ai/prompt/queues.md @@ -0,0 +1,260 @@ +# Goravel Queues + +## Job Definition + +Jobs live in `app/jobs/`. + +```go +package jobs + +type ProcessPodcast struct{} + +func (r *ProcessPodcast) Signature() string { + return "process_podcast" +} + +func (r *ProcessPodcast) Handle(args ...any) error { + // args are positional, matching the []queue.Arg slice passed at dispatch + return nil +} +``` + +### With retry control + +```go +import "time" + +func (r *ProcessPodcast) ShouldRetry(err error, attempt int) (retryable bool, delay time.Duration) { + return true, 10 * time.Second +} +``` + +### Generate job + +```shell +./artisan make:job ProcessPodcast +./artisan make:job user/ProcessPodcast +``` + +--- + +## Register Jobs + +Generated jobs auto-register in `bootstrap/jobs.go`. Manual registration: + +```go +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithJobs(jobs.Jobs). + WithConfig(config.Boot). + Create() +} +``` + +--- + +## Dispatching Jobs + +```go +import ( + "github.com/goravel/framework/contracts/queue" + "goravel/app/facades" + "goravel/app/jobs" +) + +// Dispatch to default queue +err := facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{ + {Type: "int", Value: 1}, + {Type: "string", Value: "example"}, +}).Dispatch() + +// Dispatch synchronously (runs immediately in current process) +err := facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{ + {Type: "int", Value: 1}, +}).DispatchSync() +``` + +--- + +## Dispatch Options + +### Named queue + +```go +err := facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}).OnQueue("emails").Dispatch() +``` + +### Named connection + +```go +err := facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}).OnConnection("redis").Dispatch() +``` + +### Connection and queue together + +```go +err := facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}). + OnConnection("redis"). + OnQueue("high"). + Dispatch() +``` + +### Delayed dispatch + +```go +import "time" + +err := facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}). + Delay(time.Now().Add(100 * time.Second)). + Dispatch() +``` + +--- + +## Job Chaining + +Executes jobs in order. Stops on first failure. + +```go +err := facades.Queue().Chain([]queue.Jobs{ + { + Job: &jobs.ProcessPodcast{}, + Args: []queue.Arg{ + {Type: "int", Value: 1}, + }, + }, + { + Job: &jobs.SendPodcastEmail{}, + Args: []queue.Arg{ + {Type: "string", Value: "user@example.com"}, + }, + }, +}).Dispatch() +``` + +--- + +## Supported Arg Types + +``` +bool, int, int8, int16, int32, int64 +uint, uint8, uint16, uint32, uint64 +float32, float64 +string +[]bool, []int, []int8, []int16, []int32, []int64 +[]uint, []uint8, []uint16, []uint32, []uint64 +[]float32, []float64 +[]string +``` + +--- + +## Drivers + +Configure in `config/queue.go`. + +| Driver | Description | +|--------|-------------| +| `sync` | Runs in current process, no queue (default) | +| `database` | Stores jobs in database table | +| custom | Implement `contracts/queue/driver.go` interface | + +### Database driver setup + +Create the jobs table migration: `20210101000002_create_jobs_table.go` (included in the default goravel template). + +### Custom driver configuration + +```go +// config/queue.go +"connections": map[string]any{ + "redis": map[string]any{ + "driver": "custom", + "connection": "default", + "queue": "default", + "via": func() (queue.Driver, error) { + return redisfacades.Queue("redis") + }, + }, +}, +``` + +--- + +## Failed Jobs + +View failed jobs: + +```shell +./artisan queue:failed +``` + +Retry failed jobs: + +```shell +# Single job by UUID +./artisan queue:retry 4427387e-c75a-4295-afb3-2f3d0e410494 + +# Multiple jobs +./artisan queue:retry uuid1 uuid2 + +# All failed jobs +./artisan queue:retry all + +# By connection +./artisan queue:retry --connection=redis + +# By queue +./artisan queue:retry --queue=processing +``` + +--- + +## Reading Args in Handle + +Args are passed positionally. Match by index: + +```go +func (r *ProcessPodcast) Handle(args ...any) error { + podcastID := args[0].(int) + title := args[1].(string) + return nil +} +``` + +Dispatched with: + +```go +facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{ + {Type: "int", Value: podcastID}, + {Type: "string", Value: title}, +}).Dispatch() +``` + +--- + +## Custom Queue Workers (Runners) + +The default queue worker is started automatically. To run additional workers with different config, add a runner: + +```go +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithConfig(config.Boot). + WithRunners(func() []contractsfoundation.Runner { + return []contractsfoundation.Runner{ + NewCustomQueueRunner(), + } + }). + Create() +} +``` + +--- + +## Gotchas + +- `Type` in `queue.Arg` must be an exact string from the supported list. Invalid types cause silent failures. +- Arguments in `Handle(args ...any)` are positional. Order must match the `[]queue.Arg` slice passed at dispatch. +- The sync driver runs jobs synchronously in the same process. It does not queue anything. Use it for testing or development only. +- For database driver, create the `jobs` and `failed_jobs` tables before dispatching. diff --git a/.ai/prompt/route.md b/.ai/prompt/route.md new file mode 100644 index 000000000..4bb967874 --- /dev/null +++ b/.ai/prompt/route.md @@ -0,0 +1,360 @@ +# Goravel Routing + +## Basic Routing + +```go +import "github.com/goravel/framework/contracts/http" + +facades.Route().Get("/", func(ctx http.Context) http.Response { + return ctx.Response().Json(http.StatusOK, http.Json{"Hello": "Goravel"}) +}) +facades.Route().Post("/", userController.Show) +facades.Route().Put("/", userController.Show) +facades.Route().Delete("/", userController.Show) +facades.Route().Patch("/", userController.Show) +facades.Route().Options("/", userController.Show) +facades.Route().Any("/", userController.Show) +``` + +Every handler must have signature: `func(ctx http.Context) http.Response` + +--- + +## Route Parameters + +```go +facades.Route().Get("/input/{id}", func(ctx http.Context) http.Response { + return ctx.Response().Success().Json(http.Json{ + "id": ctx.Request().Input("id"), + }) +}) +``` + +--- + +## Resource Routing + +```go +import "github.com/goravel/framework/contracts/http" + +resourceController := NewResourceController() +facades.Route().Resource("/resource", resourceController) + +type ResourceController struct{} +func NewResourceController() *ResourceController { return &ResourceController{} } + +// GET /resource → Index +// GET /resource/{id} → Show +// POST /resource → Store +// PUT /resource/{id} → Update +// DELETE /resource/{id} → Destroy +func (c *ResourceController) Index(ctx http.Context) http.Response { ... } +func (c *ResourceController) Show(ctx http.Context) http.Response { ... } +func (c *ResourceController) Store(ctx http.Context) http.Response { ... } +func (c *ResourceController) Update(ctx http.Context) http.Response { ... } +func (c *ResourceController) Destroy(ctx http.Context) http.Response { ... } +``` + +--- + +## Group Routing + +```go +import "github.com/goravel/framework/contracts/route" + +facades.Route().Group(func(router route.Router) { + router.Get("group/{id}", func(ctx http.Context) http.Response { + return ctx.Response().Success().String(ctx.Request().Query("id", "1")) + }) +}) +``` + +--- + +## Routing Prefix + +```go +facades.Route().Prefix("users").Get("/", userController.Show) +``` + +--- + +## Middleware on Routes + +```go +import "github.com/goravel/framework/http/middleware" + +facades.Route().Middleware(middleware.Cors()).Get("users", userController.Show) + +// Multiple middleware: +facades.Route().Middleware(middleware.Auth(), middleware.Throttle("api")).Get("profile", profileController.Show) +``` + +--- + +## Route Naming + +```go +facades.Route().Get("users", userController.Index).Name("users.index") +``` + +## Get Route Info by Name + +```go +route := facades.Route().Info("users.index") +``` + +## Get All Routes + +```go +routes := facades.Route().GetRoutes() +``` + +## List Routes (CLI) + +```shell +./artisan route:list +``` + +--- + +## Fallback Routes + +```go +facades.Route().Fallback(func(ctx http.Context) http.Response { + return ctx.Response().String(404, "not found") +}) +``` + +--- + +## File Routing + +```go +import "net/http" + +facades.Route().Static("static", "./public") +facades.Route().StaticFile("static-file", "./public/logo.png") +facades.Route().StaticFS("static-fs", http.Dir("./public")) +``` + +--- + +## Default Route File Registration + +```go +// bootstrap/app.go +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithRouting(func() { + routes.Web() + routes.Api() + }). + WithConfig(config.Boot). + Create() +} +``` + +--- + +## Rate Limiting + +### Define Rate Limiters + +Define in `WithCallback` in `bootstrap/app.go`: + +```go +import ( + contractshttp "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/http/limit" +) + +WithCallback(func() { + // Simple per-minute limit + facades.RateLimiter().For("global", func(ctx contractshttp.Context) contractshttp.Limit { + return limit.PerMinute(1000) + }) + + // Custom response on limit exceeded + facades.RateLimiter().For("api", func(ctx contractshttp.Context) contractshttp.Limit { + return limit.PerMinute(60).Response(func(ctx contractshttp.Context) { + ctx.Request().AbortWithStatus(http.StatusTooManyRequests) + }) + }) + + // Dynamic limit based on request + facades.RateLimiter().For("dynamic", func(ctx contractshttp.Context) contractshttp.Limit { + if is_vip() { + return limit.PerMinute(100) + } + return nil + }) + + // Segment by IP + facades.RateLimiter().For("per-ip", func(ctx contractshttp.Context) contractshttp.Limit { + if is_vip() { + return limit.PerMinute(100).By(ctx.Request().Ip()) + } + return nil + }) + + // Segment by user/IP with fallback + facades.RateLimiter().For("user-or-ip", func(ctx contractshttp.Context) contractshttp.Limit { + if userID != 0 { + return limit.PerMinute(100).By(userID) + } + return limit.PerMinute(10).By(ctx.Request().Ip()) + }) +}) +``` + +### Multiple Rate Limits + +```go +facades.RateLimiter().ForWithLimits("login", func(ctx contractshttp.Context) []contractshttp.Limit { + return []contractshttp.Limit{ + limit.PerMinute(500), + limit.PerMinute(100).By(ctx.Request().Ip()), + } +}) +``` + +### Attach Rate Limiter to Route + +```go +import "github.com/goravel/framework/http/middleware" + +facades.Route().Middleware(middleware.Throttle("global")).Get("/", func(ctx http.Context) http.Response { + return ctx.Response().Json(200, http.Json{"Hello": "Goravel"}) +}) +``` + +--- + +## CORS + +CORS is enabled by default. Configure in `config/cors.go`: + +```go +// config/cors.go +config.Add("cors", map[string]any{ + "paths": []string{}, // paths to apply CORS to (empty = all) + "allowed_methods": []string{"*"}, // or []string{"GET", "POST", "PUT", "DELETE"} + "allowed_origins": []string{"*"}, // or []string{"https://example.com"} + "allowed_headers": []string{"*"}, // or []string{"Content-Type", "Authorization"} + "exposed_headers": []string{}, + "max_age": 0, // preflight cache seconds (0 = no cache) + "supports_credentials": false, // true enables cookies/auth headers +}) +``` + +Apply CORS middleware on specific routes: + +```go +import "github.com/goravel/framework/http/middleware" + +facades.Route().Middleware(middleware.Cors()).Get("users", userController.Show) +``` + +--- + +## HTTP Drivers + +Install via artisan: + +```shell +./artisan package:install github.com/goravel/gin +./artisan package:install github.com/goravel/fiber +``` + +### Gin (default) + +```go +// config/http.go +import ( + "github.com/gin-gonic/gin/render" + "github.com/goravel/framework/contracts/route" + "github.com/goravel/gin" + ginfacades "github.com/goravel/gin/facades" +) + +config.Add("http", map[string]any{ + "default": "gin", + "drivers": map[string]any{ + "gin": map[string]any{ + "body_limit": 4096, // KB, default 4096 + "header_limit": 4096, // KB, default 4096 + "route": func() (route.Route, error) { + return ginfacades.Route("gin"), nil + }, + // Optional: custom HTML template renderer + "template": func() (render.HTMLRender, error) { + return gin.DefaultTemplate() + }, + }, + }, + "url": config.Env("APP_URL", "http://localhost"), + "host": config.Env("APP_HOST", "127.0.0.1"), + "port": config.Env("APP_PORT", "3000"), + "request_timeout": 3, // seconds + "tls": map[string]any{ + "host": config.Env("APP_HOST", "127.0.0.1"), + "port": config.Env("APP_PORT", "3000"), + "ssl": map[string]any{ + "cert": "", // path to cert file + "key": "", // path to key file + }, + }, + // HTTP client configuration (for facades.Http()) + "default_client": config.Env("HTTP_CLIENT_DEFAULT", "default"), + "clients": map[string]any{ + "default": map[string]any{ + "base_url": config.Env("HTTP_CLIENT_BASE_URL", ""), + "timeout": config.Env("HTTP_CLIENT_TIMEOUT", "30s"), + "max_idle_conns": config.Env("HTTP_CLIENT_MAX_IDLE_CONNS", 100), + "max_idle_conns_per_host": config.Env("HTTP_CLIENT_MAX_IDLE_CONNS_PER_HOST", 2), + "max_conns_per_host": config.Env("HTTP_CLIENT_MAX_CONN_PER_HOST", 0), + "idle_conn_timeout": config.Env("HTTP_CLIENT_IDLE_CONN_TIMEOUT", "90s"), + }, + }, +}) +``` + +### Fiber + +```go +// config/http.go +import ( + "github.com/gofiber/fiber/v2" + "github.com/gofiber/template/html/v2" + "github.com/goravel/framework/contracts/route" + "github.com/goravel/framework/support/path" + fiberfacades "github.com/goravel/fiber/facades" +) + +config.Add("http", map[string]any{ + "default": "fiber", + "drivers": map[string]any{ + "fiber": map[string]any{ + // WARNING: immutable mode — only disable if you understand zero-allocation implications + "immutable": true, + "prefork": false, + "body_limit": 4096, // KB + "header_limit": 4096, // KB + "route": func() (route.Route, error) { + return fiberfacades.Route("fiber"), nil + }, + // Optional: custom template engine + "template": func() (fiber.Views, error) { + return html.New(path.Resource("views"), ".tmpl"), nil + }, + }, + }, + // ... same host/port/tls/clients as Gin config above +}) +``` + +| Driver | Package | +|--------|---------| +| Gin | github.com/goravel/gin | +| Fiber | github.com/goravel/fiber | diff --git a/.ai/prompt/routing.md b/.ai/prompt/routing.md new file mode 100644 index 000000000..332affebd --- /dev/null +++ b/.ai/prompt/routing.md @@ -0,0 +1,267 @@ +# Goravel Routing + +## Setup + +Routes are defined in `routes/` and registered in `bootstrap/app.go`: + +```go +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithRouting(func() { + routes.Web() + routes.Api() + }). + WithConfig(config.Boot). + Create() +} +``` + +Route files call `facades.Route()` methods: + +```go +package routes + +import "goravel/app/facades" + +func Web() { + facades.Route().Get("/", homeController.Index) +} +``` + +--- + +## HTTP Methods + +```go +facades.Route().Get("/", handler) +facades.Route().Post("/", handler) +facades.Route().Put("/", handler) +facades.Route().Delete("/", handler) +facades.Route().Patch("/", handler) +facades.Route().Options("/", handler) +facades.Route().Any("/", handler) +``` + +Handler signature: + +```go +import "github.com/goravel/framework/contracts/http" + +func(ctx http.Context) http.Response +``` + +--- + +## Inline Handler + +```go +facades.Route().Get("/", func(ctx http.Context) http.Response { + return ctx.Response().Json(http.StatusOK, http.Json{ + "Hello": "Goravel", + }) +}) +``` + +--- + +## Route Parameters + +```go +facades.Route().Get("/users/{id}", func(ctx http.Context) http.Response { + id := ctx.Request().Input("id") + return ctx.Response().Success().Json(http.Json{"id": id}) +}) +``` + +--- + +## Group Routing + +```go +import "github.com/goravel/framework/contracts/route" + +facades.Route().Group(func(router route.Router) { + router.Get("users/{id}", userController.Show) + router.Post("users", userController.Store) +}) +``` + +--- + +## Routing Prefix + +```go +facades.Route().Prefix("api/v1").Get("users", userController.Index) + +facades.Route().Prefix("api").Group(func(router route.Router) { + router.Get("users", userController.Index) + router.Post("users", userController.Store) +}) +``` + +--- + +## Resource Routing + +```go +import "github.com/goravel/framework/contracts/http" + +resourceController := controllers.NewPhotoController() +facades.Route().Resource("photos", resourceController) +``` + +Resource controller must implement these methods: + +```go +type PhotoController struct{} + +func NewPhotoController() *PhotoController { + return &PhotoController{} +} + +// GET /photos +func (c *PhotoController) Index(ctx http.Context) http.Response {} + +// GET /photos/{photo} +func (c *PhotoController) Show(ctx http.Context) http.Response {} + +// POST /photos +func (c *PhotoController) Store(ctx http.Context) http.Response {} + +// PUT/PATCH /photos/{photo} +func (c *PhotoController) Update(ctx http.Context) http.Response {} + +// DELETE /photos/{photo} +func (c *PhotoController) Destroy(ctx http.Context) http.Response {} +``` + +--- + +## Middleware on Routes + +```go +import "github.com/goravel/framework/http/middleware" + +facades.Route().Middleware(middleware.Cors()).Get("users", userController.Show) +facades.Route().Middleware(middleware.Auth(), middleware.Throttle("api")).Get("profile", profileController.Show) +``` + +--- + +## File Serving + +```go +import "net/http" + +facades.Route().Static("static", "./public") +facades.Route().StaticFile("static-file", "./public/logo.png") +facades.Route().StaticFS("static-fs", http.Dir("./public")) +``` + +--- + +## Named Routes + +```go +facades.Route().Get("users", userController.Index).Name("users.index") + +// Get route info by name +route := facades.Route().Info("users.index") +``` + +--- + +## Fallback Route + +```go +facades.Route().Fallback(func(ctx http.Context) http.Response { + return ctx.Response().String(404, "not found") +}) +``` + +--- + +## Get All Routes + +```go +routes := facades.Route().GetRoutes() +``` + +--- + +## Rate Limiting + +### Define a rate limiter + +Rate limiters must be defined inside `WithCallback` in `bootstrap/app.go`: + +```go +import ( + contractshttp "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/http/limit" +) + +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithConfig(config.Boot). + WithCallback(func() { + facades.RateLimiter().For("global", func(ctx contractshttp.Context) contractshttp.Limit { + return limit.PerMinute(1000) + }) + + // Per-IP limit + facades.RateLimiter().For("api", func(ctx contractshttp.Context) contractshttp.Limit { + return limit.PerMinute(60).By(ctx.Request().Ip()) + }) + + // Multiple limits + facades.RateLimiter().ForWithLimits("login", func(ctx contractshttp.Context) []contractshttp.Limit { + return []contractshttp.Limit{ + limit.PerMinute(500), + limit.PerMinute(5).By(ctx.Request().Ip()), + } + }) + }). + Create() +} +``` + +### Attach to route + +```go +import "github.com/goravel/framework/http/middleware" + +facades.Route().Middleware(middleware.Throttle("global")).Get("/", handler) +``` + +### Custom rate limit response + +```go +facades.RateLimiter().For("global", func(ctx contractshttp.Context) contractshttp.Limit { + return limit.PerMinute(1000).Response(func(ctx contractshttp.Context) { + ctx.Request().AbortWithStatus(http.StatusTooManyRequests) + }) +}) +``` + +--- + +## CORS + +CORS is enabled by default. Configure in `config/cors.go`. + +--- + +## List Routes + +```shell +./artisan route:list +``` + +--- + +## Gotchas + +- Route parameters use `ctx.Request().Input("param")` not `ctx.Request().Route("param")` for basic use. `Route()` is only needed for explicit route-segment access. +- `Prefix` and `Group` can be chained: `facades.Route().Prefix("api").Group(...)` +- Middleware registered via `WithMiddleware` in `bootstrap/app.go` applies globally to all HTTP requests. diff --git a/.ai/prompt/session.md b/.ai/prompt/session.md new file mode 100644 index 000000000..eb284f9d1 --- /dev/null +++ b/.ai/prompt/session.md @@ -0,0 +1,265 @@ +# Goravel Session + +## Configuration + +Full `config/session.go`: + +```go +import ( + "github.com/goravel/framework/support/path" + "github.com/goravel/framework/support/str" + "goravel/app/facades" +) + +config.Add("session", map[string]any{ + "default": "file", // driver name to use + + "drivers": map[string]any{ + "file": map[string]any{ + "driver": "file", + }, + // Redis driver (requires goravel/redis package): + // "redis": map[string]any{ + // "driver": "custom", + // "connection": "default", + // "via": func() (session.Driver, error) { + // return redisfacades.Session("redis"), nil + // }, + // }, + }, + + // Session lifetime in minutes + "lifetime": config.Env("SESSION_LIFETIME", 120), + "expire_on_close": config.Env("SESSION_EXPIRE_ON_CLOSE", false), + + // File session storage path (only for file driver) + "files": path.Storage("framework/sessions"), + + // Garbage collection interval in minutes (-1 to disable) + "gc_interval": config.Env("SESSION_GC_INTERVAL", 30), + + // Cookie name (defaults to app_name_session) + "cookie": config.Env("SESSION_COOKIE", str.Of(config.GetString("app.name")).Snake().Lower().String()+"_session"), + "path": config.Env("SESSION_PATH", "/"), + "domain": config.Env("SESSION_DOMAIN", ""), + "secure": config.Env("SESSION_SECURE", false), // HTTPS only + "http_only": config.Env("SESSION_HTTP_ONLY", true), // prevent JS access + "same_site": config.Env("SESSION_SAME_SITE", "lax"), // "lax", "strict", "none" +}) +``` + +### Redis Session Driver + +Install `goravel/redis`: + +```shell +./artisan package:install github.com/goravel/redis +``` + +```go +// config/session.go +import ( + "github.com/goravel/framework/contracts/session" + redisfacades "github.com/goravel/redis/facades" +) + +"default": "redis", + +"drivers": map[string]any{ + "redis": map[string]any{ + "driver": "custom", + "connection": "default", + "via": func() (session.Driver, error) { + return redisfacades.Session("redis"), nil + }, + }, +}, +``` + +Redis connection must be defined in `config/database.go`: + +```go +"redis": map[string]any{ + "default": map[string]any{ + "host": config.Env("REDIS_HOST", "127.0.0.1"), + "password": config.Env("REDIS_PASSWORD", ""), + "port": config.Env("REDIS_PORT", 6379), + "database": config.Env("REDIS_DB", 0), + }, +}, +``` + +### Register Session Middleware + +Configure driver in `config/session.go`. Default driver: `file` (stores in `storage/framework/sessions`). + +Register session middleware: + +```go +import "github.com/goravel/framework/session/middleware" + +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithMiddleware(func(handler configuration.Middleware) { + handler.Append(middleware.StartSession()) + }). + WithConfig(config.Boot). + Create() +} +``` + +--- + +## Session Operations + +All operations via `ctx.Request().Session()`: + +### Read + +```go +value := ctx.Request().Session().Get("key") +value := ctx.Request().Session().Get("key", "default") +data := ctx.Request().Session().All() +data := ctx.Request().Session().Only([]string{"username", "email"}) +``` + +### Check existence + +```go +ctx.Request().Session().Has("user") // true if present and not nil +ctx.Request().Session().Exists("user") // true even if nil +ctx.Request().Session().Missing("user") // true if absent +``` + +### Write + +```go +ctx.Request().Session().Put("key", "value") +``` + +### Retrieve and delete + +```go +value := ctx.Request().Session().Pull("key") +``` + +### Delete + +```go +ctx.Request().Session().Forget("username", "email") +ctx.Request().Session().Flush() +``` + +### Regenerate session ID (prevents session fixation) + +```go +ctx.Request().Session().Regenerate() +``` + +### Invalidate (regenerate ID + clear all data) + +```go +ctx.Request().Session().Invalidate() + +// After invalidating, save the new session ID to cookie: +ctx.Response().Cookie(http.Cookie{ + Name: ctx.Request().Session().GetName(), + Value: ctx.Request().Session().GetID(), + MaxAge: facades.Config().GetInt("session.lifetime") * 60, + Path: facades.Config().GetString("session.path"), + Domain: facades.Config().GetString("session.domain"), + Secure: facades.Config().GetBool("session.secure"), + HttpOnly: facades.Config().GetBool("session.http_only"), + SameSite: facades.Config().GetString("session.same_site"), +}) +``` + +--- + +## Flash Data + +Flash data is only available for the next request, then deleted automatically. + +```go +ctx.Request().Session().Flash("status", "Task was successful!") + +// Keep flash data for one more request +ctx.Request().Session().Reflash() + +// Keep specific flash keys for one more request +ctx.Request().Session().Keep("status", "username") + +// Flash data available immediately in current request +ctx.Request().Session().Now("status", "Task was successful!") +``` + +--- + +## Session Manager + +### Build a custom session + +```go +session := facades.Session().BuildSession(driver) +session := facades.Session().BuildSession(driver, "custom-session-id") +``` + +### Get a driver instance + +```go +driver, err := facades.Session().Driver("file") +``` + +### Start and save manually + +```go +session := facades.Session().BuildSession(driver) +session.Start() +session.Save() +``` + +### Attach session to request + +```go +ctx.Request().SetSession(session) +``` + +### Check if request has a session + +```go +if ctx.Request().HasSession() { + // ... +} +``` + +--- + +## Custom Session Driver + +Implement the `contracts/session/driver` interface: + +```go +type Driver interface { + Close() error + Destroy(id string) error + Gc(maxLifetime int) error + Open(path string, name string) error + Read(id string) (string, error) + Write(id string, data string) error +} +``` + +Register in `config/session.go`: + +```go +"default": "custom", + +"drivers": map[string]any{ + "custom": map[string]any{ + "driver": "custom", + "via": func() (session.Driver, error) { + return &MyDriver{}, nil + }, + }, +}, +``` diff --git a/.ai/prompt/storage.md b/.ai/prompt/storage.md new file mode 100644 index 000000000..bd09b2ae1 --- /dev/null +++ b/.ai/prompt/storage.md @@ -0,0 +1,213 @@ +# Goravel Storage / Filesystem + +## Configuration + +Configure disks in `config/filesystems.go`. Default disk: `local` (stores in `storage/app`). Storage directory is configurable via `WithPaths`. + +Available drivers: + +| Driver | Package | +|--------|---------| +| local | built-in | +| S3 | github.com/goravel/s3 | +| OSS | github.com/goravel/oss | +| COS | github.com/goravel/cos | +| Minio | github.com/goravel/minio | + +--- + +## Basic Usage + +```go +// Default disk +err := facades.Storage().Put("file.jpg", contents) +content := facades.Storage().Get("file.jpg") + +// Specific disk +facades.Storage().Disk("s3").Put("avatars/1.png", "Contents") + +// Inject context +facades.Storage().WithContext(ctx).Put("avatars/1.png", "Contents") +``` + +--- + +## File Existence + +```go +if facades.Storage().Disk("s3").Exists("file.jpg") { + // file exists +} + +if facades.Storage().Disk("s3").Missing("file.jpg") { + // file missing +} +``` + +--- + +## File URLs + +```go +url := facades.Storage().Url("file.jpg") + +// Temporary URL (non-local drivers) +url, err := facades.Storage().TemporaryUrl("file.jpg", time.Now().Add(5*time.Minute)) +``` + +--- + +## File Metadata + +```go +size := facades.Storage().Size("file.jpg") +lastModified, err := facades.Storage().LastModified("file.jpg") +mime, err := facades.Storage().MimeType("file.jpg") +path := facades.Storage().Path("file.jpg") +``` + +Using `NewFile`: + +```go +import "github.com/goravel/framework/filesystem" + +file, err := filesystem.NewFile("./logo.png") +size, _ := file.Size() +lastModified, _ := file.LastModified() +mime, _ := file.MimeType() +``` + +--- + +## Storing Files + +```go +// Put raw content +err := facades.Storage().Put("file.jpg", contents) + +// PutFile (auto-generates unique filename) +import "github.com/goravel/framework/filesystem" +file, err := filesystem.NewFile("./logo.png") +path := facades.Storage().PutFile("photos", file) + +// PutFileAs (specify filename) +path = facades.Storage().PutFileAs("photos", file, "photo.jpg") +``` + +--- + +## File Uploads (from HTTP request) + +```go +func (r *UserController) Store(ctx http.Context) http.Response { + file, err := ctx.Request().File("avatar") + + // Auto-generated filename + path, err := file.Store("avatars") + + // Custom filename + path, err = file.StoreAs("avatars", "custom-name") + + // Specific disk + path, err = file.Disk("s3").Store("avatars") + + // Get client-provided info (unsafe — may be tampered) + name := file.GetClientOriginalName() + ext := file.GetClientOriginalExtension() + + // Safe alternatives + name = file.HashName() + ext, err = file.Extension() // determined from MIME type + + return ctx.Response().Success().Json(http.Json{"path": path}) +} +``` + +--- + +## Copy, Move, Delete + +```go +err := facades.Storage().Copy("old/file.jpg", "new/file.jpg") +err = facades.Storage().Move("old/file.jpg", "new/file.jpg") + +// Delete one or multiple files +err = facades.Storage().Delete("file.jpg") +err = facades.Storage().Delete("file.jpg", "file2.jpg") +err = facades.Storage().Disk("s3").Delete("file.jpg") +``` + +--- + +## Directories + +```go +// List files +files, err := facades.Storage().Disk("s3").Files("directory") +files, err = facades.Storage().Disk("s3").AllFiles("directory") + +// List directories +dirs, err := facades.Storage().Disk("s3").Directories("directory") +dirs, err = facades.Storage().Disk("s3").AllDirectories("directory") + +// Create/delete directories +err = facades.Storage().MakeDirectory("new/directory") +err = facades.Storage().DeleteDirectory("old/directory") +``` + +--- + +## Public Disk + +Serve publicly accessible files: + +```go +// config/filesystems.go: public disk uses local driver → storage/app/public + +// Serve via route: +facades.Route().Static("storage", "./storage/app/public") +``` + +--- + +## Custom Driver + +```go +// config/filesystems.go +"custom": map[string]any{ + "driver": "custom", + "via": filesystems.NewLocal(), +}, +``` + +Implement `contracts/filesystem/Driver` interface: + +```go +type Driver interface { + AllDirectories(path string) ([]string, error) + AllFiles(path string) ([]string, error) + Copy(oldFile, newFile string) error + Delete(file ...string) error + DeleteDirectory(directory string) error + Directories(path string) ([]string, error) + Exists(file string) bool + Files(path string) ([]string, error) + Get(file string) (string, error) + GetBytes(file string) ([]byte, error) + LastModified(file string) (time.Time, error) + MakeDirectory(directory string) error + MimeType(file string) (string, error) + Missing(file string) bool + Move(oldFile, newFile string) error + Path(file string) string + Put(file, content string) error + PutFile(path string, source File) (string, error) + PutFileAs(path string, source File, name string) (string, error) + Size(file string) (int64, error) + TemporaryUrl(file string, time time.Time) (string, error) + WithContext(ctx context.Context) Driver + Url(file string) string +} +``` + +Use `facades.Config().Env()` (not `facades.Config().Get()`) inside custom driver — config is not yet loaded when the driver is registered. diff --git a/.ai/prompt/testing.md b/.ai/prompt/testing.md new file mode 100644 index 000000000..c679109db --- /dev/null +++ b/.ai/prompt/testing.md @@ -0,0 +1,421 @@ +# Goravel Testing + +## Setup + +Uses [stretchr/testify](https://github.com/stretchr/testify) suite. Framework auto-bootstraps the app before tests run. + +```shell +./artisan make:test feature/UserTest +``` + +```go +package feature + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "goravel/tests" +) + +type ExampleTestSuite struct { + suite.Suite + tests.TestCase +} + +func TestExampleTestSuite(t *testing.T) { + suite.Run(t, new(ExampleTestSuite)) +} + +// SetupTest runs before each test +func (s *ExampleTestSuite) SetupTest() {} + +// TearDownTest runs after each test +func (s *ExampleTestSuite) TearDownTest() {} + +func (s *ExampleTestSuite) TestIndex() { + s.True(true) +} +``` + +--- + +## Environment + +- Default: reads `.env` from root +- Package-level override: place `.env` in the test package directory (read first) +- Named env file: `go test ./... --env=.env.testing` or `-e=.env.testing` + +--- + +## HTTP Tests + +### Make Requests + +```go +func (s *ExampleTestSuite) TestIndex() { + response, err := s.Http(s.T()).Get("/users/1") + s.Nil(err) + response.AssertStatus(200) +} +``` + +### Set Headers + +```go +response, err := s.Http(s.T()).WithHeader("X-Custom-Header", "Value").Get("/users/1") + +response, err := s.Http(s.T()).WithHeaders(map[string]string{ + "X-Custom-Header": "Value", + "Accept": "application/json", +}).Get("/users/1") +``` + +### Set Cookies + +```go +import "github.com/goravel/framework/testing/http" + +response, err := s.Http(s.T()).WithCookie(http.Cookie("name", "value")).Get("/users/1") + +response, err := s.Http(s.T()).WithCookies(http.Cookies(map[string]string{ + "name": "value", + "lang": "en", +})).Get("/users/1") +``` + +### Set Session + +```go +response, err := s.Http(s.T()).WithSession(map[string]any{"role": "admin"}).Get("/users/1") +``` + +### Build Request Body (POST/PUT/DELETE) + +```go +import "github.com/goravel/framework/support/http" + +builder := http.NewBody().SetField("name", "goravel") +body, err := builder.Build() + +response, err := s.Http(s.T()). + WithHeader("Content-Type", body.ContentType()). + Post("/users", body) +``` + +### Inspect Response + +```go +content, err := response.Content() // raw response body string +cookies := response.Cookies() +headers := response.Headers() +json, err := response.Json() // map[string]any +session, err := response.Session() // all session values +``` + +--- + +## JSON Assertions + +```go +// Contains subset (does not require exact match) +response.AssertJson(map[string]any{"created": true}) + +// Must match exactly (no extra/missing fields) +response.AssertExactJson(map[string]any{"created": true}) + +// JSON missing +response.AssertJsonMissing(map[string]any{"created": false}) +``` + +### Fluent JSON + +```go +import contractstesting "github.com/goravel/framework/contracts/testing" + +response.AssertFluentJson(func(json contractstesting.AssertableJSON) { + json.Where("id", float64(1)). + Where("name", "goravel"). + WhereNot("lang", "en"). + Missing("password"). + Has("email"). + HasAny([]string{"username", "email"}). + MissingAll([]string{"secret", "token"}) +}) +``` + +### JSON Collections + +```go +response.AssertFluentJson(func(json contractstesting.AssertableJSON) { + // Count + First element + json.Count("items", 2). + First("items", func(json contractstesting.AssertableJSON) { + json.Where("id", float64(1)) + }) + + // Iterate all + json.Each("items", func(json contractstesting.AssertableJSON) { + json.Has("id") + }) + + // Count + First combined + json.HasWithScope("items", 2, func(json contractstesting.AssertableJSON) { + json.Where("id", float64(1)) + }) +}) +``` + +--- + +## Response Assertions + +```go +response.AssertStatus(200) +response.AssertOk() // 200 +response.AssertCreated() // 201 +response.AssertAccepted() // 202 +response.AssertNoContent() // 204 +response.AssertPartialContent() // 206 +response.AssertMovedPermanently() // 301 +response.AssertFound() // 302 +response.AssertNotModified() // 304 +response.AssertTemporaryRedirect() // 307 +response.AssertBadRequest() // 400 +response.AssertUnauthorized() // 401 +response.AssertPaymentRequired() // 402 +response.AssertForbidden() // 403 +response.AssertNotFound() // 404 +response.AssertMethodNotAllowed() // 405 +response.AssertRequestTimeout() // 408 +response.AssertConflict() // 409 +response.AssertGone() // 410 +response.AssertUnprocessableEntity() // 422 +response.AssertTooManyRequests() // 429 +response.AssertInternalServerError() // 500 +response.AssertServiceUnavailable() // 503 +response.AssertSuccessful() // 2xx +response.AssertServerError() // 5xx + +// Header assertions +response.AssertHeader("Content-Type", "application/json") +response.AssertHeaderMissing("X-Custom") + +// Cookie assertions +response.AssertCookie("name", "value") +response.AssertCookieExpired("name") +response.AssertCookieMissing("name") +response.AssertCookieNotExpired("name") + +// Body content +response.AssertSee([]string{"
"}, false) // second param: escape HTML +response.AssertDontSee([]string{"error"}, true) +response.AssertSeeInOrder([]string{"First", "Second"}, false) +``` + +--- + +## Database Testing + +### Factories + +```go +var user models.User +err := facades.Orm().Factory().Create(&user) +``` + +### Seeders + +```go +func (s *ExampleTestSuite) TestIndex() { + s.Seed() // runs DatabaseSeeder + s.Seed(&seeders.UserSeeder{}, &seeders.PhotoSeeder{}) // specific seeders +} +``` + +### Refresh Database (per-test) + +```go +func (s *ExampleTestSuite) SetupTest() { + s.RefreshDatabase() +} +``` + +--- + +## Docker Testing + +For parallel package tests that need isolated databases/caches. + +> Docker testing does not work on Windows. + +### Full Example + +```go +// tests/feature/main_test.go +package feature + +import ( + "fmt" + "os" + "testing" + + "goravel/app/facades" + "goravel/database/seeders" +) + +func TestMain(m *testing.M) { + database, err := facades.Testing().Docker().Database() + if err != nil { + panic(err) + } + + if err := database.Build(); err != nil { + panic(err) + } + if err := database.Ready(); err != nil { + panic(err) + } + if err := database.Migrate(); err != nil { + panic(err) + } + + if err := facades.App().Restart(); err != nil { + panic(err) + } + + exit := m.Run() + + if err := database.Shutdown(); err != nil { + panic(err) + } + + os.Exit(exit) +} +``` + +### Docker API + +```go +// Create images +database, err := facades.Testing().Docker().Database() +database, err := facades.Testing().Docker().Database("postgres") + +cache, err := facades.Testing().Docker().Cache() +cache, err := facades.Testing().Docker().Cache("redis") + +// Custom image +import contractstesting "github.com/goravel/framework/contracts/testing" + +image, err := facades.Testing().Docker().Image(contractstesting.Image{ + Repository: "mysql", + Tag: "5.7", + Env: []string{"MYSQL_ROOT_PASSWORD=secret", "MYSQL_DATABASE=goravel"}, + ExposedPorts: []string{"3306"}, +}) + +// Build and configure +err := database.Build() +config := database.Config() // get connection config + +// Seed +err := database.Seed() +err := database.Seed(&seeders.UserSeeder{}) + +// Refresh (serial tests only — not safe for parallel) +err := database.Fresh() +err := cache.Fresh() + +// Shutdown (auto-uninstalls after 1 hour if not called) +err := database.Shutdown() +``` + +--- + +## Mock Testing + +All facades can be mocked via `mock.Factory()`: + +```go +import "github.com/goravel/framework/testing/mock" +``` + +### Mock Pattern + +```go +func TestSomething(t *testing.T) { + mockFactory := mock.Factory() + mockCache := mockFactory.Cache() + mockCache.On("Put", "name", "goravel", mock.Anything).Return(nil).Once() + mockCache.On("Get", "name", "test").Return("Goravel").Once() + + res := MyFunction() + assert.Equal(t, "Goravel", res) + + mockCache.AssertExpectations(t) +} +``` + +### Available Mocks + +| Mock | Factory Method | +|------|---------------| +| App | `mockFactory.App()` | +| Artisan | `mockFactory.Artisan()` | +| Auth | `mockFactory.Auth()` | +| Cache | `mockFactory.Cache()` | +| Config | `mockFactory.Config()` | +| Crypt | `mockFactory.Crypt()` | +| Event | `mockFactory.Event()` + `mockFactory.EventTask()` | +| Gate | `mockFactory.Gate()` | +| Grpc | `mockFactory.Grpc()` | +| Hash | `mockFactory.Hash()` | +| Lang | `mockFactory.Lang()` | +| Log | `mockFactory.Log()` (uses fmt, not real log) | +| Mail | `mockFactory.Mail()` | +| Orm | `mockFactory.Orm()` + `mockFactory.OrmQuery()` | +| Queue | `mockFactory.Queue()` + `mockFactory.QueueTask()` | +| Storage | `mockFactory.Storage()` + `mockFactory.StorageDriver()` | +| Validation | `mockFactory.Validation()` + `mockFactory.ValidationValidator()` + `mockFactory.ValidationErrors()` | +| View | `mockFactory.View()` | + +### Mock ORM Transaction + +```go +func TestTransaction(t *testing.T) { + mockFactory := mock.Factory() + mockOrm := mockFactory.Orm() + mockOrmTransaction := mockFactory.OrmTransaction() + mockOrm.On("Transaction", mock.Anything).Return(func(txFunc func(tx orm.Transaction) error) error { + return txFunc(mockOrmTransaction) + }) + + var test Test + mockOrmTransaction.On("Create", &test).Return(func(test2 interface{}) error { + test2.(*Test).ID = 1 + return nil + }).Once() + mockOrmTransaction.On("Where", "id = ?", uint(1)).Return(mockOrmTransaction).Once() + mockOrmTransaction.On("Find", mock.Anything).Return(nil).Once() + + assert.Nil(t, Transaction()) +} +``` + +### Mock Event + +```go +func TestEvent(t *testing.T) { + mockFactory := mock.Factory() + mockEvent := mockFactory.Event() + mockTask := mockFactory.EventTask() + mockEvent.On("Job", mock.Anything, mock.Anything).Return(mockTask).Once() + mockTask.On("Dispatch").Return(nil).Once() + + assert.Nil(t, Event()) + + mockEvent.AssertExpectations(t) + mockTask.AssertExpectations(t) +} +``` diff --git a/.ai/prompt/validation.md b/.ai/prompt/validation.md new file mode 100644 index 000000000..990871456 --- /dev/null +++ b/.ai/prompt/validation.md @@ -0,0 +1,461 @@ +# Goravel Validation + +## Inline Validation + +```go +func (r *PostController) Store(ctx http.Context) http.Response { + validator, err := ctx.Request().Validate(map[string]string{ + "title": "required|max_len:255", + "body": "required", + "code": "required|regex:^\\d{4,6}$", + }) + + if err != nil { + return ctx.Response().Json(http.StatusBadRequest, http.Json{"error": err.Error()}) + } + + if validator.Fails() { + return ctx.Response().Json(http.StatusUnprocessableEntity, validator.Errors().All()) + } + + var post models.Post + err = validator.Bind(&post) + ... +} +``` + +### Nested attributes (dot notation) + +```go +validator, err := ctx.Request().Validate(map[string]string{ + "author.name": "required", + "author.description": "required", +}) +``` + +### Array / slice validation + +```go +validator, err := ctx.Request().Validate(map[string]string{ + "tags.*": "required", +}) +``` + +--- + +## Form Request Validation + +### Generate form request + +```shell +./artisan make:request StorePostRequest +./artisan make:request user/StorePostRequest +``` + +### Define form request + +```go +package requests + +import ( + "mime/multipart" + + "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/contracts/validation" +) + +type StorePostRequest struct { + Name string `form:"name" json:"name"` + File *multipart.FileHeader `form:"file" json:"file"` + Files []*multipart.FileHeader `form:"files" json:"files"` +} + +func (r *StorePostRequest) Authorize(ctx http.Context) error { + return nil +} + +func (r *StorePostRequest) Rules(ctx http.Context) map[string]string { + return map[string]string{ + "name": "required|max_len:255", + "file": "required|file", + "files": "required|array", + "files.*": "required|file", + } +} + +// Optional - filter/transform input before validation +func (r *StorePostRequest) Filters(ctx http.Context) map[string]string { + return map[string]string{ + "name": "trim", + } +} + +// Optional - custom error messages +func (r *StorePostRequest) Messages() map[string]string { + return map[string]string{ + "name.required": "A name is required.", + } +} + +// Optional - custom attribute names in error messages +func (r *StorePostRequest) Attributes() map[string]string { + return map[string]string{ + "name": "full name", + } +} + +// Optional - modify data before rules run +func (r *StorePostRequest) PrepareForValidation(ctx http.Context, data validation.Data) error { + if name, exist := data.Get("name"); exist { + return data.Set("name", strings.TrimSpace(name.(string))) + } + return nil +} +``` + +### Use form request in controller + +```go +func (r *PostController) Store(ctx http.Context) http.Response { + var storePost requests.StorePostRequest + errors, err := ctx.Request().ValidateRequest(&storePost) + + if err != nil { + return ctx.Response().Json(http.StatusBadRequest, http.Json{"error": err.Error()}) + } + + if errors != nil { + return ctx.Response().Json(http.StatusUnprocessableEntity, errors.All()) + } + + // storePost.Name is populated + fmt.Println(storePost.Name) + ... +} +``` + +### Authorization in form request + +```go +func (r *StorePostRequest) Authorize(ctx http.Context) error { + var comment models.Comment + facades.Orm().Query().First(&comment) + + if comment.ID == 0 { + return errors.New("comment not found") + } + + if !facades.Gate().Allows("update", map[string]any{"comment": comment}) { + return errors.New("unauthorized") + } + + return nil +} +``` + +The error from `Authorize` is returned as the first return value of `ValidateRequest`. + +--- + +## Manual Validator + +```go +import "goravel/app/facades" + +validator, err := facades.Validation().Make( + ctx, + map[string]any{ + "name": "Goravel", + }, + map[string]string{ + "name": "required|max_len:255", + }, +) + +if validator.Fails() { + // handle errors +} + +var user models.User +err = validator.Bind(&user) +``` + +### Custom messages with Make + +```go +import "github.com/goravel/framework/validation" + +validator, err := facades.Validation().Make(ctx, input, rules, + validation.Messages(map[string]string{ + "required": "The :attribute field is required.", + "email.required": "We need your email address.", + }), +) +``` + +### Custom attributes with Make + +```go +validator, err := facades.Validation().Make(ctx, input, rules, + validation.Attributes(map[string]string{ + "email": "email address", + }), +) +``` + +### PrepareForValidation with Make + +```go +import ( + validationcontract "github.com/goravel/framework/contracts/validation" + "github.com/goravel/framework/validation" +) + +validator, err := facades.Validation().Make(ctx, input, rules, + validation.PrepareForValidation(func(ctx http.Context, data validationcontract.Data) error { + if name, exist := data.Get("name"); exist { + return data.Set("name", strings.TrimSpace(name.(string))) + } + return nil + }), +) +``` + +--- + +## Working with Errors + +```go +// Check if any errors +if validator.Fails() {} + +// One message for a field (random if multiple) +msg := validator.Errors().One("email") + +// All messages for a field +msgs := validator.Errors().Get("email") + +// All messages for all fields +all := validator.Errors().All() + +// Check if a specific field has errors +if validator.Errors().Has("email") {} +``` + +--- + +## Bind Validated Data + +```go +// Bind to struct after inline Validate +validator, err := ctx.Request().Validate(rules) +var user models.User +err = validator.Bind(&user) + +// Data auto-bound when using ValidateRequest +var storePost requests.StorePostRequest +errors, err := ctx.Request().ValidateRequest(&storePost) +fmt.Println(storePost.Name) +``` + +--- + +## Available Validation Rules + +| Rule | Usage | +|------|-------| +| `required` | Field must be present and not zero value | +| `required_if:field,value` | Required when another field equals value | +| `required_unless:field,value` | Required unless another field equals value | +| `required_with:foo,bar` | Required if any of the listed fields are present | +| `required_with_all:foo,bar` | Required if all of the listed fields are present | +| `required_without:foo,bar` | Required if any of the listed fields are absent | +| `required_without_all:foo,bar` | Required if all of the listed fields are absent | +| `int` | Integer type, optionally `int:min` or `int:min,max` | +| `uint` | Unsigned integer, value >= 0 | +| `bool` | Boolean string (1/0/true/false/yes/no/on/off) | +| `string` | String type, optionally `string:min` or `string:min,max` | +| `float` | Float type | +| `slice` | Slice type | +| `in:a,b,c` | Value must be in list | +| `not_in:a,b,c` | Value must not be in list | +| `starts_with:foo` | Must start with substring | +| `ends_with:foo` | Must end with substring | +| `between:min,max` | Numeric value within range | +| `min:value` | Minimum numeric value | +| `max:value` | Maximum numeric value | +| `eq:value` | Equal to value | +| `ne:value` | Not equal to value | +| `lt:value` | Less than value | +| `gt:value` | Greater than value | +| `len:value` | Exact length (string/array/slice/map) | +| `min_len:value` | Minimum length | +| `max_len:value` | Maximum length | +| `email` | Valid email address | +| `array` | Array or slice | +| `map` | Map type | +| `eq_field:field` | Equal to another field | +| `ne_field:field` | Not equal to another field | +| `gt_field:field` | Greater than another field | +| `gte_field:field` | Greater than or equal to another field | +| `lt_field:field` | Less than another field | +| `lte_field:field` | Less than or equal to another field | +| `file` | Uploaded file | +| `image` | Uploaded image file | +| `date` | Date string | +| `gt_date:value` | After given date | +| `lt_date:value` | Before given date | +| `gte_date:value` | On or after given date | +| `lte_date:value` | On or before given date | +| `alpha` | Letters only | +| `alpha_num` | Letters and numbers only | +| `alpha_dash` | Letters, numbers, dashes, underscores | +| `json` | Valid JSON string | +| `number` | Numeric string >= 0 | +| `full_url` | Full URL starting with http or https | +| `ip` | IPv4 or IPv6 | +| `ipv4` | IPv4 | +| `ipv6` | IPv6 | +| `regex:pattern` | Matches regular expression | +| `uuid` | UUID string | +| `uuid3` | UUID v3 | +| `uuid4` | UUID v4 | +| `uuid5` | UUID v5 | + +--- + +## Available Filters + +| Filter | Effect | +|--------|--------| +| `trim` / `trimSpace` | Remove surrounding whitespace | +| `ltrim` / `trimLeft` | Remove left whitespace | +| `rtrim` / `trimRight` | Remove right whitespace | +| `int` / `toInt` | Convert to int | +| `uint` / `toUint` | Convert to uint | +| `int64` / `toInt64` | Convert to int64 | +| `float` / `toFloat` | Convert to float | +| `bool` / `toBool` | Convert to bool | +| `lower` / `lowercase` | Lowercase | +| `upper` / `uppercase` | Uppercase | +| `camel` / `camelCase` | camelCase | +| `snake` / `snakeCase` | snake_case | +| `escapeHtml` / `escapeHTML` | Escape HTML | +| `str2ints` / `strToInts` | String to `[]int` | +| `str2arr` / `strToArray` | String to `[]string` | + +--- + +## Custom Validation Rules + +### Generate rule + +```shell +./artisan make:rule Uppercase +``` + +### Define rule + +```go +package rules + +import ( + "context" + "strings" + + "github.com/goravel/framework/contracts/validation" +) + +type Uppercase struct{} + +func (r *Uppercase) Signature() string { + return "uppercase" +} + +func (r *Uppercase) Passes(ctx context.Context, data validation.Data, val any, options ...any) bool { + s, ok := val.(string) + if !ok { + return false + } + return strings.ToUpper(s) == s +} + +func (r *Uppercase) Message(ctx context.Context) string { + return "The :attribute must be uppercase." +} +``` + +### Register rules + +Generated rules auto-register in `bootstrap/rules.go`. Manual registration: + +```go +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithRules(rules.Rules). + WithConfig(config.Boot). + Create() +} +``` + +Use the rule: + +```go +validator, err := ctx.Request().Validate(map[string]string{ + "name": "required|uppercase", +}) +``` + +--- + +## Custom Filters + +### Generate filter + +```shell +./artisan make:filter ToInt +``` + +### Define filter + +```go +package filters + +import ( + "context" + + "github.com/spf13/cast" +) + +type ToInt struct{} + +func (r *ToInt) Signature() string { + return "ToInt" +} + +func (r *ToInt) Handle(ctx context.Context) any { + return func(val any) int { + return cast.ToInt(val) + } +} +``` + +### Register filters + +```go +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithFilters(filters.Filters). + WithConfig(config.Boot). + Create() +} +``` + +--- + +## Gotchas + +- When using `ctx.Request().Validate(rules)` (inline), JSON-decoded `int` values arrive as `float64`. The `int` rule will fail. Fix: use `facades.Validation().Make()` instead, or add `PrepareForValidation` to convert them. +- Form fields bind as `string` by default. Use JSON if you need typed numeric or boolean fields in a form request struct. +- `Authorize()` error is distinct from validation errors. It is returned as the first return value from `ValidateRequest`, not inside `Errors()`. +- `validator.Bind()` binds all incoming data, not just the validated fields. Filter what you need after binding. diff --git a/.ai/prompt/view.md b/.ai/prompt/view.md new file mode 100644 index 000000000..f92fed551 --- /dev/null +++ b/.ai/prompt/view.md @@ -0,0 +1,167 @@ +# Goravel Views + +## Template Files + +Default template engine: `html/template`. Files use `.tmpl` extension, stored in `resources/views/` (configurable via `WithPaths`). + +``` +// resources/views/welcome.tmpl +{{ define "welcome.tmpl" }} + + +

Hello, {{ .name }}

+ + +{{ end }} +``` + +Nested views: + +``` +// resources/views/admin/profile.tmpl +{{ define "admin/profile.tmpl" }} +

Welcome to the Admin Panel

+{{ end }} +``` + +--- + +## Rendering Views + +```go +facades.Route().Get("/", func(ctx http.Context) http.Response { + return ctx.Response().View().Make("welcome.tmpl", map[string]any{ + "name": "Goravel", + }) +}) +``` + +Nested view: + +```go +return ctx.Response().View().Make("admin/profile.tmpl", map[string]any{ + "name": "Goravel", +}) +``` + +First available view: + +```go +return ctx.Response().View().First([]string{"custom/admin.tmpl", "admin.tmpl"}, map[string]any{ + "name": "Goravel", +}) +``` + +--- + +## Check View Exists + +```go +if facades.View().Exist("welcome.tmpl") { + // ... +} +``` + +--- + +## Sharing Data With All Views + +Call in `WithCallback` in `bootstrap/app.go`: + +```go +WithCallback(func() { + facades.View().Share("appName", "MyApp") + facades.View().Share("version", "1.0") +}) +``` + +--- + +## CSRF Token Middleware (v1.17) + +1. Register `middleware.VerifyCsrfToken(exceptPaths)` globally or on specific routes. +2. Include the CSRF token in forms: + +```html + +``` + +Or in request headers: + +``` +X-CSRF-TOKEN: {{ .csrf_token }} +``` + +Registration: + +```go +import "github.com/goravel/framework/http/middleware" + +handler.Append(middleware.VerifyCsrfToken([]string{ + "api/*", + "webhook/*", +})) +``` + +--- + +## Custom Delimiters and Functions (Gin Driver) + +```go +// config/http.go +import ( + "html/template" + "github.com/gin-gonic/gin/render" + "github.com/goravel/gin" +) + +"template": func() (render.HTMLRender, error) { + return gin.NewTemplate(gin.RenderOptions{ + Delims: &gin.Delims{ + Left: "{{", + Right: "}}", + }, + FuncMap: template.FuncMap{ + "upper": strings.ToUpper, + }, + }) +}, +``` + +--- + +## Custom Template Engine (Fiber Driver) + +```go +// config/http.go +import ( + "github.com/gofiber/fiber/v2" + "github.com/gofiber/template/html/v2" + "github.com/goravel/framework/support/path" +) + +"template": func() (fiber.Views, error) { + engine := &html.Engine{ + Engine: template.Engine{ + Left: "{{", + Right: "}}", + Directory: path.Resource("views"), + Extension: ".tmpl", + LayoutName: "embed", + Funcmap: make(map[string]interface{}), + }, + } + engine.AddFunc(engine.LayoutName, func() error { + return fmt.Errorf("layoutName called unexpectedly") + }) + return engine, nil +}, +``` + +--- + +## Gotchas + +- View template `define` name must match the path passed to `Make`. Nested views must use `define "admin/profile.tmpl"` to match the `Make("admin/profile.tmpl", ...)` call. +- `Share` data is available in all views; per-request data is passed via `Make`'s second argument. +- View resources directory is configurable via `paths.Resources("views-root")` in `WithPaths`. diff --git a/.ai/skills/.gitkeep b/.ai/skills/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/.github/workflows/generate-agents.yml b/.github/workflows/generate-agents.yml new file mode 100644 index 000000000..e7b326ab7 --- /dev/null +++ b/.github/workflows/generate-agents.yml @@ -0,0 +1,266 @@ +name: Generate Agent Skill Files + +on: + push: + branches: + - "**" + paths: + - "en/**" + workflow_dispatch: + inputs: + force_files: + description: "Space-separated list of en/ files to treat as changed (leave empty to scan HEAD~1)" + required: false + default: "" + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + # ───────────────────────────────────────────────────────────── + # Job 1: Detect which .ai/ prompt files would be affected. + # Runs on every push. Zero API calls. Posts a summary comment + # on the commit so the team can decide whether to approve. + # ───────────────────────────────────────────────────────────── + detect: + name: Detect affected prompt files + runs-on: ubuntu-latest + + outputs: + changed_sources: ${{ steps.changed.outputs.files }} + affected_outputs: ${{ steps.affected.outputs.files }} + has_changes: ${{ steps.affected.outputs.has_changes }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Detect changed en/ files + id: changed + run: | + if [ -n "${{ github.event.inputs.force_files }}" ]; then + CHANGED="${{ github.event.inputs.force_files }}" + else + CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'en/**' | tr '\n' ' ') + fi + echo "files=$CHANGED" >> "$GITHUB_OUTPUT" + echo "Changed en/ files: $CHANGED" + + - name: Determine affected prompt files + id: affected + run: | + python3 - <<'EOF' + import json, os + + changed_raw = os.environ.get("CHANGED_FILES", "") + changed = set(changed_raw.split()) + + with open("scripts/generate-agents/config.json") as f: + config = json.load(f) + + outputs = set() + for topic in config["topics"]: + for src in topic["sources"]: + if src in changed: + outputs.add(topic["output"]) + break + + # AGENTS.md always regenerates when any source changes + if changed: + for topic in config["topics"]: + if topic["output"] == ".ai/AGENTS.md": + outputs.add(".ai/AGENTS.md") + break + + result = " ".join(sorted(outputs)) + has_changes = "true" if outputs else "false" + print(f"Affected outputs: {result}") + print(f"Has changes: {has_changes}") + + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"files={result}\n") + f.write(f"has_changes={has_changes}\n") + EOF + env: + CHANGED_FILES: ${{ steps.changed.outputs.files }} + + - name: Post detection summary + if: steps.affected.outputs.has_changes == 'true' + uses: peter-evans/commit-comment@v3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + body: | + ## Agent skill file regeneration needed + + The following `en/` documentation files changed in this push: + + ``` + ${{ steps.changed.outputs.files }} + ``` + + The following `.ai/` prompt files would be regenerated: + + ``` + ${{ steps.affected.outputs.files }} + ``` + + **To trigger regeneration**, go to: + [Actions → Generate Agent Skill Files → Run workflow](../../actions/workflows/generate-agents.yml) + and click **Run workflow** on this branch. + + > No API tokens are spent until you manually approve the regeneration run. + + # ───────────────────────────────────────────────────────────── + # Job 2: Actually regenerate. Only runs on manual dispatch + # (workflow_dispatch). Requires the "ai-regeneration" GitHub + # environment to be configured with a required reviewer so a + # human must click Approve before DeepSeek is called. + # + # Setup: Repo Settings → Environments → New environment + # Name: ai-regeneration + # Required reviewers: add yourself (or the team) + # ───────────────────────────────────────────────────────────── + regenerate: + name: Regenerate prompt files (requires approval) + runs-on: ubuntu-latest + needs: detect + if: | + github.event_name == 'workflow_dispatch' && + needs.detect.outputs.has_changes == 'true' + environment: ai-regeneration # ← approval gate lives here + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install requests + + - name: Regenerate affected files + run: | + python3 - <<'EOF' + import json, os, sys, requests + + api_key = os.environ["DEEPSEEK_API_KEY"] + affected = os.environ.get("AFFECTED_FILES", "").split() + + if not affected: + print("No files to regenerate.") + sys.exit(0) + + with open("scripts/generate-agents/config.json") as f: + config = json.load(f) + + topics_by_output = {t["output"]: t for t in config["topics"]} + + def read_sources(sources): + parts = [] + for src in sources: + if os.path.exists(src): + with open(src) as f: + parts.append(f"### {src}\n\n{f.read()}") + else: + print(f"Warning: source file not found: {src}") + return "\n\n---\n\n".join(parts) + + system_prompt = ( + "You are generating AI agent skill files for the goravel/docs repository. " + "The output is used by AI coding agents (Cursor, Claude, Copilot) to write correct Goravel v1.17 code. " + "Rules:\n" + "- Output only what an AI needs to write code. No prose, no tutorial explanations.\n" + "- Show complete working code examples from the documentation.\n" + "- Include ALL configuration options, not just the defaults.\n" + "- List gotchas and wrong/correct patterns explicitly.\n" + "- Use exact import paths as shown in the docs.\n" + "- Mark v1.17 breaking changes with // BREAKING v1.17:\n" + "- Do not invent any behaviour not present in the source docs.\n" + "- Do not use em dashes. Write clearly and directly.\n" + "- If something is ambiguous, note it as a comment in code rather than inventing behaviour.\n" + ) + + for output_path in affected: + topic = topics_by_output.get(output_path) + if not topic: + print(f"No topic config found for: {output_path}") + continue + + sources_content = read_sources(topic["sources"]) + + user_prompt = ( + f"Regenerate the file `{output_path}` based on the following source documentation.\n\n" + f"{sources_content}\n\n" + f"Output only the raw Markdown content for `{output_path}`. " + f"No preamble, no commentary outside the file content." + ) + + print(f"Regenerating: {output_path}") + + response = requests.post( + "https://api.deepseek.com/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "model": "deepseek-chat", + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": 0.1, + "max_tokens": 8192, + }, + timeout=120, + ) + + response.raise_for_status() + content = response.json()["choices"][0]["message"]["content"] + + # Strip markdown code fences if the model wrapped the output + if content.startswith("```"): + lines = content.splitlines() + content = "\n".join(lines[1:-1]) if lines[-1].strip() == "```" else "\n".join(lines[1:]) + + os.makedirs(os.path.dirname(output_path), exist_ok=True) + with open(output_path, "w") as f: + f.write(content) + + print(f" Written: {output_path}") + + print("Done.") + EOF + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + AFFECTED_FILES: ${{ needs.detect.outputs.affected_outputs }} + + - name: Open pull request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: auto/regenerate-agent-files + commit-message: "chore: regenerate agent skill files" + title: "chore: regenerate .ai/ prompt files from updated docs" + body: | + This PR was opened automatically after a manually approved regeneration run. + + **Changed source files:** + ${{ needs.detect.outputs.changed_sources }} + + **Regenerated output files:** + ${{ needs.detect.outputs.affected_outputs }} + + Please review the generated content before merging. Do not auto-merge. + delete-branch: true + draft: false + labels: "ai-generated,documentation" diff --git a/scripts/generate-agents/config.json b/scripts/generate-agents/config.json new file mode 100644 index 000000000..eeafcdd50 --- /dev/null +++ b/scripts/generate-agents/config.json @@ -0,0 +1,207 @@ +{ + "topics": [ + { + "output": ".ai/AGENTS.md", + "sources": [ + "en/upgrade/v1.17.md", + "en/getting-started/installation.md", + "en/getting-started/configuration.md", + "en/getting-started/directory-structure.md", + "en/architecture-concepts/request-lifecycle.md", + "en/architecture-concepts/facades.md", + "en/architecture-concepts/service-container.md", + "en/architecture-concepts/service-providers.md", + "en/digging-deeper/artisan-console.md", + "en/database/migrations.md" + ] + }, + { + "output": ".ai/prompt/bootstrap.md", + "sources": [ + "en/architecture-concepts/request-lifecycle.md", + "en/architecture-concepts/service-container.md", + "en/architecture-concepts/service-providers.md", + "en/architecture-concepts/facades.md", + "en/getting-started/directory-structure.md", + "en/getting-started/configuration.md" + ] + }, + { + "output": ".ai/prompt/route.md", + "sources": [ + "en/the-basics/routing.md", + "en/the-basics/middleware.md" + ] + }, + { + "output": ".ai/prompt/middleware.md", + "sources": [ + "en/the-basics/middleware.md", + "en/the-basics/views.md" + ] + }, + { + "output": ".ai/prompt/controller.md", + "sources": [ + "en/the-basics/controllers.md", + "en/the-basics/request.md", + "en/the-basics/response.md" + ] + }, + { + "output": ".ai/prompt/view.md", + "sources": [ + "en/the-basics/views.md" + ] + }, + { + "output": ".ai/prompt/session.md", + "sources": [ + "en/the-basics/session.md" + ] + }, + { + "output": ".ai/prompt/validation.md", + "sources": [ + "en/the-basics/validation.md" + ] + }, + { + "output": ".ai/prompt/log.md", + "sources": [ + "en/the-basics/logging.md" + ] + }, + { + "output": ".ai/prompt/grpc.md", + "sources": [ + "en/the-basics/grpc.md" + ] + }, + { + "output": ".ai/prompt/orm.md", + "sources": [ + "en/orm/getting-started.md", + "en/orm/relationships.md", + "en/orm/factories.md", + "en/database/getting-started.md", + "en/database/queries.md", + "en/database/migrations.md", + "en/database/seeding.md" + ] + }, + { + "output": ".ai/prompt/auth.md", + "sources": [ + "en/security/authentication.md", + "en/security/authorization.md", + "en/security/hashing.md", + "en/security/encryption.md" + ] + }, + { + "output": ".ai/prompt/artisan.md", + "sources": [ + "en/digging-deeper/artisan-console.md", + "en/digging-deeper/task-scheduling.md" + ] + }, + { + "output": ".ai/prompt/cache.md", + "sources": [ + "en/digging-deeper/cache.md" + ] + }, + { + "output": ".ai/prompt/event.md", + "sources": [ + "en/digging-deeper/event.md" + ] + }, + { + "output": ".ai/prompt/queue.md", + "sources": [ + "en/digging-deeper/queues.md" + ] + }, + { + "output": ".ai/prompt/storage.md", + "sources": [ + "en/digging-deeper/filesystem.md" + ] + }, + { + "output": ".ai/prompt/mail.md", + "sources": [ + "en/digging-deeper/mail.md" + ] + }, + { + "output": ".ai/prompt/http.md", + "sources": [ + "en/digging-deeper/http-client.md" + ] + }, + { + "output": ".ai/prompt/process.md", + "sources": [ + "en/digging-deeper/processes.md" + ] + }, + { + "output": ".ai/prompt/localization.md", + "sources": [ + "en/digging-deeper/localization.md" + ] + }, + { + "output": ".ai/prompt/migration.md", + "sources": [ + "en/database/migrations.md" + ] + }, + { + "output": ".ai/prompt/testing.md", + "sources": [ + "en/testing/getting-started.md", + "en/testing/http-tests.md", + "en/testing/mock.md" + ] + }, + { + "output": ".ai/prompt/helpers.md", + "sources": [ + "en/digging-deeper/helpers.md", + "en/digging-deeper/strings.md", + "en/digging-deeper/color.md", + "en/digging-deeper/pluralization.md" + ] + }, + { + "output": ".ai/prompt/best-practices.md", + "sources": [ + "en/upgrade/v1.17.md", + "en/orm/getting-started.md", + "en/orm/relationships.md", + "en/the-basics/routing.md", + "en/the-basics/middleware.md", + "en/the-basics/controllers.md", + "en/the-basics/request.md", + "en/the-basics/response.md", + "en/the-basics/session.md", + "en/the-basics/validation.md", + "en/security/authentication.md", + "en/security/authorization.md", + "en/security/hashing.md", + "en/digging-deeper/queues.md", + "en/digging-deeper/cache.md", + "en/digging-deeper/event.md", + "en/testing/getting-started.md", + "en/testing/http-tests.md", + "en/testing/mock.md", + "en/architecture-concepts/service-container.md", + "en/architecture-concepts/service-providers.md" + ] + } + ] +} From ff6a89e6365d76d2f5335518a028157eba59ee54 Mon Sep 17 00:00:00 2001 From: kkumar-gcc Date: Sat, 21 Mar 2026 01:01:18 +0530 Subject: [PATCH 2/2] optimise docs --- .ai/AGENTS.md | 373 ++++------- .ai/agents.json | 412 ++++++++++++ .ai/knowledge/artisan.md | 135 ++++ .ai/knowledge/auth.md | 129 ++++ .ai/knowledge/bootstrap.md | 144 ++++ .ai/knowledge/cache.md | 112 ++++ .ai/knowledge/carbon.md | 205 ++++++ .ai/knowledge/event.md | 124 ++++ .ai/knowledge/grpc.md | 145 ++++ .ai/knowledge/hash-crypt.md | 118 ++++ .ai/knowledge/helpers.md | 121 ++++ .ai/knowledge/http-client.md | 137 ++++ .ai/knowledge/localization.md | 120 ++++ .ai/knowledge/log.md | 92 +++ .ai/knowledge/mail.md | 127 ++++ .ai/knowledge/migration.md | 118 ++++ .ai/knowledge/orm.md | 183 +++++ .ai/knowledge/process.md | 139 ++++ .ai/knowledge/queue.md | 116 ++++ .ai/knowledge/request-response.md | 132 ++++ .ai/knowledge/route.md | 125 ++++ .ai/knowledge/schedule.md | 110 +++ .ai/knowledge/session.md | 137 ++++ .ai/knowledge/storage.md | 146 ++++ .ai/knowledge/str.md | 167 +++++ .ai/knowledge/testing.md | 205 ++++++ .ai/knowledge/validation.md | 149 +++++ .ai/knowledge/view.md | 115 ++++ .ai/prompt/artisan.md | 324 --------- .ai/prompt/auth.md | 390 ----------- .ai/prompt/best-practices.md | 992 ---------------------------- .ai/prompt/bootstrap.md | 402 ----------- .ai/prompt/cache.md | 254 ------- .ai/prompt/controller.md | 316 --------- .ai/prompt/controllers.md | 343 ---------- .ai/prompt/event.md | 134 ---- .ai/prompt/events.md | 201 ------ .ai/prompt/facades.md | 338 ---------- .ai/prompt/grpc.md | 269 -------- .ai/prompt/helpers.md | 348 ---------- .ai/prompt/http.md | 249 ------- .ai/prompt/localization.md | 154 ----- .ai/prompt/log.md | 158 ----- .ai/prompt/mail.md | 162 ----- .ai/prompt/middleware.md | 217 ------ .ai/prompt/migration.md | 346 ---------- .ai/prompt/models.md | 920 -------------------------- .ai/prompt/orm.md | 474 ------------- .ai/prompt/process.md | 207 ------ .ai/prompt/queue.md | 180 ----- .ai/prompt/queues.md | 260 -------- .ai/prompt/route.md | 360 ---------- .ai/prompt/routing.md | 267 -------- .ai/prompt/session.md | 265 -------- .ai/prompt/storage.md | 213 ------ .ai/prompt/testing.md | 421 ------------ .ai/prompt/validation.md | 461 ------------- .ai/prompt/view.md | 167 ----- scripts/generate-agents/config.json | 159 ++--- 59 files changed, 4138 insertions(+), 10149 deletions(-) create mode 100644 .ai/agents.json create mode 100644 .ai/knowledge/artisan.md create mode 100644 .ai/knowledge/auth.md create mode 100644 .ai/knowledge/bootstrap.md create mode 100644 .ai/knowledge/cache.md create mode 100644 .ai/knowledge/carbon.md create mode 100644 .ai/knowledge/event.md create mode 100644 .ai/knowledge/grpc.md create mode 100644 .ai/knowledge/hash-crypt.md create mode 100644 .ai/knowledge/helpers.md create mode 100644 .ai/knowledge/http-client.md create mode 100644 .ai/knowledge/localization.md create mode 100644 .ai/knowledge/log.md create mode 100644 .ai/knowledge/mail.md create mode 100644 .ai/knowledge/migration.md create mode 100644 .ai/knowledge/orm.md create mode 100644 .ai/knowledge/process.md create mode 100644 .ai/knowledge/queue.md create mode 100644 .ai/knowledge/request-response.md create mode 100644 .ai/knowledge/route.md create mode 100644 .ai/knowledge/schedule.md create mode 100644 .ai/knowledge/session.md create mode 100644 .ai/knowledge/storage.md create mode 100644 .ai/knowledge/str.md create mode 100644 .ai/knowledge/testing.md create mode 100644 .ai/knowledge/validation.md create mode 100644 .ai/knowledge/view.md delete mode 100644 .ai/prompt/artisan.md delete mode 100644 .ai/prompt/auth.md delete mode 100644 .ai/prompt/best-practices.md delete mode 100644 .ai/prompt/bootstrap.md delete mode 100644 .ai/prompt/cache.md delete mode 100644 .ai/prompt/controller.md delete mode 100644 .ai/prompt/controllers.md delete mode 100644 .ai/prompt/event.md delete mode 100644 .ai/prompt/events.md delete mode 100644 .ai/prompt/facades.md delete mode 100644 .ai/prompt/grpc.md delete mode 100644 .ai/prompt/helpers.md delete mode 100644 .ai/prompt/http.md delete mode 100644 .ai/prompt/localization.md delete mode 100644 .ai/prompt/log.md delete mode 100644 .ai/prompt/mail.md delete mode 100644 .ai/prompt/middleware.md delete mode 100644 .ai/prompt/migration.md delete mode 100644 .ai/prompt/models.md delete mode 100644 .ai/prompt/orm.md delete mode 100644 .ai/prompt/process.md delete mode 100644 .ai/prompt/queue.md delete mode 100644 .ai/prompt/queues.md delete mode 100644 .ai/prompt/route.md delete mode 100644 .ai/prompt/routing.md delete mode 100644 .ai/prompt/session.md delete mode 100644 .ai/prompt/storage.md delete mode 100644 .ai/prompt/testing.md delete mode 100644 .ai/prompt/validation.md delete mode 100644 .ai/prompt/view.md diff --git a/.ai/AGENTS.md b/.ai/AGENTS.md index fd819b759..172ca5e69 100644 --- a/.ai/AGENTS.md +++ b/.ai/AGENTS.md @@ -1,281 +1,154 @@ -# Goravel v1.17 — Agent Reference - - - -## Hard Rules (break code if violated) - -1. Import facades from `{module}/app/facades` where `{module}` is the Go module name from `go.mod`. Never use `github.com/goravel/framework/facades` — it does not exist. -2. Never hardcode directory paths (`app/`, `config/`, `routes/`, etc.) as fixed. All paths are configurable via `WithPaths` in `bootstrap/app.go`. -3. Every controller/route handler must have signature `func(ctx http.Context) http.Response`. Missing the return type is a compile error. -4. Do not call `facades.Grpc().Client()` — it is deprecated. Use `facades.Grpc().Connect("name")`. -5. `GlobalScopes()` on a model must return `map[string]func(orm.Query) orm.Query`, NOT `[]func(...)`. -6. `Sum(column string, dest any) error` — Sum no longer returns `(int64, error)`. -7. `facades.Validation().Make()` requires `ctx` as first argument: `Make(ctx, input, rules)`. -8. Custom Rule `Passes(ctx context.Context, data Data, val any, opts ...any) bool` and `Message(ctx context.Context) string`. -9. Custom Filter `Handle(ctx context.Context) any`. -10. Custom log driver `Handle(channel string) (Handler, error)` — NOT `(Hook, error)`. Use `log.HookToHandler(hook)` adapter for old hooks. -11. `grpc.clients` config key renamed to `grpc.servers`. -12. `Http.Request.Bind` is removed — use `response.Bind(&dest)` on the response object. -13. Machinery queue driver is removed. Migrate to `redis`, `database`, or `sync`. -14. Golang >= 1.24 required for v1.17. -15. Register new service providers `&process.ServiceProvider{}` and `&view.ServiceProvider{}`. -16. Middleware is a function returning `http.Middleware`, not a struct. -17. Service providers: only bind things in `Register`. Never register routes/events in `Register` — use `Boot` or `WithCallback`. -18. `Find(&model, id)` returns nil error even when record is not found. Use `FindOrFail` to error on missing. -19. Struct updates skip zero-value fields. Use `map[string]any` to set zero values. -20. `facades.Auth(ctx).User()` / `ID()` requires `Parse(token)` to be called first. +# Goravel Agent Reference + +Go-first framework. Not Laravel. Do not port PHP patterns directly. --- -## Laravel → Goravel Cheatsheet - -| Laravel | Goravel | -|---------|---------| -| `Route::get('/', [C::class, 'm'])` | `facades.Route().Get("/", controller.Method)` | -| `Route::group(['prefix'=>'api'], fn)` | `facades.Route().Prefix("api").Group(func(r route.Router){...})` | -| `Route::middleware(['auth'])` | `facades.Route().Middleware(middleware.Auth()).Get(...)` | -| `$request->input('name')` | `ctx.Request().Input("name")` | -| `$request->validate([...])` | `ctx.Request().Validate(map[string]string{...})` | -| `return response()->json([...])` | `return ctx.Response().Json(http.StatusOK, http.Json{...})` | -| `Auth::login($user)` | `facades.Auth(ctx).Login(&user)` | -| `Auth::user()` | `facades.Auth(ctx).User(&user)` | -| `Hash::make('password')` | `facades.Hash().Make(password)` | -| `Hash::check('plain', $hash)` | `facades.Hash().Check("plain", hash)` | -| `Crypt::encryptString($v)` | `facades.Crypt().EncryptString(v)` | -| `Gate::define('action', fn)` | `facades.Gate().Define("action", fn)` | -| `Gate::allows('action', $args)` | `facades.Gate().Allows("action", map[string]any{...})` | -| `User::find(1)` | `facades.Orm().Query().Find(&user, 1)` | -| `User::findOrFail(1)` | `facades.Orm().Query().FindOrFail(&user, 1)` | -| `User::where('name','tom')->first()` | `facades.Orm().Query().Where("name","tom").First(&user)` | -| `User::create([...])` | `facades.Orm().Query().Create(&user)` | -| `$user->save()` | `facades.Orm().Query().Save(&user)` | -| `$user->delete()` | `facades.Orm().Query().Delete(&user)` | -| `User::withTrashed()->find(1)` | `facades.Orm().Query().WithTrashed().Find(&user,1)` | -| `User::with('posts')->get()` | `facades.Orm().Query().With("Posts").Get(&users)` | -| `dispatch(new Job($args))` | `facades.Queue().Job(&jobs.MyJob{}, args).Dispatch()` | -| `event(new Foo($args))` | `facades.Event().Job(&events.Foo{}, args).Dispatch()` | -| `Schema::create('users', fn)` | `facades.Schema().Create("users", func(t schema.Blueprint){...})` | -| `Cache::put('k', $v, 60)` | `facades.Cache().Put("k", v, 60*time.Second)` | -| `Cache::remember('k', 60, fn)` | `facades.Cache().Remember("k", 60*time.Second, fn)` | -| `Storage::put('f', $c)` | `facades.Storage().Put("f", contents)` | -| `Mail::to([])->send(new M())` | `facades.Mail().To([...]).Content(...).Send()` | -| `Http::get(url)` | `facades.Http().Get(url)` | -| `Log::info('msg')` | `facades.Log().Info("msg")` | -| `__('key')` | `facades.Lang(ctx).Get("key")` | -| `php artisan make:model User` | `./artisan make:model User` | -| `php artisan migrate` | `./artisan migrate` | +## Setup ---- +Knowledge files are installed per-facade via the framework CLI: -## Bootstrap Lifecycle — `bootstrap/app.go` +```bash +# Install knowledge for specific facades you are using +./artisan agents:install --facade=Orm,Route,Auth -```go -// bootstrap/app.go -package bootstrap - -import ( - "github.com/goravel/framework/foundation" - contractsfoundation "github.com/goravel/framework/contracts/foundation" - // ... other imports -) - -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithProviders(Providers). // bootstrap/providers.go - WithConfig(config.Boot). // registers config files - WithRouting(func() { // registers HTTP/gRPC routes - routes.Web() - routes.Grpc() - }). - WithMiddleware(func(handler configuration.Middleware) { - handler.Append(middleware.Custom()) - }). - WithCommands(Commands). // bootstrap/commands.go - WithEvents(func() map[event.Event][]event.Listener { - return map[event.Event][]event.Listener{ - events.NewOrderShipped(): {listeners.NewSendShipmentNotification()}, - } - }). - WithJobs(Jobs). // bootstrap/jobs.go - WithMigrations(Migrations). // bootstrap/migrations.go - WithSeeders(Seeders). // bootstrap/seeders.go - WithSchedule(func() []schedule.Event { - return []schedule.Event{ - facades.Schedule().Call(func() { ... }).Daily(), - } - }). - WithRules(Rules). // bootstrap/rules.go - WithFilters(Filters). // bootstrap/filters.go - WithRunners(func() []foundation.Runner { - return []foundation.Runner{NewCustomRunner()} - }). - WithPaths(func(paths configuration.Paths) { - paths.App("src") // optional: customize directories - }). - WithCallback(func() { - // runs after all providers Boot(); all facades available here - facades.Gate().Define(...) - facades.RateLimiter().For(...) - facades.Orm().Observe(...) - }). - Create() -} +# Install all knowledge files +./artisan agents:install --all + +# Update already-installed files to latest +./artisan agents:install --update ``` -**Boot order inside `Create()`:** -1. Load configuration (`WithConfig`) -2. Register all service providers (calls `Register` on each) -3. Boot all service providers (calls `Boot` on each) -4. Run `WithCallback` -5. Runners start (HTTP server, Queue worker, Schedule, gRPC, etc.) +Files are downloaded from the docs repo and placed in `.ai/knowledge/`. +The manifest at `.ai/agents.json` maps facade names to file URLs. --- -## Facade Import Pattern - -Facades live in `app/facades/` of your project. Import path depends on `go.mod` module name: +## Facade Import ```go -// go.mod: module github.com/mycompany/myapp +// WRONG: import "github.com/goravel/framework/facades" (does not exist) +// RIGHT: import path from go.mod module name +import "yourmodule/app/facades" +``` -import "github.com/mycompany/myapp/app/facades" +Your installed facades are in `app/facades/`. Check which ones exist before using them. +Not all facades are installed in every project. -facades.Route().Get("/", handler) -facades.Orm().Query().Find(&user, 1) -facades.Auth(ctx).Login(&user) -``` +--- -Available facades: `App`, `Artisan`, `Auth`, `Cache`, `Config`, `Crypt`, `DB`, `Event`, `Gate`, `Grpc`, `Hash`, `Http`, `Lang`, `Log`, `Mail`, `Orm`, `Process`, `Queue`, `RateLimiter`, `Route`, `Schedule`, `Schema`, `Seeder`, `Session`, `Storage`, `Validation`, `View`. +## Wrong vs Right Patterns + +| Pattern | Wrong | Right | +| --------------------- | ----------------------------- | ------------------------------------------------------------------- | +| Handler return | `func(ctx http.Context)` | `func(ctx http.Context) http.Response` | +| Find missing record | check `err != nil` | `FindOrFail` -- `Find` returns nil on miss | +| Struct zero update | `Update(User{Age: 0})` | `Update(map[string]any{"age": 0})` | +| GlobalScopes return | `[]func(Query) Query` | `map[string]func(Query) Query` | +| Sum/Avg/Max/Min | `sum, err := .Sum("col")` | `err := .Sum("col", &dest)` | +| Validation Make | `Make(input, rules)` | `Make(ctx, input, rules)` | +| Custom Rule Passes | `Passes(data, val)` | `Passes(ctx context.Context, data Data, val any, opts ...any) bool` | +| Custom Rule Message | `Message() string` | `Message(ctx context.Context) string` | +| Custom Filter Handle | `Handle() any` | `Handle(ctx context.Context) any` | +| Log driver Handle | `Handle(ch) (Hook, error)` | `Handle(ch string) (Handler, error)` | +| Log With | `With("key", val)` | `With(map[string]any{"key": val})` | +| Log Request | `Request(*http.Request)` | `Request(http.ContextRequest)` | +| Auth User/ID | call any time | call `Parse(token)` first on same ctx | +| gRPC client | `Grpc().Client()` | `Grpc().Connect("name")` | +| Http client Bind | `request.Bind(&dest)` | `response.Bind(&dest)` | +| Http client body | `Post(uri, string)` | `Post(uri, io.Reader)` | +| Middleware type | struct with Handle | `func() http.Middleware` returning closure | +| Middleware abort | bare `return` | `.Abort(); return` | +| Session chain | methods return void | all mutating methods return `Session` | +| Session Regenerate | returns `bool` | returns `error` | +| Cache Increment | returns `(int, error)` | returns `(int64, error)` | +| Validation Errors.Get | returns `[]string` | returns `map[string]string` | +| Validation Errors.All | returns `map[string][]string` | returns `map[string]map[string]string` | +| UserProvider typo | `RetrieveByID` | `RetriveByID` (matches contract exactly) | +| Queue Jobs alias | `Jobs{}` | `ChainJob{}` (Jobs is deprecated) | +| Queue Machinery | use Machinery driver | removed; use `sync`/`database`/`redis` | +| ORM Delete args | `Delete(value, conds...)` | `Delete(value ...any)` | +| ORM Update return | `error` | `(*db.Result, error)` | --- -## Directory Paths Are Configurable +## Available Facades -```go -WithPaths(func(paths configuration.Paths) { - paths.App("src") - paths.Config("configuration") - paths.Database("db") - paths.Routes("api/routes") - paths.Storage("data") - paths.Resources("views-root") -}) +``` +App Artisan Auth(ctx) Cache Config Crypt DB Event Gate Grpc +Hash Http Lang(ctx) Log Mail Orm Process Queue RateLimiter +Route Schedule Schema Seeder Session Storage Validation View ``` -Never assume `app/`, `config/`, etc. are fixed. +`Auth(ctx)` and `Lang(ctx)` take `http.Context`. All others: `facades.Name()`. + +For the latest interface of any facade, the authoritative source is the framework contracts: +`https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/` --- -## Artisan Commands - -```shell -# Code generation -./artisan make:controller UserController -./artisan make:controller --resource PhotoController -./artisan make:controller user/UserController -./artisan make:model User -./artisan make:model --table=users User -./artisan make:migration create_users_table -./artisan make:migration create_users_table -m User # v1.17: from model -./artisan make:seeder UserSeeder -./artisan make:factory UserFactory -./artisan make:command SendEmails -./artisan make:middleware Auth -./artisan make:middleware user/Auth -./artisan make:job ProcessPodcast -./artisan make:event OrderShipped -./artisan make:listener SendShipmentNotification -./artisan make:observer UserObserver -./artisan make:policy PostPolicy -./artisan make:request StorePostRequest -./artisan make:rule Uppercase -./artisan make:filter ToInt -./artisan make:mail OrderShipped -./artisan make:provider YourServiceProvider # v1.17 new -./artisan make:view welcome # v1.17 new - -# Database -./artisan migrate -./artisan migrate:status -./artisan migrate:rollback # v1.17: rolls back all of last batch -./artisan migrate:rollback --batch=2 -./artisan migrate:rollback --step=5 -./artisan migrate:reset -./artisan migrate:refresh -./artisan migrate:fresh -./artisan migrate:fresh --seed -./artisan db:seed -./artisan db:seed --seeder=UserSeeder -./artisan db:show -./artisan db:table users - -# Application -./artisan key:generate -./artisan jwt:secret -./artisan env:encrypt -./artisan env:decrypt -./artisan about -./artisan list -./artisan route:list -./artisan schedule:run -./artisan schedule:list -./artisan queue:failed -./artisan queue:retry {uuid} -./artisan queue:retry all - -# Build (v1.17: new flags) -./artisan build -./artisan build --arch=amd64 -./artisan build --static - -# Packages (Goravel Lite) -./artisan package:install Route -./artisan package:install --all -./artisan package:uninstall Route -``` +## Bootstrap Entry Point + +The application entry is `bootstrap/app.go`. For the full `ApplicationBuilder` interface: +`https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/foundation/application_builder.go` + +Boot order: **Config** -> **Register providers** -> **Boot providers** -> **WithCallback** -> **Runners** + +`WithCallback` is the only safe place to call facades during startup (gates, observers, rate limiters). --- -## v1.17 Breaking Changes Summary - -| Area | Change | -|------|--------| -| Queue | Machinery driver completely removed | -| gRPC config | `grpc.clients` → `grpc.servers`; `Client()` deprecated, use `Connect()` | -| Log driver | `Handle(channel) (Handler, error)` replaces `(Hook, error)`; adapter: `log.HookToHandler(hook)` | -| migrate:rollback | Rolls back entire last batch by default (was 1 migration); use `--step` for old behavior | -| Http client | `Request.Bind()` removed; use `response.Bind(&dest)` | -| Validation | `Make(ctx, input, rules)` — ctx now required; Rule/Filter interfaces have `ctx context.Context` | -| ORM GlobalScopes | Return type changed: `map[string]func(orm.Query) orm.Query` (was `[]func(...)`) | -| ORM Sum | Signature: `Sum(column string, dest any) error` (was `(int64, error)`) | -| Package setup | `match.Providers()` → `match.ProvidersInConfig()` for old code structure | -| Golang | Minimum version: 1.24 (was 1.23) | +## Section-Based Loading + +Every knowledge file has this structure. Load only what the sub-task requires. + +| Section | Contains | Load when | +| --------------------------- | ------------------------------------------- | -------------------------------------------------------- | +| `## Core Imports` | Exact import paths | Writing import blocks | +| `## Contracts` | Raw GitHub URLs to framework contract files | Need exact type/interface definition; fetch the URL live | +| `## Available Methods` | Concise method list with return types | Looking up a method name or return type | +| `## Implementation Example` | One complete working code block | Starting an implementation from scratch | +| `## Rules` | Constraints, gotchas, deprecated notices | Debugging; something is not working | + +**Interface and struct definitions are not duplicated in these files.** +Fetch the contract URL(s) from `## Contracts` when you need exact signatures. +The framework contracts are always current and authoritative. + +- Skip `## Implementation Example` when you only need a method signature. +- Skip `## Core Imports` when imports are already established in the file being edited. +- Read `## Rules` last -- only needed when the straightforward approach is failing. --- -## Prompt File Index - -- [prompt/bootstrap.md](prompt/bootstrap.md) — Service container, providers, runners, lifecycle -- [prompt/route.md](prompt/route.md) — Routing, rate limiting, CORS, static files -- [prompt/middleware.md](prompt/middleware.md) — Middleware definition, global/route, abort, CSRF -- [prompt/controller.md](prompt/controller.md) — Controllers, request input, responses -- [prompt/view.md](prompt/view.md) — View templates, CSRF, facades.View -- [prompt/session.md](prompt/session.md) — Session operations -- [prompt/validation.md](prompt/validation.md) — Validation rules, custom rules/filters -- [prompt/log.md](prompt/log.md) — Logging, channels, custom drivers -- [prompt/grpc.md](prompt/grpc.md) — gRPC server/client, interceptors -- [prompt/orm.md](prompt/orm.md) — ORM models, queries, relations, migrations, seeders, factories -- [prompt/auth.md](prompt/auth.md) — Auth (JWT/session), gates, hashing, encryption -- [prompt/artisan.md](prompt/artisan.md) — Console commands, scheduling -- [prompt/cache.md](prompt/cache.md) — Cache operations, atomic locks -- [prompt/event.md](prompt/event.md) — Events and listeners -- [prompt/queue.md](prompt/queue.md) — Queue jobs, dispatching, chaining -- [prompt/storage.md](prompt/storage.md) — Filesystem/storage operations -- [prompt/mail.md](prompt/mail.md) — Mail sending, templates, Mailable -- [prompt/http.md](prompt/http.md) — HTTP client facade -- [prompt/process.md](prompt/process.md) — Process facade (new in v1.17) -- [prompt/localization.md](prompt/localization.md) — Lang/translation -- [prompt/migration.md](prompt/migration.md) — Migration commands, auto-generation from model, all column types/modifiers/indexes -- [prompt/testing.md](prompt/testing.md) — HTTP tests, mocks, Docker testing, all assertions -- [prompt/helpers.md](prompt/helpers.md) — Path/carbon/debug/maps/convert/collect helpers, fluent str, color -- [prompt/best-practices.md](prompt/best-practices.md) — Naming conventions, ORM patterns, security, middleware, jobs, cache, performance +## Knowledge Files + +Install and load the relevant file for your task. Check `.ai/agents.json` for the full facade-to-file map. + +| When task involves... | File | +| ------------------------------------------------------------- | --------------------- | +| Service container, providers, `Bind`/`Singleton`, runners | `bootstrap.md` | +| Defining routes, middleware, rate limiting | `route.md` | +| Request input, HTTP responses, cookies, streams | `request-response.md` | +| Database queries, models, relations, transactions, ORM events | `orm.md` | +| Database table creation, column types, indexes | `migration.md` | +| Caching, atomic locks | `cache.md` | +| JWT auth, login/logout, guards, gates, policies | `auth.md` | +| Background jobs, dispatch, chaining | `queue.md` | +| Events, listeners | `event.md` | +| Sending email, Mailable structs | `mail.md` | +| File upload, disk read/write, cloud storage | `storage.md` | +| Log entries, channels, custom drivers | `log.md` | +| Input validation, form requests, custom rules | `validation.md` | +| Artisan commands, arguments, flags, prompts | `artisan.md` | +| Scheduled tasks, cron | `schedule.md` | +| HTTP requests to external APIs, test faking | `http-client.md` | +| Password hashing, string encryption | `hash-crypt.md` | +| Session read/write, flash data | `session.md` | +| gRPC server/client, interceptors | `grpc.md` | +| External shell commands, pipelines, pools | `process.md` | +| Translations, pluralization, locale | `localization.md` | +| View templates, CSRF | `view.md` | +| HTTP tests, mock facades, Docker DB | `testing.md` | +| `str.Of(...)` fluent string methods | `str.md` | +| Date/time, `carbon.Parse`, arithmetic | `carbon.md` | +| Path helpers, maps, slices, debug, color | `helpers.md` | diff --git a/.ai/agents.json b/.ai/agents.json new file mode 100644 index 000000000..6f4f0ff97 --- /dev/null +++ b/.ai/agents.json @@ -0,0 +1,412 @@ +{ + "version": 1, + "base_url": "https://raw.githubusercontent.com/goravel/docs/master/.ai/knowledge", + "agents_md": "https://raw.githubusercontent.com/goravel/docs/master/.ai/AGENTS.md", + "local_dir": ".ai/knowledge", + "facades": { + "App": { + "file": "bootstrap.md", + "triggers": [ + "foundation.Application", + "ServiceProvider", + "Bind", + "Singleton", + "Runner" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/foundation/application_builder.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/foundation/application.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/foundation/service_provider.go" + ] + }, + "Artisan": { + "file": "artisan.md", + "triggers": [ + "console.Command", + "Artisan", + "make:command" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/console/command.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/console/command/flags.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/console/command/arguments.go" + ] + }, + "Auth": { + "file": "auth.md", + "triggers": [ + "auth.Auth", + "GuardDriver", + "Parse", + "Login", + "Gate" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/auth/auth.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/auth/access/gate.go" + ] + }, + "Cache": { + "file": "cache.md", + "triggers": [ + "cache.Cache", + "cache.Driver", + "Lock" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/cache/cache.go" + ] + }, + "Config": { + "file": "bootstrap.md", + "triggers": [ + "config.Config" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/foundation/application_builder.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/foundation/application.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/foundation/service_provider.go" + ] + }, + "Crypt": { + "file": "hash-crypt.md", + "triggers": [ + "crypt.Crypt", + "EncryptString", + "DecryptString" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/hash/hash.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/crypt/crypt.go" + ] + }, + "DB": { + "file": "orm.md", + "triggers": [ + "database/db", + "DB.Table", + "DB.Raw" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/orm/orm.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/orm/events.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/orm/observer.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/orm/factory.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/factory/factory.go" + ] + }, + "Event": { + "file": "event.md", + "triggers": [ + "event.Event", + "event.Listener", + "Dispatch" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/event/events.go" + ] + }, + "Gate": { + "file": "auth.md", + "triggers": [ + "access.Gate", + "Define", + "Allows", + "Denies" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/auth/auth.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/auth/access/gate.go" + ] + }, + "Grpc": { + "file": "grpc.md", + "triggers": [ + "grpc.Grpc", + "Connect", + "grpc.Server" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/grpc/grpc.go" + ] + }, + "Hash": { + "file": "hash-crypt.md", + "triggers": [ + "hash.Hash", + "Make", + "Check", + "NeedsRehash" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/hash/hash.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/crypt/crypt.go" + ] + }, + "Http": { + "file": "http-client.md", + "triggers": [ + "http/client", + "client.Request", + "client.Response" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/client/request.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/client/response.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/client/factory.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/client/fake_response.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/client/fake_sequence.go" + ] + }, + "Lang": { + "file": "localization.md", + "triggers": [ + "translation.Translator", + "Choice", + "SetLocale" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/translation/translator.go" + ] + }, + "Log": { + "file": "log.md", + "triggers": [ + "log.Log", + "log.Writer", + "log.Logger", + "log.Handler" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/log/log.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/log/level.go" + ] + }, + "Mail": { + "file": "mail.md", + "triggers": [ + "mail.Mail", + "mail.Mailable", + "mail.Content", + "mail.Envelope" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/mail/mail.go" + ] + }, + "Orm": { + "file": "orm.md", + "triggers": [ + "orm.Orm", + "orm.Query", + "orm.Model", + "database/orm" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/orm/orm.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/orm/events.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/orm/observer.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/orm/factory.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/factory/factory.go" + ] + }, + "Process": { + "file": "process.md", + "triggers": [ + "process.Process", + "process.Result", + "process.Running" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/process/process.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/process/pipeline.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/process/pool.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/process/result.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/process/running.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/process/running_pipe.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/process/running_pool.go" + ] + }, + "Queue": { + "file": "queue.md", + "triggers": [ + "queue.Queue", + "queue.Job", + "queue.PendingJob", + "ChainJob" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/queue/job.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/queue/queue.go" + ] + }, + "RateLimiter": { + "file": "route.md", + "triggers": [ + "http.RateLimiter", + "Limit", + "PerMinute", + "PerHour" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/route/route.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/rate_limiter.go" + ] + }, + "Route": { + "file": "route.md", + "triggers": [ + "route.Route", + "route.Router", + "HandlerFunc" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/route/route.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/rate_limiter.go" + ] + }, + "Schedule": { + "file": "schedule.md", + "triggers": [ + "schedule.Schedule", + "schedule.Event", + "Cron" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/schedule/event.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/schedule/schedule.go" + ] + }, + "Schema": { + "file": "migration.md", + "triggers": [ + "schema.Schema", + "schema.Blueprint", + "Migration" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/schema/schema.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/schema/blueprint.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/schema/index.go" + ] + }, + "Seeder": { + "file": "orm.md", + "triggers": [ + "seeder.Seeder", + "database/seeder" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/orm/orm.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/orm/events.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/orm/observer.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/orm/factory.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/factory/factory.go" + ] + }, + "Session": { + "file": "session.md", + "triggers": [ + "session.Session", + "session.Driver", + "session.Manager" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/session/session.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/session/manager.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/session/driver.go" + ] + }, + "Storage": { + "file": "storage.md", + "triggers": [ + "filesystem.Storage", + "filesystem.Driver", + "filesystem.File" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/filesystem/storage.go" + ] + }, + "Validation": { + "file": "validation.md", + "triggers": [ + "validation.Validation", + "validation.Validator", + "validation.Rule" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/validation/validation.go" + ] + }, + "View": { + "file": "view.md", + "triggers": [ + "view.View", + "ResponseView", + "Make" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/view/view.go" + ] + } + }, + "extras": { + "str": { + "file": "str.md", + "triggers": [ + "support/str", + "str.Of" + ], + "contract_urls": [] + }, + "carbon": { + "file": "carbon.md", + "triggers": [ + "support/carbon", + "carbon.Now", + "carbon.DateTime" + ], + "contract_urls": [] + }, + "helpers": { + "file": "helpers.md", + "triggers": [ + "support/path", + "support/maps", + "support/collect", + "support/convert" + ], + "contract_urls": [] + }, + "testing": { + "file": "testing.md", + "triggers": [ + "testing.TestCase", + "testing/http", + "mock.Factory" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/testing/testing.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/testing/http/request.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/testing/http/response.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/testing/http/assertable_json.go" + ] + }, + "request-response": { + "file": "request-response.md", + "triggers": [ + "http.Context", + "ContextRequest", + "ContextResponse", + "HandlerFunc" + ], + "contract_urls": [ + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/request.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/response.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/context.go", + "https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/cookie.go" + ] + } + } +} \ No newline at end of file diff --git a/.ai/knowledge/artisan.md b/.ai/knowledge/artisan.md new file mode 100644 index 000000000..ce46d83be --- /dev/null +++ b/.ai/knowledge/artisan.md @@ -0,0 +1,135 @@ +# Artisan Console Facade + +## Core Imports + +```go +import ( + "context" + "time" + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/console/command.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/console/command/flags.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/console/command/arguments.go` + +## Available Methods + +**facades.Artisan():** + +- `Call(command string)` - run command string programmatically + +**console.Context - Arguments:** + +- `Argument(index int)` string - 0-based positional +- `Arguments()` []string +- `ArgumentString/Int/Int8/Int16/Int32/Int64(key)` T +- `ArgumentUint/Uint8/Uint16/Uint32/Uint64(key)` T +- `ArgumentFloat32/Float64(key)` T +- `ArgumentStringSlice/IntSlice/Int8Slice/.../Float64Slice(key)` []T +- `ArgumentTimestamp(key)` time.Time +- `ArgumentTimestampSlice(key)` []time.Time + +**console.Context - Flags/Options:** + +- `Option(key)` string +- `OptionBool(key)` bool +- `OptionInt/OptionInt64/OptionFloat64(key)` T +- `OptionSlice(key)` []string +- `OptionIntSlice/OptionInt64Slice/OptionFloat64Slice(key)` []T + +**console.Context - Output:** + +- `Info/Warning/Error/Comment/Success/Line(message)` +- `Green/Greenln/Red/Redln/Yellow/Yellowln/Black/Blackln(message)` +- `NewLine(times ...int)` +- `Divider(filler ...string)` +- `TwoColumnDetail(first, second string, filler ...rune)` +- `Table(headers []string, rows [][]string, option ...TableOption)` + +**console.Context - Interactive:** + +- `Ask(question, ...AskOption)` (string, error) +- `Secret(question, ...SecretOption)` (string, error) +- `Confirm(question, ...ConfirmOption)` bool +- `Choice(question, []Choice, ...ChoiceOption)` (string, error) +- `MultiSelect(question, []Choice, ...MultiSelectOption)` ([]string, error) + +**console.Context - Progress:** + +- `CreateProgressBar(total int)` Progress +- `WithProgressBar(items []any, callback func(any) error)` ([]any, error) +- `Spinner(message string, option SpinnerOption)` error + +## Implementation Example + +```go +package commands + +import ( + "fmt" + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +type SendEmails struct{} + +func (r *SendEmails) Signature() string { return "send:emails" } +func (r *SendEmails) Description() string { return "Send bulk emails" } + +func (r *SendEmails) Extend() command.Extend { + return command.Extend{ + Category: "mail", + Arguments: []command.Argument{ + &command.ArgumentString{Name: "subject", Usage: "Email subject", Required: true}, + }, + Flags: []command.Flag{ + &command.StringFlag{Name: "lang", Value: "en", Aliases: []string{"l"}}, + &command.BoolFlag{Name: "dry-run"}, + &command.IntSliceFlag{Name: "ids", Usage: "User IDs"}, + }, + } +} + +func (r *SendEmails) Handle(ctx console.Context) error { + subject := ctx.ArgumentString("subject") + lang := ctx.Option("lang") + dryRun := ctx.OptionBool("dry-run") + ids := ctx.OptionIntSlice("ids") + + ctx.Info(fmt.Sprintf("Sending to %d users (lang=%s dry=%v)", len(ids), lang, dryRun)) + + // Table output + ctx.Table([]string{"ID", "Status"}, [][]string{{"1", "queued"}}) + + // Auto progress bar + items := make([]any, len(ids)) + for i, id := range ids { items[i] = id } + _, err := ctx.WithProgressBar(items, func(item any) error { + return nil + }) + + _ = subject + return err +} +``` + +## Rules + +- Register via `WithCommands` in `bootstrap/app.go`; `make:command` auto-registers in `bootstrap/commands.go`. +- `Signature()` is the CLI name and must be unique across all commands. +- `Argument(index)` is 0-based positional; `ArgumentString(name)` is by declared name. +- Slice flags (`IntSliceFlag`) use `OptionIntSlice`; `StringSliceFlag` uses `OptionSlice`. +- `OptionFloat64Slice` reads `Float64SliceFlag` values. +- `Ask/Confirm/Choice/MultiSelect` are interactive; do not use in non-TTY (CI) environments. +- `CreateProgressBar` requires `.Start()` first, then `.Advance()`, then `.Finish()`. +- `WithProgressBar` handles lifecycle automatically. +- `Spinner.Action` must block; spinner stops when Action returns. +- `facades.Artisan().Call("cmd args --flag val")` passes the full string including args and flags. diff --git a/.ai/knowledge/auth.md b/.ai/knowledge/auth.md new file mode 100644 index 000000000..7753d6748 --- /dev/null +++ b/.ai/knowledge/auth.md @@ -0,0 +1,129 @@ +# Auth Facade + +## Core Imports + +```go +import ( + "context" + "github.com/goravel/framework/contracts/http" + contractsauth "github.com/goravel/framework/contracts/auth" + contractsaccess "github.com/goravel/framework/contracts/auth/access" + "github.com/goravel/framework/auth/access" // access.NewAllowResponse(), access.NewDenyResponse() + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/auth/auth.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/auth/access/gate.go` + +## Available Methods + +**facades.Auth(ctx):** - takes `http.Context` + +- `Check()` bool - logged in? +- `Guest()` bool - not logged in? +- `Login(&user)` (string, error) - generate token +- `LoginUsingID(id)` (string, error) +- `Parse(token)` (\*Payload, error) - **must call before User/ID** +- `User(&dest)` error - populate struct (requires prior Parse) +- `ID()` (string, error) - user ID as string (requires prior Parse) +- `Refresh()` (string, error) - new token (requires prior Parse) +- `Logout()` error +- `Guard(name)` GuardDriver - switch guard +- `Extend(name, GuardFunc)` - register custom guard driver +- `Provider(name, UserProviderFunc)` - register custom user provider + +**facades.Gate():** + +- `Define(ability, func(ctx context.Context, args map[string]any) Response)` +- `Allows/Denies(ability, args)` bool +- `Inspect(ability, args)` Response +- `Any/None([]string, args)` bool +- `Before(func(ctx, ability, args) Response)` - runs before all checks +- `After(func(ctx, ability, args, result) Response)` - runs after checks +- `WithContext(ctx)` Gate + +**access helpers:** + +- `access.NewAllowResponse()` Response +- `access.NewDenyResponse(message)` Response + +## Implementation Example + +```go +// middleware/auth.go +package middleware + +import ( + "github.com/goravel/framework/contracts/http" + "yourmodule/app/facades" +) + +func Auth() http.Middleware { + return func(ctx http.Context) { + token := ctx.Request().Header("Authorization") + if _, err := facades.Auth(ctx).Parse(token); err != nil { + ctx.Response().String(http.StatusUnauthorized, "unauthorized").Abort() + return + } + ctx.Request().Next() + } +} + +// controllers/auth_controller.go +package controllers + +import ( + "github.com/goravel/framework/contracts/http" + "yourmodule/app/facades" + "yourmodule/app/models" +) + +type AuthController struct{} + +func (r *AuthController) Login(ctx http.Context) http.Response { + var user models.User + facades.Orm().Query().Where("email", ctx.Request().Input("email")).First(&user) + + token, err := facades.Auth(ctx).Login(&user) + if err != nil { + return ctx.Response().Json(http.StatusInternalServerError, http.Json{"error": err.Error()}) + } + return ctx.Response().Json(http.StatusOK, http.Json{"token": token}) +} + +func (r *AuthController) Me(ctx http.Context) http.Response { + // Auth middleware already called Parse; User() works here + var user models.User + if err := facades.Auth(ctx).User(&user); err != nil { + return ctx.Response().Json(http.StatusUnauthorized, http.Json{"error": "unauthorized"}) + } + return ctx.Response().Json(http.StatusOK, user) +} + +// Gate definition in bootstrap/app.go WithCallback: +// facades.Gate().Define("update-post", func(ctx context.Context, args map[string]any) contractsaccess.Response { +// // ctx here is context.Context, NOT http.Context +// post := args["post"].(models.Post) +// userID := args["user_id"].(uint) +// if userID == post.UserID { return access.NewAllowResponse() } +// return access.NewDenyResponse("forbidden") +// }) +``` + +## Rules + +- `facades.Auth(ctx)` takes `http.Context`, not `context.Context`. +- `User()` and `ID()` require `Parse(token)` to have been called first on the same ctx. +- `Check()` / `Guest()` also require prior `Parse`. +- `Guard(name)` must be called before `Login`/`User`/etc. when using non-default guard. +- `Extend` and `Provider` must be called inside `WithCallback` in `bootstrap/app.go`. +- `UserProvider.RetriveByID` - spelled without the second 'e' ("Retrive") - this matches the contract. +- Gate `Define` callback receives `context.Context`, not `http.Context` - extract user from ctx value. +- Gate `Before` returning a non-nil Response short-circuits all other `Define` callbacks. +- Gate `After` only fires if `Define` returned a result (not short-circuited by `Before`). +- JWT config: `config/jwt.go` for global; per-guard overrides in `config/auth.go`. diff --git a/.ai/knowledge/bootstrap.md b/.ai/knowledge/bootstrap.md new file mode 100644 index 000000000..06e6479ef --- /dev/null +++ b/.ai/knowledge/bootstrap.md @@ -0,0 +1,144 @@ +# Bootstrap, Service Container & Providers + +## Core Imports + +```go +import ( + "github.com/goravel/framework/foundation" + contractsfoundation "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/contracts/foundation/binding" + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/foundation/application_builder.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/foundation/application.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/foundation/service_provider.go` + +## Available Methods + +**Service container (inside providers):** + +- `app.Bind(key, func(Application) (any, error))` -- new instance per resolution +- `app.Singleton(key, func(Application) (any, error))` -- single instance +- `app.Instance(key, obj)` -- bind pre-created object +- `app.BindWith(key, func(Application, map[string]any) (any, error))` -- with parameters +- `app.Make(key)` (any, error) -- resolve +- `app.MakeWith(key, params)` (any, error) -- resolve with parameters + +**Via App facade (outside providers):** + +- `facades.App().Make(key)` (any, error) +- `facades.App().Bind(key, fn)` +- `facades.App().Singleton(key, fn)` +- `facades.App().SetLocale(ctx, locale)` +- `facades.App().CurrentLocale(ctx)` string +- `facades.App().IsLocale(ctx, locale)` bool +- `facades.App().Restart()` error -- used in Docker testing + +**bootstrap/app.go builder:** + +- `WithProviders(func() []ServiceProvider)` +- `WithConfig(func())` +- `WithRouting(func())` +- `WithMiddleware(func(Middleware))` +- `WithCommands(func() []Command)` +- `WithJobs(func() []Job)` +- `WithMigrations(func() []Migration)` +- `WithSeeders(func() []Seeder)` +- `WithEvents(func() map[Event][]Listener)` +- `WithSchedule(func() []schedule.Event)` +- `WithRules(func() []Rule)` +- `WithFilters(func() []Filter)` +- `WithRunners(func() []Runner)` +- `WithCallback(func())` +- `WithPaths(func(Paths))` +- `Create()` Application + +## Implementation Example + +```go +// app/providers/payment_service_provider.go +package providers + +import ( + "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/contracts/foundation/binding" + "yourmodule/app/facades" +) + +type PaymentServiceProvider struct{} + +func (r *PaymentServiceProvider) Register(app foundation.Application) { + // Only bind into container here. Never call facades.* here. + app.Singleton("payment", func(app foundation.Application) (any, error) { + return NewPaymentService(app.MakeConfig()), nil + }) +} + +func (r *PaymentServiceProvider) Boot(app foundation.Application) { + // All providers registered. Facades are safe here. + facades.Route().Get("/payment/health", paymentController.Health) +} + +// Optional: declare dependency ordering +func (r *PaymentServiceProvider) Relationship() binding.Relationship { + return binding.Relationship{ + Bindings: []string{"payment"}, + Dependencies: []string{binding.Config, binding.Route}, + } +} + +// app/facades/payment.go +package facades + +import "yourmodule/app/contracts" + +func Payment() contracts.PaymentService { + instance, err := App().Make("payment") + if err != nil { + panic(err) + } + return instance.(contracts.PaymentService) +} + +// Custom runner +type QueueRunner struct{} + +func (r *QueueRunner) ShouldRun() bool { return true } +func (r *QueueRunner) Run() error { /* start worker */ ; return nil } +func (r *QueueRunner) Shutdown() error { /* graceful stop */ ; return nil } + +// bootstrap/app.go -- full example +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithProviders(Providers). + WithConfig(config.Boot). + WithRouting(func() { routes.Web() }). + WithCallback(func() { + facades.Gate().Define("update-post", policies.NewPostPolicy().Update) + facades.RateLimiter().For("api", func(ctx contractshttp.Context) contractshttp.Limit { + return httplimit.PerMinute(60).By(ctx.Request().Ip()) + }) + facades.Orm().Observe(models.User{}, &observers.UserObserver{}) + }). + WithRunners(func() []contractsfoundation.Runner { + return []contractsfoundation.Runner{&QueueRunner{}} + }). + Create() +} +``` + +## Rules + +- `Register` binds things into the container only. Never call `facades.*` inside `Register` -- bindings from other providers may not exist yet. +- `Boot` runs after all `Register` calls complete. Facades are safe here. +- `WithCallback` runs after all `Boot` calls. Use it for gates, observers, rate limiters, Schema extensions. +- Providers without `Relationship` run after those that declare it; ordering between them is not guaranteed. +- Auto-generated artifacts (`make:command`, `make:job`, `make:rule`, etc.) self-register in `bootstrap/` files. +- `Runner.ShouldRun()` returning false skips that runner entirely. +- `facades.App().Restart()` is used in Docker tests after reconfiguring the database connection. diff --git a/.ai/knowledge/cache.md b/.ai/knowledge/cache.md new file mode 100644 index 000000000..ebfc0f7c8 --- /dev/null +++ b/.ai/knowledge/cache.md @@ -0,0 +1,112 @@ +# Cache Facade + +## Core Imports + +```go +import ( + "context" + "time" + "github.com/goravel/framework/contracts/cache" + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/cache/cache.go` + +## Available Methods + +**facades.Cache():** + +- `Store(name)` Driver - switch to named store +- `WithContext(ctx)` Driver - inject context +- `Get(key, default?)` any +- `GetBool/GetInt/GetInt64/GetString(key, default?)` T +- `Has(key)` bool +- `Put(key, value, ttl)` error - `ttl=0` stores forever +- `Add(key, value, ttl)` bool - stores only if absent; returns false if key exists +- `Forever(key, value)` bool +- `Remember(key, ttl, func() (any, error))` (any, error) +- `RememberForever(key, func() (any, error))` (any, error) +- `Pull(key, default?)` any - retrieve + delete atomically +- `Increment(key, amount?)` (int64, error) +- `Decrement(key, amount?)` (int64, error) +- `Forget(key)` bool +- `Flush()` bool +- `Lock(key, ttl?)` Lock + +**Lock:** + +- `Get(callback?)` bool - acquire immediately; auto-releases after callback +- `Block(duration, callback?)` bool - wait up to duration; auto-releases after callback +- `BlockWithTicker(duration, ticker, callback?)` bool - poll every `ticker` interval +- `Release()` bool - release (ownership-aware) +- `ForceRelease()` bool - release regardless of owner + +## Implementation Example + +```go +package services + +import ( + "fmt" + "time" + "yourmodule/app/facades" + "yourmodule/app/models" +) + +type UserService struct{} + +func (s *UserService) GetUser(id int) (*models.User, error) { + key := fmt.Sprintf("user:%d", id) + + raw, err := facades.Cache().Remember(key, 5*time.Minute, func() (any, error) { + var user models.User + if err := facades.Orm().Query().FindOrFail(&user, id); err != nil { + return nil, err + } + return &user, nil + }) + if err != nil { + return nil, err + } + return raw.(*models.User), nil +} + +func (s *UserService) ProcessJob(jobID string) error { + lock := facades.Cache().Lock("job:"+jobID, 30*time.Second) + + if !lock.Block(5*time.Second, func() { + facades.Cache().Put("job:"+jobID+":status", "processing", 30*time.Second) + }) { + return fmt.Errorf("could not acquire lock") + } + return nil +} + +// Increment/Decrement return int64 +func (s *UserService) TrackVisit(userID int) { + key := fmt.Sprintf("visits:%d", userID) + count, err := facades.Cache().Increment(key) + if err == nil { + fmt.Printf("visits: %d\n", count) // count is int64 + } +} +``` + +## Rules + +- `Increment`/`Decrement` return `(int64, error)`, not `(int, error)`. +- `Put(key, value, 0)` stores forever (equivalent to `Forever`). +- `Add` returns `false` if key already exists; it does not overwrite. +- `Remember` only executes the closure on cache miss; stores the result automatically. +- `Pull` is atomic: retrieve + delete in one operation. +- `Lock.Get(func(){})` auto-releases the lock after the closure completes. +- `Lock.Block` returns `false` if lock cannot be acquired within the timeout. +- `BlockWithTicker` polls every `ticker` interval instead of continuously retrying. +- `ForceRelease` ignores ownership - use only for cleanup. +- `OnOneServer` scheduling requires `memcached`, `dynamodb`, or `redis` as the default cache. +- Configure stores in `config/cache.go` under `stores`; set `default` to the desired store name. diff --git a/.ai/knowledge/carbon.md b/.ai/knowledge/carbon.md new file mode 100644 index 000000000..2596f1302 --- /dev/null +++ b/.ai/knowledge/carbon.md @@ -0,0 +1,205 @@ +# Carbon (Date/Time) + +## Import + +```go +import "github.com/goravel/framework/support/carbon" +``` + +## Contracts + +Support library wrapping `dromara/carbon`. No framework contract file. +All types (`Carbon`, `DateTime`, `Date`, `Timestamp`, etc.) are defined in the `support/carbon` package directly. + +Wraps `dromara/carbon`. All methods are on the `Carbon` type returned by constructors. + +## Constructors + +```go +carbon.Now() +carbon.Now(timezone ...string) +carbon.Yesterday(timezone ...string) +carbon.Tomorrow(timezone ...string) + +// Parse +carbon.Parse("2020-08-05 13:14:15") +carbon.ParseByLayout("2020-08-05 13:14:15", carbon.DateTimeLayout) +carbon.ParseByLayout("2020|08|05", []string{"2006|01|02", "2006|1|2"}) // multiple layouts +carbon.ParseByFormat("2020-08-05 13:14:15", carbon.DateTimeFormat) +carbon.ParseByFormat("2020|08|05", []string{"Y|m|d", "y|m|d"}) // multiple formats + +// From primitives +carbon.FromTimestamp(int64) +carbon.FromTimestampMilli(int64) +carbon.FromTimestampMicro(int64) +carbon.FromTimestampNano(int64) +carbon.FromStdTime(t time.Time) + +// From components +carbon.FromDateTime(year, month, day, hour, minute, second int) +carbon.FromDateTimeMilli(year, month, day, hour, minute, second, millisecond int) +carbon.FromDateTimeMicro(year, month, day, hour, minute, second, microsecond int) +carbon.FromDateTimeNano(year, month, day, hour, minute, second, nanosecond int) +carbon.FromDate(year, month, day int) +carbon.FromDateMilli(year, month, day, millisecond int) +carbon.FromDateMicro(year, month, day, microsecond int) +carbon.FromDateNano(year, month, day, nanosecond int) +carbon.FromTime(hour, minute, second int) +carbon.FromTimeMilli(hour, minute, second, millisecond int) +carbon.FromTimeMicro(hour, minute, second, microsecond int) +carbon.FromTimeNano(hour, minute, second, nanosecond int) +``` + +## Built-in Layout / Format Constants + +```go +carbon.DateTimeLayout // "2006-01-02 15:04:05" +carbon.DateLayout // "2006-01-02" +carbon.TimeLayout // "15:04:05" +carbon.DateTimeFormat // "Y-m-d H:i:s" +carbon.DateFormat // "Y-m-d" +carbon.TimeFormat // "H:i:s" +``` + +## Global Config + +```go +carbon.SetTimezone(carbon.UTC) // or "Asia/Shanghai" etc. +carbon.SetLocale("en") // affects DiffForHumans, weekday names +carbon.SetWeekStartsAt(carbon.Monday) +``` + +## Arithmetic + +```go +c.AddYears(n int) Carbon +c.AddYear() Carbon +c.SubYears(n int) Carbon +c.AddMonths(n int) Carbon +c.AddWeeks(n int) Carbon +c.AddDays(n int) Carbon +c.AddDay() Carbon +c.SubDays(n int) Carbon +c.AddHours(n int) Carbon +c.AddMinutes(n int) Carbon +c.AddSeconds(n int) Carbon +c.SubSeconds(n int) Carbon +``` + +## Start / End Of Period + +```go +c.StartOfDay() Carbon +c.EndOfDay() Carbon +c.StartOfWeek() Carbon +c.EndOfWeek() Carbon +c.StartOfMonth() Carbon +c.EndOfMonth() Carbon +c.StartOfYear() Carbon +c.EndOfYear() Carbon +``` + +## Comparison + +```go +c.Gt(other Carbon) bool // greater than +c.Lt(other Carbon) bool // less than +c.Eq(other Carbon) bool // equal +c.Gte(other Carbon) bool +c.Lte(other Carbon) bool +c.Between(min, max Carbon) bool +c.IsPast() bool +c.IsFuture() bool +c.IsToday() bool +c.IsYesterday() bool +c.IsTomorrow() bool +c.IsWeekday() bool +c.IsWeekend() bool +``` + +## Output + +```go +c.String() string // "2020-08-05 13:14:15" +c.ToDateTimeString() string +c.ToDateString() string +c.ToTimeString() string +c.ToTimestamp() int64 +c.ToTimestampMilli() int64 +c.ToTimestampMicro() int64 +c.ToTimestampNano() int64 +c.ToStdTime() time.Time +c.DiffForHumans() string // "2 hours ago" +c.Format(layout string) string // carbon format: "Y-m-d" +c.Layout(layout string) string // Go layout: "2006-01-02" +``` + +## Getters + +```go +c.Year() int +c.Month() int +c.Day() int +c.Hour() int +c.Minute() int +c.Second() int +c.DayOfWeek() int // 0=Sunday +c.DayOfYear() int +c.WeekOfYear() int +c.DaysInMonth() int +c.Timezone() string +``` + +## Validity + +```go +c.IsZero() bool // true when carbon holds the zero value (invalid/unparseable input) +c.IsValid() bool // true when carbon holds a non-zero value +``` + +## ORM Model Fields + +`orm.Model` embeds `CreatedAt` and `UpdatedAt` as `carbon.DateTime`, not `time.Time`. +Use `carbon.DateTime` (or `carbon.Date`, `carbon.Timestamp` etc.) for model date fields. + +```go +import ( + "github.com/goravel/framework/database/orm" + "github.com/goravel/framework/support/carbon" +) + +type User struct { + orm.Model // CreatedAt, UpdatedAt are carbon.DateTime + Name string + Birthday carbon.Date // date only, no time component + LastLogin carbon.DateTime // full datetime + DeletedAt carbon.DateTime `gorm:"column:deleted_at"` +} + +// Reading a value +user.CreatedAt.ToDateTimeString() // "2024-01-15 10:30:00" +user.CreatedAt.ToStdTime() // time.Time + +// Writing a value +user.Birthday = carbon.NewDate(1990, 6, 15) +user.LastLogin = carbon.NewDateTime(2024, 1, 15, 10, 30, 0) +``` + +## Test Time Control + +```go +carbon.SetTestNow(carbon.Now()) // freeze time for tests +carbon.CleanTestNow() // unfreeze (call in test teardown) +carbon.IsTestNow() bool +``` + +## Rules + +- `carbon.SetTimezone` and `carbon.SetLocale` are global and affect all subsequent calls. +- In tests, always call `carbon.CleanTestNow()` in teardown to avoid polluting other tests. +- `ParseByLayout` accepts either a single `string` layout or `[]string` (tries each in order). +- `ParseByFormat` also accepts either a single `string` format or `[]string`. +- `Format` uses carbon/PHP format strings (`Y`, `m`, `d`); `Layout` uses Go time format strings (`2006`, `01`, `02`). +- Invalid parse returns a zero `Carbon` -- check `c.IsZero()` before use. +- `orm.Model` fields `CreatedAt` and `UpdatedAt` are `carbon.DateTime`, NOT `time.Time`. Using `time.Time` causes scan errors. +- For model date fields use the typed carbon wrappers: `carbon.Date`, `carbon.DateTime`, `carbon.Timestamp`, `carbon.TimestampMilli`, `carbon.TimestampMicro`, `carbon.TimestampNano`. diff --git a/.ai/knowledge/event.md b/.ai/knowledge/event.md new file mode 100644 index 000000000..8d44b6b2d --- /dev/null +++ b/.ai/knowledge/event.md @@ -0,0 +1,124 @@ +# Event Facade + +## Core Imports + +```go +import ( + "github.com/goravel/framework/contracts/event" + + "yourmodule/app/facades" + "yourmodule/app/events" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/event/events.go` + +## Available Methods + +**facades.Event():** + +- `Job(event Event, args []Arg)` Task - create dispatchable event task +- `Job(...).Dispatch()` error - fire event synchronously or via queue + +**Registration (bootstrap/app.go):** + +- `WithEvents(func() map[event.Event][]event.Listener)` - map events to listeners + +## Implementation Example + +```go +// app/events/order_shipped.go +package events + +import "github.com/goravel/framework/contracts/event" + +type OrderShipped struct{} + +func NewOrderShipped() *OrderShipped { return &OrderShipped{} } + +func (r *OrderShipped) Handle(args []event.Arg) ([]event.Arg, error) { + // Transform or enrich args before passing to listeners + return args, nil +} + +// app/listeners/send_shipment_notification.go +package listeners + +import "github.com/goravel/framework/contracts/event" + +type SendShipmentNotification struct{} + +func NewSendShipmentNotification() *SendShipmentNotification { + return &SendShipmentNotification{} +} + +func (r *SendShipmentNotification) Signature() string { + return "send_shipment_notification" +} + +func (r *SendShipmentNotification) Queue(args ...any) event.Queue { + return event.Queue{ + Enable: true, // true = async via queue + Connection: "redis", + Queue: "notifications", + } +} + +func (r *SendShipmentNotification) Handle(args ...any) error { + orderID := args[0].(int) + // send notification logic + _ = orderID + return nil +} + +// bootstrap/app.go - registration +// WithEvents(func() map[event.Event][]event.Listener { +// return map[event.Event][]event.Listener{ +// events.NewOrderShipped(): { +// listeners.NewSendShipmentNotification(), +// }, +// } +// }) + +// controllers/order_controller.go - dispatching +package controllers + +import ( + "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/contracts/event" + + "yourmodule/app/facades" + "yourmodule/app/events" +) + +type OrderController struct{} + +func (r *OrderController) Ship(ctx http.Context) http.Response { + orderID := ctx.Request().RouteInt("id") + + err := facades.Event().Job(&events.OrderShipped{}, []event.Arg{ + {Type: "int", Value: orderID}, + {Type: "string", Value: "express"}, + }).Dispatch() + if err != nil { + return ctx.Response().Json(http.StatusInternalServerError, http.Json{"error": err.Error()}) + } + return ctx.Response().Json(http.StatusOK, http.Json{"shipped": true}) +} +``` + +## Rules + +- Events and listeners are registered via `WithEvents` in `bootstrap/app.go` - `make:event`/`make:listener` auto-register. +- `Listener.Signature()` must be unique across all listeners. +- `Event.Handle(args)` receives the dispatched args, can modify them, and returns the (potentially modified) args to listeners. +- `Listener.Handle(args ...any)` receives the output of `Event.Handle` as variadic `any`. +- To stop propagation to subsequent listeners, return an `error` from `Listener.Handle`. +- `Listener.Queue(args).Enable = true` makes the listener asynchronous - requires queue to be configured. +- Queued listeners dispatched within a DB transaction may run before the transaction commits - place the dispatch outside the transaction when the listener depends on newly committed data. +- `event.Arg.Type` must be one of the supported primitive types (same as `queue.Arg`). +- `make:event` creates in `app/events/`; `make:listener` creates in `app/listeners/`. diff --git a/.ai/knowledge/grpc.md b/.ai/knowledge/grpc.md new file mode 100644 index 000000000..facb1a585 --- /dev/null +++ b/.ai/knowledge/grpc.md @@ -0,0 +1,145 @@ +# gRPC Facade + +## Core Imports + +```go +import ( + "context" + "net/http" + "google.golang.org/grpc" + "google.golang.org/grpc/stats" + contractshttp "github.com/goravel/framework/contracts/http" + + proto "github.com/goravel/example-proto" // your generated proto package + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/grpc/grpc.go` + +## Available Methods + +**facades.Grpc():** + +- `Server()` \*grpc.Server - get the gRPC server instance (use to register services in routes) +- `Connect(name)` (\*grpc.ClientConn, error) - get client connection by server name from config + +**bootstrap/app.go registration:** + +- `WithGrpcServerInterceptors(func() []grpc.UnaryServerInterceptor)` +- `WithGrpcClientInterceptors(func() map[string][]grpc.UnaryClientInterceptor)` - key = interceptor group name +- `WithGrpcServerStatsHandlers(func() []stats.Handler)` +- `WithGrpcClientStatsHandlers(func() map[string][]stats.Handler)` - key = server name from config + +## Implementation Example + +```go +// config/grpc.go +config.Add("grpc", map[string]any{ + "host": config.Env("GRPC_HOST", ""), + "port": config.Env("GRPC_PORT", "9000"), + "servers": map[string]any{ // BREAKING v1.17: was "clients" + "user": map[string]any{ + "host": config.Env("GRPC_USER_HOST", "127.0.0.1"), + "port": config.Env("GRPC_USER_PORT", "9001"), + "interceptors": []string{"default"}, // client interceptor group + "stats_handlers": []string{"user"}, // client stats handler group + }, + }, +}) + +// routes/grpc.go - register gRPC service +package routes + +import ( + proto "github.com/goravel/example-proto" + "yourmodule/app/facades" + grpccontrollers "yourmodule/app/grpc/controllers" +) + +func Grpc() { + proto.RegisterUserServiceServer( + facades.Grpc().Server(), + grpccontrollers.NewUserController(), + ) +} + +// app/grpc/controllers/user_controller.go +package controllers + +import ( + "context" + "net/http" + proto "github.com/goravel/example-proto" +) + +type UserController struct{} + +func NewUserController() *UserController { return &UserController{} } + +func (r *UserController) GetUser(ctx context.Context, req *proto.UserRequest) (*proto.UserResponse, error) { + return &proto.UserResponse{ + Code: http.StatusOK, + Data: &proto.User{Id: 1, Name: "Goravel", Token: req.GetToken()}, + }, nil +} + +// HTTP controller calling gRPC +// app/http/controllers/grpc_controller.go +package controllers + +import ( + "fmt" + proto "github.com/goravel/example-proto" + contractshttp "github.com/goravel/framework/contracts/http" + "yourmodule/app/facades" +) + +type GrpcController struct { + userService proto.UserServiceClient +} + +func NewGrpcController() *GrpcController { + conn, err := facades.Grpc().Connect("user") // BREAKING: was Client() + if err != nil { + facades.Log().Errorf("grpc connect failed: %+v", err) + } + return &GrpcController{userService: proto.NewUserServiceClient(conn)} +} + +func (r *GrpcController) GetUser(ctx contractshttp.Context) contractshttp.Response { + resp, err := r.userService.GetUser(ctx.Context(), &proto.UserRequest{ + Token: ctx.Request().Input("token"), + }) + if err != nil { + return ctx.Response().String(contractshttp.StatusInternalServerError, fmt.Sprintf("%+v", err)) + } + return ctx.Response().Success().Json(resp.GetData()) +} + +// Interceptors - bootstrap/app.go +// WithGrpcServerInterceptors(func() []grpc.UnaryServerInterceptor { +// return []grpc.UnaryServerInterceptor{interceptors.AuthServer} +// }). +// WithGrpcClientInterceptors(func() map[string][]grpc.UnaryClientInterceptor { +// return map[string][]grpc.UnaryClientInterceptor{ +// "default": {interceptors.LogClient}, // "default" matches config interceptors array +// } +// }) +``` + +## Rules + +- `facades.Grpc().Client("name")` is **deprecated** - use `facades.Grpc().Connect("name")`. +- Config key is `grpc.servers` - **not** `grpc.clients` (renamed in v1.17). +- gRPC controllers use `context.Context` (stdlib), not `http.Context` (Goravel). +- Register gRPC services in `routes/grpc.go` → called from `WithRouting` in `bootstrap/app.go`. +- Client interceptor map key matches the `interceptors` array value in `config/grpc.go` servers config. +- Stats handler map key for clients matches the `stats_handlers` array value in config. +- `Connect` returns `*grpc.ClientConn` - wrap with `proto.NewXxxServiceClient(conn)` to get typed client. +- Instantiate gRPC clients in the controller constructor (once), not per-request. +- Server interceptors apply to all incoming gRPC calls; client interceptors apply per-connection group. diff --git a/.ai/knowledge/hash-crypt.md b/.ai/knowledge/hash-crypt.md new file mode 100644 index 000000000..b46e95828 --- /dev/null +++ b/.ai/knowledge/hash-crypt.md @@ -0,0 +1,118 @@ +# Hash & Crypt Facades + +## Core Imports + +```go +import ( + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/hash/hash.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/crypt/crypt.go` + +## Available Methods + +**facades.Hash():** + +- `Make(value string)` (string, error) - hash a plain-text password (Argon2id or Bcrypt) +- `Check(value, hashedValue string)` bool - verify plain-text against hash +- `NeedsRehash(hashedValue string)` bool - true if hash algorithm/cost has changed + +**facades.Crypt():** + +- `EncryptString(value string)` (string, error) - AES-256-GCM encrypt with GMAC signature +- `DecryptString(value string)` (string, error) - decrypt; returns error if GMAC invalid + +## Implementation Example + +```go +package controllers + +import ( + "github.com/goravel/framework/contracts/http" + "yourmodule/app/facades" + "yourmodule/app/models" +) + +type AuthController struct{} + +// Register - hash password before storing +func (r *AuthController) Register(ctx http.Context) http.Response { + plain := ctx.Request().Input("password") + + hashed, err := facades.Hash().Make(plain) + if err != nil { + return ctx.Response().Json(http.StatusInternalServerError, http.Json{"error": err.Error()}) + } + + user := models.User{ + Email: ctx.Request().Input("email"), + Password: hashed, + } + if err := facades.Orm().Query().Create(&user); err != nil { + return ctx.Response().Json(http.StatusInternalServerError, http.Json{"error": err.Error()}) + } + return ctx.Response().Json(http.StatusCreated, http.Json{"id": user.ID}) +} + +// Login - verify password +func (r *AuthController) Login(ctx http.Context) http.Response { + var user models.User + facades.Orm().Query().Where("email", ctx.Request().Input("email")).First(&user) + + if !facades.Hash().Check(ctx.Request().Input("password"), user.Password) { + return ctx.Response().Json(http.StatusUnauthorized, http.Json{"error": "invalid credentials"}) + } + + // Rehash if needed (e.g., after changing algorithm) + if facades.Hash().NeedsRehash(user.Password) { + newHash, _ := facades.Hash().Make(ctx.Request().Input("password")) + facades.Orm().Query().Model(&user).Update("password", newHash) + } + + token, _ := facades.Auth(ctx).Login(&user) + return ctx.Response().Json(http.StatusOK, http.Json{"token": token}) +} + +// Encrypt/Decrypt sensitive data +func (r *AuthController) StoreSecret(ctx http.Context) http.Response { + secret := ctx.Request().Input("secret") + + encrypted, err := facades.Crypt().EncryptString(secret) + if err != nil { + return ctx.Response().Json(http.StatusInternalServerError, http.Json{"error": err.Error()}) + } + + // Store `encrypted` in DB... + + // Later, decrypt: + plain, err := facades.Crypt().DecryptString(encrypted) + if err != nil { + return ctx.Response().Json(http.StatusBadRequest, http.Json{"error": "tampered or invalid data"}) + } + + return ctx.Response().Json(http.StatusOK, http.Json{"secret": plain}) +} +``` + +## Rules + +**Hash:** + +- Default hashing algorithm is Argon2id; Bcrypt also supported - configure in `config/hashing.go`. +- `Check` is timing-safe - always use it instead of direct string comparison. +- `NeedsRehash` should be checked on login and password re-hashed if `true`. +- Never store plain-text passwords; always hash before persisting. + +**Crypt:** + +- Requires `APP_KEY` to be set in `.env`; generate with `./artisan key:generate`. +- Encryption uses AES-256-GCM with GMAC authentication - data integrity is verified on decrypt. +- `DecryptString` returns an error if data has been tampered with or the key is wrong. +- Encrypted values are base64-encoded strings safe for storage in database columns. +- Do **not** use Crypt for passwords - use Hash instead. diff --git a/.ai/knowledge/helpers.md b/.ai/knowledge/helpers.md new file mode 100644 index 000000000..179be2331 --- /dev/null +++ b/.ai/knowledge/helpers.md @@ -0,0 +1,121 @@ +# Helpers (path / maps / collect / convert / debug / color) + +For fluent strings see `str.md`. For date/time see `carbon.md`. + +## Contracts + +Support utilities. No framework contract files. +All functions are in their respective `support/*` packages. + +## Imports + +```go +import ( + "github.com/goravel/framework/support/path" + "github.com/goravel/framework/support/maps" + "github.com/goravel/framework/support/collect" + "github.com/goravel/framework/support/convert" + "github.com/goravel/framework/support/debug" + "github.com/goravel/framework/support/color" + supphttp "github.com/goravel/framework/support/http" +) +``` + +## path -- Absolute paths (respect WithPaths config) + +```go +path.App(rel ...string) // abs path to app/ +path.Base(rel ...string) // project root +path.Config(rel ...string) // config/ +path.Database(rel ...string) // database/ +path.Storage(rel ...string) // storage/ +path.Public(rel ...string) // public/ +path.Lang(rel ...string) // lang/ +path.Resource(rel ...string) // resources/ +``` + +## maps -- map[string]any operations + +```go +maps.Add(m, key, value) // add only if key absent +maps.Exists(m, key) bool +maps.Forget(m, keys ...string) // remove one or more keys +maps.Get(m, key, defaultValue) any +maps.Has(m, keys ...string) bool // true only if ALL keys present +maps.HasAny(m, keys ...string) bool // true if ANY key present +maps.Only(m, keys ...string) map[string]any // return subset +maps.Pull(m, key, defaultValue ...any) any // get + remove +maps.Set(m, key, value) +maps.Where(m, func(k string, v any) bool) map[string]any +``` + +## collect -- generic slice/map helpers + +```go +collect.Count(slice) int +collect.CountBy(slice, func(v T) bool) int +collect.Each(slice, func(v T, i int)) +collect.Filter(slice, func(v T) bool) []T +collect.GroupBy(slice, func(v T) string) map[string][]T +collect.Keys(m map[K]V) []K +collect.Map(slice, func(v T, i int) U) []U +collect.Max(slice) T +collect.Min(slice) T +collect.Merge(m1, m2 map[K]V) map[K]V // m2 wins on conflict +collect.Reverse(slice) []T +collect.Shuffle(slice) []T +collect.Split(slice, size int) [][]T +collect.Sum(slice) T +collect.Unique(slice) []T // first occurrence kept +collect.Values(m map[K]V) []V +``` + +## convert -- value utilities + +```go +convert.Tap(val T, fn func(T)) T // pass to fn, return original val +convert.Transform(val T, fn func(T) U) U // convert type +convert.With(val T, fn func(T) U) U // run fn, return result +convert.Default(val T, fallback T) T // first non-zero value +convert.Pointer(val T) *T // wrap in pointer +``` + +## debug -- variable dump + +```go +debug.Dump(vars ...any) // print to stdout +debug.SDump(vars ...any) string // return as string +debug.FDump(w io.Writer, vars ...any) // write to writer +``` + +## color -- terminal output + +```go +// Constructors: Red, Green, Yellow, Blue, Magenta, Cyan, White, Black, Gray, Default +color.Red().Println("msg") +color.Green().Printf("ok: %s", val) +color.Blue().Sprint("info") // returns colored string without newline +color.New(color.FgRed).Println("custom") + +// Methods on each color: Print / Println / Printf / Sprint / Sprintln / Sprintf +``` + +## supphttp.NewBody -- request body builder + +Used for `facades.Http()` POST bodies and test HTTP requests. + +```go +body := supphttp.NewBody().SetField("name", "Alice").SetField("role", "admin") +built, err := body.Build() +built.ContentType() string // set as Content-Type header +built.Reader() io.Reader // pass as request body +``` + +## Rules + +- `path.*` returns absolute paths; always correct relative to `WithPaths` config -- never construct paths manually with `filepath.Join`. +- `maps.*` mutates the map in-place for `Add`, `Set`, `Forget`, `Pull`. +- `collect.Unique` keeps the first occurrence of duplicates, not the last. +- `collect.Merge` is shallow -- nested maps are not deep-merged. +- `convert.Default` returns the first argument if it is non-zero, otherwise the second. +- `color` output is suppressed in non-TTY environments automatically. diff --git a/.ai/knowledge/http-client.md b/.ai/knowledge/http-client.md new file mode 100644 index 000000000..b56535150 --- /dev/null +++ b/.ai/knowledge/http-client.md @@ -0,0 +1,137 @@ +# HTTP Client Facade + +## Core Imports + +```go +import ( + "context" + "io" + "net/http" + contractsclient "github.com/goravel/framework/contracts/http/client" + supphttp "github.com/goravel/framework/support/http" + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/client/request.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/client/response.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/client/factory.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/client/fake_response.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/client/fake_sequence.go` + +## Available Methods + +**facades.Http():** + +- `Get/Head/Options(uri)` (Response, error) +- `Post/Put/Patch/Delete(uri, body io.Reader)` (Response, error) +- `WithHeader/WithHeaders/ReplaceHeaders/WithoutHeader/FlushHeaders(...)` +- `Accept(contentType)` / `AcceptJSON()` / `AsForm()` +- `WithToken(token, type?)` - default "Bearer" +- `WithBasicAuth(user, pass)` +- `WithQueryParameter/WithQueryParameters/WithQueryString` +- `WithUrlParameter/WithUrlParameters` +- `WithCookie/*Cookies` +- `WithContext(ctx)` Request +- `Client(name?)` Request - named client from config +- `Fake(map[string]any)` Factory +- `Response()` FakeResponse +- `Sequence()` FakeSequence +- `Reset()` - clear all fakes +- `AssertSent/AssertNotSent/AssertNothingSent/AssertSentCount` + +## Implementation Example + +```go +package services + +import ( + "context" + "fmt" + "time" + supphttp "github.com/goravel/framework/support/http" + "yourmodule/app/facades" +) + +type GithubService struct{} + +type GithubUser struct { + ID int `json:"id"` + Login string `json:"login"` +} + +// GET request +func (s *GithubService) GetUser(username string) (*GithubUser, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + resp, err := facades.Http(). + WithContext(ctx). + AcceptJSON(). + WithToken("ghp_xxx"). + WithUrlParameter("username", username). + Get("https://api.github.com/users/{username}") + if err != nil { + return nil, err + } + if !resp.Successful() { + return nil, fmt.Errorf("request failed: %d", resp.Status()) + } + var user GithubUser + return &user, resp.Bind(&user) +} + +// POST with body +func (s *GithubService) CreateIssue(repo string, title string) error { + body := supphttp.NewBody(). + SetField("title", title). + SetField("body", "Created via API") + built, err := body.Build() + if err != nil { + return err + } + resp, err := facades.Http(). + WithToken("ghp_xxx"). + WithHeader("Content-Type", built.ContentType()). + Post("https://api.github.com/repos/"+repo+"/issues", built.Reader()) + if err != nil { + return err + } + if !resp.Created() { + return fmt.Errorf("failed to create issue: %d", resp.Status()) + } + return nil +} + +// Testing with fakes +// func TestGetUser(t *testing.T) { +// defer facades.Http().Reset() +// facades.Http().Fake(map[string]any{ +// "https://api.github.com/*": facades.Http().Response().Json(200, map[string]any{ +// "id": 1, "login": "goravel", +// }), +// }) +// user, err := (&GithubService{}).GetUser("goravel") +// assert.Nil(t, err) +// assert.Equal(t, "goravel", user.Login) +// facades.Http().AssertSentCount(1) +// } +``` + +## Rules + +- `Post/Put/Patch/Delete` all take `io.Reader` as body - use `supphttp.NewBody().Build()` or pass `nil`. +- `Response.Bind(&dest)` on the response object - **not** `Request.Bind`. +- `Response.Stream()` returns `(io.ReadCloser, error)` for streaming bodies. +- `facades.Http().Reset()` must be deferred in every test using `Fake`; fakes are global state. +- Never use `t.Parallel()` with `Fake` - it mutates global state, causing race conditions. +- `"*"` as fake key matches all unmatched URLs. +- `PreventStrayRequests()` panics if a request is made without a matching fake. +- `Sequence` exhaustion returns error unless `WhenEmpty` is set. +- `WithToken("tok")` defaults to `"Bearer"` scheme; pass second arg for custom scheme. +- `WithUrlParameter("key", "val")` replaces `{key}` in the URL path. +- HTTP client configs (timeouts, base URLs) are in `config/http.go` under `clients`. diff --git a/.ai/knowledge/localization.md b/.ai/knowledge/localization.md new file mode 100644 index 000000000..6735fa73f --- /dev/null +++ b/.ai/knowledge/localization.md @@ -0,0 +1,120 @@ +# Localization Facade + +## Core Imports + +```go +import ( + "github.com/goravel/framework/translation" + "github.com/goravel/framework/contracts/http" + + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/translation/translator.go` + +## Available Methods + +**facades.Lang(ctx):** - takes `http.Context` + +- `Get(key string, opts ...translation.Option)` string - translate key +- `Choice(key string, count int, opts ...translation.Option)` string - pluralized translation + +**facades.App():** + +- `SetLocale(ctx http.Context, locale string)` - change locale for request +- `CurrentLocale(ctx http.Context)` string - get current locale +- `IsLocale(ctx http.Context, locale string)` bool + +## Implementation Example + +```go +// lang/en.json +// { +// "welcome": "Welcome, :name!", +// "messages": { +// "saved": "Record saved" +// }, +// "apples": "There is one apple|There are many apples", +// "items": "{0} None|[1,19] Some|[20,*] Many" +// } + +// lang/en/user.json (categorized) +// { "profile": "Your profile" } + +package controllers + +import ( + "github.com/goravel/framework/translation" + "github.com/goravel/framework/contracts/http" + "yourmodule/app/facades" +) + +type LangController struct{} + +func (r *LangController) Index(ctx http.Context) http.Response { + // Simple key + msg := facades.Lang(ctx).Get("messages.saved") + + // With placeholder replacement (:name → "Alice") + welcome := facades.Lang(ctx).Get("welcome", translation.Option{ + Replace: map[string]string{"name": "Alice"}, + }) + + // Categorized file: "user/profile" = lang/en/user.json → "profile" key + profile := facades.Lang(ctx).Get("user/profile") + + // Pluralization + one := facades.Lang(ctx).Choice("apples", 1) // "There is one apple" + many := facades.Lang(ctx).Choice("apples", 5) // "There are many apples" + none := facades.Lang(ctx).Choice("items", 0) // "None" + + // Switch locale per-request + facades.App().SetLocale(ctx, "zh_CN") + translated := facades.Lang(ctx).Get("welcome", translation.Option{ + Replace: map[string]string{"name": "Alice"}, + }) + + return ctx.Response().Json(http.StatusOK, http.Json{ + "msg": msg, + "welcome": welcome, + "profile": profile, + "one": one, + "many": many, + "none": none, + "translated": translated, + }) +} +``` + +### Embed lang files into binary (optional) + +```go +// lang/fs.go +package lang + +import "embed" + +//go:embed * +var FS embed.FS + +// config/app.go +"lang_path": "lang", +"lang_fs": lang.FS, +``` + +## Rules + +- `facades.Lang(ctx)` takes `http.Context`, not `context.Context`. +- Default and fallback locale set in `config/app.go` under `locale` and `fallback_locale`. +- Lang files: flat JSON (`lang/en.json`) or categorized (`lang/en/user.json`). +- Categorized key format: `"category/key"` or `"category/nested.key"`. +- Placeholders use `:name` syntax in JSON values - replaced via `Option.Replace`. +- `Choice` pluralization formats: `"one|many"` (count 1 = first, else second); `"{0} x|[1,5] y|[6,*] z"` (range-based). +- When both file and embed exist for the same path, the file takes priority; embed is the fallback. +- `SetLocale` changes locale for the current request context only - does not affect other requests. +- Missing key returns the key string itself as fallback. diff --git a/.ai/knowledge/log.md b/.ai/knowledge/log.md new file mode 100644 index 000000000..275f9d078 --- /dev/null +++ b/.ai/knowledge/log.md @@ -0,0 +1,92 @@ +# Log Facade + +## Core Imports + +```go +import ( + contractslog "github.com/goravel/framework/contracts/log" + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/log/log.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/log/level.go` + +## Available Methods + +- `WithContext(ctx)` Log +- `Channel(name)` Log +- `Stack([]string)` Log +- `Debug/Info/Warning/Error/Fatal/Panic(args ...any)` +- `Debugf/Infof/Warningf/Errorf/Fatalf/Panicf(format, args...)` +- `Code(string)` Writer +- `Hint(string)` Writer +- `In(domain string)` Writer +- `Owner(any)` Writer +- `Request(http.ContextRequest)` Writer +- `Response(http.ContextResponse)` Writer +- `Tags(tags ...string)` Writer +- `User(any)` Writer +- `With(map[string]any)` Writer - pass a map, NOT key/value pair +- `WithTrace()` Writer + +## Implementation Example + +```go +package services + +import "yourmodule/app/facades" + +func Process(userID int, orderID int) error { + // Simple + facades.Log().Info("processing order") + facades.Log().Infof("order %d for user %d", orderID, userID) + + // Rich context - With takes a MAP + facades.Log(). + User(userID). + With(map[string]any{"order_id": orderID, "amount": 99.99}). + Tags("orders", "billing"). + In("billing"). + Code("ORD-001"). + Hint("check payment gateway if this fails"). + Info("payment initiated") + + // With stack trace on error + if err := doWork(); err != nil { + facades.Log(). + WithTrace(). + User(userID). + With(map[string]any{"order_id": orderID}). + Error(err) + return err + } + + // Specific channel + facades.Log().Channel("slack").Infof("order %d completed", orderID) + + // Multiple channels simultaneously + facades.Log().Stack([]string{"daily", "slack"}).Error("critical failure") + + return nil +} + +func doWork() error { return nil } +``` + +## Rules + +- `With(data map[string]any)` takes a **map**, not `(key, value)` pair arguments. +- `Request(req)` takes `http.ContextRequest` (from `ctx.Request()`), not `*http.Request`. +- `Response(res)` takes `http.ContextResponse` (from `ctx.Response()`), not `*http.Response`. +- `Fatal` calls `os.Exit(1)` after logging - do not use in goroutines where cleanup is needed. +- `Panic` panics after logging - will trigger any registered `recover` middleware. +- Chain methods are order-independent; must precede the terminal level call. +- Custom driver: implement `Logger` interface (`Handle(channel) (Handler, error)`), not the deprecated `Hook`. +- `Handler` has `Enabled(Level) bool` and `Handle(Entry) error`. +- Use `log.HookToHandler(hook)` adapter only if adapting a legacy `Hook` implementation. +- Default channel is `stack`; configure in `config/logging.go`. diff --git a/.ai/knowledge/mail.md b/.ai/knowledge/mail.md new file mode 100644 index 000000000..efe120304 --- /dev/null +++ b/.ai/knowledge/mail.md @@ -0,0 +1,127 @@ +# Mail Facade + +## Core Imports + +```go +import ( + "github.com/goravel/framework/mail" + contractsmail "github.com/goravel/framework/contracts/mail" + + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/mail/mail.go` + +## Available Methods + +**Fluent builder (facades.Mail()):** + +- `To([]string)` Mail - set recipients +- `Cc([]string)` Mail - set CC recipients +- `Bcc([]string)` Mail - set BCC recipients +- `From(mail.Address(addr, name))` Mail - override sender +- `Subject(string)` Mail - set subject +- `Content(mail.Content{...})` Mail - set HTML body or template +- `Attach([]string)` Mail - attach files by path +- `Headers(map[string]string)` Mail - set custom headers +- `Send()` error - send immediately +- `Queue(queueConfig?)` error - push to queue (optional `mail.Queue()` config) + +**Mailable pattern:** + +- `facades.Mail().Send(mailable)` error - send from Mailable struct +- `facades.Mail().Queue(mailable)` error - queue from Mailable struct + +**mail helpers:** + +- `mail.Html(htmlString)` Content - shorthand for raw HTML content +- `mail.Address(address, name)` From - construct From struct +- `mail.Queue()` Queue builder - `.Connection(name).Queue(name)` for custom queue + +## Implementation Example + +```go +// Direct fluent send +package controllers + +import ( + "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/mail" + "yourmodule/app/facades" +) + +type OrderController struct{} + +func (r *OrderController) SendConfirmation(ctx http.Context) http.Response { + err := facades.Mail(). + To([]string{"customer@example.com"}). + Cc([]string{"support@example.com"}). + Subject("Order Confirmed"). + Content(mail.Html("

Your order #123 is confirmed!

")). + Attach([]string{"./storage/invoices/123.pdf"}). + Headers(map[string]string{"X-Order-ID": "123"}). + Send() + + if err != nil { + return ctx.Response().Json(http.StatusInternalServerError, http.Json{"error": err.Error()}) + } + return ctx.Response().Json(http.StatusOK, http.Json{"sent": true}) +} + +// Mailable struct pattern +// app/mails/order_shipped.go +package mails + +import "github.com/goravel/framework/contracts/mail" + +type OrderShipped struct { + OrderID int +} + +func NewOrderShipped(id int) *OrderShipped { return &OrderShipped{OrderID: id} } + +func (m *OrderShipped) Attachments() []string { return []string{} } + +func (m *OrderShipped) Content() *mail.Content { + return &mail.Content{ + View: "order_shipped.tmpl", + With: map[string]any{"OrderID": m.OrderID}, + } +} + +func (m *OrderShipped) Envelope() *mail.Envelope { + return &mail.Envelope{ + To: []string{"customer@example.com"}, + Subject: fmt.Sprintf("Order #%d Shipped", m.OrderID), + } +} + +func (m *OrderShipped) Headers() map[string]string { + return map[string]string{"X-Mailer": "Goravel"} +} + +func (m *OrderShipped) Queue() *mail.Queue { + return &mail.Queue{Connection: "redis", Queue: "mail"} +} + +// Usage: +// facades.Mail().Send(mails.NewOrderShipped(123)) +// facades.Mail().Queue(mails.NewOrderShipped(123)) +``` + +## Rules + +- Configure SMTP in `config/mail.go`; global sender from `MAIL_FROM_ADDRESS` / `MAIL_FROM_NAME`. +- Custom `From` address must be authorized by the configured SMTP server. +- `Content(mail.Html(str))` sends raw HTML; `Content(mail.Content{View: "file.tmpl", With: data})` uses template engine. +- Templates are loaded from the path configured in `config/mail.go` under `template.engines.html.path`. +- `Queue()` with no argument uses the default queue; pass `mail.Queue().Connection("c").Queue("q")` for custom. +- `Mailable.Queue()` returning `nil` causes `facades.Mail().Queue(mailable)` to send synchronously. +- `make:mail OrderShipped` generates scaffold in `app/mails/` directory. +- Attach paths are relative to the application working directory. +- Template engine defaults to `html/template`; custom engines implement `contracts/mail.Template`. diff --git a/.ai/knowledge/migration.md b/.ai/knowledge/migration.md new file mode 100644 index 000000000..4c0d44c84 --- /dev/null +++ b/.ai/knowledge/migration.md @@ -0,0 +1,118 @@ +# Migration & Schema Facade + +## Core Imports + +```go +import ( + "github.com/goravel/framework/contracts/database/schema" + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/schema/schema.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/schema/blueprint.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/schema/index.go` + +## Available Methods + +**facades.Schema():** + +- `Create(table, func(Blueprint))` error - create new table +- `Table(table, func(Blueprint))` error - modify existing table +- `Drop(table)` error +- `DropIfExists(table)` error +- `Rename(from, to)` error +- `HasTable(table)` bool +- `HasColumn(table, column)` bool +- `HasColumns(table, []string)` bool +- `HasIndex(table, index)` bool +- `Connection(name)` Schema - use specific DB connection +- `Extend(Extension)` - register custom Go types for `make:model --table` + +**Migration struct methods:** + +- `Signature() string` - unique identifier (timestamp_name format) +- `Up() error` - apply migration +- `Down() error` - reverse migration +- `Connection() string` - (optional) override DB connection + +## Implementation Example + +```go +// database/migrations/20240101_create_users_table.go +package migrations + +import ( + "github.com/goravel/framework/contracts/database/schema" + "yourmodule/app/facades" +) + +type M20240101000000CreateUsersTable struct{} + +func (r *M20240101000000CreateUsersTable) Signature() string { + return "20240101000000_create_users_table" +} + +func (r *M20240101000000CreateUsersTable) Up() error { + if facades.Schema().HasTable("users") { + return nil + } + return facades.Schema().Create("users", func(table schema.Blueprint) { + table.ID() + table.String("name").Comment("display name") + table.String("email").Unique() + table.String("password") + table.Boolean("active").Default(true) + table.Json("settings").Nullable() + table.Timestamps() + table.SoftDeletes() + }) +} + +func (r *M20240101000000CreateUsersTable) Down() error { + return facades.Schema().DropIfExists("users") +} + +// Adding columns to existing table +type M20240102000000AddAvatarToUsersTable struct{} + +func (r *M20240102000000AddAvatarToUsersTable) Signature() string { + return "20240102000000_add_avatar_to_users_table" +} + +func (r *M20240102000000AddAvatarToUsersTable) Up() error { + return facades.Schema().Table("users", func(table schema.Blueprint) { + table.String("avatar").Nullable().After("email") + table.UnsignedBigInteger("role_id").Nullable() + table.Foreign("role_id").References("id").On("roles") + table.Index("role_id") + }) +} + +func (r *M20240102000000AddAvatarToUsersTable) Down() error { + return facades.Schema().Table("users", func(table schema.Blueprint) { + table.DropForeign("role_id") + table.DropColumn("avatar", "role_id") + }) +} +``` + +## Rules + +- `Signature()` must be unique - conventional format: `YYYYMMDDHHMMSS_description`. +- `make:migration` auto-registers in `bootstrap/migrations.go`; manual files must be added there. +- Always guard `Up()` with `HasTable`/`HasColumn` checks to make migrations idempotent. +- `Down()` should exactly reverse `Up()` - always implement it. +- `migrate:rollback` (v1.17) rolls back the **entire last batch**, not one migration - use `--step=N` for finer control. +- `Change()` modifier (column modification) is supported on MySQL, PostgreSQL, Sqlserver only. +- `After()` (position) is **MySQL only**. `Always()`/`GeneratedAs()` are **PostgreSQL only**. +- `Enum` on PostgreSQL/SQLite/Sqlserver is stored as string - not a native ENUM type. +- `Morphs("taggable")` creates `taggable_id` (uint) + `taggable_type` (string) columns. +- Foreign key: call `.References("id").On("table")` chained on `.Foreign("col")`. +- `DropForeign("col")` drops by column name convention; `DropForeignByName("name")` by explicit constraint name. +- `facades.Schema().Connection("postgres")` overrides the connection for that schema operation. +- Register models for `make:model --table` via `facades.Schema().Extend()` in `WithCallback`. diff --git a/.ai/knowledge/orm.md b/.ai/knowledge/orm.md new file mode 100644 index 000000000..a9dd0bc3d --- /dev/null +++ b/.ai/knowledge/orm.md @@ -0,0 +1,183 @@ +# ORM Facade + +## Core Imports + +```go +import ( + "github.com/goravel/framework/database/orm" + contractsorm "github.com/goravel/framework/contracts/database/orm" + "github.com/goravel/framework/contracts/database/factory" + "github.com/goravel/framework/database/db" // for db.Raw() + "github.com/goravel/framework/errors" // for errors.OrmRecordNotFound + "github.com/goravel/framework/support/carbon" // for carbon.DateTime model fields + + "yourmodule/app/facades" + "yourmodule/database/factories" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/orm/orm.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/orm/events.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/orm/observer.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/orm/factory.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/database/factory/factory.go` + +## Available Methods + +**facades.Orm():** + +- `Query()` Query - get default DB query instance +- `Connection(name)` Orm - switch database connection +- `WithContext(ctx)` Orm - inject context +- `DB()` (\*sql.DB, error) - raw sql.DB instance +- `Transaction(func(Query) error)` error - atomic transaction +- `Observe(model, observer)` - register model observer +- `Factory()` Factory - get factory builder for the model + +**facades.Orm().Factory():** + +- `Make(&model, overrides ...map[string]any)` error - build without saving +- `Create(&model, overrides ...map[string]any)` error - build and save to DB +- `CreateQuietly(&model, overrides ...map[string]any)` error - create without model events +- `Count(n int)` Factory - produce a collection of n models +- `Times(n int)` Factory - alias for Count + +**Query (chained):** + +- `Find(&dest, id?)` error - nil error if not found (use FindOrFail for error) +- `FindOrFail(&dest, id)` error - errors if not found +- `First(&dest)` error - first record ordered by PK +- `FirstOrFail(&dest)` error - errors if not found +- `Get(&dest)` error - all matching records +- `Create(&value)` error - INSERT; auto-fills CreatedAt/UpdatedAt +- `Save(&value)` error - full UPDATE of existing model +- `Update(col, val)` error - UPDATE specific column(s) +- `Delete(&value)` (Result, error) - soft delete (or hard if no SoftDeletes) +- `ForceDelete(&value)` (Result, error) - bypass soft delete +- `WithTrashed()` Query - include soft-deleted records +- `Restore(&value)` error - undelete soft-deleted record +- `Count()` (int64, error) +- `Exists()` (bool, error) +- `Sum(col, &dest)` error - aggregate into pointer +- `Paginate(page, perPage, &dest, &total)` error +- `With("Relation")` Query - eager load +- `Load(&model, "Relation")` error - lazy eager load +- `Association("Field")` Association - manage has-many/m2m + +## Implementation Example + +```go +// app/models/user.go +package models + +import ( + "github.com/goravel/framework/contracts/database/factory" + "github.com/goravel/framework/database/orm" + "github.com/goravel/framework/support/carbon" + "yourmodule/database/factories" +) + +type User struct { + orm.Model // ID, CreatedAt carbon.DateTime, UpdatedAt carbon.DateTime + Name string + Email string + Birthday carbon.Date // date-only field; use carbon types, not time.Time + orm.SoftDeletes // DeletedAt carbon.DateTime +} + +// Bind factory for test seeding +func (u *User) Factory() factory.Factory { + return &factories.UserFactory{} +} + +// Optional overrides +func (u *User) TableName() string { return "goravel_user" } +func (u *User) Connection() string { return "postgres" } + +// controllers/user_controller.go +package controllers + +import ( + "github.com/goravel/framework/contracts/http" + contractsorm "github.com/goravel/framework/contracts/database/orm" + "github.com/goravel/framework/errors" + + "yourmodule/app/facades" + "yourmodule/app/models" +) + +type UserController struct{} + +func (r *UserController) Index(ctx http.Context) http.Response { + var users []models.User + if err := facades.Orm().WithContext(ctx.Context()).Query(). + With("Posts"). + Where("active", 1). + OrderBy("created_at", "desc"). + Paginate(1, 15, &users, new(int64)); err != nil { + return ctx.Response().Json(http.StatusInternalServerError, http.Json{"error": err.Error()}) + } + return ctx.Response().Json(http.StatusOK, users) +} + +func (r *UserController) Show(ctx http.Context) http.Response { + var user models.User + err := facades.Orm().Query().FindOrFail(&user, ctx.Request().RouteInt("id")) + if errors.Is(err, errors.OrmRecordNotFound) { + return ctx.Response().Json(http.StatusNotFound, http.Json{"error": "not found"}) + } + return ctx.Response().Json(http.StatusOK, user) +} + +func (r *UserController) Store(ctx http.Context) http.Response { + user := models.User{Name: ctx.Request().Input("name")} + if err := facades.Orm().Query().Create(&user); err != nil { + return ctx.Response().Json(http.StatusInternalServerError, http.Json{"error": err.Error()}) + } + return ctx.Response().Json(http.StatusCreated, user) +} + +func (r *UserController) Transaction(ctx http.Context) http.Response { + err := facades.Orm().Transaction(func(tx contractsorm.Query) error { + user := models.User{Name: "Alice"} + if err := tx.Create(&user); err != nil { + return err + } + return tx.Model(&models.Role{}).Where("id", 1).Update("user_id", user.ID) + }) + if err != nil { + return ctx.Response().Json(http.StatusInternalServerError, http.Json{"error": err.Error()}) + } + return ctx.Response().Json(http.StatusOK, http.Json{"ok": true}) +} + +// Factory usage (in tests or seeders) +// var user models.User +// facades.Orm().Factory().Create(&user) +// facades.Orm().Factory().Count(5).Create(&users) +// facades.Orm().Factory().Create(&user, map[string]any{"Name": "Alice"}) +// facades.Orm().Factory().CreateQuietly(&user) // no model events +``` + +## Rules + +- `Find(&model, id)` returns **nil error** when record not found - use `FindOrFail` to error on missing. +- Struct `Update(struct{})` skips zero-value fields - use `map[string]any` to set zero values explicitly. +- `GlobalScopes()` must return `map[string]func(contractsorm.Query) contractsorm.Query` - **not** a slice. +- `Sum(column, &dest)` signature: `error` only (dest is a pointer to the result variable). +- Model events only trigger when operating on a model instance. Batch operations (`Table("users").Delete()`) do **not** trigger events. +- If both `DispatchesEvents` and `Observer` are set, only `DispatchesEvents` applies. +- `WithoutEvents()` suppresses all model events for that query chain. +- `SaveQuietly` saves without dispatching any events. +- Relations use struct field names (PascalCase), not column names: `.With("Posts")` not `.With("posts")`. +- `Association("Posts").Find(&posts)` uses the parent model instance already loaded. +- `Cursor()` - do not use `With()` in the same query; use `Load()` inside the loop instead. +- `ForceDelete` permanently removes records even if `orm.SoftDeletes` is embedded. +- Default connection is set in `config/database.go`; override per-query with `Connection("name")`. +- `orm.Model` embeds `CreatedAt` and `UpdatedAt` as `carbon.DateTime`, NOT `time.Time`. Using `time.Time` causes scan errors. +- For custom date/time model fields use carbon types: `carbon.Date`, `carbon.DateTime`, `carbon.Timestamp`. +- Factory `Definition()` map keys must be PascalCase struct field names, not column names. diff --git a/.ai/knowledge/process.md b/.ai/knowledge/process.md new file mode 100644 index 000000000..1a2f717ee --- /dev/null +++ b/.ai/knowledge/process.md @@ -0,0 +1,139 @@ +# Process Facade + +## Core Imports + +```go +import ( + "context" + "io" + "os" + "time" + contractsprocess "github.com/goravel/framework/contracts/process" + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/process/process.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/process/pipeline.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/process/pool.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/process/result.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/process/running.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/process/running_pipe.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/process/running_pool.go` + +## Available Methods + +**facades.Process() - builder (chainable):** + +- `Path(dir)` - working directory +- `Timeout(d)` - kill after duration +- `Env(map[string]string)` - add env vars (inherits system) +- `Input(io.Reader)` - pipe to stdin +- `Quietly()` - capture but suppress terminal output +- `DisableBuffering()` - do not buffer in memory (use with `OnOutput`) +- `OnOutput(func(OutputType, []byte))` - stream output +- `WithContext(ctx)` - bind to context +- `TTY()` - interactive terminal; output not captured; `Input()` is ignored +- `WithSpinner(message?)` - show spinner while running + +**Execution:** + +- `Run(name, args...)` Result - synchronous +- `Start(name, args...)` (Running, error) - async; **must call `.Wait()`** +- `Pipe(func(Pipe))` Pipeline - define pipe; set options **after** `Pipe()` call +- `Pool(func(Pool))` PoolBuilder - define concurrent processes + +## Implementation Example + +```go +package services + +import ( + "fmt" + "os" + "time" + contractsprocess "github.com/goravel/framework/contracts/process" + "yourmodule/app/facades" +) + +// Synchronous +func RunBuild() error { + result := facades.Process(). + Path("/var/www/app"). + Timeout(5 * time.Minute). + Env(map[string]string{"NODE_ENV": "production"}). + Run("npm", "run", "build") + if result.Failed() { + return fmt.Errorf("exit %d: %s", result.ExitCode(), result.ErrorOutput()) + } + return nil +} + +// Async - must call Wait() +func RunAsync() error { + running, err := facades.Process().Timeout(10 * time.Second).Start("sleep", "5") + if err != nil { return err } + + select { + case <-running.Done(): + case <-time.After(8 * time.Second): + running.Stop(2 * time.Second) + } + result := running.Wait() // always call Wait() + if result.Failed() { return result.Error() } + return nil +} + +// Pipeline - options applied AFTER Pipe() +func RunPipeline() error { + result := facades.Process(). + Pipe(func(pipe contractsprocess.Pipe) { + pipe.Command("cat", "app.log").As("source") + pipe.Command("grep", "ERROR").As("filter") + }). + Timeout(30 * time.Second). // options AFTER Pipe() + OnOutput(func(typ contractsprocess.OutputType, line []byte, key string) { + fmt.Printf("[%s] %s", key, line) // key identifies which stage + }). + Run() + if result.Failed() { return result.Error() } + return nil +} + +// Pool - concurrent processes +func RunPool() error { + results, err := facades.Process(). + Pool(func(pool contractsprocess.Pool) { + pool.Command("npm", "test").As("tests") + pool.Command("go", "vet", "./...").As("vet") + }). + Concurrency(2). + Timeout(10 * time.Minute). + Run() + if err != nil { return err } + for key, res := range results { + if res.Failed() { + return fmt.Errorf("%s failed: %s", key, res.ErrorOutput()) + } + } + return nil +} +``` + +## Rules + +- `Run("ls -la")` - shell string with spaces triggers `/bin/sh -c` wrapper automatically. +- `Start()` is async - **always** call `.Wait()` to reap OS resources, even after `Done()`. +- Pipeline options (`Timeout`, `Env`, `WithContext`) set **before** `Pipe()` are **ignored** - apply them after `Pipe()`. +- `DisableBuffering` - `Result.Output()` and `Result.ErrorOutput()` return empty; use `OnOutput` to consume. +- `TTY()` - output is not captured; `Input()` is ignored; your terminal becomes the subprocess stdin. +- `Pool.OnOutput` callback is called from multiple goroutines - ensure thread safety. +- `Pool.PoolCommand` supports per-process overrides: `Path`, `Timeout`, `Env`, `Input`, `Quietly`, `DisableBuffering`, `WithContext`. +- `Pool.Run()` returns `map[string]Result` keyed by `.As(key)` names. +- `Stop(timeout, sig?)` sends SIGTERM, then SIGKILL after timeout if process is still alive. +- `WithSpinner` shows a terminal spinner while the process runs. +- `Process` facade is registered via `&process.ServiceProvider{}` in `bootstrap/providers.go`. diff --git a/.ai/knowledge/queue.md b/.ai/knowledge/queue.md new file mode 100644 index 000000000..83985f337 --- /dev/null +++ b/.ai/knowledge/queue.md @@ -0,0 +1,116 @@ +# Queue Facade + +## Core Imports + +```go +import ( + "time" + "github.com/goravel/framework/contracts/queue" + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/queue/job.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/queue/queue.go` + +## Available Methods + +**facades.Queue():** + +- `Job(job Job, args ...[]Arg)` PendingJob - create dispatchable task +- `Chain([]ChainJob)` PendingJob - create chained sequence +- `Worker(payloads ...Args)` Worker - create a queue worker +- `Register(jobs []Job)` - register jobs manually + +**PendingJob:** + +- `.Dispatch()` error - push to queue +- `.DispatchSync()` error - execute immediately in current process +- `.Delay(time.Time)` PendingJob - delay until absolute time +- `.OnQueue(name)` PendingJob - named queue +- `.OnConnection(name)` PendingJob - named connection + +## Implementation Example + +```go +// app/jobs/process_podcast.go +package jobs + +import "time" + +type ProcessPodcast struct{} + +func (r *ProcessPodcast) Signature() string { return "process_podcast" } + +func (r *ProcessPodcast) Handle(args ...any) error { + podcastID := args[0].(int) + _ = podcastID + return nil +} + +// Optional retry control +func (r *ProcessPodcast) ShouldRetry(err error, attempt int) (bool, time.Duration) { + if attempt < 3 { return true, 10 * time.Second } + return false, 0 +} + +// app/jobs/send_notification.go +type SendNotification struct{} +func (r *SendNotification) Signature() string { return "send_notification" } +func (r *SendNotification) Handle(args ...any) error { return nil } + +// controllers/podcast_controller.go +package controllers + +import ( + "time" + "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/contracts/queue" + "yourmodule/app/facades" + "yourmodule/app/jobs" +) + +type PodcastController struct{} + +func (r *PodcastController) Store(ctx http.Context) http.Response { + podcastID := 42 + + // Basic dispatch + _ = facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{ + {Type: "int", Value: podcastID}, + }).Dispatch() + + // Delayed to specific time + _ = facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{ + {Type: "int", Value: podcastID}, + }).Delay(time.Now().Add(5 * time.Minute)).OnQueue("podcasts").Dispatch() + + // Chained - stops on first failure + _ = facades.Queue().Chain([]queue.ChainJob{ + {Job: &jobs.ProcessPodcast{}, Args: []queue.Arg{{Type: "int", Value: podcastID}}}, + {Job: &jobs.SendNotification{}, Args: []queue.Arg{{Type: "string", Value: "done"}}}, + }).Dispatch() + + return ctx.Response().Json(http.StatusAccepted, http.Json{"queued": true}) +} +``` + +## Rules + +- Register jobs via `WithJobs(Jobs)` in `bootstrap/app.go`; `make:job` auto-registers in `bootstrap/jobs.go`. +- `Signature()` must be unique across all jobs. +- `Handle(args ...any)` receives args in the same order as `[]Arg` passed at dispatch. +- `Arg.Type` must be a supported primitive string - see list below. +- `Chain` stops at first job failure; subsequent jobs are not executed. +- `Delay` takes `time.Time` (absolute), not `time.Duration` - use `time.Now().Add(d)`. +- `DispatchSync` bypasses the queue entirely; executes in the current goroutine. +- `Jobs` type alias for `ChainJob` is deprecated; use `ChainJob` directly. +- Machinery driver is removed; available drivers: `sync`, `database`, `redis` (via `goravel/redis`). +- Configure connections in `config/queue.go`; set `default` connection name. + +**Supported `Arg.Type` values:** +`bool`, `int`, `int8`, `int16`, `int32`, `int64`, `uint`, `uint8`, `uint16`, `uint32`, `uint64`, `float32`, `float64`, `string`, `[]bool`, `[]int`, `[]int8`, `[]int16`, `[]int32`, `[]int64`, `[]uint`, `[]uint8`, `[]uint16`, `[]uint32`, `[]uint64`, `[]float32`, `[]float64`, `[]string` diff --git a/.ai/knowledge/request-response.md b/.ai/knowledge/request-response.md new file mode 100644 index 000000000..cea93e671 --- /dev/null +++ b/.ai/knowledge/request-response.md @@ -0,0 +1,132 @@ +# Request & Response + +## Core Imports + +```go +import ( + "net/http" + "github.com/goravel/framework/contracts/http" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/request.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/response.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/context.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/cookie.go` + +## Available Methods - Request + +- `Path()` string - `/users/1` +- `OriginPath()` string - `/users/{id}` +- `Url()` / `FullUrl()` / `Host()` / `Method()` / `Name()` / `Ip()` +- `Info()` Info - route handler/method/name/path +- `Route(key)` string / `RouteInt` / `RouteInt64` +- `Query(key, default?)` / `QueryInt` / `QueryInt64` / `QueryBool` / `QueryArray` / `QueryMap` +- `Queries()` map[string]string - all query params +- `Input(key, default?)` - JSON > form > query > route priority +- `InputInt` / `InputInt64` / `InputBool` / `InputArray` / `InputMap` / `InputMapArray` +- `All()` map[string]any - merged JSON + form + query +- `Bind(&obj)` error - JSON or form -> struct (tags: `json:` or `form:`) +- `BindQuery(&obj)` error - query string -> struct (tags: `form:`) +- `Header(key, default?)` / `Headers()` http.Header +- `Cookie(key, default?)` +- `File(name)` (filesystem.File, error) / `Files(name)` ([]filesystem.File, error) +- `Validate(rules)` (Validator, error) +- `ValidateRequest(&formReq)` (Errors, error) +- `Next()` - advance middleware chain +- `Abort(code?)` - halt; optional status code, default 400 +- `Origin()` \*http.Request - underlying stdlib request + +## Available Methods - Response + +- `Header(key, value)` ContextResponse - chainable, before terminal call +- `Cookie(Cookie)` ContextResponse / `WithoutCookie(name)` ContextResponse +- `Json(code, obj)` AbortableResponse +- `String(code, format, values...)` AbortableResponse +- `Data(code, contentType, []byte)` AbortableResponse +- `NoContent(code?)` AbortableResponse +- `Redirect(code, url)` AbortableResponse +- `File(path)` Response / `Download(path, name)` Response +- `Stream(code, func(StreamWriter) error)` Response +- `Success()` ResponseStatus - then `.Json(obj)` / `.String(fmt)` (no code needed) +- `Status(code)` ResponseStatus - then `.Json(obj)` / `.String(fmt)` +- `View()` ResponseView - then `.Make(name, data...)` / `.First(names, data...)` +- `Origin()` ResponseOrigin - use in after-middleware to read body/status +- `Flush()` - flush buffered data to client + +## Implementation Example + +```go +package controllers + +import ( + "github.com/goravel/framework/contracts/http" + "yourmodule/app/models" +) + +type UserController struct{} + +func (r *UserController) Show(ctx http.Context) http.Response { + id := ctx.Request().RouteInt("id") + + var input struct { + Name string `json:"name" form:"name"` + } + if err := ctx.Request().Bind(&input); err != nil { + return ctx.Response().Json(http.StatusBadRequest, http.Json{"error": err.Error()}) + } + + validator, err := ctx.Request().Validate(map[string]string{ + "name": "required|max_len:255", + }) + if err != nil || validator.Fails() { + return ctx.Response().Json(http.StatusUnprocessableEntity, validator.Errors().All()) + } + + return ctx.Response(). + Header("X-ID", fmt.Sprint(id)). + Cookie(http.Cookie{Name: "visited", Value: "1", HttpOnly: true}). + Json(http.StatusOK, http.Json{"id": id, "name": input.Name}) +} + +func (r *UserController) Stream(ctx http.Context) http.Response { + return ctx.Response().Stream(http.StatusOK, func(w http.StreamWriter) error { + for _, chunk := range []string{"a", "b", "c"} { + if _, err := w.WriteString(chunk); err != nil { return err } + if err := w.Flush(); err != nil { return err } + } + return nil + }) +} + +// Form request pattern +type StoreUserRequest struct { + Name string `form:"name" json:"name"` +} +func (r *StoreUserRequest) Authorize(ctx http.Context) error { return nil } +func (r *StoreUserRequest) Rules(ctx http.Context) map[string]string { + return map[string]string{"name": "required|max_len:100"} +} +// Usage: errs, err := ctx.Request().ValidateRequest(&StoreUserRequest{}) +``` + +## Rules + +- Every handler **must** return `http.Response` - missing return type is a compile error. +- `Input` reads in priority order: JSON body > form > query > route params. +- `All()` merges JSON + form + query; does not include route params. +- `Bind` uses `json:` tags for JSON body, `form:` tags for form data. +- `BindQuery` only reads query string; struct tags must be `form:`. +- `Next()` must be called in middleware to advance the chain; omitting it halts execution. +- `Abort(code?)` halts remaining middleware and handler; default status is 400. +- `Header/Cookie/WithoutCookie` are chainable and must precede the terminal call. +- `Success()` returns `ResponseStatus` - then call `.Json(obj)` without a status code. +- `ResponseView.Make(view, data...)` takes variadic `any` (not a map argument). +- `Origin()` on response is only useful in after-middleware to inspect the final response. +- `FormRequest` optional interfaces (`WithFilters`, `WithMessages`, `WithAttributes`, `WithPrepareForValidation`) are detected by interface assertion - no explicit declaration needed. +- `AbortableResponse.Abort()` aborts the request; call it after `Json`/`String` etc. +- `ctx.WithValue(key, value)` / `ctx.Value(key)` pass data between middleware and handlers. diff --git a/.ai/knowledge/route.md b/.ai/knowledge/route.md new file mode 100644 index 000000000..4aff8157a --- /dev/null +++ b/.ai/knowledge/route.md @@ -0,0 +1,125 @@ +# Route Facade + +## Core Imports + +```go +import ( + "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/contracts/route" + httplimit "github.com/goravel/framework/http/limit" + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/route/route.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/http/rate_limiter.go` + +## Available Methods + +**facades.Route():** + +- `Get/Post/Put/Delete/Patch/Options/Any(path, handler)` Action +- `Resource(path, controller)` Action +- `Group(func(Router))` +- `Prefix(path)` Router +- `Middleware(middlewares...)` Router +- `Static(path, root)` Action +- `StaticFile(path, file)` Action +- `StaticFS(path, fs)` Action +- `Fallback(handler)` - unmatched routes +- `GetRoutes()` []http.Info - all registered routes +- `Info(name)` http.Info - route by name + +**facades.RateLimiter():** + +- `For(name, func(ctx) Limit)` - register single limiter +- `ForWithLimits(name, func(ctx) []Limit)` - register multiple limits + +**httplimit constructors:** + +- `PerMinute(maxAttempts int)` Limit +- `PerMinutes(decayMinutes, maxAttempts int)` Limit +- `PerHour(maxAttempts int)` Limit +- `PerHours(decayHours, maxAttempts int)` Limit +- `PerDay(maxAttempts int)` Limit +- `PerDays(decayDays, maxAttempts int)` Limit + +**Action:** + +- `.Name(name string)` Action - assign name to route + +## Implementation Example + +```go +package routes + +import ( + contractshttp "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/contracts/route" + httplimit "github.com/goravel/framework/http/limit" + httpmiddleware "github.com/goravel/framework/http/middleware" + "yourmodule/app/facades" + "yourmodule/app/http/controllers" + appmw "yourmodule/app/http/middleware" +) + +func Web() { + // Rate limiter - register in WithCallback in bootstrap/app.go + facades.RateLimiter().For("api", func(ctx contractshttp.Context) contractshttp.Limit { + return httplimit.PerMinute(60).By(ctx.Request().Ip()) + }) + facades.RateLimiter().ForWithLimits("uploads", func(ctx contractshttp.Context) []contractshttp.Limit { + return []contractshttp.Limit{ + httplimit.PerMinute(10).By(ctx.Request().Ip()), + httplimit.PerDay(100).By(ctx.Request().Ip()), + } + }) + + userCtrl := controllers.NewUserController() + + // Named routes + facades.Route().Get("/", func(ctx contractshttp.Context) contractshttp.Response { + return ctx.Response().Json(contractshttp.StatusOK, contractshttp.Json{"ok": true}) + }).Name("home") + + // Prefix + middleware group + facades.Route(). + Prefix("api/v1"). + Middleware(appmw.Auth(), httpmiddleware.Throttle("api")). + Group(func(r route.Router) { + r.Get("users", userCtrl.Index).Name("users.index") + r.Post("users", userCtrl.Store) + r.Get("users/{id}", userCtrl.Show) + r.Put("users/{id}", userCtrl.Update) + r.Delete("users/{id}", userCtrl.Destroy) + }) + + // Resource routes (auto-generates Index/Show/Store/Update/Destroy) + facades.Route().Resource("photos", controllers.NewPhotoController()) + + // Static files + facades.Route().Static("storage", "./storage/app/public") + + // 404 fallback + facades.Route().Fallback(func(ctx contractshttp.Context) contractshttp.Response { + return ctx.Response().Json(404, contractshttp.Json{"error": "not found"}) + }) +} +``` + +## Rules + +- Every handler must have signature `func(ctx http.Context) http.Response`. +- `facades.Route()` and `facades.RateLimiter()` are project facades from `yourmodule/app/facades`. +- Rate limiters must be registered in `WithCallback` (all providers booted) before routes are used. +- `Group` callback receives `route.Router`, not the facade. +- `Resource` requires all 5 methods on the controller. +- Route names are set via `.Name()` on the returned `Action`; retrieve with `facades.Route().Info(name)`. +- `Throttle("name")` middleware name must match a registered `RateLimiter` name. +- `PerMinutes(5, 100)` = 100 requests per 5 minutes; first arg is decay, second is max. +- Static path is relative to the application working directory. +- `GlobalMiddleware` on Route is deprecated; use `WithMiddleware` in `bootstrap/app.go`. diff --git a/.ai/knowledge/schedule.md b/.ai/knowledge/schedule.md new file mode 100644 index 000000000..8d00431e7 --- /dev/null +++ b/.ai/knowledge/schedule.md @@ -0,0 +1,110 @@ +# Schedule Facade + +## Core Imports + +```go +import ( + "github.com/goravel/framework/contracts/schedule" + + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/schedule/event.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/schedule/schedule.go` + +## Available Methods + +**facades.Schedule():** + +- `Call(func())` Event - schedule a closure +- `Command(signature string)` Event - schedule an Artisan command by signature + +**Event frequency (chainable):** + +- `.Cron("* * * * *")` - 5-field cron (minutes); `.Cron("* * * * * *")` - 6-field (seconds) +- `.EverySecond()` / `.EveryMinute()` / `.Hourly()` / `.Daily()` / `.Weekly()` / `.Monthly()` / `.Quarterly()` / `.Yearly()` +- `.DailyAt("13:00")` - specific time daily +- `.HourlyAt(17)` - at 17 minutes past each hour +- `.Days(1, 3, 5)` - specific weekdays (1=Mon, 7=Sun) +- `.Weekdays()` / `.Weekends()` / `.Mondays()` ... `.Sundays()` + +**Overlap control:** + +- `.SkipIfStillRunning()` - skip if previous execution is still running +- `.DelayIfStillRunning()` - queue until previous execution finishes + +**Distributed:** + +- `.OnOneServer()` - run on only one server (requires non-memory cache driver) +- `.Name("name")` - required for closure tasks with `.OnOneServer()` + +## Implementation Example + +```go +// bootstrap/app.go +package bootstrap + +import ( + contractsfoundation "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/contracts/schedule" + "github.com/goravel/framework/foundation" + + "yourmodule/app/facades" + "yourmodule/app/models" + "yourmodule/bootstrap/config" +) + +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithConfig(config.Boot). + WithSchedule(func() []schedule.Event { + return []schedule.Event{ + // Run closure every minute + facades.Schedule().Call(func() { + facades.Log().Info("heartbeat") + }).EveryMinute().Name("heartbeat"), + + // Run closure daily at 2am, single server only + facades.Schedule().Call(func() { + facades.Orm().Query(). + Where("1 = 1"). + Delete(&models.TempFile{}) + }).DailyAt("02:00").OnOneServer().Name("cleanup-temp"), + + // Run Artisan command every 5 minutes + facades.Schedule().Command("send:emails --lang en"). + EveryFiveMinutes(). + SkipIfStillRunning(), + + // Custom cron expression + facades.Schedule().Command("report:generate"). + Cron("0 9 * * 1"). // every Monday at 9:00 + OnOneServer(), + + // Weekdays only + facades.Schedule().Call(func() { + // business hours sync + }).Weekdays().HourlyAt(0), + } + }). + Create() +} +``` + +## Rules + +- Define schedules via `WithSchedule` in `bootstrap/app.go`; the scheduler runs automatically on `Start()`. +- `Command("signature")` runs an Artisan command; include arguments/flags in the string. +- `OnOneServer()` requires a distributed cache driver (`redis`, `memcached`, `dynamodb`) - **not** `memory`. +- Closure tasks must have `.Name("unique-name")` when using `.OnOneServer()`. +- `.SkipIfStillRunning()` silently skips; `.DelayIfStillRunning()` waits for the running task to complete. +- `Cron("* * * * *")` is minute-resolution; `Cron("* * * * * *")` is second-resolution (6 fields). +- When `app.debug = true`, all schedule logs are printed; otherwise only `error` level. +- Run the scheduler manually: `./artisan schedule:run`. +- View all scheduled tasks: `./artisan schedule:list`. +- `Days(1, 3, 5)` - weekday integers: 0=Sunday, 1=Monday, ..., 6=Saturday. diff --git a/.ai/knowledge/session.md b/.ai/knowledge/session.md new file mode 100644 index 000000000..4ae06bc24 --- /dev/null +++ b/.ai/knowledge/session.md @@ -0,0 +1,137 @@ +# Session Facade + +## Core Imports + +```go +import ( + "github.com/goravel/framework/contracts/http" + contractssession "github.com/goravel/framework/contracts/session" + httpmiddleware "github.com/goravel/framework/http/middleware" + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/session/session.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/session/manager.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/session/driver.go` + +## Available Methods + +**ctx.Request():** + +- `HasSession()` bool +- `Session()` Session +- `SetSession(session)` ContextRequest + +**Session:** + +- `Get(key, default?)` any +- `All()` map[string]any +- `Only([]string)` map[string]any +- `Has(key)` bool - present and not nil +- `Exists(key)` bool - present even if nil +- `Missing(key)` bool +- `Token()` string - CSRF token +- `Put(key, value)` Session +- `Pull(key, default?)` any - retrieve + delete +- `Flash(key, value)` Session - available next request only +- `Now(key, value)` Session - available current request only +- `Reflash()` Session - extend all flash by one more request +- `Keep(keys...)` Session - extend specific flash keys +- `Forget(keys...)` Session +- `Flush()` Session - clear all +- `Remove(key)` any - remove and return value +- `Regenerate(destroy?)` error - new session ID +- `Invalidate()` error - new ID + flush data +- `GetID()` / `SetID(id)` / `GetName()` / `SetName(name)` +- `Save()` error +- `Start()` bool + +**facades.Session():** + +- `Driver(name?)` (Driver, error) +- `BuildSession(driver, sessionID?)` (Session, error) +- `ReleaseSession(session)` + +## Implementation Example + +```go +// bootstrap/app.go - register session middleware globally +// WithMiddleware(func(h configuration.Middleware) { +// h.Append(httpmiddleware.StartSession()) +// }) + +package controllers + +import ( + "fmt" + "github.com/goravel/framework/contracts/http" + "yourmodule/app/facades" +) + +type SessionController struct{} + +func (r *SessionController) Store(ctx http.Context) http.Response { + session := ctx.Request().Session() + + session.Put("user_id", 42) + session.Flash("status", "Profile updated!") + session.Now("notice", "Visible this request only") + + return ctx.Response().Json(http.StatusOK, http.Json{"stored": true}) +} + +func (r *SessionController) Read(ctx http.Context) http.Response { + session := ctx.Request().Session() + + userID := session.Get("user_id", 0) + status := session.Pull("status") // read + delete + + if session.Missing("preferences") { + session.Put("preferences", map[string]any{"theme": "light"}) + } + + return ctx.Response().Json(http.StatusOK, http.Json{ + "user_id": userID, + "status": status, + }) +} + +func (r *SessionController) Regenerate(ctx http.Context) http.Response { + // Regenerate session ID after login (prevents fixation) + if err := ctx.Request().Session().Regenerate(); err != nil { + return ctx.Response().Json(http.StatusInternalServerError, http.Json{"error": err.Error()}) + } + + // Update session cookie with new ID + ctx.Response().Cookie(http.Cookie{ + Name: ctx.Request().Session().GetName(), + Value: ctx.Request().Session().GetID(), + MaxAge: facades.Config().GetInt("session.lifetime") * 60, + Path: facades.Config().GetString("session.path"), + Domain: facades.Config().GetString("session.domain"), + Secure: facades.Config().GetBool("session.secure"), + HttpOnly: facades.Config().GetBool("session.http_only"), + SameSite: facades.Config().GetString("session.same_site"), + }) + + return ctx.Response().Json(http.StatusOK, http.Json{"ok": true}) +} +``` + +## Rules + +- Sessions are not started automatically; register `httpmiddleware.StartSession()` explicitly. +- `ctx.Request().Session()` panics if no session middleware is registered; check `HasSession()` first if uncertain. +- `Put/Flash/Now/Reflash/Keep/Forget/Flush` all return `Session` for chaining. +- `Regenerate()` and `Invalidate()` return `error`, not `bool`. +- `Remove(key)` returns the removed value as `any`. +- `Flash` is available for exactly one subsequent request; `Reflash()` extends by one more. +- `Now` is an immediate flash; visible in the **current** request, not the next. +- After `Regenerate`/`Invalidate`, update the session cookie manually (see example above). +- Default driver is `file`; configure in `config/session.go`. +- `Token()` returns the CSRF token associated with the session. diff --git a/.ai/knowledge/storage.md b/.ai/knowledge/storage.md new file mode 100644 index 000000000..baf885876 --- /dev/null +++ b/.ai/knowledge/storage.md @@ -0,0 +1,146 @@ +# Storage Facade + +## Core Imports + +```go +import ( + "time" + "context" + "github.com/goravel/framework/filesystem" + contractsfilesystem "github.com/goravel/framework/contracts/filesystem" + + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/filesystem/storage.go` + +## Available Methods + +**facades.Storage():** + +- `Disk(name)` Driver - switch to named disk +- `WithContext(ctx)` Driver - inject context +- `Get(path)` (string, error) - read file contents +- `GetBytes(path)` ([]byte, error) - read as bytes +- `Exists(path)` bool - check file exists +- `Missing(path)` bool - check file is absent +- `Put(path, content)` error - write string content +- `PutFile(dir, file)` (string, error) - store with auto-generated name; returns path +- `PutFileAs(dir, file, name)` (string, error) - store with explicit name +- `Copy(src, dst)` error +- `Move(src, dst)` error +- `Delete(files...)` error +- `Url(path)` string - public URL +- `TemporaryUrl(path, time.Time)` (string, error) - expiring URL (non-local drivers) +- `Size(path)` (int64, error) +- `LastModified(path)` (time.Time, error) +- `MimeType(path)` (string, error) +- `Path(path)` string - absolute local path +- `Files(dir)` ([]string, error) - files in directory +- `AllFiles(dir)` ([]string, error) - files including subdirectories +- `Directories(dir)` ([]string, error) +- `AllDirectories(dir)` ([]string, error) +- `MakeDirectory(dir)` error +- `DeleteDirectory(dir)` error + +**filesystem.File (local file object):** + +- `filesystem.NewFile(path)` (\*File, error) - create from local path +- `.Size()` (int64, error) +- `.LastModified()` (time.Time, error) +- `.MimeType()` (string, error) + +**Uploaded file (from ctx.Request().File):** + +- `.Store(dir)` (string, error) - save to default disk; returns stored path +- `.StoreAs(dir, name)` (string, error) - save with specific name +- `.Disk(name)` File - target a specific disk +- `.GetClientOriginalName()` string - unsafe; use for display only +- `.GetClientOriginalExtension()` string - unsafe +- `.HashName()` string - safe random name +- `.Extension()` (string, error) - safe MIME-based extension + +## Implementation Example + +```go +package controllers + +import ( + "time" + "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/filesystem" + "yourmodule/app/facades" +) + +type FileController struct{} + +// Upload file from request +func (r *FileController) Upload(ctx http.Context) http.Response { + file, err := ctx.Request().File("avatar") + if err != nil { + return ctx.Response().Json(http.StatusBadRequest, http.Json{"error": "no file"}) + } + + // Store to default disk with auto-name + path, err := file.Store("avatars") + if err != nil { + return ctx.Response().Json(http.StatusInternalServerError, http.Json{"error": err.Error()}) + } + return ctx.Response().Json(http.StatusOK, http.Json{"path": path}) +} + +// Store to S3 with specific name +func (r *FileController) UploadToS3(ctx http.Context) http.Response { + file, _ := ctx.Request().File("document") + path, err := file.Disk("s3").StoreAs("docs", file.HashName()) + if err != nil { + return ctx.Response().Json(http.StatusInternalServerError, http.Json{"error": err.Error()}) + } + url := facades.Storage().Disk("s3").Url(path) + return ctx.Response().Json(http.StatusOK, http.Json{"url": url}) +} + +// Read / write directly +func (r *FileController) ProcessFile(ctx http.Context) http.Response { + // Write + _ = facades.Storage().Put("data/report.txt", "hello world") + + // Read + content, _ := facades.Storage().Get("data/report.txt") + + // Temporary URL (for S3/OSS etc.) + tmpUrl, _ := facades.Storage().Disk("s3").TemporaryUrl( + "private/file.pdf", + time.Now().Add(15*time.Minute), + ) + + // Copy local file to storage + localFile, _ := filesystem.NewFile("./resources/logo.png") + storedPath := facades.Storage().PutFile("images", localFile) + + return ctx.Response().Json(http.StatusOK, http.Json{ + "content": content, + "tmp_url": tmpUrl, + "logo_path": storedPath, + }) +} +``` + +## Rules + +- Default disk is `local`; configure in `config/filesystems.go` under the `default` key. +- All paths are relative to the disk's configured `root` directory. +- `Url()` on `local` driver prepends `/storage` - serve via `facades.Route().Static("storage", "./storage/app/public")`. +- `TemporaryUrl()` only works on non-local drivers (S3, OSS, etc.). +- `PutFile` auto-generates a unique filename; `PutFileAs` uses the name you provide. +- If name in `StoreAs`/`PutFileAs` has no extension, the MIME-detected extension is appended automatically. +- `GetClientOriginalName`/`GetClientOriginalExtension` are **unsafe** - can be tampered by clients. +- Use `HashName()` and `Extension()` for security-safe filenames. +- Custom driver: set `"driver": "custom"` and `"via": driverInstance` in `config/filesystems.go`. +- Use `facades.Config().Env()` inside custom drivers - regular config is not yet loaded when the driver registers. +- External drivers: S3 (`goravel/s3`), OSS (`goravel/oss`), COS (`goravel/cos`), Minio (`goravel/minio`). diff --git a/.ai/knowledge/str.md b/.ai/knowledge/str.md new file mode 100644 index 000000000..e4902310d --- /dev/null +++ b/.ai/knowledge/str.md @@ -0,0 +1,167 @@ +# Fluent String (str) + +## Import + +```go +import "github.com/goravel/framework/support/str" +``` + +## Contracts + +Support library. No framework contract file. +The `*str.String` type and all methods are defined in `support/str`. + +## Usage Pattern + +`str.Of(s)` returns `*str.String`. Chain methods. Call `.String()` at the end to get a `string`. +Methods that return a non-string value (`bool`, `int`, `[]string`) do NOT need `.String()`. + +## Transform Methods + +```go +str.Of(s).Append(values ...string) *str.String +str.Of(s).Prepend(values ...string) *str.String +str.Of(s).Remove(values ...string) *str.String +str.Of(s).Replace(search, replace string, caseSensitive ...bool) *str.String +str.Of(s).ReplaceFirst(search, replace string) *str.String +str.Of(s).ReplaceLast(search, replace string) *str.String +str.Of(s).ReplaceStart(search, replace string) *str.String +str.Of(s).ReplaceEnd(search, replace string) *str.String +str.Of(s).ReplaceMatches(pattern, replace string) *str.String +str.Of(s).Swap(pairs map[string]string) *str.String +str.Of(s).Repeat(times int) *str.String +str.Of(s).Limit(limit int, end ...string) *str.String // default end: "..." +str.Of(s).Words(words int, end ...string) *str.String +str.Of(s).Mask(char string, index int, length ...int) *str.String +str.Of(s).PadLeft(length int, pad string) *str.String +str.Of(s).PadRight(length int, pad string) *str.String +str.Of(s).PadBoth(length int, pad string) *str.String +str.Of(s).NewLine(count ...int) *str.String +``` + +## Case Methods + +```go +str.Of(s).Lower() *str.String // "GORAVEL" -> "goravel" +str.Of(s).Upper() *str.String // "goravel" -> "GORAVEL" +str.Of(s).Title() *str.String // "goravel framework" -> "Goravel Framework" +str.Of(s).UcFirst() *str.String // "goravel" -> "Goravel" +str.Of(s).LcFirst() *str.String // "Goravel" -> "goravel" +str.Of(s).Camel() *str.String // "hello_world" -> "helloWorld" +str.Of(s).Studly() *str.String // "hello_world" -> "HelloWorld" +str.Of(s).Snake() *str.String // "GoravelFramework" -> "goravel_framework" +str.Of(s).Kebab() *str.String // "GoravelFramework" -> "goravel-framework" +str.Of(s).Headline() *str.String // "bowen_han" -> "Bowen Han" +str.Of(s).UcSplit() []string // "GoravelFramework" -> ["Goravel","Framework"] +``` + +## Trim Methods + +```go +str.Of(s).Trim(chars ...string) *str.String // both sides; optional chars to trim +str.Of(s).LTrim(chars ...string) *str.String // left only +str.Of(s).RTrim(chars ...string) *str.String // right only +str.Of(s).Squish() *str.String // collapse internal whitespace +``` + +## Extract Methods + +```go +str.Of(s).After(search string) *str.String +str.Of(s).AfterLast(search string) *str.String +str.Of(s).Before(search string) *str.String +str.Of(s).BeforeLast(search string) *str.String +str.Of(s).Between(from, to string) *str.String +str.Of(s).BetweenFirst(from, to string) *str.String +str.Of(s).ChopStart(needle string) *str.String +str.Of(s).ChopEnd(needle string) *str.String +str.Of(s).Substr(start, length int) string // returns string directly +str.Of(s).CharAt(index int) string // returns string directly +str.Of(s).Basename(suffix ...string) *str.String +str.Of(s).Dirname(levels ...int) *str.String +str.Of(s).Except(excerpt string, opts ...str.ExcerptOption) *str.String +str.Of(s).Explode(delimiter string) []string // returns []string directly +str.Of(s).Split(delimiter string) []string // returns []string directly +``` + +## Check Methods (return bool -- no .String() needed) + +```go +str.Of(s).Contains(needles ...string) bool // any needle matches +str.Of(s).ContainsAll(needles ...string) bool // all needles match +str.Of(s).StartsWith(needles ...string) bool +str.Of(s).EndsWith(needles ...string) bool +str.Of(s).Exactly(value string) bool +str.Of(s).Is(patterns ...string) bool // glob patterns +str.Of(s).IsEmpty() bool +str.Of(s).IsNotEmpty() bool +str.Of(s).IsAscii() bool +str.Of(s).IsSlice() bool // valid JSON array +str.Of(s).IsMap() bool // valid JSON object +str.Of(s).IsUlid() bool +str.Of(s).IsUuid() bool +str.Of(s).IsMatch(pattern string) bool // regex +str.Of(s).Test(pattern string) bool // alias for IsMatch +str.Of(s).Length() int +str.Of(s).WordCount() int +``` + +## Regex Methods + +```go +str.Of(s).Match(pattern string) *str.String // first match +str.Of(s).MatchAll(pattern string) []string // all matches; returns []string directly +``` + +## Pluralization + +```go +str.Of(s).Plural(count ...int) *str.String // no arg or count>1 = plural; count=1 = singular +str.Of(s).Singular() *str.String +``` + +## Path-like Methods + +```go +str.Of(s).Finish(cap string) *str.String // ensure ends with cap +str.Of(s).Start(prefix string) *str.String // ensure starts with prefix +``` + +## Conditional Chaining + +```go +str.Of(s).When(cond bool, fn func(*str.String) *str.String, otherwise ...func(*str.String) *str.String) *str.String +str.Of(s).Unless(cond func(*str.String) bool, fn func(*str.String) *str.String) *str.String +str.Of(s).WhenEmpty(fn func(*str.String) *str.String) *str.String +str.Of(s).WhenNotEmpty(fn func(*str.String) *str.String) *str.String +str.Of(s).WhenContains(needle string, fn func(*str.String) *str.String) *str.String +str.Of(s).WhenContainsAll(needles []string, fn func(*str.String) *str.String) *str.String +str.Of(s).WhenStartsWith(needle string, fn func(*str.String) *str.String) *str.String +str.Of(s).WhenEndsWith(needle string, fn func(*str.String) *str.String) *str.String +str.Of(s).WhenExactly(value string, fn func(*str.String) *str.String) *str.String +str.Of(s).WhenNotExactly(value string, fn func(*str.String) *str.String) *str.String +str.Of(s).WhenIs(pattern string, fn func(*str.String) *str.String) *str.String +str.Of(s).WhenIsAscii(fn func(*str.String) *str.String) *str.String +str.Of(s).WhenIsUlid(fn func(*str.String) *str.String) *str.String +str.Of(s).WhenIsUuid(fn func(*str.String) *str.String) *str.String +str.Of(s).WhenTest(pattern string, fn func(*str.String) *str.String) *str.String +``` + +## Utility + +```go +str.Of(s).Tap(fn func(string)) *str.String // inspect without modifying +str.Of(s).Pipe(fn func(string) string) *str.String +str.Of(s).String() string // terminal: get final string value +``` + +## Rules + +- Always call `.String()` to extract a plain `string`; intermediate chain returns `*str.String`. +- Methods returning `bool`, `int`, `[]string`, or `string` directly (e.g. `CharAt`, `Substr`, `Explode`) do NOT need `.String()`. +- `Plural()` with no argument returns plural form. `Plural(1)` returns singular. +- `Replace(search, replace, false)` performs case-insensitive replacement. +- `Limit(7)` truncates to 7 characters and appends `"..."` by default. +- `Mask("*", 3)` masks from index 3 to end. `Mask("*", -13, 3)` masks 3 chars from position -13 (from end). +- `Except` extracts a contextual excerpt around the search term; `Radius` controls surrounding character count; `Omission` changes the ellipsis string (default `"..."`). +- `UcFirst` (not `UpperFirst`) is the correct method name for capitalising the first character. diff --git a/.ai/knowledge/testing.md b/.ai/knowledge/testing.md new file mode 100644 index 000000000..90b8968b9 --- /dev/null +++ b/.ai/knowledge/testing.md @@ -0,0 +1,205 @@ +# Testing + +## Core Imports + +```go +import ( + "testing" + "github.com/stretchr/testify/suite" + testinghttp "github.com/goravel/framework/testing/http" + "github.com/goravel/framework/testing/mock" + contractstesting "github.com/goravel/framework/contracts/testing" + contractsorm "github.com/goravel/framework/contracts/database/orm" + + "yourmodule/tests" + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/testing/testing.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/testing/http/request.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/testing/http/response.go` +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/testing/http/assertable_json.go` + +## Available Methods + +**Suite helpers (from `tests.TestCase`):** + +- `s.Http(t)` TestRequest - create HTTP test client +- `s.Seed(seeders ...Seeder)` - run seeders (no arg = DatabaseSeeder) +- `s.RefreshDatabase()` - migrate fresh before each test (call in `SetupTest`) + +**TestRequest (from `s.Http(t)`):** + +- `.WithHeader(key, value)` TestRequest +- `.WithHeaders(map[string]string)` TestRequest +- `.WithCookie(testinghttp.Cookie(name, value))` TestRequest +- `.WithCookies(testinghttp.Cookies(map[string]string))` TestRequest +- `.WithSession(map[string]any)` TestRequest +- `.Get(path)` (TestResponse, error) +- `.Post(path, body)` (TestResponse, error) +- `.Put(path, body)` (TestResponse, error) +- `.Patch(path, body)` (TestResponse, error) +- `.Delete(path, body?)` (TestResponse, error) + +**TestResponse assertions:** + +- `.AssertStatus(code)` +- `.AssertOk()` / `.AssertCreated()` / `.AssertAccepted()` / `.AssertNoContent()` +- `.AssertPartialContent()` / `.AssertNotModified()` / `.AssertTemporaryRedirect()` +- `.AssertMovedPermanently()` / `.AssertFound()` +- `.AssertBadRequest()` / `.AssertUnauthorized()` / `.AssertPaymentRequired()` +- `.AssertForbidden()` / `.AssertNotFound()` / `.AssertMethodNotAllowed()` +- `.AssertRequestTimeout()` / `.AssertConflict()` / `.AssertGone()` +- `.AssertUnprocessableEntity()` / `.AssertTooManyRequests()` +- `.AssertInternalServerError()` / `.AssertServiceUnavailable()` +- `.AssertSuccessful()` (2xx) / `.AssertServerError()` (5xx) +- `.AssertHeader(key, value)` / `.AssertHeaderMissing(key)` +- `.AssertCookie(name, value)` / `.AssertCookieMissing(name)` +- `.AssertCookieExpired(name)` / `.AssertCookieNotExpired(name)` +- `.AssertJson(map[string]any)` - subset match +- `.AssertExactJson(map[string]any)` - exact match +- `.AssertJsonMissing(map[string]any)` +- `.AssertFluentJson(func(AssertableJSON))` - fluent chain +- `.AssertSee([]string, escape bool)` / `.AssertDontSee([]string, escape bool)` +- `.AssertSeeInOrder([]string, escape bool)` + +**TestResponse data:** + +- `.Content()` (string, error) +- `.Json()` (map[string]any, error) +- `.Cookies()` []\*http.Cookie +- `.Headers()` http.Header +- `.Session()` (map[string]any, error) + +**Mock factory (`mock.Factory()`):** + +- `.App()` / `.Artisan()` / `.Auth()` / `.Cache()` / `.Config()` / `.Crypt()` +- `.Event()` + `.EventTask()` +- `.Gate()` / `.Grpc()` / `.Hash()` / `.Lang()` / `.Log()` / `.Mail()` +- `.Orm()` + `.OrmQuery()` + `.OrmTransaction()` +- `.Queue()` + `.QueueTask()` +- `.Storage()` + `.StorageDriver()` +- `.Validation()` + `.ValidationValidator()` + `.ValidationErrors()` +- `.View()` + +**Docker testing (`facades.Testing().Docker()`):** + +- `.Database(driver?)` (DatabaseImage, error) - default driver from config +- `.Cache(driver?)` (CacheImage, error) +- `.Image(contractstesting.Image)` (Image, error) - custom image +- `.Build()` error - start container +- `.Ready()` error - wait until healthy +- `.Migrate()` error +- `.Seed(seeders ...Seeder)` error +- `.Config()` - get connection config (use after Build) +- `.Fresh()` error - drop+migrate (not safe for parallel) +- `.Shutdown()` error - stop container + +## Implementation Example + +```go +// tests/feature/user_test.go +package feature + +import ( + "testing" + "github.com/stretchr/testify/suite" + contractstesting "github.com/goravel/framework/contracts/testing" + "github.com/goravel/framework/support/http" + testinghttp "github.com/goravel/framework/testing/http" + "github.com/goravel/framework/testing/mock" + + "yourmodule/tests" + "yourmodule/app/facades" +) + +type UserTestSuite struct { + suite.Suite + tests.TestCase +} + +func TestUserSuite(t *testing.T) { suite.Run(t, new(UserTestSuite)) } + +func (s *UserTestSuite) SetupTest() { + s.RefreshDatabase() // fresh DB before each test +} + +func (s *UserTestSuite) TestIndex() { + resp, err := s.Http(s.T()). + WithHeader("Accept", "application/json"). + Get("/api/users") + + s.Nil(err) + resp.AssertOk() + resp.AssertFluentJson(func(json contractstesting.AssertableJSON) { + json.Has("data").Count("data", 0) + }) +} + +func (s *UserTestSuite) TestCreate() { + body := http.NewBody(). + SetField("name", "Alice"). + SetField("email", "alice@example.com") + built, _ := body.Build() + + resp, err := s.Http(s.T()). + WithHeader("Content-Type", built.ContentType()). + Post("/api/users", built) + + s.Nil(err) + resp.AssertCreated() + resp.AssertFluentJson(func(json contractstesting.AssertableJSON) { + json.Where("name", "Alice").Has("id").Missing("password") + }) +} + +// Unit test with mock +func TestUserServiceCache(t *testing.T) { + mockFactory := mock.Factory() + mockCache := mockFactory.Cache() + mockCache.On("Remember", "user:1", mock.Anything, mock.Anything). + Return(&User{ID: 1, Name: "Alice"}, nil).Once() + + svc := &UserService{} // uses facades.Cache() internally + user, err := svc.GetUser(1) + assert.Nil(t, err) + assert.Equal(t, "Alice", user.Name) + mockCache.AssertExpectations(t) +} + +// Docker test - tests/feature/main_test.go +func TestMain(m *testing.M) { + db, err := facades.Testing().Docker().Database() + if err != nil { panic(err) } + if err := db.Build(); err != nil { panic(err) } + if err := db.Ready(); err != nil { panic(err) } + if err := db.Migrate(); err != nil { panic(err) } + if err := facades.App().Restart(); err != nil { panic(err) } + + exit := m.Run() + db.Shutdown() + os.Exit(exit) +} +``` + +## Rules + +- Embed `tests.TestCase` (not `suite.Suite` alone) to get `Http()`, `Seed()`, `RefreshDatabase()`. +- `s.RefreshDatabase()` in `SetupTest()` drops and re-migrates before **each** test. +- `s.Http(s.T())` creates a new test HTTP client per call; chain builder methods before the verb. +- Post/Put/Patch body: use `http.NewBody().SetField(k,v).Build()` and set `Content-Type` from `built.ContentType()`. +- `AssertJson` is a **subset** match - extra fields in response are allowed. +- `AssertExactJson` requires **exact** match - no extra or missing fields. +- JSON numeric values come back as `float64` in Go - use `float64(1)` not `1` in assertions. +- Mock `Log` facade uses `fmt.Print` - it does not write real log files during tests. +- `mock.Factory()` replaces the global facade binding for that test scope automatically. +- Docker `Fresh()` is **not safe** for parallel tests - it drops all tables. +- Never use `t.Parallel()` alongside `facades.Http().Fake(...)` - global state, race conditions. +- Always `defer facades.Http().Reset()` in any test using `Fake`. +- Docker images auto-shutdown after 1 hour if `Shutdown()` is not called. +- `.env` in the test package directory overrides root `.env`; or pass `--env=.env.testing`. diff --git a/.ai/knowledge/validation.md b/.ai/knowledge/validation.md new file mode 100644 index 000000000..b4dc77f2e --- /dev/null +++ b/.ai/knowledge/validation.md @@ -0,0 +1,149 @@ +# Validation Facade + +## Core Imports + +```go +import ( + "context" + "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/contracts/validation" + validationpkg "github.com/goravel/framework/validation" + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/validation/validation.go` + +## Available Methods + +**Inline on request:** + +- `ctx.Request().Validate(rules map[string]string, opts ...Option)` (Validator, error) +- `ctx.Request().ValidateRequest(formRequest)` (Errors, error) + +**Manual:** + +- `facades.Validation().Make(ctx context.Context, data any, rules map[string]string, opts...Option)` (Validator, error) + - `ctx` is `context.Context` (use `ctx.Context()` from http.Context) + - `data` is `map[string]any` or struct + +**Validator:** + +- `Fails()` bool +- `Errors().One(key?)` string - first error for optional key +- `Errors().Get(key)` map[string]string - `map[ruleName]message` +- `Errors().All()` map[string]map[string]string - `map[field]map[rule]message` +- `Errors().Has(key)` bool +- `Bind(&dest)` error - populate struct from validated data + +## Implementation Example + +```go +// Inline validation +package controllers + +import ( + "github.com/goravel/framework/contracts/http" + validationpkg "github.com/goravel/framework/validation" + "yourmodule/app/facades" +) + +type PostController struct{} + +func (r *PostController) Store(ctx http.Context) http.Response { + validator, err := ctx.Request().Validate(map[string]string{ + "title": "required|max_len:255", + "body": "required", + "author.name": "required", // nested + "tags.*": "required|string", // slice elements + }) + if err != nil { + return ctx.Response().Json(http.StatusBadRequest, http.Json{"error": err.Error()}) + } + if validator.Fails() { + // All() returns map[field]map[rule]message + return ctx.Response().Json(http.StatusUnprocessableEntity, validator.Errors().All()) + } + return ctx.Response().Json(http.StatusCreated, http.Json{"ok": true}) +} + +// Form Request pattern +// app/http/requests/store_post_request.go +package requests + +import ( + "context" + "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/contracts/validation" +) + +type StorePostRequest struct { + Title string `form:"title" json:"title"` + Body string `form:"body" json:"body"` +} + +func (r *StorePostRequest) Authorize(ctx http.Context) error { return nil } + +func (r *StorePostRequest) Rules(ctx http.Context) map[string]string { + return map[string]string{ + "title": "required|max_len:255", + "body": "required", + } +} +// Optional - detected by interface assertion, no explicit declaration needed: +func (r *StorePostRequest) Messages(ctx http.Context) map[string]string { + return map[string]string{"title.required": "Title is required"} +} +func (r *StorePostRequest) Attributes(ctx http.Context) map[string]string { + return map[string]string{"title": "post title"} +} +func (r *StorePostRequest) Filters(ctx http.Context) map[string]string { + return map[string]string{"title": "trim"} +} +func (r *StorePostRequest) PrepareForValidation(ctx http.Context, data validation.Data) error { + if title, ok := data.Get("title"); ok { + return data.Set("title", title.(string)+" [processed]") + } + return nil +} + +// Usage: +// var req requests.StorePostRequest +// errs, err := ctx.Request().ValidateRequest(&req) +// fmt.Println(req.Title) // auto-bound after validation + +// Manual with options: +// validator, _ := facades.Validation().Make( +// ctx.Context(), // context.Context, not http.Context +// map[string]any{"name": "Goravel"}, +// map[string]string{"name": "required|max_len:50"}, +// validationpkg.Messages(map[string]string{"name.required": "Name is required"}), +// ) +``` + +## Rules + +- `facades.Validation().Make(ctx, ...)` - first arg is `context.Context`, use `ctx.Context()`. +- `Errors.Get(key)` returns `map[string]string` (rule -> message), NOT `[]string`. +- `Errors.All()` returns `map[string]map[string]string` (field -> rule -> message), NOT `map[string][]string`. +- `Errors.One()` with no args returns first error of any field; `One("field")` returns first error of that field. +- Custom `Rule.Passes(ctx context.Context, ...)` - `ctx` is `context.Context`, not `http.Context`. +- Custom `Rule.Message(ctx context.Context)` - `ctx` is `context.Context`. +- Custom `Filter.Handle(ctx context.Context) any` - must return a function (`func(val T) U`). +- FormRequest optional interfaces (`WithFilters`, `WithMessages`, `WithAttributes`, `WithPrepareForValidation`) - detected by interface assertion, not embedded. +- `ValidateRequest` auto-binds validated fields to struct; struct tags: `form:` or `json:`. +- Nested fields use dot notation: `"author.name"`. Slice element rules use `*`: `"tags.*"`. +- Register custom rules via `WithRules` and filters via `WithFilters` in `bootstrap/app.go`. + +**Built-in rules (pipe-separated):** +`required`, `required_if:field,val`, `required_unless:field,val`, `required_with:fields`, +`required_without:fields`, `int[:min[,max]]`, `uint`, `bool`, `string[:min[,max]]`, `float`, +`in:a,b,c`, `not_in:a,b,c`, `between:min,max`, `min:val`, `max:val`, `eq:val`, `ne:val`, +`lt:val`, `gt:val`, `len:val`, `min_len:val`, `max_len:val`, `email`, `array`, `map`, +`file`, `image`, `date`, `gt_date:val`, `lt_date:val`, `alpha`, `alpha_num`, `alpha_dash`, +`json`, `number`, `full_url`, `ip`, `ipv4`, `ipv6`, `regex:pattern`, `uuid`, `uuid3`, +`uuid4`, `uuid5`, `starts_with:val`, `ends_with:val` diff --git a/.ai/knowledge/view.md b/.ai/knowledge/view.md new file mode 100644 index 000000000..f1a13ad3b --- /dev/null +++ b/.ai/knowledge/view.md @@ -0,0 +1,115 @@ +# View Facade + +## Core Imports + +```go +import ( + "github.com/goravel/framework/contracts/http" + "yourmodule/app/facades" +) +``` + +## Contracts + +Fetch these files for exact, always-current type definitions: + +- `https://raw.githubusercontent.com/goravel/framework/refs/heads/master/contracts/view/view.go` + +## Available Methods + +**ctx.Response().View():** + +- `Make(viewName, data?)` http.Response - render template; returns full http.Response +- `First([]string, data?)` http.Response - render first existing view from list + +**facades.View():** + +- `Exist(name)` bool - check if template exists +- `Share(key, value)` - share data with all views (call in `WithCallback`) +- `GetShared()` map[string]any - get all shared data + +## Implementation Example + +```go +// resources/views/welcome.tmpl +// {{ define "welcome.tmpl" }} +//

Hello, {{ .name }}!

+// {{ end }} + +// resources/views/admin/dashboard.tmpl +// {{ define "admin/dashboard.tmpl" }} +//

Admin: {{ .appName }}

+// {{ end }} + +// bootstrap/app.go - share global data +// WithCallback(func() { +// facades.View().Share("appName", "MyApp") +// facades.View().Share("version", "1.0") +// }) + +package controllers + +import ( + "github.com/goravel/framework/contracts/http" + "yourmodule/app/facades" +) + +type PageController struct{} + +func (r *PageController) Welcome(ctx http.Context) http.Response { + return ctx.Response().View().Make("welcome.tmpl", map[string]any{ + "name": ctx.Request().Query("name", "Guest"), + }) +} + +func (r *PageController) Dashboard(ctx http.Context) http.Response { + return ctx.Response().View().Make("admin/dashboard.tmpl", map[string]any{ + "user": "Alice", + }) +} + +func (r *PageController) TryViews(ctx http.Context) http.Response { + // Try custom theme first, fall back to default + return ctx.Response().View().First( + []string{"themes/custom/welcome.tmpl", "welcome.tmpl"}, + map[string]any{"name": "Guest"}, + ) +} + +func (r *PageController) CheckView(ctx http.Context) http.Response { + if facades.View().Exist("maintenance.tmpl") { + return ctx.Response().View().Make("maintenance.tmpl") + } + return ctx.Response().View().Make("welcome.tmpl") +} +``` + +### CSRF Protection + +```go +// Register middleware globally or per-route +import "github.com/goravel/framework/http/middleware" + +handler.Append(middleware.VerifyCsrfToken([]string{ + "api/*", // exclude these paths from CSRF check + "webhook/*", +})) + +// In template - include the injected csrf_token variable +// +// Or in AJAX headers: X-CSRF-TOKEN: {{ .csrf_token }} +``` + +## Rules + +- Template `define` name **must** match the string passed to `Make` exactly - including subdirectory path. + - `Make("admin/dashboard.tmpl")` requires `{{ define "admin/dashboard.tmpl" }}` in the file. +- Default template engine: `html/template`; files use `.tmpl` extension. +- Default views directory: `resources/views/` - configurable via `paths.Resources("dir")` in `WithPaths`. +- `Share` data is global to all views; per-request data is passed via `Make`'s second argument - per-request data takes precedence on key collision. +- Call `facades.View().Share(...)` in `WithCallback`, not in `Register` or `Boot` of a provider. +- `View` facade is new in v1.17 - register `&view.ServiceProvider{}` in providers. +- CSRF token is auto-injected as `csrf_token` into view data when `VerifyCsrfToken` middleware is active. +- `VerifyCsrfToken` excludes paths by glob pattern; always exclude API and webhook endpoints. +- Custom template engine (Gin): configure `"template"` key in `config/http.go` with `gin.NewTemplate(...)`. +- `make:view welcome` generates `resources/views/welcome.tmpl` scaffold. diff --git a/.ai/prompt/artisan.md b/.ai/prompt/artisan.md deleted file mode 100644 index 866b775f2..000000000 --- a/.ai/prompt/artisan.md +++ /dev/null @@ -1,324 +0,0 @@ -# Goravel Artisan Console & Task Scheduling - -## Command Structure - -```go -package commands - -import ( - "github.com/goravel/framework/contracts/console" - "github.com/goravel/framework/contracts/console/command" -) - -type SendEmails struct{} - -func (r *SendEmails) Signature() string { - return "send:emails" -} - -func (r *SendEmails) Description() string { - return "Send emails" -} - -func (r *SendEmails) Extend() command.Extend { - return command.Extend{ - Category: "mail", - } -} - -func (r *SendEmails) Handle(ctx console.Context) error { - return nil -} -``` - -Generate: - -```shell -./artisan make:command SendEmails -./artisan make:command user/SendEmails -``` - -Register in `bootstrap/app.go`: - -```go -WithCommands(Commands) -``` - ---- - -## Arguments (v1.17) - -```go -func (r *SendEmails) Extend() command.Extend { - return command.Extend{ - Arguments: []command.Argument{ - &command.ArgumentString{ - Name: "subject", - Usage: "subject of email", - Required: true, - }, - &command.ArgumentStringSlice{ - Name: "emails", - Usage: "target emails", - Min: 1, - Max: -1, // -1 = unlimited - }, - }, - } -} - -func (r *SendEmails) Handle(ctx console.Context) error { - subject := ctx.ArgumentString("subject") - emails := ctx.ArgumentStringSlice("emails") - - // Or by index - first := ctx.Argument(0) - all := ctx.Arguments() - - return nil -} -``` - -Supported argument types: `ArgumentString`, `ArgumentInt`, `ArgumentInt64`, `ArgumentFloat64`, `ArgumentUint`, `ArgumentTimestamp`, `ArgumentStringSlice`, `ArgumentIntSlice`, `ArgumentInt64Slice`, `ArgumentFloat64Slice`, `ArgumentUintSlice`, `ArgumentTimestampSlice`, and more. - ---- - -## Options (Flags) - -```go -func (r *ListCommand) Extend() command.Extend { - return command.Extend{ - Flags: []command.Flag{ - &command.StringFlag{ - Name: "lang", - Value: "default", - Aliases: []string{"l"}, - Usage: "language for the greeting", - }, - &command.BoolFlag{ - Name: "verbose", - Usage: "enable verbose output", - }, - }, - } -} - -func (r *ListCommand) Handle(ctx console.Context) error { - lang := ctx.Option("lang") - verbose := ctx.OptionBool("verbose") - return nil -} -``` - -Usage: - -```shell -./artisan emails --lang Chinese -./artisan emails -l Chinese -``` - -Other flag types: `StringSliceFlag`, `BoolFlag`, `Float64Flag`, `Float64SliceFlag`, `IntFlag`, `IntSliceFlag`, `Int64Flag`, `Int64SliceFlag`. - ---- - -## Interactive Input - -```go -// Ask (text input) -email, err := ctx.Ask("What is your email address?") -name, err := ctx.Ask("What is your name?", console.AskOption{ - Default: "Goravel", - Placeholder: "Enter name", - Limit: 100, - Validate: func(s string) error { return nil }, -}) - -// Secret (hidden input) -password, err := ctx.Secret("What is the password?", console.SecretOption{ - Validate: func(s string) error { - if len(s) < 8 { - return errors.New("password must be at least 8 characters") - } - return nil - }, -}) - -// Confirm -if ctx.Confirm("Do you wish to continue?") { - // ... -} -if ctx.Confirm("Do you wish to continue?", console.ConfirmOption{ - Default: true, Affirmative: "Yes", Negative: "No", -}) { - // ... -} - -// Single select -color, err := ctx.Choice("Favorite language?", []console.Choice{ - {Key: "go", Value: "Go"}, - {Key: "php", Value: "PHP", Selected: true}, -}) - -// Multi-select -colors, err := ctx.MultiSelect("Favorite languages?", []console.Choice{ - {Key: "go", Value: "Go"}, - {Key: "php", Value: "PHP"}, -}, console.MultiSelectOption{ - Default: []string{"go"}, - Filterable: true, - Limit: 3, -}) -``` - ---- - -## Output - -```go -ctx.Info("Info message") -ctx.Comment("Comment message") -ctx.Warning("Warning message") -ctx.Error("Error message") -ctx.Line("Line message") - -ctx.Green("green text") -ctx.Greenln("green line") -ctx.Red("red text") -ctx.Yellow("yellow text") -ctx.Black("black text") - -ctx.NewLine() -ctx.NewLine(2) -ctx.Divider() -ctx.Divider("=>") -``` - -### Progress Bars - -```go -items := []any{"item1", "item2", "item3"} -_, err := ctx.WithProgressBar(items, func(item any) error { - // process item - return nil -}) - -// Manual progress bar -bar := ctx.CreateProgressBar(len(items)) -bar.Start() -for _, item := range items { - // process - bar.Advance() - time.Sleep(50 * time.Millisecond) -} -bar.Finish() -``` - -### Spinner - -```go -err := ctx.Spinner("Loading...", console.SpinnerOption{ - Action: func() error { - time.Sleep(2 * time.Second) - return nil - }, -}) -``` - ---- - -## Call Artisan Commands Programmatically - -```go -facades.Artisan().Call("send:emails") -facades.Artisan().Call("send:emails --lang Chinese name") -``` - ---- - -## Disable Colors - -```shell -./artisan list --no-ansi -``` - ---- - -## Task Scheduling - -Define in `WithSchedule` in `bootstrap/app.go`: - -```go -WithSchedule(func() []schedule.Event { - return []schedule.Event{ - // Closure task - facades.Schedule().Call(func() { - facades.Orm().Query().Where("1 = 1").Delete(&models.User{}) - }).Daily(), - - // Artisan command task - facades.Schedule().Command("send:emails name").EveryMinute(), - - // Named closure for OnOneServer - facades.Schedule().Call(func() { - fmt.Println("goravel") - }).Daily().OnOneServer().Name("goravel"), - } -}) -``` - -### Frequency Methods - -| Method | Frequency | -|--------|-----------| -| `.Cron("* * * * *")` | Custom cron (minutes) | -| `.Cron("* * * * * *")` | Custom cron (seconds) | -| `.EverySecond()` | Every second | -| `.EveryTwoSeconds()` | Every 2 seconds | -| `.EveryFiveSeconds()` | Every 5 seconds | -| `.EveryTenSeconds()` | Every 10 seconds | -| `.EveryFifteenSeconds()` | Every 15 seconds | -| `.EveryThirtySeconds()` | Every 30 seconds | -| `.EveryMinute()` | Every minute | -| `.EveryTwoMinutes()` | Every 2 minutes | -| `.EveryFiveMinutes()` | Every 5 minutes | -| `.EveryTenMinutes()` | Every 10 minutes | -| `.EveryFifteenMinutes()` | Every 15 minutes | -| `.EveryThirtyMinutes()` | Every 30 minutes | -| `.Hourly()` | Every hour | -| `.HourlyAt(17)` | Every hour at :17 | -| `.EveryTwoHours()` | Every 2 hours | -| `.EverySixHours()` | Every 6 hours | -| `.Daily()` | Daily at midnight | -| `.DailyAt("13:00")` | Daily at 13:00 | -| `.Days(1, 3, 5)` | Mon, Wed, Fri | -| `.Weekdays()` | Mon–Fri | -| `.Weekends()` | Sat–Sun | -| `.Weekly()` | Weekly | -| `.Monthly()` | Monthly | -| `.Quarterly()` | Quarterly | -| `.Yearly()` | Yearly | - -### Overlap Prevention - -```go -facades.Schedule().Command("send:emails name").EveryMinute().SkipIfStillRunning() -facades.Schedule().Command("send:emails name").EveryMinute().DelayIfStillRunning() -``` - -### Single Server Execution - -Requires memcached, dynamodb, or redis as default cache driver: - -```go -facades.Schedule().Command("report:generate").Daily().OnOneServer() - -// Named closure for OnOneServer: -facades.Schedule().Call(func() {}).Daily().OnOneServer().Name("unique-name") -``` - -### Run Scheduler Manually - -```shell -./artisan schedule:run -./artisan schedule:list -``` diff --git a/.ai/prompt/auth.md b/.ai/prompt/auth.md deleted file mode 100644 index f8fb06adf..000000000 --- a/.ai/prompt/auth.md +++ /dev/null @@ -1,390 +0,0 @@ -# Goravel Authentication, Authorization, and Hashing - -## Authentication Setup - -Configure guards in `config/auth.go` and JWT parameters in `config/jwt.go`. - -Generate JWT secret: - -```shell -./artisan jwt:secret -``` - ---- - -## JWT Authentication - -### Login with model - -```go -import "goravel/app/facades" - -var user models.User -user.ID = 1 - -token, err := facades.Auth(ctx).Login(&user) -``` - -Model must have a primary key. If the model does not embed `orm.Model`, add a `primaryKey` tag: - -```go -type User struct { - ID uint `gorm:"primaryKey"` - Name string -} -``` - -### Login with ID - -```go -token, err := facades.Auth(ctx).LoginUsingID(1) -``` - -### Parse token - -```go -payload, err := facades.Auth(ctx).Parse(token) -``` - -`payload` fields: -- `Guard` - current guard name -- `Key` - user identifier -- `ExpireAt` - expiration time -- `IssuedAt` - issue time - -Check if token is expired: - -```go -import ( - "errors" - "github.com/goravel/framework/auth" -) - -if errors.Is(err, auth.ErrorTokenExpired) { - // token expired, allow refresh -} -``` - -Token can be passed with or without the `Bearer` prefix. - -### Get authenticated user - -Must call `Parse` first (typically in middleware): - -```go -var user models.User -err := facades.Auth(ctx).User(&user) - -id, err := facades.Auth(ctx).ID() -``` - -### Refresh token - -Requires a prior `Parse` call: - -```go -token, err := facades.Auth(ctx).Refresh() -``` - -### Logout - -```go -err := facades.Auth(ctx).Logout() -``` - ---- - -## Multiple Guards - -Configure guards in `config/auth.go`: - -```go -"guards": map[string]any{ - "user": map[string]any{ - "driver": "jwt", - }, - "admin": map[string]any{ - "driver": "jwt", - "ttl": 60, - "refresh_ttl": 0, - "secret": "admin-secret", - }, -}, -``` - -Use a specific guard (must call `Guard` before any other method when not using the default): - -```go -token, err := facades.Auth(ctx).Guard("admin").LoginUsingID(1) -err := facades.Auth(ctx).Guard("admin").Parse(token) - -var admin models.Admin -err := facades.Auth(ctx).Guard("admin").User(&admin) -``` - ---- - -## Auth Middleware Pattern - -```go -package middleware - -import ( - "strings" - - "github.com/goravel/framework/contracts/http" - "goravel/app/facades" -) - -func Auth() http.Middleware { - return func(ctx http.Context) { - header := ctx.Request().Header("Authorization", "") - token := strings.TrimPrefix(header, "Bearer ") - - payload, err := facades.Auth(ctx).Parse(token) - if err != nil { - ctx.Response().String(http.StatusUnauthorized, "unauthorized").Abort() - return - } - _ = payload - ctx.Request().Next() - } -} -``` - ---- - -## Custom Guard Driver - -Register in the `Boot` method of a service provider: - -```go -import ( - "github.com/goravel/framework/contracts/auth" - contractshttp "github.com/goravel/framework/contracts/http" -) - -func (receiver *AuthServiceProvider) Boot(app foundation.Application) { - facades.Auth().Extend("custom-driver", func(ctx contractshttp.Context, name string, userProvider auth.UserProvider) (auth.GuardDriver, error) { - return &CustomGuard{}, nil - }) -} -``` - -Reference in `config/auth.go`: - -```go -"guards": map[string]any{ - "api": map[string]any{ - "driver": "custom-driver", - "provider": "users", - }, -}, -``` - ---- - -## Custom UserProvider - -```go -facades.Auth().Provider("custom-provider", func(ctx contractshttp.Context) (auth.UserProvider, error) { - return &UserProvider{}, nil -}) -``` - -Reference in `config/auth.go`: - -```go -"providers": map[string]any{ - "users": map[string]any{ - "driver": "custom-provider", - }, -}, - -"guards": map[string]any{ - "api": map[string]any{ - "driver": "jwt", - "provider": "users", - }, -}, -``` - ---- - -## Authorization - Gates - -### Define a gate - -Gates are defined inside `WithCallback` in `bootstrap/app.go`: - -```go -import ( - "context" - "github.com/goravel/framework/auth/access" - contractsaccess "github.com/goravel/framework/contracts/auth/access" -) - -WithCallback(func() { - facades.Gate().Define("update-post", - func(ctx context.Context, arguments map[string]any) contractsaccess.Response { - user := ctx.Value("user").(models.User) - post := arguments["post"].(models.Post) - - if user.ID == post.UserID { - return access.NewAllowResponse() - } - return access.NewDenyResponse("You do not own this post.") - }, - ) -}) -``` - -### Check a gate - -```go -if facades.Gate().Allows("update-post", map[string]any{"post": post}) { - // authorized -} - -if facades.Gate().Denies("update-post", map[string]any{"post": post}) { - // denied -} - -// Check multiple abilities -if facades.Gate().Any([]string{"update-post", "delete-post"}, map[string]any{"post": post}) { - // can do at least one -} - -if facades.Gate().None([]string{"update-post", "delete-post"}, map[string]any{"post": post}) { - // can do none -} -``` - -### Full response - -```go -response := facades.Gate().Inspect("update-post", map[string]any{"post": post}) - -if response.Allowed() { - // authorized -} else { - fmt.Println(response.Message()) -} -``` - -### Inject context into gate - -```go -facades.Gate().WithContext(ctx).Allows("update-post", map[string]any{"post": post}) -``` - -### Before / After hooks - -```go -facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response { - user := ctx.Value("user").(models.User) - if isAdministrator(user) { - return access.NewAllowResponse() - } - return nil // nil means continue to next check -}) - -facades.Gate().After(func(ctx context.Context, ability string, arguments map[string]any, result contractsaccess.Response) contractsaccess.Response { - user := ctx.Value("user").(models.User) - if isAdministrator(user) { - return access.NewAllowResponse() - } - return nil -}) -``` - -Note: `After` result only applies when `Define` returns `nil`. - ---- - -## Authorization - Policies - -### Generate policy - -```shell -./artisan make:policy PostPolicy -./artisan make:policy user/PostPolicy -``` - -### Write policy - -```go -package policies - -import ( - "context" - - "github.com/goravel/framework/auth/access" - contractsaccess "github.com/goravel/framework/contracts/auth/access" - "goravel/app/models" -) - -type PostPolicy struct{} - -func NewPostPolicy() *PostPolicy { - return &PostPolicy{} -} - -func (r *PostPolicy) Update(ctx context.Context, arguments map[string]any) contractsaccess.Response { - user := ctx.Value("user").(models.User) - post := arguments["post"].(models.Post) - - if user.ID == post.UserID { - return access.NewAllowResponse() - } - return access.NewDenyResponse("You do not own this post.") -} -``` - -### Register policy - -Register by mapping the policy method to a gate name inside `WithCallback`: - -```go -WithCallback(func() { - facades.Gate().Define("update-post", policies.NewPostPolicy().Update) - facades.Gate().Define("delete-post", policies.NewPostPolicy().Delete) -}) -``` - ---- - -## Hashing - -### Hash a password - -```go -hashed, err := facades.Hash().Make(password) -``` - -### Verify a password - -```go -if facades.Hash().Check("plain-text-password", hashedPassword) { - // passwords match -} -``` - -### Check if rehash needed - -```go -if facades.Hash().NeedsRehash(hashed) { - hashed, err = facades.Hash().Make("plain-text-password") -} -``` - -Configure the hashing driver (argon2id or bcrypt) in `config/hashing.go`. - ---- - -## Gotchas - -- `facades.Auth(ctx).User(&user)` requires a prior `Parse(token)` call on the same context. Without parsing, the user struct remains empty. -- When using a non-default guard, always chain `.Guard("name")` before `Login`, `Parse`, `User`, `ID`, `Refresh`, and `Logout`. -- Gate `Before` returning `nil` does not deny the action; it allows other checks to proceed. Return a response from `access.NewDenyResponse()` to explicitly deny. -- Policy methods and gate closures receive the same arguments map. Keys must match what you pass to `Allows`. diff --git a/.ai/prompt/best-practices.md b/.ai/prompt/best-practices.md deleted file mode 100644 index e1dc83dbb..000000000 --- a/.ai/prompt/best-practices.md +++ /dev/null @@ -1,992 +0,0 @@ -# Goravel Best Practices - -These are required patterns for correct, idiomatic Goravel code. Violations produce bugs, security holes, or poor performance. - ---- - -## Naming Conventions - -| Thing | Convention | Example | -|-------|-----------|---------| -| Model struct | PascalCase singular | `User`, `OrderItem` | -| Table name | snake_case plural (auto-derived) | `users`, `order_items` | -| Foreign key | `{model_name}_id` snake_case | `user_id`, `order_item_id` | -| Relationship field | PascalCase matching model | `Posts []*Post`, `Author *Author` | -| Controller | PascalCase + Controller suffix | `UserController`, `OrderController` | -| Middleware | PascalCase function returning `http.Middleware` | `func Auth() http.Middleware` | -| Event | PascalCase past-tense noun | `OrderShipped`, `UserRegistered` | -| Listener | PascalCase action verb phrase | `SendShipmentNotification` | -| Job | PascalCase noun | `ProcessPodcast`, `SendWelcomeEmail` | -| Command signature | `category:action` kebab | `send:emails`, `report:generate` | -| Config key | dot-separated snake_case | `app.name`, `database.default` | -| Route name | `resource.action` dot-separated | `users.index`, `posts.show` | - -Model to table name rule: `UserOrder` -> `user_orders`. Goravel pluralizes automatically using the snake_case of the struct name. - ---- - -## Use Contract Interfaces, Not Concrete Types - -Goravel's `contracts/` package defines interfaces for every facade. Type against the interface so code is testable and decoupled from the implementation. - -```go -import ( - contractsorm "github.com/goravel/framework/contracts/database/orm" - contractshttp "github.com/goravel/framework/contracts/http" - contractslog "github.com/goravel/framework/contracts/log" -) - -// WRONG: depends on a concrete type, hard to mock -type UserService struct { - db *gorm.DB -} - -// CORRECT: depends on the Goravel ORM contract -type UserService struct { - orm contractsorm.Orm -} - -func NewUserService(orm contractsorm.Orm) *UserService { - return &UserService{orm: orm} -} -``` - -When resolving from the service container, use the typed Make helpers: - -```go -// In a service provider Boot or Register -orm := app.MakeOrm() // returns contracts/database/orm.Orm -config := app.MakeConfig() // returns contracts/config.Config -route := app.MakeRoute() // returns contracts/route.Route -auth := app.MakeAuth(ctx) // returns contracts/auth.Auth -cache := app.MakeCache() // returns contracts/cache.Cache -``` - -Common contract import paths: - -```go -contractsorm "github.com/goravel/framework/contracts/database/orm" -contractshttp "github.com/goravel/framework/contracts/http" -contractslog "github.com/goravel/framework/contracts/log" -contractsconfig "github.com/goravel/framework/contracts/config" -contractscache "github.com/goravel/framework/contracts/cache" -contractsqueue "github.com/goravel/framework/contracts/queue" -contractsevent "github.com/goravel/framework/contracts/event" -contractsauth "github.com/goravel/framework/contracts/auth" -contractsstorage "github.com/goravel/framework/contracts/filesystem" -contractsschedule "github.com/goravel/framework/contracts/schedule" -``` - ---- - -## ORM / Database - -### Always eager load to avoid N+1 - -```go -// WRONG: N+1 (1 query for books, 1 per book for author) -var books []models.Book -facades.Orm().Query().Find(&books) -for _, book := range books { - facades.Orm().Query().Find(&author, book.AuthorID) -} - -// CORRECT: 2 queries total -facades.Orm().Query().With("Author").Find(&books) - -// Multiple relationships -facades.Orm().Query().With("Author").With("Publisher").Find(&books) - -// Nested -facades.Orm().Query().With("Author.Contacts").Find(&books) - -// Constrained eager load -facades.Orm().Query().With("Author", func(query orm.Query) orm.Query { - return query.Where("active = ?", true) -}).Find(&books) -``` - -### Use FindOrFail when missing = error - -```go -// WRONG: Find returns nil error even when record not found -var user models.User -err := facades.Orm().Query().Find(&user, 1) -// err is nil even if no record; user is zero-value struct - -// CORRECT: FindOrFail errors when not found -err := facades.Orm().Query().FindOrFail(&user, 1) -if err != nil { - return ctx.Response().Json(http.StatusNotFound, http.Json{"error": "not found"}) -} -``` - -### Use map[string]any to update zero values - -```go -// WRONG: struct update skips zero-value fields (false, 0, "") -facades.Orm().Query().Save(&user) // won't clear fields set to zero - -// CORRECT: use map to explicitly set zero values -facades.Orm().Query().Model(&user).Update(map[string]any{ - "active": false, - "score": 0, - "name": "", -}) -``` - -### Always use transactions for multiple writes - -```go -// CORRECT: wrap related writes in a transaction -err := facades.Orm().Transaction(func(tx orm.Transaction) error { - if err := tx.Create(&order); err != nil { - return err // auto-rollback - } - if err := tx.Create(&orderItem); err != nil { - return err // auto-rollback - } - return nil // auto-commit -}) -``` - -### Use Chunk for large datasets: never load all rows - -```go -// WRONG: loads entire table into memory -var users []models.User -facades.Orm().Query().Find(&users) - -// CORRECT: process in batches -facades.Orm().Query().Chunk(100, func(users []models.User) bool { - for _, user := range users { - // process - } - return true // return false to stop -}) -``` - -### Index foreign keys and frequently filtered columns - -Every foreign key column must have a database index. Add in migration: - -```go -table.Index("user_id") -table.Index("status", "created_at") // composite for common filter+sort -table.Unique("email") -``` - -### Use soft deletes for recoverable data - -```go -type User struct { - orm.Model - Name string - orm.SoftDeletes // adds deleted_at; Delete() sets it, doesn't remove row -} - -// Query includes soft-deleted -facades.Orm().Query().WithTrashed().Find(&users) - -// Hard delete -facades.Orm().Query().ForceDelete(&user) -``` - -### GlobalScopes must return map, not slice - -```go -// WRONG -func (u *User) GlobalScopes() []func(orm.Query) orm.Query { ... } - -// CORRECT -func (u *User) GlobalScopes() map[string]func(orm.Query) orm.Query { - return map[string]func(orm.Query) orm.Query{ - "active": func(query orm.Query) orm.Query { - return query.Where("active = ?", true) - }, - } -} - -// Disable specific scope -facades.Orm().Query().WithoutGlobalScope("active").Find(&users) - -// Disable all scopes -facades.Orm().Query().WithoutGlobalScopes().Find(&users) -``` - -### Sum takes a destination pointer, not a return value - -```go -// WRONG (old) -total, err := facades.Orm().Query().Sum("amount") - -// CORRECT -var total float64 -err := facades.Orm().Query().Sum("amount", &total) -``` - ---- - -## Controllers / HTTP - -### Always return http.Response: never return nil - -```go -// WRONG: missing return, compile error -func (r *UserController) Show(ctx http.Context) http.Response { - // forgot return -} - -// CORRECT -func (r *UserController) Show(ctx http.Context) http.Response { - return ctx.Response().Json(http.StatusOK, http.Json{"id": 1}) -} -``` - -### Validate all input before using it - -```go -// CORRECT: validate first, use after -func (r *UserController) Store(ctx http.Context) http.Response { - validator, err := ctx.Request().Validate(map[string]string{ - "name": "required|max_len:100", - "email": "required|email", - }) - if err != nil { - return ctx.Response().Json(http.StatusBadRequest, http.Json{"error": err.Error()}) - } - if validator.Fails() { - return ctx.Response().Json(http.StatusUnprocessableEntity, validator.Errors().All()) - } - - var user models.User - if err := validator.Bind(&user); err != nil { - return ctx.Response().Json(http.StatusBadRequest, http.Json{"error": err.Error()}) - } - // use user safely -} -``` - -### Use response.Bind, not Request.Bind - -```go -// WRONG -resp, _ := facades.Http().Get(url) -resp.Request.Bind(&dest) // compile error - -// CORRECT -resp, err := facades.Http().Get(url) -if err != nil { - return -} -if err := resp.Bind(&dest); err != nil { - return -} -``` - -### Use typed input methods to avoid string parsing - -```go -// WRONG: manual type conversion -id := ctx.Request().Input("id") // string -idInt, _ := strconv.Atoi(id) - -// CORRECT: typed methods -id := ctx.Request().RouteInt("id") -page := ctx.Request().QueryInt("page", 1) -active := ctx.Request().InputBool("active") -``` - -### Do not leak internal errors to HTTP responses - -```go -// WRONG: exposes internal detail -if err := facades.Orm().Query().Find(&user, id); err != nil { - return ctx.Response().Json(500, http.Json{"error": err.Error()}) -} - -// CORRECT: log internally, return generic message -if err := facades.Orm().Query().FindOrFail(&user, id); err != nil { - facades.Log().WithContext(ctx.Context()).Error(err) - return ctx.Response().Json(http.StatusNotFound, http.Json{"error": "resource not found"}) -} -``` - -### Set custom panic recovery - -```go -// bootstrap/app.go -WithMiddleware(func(handler configuration.Middleware) { - handler.Append( - middleware.StartSession(), - ).Recover(func(ctx http.Context, err any) { - facades.Log().Error(err) - _ = ctx.Response().String(http.StatusInternalServerError, "internal server error").Abort() - }) -}) -``` - ---- - -## Security - -### Never store raw passwords - -```go -// WRONG -user.Password = req.Password - -// CORRECT -hashed, err := facades.Hash().Make(req.Password) -if err != nil { - return err -} -user.Password = hashed - -// Verify -valid := facades.Hash().Check(req.Password, user.Password) -``` - -### Call Parse before accessing Auth user - -```go -// WRONG: User/ID return empty if Parse not called -userID, _ := facades.Auth(ctx).ID() - -// CORRECT -token := ctx.Request().Header("Authorization") -payload, err := facades.Auth(ctx).Parse(token) -if err != nil { - return ctx.Response().Json(http.StatusUnauthorized, http.Json{"error": "invalid token"}) -} -userID, _ := facades.Auth(ctx).ID() -``` - -### Regenerate session ID after login (prevent session fixation) - -```go -func (r *AuthController) Login(ctx http.Context) http.Response { - // ... authenticate user ... - - facades.Auth(ctx).Login(&user) - - // Must regenerate session ID after login - ctx.Request().Session().Regenerate() - - return ctx.Response().Json(http.StatusOK, http.Json{"message": "logged in"}) -} -``` - -### Use rate limiting on auth endpoints - -```go -// bootstrap/app.go -WithCallback(func() { - facades.RateLimiter().For("login", func(ctx http.Context) http.Limit { - return limit.PerMinute(5).By(ctx.Request().Ip()) - }) -}) - -// routes -facades.Route().Middleware(middleware.Throttle("login")).Post("/login", authController.Login) -``` - -### Use CSRF for web (non-API) routes - -```go -import "github.com/goravel/framework/http/middleware" - -// Global for all web routes -handler.Append(middleware.VerifyCsrfToken([]string{ - "api/*", // except API routes - "webhook/*", // except webhooks -})) -``` - -### Never put secrets in config values directly: use .env - -```go -// WRONG -"api_key": "sk-abc123...", - -// CORRECT -"api_key": config.Env("STRIPE_API_KEY", ""), -``` - ---- - -## Service Container / Providers - -### Only bind in Register: never use facades in Register - -```go -// WRONG: facades not yet booted during Register -func (r *ServiceProvider) Register(app foundation.Application) { - facades.Log().Info("registering") // PANIC: Log not ready - app.Singleton("myservice", func(app foundation.Application) (any, error) { - return NewMyService(), nil - }) -} - -// CORRECT -func (r *ServiceProvider) Register(app foundation.Application) { - app.Singleton("myservice", func(app foundation.Application) (any, error) { - return NewMyService(app.MakeConfig()), nil // use app.Make*, not facades - }) -} - -func (r *ServiceProvider) Boot(app foundation.Application) { - facades.Log().Info("provider booted") // safe here - route := app.MakeRoute() - route.Get("/health", healthController.Check) -} -``` - -### Use Singleton for stateless, Bind for stateful - -```go -// Stateless service (DB connection, config reader) -> Singleton -app.Singleton("db", func(app foundation.Application) (any, error) { - return NewDB(app.MakeConfig()), nil -}) - -// Stateful / per-request -> Bind (new instance each call) -app.Bind("request.context", func(app foundation.Application) (any, error) { - return NewRequestContext(), nil -}) -``` - -### Use WithCallback for post-boot setup - -```go -// Gates, rate limiters, observers: all require facades to be ready -WithCallback(func() { - facades.Gate().Define("edit-post", func(ctx context.Context, args map[string]any) access.Response { - user := ctx.Value("user").(models.User) - post := args["post"].(models.Post) - if user.ID == post.UserID { - return access.NewAllowResponse() - } - return access.NewDenyResponse("forbidden") - }) - - facades.Orm().Observe(&models.User{}, &observers.UserObserver{}) - facades.RateLimiter().For("api", func(ctx http.Context) http.Limit { - return limit.PerMinute(60).By(ctx.Request().Ip()) - }) -}) -``` - ---- - -## Middleware - -### Middleware is a function type, not a struct - -```go -// WRONG -type AuthMiddleware struct{} -func (m *AuthMiddleware) Handle(ctx http.Context, next http.HandlerFunc) http.Response { ... } - -// CORRECT -func Auth() http.Middleware { - return func(ctx http.Context) { - token := ctx.Request().Header("Authorization", "") - if token == "" { - ctx.Request().AbortWithStatus(http.StatusUnauthorized) - return - } - // validate... - } -} -``` - -### Always call next or Abort: never skip both - -```go -func MyMiddleware() http.Middleware { - return func(ctx http.Context) { - if !valid(ctx) { - ctx.Request().AbortWithStatus(http.StatusForbidden) - return // stop here - } - // returning without AbortWithStatus lets the framework continue to the next handler - } -} -``` - -### Register global middleware for app-wide concerns - -```go -// bootstrap/app.go -WithMiddleware(func(handler configuration.Middleware) { - handler.Append( - middleware.Cors(), - sessionmiddleware.StartSession(), - middleware.VerifyCsrfToken([]string{"api/*"}), - ) -}) -``` - ---- - -## Queue / Jobs - -### Make jobs idempotent: safe to run multiple times - -```go -// CORRECT: check before acting -func (r *ProcessOrderJob) Handle(args ...any) error { - order := r.order - if order.Status == "processed" { - return nil // already done, skip - } - // ... process ... - order.Status = "processed" - return facades.Orm().Query().Save(&order) -} -``` - -### Use ShouldRetry to control retry behavior - -```go -func (r *SendEmailJob) ShouldRetry(attempts uint, err error) bool { - // Retry transient errors, not permanent failures - if errors.Is(err, ErrRateLimit) { - return true - } - return attempts < 3 -} -``` - -### Keep jobs focused: one responsibility per job - -```go -// WRONG: job doing too much -func (r *UserRegistrationJob) Handle(args ...any) error { - // send welcome email + create subscription + notify admin + update analytics -} - -// CORRECT: chain focused jobs -facades.Queue().Job(&jobs.SendWelcomeEmail{}, args). - Chain([]queue.Jobs{ - {Job: &jobs.CreateSubscription{}, Args: args}, - {Job: &jobs.NotifyAdmin{}, Args: args}, - }).Dispatch() -``` - -### Don't dispatch events inside open transactions - -```go -// WRONG: if transaction rolls back, event was already dispatched -facades.Orm().Transaction(func(tx orm.Transaction) error { - tx.Create(&order) - facades.Event().Job(&events.OrderCreated{}, args).Dispatch() // too early - return nil -}) - -// CORRECT: dispatch after commit -err := facades.Orm().Transaction(func(tx orm.Transaction) error { - return tx.Create(&order) -}) -if err == nil { - facades.Event().Job(&events.OrderCreated{}, args).Dispatch() -} -``` - ---- - -## Cache - -### Use Remember instead of manual Get + Put - -```go -// WRONG: race condition between Get and Put -val := facades.Cache().Get("key", nil) -if val == nil { - val = expensiveComputation() - facades.Cache().Put("key", val, 5*time.Minute) -} - -// CORRECT: atomic -val, err := facades.Cache().Remember("key", 5*time.Minute, func() (any, error) { - return expensiveComputation(), nil -}) -``` - -### Use atomic locks for distributed mutual exclusion - -```go -lock := facades.Cache().Lock("process:order:"+orderID, 30*time.Second) -if lock.Get() { - defer lock.Release() - // only one server runs this at a time - processOrder(orderID) -} else { - // already being processed elsewhere -} - -// Block and wait (up to 5 seconds) -if lock.Block(5 * time.Second) { - defer lock.Release() - processOrder(orderID) -} -``` - -### Use Store() to be explicit about which store - -```go -// When using multiple cache stores, be explicit -facades.Cache().Store("redis").Put("session:data", data, 1*time.Hour) -facades.Cache().Store("memory").Put("rate:limit:123", count, 1*time.Minute) -``` - ---- - -## Events - -### Keep event Args serializable - -```go -// CORRECT: use typed Args with Type and Value; must survive serialization for queued listeners -facades.Event().Job(&events.OrderShipped{}, []event.Arg{ - {Type: "int", Value: order.ID}, - {Type: "string", Value: order.Status}, -}).Dispatch() - -// WRONG for queued listeners: passing non-serializable objects (functions, channels, etc.) -``` - -### Queue non-critical listeners - -```go -// Sending email on order shipped; should not block the HTTP response -func (r *SendShipmentNotification) Queue(args ...any) event.Queue { - return event.Queue{ - Enable: true, - Connection: "redis", - Queue: "notifications", - } -} -``` - ---- - -## Error Handling - -### Return errors: never suppress them - -```go -// WRONG: swallowing errors hides bugs -result, _ := facades.Cache().Remember("key", ttl, fn) -facades.Orm().Query().Create(&user) // ignoring returned error - -// CORRECT -result, err := facades.Cache().Remember("key", ttl, fn) -if err != nil { - return err -} -if err := facades.Orm().Query().Create(&user); err != nil { - return err -} -``` - -### Log at the boundary: not deep in service layers - -```go -// WRONG: logging at every layer creates duplicate noise -func (s *UserService) Create(data UserData) error { - err := facades.Orm().Query().Create(&user) - facades.Log().Error(err) // logged here... - return err -} - -func (r *UserController) Store(ctx http.Context) http.Response { - err := s.Create(data) - facades.Log().Error(err) // ...and again here -} - -// CORRECT: log once at the outermost boundary (controller/command) -func (r *UserController) Store(ctx http.Context) http.Response { - if err := s.Create(data); err != nil { - facades.Log().WithContext(ctx.Context()). - With(log.Fields{"user": data}). - Error(err) - return ctx.Response().Json(http.StatusInternalServerError, ...) - } -} -``` - ---- - -## Session - -### Use Redis session driver for multi-server deployments - -File sessions are stored locally and won't work across multiple server instances. Use Redis: - -```go -// config/session.go -"default": "redis", -"drivers": map[string]any{ - "redis": map[string]any{ - "driver": "custom", - "connection": "default", - "via": func() (session.Driver, error) { - return redisfacades.Session("redis"), nil - }, - }, -}, -``` - -### Never store sensitive data in sessions - -```go -// WRONG: storing raw credentials or tokens in session -ctx.Request().Session().Put("password", password) -ctx.Request().Session().Put("api_secret", secret) - -// CORRECT: store only the user ID, look up sensitive data from DB/cache on each request -ctx.Request().Session().Put("user_id", user.ID) -``` - ---- - -## Configuration - -### Always use .env for environment-specific values - -```go -// config/app.go -"debug": config.Env("APP_DEBUG", false), -"key": config.Env("APP_KEY", ""), - -// Access in code -debug := facades.Config().GetBool("app.debug", false) -key := facades.Config().GetString("app.key", "") -``` - -### Never hardcode paths: use WithPaths or path helpers - -```go -// WRONG -file, err := os.Open("storage/uploads/photo.jpg") - -// CORRECT -import "github.com/goravel/framework/support/path" -file, err := os.Open(path.Storage("uploads/photo.jpg")) -``` - -### Use facades.Config().Add() to set runtime config - -```go -// Useful in tests or dynamic configuration -facades.Config().Add("service.api_url", "https://staging.api.example.com") -facades.Config().Add("feature.flags", map[string]any{"new_ui": true}) -``` - ---- - -## Testing - -### Use RefreshDatabase in SetupTest for clean state - -```go -func (s *UserTestSuite) SetupTest() { - s.RefreshDatabase() // wipe and re-migrate before each test -} -``` - -### Use Docker for parallel package tests - -```go -// tests/feature/main_test.go -func TestMain(m *testing.M) { - database, _ := facades.Testing().Docker().Database() - database.Build() - database.Ready() - database.Migrate() - facades.App().Restart() - exit := m.Run() - database.Shutdown() - os.Exit(exit) -} -``` - -### Use mock.Factory() in unit tests: never real facades - -```go -// CORRECT: no real DB/cache/mail calls in unit tests -func TestCreateUser(t *testing.T) { - mockFactory := mock.Factory() - mockOrm := mockFactory.Orm() - mockOrmQuery := mockFactory.OrmQuery() - mockOrm.On("Query").Return(mockOrmQuery) - mockOrmQuery.On("Create", mock.Anything).Return(nil).Once() - - err := userService.Create(UserData{Name: "test"}) - assert.Nil(t, err) - mockOrmQuery.AssertExpectations(t) -} -``` - -### Use s.Http() for full integration HTTP tests - -```go -func (s *UserTestSuite) TestStore() { - builder := http.NewBody().SetField("name", "goravel").SetField("email", "test@example.com") - body, _ := builder.Build() - - response, err := s.Http(s.T()). - WithHeader("Content-Type", body.ContentType()). - WithHeader("Accept", "application/json"). - Post("/users", body) - - s.Nil(err) - response.AssertCreated().AssertJson(map[string]any{"name": "goravel"}) -} -``` - ---- - -## Performance - -### Queue slow operations: never block the HTTP response - -```go -// WRONG: sending email synchronously blocks response for 2-5 seconds -func (r *OrderController) Store(ctx http.Context) http.Response { - facades.Mail().To([]string{user.Email}).Content(...).Send() // SLOW - return ctx.Response().Json(http.StatusCreated, order) -} - -// CORRECT: queue it -func (r *OrderController) Store(ctx http.Context) http.Response { - facades.Queue().Job(&jobs.SendOrderConfirmation{}, args).Dispatch() - return ctx.Response().Json(http.StatusCreated, order) -} -``` - -### Tune database connection pool - -```go -// config/database.go -"pool": map[string]any{ - "max_idle_conns": 10, // keep warm connections - "max_open_conns": 100, // cap total connections - "conn_max_idletime": 3600, // close idle after 1h - "conn_max_lifetime": 3600, // recycle connections after 1h -}, -"slow_threshold": 200, // log queries slower than 200ms -``` - -### Cache computed/aggregated data - -```go -// CORRECT: expensive aggregation behind cache -stats, err := facades.Cache().Remember("dashboard:stats", 5*time.Minute, func() (any, error) { - var result DashboardStats - facades.Orm().Query(). - Model(&models.Order{}). - Select("COUNT(*) as count, SUM(total) as revenue"). - Where("created_at > ?", time.Now().AddDate(0, -1, 0)). - First(&result) - return result, nil -}) -``` - -### Use pagination: never return all records in APIs - -```go -// WRONG -var users []models.User -facades.Orm().Query().Find(&users) -return ctx.Response().Json(200, users) // could be millions of rows - -// CORRECT -page := ctx.Request().QueryInt("page", 1) -perPage := ctx.Request().QueryInt("per_page", 20) -var users []models.User -var total int64 -facades.Orm().Query().Paginate(page, perPage, &users, &total) -return ctx.Response().Json(200, http.Json{ - "data": users, - "total": total, - "page": page, -}) -``` - ---- - -## Package Installation - -### Use artisan to install official packages - -```shell -# Installs package, registers service provider, updates config automatically -./artisan package:install github.com/goravel/redis -./artisan package:install github.com/goravel/gin -./artisan package:install github.com/goravel/fiber -./artisan package:install github.com/goravel/postgres -./artisan package:install github.com/goravel/mysql -./artisan package:install github.com/goravel/s3 -./artisan package:install github.com/goravel/minio - -# Publish package resources manually if needed -./artisan vendor:publish --package=github.com/goravel/example-package -./artisan vendor:publish --package=github.com/goravel/example-package --tag=config -./artisan vendor:publish --package=./packages/local-package --force -``` - -### Register process and view service providers - -```go -// bootstrap/providers.go -&process.ServiceProvider{}, // facades.Process() -&view.ServiceProvider{}, // facades.View() -``` - ---- - -## Compile / Deployment - -### Timezone data for non-UTC timezones - -Alpine-based Docker images and scratch containers have no timezone database. If your app uses any non-UTC timezone, provide timezone data using one of these methods: - -```dockerfile -# Option 1: install tzdata in the container image (works with time.LoadLocation) -RUN apk add --no-cache tzdata -``` - -```go -// Option 2: embed timezone data into the binary at compile time -import _ "time/tzdata" -``` - -```shell -# Option 3: equivalent to Option 2 using a build tag instead of an import -go build -tags timetzdata . -``` - -Options 2 and 3 do the same thing. Option 1 keeps the binary smaller but requires the OS package. Option 2/3 makes the binary self-contained. - -### Static compilation for containerless deployment - -```shell -go build --ldflags "-extldflags -static" -o main . - -# Or via artisan -./artisan build --static --os=linux -``` - -### Files required on deployment server - -``` -.env -./main # compiled binary -./public/ # if exists -./resources/ # if exists -``` - -Do not ship `database/migrations/`. Migrations run at startup via `./artisan migrate` or auto-run if configured. diff --git a/.ai/prompt/bootstrap.md b/.ai/prompt/bootstrap.md deleted file mode 100644 index 1c797b94f..000000000 --- a/.ai/prompt/bootstrap.md +++ /dev/null @@ -1,402 +0,0 @@ -# Goravel Bootstrap, Service Container, and Service Providers - -## Bootstrap Entry Point - -```go -// main.go -package main - -import "goravel/bootstrap" - -func main() { - app := bootstrap.Boot() - app.Wait() -} -``` - -```go -// bootstrap/app.go -package bootstrap - -import ( - "github.com/goravel/framework/foundation" - contractsfoundation "github.com/goravel/framework/contracts/foundation" -) - -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithProviders(Providers). - WithConfig(config.Boot). - WithRouting(func() { - routes.Web() - routes.Grpc() - }). - WithMiddleware(func(handler configuration.Middleware) { - handler.Append(middleware.Custom()) - }). - WithCommands(Commands). - WithEvents(func() map[event.Event][]event.Listener { - return map[event.Event][]event.Listener{ - events.NewOrderShipped(): {listeners.NewSendShipmentNotification()}, - } - }). - WithJobs(Jobs). - WithMigrations(Migrations). - WithSeeders(Seeders). - WithSchedule(func() []schedule.Event { - return []schedule.Event{ - facades.Schedule().Call(func() {}).Daily(), - } - }). - WithRules(Rules). - WithFilters(Filters). - WithRunners(func() []foundation.Runner { - return []foundation.Runner{NewCustomRunner()} - }). - WithPaths(func(paths configuration.Paths) { - paths.App("src") // optional: customize directories - paths.Config("configuration") - paths.Database("db") - paths.Routes("api/routes") - paths.Storage("data") - paths.Resources("views-root") - }). - WithCallback(func() { - // runs after all providers Boot(); all facades available here - facades.Gate().Define("update-post", fn) - facades.RateLimiter().For("global", fn) - facades.Orm().Observe(&models.User{}, &observers.UserObserver{}) - }). - Create() -} -``` - -**Boot order inside `Create()`:** -1. Load configuration (`WithConfig`) -2. Register all service providers (calls `Register` on each) -3. Boot all service providers (calls `Boot` on each) -4. Run `WithCallback` -5. Runners start (HTTP server, Queue worker, Schedule, gRPC, etc.) - ---- - -## Directory Paths Are Configurable - -`WithPaths` customizes directory layout. Never assume `app/`, `config/`, etc. are fixed. - -```go -WithPaths(func(paths configuration.Paths) { - paths.App("src") - paths.Config("configuration") - paths.Database("db") - paths.Routes("api/routes") - paths.Storage("data") - paths.Resources("views-root") -}) -``` - ---- - -## Service Container - -### Bind (new instance each call) - -```go -func (r *ServiceProvider) Register(app foundation.Application) { - app.Bind("goravel.route", func(app foundation.Application) (any, error) { - return NewRoute(app.MakeConfig()), nil - }) -} -``` - -### Singleton (same instance every call) - -```go -app.Singleton("goravel.gin", func(app foundation.Application) (any, error) { - return NewGin(app.MakeConfig()), nil -}) -``` - -### Instance (existing object) - -```go -app.Instance("goravel.key", existingInstance) -``` - -### BindWith (bind with extra parameters) - -```go -app.BindWith("goravel.route", func(app foundation.Application, parameters map[string]any) (any, error) { - return NewRoute(app.MakeConfig()), nil -}) -``` - -### Make (resolve from container) - -```go -instance, err := app.Make("goravel.route") - -// from outside a service provider: -instance, err := facades.App().Make("goravel.route") -``` - -### MakeWith (resolve with parameters) - -```go -instance, err := app.MakeWith("goravel.route", map[string]any{"id": 1}) -``` - -### Convenience resolvers - -```go -app.MakeArtisan() -app.MakeAuth(ctx) -app.MakeCache() -app.MakeConfig() -app.MakeRoute() -// etc. -``` - ---- - -## Service Providers - -Create via artisan (auto-registered in `bootstrap/providers.go`): - -```shell -./artisan make:provider YourServiceProvider -``` - -```go -package providers - -import "github.com/goravel/framework/contracts/foundation" - -type YourServiceProvider struct{} - -// Register: only bind into the service container here. -// NEVER register routes, events, or listeners in Register. -func (r *YourServiceProvider) Register(app foundation.Application) { - app.Singleton("custom", func(app foundation.Application) (any, error) { - return New(), nil - }) -} - -// Boot: register routes, event listeners, or any startup logic here. -func (r *YourServiceProvider) Boot(app foundation.Application) {} -``` - -### Dependency Relationship - -```go -import "github.com/goravel/framework/contracts/foundation/binding" - -func (r *ServiceProvider) Relationship() binding.Relationship { - return binding.Relationship{ - Bindings: []string{ - "custom", - }, - Dependencies: []string{ - binding.Config, - }, - ProvideFor: []string{ - binding.Cache, - }, - } -} -``` - -Providers with `Relationship()` are registered in dependency order; those without are registered last. - ---- - -## Runners Interface (v1.17) - -Implement `Runners` in a service provider to auto-start/shutdown services: - -```go -// BREAKING v1.17: register new service providers &process.ServiceProvider{} and &view.ServiceProvider{} - -type Runner interface { - ShouldRun() bool - Run() error - Shutdown() error -} - -// In service provider: -func (r *ServiceProvider) Runners(app foundation.Application) []foundation.Runner { - return []foundation.Runner{NewMyRunner(app.MakeConfig())} -} - -// Runner implementation: -type MyRunner struct { - config config.Config - route route.Route -} - -func NewMyRunner(config config.Config, route route.Route) *MyRunner { - return &MyRunner{config: config, route: route} -} - -func (r *MyRunner) ShouldRun() bool { - return r.route != nil && r.config.GetString("http.default") != "" -} - -func (r *MyRunner) Run() error { - return r.route.Run() -} - -func (r *MyRunner) Shutdown() error { - return r.route.Shutdown() -} -``` - -Custom runners via `WithRunners`: - -```go -WithRunners(func() []foundation.Runner { - return []foundation.Runner{NewCustomRunner()} -}) -``` - ---- - -## Facade Import Pattern - -Facades live in `app/facades/` of your project. Import path depends on `go.mod` module name: - -```go -// go.mod: module github.com/mycompany/myapp - -import "github.com/mycompany/myapp/app/facades" - -facades.Route().Get("/", handler) -facades.Orm().Query().Find(&user, 1) -facades.Auth(ctx).Login(&user) -``` - -Available facades: `App`, `Artisan`, `Auth`, `Cache`, `Config`, `Crypt`, `DB`, `Event`, `Gate`, `Grpc`, `Hash`, `Http`, `Lang`, `Log`, `Mail`, `Orm`, `Process`, `Queue`, `RateLimiter`, `Route`, `Schedule`, `Schema`, `Seeder`, `Session`, `Storage`, `Validation`, `View`. - -Never import `github.com/goravel/framework/facades` — it does not exist. - ---- - -## HTTP Driver Configuration - -### Gin (default) - -```go -// config/http.go -import ( - "github.com/gin-gonic/gin/render" - "github.com/goravel/framework/contracts/route" - "github.com/goravel/gin" - ginfacades "github.com/goravel/gin/facades" -) - -config.Add("http", map[string]any{ - "default": "gin", - "drivers": map[string]any{ - "gin": map[string]any{ - "body_limit": 4096, // KB (default 4096) - "header_limit": 4096, - "route": func() (route.Route, error) { - return ginfacades.Route("gin"), nil - }, - "template": func() (render.HTMLRender, error) { - return gin.DefaultTemplate() - }, - }, - }, - "url": config.Env("APP_URL", "http://localhost"), - "host": config.Env("APP_HOST", "127.0.0.1"), - "port": config.Env("APP_PORT", "3000"), - "request_timeout": 3, // seconds - "tls": map[string]any{ - "host": config.Env("APP_HOST", "127.0.0.1"), - "port": config.Env("APP_PORT", "3000"), - "ssl": map[string]any{ - "cert": "", // path to .pem - "key": "", // path to .key - }, - }, - "default_client": config.Env("HTTP_CLIENT_DEFAULT", "default"), - "clients": map[string]any{ - "default": map[string]any{ - "base_url": config.Env("HTTP_CLIENT_BASE_URL", ""), - "timeout": config.Env("HTTP_CLIENT_TIMEOUT", "30s"), - "max_idle_conns": config.Env("HTTP_CLIENT_MAX_IDLE_CONNS", 100), - "max_idle_conns_per_host": config.Env("HTTP_CLIENT_MAX_IDLE_CONNS_PER_HOST", 2), - "max_conns_per_host": config.Env("HTTP_CLIENT_MAX_CONN_PER_HOST", 0), - "idle_conn_timeout": config.Env("HTTP_CLIENT_IDLE_CONN_TIMEOUT", "90s"), - }, - }, -}) -``` - -### Fiber - -```go -// config/http.go -import ( - "github.com/gofiber/fiber/v2" - "github.com/gofiber/template/html/v2" - "github.com/goravel/framework/contracts/route" - "github.com/goravel/framework/support/path" - fiberfacades "github.com/goravel/fiber/facades" -) - -config.Add("http", map[string]any{ - "default": "fiber", - "drivers": map[string]any{ - "fiber": map[string]any{ - "immutable": true, // zero-allocation mode; understand before disabling - "prefork": false, - "body_limit": 4096, - "header_limit": 4096, - "route": func() (route.Route, error) { - return fiberfacades.Route("fiber"), nil - }, - "template": func() (fiber.Views, error) { - return html.New(path.Resource("views"), ".tmpl"), nil - }, - }, - }, - // url/host/port/tls/clients same as Gin config -}) -``` - -Install drivers: - -```shell -./artisan package:install github.com/goravel/gin -./artisan package:install github.com/goravel/fiber -``` - ---- - -## WithCallback Pattern - -Use `WithCallback` for any code that requires all facades to be available: - -```go -WithCallback(func() { - // Gates - facades.Gate().Define("edit-post", func(ctx context.Context, args map[string]any) contractsaccess.Response { - user := ctx.Value("user").(models.User) - post := args["post"].(models.Post) - if user.ID == post.UserID { - return access.NewAllowResponse() - } - return access.NewDenyResponse("forbidden") - }) - - // Rate limiters - facades.RateLimiter().For("api", func(ctx contractshttp.Context) contractshttp.Limit { - return limit.PerMinute(60).By(ctx.Request().Ip()) - }) - - // ORM observers - facades.Orm().Observe(&models.User{}, &observers.UserObserver{}) -}) -``` diff --git a/.ai/prompt/cache.md b/.ai/prompt/cache.md deleted file mode 100644 index 4eec6e218..000000000 --- a/.ai/prompt/cache.md +++ /dev/null @@ -1,254 +0,0 @@ -# Goravel Cache - -## Configuration - -Full `config/cache.go`: - -```go -// config/cache.go -config.Add("cache", map[string]any{ - "default": "memory", // driver name to use - - // Cache stores - // Available built-in drivers: "memory" - // External: goravel/redis → "custom" - "stores": map[string]any{ - "memory": map[string]any{ - "driver": "memory", - }, - // Redis store (requires goravel/redis package): - // "redis": map[string]any{ - // "driver": "custom", - // "connection": "default", - // "via": func() (cache.Driver, error) { - // return redisfacades.Cache("redis"), nil - // }, - // }, - }, - - // Cache key prefix (must match: a-zA-Z0-9_-) - "prefix": config.GetString("APP_NAME", "goravel") + "_cache", -}) -``` - -### Redis Cache Driver - -Install `goravel/redis`: - -```shell -./artisan package:install github.com/goravel/redis -``` - -```go -// config/cache.go -import ( - "github.com/goravel/framework/contracts/cache" - redisfacades "github.com/goravel/redis/facades" -) - -"default": "redis", - -"stores": map[string]any{ - "redis": map[string]any{ - "driver": "custom", - "connection": "default", - "via": func() (cache.Driver, error) { - return redisfacades.Cache("redis"), nil - }, - }, -}, -``` - -Redis connection in `config/database.go`: - -```go -"redis": map[string]any{ - "default": map[string]any{ - "host": config.Env("REDIS_HOST", "127.0.0.1"), - "password": config.Env("REDIS_PASSWORD", ""), - "port": config.Env("REDIS_PORT", 6379), - "database": config.Env("REDIS_DB", 0), - }, -}, -``` - -Default driver: `memory`. Redis driver available via `github.com/goravel/redis`. - ---- - -## Basic Usage - -### Inject context - -```go -facades.Cache().WithContext(ctx) -``` - -### Multiple stores - -```go -value := facades.Cache().Store("redis").Get("foo") -``` - ---- - -## Get - -```go -value := facades.Cache().Get("goravel", "default") -value := facades.Cache().GetBool("goravel", true) -value := facades.Cache().GetInt("goravel", 1) -value := facades.Cache().GetString("goravel", "default") - -// Closure default -value = facades.Cache().Get("goravel", func() any { - return "computed-default" -}) -``` - ---- - -## Check - -```go -exists := facades.Cache().Has("goravel") -``` - ---- - -## Put (store) - -```go -// With TTL -err := facades.Cache().Put("goravel", "value", 5*time.Second) - -// Forever (TTL = 0 or use Forever) -err = facades.Cache().Put("goravel", "value", 0) -ok := facades.Cache().Forever("goravel", "value") -``` - ---- - -## Add (only if not present) - -```go -ok := facades.Cache().Add("goravel", "value", 5*time.Second) -// true if stored, false if key already existed -``` - ---- - -## Retrieve & Store - -```go -value, err := facades.Cache().Remember("goravel", 5*time.Second, func() (any, error) { - return "goravel", nil -}) - -value, err = facades.Cache().RememberForever("goravel", func() (any, error) { - return "default", nil -}) -``` - ---- - -## Pull (retrieve and delete) - -```go -value := facades.Cache().Pull("goravel", "default") -``` - ---- - -## Increment / Decrement - -```go -facades.Cache().Increment("key") -facades.Cache().Increment("key", 5) -facades.Cache().Decrement("key") -facades.Cache().Decrement("key", 5) -``` - ---- - -## Delete - -```go -ok := facades.Cache().Forget("goravel") -ok = facades.Cache().Flush() -``` - ---- - -## Atomic Locks - -```go -// Acquire lock -lock := facades.Cache().Lock("foo", 10*time.Second) - -if lock.Get() { - // lock acquired for 10 seconds - lock.Release() -} - -// Closure (auto-released) -facades.Cache().Lock("foo", 10*time.Second).Get(func() { - // lock held here, auto-released after -}) - -// Block (wait up to 5 seconds) -lock = facades.Cache().Lock("foo", 10*time.Second) -if lock.Block(5 * time.Second) { - lock.Release() -} - -// Block with closure -facades.Cache().Lock("foo", 10*time.Second).Block(5*time.Second, func() { - // runs when lock acquired -}) - -// Force release (regardless of owner) -facades.Cache().Lock("processing").ForceRelease() -``` - ---- - -## Custom Cache Driver - -```go -// config/cache.go -"stores": map[string]interface{}{ - "memory": map[string]any{ - "driver": "memory", - }, - "custom": map[string]interface{}{ - "driver": "custom", - "via": &MyDriver{}, - }, -}, -``` - -Implement `contracts/cache/Driver`: - -```go -type Driver interface { - Add(key string, value any, t time.Duration) bool - Decrement(key string, value ...int) (int, error) - Forever(key string, value any) bool - Forget(key string) bool - Flush() bool - Get(key string, def ...any) any - GetBool(key string, def ...bool) bool - GetInt(key string, def ...int) int - GetInt64(key string, def ...int64) int64 - GetString(key string, def ...string) string - Has(key string) bool - Increment(key string, value ...int) (int, error) - Lock(key string, t ...time.Duration) Lock - Put(key string, value any, t time.Duration) error - Pull(key string, def ...any) any - Remember(key string, ttl time.Duration, callback func() (any, error)) (any, error) - RememberForever(key string, callback func() (any, error)) (any, error) - WithContext(ctx context.Context) Driver -} -``` diff --git a/.ai/prompt/controller.md b/.ai/prompt/controller.md deleted file mode 100644 index b4a1663cb..000000000 --- a/.ai/prompt/controller.md +++ /dev/null @@ -1,316 +0,0 @@ -# Goravel Controllers, Requests, and Responses - -## Controller Structure - -```go -package controllers - -import ( - "github.com/goravel/framework/contracts/http" - - "goravel/app/facades" -) - -type UserController struct { - // inject services -} - -func NewUserController() *UserController { - return &UserController{} -} - -// Every handler must return http.Response -func (r *UserController) Show(ctx http.Context) http.Response { - return ctx.Response().Success().Json(http.Json{ - "Hello": "Goravel", - }) -} -``` - -Generate via artisan: - -```shell -./artisan make:controller UserController -./artisan make:controller user/UserController -./artisan make:controller --resource PhotoController -``` - -Register in routes: - -```go -package routes - -import ( - "goravel/app/facades" - "goravel/app/http/controllers" -) - -func Api() { - userController := controllers.NewUserController() - facades.Route().Get("/{id}", userController.Show) -} -``` - ---- - -## Resource Controllers - -```go -facades.Route().Resource("photos", controllers.NewPhotoController()) - -// Generated actions: -// GET /photos → Index -// POST /photos → Store -// GET /photos/{photo} → Show -// PUT /photos/{photo} → Update -// DELETE /photos/{photo} → Destroy -``` - ---- - -## Request Input - -### Route parameters - -```go -// /users/{id} -id := ctx.Request().Route("id") -id := ctx.Request().RouteInt("id") -id := ctx.Request().RouteInt64("id") -``` - -### Query string - -```go -// /users?name=goravel -name := ctx.Request().Query("name") -name := ctx.Request().Query("name", "default") -id := ctx.Request().QueryInt("id") -id := ctx.Request().QueryInt64("id") -flag := ctx.Request().QueryBool("flag") - -// /users?names=a&names=b -names := ctx.Request().QueryArray("names") - -// /users?names[a]=goravel1&names[b]=goravel2 -names := ctx.Request().QueryMap("names") - -queries := ctx.Request().Queries() -``` - -### JSON/form input - -```go -name := ctx.Request().Input("name") -name := ctx.Request().Input("name", "default") -name := ctx.Request().InputInt("name") -name := ctx.Request().InputInt64("name") -name := ctx.Request().InputBool("name") -name := ctx.Request().InputArray("name") -name := ctx.Request().InputMap("name") -name := ctx.Request().InputMapArray("name") - -// All input (json + form + query, priority: json > form > query) -data := ctx.Request().All() -``` - -### Bind JSON/form to struct - -```go -type User struct { - Name string `form:"name" json:"name"` -} - -var user User -err := ctx.Request().Bind(&user) - -var data map[string]any -err := ctx.Request().Bind(&data) -``` - -### Bind query to struct - -```go -type Filter struct { - ID string `form:"id"` -} -var filter Filter -err := ctx.Request().BindQuery(&filter) -``` - ---- - -## Request Metadata - -```go -path := ctx.Request().Path() // /users/1 -originPath := ctx.Request().OriginPath() // /users/{id} -url := ctx.Request().Url() // /users?name=Goravel -fullUrl := ctx.Request().FullUrl() // http://host/users?name=Goravel -host := ctx.Request().Host() -method := ctx.Request().Method() -ip := ctx.Request().Ip() -info := ctx.Request().Info() -name := ctx.Request().Name() -header := ctx.Request().Header("X-Header-Name", "default") -headers := ctx.Request().Headers() -``` - ---- - -## File Upload - -```go -file, err := ctx.Request().File("file") -files, err := ctx.Request().Files("file") - -// Save file -file.Store("./public") -``` - ---- - -## Context Data - -```go -// Set -ctx.WithValue("user", "Goravel") - -// Get -user := ctx.Value("user") - -// Get stdlib context -c := ctx.Context() -``` - ---- - -## Cookie - -```go -value := ctx.Request().Cookie("name") -value := ctx.Request().Cookie("name", "default") -``` - ---- - -## Response - -### String - -```go -return ctx.Response().String(http.StatusOK, "Hello Goravel") -``` - -### JSON - -```go -return ctx.Response().Json(http.StatusOK, http.Json{ - "Hello": "Goravel", -}) - -return ctx.Response().Json(http.StatusOK, struct { - ID uint `json:"id"` - Name string `json:"name"` -}{ID: 1, Name: "Goravel"}) -``` - -### Success shorthand (200) - -```go -return ctx.Response().Success().String("Hello Goravel") -return ctx.Response().Success().Json(http.Json{"Hello": "Goravel"}) -``` - -### Custom status - -```go -return ctx.Response().Status(http.StatusCreated).Json(http.Json{"id": 1}) -``` - -### Raw data - -```go -return ctx.Response().Data(http.StatusOK, "text/html; charset=utf-8", []byte("Goravel")) -``` - -### File & download - -```go -return ctx.Response().File("./public/logo.png") -return ctx.Response().Download("./public/logo.png", "logo.png") -``` - -### Header - -```go -return ctx.Response().Header("X-Custom", "value").String(http.StatusOK, "ok") -``` - -### Cookie - -```go -import "time" - -ctx.Response().Cookie(http.Cookie{ - Name: "name", - Value: "Goravel", - Path: "/", - Domain: "goravel.dev", - Expires: time.Now().Add(24 * time.Hour), - Secure: true, - HttpOnly: true, -}) - -ctx.Response().WithoutCookie("name") -``` - -### Stream (SSE / chunked) - -```go -return ctx.Response().Stream(http.StatusOK, func(w http.StreamWriter) error { - for _, item := range []string{"a", "b", "c"} { - if _, err := w.Write([]byte(item + "\n")); err != nil { - return err - } - if err := w.Flush(); err != nil { - return err - } - time.Sleep(1 * time.Second) - } - return nil -}) -``` - -### Redirect - -```go -return ctx.Response().Redirect(http.StatusMovedPermanently, "https://goravel.dev") -``` - -### No content - -```go -return ctx.Response().NoContent() -return ctx.Response().NoContent(http.StatusResetContent) -``` - -### Inspect response in middleware - -```go -origin := ctx.Response().Origin() -// origin.Body() — response bytes -// origin.Header() — headers -// origin.Status() — status code -// origin.Size() — body size -``` - ---- - -## Abort Request (in middleware/handler) - -```go -ctx.Request().Abort() -ctx.Request().Abort(http.StatusNotFound) -ctx.Response().String(http.StatusForbidden, "forbidden").Abort() -``` diff --git a/.ai/prompt/controllers.md b/.ai/prompt/controllers.md deleted file mode 100644 index d667de484..000000000 --- a/.ai/prompt/controllers.md +++ /dev/null @@ -1,343 +0,0 @@ -# Goravel Controllers, Requests, and Responses - -## Controller Definition - -Controllers live in `app/http/controllers/`. - -```go -package controllers - -import ( - "github.com/goravel/framework/contracts/http" - - "goravel/app/facades" -) - -type UserController struct{} - -func NewUserController() *UserController { - return &UserController{} -} - -func (r *UserController) Show(ctx http.Context) http.Response { - return ctx.Response().Success().Json(http.Json{ - "Hello": "Goravel", - }) -} -``` - -Register in route: - -```go -package routes - -import ( - "goravel/app/facades" - "goravel/app/http/controllers" -) - -func Api() { - userController := controllers.NewUserController() - facades.Route().Get("/users/{id}", userController.Show) - facades.Route().Post("/users", userController.Store) -} -``` - -### Generate controller - -```shell -./artisan make:controller UserController -./artisan make:controller user/UserController -./artisan make:controller --resource PhotoController -``` - ---- - -## Request - Reading Input - -### Route parameters - -```go -// /users/{id} -id := ctx.Request().Route("id") -id := ctx.Request().RouteInt("id") -id := ctx.Request().RouteInt64("id") -``` - -### Query string - -```go -// /users?name=goravel -name := ctx.Request().Query("name") -name := ctx.Request().Query("name", "default") - -id := ctx.Request().QueryInt("id") -id := ctx.Request().QueryInt64("id") -flag := ctx.Request().QueryBool("flag") - -// /users?names=a&names=b -names := ctx.Request().QueryArray("names") - -// /users?names[a]=1&names[b]=2 -names := ctx.Request().QueryMap("names") - -all := ctx.Request().Queries() -``` - -### Body / form input - -Reads from JSON body or form data (priority: json, then form): - -```go -name := ctx.Request().Input("name") -name := ctx.Request().Input("name", "default") -age := ctx.Request().InputInt("age") -age := ctx.Request().InputInt64("age") -flag := ctx.Request().InputBool("flag") -tags := ctx.Request().InputArray("tags") -meta := ctx.Request().InputMap("meta") -meta := ctx.Request().InputMapArray("meta") -``` - -### All input - -```go -data := ctx.Request().All() // map[string]any combining json + form + query -``` - -### Bind to struct - -```go -type CreateUserRequest struct { - Name string `form:"name" json:"name"` - Email string `form:"email" json:"email"` -} - -var req CreateUserRequest -err := ctx.Request().Bind(&req) -``` - -```go -var data map[string]any -err := ctx.Request().Bind(&data) -``` - -### Bind query to struct - -```go -type SearchRequest struct { - Query string `form:"q"` - Page string `form:"page"` -} - -var req SearchRequest -err := ctx.Request().BindQuery(&req) -``` - ---- - -## Request - Metadata - -```go -path := ctx.Request().Path() // /users/1 -origin := ctx.Request().OriginPath() // /users/{id} -url := ctx.Request().Url() // /users?name=goravel -host := ctx.Request().Host() -fullUrl := ctx.Request().FullUrl() // http://example.com/users?name=goravel -method := ctx.Request().Method() -ip := ctx.Request().Ip() -header := ctx.Request().Header("X-Token", "default") -headers := ctx.Request().Headers() -name := ctx.Request().Name() // named route -``` - ---- - -## Request - Files - -```go -file, err := ctx.Request().File("avatar") -files, err := ctx.Request().Files("photos") - -// Save to disk -file.Store("./public/uploads") -``` - ---- - -## Request - Cookies - -```go -value := ctx.Request().Cookie("name") -value := ctx.Request().Cookie("name", "default") -``` - ---- - -## Request - Context Values - -```go -// Set -ctx.WithValue("user", userObj) - -// Get -user := ctx.Value("user") - -// Standard context -stdCtx := ctx.Context() -``` - ---- - -## Request - Abort - -```go -ctx.Request().Abort() -ctx.Request().Abort(http.StatusUnauthorized) -ctx.Request().AbortWithStatus(http.StatusForbidden) -``` - ---- - -## Response - String - -```go -return ctx.Response().String(http.StatusOK, "Hello Goravel") -``` - -## Response - JSON - -```go -return ctx.Response().Json(http.StatusOK, http.Json{ - "name": "Goravel", - "id": 1, -}) - -// Struct -return ctx.Response().Json(http.StatusOK, struct { - ID uint `json:"id"` - Name string `json:"name"` -}{ID: 1, Name: "Goravel"}) -``` - -## Response - Success shorthand (200) - -```go -return ctx.Response().Success().String("Hello") -return ctx.Response().Success().Json(http.Json{"key": "value"}) -``` - -## Response - Custom status - -```go -return ctx.Response().Status(http.StatusCreated).Json(http.Json{ - "id": 1, -}) -``` - -## Response - Custom data - -```go -return ctx.Response().Data(http.StatusOK, "text/html; charset=utf-8", []byte("Goravel")) -``` - -## Response - File / Download - -```go -return ctx.Response().File("./public/logo.png") -return ctx.Response().Download("./public/report.pdf", "report.pdf") -``` - -## Response - Headers - -```go -return ctx.Response().Header("X-Custom-Header", "value").Json(http.StatusOK, http.Json{}) -``` - -## Response - Cookie - -```go -import "time" - -return ctx.Response().Cookie(http.Cookie{ - Name: "session", - Value: "abc123", - Path: "/", - Domain: "example.com", - Expires: time.Now().Add(24 * time.Hour), - Secure: true, - HttpOnly: true, -}).Json(http.StatusOK, http.Json{}) -``` - -Remove a cookie: - -```go -return ctx.Response().WithoutCookie("session").String(http.StatusOK, "ok") -``` - -## Response - Stream - -```go -return ctx.Response().Stream(http.StatusOK, func(w http.StreamWriter) error { - items := []string{"a", "b", "c"} - for _, item := range items { - if _, err := w.Write([]byte(item + "\n")); err != nil { - return err - } - if err := w.Flush(); err != nil { - return err - } - time.Sleep(1 * time.Second) - } - return nil -}) -``` - -## Response - Redirect - -```go -return ctx.Response().Redirect(http.StatusMovedPermanently, "https://goravel.dev") -``` - -## Response - No Content - -```go -return ctx.Response().NoContent() -return ctx.Response().NoContent(http.StatusNoContent) -``` - -## Response - Abort inside middleware - -```go -return ctx.Response().String(http.StatusUnauthorized, "unauthorized").Abort() -``` - ---- - -## Custom Recovery (panic handler) - -Set in `bootstrap/app.go`: - -```go -import ( - contractshttp "github.com/goravel/framework/contracts/http" - configuration "github.com/goravel/framework/contracts/foundation/configuration" -) - -WithMiddleware(func(handler configuration.Middleware) { - handler.Recover(func(ctx contractshttp.Context, err any) { - facades.Log().Error(err) - _ = ctx.Response().String(contractshttp.StatusInternalServerError, "internal error").Abort() - }) -}) -``` - ---- - -## Gotchas - -- Always return `ctx.Response()...` from controller methods. Returning without calling `ctx.Response()` leaves the response empty. -- `Input()` reads JSON body or form; `Query()` reads query string only. They do not overlap. -- `Bind()` only binds JSON body or form data. Use `BindQuery()` for query strings. -- `form` fields are always `string` type when bound. Use JSON if you need non-string types. diff --git a/.ai/prompt/event.md b/.ai/prompt/event.md deleted file mode 100644 index 2a1fb0b5d..000000000 --- a/.ai/prompt/event.md +++ /dev/null @@ -1,134 +0,0 @@ -# Goravel Events and Listeners - -## Registration - -Register events and listeners in `WithEvents` in `bootstrap/app.go`: - -```go -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithEvents(func() map[event.Event][]event.Listener { - return map[event.Event][]event.Listener{ - events.NewOrderShipped(): { - listeners.NewSendShipmentNotification(), - listeners.NewLogOrderShipped(), - }, - } - }). - WithConfig(config.Boot). - Create() -} -``` - -Generate via artisan: - -```shell -./artisan make:event OrderShipped -./artisan make:event user/OrderShipped - -./artisan make:listener SendShipmentNotification -./artisan make:listener user/SendShipmentNotification -``` - ---- - -## Define an Event - -```go -package events - -import "github.com/goravel/framework/contracts/event" - -type OrderShipped struct{} - -// Handle transforms args before passing to listeners -func (r *OrderShipped) Handle(args []event.Arg) ([]event.Arg, error) { - return args, nil -} -``` - ---- - -## Define a Listener - -```go -package listeners - -import "github.com/goravel/framework/contracts/event" - -type SendShipmentNotification struct{} - -func (r *SendShipmentNotification) Signature() string { - return "send_shipment_notification" -} - -// Queue controls async execution -func (r *SendShipmentNotification) Queue(args ...any) event.Queue { - return event.Queue{ - Enable: false, // true to run in queue - Connection: "", - Queue: "", - } -} - -// Handle receives args returned by event.Handle -func (r *SendShipmentNotification) Handle(args ...any) error { - name := args[0] - _ = name - return nil -} -``` - -Returning an error from `Handle` stops propagation to subsequent listeners. - ---- - -## Queued Listener - -```go -func (r *SendShipmentNotification) Queue(args ...any) event.Queue { - return event.Queue{ - Enable: true, - Connection: "redis", - Queue: "notifications", - } -} -``` - ---- - -## Dispatch an Event - -```go -import ( - "github.com/goravel/framework/contracts/event" - "goravel/app/events" - "goravel/app/facades" -) - -err := facades.Event().Job(&events.OrderShipped{}, []event.Arg{ - {Type: "string", Value: "Goravel"}, - {Type: "int", Value: 1}, -}).Dispatch() -``` - ---- - -## Supported `event.Arg.Type` Values - -``` -bool, int, int8, int16, int32, int64, -uint, uint8, uint16, uint32, uint64, -float32, float64, string, -[]bool, []int, []int8, []int16, []int32, []int64, -[]uint, []uint8, []uint16, []uint32, []uint64, -[]float32, []float64, []string -``` - ---- - -## Gotchas - -- Queued listeners run outside database transactions. If your listener reads data written in the same transaction, the transaction may not have committed yet when the listener runs. -- Returning an error from `Handle` in a listener stops propagation to subsequent listeners. -- Each `NewOrderShipped()` call creates a new event instance — the map key is the instance used for type matching. diff --git a/.ai/prompt/events.md b/.ai/prompt/events.md deleted file mode 100644 index f682e31f4..000000000 --- a/.ai/prompt/events.md +++ /dev/null @@ -1,201 +0,0 @@ -# Goravel Events - -## Register Events and Listeners - -All events and listeners are registered in `bootstrap/app.go` via `WithEvents`: - -```go -import ( - "github.com/goravel/framework/contracts/event" - "goravel/app/events" - "goravel/app/listeners" -) - -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithEvents(func() map[event.Event][]event.Listener { - return map[event.Event][]event.Listener{ - events.NewOrderShipped(): { - listeners.NewSendShipmentNotification(), - listeners.NewUpdateInventory(), - }, - events.NewUserRegistered(): { - listeners.NewSendWelcomeEmail(), - }, - } - }). - WithConfig(config.Boot). - Create() -} -``` - ---- - -## Defining Events - -Events live in `app/events/`. An event holds and transforms data passed to listeners. - -```go -package events - -import "github.com/goravel/framework/contracts/event" - -type OrderShipped struct{} - -func NewOrderShipped() *OrderShipped { - return &OrderShipped{} -} - -func (r *OrderShipped) Handle(args []event.Arg) ([]event.Arg, error) { - // Transform args before passing to listeners, or return as-is - return args, nil -} -``` - -### Generate event - -```shell -./artisan make:event OrderShipped -./artisan make:event user/OrderShipped -``` - ---- - -## Defining Listeners - -Listeners live in `app/listeners/`. - -```go -package listeners - -import "github.com/goravel/framework/contracts/event" - -type SendShipmentNotification struct{} - -func NewSendShipmentNotification() *SendShipmentNotification { - return &SendShipmentNotification{} -} - -func (r *SendShipmentNotification) Signature() string { - return "send_shipment_notification" -} - -func (r *SendShipmentNotification) Queue(args ...any) event.Queue { - return event.Queue{ - Enable: false, - Connection: "", - Queue: "", - } -} - -func (r *SendShipmentNotification) Handle(args ...any) error { - // args are the []event.Arg values returned by the event's Handle method - return nil -} -``` - -### Generate listener - -```shell -./artisan make:listener SendShipmentNotification -./artisan make:listener user/SendShipmentNotification -``` - ---- - -## Dispatching Events - -```go -package controllers - -import ( - "github.com/goravel/framework/contracts/event" - contractshttp "github.com/goravel/framework/contracts/http" - - "goravel/app/events" - "goravel/app/facades" -) - -type OrderController struct{} - -func (r *OrderController) Ship(ctx contractshttp.Context) contractshttp.Response { - err := facades.Event().Job(&events.OrderShipped{}, []event.Arg{ - {Type: "string", Value: "order-123"}, - {Type: "int", Value: 1}, - }).Dispatch() - - if err != nil { - return ctx.Response().String(500, "dispatch failed") - } - - return ctx.Response().Success().Json(contractshttp.Json{"status": "shipped"}) -} -``` - ---- - -## Queued Listeners - -Set `Enable: true` in the `Queue` method to run the listener asynchronously via the queue: - -```go -func (r *SendShipmentNotification) Queue(args ...any) event.Queue { - return event.Queue{ - Enable: true, - Connection: "redis", - Queue: "notifications", - } -} -``` - -Use an empty string for `Connection` and `Queue` to use defaults. - -### Reading args in Handle - -Args are positional, matching the `[]event.Arg` slice dispatched: - -```go -func (r *SendShipmentNotification) Handle(args ...any) error { - orderID := args[0].(string) - userID := args[1].(int) - // send notification - return nil -} -``` - ---- - -## Stop Event Propagation - -Return an error from `Handle` to stop propagation to subsequent listeners: - -```go -func (r *SendShipmentNotification) Handle(args ...any) error { - // returning a non-nil error stops further listeners - return errors.New("stop propagation") -} -``` - ---- - -## Supported Arg Types - -``` -bool, int, int8, int16, int32, int64 -uint, uint8, uint16, uint32, uint64 -float32, float64 -string -[]bool, []int, []int8, []int16, []int32, []int64 -[]uint, []uint8, []uint16, []uint32, []uint64 -[]float32, []float64 -[]string -``` - ---- - -## Gotchas - -- Events must be registered in `WithEvents` before they can be dispatched. Dispatching an unregistered event silently does nothing. -- `Type` in `event.Arg` must be an exact string from the supported list. -- When a queued listener is dispatched inside a database transaction, the queue may process the listener before the transaction commits. Place event dispatches outside transactions if listeners read the data written by the transaction. -- Returning a non-nil error from a listener's `Handle` stops propagation to subsequent listeners for that event. diff --git a/.ai/prompt/facades.md b/.ai/prompt/facades.md deleted file mode 100644 index 66dcdf4de..000000000 --- a/.ai/prompt/facades.md +++ /dev/null @@ -1,338 +0,0 @@ -# Goravel Facades, Service Container, and Service Providers - -## Facade Import Path - -Facades are defined in `app/facades/` inside your project. The import path is: - -```go -// go.mod: module goravel -import "goravel/app/facades" - -facades.Route().Get("/", handler) -facades.Orm().Query().Find(&user, 1) -facades.Config().GetString("app.name") -``` - -The module name in `go.mod` determines the import path. Never use `github.com/goravel/framework/app/facades`. - ---- - -## How Facades Work - -Each facade calls a `Make*` method on the application's service container to retrieve the registered instance: - -```go -// app/facades/route.go -package facades - -import "github.com/goravel/framework/contracts/route" - -func Route() route.Route { - return App().MakeRoute() -} -``` - ---- - -## Creating a Custom Facade - -1. Register a binding in a service provider -2. Create a facade function in `app/facades/` - -```go -// app/facades/payment.go -package facades - -import "goravel/app/contracts" - -func Payment() contracts.PaymentService { - instance, err := App().Make("goravel.payment") - if err != nil { - panic(err) - } - return instance.(contracts.PaymentService) -} -``` - ---- - -## Service Container - -The service container manages bindings and dependencies. - -### Bind (creates a new instance each call) - -```go -app.Bind("goravel.payment", func(app foundation.Application) (any, error) { - return NewPaymentService(app.MakeConfig()), nil -}) -``` - -### Singleton (creates once, returns same instance after) - -```go -app.Singleton("goravel.payment", func(app foundation.Application) (any, error) { - return NewPaymentService(app.MakeConfig()), nil -}) -``` - -### Instance (bind an already-created object) - -```go -app.Instance("goravel.payment", existingInstance) -``` - -### BindWith (bind with extra parameters) - -```go -app.BindWith("goravel.payment", func(app foundation.Application, parameters map[string]any) (any, error) { - return NewPaymentService(parameters["apiKey"].(string)), nil -}) -``` - -### Resolve - -```go -// Inside a service provider -instance, err := app.Make("goravel.payment") - -// Outside service providers (via App facade) -instance, err := facades.App().Make("goravel.payment") - -// With parameters (matches BindWith) -instance, err := app.MakeWith("goravel.payment", map[string]any{"apiKey": "sk-xxx"}) -``` - -### Framework convenience methods - -```go -app.MakeConfig() -app.MakeRoute() -app.MakeOrm() -app.MakeAuth(ctx) -app.MakeLog() -// and others -``` - ---- - -## Service Providers - -### Create a service provider - -```shell -./artisan make:provider PaymentServiceProvider -``` - -Generated providers auto-register in `bootstrap/providers.go`. - -### Provider structure - -```go -package providers - -import ( - "github.com/goravel/framework/contracts/foundation" - "goravel/app/facades" -) - -type PaymentServiceProvider struct{} - -func (r *PaymentServiceProvider) Register(app foundation.Application) { - // Only bind into the container here - // Never register routes, events, listeners here - app.Singleton("goravel.payment", func(app foundation.Application) (any, error) { - return NewPaymentService(app.MakeConfig()), nil - }) -} - -func (r *PaymentServiceProvider) Boot(app foundation.Application) { - // Runs after all providers are registered - // Safe to use facades and other bindings here - facades.Route().Get("/payment", paymentController.Index) -} -``` - -### Register providers - -Providers auto-register when created via artisan. Manual registration in `bootstrap/app.go`: - -```go -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithProviders(providers.Providers). - WithConfig(config.Boot). - Create() -} -``` - ---- - -## Dependency Relationships Between Providers - -Use the optional `Relationship` method to declare explicit dependencies: - -```go -import "github.com/goravel/framework/contracts/foundation/binding" - -type ServiceProvider struct{} - -func (r *ServiceProvider) Relationship() binding.Relationship { - return binding.Relationship{ - Bindings: []string{ - "custom", // what this provider registers - }, - Dependencies: []string{ - binding.Config, // must be registered before this - }, - ProvideFor: []string{ - binding.Cache, // this provider's bindings can be used by Cache - }, - } -} - -func (r *ServiceProvider) Register(app foundation.Application) { - app.Singleton("custom", func(app foundation.Application) (any, error) { - return New() - }) -} - -func (r *ServiceProvider) Boot(app foundation.Application) {} -``` - -Providers that implement `Relationship` are sorted by dependency graph. Providers without it run last. - ---- - -## Runners - -Service providers can implement `Runners` to start and stop background services: - -```go -type ServiceProvider struct{} - -func (r *ServiceProvider) Register(app foundation.Application) {} - -func (r *ServiceProvider) Boot(app foundation.Application) {} - -func (r *ServiceProvider) Runners(app foundation.Application) []foundation.Runner { - return []foundation.Runner{ - NewMyServiceRunner(app.MakeConfig()), - } -} -``` - -Runner interface: - -```go -type Runner interface { - ShouldRun() bool - Run() error - Shutdown() error -} -``` - -Example runner: - -```go -type MyServiceRunner struct { - config config.Config -} - -func NewMyServiceRunner(config config.Config) *MyServiceRunner { - return &MyServiceRunner{config: config} -} - -func (r *MyServiceRunner) ShouldRun() bool { - return r.config.GetBool("myservice.enabled") -} - -func (r *MyServiceRunner) Run() error { - // start the service - return nil -} - -func (r *MyServiceRunner) Shutdown() error { - // graceful shutdown - return nil -} -``` - -Add runners in `bootstrap/app.go`: - -```go -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithConfig(config.Boot). - WithRunners(func() []contractsfoundation.Runner { - return []contractsfoundation.Runner{ - NewMyServiceRunner(), - } - }). - Create() -} -``` - ---- - -## WithCallback - -Code in `WithCallback` runs after all providers have been registered and booted. All facades are available: - -```go -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithConfig(config.Boot). - WithCallback(func() { - // Register gates - facades.Gate().Define("update-post", policyFn) - - // Register rate limiters - facades.RateLimiter().For("api", func(ctx contractshttp.Context) contractshttp.Limit { - return limit.PerMinute(60) - }) - - // Register observers - facades.Orm().Observe(models.User{}, &observers.UserObserver{}) - }). - Create() -} -``` - ---- - -## Install and Uninstall Facades (goravel-lite) - -For `goravel/goravel-lite`, facades are installed selectively: - -```shell -./artisan package:install Route -./artisan package:install Cache -./artisan package:install --all -./artisan package:install --all --default - -./artisan package:uninstall Route -``` - -Note: when selecting facades interactively, press `x` to select an item, then `Enter` to confirm. Pressing `Enter` without `x` does not select. - ---- - -## Accessing the App Facade - -```go -// From anywhere -instance, err := facades.App().Make("goravel.payment") -facades.App().Bind("key", fn) -facades.App().Singleton("key", fn) -facades.App().Instance("key", obj) -``` - ---- - -## Gotchas - -- Never register routes, events, or other side effects inside `Register`. Use `Boot` or `WithCallback` instead. -- `Register` is called on all providers before `Boot` is called on any. Do not call `facades.*` inside `Register` - the binding may not exist yet. -- Facade functions return an interface. Always check for nil or use type assertions carefully. -- Providers without `Relationship` run after those with it. If your provider depends on a custom provider that does not declare relationships, ordering is not guaranteed. diff --git a/.ai/prompt/grpc.md b/.ai/prompt/grpc.md deleted file mode 100644 index ffd518abf..000000000 --- a/.ai/prompt/grpc.md +++ /dev/null @@ -1,269 +0,0 @@ -# Goravel gRPC - -## Configuration - -Configure in `config/grpc.go`: - -```go -// BREAKING v1.17: grpc.clients renamed to grpc.servers - -config.Add("grpc", map[string]any{ - "host": config.Env("GRPC_HOST", ""), - "port": config.Env("GRPC_PORT", ""), - - "servers": map[string]any{ // BREAKING v1.17: was "clients" - "user": map[string]any{ - "host": config.Env("GRPC_USER_HOST", ""), - "port": config.Env("GRPC_USER_PORT", ""), - "interceptors": []string{"default"}, - "stats_handlers": []string{"user"}, - }, - }, -}) -``` - ---- - -## gRPC Server Controller - -```go -// app/grpc/controllers/user_controller.go -package controllers - -import ( - "context" - "net/http" - - proto "github.com/goravel/example-proto" -) - -type UserController struct{} - -func NewUserController() *UserController { - return &UserController{} -} - -func (r *UserController) GetUser(ctx context.Context, req *proto.UserRequest) (*proto.UserResponse, error) { - return &proto.UserResponse{ - Code: http.StatusOK, - Data: &proto.User{ - Id: 1, - Name: "Goravel", - Token: req.GetToken(), - }, - }, nil -} -``` - ---- - -## Define gRPC Routes - -```go -// routes/grpc.go -package routes - -import ( - proto "github.com/goravel/example-proto" - "goravel/app/facades" - "goravel/app/grpc/controllers" -) - -func Grpc() { - proto.RegisterUserServiceServer(facades.Grpc().Server(), controllers.NewUserController()) -} -``` - -Register in `bootstrap/app.go`: - -```go -WithRouting(func() { - routes.Web() - routes.Grpc() -}) -``` - ---- - -## gRPC Client - -// BREAKING v1.17: facades.Grpc().Client() is deprecated — use facades.Grpc().Connect("name") - -```go -// app/http/controllers/grpc_controller.go -package controllers - -import ( - "fmt" - - proto "github.com/goravel/example-proto" - "github.com/goravel/framework/contracts/http" - "goravel/app/facades" -) - -type GrpcController struct { - userService proto.UserServiceClient -} - -func NewGrpcController() *GrpcController { - // BREAKING v1.17: use Connect instead of Client - client, err := facades.Grpc().Connect("user") - if err != nil { - facades.Log().Error(fmt.Sprintf("failed to connect: %+v", err)) - } - - return &GrpcController{ - userService: proto.NewUserServiceClient(client), - } -} - -func (r *GrpcController) User(ctx http.Context) http.Response { - resp, err := r.userService.GetUser(ctx, &proto.UserRequest{ - Token: ctx.Request().Input("token"), - }) - if err != nil { - return ctx.Response().String(http.StatusInternalServerError, fmt.Sprintf("err: %+v", err)) - } - return ctx.Response().Success().Json(resp.GetData()) -} -``` - ---- - -## Interceptors - -### Server Interceptor - -```go -// app/grpc/interceptors/auth_server.go -package interceptors - -import ( - "context" - "google.golang.org/grpc" -) - -func AuthServer(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) { - // pre-processing: auth check, logging, etc. - return handler(ctx, req) -} -``` - -### Client Interceptor - -```go -// app/grpc/interceptors/log_client.go -package interceptors - -import ( - "context" - "google.golang.org/grpc" -) - -func LogClient(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { - // pre-processing: add metadata, logging, etc. - return invoker(ctx, method, req, reply, cc, opts...) -} -``` - -### Register Interceptors - -```go -// bootstrap/app.go -import ( - "google.golang.org/grpc" - "goravel/app/grpc/interceptors" -) - -foundation.Setup(). - WithGrpcServerInterceptors(func() []grpc.UnaryServerInterceptor { - return []grpc.UnaryServerInterceptor{ - interceptors.AuthServer, - } - }). - WithGrpcClientInterceptors(func() map[string][]grpc.UnaryClientInterceptor { - return map[string][]grpc.UnaryClientInterceptor{ - "default": {interceptors.LogClient}, - } - }). - Create() -``` - -The map key (`"default"`) is a group name referenced in `config/grpc.go` servers `interceptors` array. - ---- - -## Stats Handlers - -### Server Stats Handler - -```go -// app/grpc/stats/server_handler.go -package stats - -import ( - "context" - "google.golang.org/grpc/stats" -) - -type ServerStatsHandler struct{} - -func NewServerStatsHandler() stats.Handler { return &ServerStatsHandler{} } - -func (h *ServerStatsHandler) TagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context { - return ctx -} -func (h *ServerStatsHandler) HandleRPC(ctx context.Context, s stats.RPCStats) {} -func (h *ServerStatsHandler) TagConn(ctx context.Context, info *stats.ConnTagInfo) context.Context { - return ctx -} -func (h *ServerStatsHandler) HandleConn(ctx context.Context, s stats.ConnStats) {} -``` - -### Client Stats Handler - -```go -// app/grpc/stats/client_handler.go -package stats - -import ( - "context" - "google.golang.org/grpc/stats" -) - -type ClientStatsHandler struct{} - -func NewClientStatsHandler() stats.Handler { return &ClientStatsHandler{} } - -func (h *ClientStatsHandler) TagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context { - return ctx -} -func (h *ClientStatsHandler) HandleRPC(ctx context.Context, s stats.RPCStats) {} -func (h *ClientStatsHandler) TagConn(ctx context.Context, info *stats.ConnTagInfo) context.Context { - return ctx -} -func (h *ClientStatsHandler) HandleConn(ctx context.Context, s stats.ConnStats) {} -``` - -### Register Stats Handlers - -```go -// bootstrap/app.go -import ( - "google.golang.org/grpc/stats" - grpcstats "goravel/app/grpc/stats" -) - -foundation.Setup(). - WithGrpcServerStatsHandlers(func() []stats.Handler { - return []stats.Handler{grpcstats.NewServerStatsHandler()} - }). - WithGrpcClientStatsHandlers(func() map[string][]stats.Handler { - return map[string][]stats.Handler{ - "user": {grpcstats.NewClientStatsHandler()}, - } - }). - Create() -``` - -Map key (`"user"`) is referenced in `config/grpc.go` servers `stats_handlers` array. diff --git a/.ai/prompt/helpers.md b/.ai/prompt/helpers.md deleted file mode 100644 index b3b2051e6..000000000 --- a/.ai/prompt/helpers.md +++ /dev/null @@ -1,348 +0,0 @@ -# Goravel Helpers, Strings, Color - -## Path Helpers - -```go -import "github.com/goravel/framework/support/path" - -path.App() // absolute path to app directory -path.App("http/controllers/controller.go") // path within app dir -path.Base() // project root -path.Base("vendor/bin") -path.Config() // config directory -path.Config("app.go") -path.Database() // database directory -path.Database("factories/user_factory.go") -path.Storage() // storage directory -path.Storage("app/file.txt") -path.Public() // public directory -path.Public("css/app.css") -path.Lang() // lang directory -path.Lang("en.json") -path.Resource() // resource directory -path.Resource("css/app.css") -``` - ---- - -## Carbon (Date/Time) - -```go -import "github.com/goravel/framework/support/carbon" -``` - -Wraps [dromara/carbon](https://github.com/dromara/carbon). - -```go -carbon.Now() -carbon.SetTimezone(carbon.UTC) -carbon.SetLocale("en") // see https://github.com/dromara/carbon/tree/master/lang - -// Test time control -carbon.SetTestNow(carbon.Now()) -carbon.CleanTestNow() -carbon.IsTestNow() - -// Parse -carbon.Parse("2020-08-05 13:14:15") -carbon.ParseByLayout("2020-08-05 13:14:15", carbon.DateTimeLayout) -carbon.ParseByLayout("2020|08|05 13|14|15", []string{"2006|01|02 15|04|05", "2006|1|2 3|4|5"}) -carbon.ParseByFormat("2020-08-05 13:14:15", carbon.DateTimeFormat) - -// From timestamp -carbon.FromTimestamp(1577836800) -carbon.FromTimestampMilli(1649735755999) -carbon.FromTimestampMicro(1649735755999999) -carbon.FromTimestampNano(1649735755999999999) - -// From components -carbon.FromDateTime(2020, 1, 1, 0, 0, 0) -carbon.FromDateTimeMilli(2020, 1, 1, 0, 0, 0, 999) -carbon.FromDateTimeMicro(2020, 1, 1, 0, 0, 0, 999999) -carbon.FromDateTimeNano(2020, 1, 1, 0, 0, 0, 999999999) -carbon.FromDate(2020, 1, 1) -carbon.FromDateMilli(2020, 1, 1, 999) -carbon.FromDateMicro(2020, 1, 1, 999999) -carbon.FromDateNano(2020, 1, 1, 999999999) -carbon.FromTime(13, 14, 15) -carbon.FromTimeMilli(13, 14, 15, 999) -carbon.FromTimeMicro(13, 14, 15, 999999) -carbon.FromTimeNano(13, 14, 15, 999999999) -carbon.FromStdTime(time.Now()) -``` - ---- - -## Debug - -```go -import "github.com/goravel/framework/support/debug" - -debug.Dump(myVar1, myVar2) // print to stdout -debug.SDump(myVar1, myVar2) // return as string -debug.FDump(someWriter, myVar1) // write to io.Writer -``` - ---- - -## Maps - -```go -import "github.com/goravel/framework/support/maps" - -mp := map[string]any{"name": "Goravel"} - -maps.Add(mp, "age", 22) // add only if key absent -maps.Exists(mp, "name") // true -maps.Forget(mp, "name", "age") // remove key(s) -maps.Get(mp, "name", "default") // get with default -maps.Has(mp, "name", "age") // all keys must exist -maps.HasAny(mp, "name", "email") // any key present -maps.Only(mp, "name") // subset map -maps.Pull(mp, "name") // get and remove -maps.Pull(mp, "missing", "default") // with default -maps.Set(mp, "language", "Go") // set value -maps.Where(mp, func(k string, v any) bool { // filter - return k == "name" -}) -``` - ---- - -## Convert - -```go -import "github.com/goravel/framework/support/convert" - -// Tap: pass to closure, return original value -convert.Tap("Goravel", func(value string) { - fmt.Println(value + " Framework") -}) -// → "Goravel" - -// Transform: convert using closure -convert.Transform(1, strconv.Itoa) // "1" -convert.Transform("foo", func(s string) *Foo { - return &Foo{Name: s} -}) - -// With: execute closure and return its result -convert.With("Goravel", func(value string) string { - return value + " Framework" -}) -// → "Goravel Framework" - -// Default: first non-zero value -convert.Default("", "foo") // "foo" -convert.Default("bar", "foo") // "bar" -convert.Default(0, 1) // 1 - -// Pointer: return pointer to value -convert.Pointer("foo") // *string -convert.Pointer(1) // *int -``` - ---- - -## Collect - -```go -import "github.com/goravel/framework/support/collect" - -collect.Count([]string{"a", "b"}) // 2 -collect.CountBy([]string{"a", "b"}, func(v string) bool { return v == "a" }) // 1 -collect.Each([]string{"a", "b"}, func(v string, i int) { fmt.Println(i, v) }) -collect.Filter([]string{"a", "b"}, func(v string) bool { return v == "a" }) // ["a"] -collect.GroupBy(slice, func(v T) string { return v.Key }) // map[string][]T -collect.Keys(map[string]string{"a": "1"}) // ["a"] -collect.Map([]string{"a"}, func(v string, i int) string { return strings.ToUpper(v) }) // ["A"] -collect.Max([]int{1, 2, 3}) // 3 -collect.Merge(map1, map2) // merged (map2 wins on conflict) -collect.Min([]int{1, 2, 3}) // 1 -collect.Reverse([]string{"a", "b"}) // ["b", "a"] -collect.Shuffle([]int{1, 2, 3}) // random order -collect.Split([]int{1, 2, 3, 4, 5}, 2) // [[1,2],[3,4],[5]] -collect.Sum([]int{1, 2, 3}) // 6 -collect.Unique([]string{"a", "b", "a"}) // ["a", "b"] (first occurrence kept) -collect.Values(map[string]string{"a": "1"}) // ["1"] -``` - ---- - -## Fluent Strings - -```go -import "github.com/goravel/framework/support/str" - -// Chain methods; call .String() to get final string value -str.Of(" Goravel ").Trim().Lower().UcFirst().String() // "Goravel" -``` - -### String Methods - -```go -str.Of("Hello World!").After("Hello").String() // " World!" -str.Of("docs.goravel.dev").AfterLast(".").String() // "dev" -str.Of("Bowen").Append(" Han").String() // "Bowen Han" -str.Of("framework/support/str").Basename().String() // "str" -str.Of("framework/support/str.go").Basename(".go").String() // "str" -str.Of("Hello World!").Before("World").String() // "Hello " -str.Of("docs.goravel.dev").BeforeLast(".").String() // "docs.goravel" -str.Of("[Hello] World!").Between("[", "]").String() // "Hello" -str.Of("[Hello] [World]!").BetweenFirst("[", "]").String() // "Hello" -str.Of("hello_world").Camel().String() // "helloWorld" -str.Of("Goravel").CharAt(1) // "o" -str.Of("https://goravel.com").ChopEnd(".com").String() // "https://goravel" -str.Of("https://goravel.dev").ChopStart("https://").String()// "goravel.dev" -str.Of("Goravel").Contains("Gor") // true -str.Of("Hello World").Contains("Gor", "Hello") // true (any) -str.Of("Hello World").ContainsAll("Hello", "World") // true (all) -str.Of("framework/support/str").Dirname().String() // "framework/support" -str.Of("framework/support/str").Dirname(2).String() // "framework" -str.Of("Goravel").EndsWith("vel") // true -str.Of("Goravel").EndsWith("vel", "lie") // true (any) -str.Of("Goravel").Exactly("Goravel") // true -str.Of("This is a beautiful morning").Except("beautiful", str.ExcerptOption{Radius: 5}).String() -// "...is a beautiful morn..." -str.Of("Hello World").Explode(" ") // []string{"Hello", "World"} -str.Of("framework").Finish("/").String() // "framework/" -str.Of("bowen_han").Headline().String() // "Bowen Han" -str.Of("foo123").Is("bar*", "foo*") // true -str.Of("").IsEmpty() // true -str.Of("Goravel").IsNotEmpty() // true -str.Of("Goravel").IsAscii() // true -str.Of(`[{"name":"a"}]`).IsSlice() // true -str.Of(`{"name":"a"}`).IsMap() // true -str.Of("01E5Z6Z1Z6Z1Z6Z1Z6Z1Z6Z1Z6").IsUlid() // true -str.Of("550e8400-e29b-41d4-a716-446655440000").IsUuid() // true -str.Of("GoravelFramework").Kebab().String() // "goravel-framework" -str.Of("Goravel Framework").LcFirst().String() // "goravel Framework" -str.Of("Goravel").Length() // 7 -str.Of("This is a beautiful morning").Limit(7).String() // "This is..." -str.Of("This is a beautiful morning").Limit(7, " (****)").String() // "This is (****)" -str.Of("GORAVEL").Lower().String() // "goravel" -str.Of(" Goravel ").LTrim().String() // "Goravel " -str.Of("/framework/").LTrim("/").String() // "framework/" -str.Of("krishan@email.com").Mask("*", 3).String() // "kri**************" -str.Of("krishan@email.com").Mask("*", -13, 3).String() // "kris***@email.com" -str.Of("This is a (test) string").Match(`\([^)]+\)`).String()// "(test)" -str.Of("abc123def456").MatchAll(`\d+`) // ["123", "456"] -str.Of("Hello, Goravel!").IsMatch(`(?i)goravel`) // true -str.Of("Goravel").NewLine(2).Append("Framework").String() // "Goravel\n\nFramework" -str.Of("Hello").PadBoth(10, "_").String() // "__Hello___" -str.Of("Hello").PadLeft(10, "_").String() // "_____Hello" -str.Of("Hello").PadRight(10, "_").String() // "Hello_____" -str.Of("Goravel").Pipe(func(s string) string { return s + " Framework" }).String() -str.Of("goose").Plural().String() // "geese" -str.Of("goose").Plural(1).String() // "goose" -str.Of("goose").Plural(2).String() // "geese" -str.Of("Framework").Prepend("Goravel ").String() // "Goravel Framework" -str.Of("Hello World").Remove("World").String() // "Hello " -str.Of("a").Repeat(2).String() // "aa" -str.Of("Hello World").Replace("World", "Krishan").String() // "Hello Krishan" -str.Of("Hello World").Replace("world", "Krishan", false).String() // case-insensitive -str.Of("Hello World").ReplaceEnd("World", "Goravel").String() -str.Of("Hello World").ReplaceFirst("World", "Goravel").String() -str.Of("Hello World").ReplaceLast("World", "Goravel").String() -str.Of("Hello, Goravel!").ReplaceMatches(`goravel!(.*)`, "Krishan") -str.Of("Hello World").ReplaceStart("Hello", "Goravel").String() -str.Of(" Goravel ").RTrim().String() // " Goravel" -str.Of("heroes").Singular().String() // "hero" -str.Of("GoravelFramework").Snake().String() // "goravel_framework" -str.Of("Hello World").Split(" ") // []string{"Hello", "World"} -str.Of("Hello World").Squish().String() // "Hello World" -str.Of("framework").Start("/").String() // "/framework" -str.Of("Goravel").StartsWith("Gor") // true -str.Of("Goravel").String() // "Goravel" -str.Of("goravel_framework").Studly().String() // "GoravelFramework" -str.Of("Goravel").Substr(1, 3) // "ora" -str.Of("Golang is awesome").Swap(map[string]string{"Golang": "Go", "awesome": "great"}).String() -str.Of("Goravel").Tap(func(s string) { fmt.Println(s) }).String() -str.Of("Hello, Goravel!").Test(`goravel!(.*)`) // true -str.Of("goravel framework").Title().String() // "Goravel Framework" -str.Of(" Goravel ").Trim().String() // "Goravel" -str.Of("/framework/").Trim("/").String() // "framework" -str.Of("goravel framework").UcFirst().String() // "Goravel framework" -str.Of("GoravelFramework").UcSplit() // ["Goravel", "Framework"] -str.Of("goravel").Upper().String() // "GORAVEL" -str.Of("Hello, World!").WordCount() // 2 -str.Of("Hello, World!").Words(1) // "Hello..." -str.Of("Hello, World!").Words(1, " (****)").String() // "Hello (****)" - -// Conditional chaining -str.Of("Bowen").When(true, func(s *str.String) *str.String { - return s.Append(" Han") -}).String() // "Bowen Han" - -str.Of("Hello Bowen").WhenContains("Hello", func(s *str.String) *str.String { - return s.Append(" Han") -}).String() - -str.Of("Hello Bowen").WhenContainsAll([]string{"Hello", "Bowen"}, func(s *str.String) *str.String { - return s.Append(" Han") -}).String() - -str.Of("").WhenEmpty(func(s *str.String) *str.String { - return s.Append("Goravel") -}).String() - -str.Of("Goravel").WhenIsAscii(func(s *str.String) *str.String { - return s.Append(" Framework") -}).String() - -str.Of("Goravel").WhenNotEmpty(func(s *str.String) *str.String { - return s.Append(" Framework") -}).String() - -str.Of("hello world").WhenStartsWith("hello", func(s *str.String) *str.String { - return s.Title() -}).String() - -str.Of("hello world").WhenEndsWith("world", func(s *str.String) *str.String { - return s.Title() -}).String() - -str.Of("Goravel").WhenExactly("Goravel", func(s *str.String) *str.String { - return s.Append(" Framework") -}).String() - -str.Of("foo/bar").WhenIs("foo/*", func(s *str.String) *str.String { - return s.Append("/baz") -}).String() - -str.Of("goravel framework").WhenTest(`goravel(.*)`, func(s *str.String) *str.String { - return s.Append(" is awesome") -}).String() - -// Unless: execute if condition is false -str.Of("Goravel").Unless(func(s *str.String) bool { - return false -}, func(s *str.String) *str.String { - return str.Of("Fallback Applied") -}).String() // "Fallback Applied" -``` - ---- - -## Color (Terminal Output) - -```go -import "github.com/goravel/framework/support/color" - -// Built-in colors -color.Red().Println("error") -color.Green().Printf("Hello, %s!", "Goravel") -color.Yellow().Print("warning") -color.Blue().Sprintln("info") // returns colored string -color.Magenta().Sprint("text") -color.Cyan().Sprintf("value: %d", 42) -color.White().Println("white") -color.Black().Println("black") -color.Gray().Println("gray") -color.Default().Println("default") - -// Printer interface methods: Print, Println, Printf, Sprint, Sprintln, Sprintf - -// Custom color -color.New(color.FgRed).Println("custom red") -``` diff --git a/.ai/prompt/http.md b/.ai/prompt/http.md deleted file mode 100644 index b14a57cc9..000000000 --- a/.ai/prompt/http.md +++ /dev/null @@ -1,249 +0,0 @@ -# Goravel HTTP Client - -## Basic Requests - -```go -// Default client -response, err := facades.Http().Get("https://example.com") -response, err = facades.Http().Post("https://example.com/users", body) -response, err = facades.Http().Put("https://example.com/users/1", body) -response, err = facades.Http().Delete("https://example.com/users/1", nil) -response, err = facades.Http().Patch("https://example.com/users/1", body) -response, err = facades.Http().Head("https://example.com") - -// Named client (configured in config/http.go clients map) -response, err = facades.Http().Client("github").Get("https://api.github.com") -``` - ---- - -## Response Interface - -```go -// BREAKING v1.17: Http.Request.Bind() is removed — use response.Bind(&dest) - -var user User -err = response.Bind(&user) // bind JSON body to struct - -body, err := response.Body() // raw string body -json, err := response.Json() // map[string]any -status := response.Status() // HTTP status code -header := response.Header("X-Custom") -headers := response.Headers() -cookies := response.Cookies() -cookie := response.Cookie("session") - -// Status checks -response.Successful() // 2xx -response.Failed() // not 2xx -response.ClientError() // 4xx -response.ServerError() // 5xx -response.Redirect() // 3xx -response.OK() // 200 -response.Created() // 201 -response.NotFound() // 404 -response.UnprocessableEntity() // 422 -response.TooManyRequests() // 429 -``` - ---- - -## Headers - -```go -facades.Http().WithHeader("X-Custom", "value").Get(url) -facades.Http().WithHeaders(map[string]string{"Content-Type": "application/json"}).Get(url) -facades.Http().Accept("application/xml").Get(url) -facades.Http().AcceptJson().Get(url) -facades.Http().ReplaceHeaders(map[string]string{"Authorization": "Bearer token"}).Get(url) -facades.Http().WithoutHeader("X-Old-Header").Get(url) -facades.Http().FlushHeaders().Get(url) -``` - ---- - -## Authentication - -```go -facades.Http().WithBasicAuth("username", "password").Get(url) -facades.Http().WithToken("bearer_token").Get(url) -facades.Http().WithToken("custom_token", "Token").Get(url) -facades.Http().WithoutToken().Get(url) -``` - ---- - -## Query Parameters - -```go -facades.Http().WithQueryParameter("sort", "name").Get(url) -facades.Http().WithQueryParameters(map[string]string{"page": "2", "limit": "10"}).Get(url) -facades.Http().WithQueryString("filter=active&order=price").Get(url) -``` - ---- - -## URL Templates - -```go -facades.Http(). - WithUrlParameter("id", "123"). - Get("https://api.example.com/users/{id}") - -facades.Http(). - WithUrlParameters(map[string]string{"bookId": "456", "chapterNumber": "7"}). - Get("https://api.example.com/books/{bookId}/chapters/{chapterNumber}") -``` - ---- - -## Request Body - -```go -import "github.com/goravel/framework/support/http" - -builder := http.NewBody().SetField("name", "krishan") -body, err := builder.Build() -response, err := facades.Http(). - WithHeader("Content-Type", body.ContentType()). - Post("https://example.com/users", body.Reader()) -``` - ---- - -## Cookies - -```go -import "net/http" - -facades.Http().WithCookie(&http.Cookie{Name: "user_id", Value: "123"}).Get(url) -facades.Http().WithCookies([]*http.Cookie{ - {Name: "session_token", Value: "xyz"}, - {Name: "language", Value: "en"}, -}).Get(url) -facades.Http().WithoutCookie("language").Get(url) -``` - ---- - -## Context (timeout, cancellation) - -```go -ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) -defer cancel() -response, err := facades.Http().WithContext(ctx).Get(url) -``` - ---- - -## Bind Response Body - -```go -// BREAKING v1.17: use response.Bind(&dest) instead of Request.Bind() - -type User struct { - ID int `json:"id"` - Name string `json:"name"` -} - -var user User -response, err := facades.Http().AcceptJson().Get("https://api.example.com/users/1") -err = response.Bind(&user) -``` - ---- - -## Multiple Clients Configuration - -```go -// config/http.go -"clients": map[string]any{ - "github": map[string]any{ - "base_url": "https://api.github.com", - "timeout": 30, - }, -}, -``` - -Usage: - -```go -response, err := facades.Http().Client("github").Get("/repos/goravel/framework") -``` - ---- - -## Testing - -### Fake Responses - -```go -facades.Http().Fake(map[string]any{ - "https://github.com/goravel/framework": facades.Http().Response().Json(200, map[string]string{"foo": "bar"}), - "https://google.com/*": facades.Http().Response().String(200, "Hello World"), - "github": facades.Http().Response().OK(), // named client - "*": facades.Http().Response().Status(404), // fallback -}) - -// Implicit conversion shortcuts -facades.Http().Fake(map[string]any{ - "https://goravel.dev/*": "Hello World", // → 200 string body - "https://github.com/*": map[string]string{"a": "b"}, // → 200 JSON - "https://stripe.com/*": 500, // → 500 empty -}) -``` - -### Response Sequences - -```go -facades.Http().Fake(map[string]any{ - "github": facades.Http().Sequence(). - PushStatus(500). - PushString(429, "Rate Limit"). - PushStatus(200). - WhenEmpty(facades.Http().Response().Status(404)), -}) -``` - -### Assertions - -```go -facades.Http().AssertSent(func(req client.Request) bool { - return req.Url() == "https://api.example.com/users" && - req.Method() == "POST" && - req.Input("role") == "admin" -}) - -facades.Http().AssertNotSent(func(req client.Request) bool { - return req.Url() == "https://api.example.com/legacy" -}) - -facades.Http().AssertNothingSent() -facades.Http().AssertSentCount(3) -``` - -### Prevent Stray Requests - -```go -facades.Http().Fake(map[string]any{ - "github": facades.Http().Response().OK(), -}).PreventStrayRequests() - -// Allow specific strays -facades.Http().PreventStrayRequests().AllowStrayRequests([]string{ - "http://localhost:8080/*", -}) -``` - -### Reset State Between Tests - -```go -func TestExternalApi(t *testing.T) { - defer facades.Http().Reset() - - facades.Http().Fake(nil) - // ... test code -} -``` - -Do not run tests using `Fake` in parallel — it mutates global state. diff --git a/.ai/prompt/localization.md b/.ai/prompt/localization.md deleted file mode 100644 index c4a03f168..000000000 --- a/.ai/prompt/localization.md +++ /dev/null @@ -1,154 +0,0 @@ -# Goravel Localization - -## Language File Structure - -``` -/lang - en.json - cn.json -``` - -Or categorized: - -``` -/lang - /en - user.json - role.json - /cn - user.json - role.json -``` - ---- - -## Translation File Format - -```json -// lang/en.json -{ - "name": "It's your name", - "required": { - "user_id": "UserID is required" - }, - "welcome": "Welcome, :name" -} -``` - ---- - -## Get Translation - -```go -// Simple key -facades.Lang(ctx).Get("name") - -// Nested key (dot notation) -facades.Lang(ctx).Get("required.user_id") - -// From categorized file (slash + dot notation) -facades.Lang(ctx).Get("role/user.name") -facades.Lang(ctx).Get("role/user.required.user_id") -``` - ---- - -## Replace Placeholders - -Placeholders are prefixed with `:`. - -```go -import "github.com/goravel/framework/translation" - -facades.Lang(ctx).Get("welcome", translation.Option{ - Replace: map[string]string{ - "name": "Goravel", - }, -}) -// → "Welcome, Goravel" -``` - ---- - -## Pluralization - -```json -// lang/en.json -{ - "apples": "There is one apple|There are many apples", - "items": "{0} There are none|[1,19] There are some|[20,*] There are many", - "minutes_ago": "{1} :value minute ago|[2,*] :value minutes ago" -} -``` - -```go -facades.Lang(ctx).Choice("apples", 1) // "There is one apple" -facades.Lang(ctx).Choice("apples", 5) // "There are many apples" -facades.Lang(ctx).Choice("items", 0) // "There are none" -facades.Lang(ctx).Choice("items", 10) // "There are some" - -facades.Lang(ctx).Choice("minutes_ago", 5, translation.Option{ - Replace: map[string]string{"value": "5"}, -}) -// → "5 minutes ago" -``` - ---- - -## Set Locale at Runtime - -```go -facades.App().SetLocale(ctx, "cn") -``` - -### Get / Check Current Locale - -```go -locale := facades.App().CurrentLocale(ctx) - -if facades.App().IsLocale(ctx, "en") { - // ... -} -``` - ---- - -## Default and Fallback Locale - -Configure in `config/app.go`: - -```go -"locale": "en", -"fallback_locale": "en", -``` - ---- - -## Embed Loading (compile lang files into binary) - -``` -/lang - en.json - cn.json - fs.go -``` - -```go -// lang/fs.go -package lang - -import "embed" - -//go:embed * -var FS embed.FS -``` - -```go -// config/app.go -import "goravel/lang" - -"lang_path": "lang", -"lang_fs": lang.FS, -``` - -When both file and embed exist, file takes priority; embed is the fallback. diff --git a/.ai/prompt/log.md b/.ai/prompt/log.md deleted file mode 100644 index 64582906c..000000000 --- a/.ai/prompt/log.md +++ /dev/null @@ -1,158 +0,0 @@ -# Goravel Logging - -## Configuration - -Configure channels in `config/logging.go`. Default channel: `stack` (forwards to multiple channels). - -Available channel drivers: - -| Driver | Description | -|--------|-------------| -| `stack` | Multiple channels | -| `single` | Single log file | -| `daily` | One file per day | -| `custom` | Custom driver | - ---- - -## Write Log Messages - -```go -facades.Log().Debug("message") -facades.Log().Debugf("message: %s", arg) -facades.Log().Info("message") -facades.Log().Infof("message: %s", arg) -facades.Log().Warning("message") -facades.Log().Warningf("message: %s", arg) -facades.Log().Error("message") -facades.Log().Errorf("message: %s", arg) -facades.Log().Fatal("message") -facades.Log().Fatalf("message: %s", arg) -facades.Log().Panic("message") -facades.Log().Panicf("message: %s", arg) -``` - ---- - -## Write to Specific Channel - -```go -facades.Log().Channel("single").Info("message") -``` - -## Write to Multiple Channels - -```go -facades.Log().Stack([]string{"single", "slack"}).Info("message") -``` - ---- - -## Inject HTTP Context - -```go -facades.Log().WithContext(ctx).Info("message") -``` - ---- - -## Chain Methods - -```go -facades.Log(). - User("john@example.com"). - Code("ERR_AUTH_001"). - Hint("Check the token expiry"). - In("auth"). - Owner("backend-team"). - Tags("auth", "jwt"). - With(map[string]any{"userID": 123}). - WithTrace(). - Error("Authentication failed") -``` - -| Method | Description | -|--------|-------------| -| `Code(code)` | Error code or slug | -| `Hint(hint)` | Debugging hint | -| `In(category)` | Feature category or domain | -| `Owner(owner)` | Alert owner | -| `Request(req)` | Attach HTTP request | -| `Response(resp)` | Attach HTTP response | -| `Tags(tags...)` | Feature tags | -| `User(user)` | Associated user | -| `With(data)` | Key-value context pairs | -| `WithTrace()` | Include stack trace | - ---- - -## Custom Log Driver - -// BREAKING v1.17: Handle must return (Handler, error) not (Hook, error) -// Use log.HookToHandler(hook) adapter if you have an old Hook implementation - -```go -// config/logging.go -"custom": map[string]interface{}{ - "driver": "custom", - "via": &CustomLogger{}, -}, -``` - -Implement `contracts/log/Logger`: - -```go -// BREAKING v1.17: Handle returns (Handler, error) not (Hook, error) -package log - -type Logger interface { - Handle(channel string) (Handler, error) -} -``` - -Example implementation: - -```go -package extensions - -import ( - "github.com/goravel/framework/contracts/log" -) - -type CustomLogger struct{} - -func (c *CustomLogger) Handle(channel string) (log.Handler, error) { - return &CustomHandler{channel: channel}, nil -} - -type CustomHandler struct { - channel string -} - -func (h *CustomHandler) Handle(record log.Record) error { - // write record to your custom sink - return nil -} -``` - -### Adapter for old Hook implementations - -```go -import "github.com/goravel/framework/log" - -handler := log.HookToHandler(myOldHook) -``` - ---- - -## JSON Formatter (v1.17) - -```go -// config/logging.go -"single": map[string]any{ - "driver": "single", - "path": "storage/logs/goravel.log", - "level": "debug", - "formatter": "json", // v1.17: new option -}, -``` diff --git a/.ai/prompt/mail.md b/.ai/prompt/mail.md deleted file mode 100644 index b3f2571cc..000000000 --- a/.ai/prompt/mail.md +++ /dev/null @@ -1,162 +0,0 @@ -# Goravel Mail - -## Configuration - -Configure in `config/mail.go`. Set `MAIL_FROM_ADDRESS` and `MAIL_FROM_NAME` as global sender defaults. - ---- - -## Send Mail (Fluent) - -```go -import "github.com/goravel/framework/mail" - -err := facades.Mail(). - To([]string{"example@example.com"}). - Cc([]string{"cc@example.com"}). - Bcc([]string{"bcc@example.com"}). - From(mail.Address("from@example.com", "Sender Name")). - Attach([]string{"file.png"}). - Content(mail.Html("

Hello Goravel

")). - Headers(map[string]string{"X-Mailer": "Goravel"}). - Subject("Subject"). - Send() -``` - ---- - -## Send via Queue - -```go -import "github.com/goravel/framework/mail" - -// Default queue -err := facades.Mail(). - To([]string{"example@example.com"}). - Content(mail.Html("

Hello Goravel

")). - Subject("Subject"). - Queue() - -// Custom queue -err = facades.Mail(). - To([]string{"example@example.com"}). - Content(mail.Html("

Hello Goravel

")). - Subject("Subject"). - Queue(mail.Queue().Connection("redis").Queue("mail")) -``` - ---- - -## Mailable Struct - -Generate: - -```shell -./artisan make:mail OrderShipped -``` - -```go -package mails - -import "github.com/goravel/framework/contracts/mail" - -type OrderShipped struct{} - -func NewOrderShipped() *OrderShipped { - return &OrderShipped{} -} - -func (m *OrderShipped) Headers() map[string]string { - return map[string]string{"X-Mailer": "goravel"} -} - -func (m *OrderShipped) Attachments() []string { - return []string{"./logo.png"} -} - -func (m *OrderShipped) Content() *mail.Content { - return &mail.Content{Html: "

Hello Goravel

"} -} - -func (m *OrderShipped) Envelope() *mail.Envelope { - return &mail.Envelope{ - Bcc: []string{"bcc@goravel.dev"}, - Cc: []string{"cc@goravel.dev"}, - From: mail.From{Address: "from@goravel.dev", Name: "from"}, - Subject: "Goravel", - To: []string{"to@goravel.dev"}, - } -} - -// Optional: configure queue behavior -func (m *OrderShipped) Queue() *mail.Queue { - return &mail.Queue{ - Connection: "redis", - Queue: "mail", - } -} -``` - -Use Mailable: - -```go -err := facades.Mail().Send(mails.NewOrderShipped()) -err = facades.Mail().Queue(mails.NewOrderShipped()) -``` - ---- - -## Template Support (v1.17) - -Configure template engine in `config/mail.go`: - -```go -"template": map[string]any{ - "default": config.Env("MAIL_TEMPLATE_ENGINE", "html"), - "engines": map[string]any{ - "html": map[string]any{ - "driver": "html", - "path": config.Env("MAIL_VIEWS_PATH", "resources/views/mail"), - }, - }, -}, -``` - -Create template file (e.g., `resources/views/mail/welcome.html`): - -```html -

Welcome {{.Name}}!

-

Thank you for joining {{.AppName}}.

-``` - -Send with template: - -```go -facades.Mail(). - To([]string{"user@example.com"}). - Subject("Welcome"). - Content(mail.Content{ - View: "welcome.tmpl", - With: map[string]any{ - "Name": "John", - "AppName": "Goravel", - }, - }). - Send() -``` - -### Custom Template Engine - -```go -"template": map[string]any{ - "default": "blade", - "engines": map[string]any{ - "blade": map[string]any{ - "driver": "custom", - "via": func() (mail.Template, error) { - return NewBladeTemplateEngine(), nil - }, - }, - }, -}, -``` diff --git a/.ai/prompt/middleware.md b/.ai/prompt/middleware.md deleted file mode 100644 index e376a94cf..000000000 --- a/.ai/prompt/middleware.md +++ /dev/null @@ -1,217 +0,0 @@ -# Goravel Middleware - -## Middleware Signature - -Middleware is a function that returns `http.Middleware`. It is NOT a struct. - -```go -package middleware - -import "github.com/goravel/framework/contracts/http" - -func Auth() http.Middleware { - return func(ctx http.Context) { - // logic before handler - ctx.Request().Next() - // logic after handler - } -} -``` - -### Generate middleware - -```shell -./artisan make:middleware Auth -./artisan make:middleware user/Auth -``` - -Generated file is placed in `app/http/middleware/`. - ---- - -## Aborting a Request - -```go -func Auth() http.Middleware { - return func(ctx http.Context) { - token := ctx.Request().Header("Authorization", "") - if token == "" { - ctx.Request().Abort(http.StatusUnauthorized) - return - } - ctx.Request().Next() - } -} -``` - -Abort with a response body: - -```go -func Auth() http.Middleware { - return func(ctx http.Context) { - token := ctx.Request().Header("Authorization", "") - if token == "" { - ctx.Response().String(http.StatusUnauthorized, "unauthorized").Abort() - return - } - ctx.Request().Next() - } -} -``` - -Abort forms: - -```go -ctx.Request().Abort() -ctx.Request().Abort(http.StatusForbidden) -ctx.Request().AbortWithStatus(http.StatusForbidden) -ctx.Response().String(http.StatusForbidden, "forbidden").Abort() -``` - ---- - -## Passing Data Through Middleware - -Set a value on the context to pass to the next handler: - -```go -func Auth() http.Middleware { - return func(ctx http.Context) { - userID := resolveUserID(ctx) - ctx.WithValue("userID", userID) - ctx.Request().Next() - } -} -``` - -Read in controller: - -```go -func (r *UserController) Show(ctx http.Context) http.Response { - userID := ctx.Value("userID") - ... -} -``` - ---- - -## Global Middleware - -Applies to every HTTP request. Registered in `bootstrap/app.go`: - -```go -import ( - "github.com/goravel/framework/contracts/foundation/configuration" - "goravel/app/http/middleware" -) - -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithMiddleware(func(handler configuration.Middleware) { - handler.Append( - middleware.Auth(), - middleware.Custom(), - ) - }). - WithConfig(config.Boot). - Create() -} -``` - -`handler` methods: - -| Method | Effect | -|--------|--------| -| `Append(middlewares...)` | Add to end of middleware stack | -| `Prepend(middlewares...)` | Add to start of middleware stack | -| `Use(middlewares...)` | Replace entire middleware stack | -| `Recover(fn)` | Set panic recovery handler | -| `GetGlobalMiddleware()` | Return current global middleware list | - ---- - -## Route-Level Middleware - -Apply to specific routes or groups: - -```go -import ( - "goravel/app/facades" - "goravel/app/http/middleware" -) - -// Single route -facades.Route().Middleware(middleware.Auth()).Get("profile", profileController.Show) - -// Multiple middleware -facades.Route().Middleware(middleware.Auth(), middleware.Throttle("api")).Get("admin", adminController.Index) - -// Group -facades.Route().Middleware(middleware.Auth()).Group(func(router route.Router) { - router.Get("profile", profileController.Show) - router.Put("profile", profileController.Update) -}) - -// Prefix + middleware -facades.Route().Prefix("admin").Middleware(middleware.Auth()).Group(func(router route.Router) { - router.Get("dashboard", dashboardController.Index) -}) -``` - ---- - -## Framework-Provided Middleware - -```go -import "github.com/goravel/framework/http/middleware" - -middleware.Cors() // CORS headers -middleware.Throttle("limiterName") // rate limiting (limiter defined via facades.RateLimiter()) -``` - ---- - -## Custom Recovery (Panic Handler) - -```go -import ( - contractshttp "github.com/goravel/framework/contracts/http" - "github.com/goravel/framework/contracts/foundation/configuration" -) - -WithMiddleware(func(handler configuration.Middleware) { - handler. - Append(middleware.Cors()). - Recover(func(ctx contractshttp.Context, err any) { - facades.Log().Error(err) - _ = ctx.Response().String(contractshttp.StatusInternalServerError, "internal error").Abort() - }) -}) -``` - ---- - -## Reading Response Data in Middleware (After Next) - -```go -func Logger() http.Middleware { - return func(ctx http.Context) { - ctx.Request().Next() - - origin := ctx.Response().Origin() - // origin.Body() - response bytes - // origin.Header() - response headers - // origin.Status() - HTTP status code - // origin.Size() - response body size - } -} -``` - ---- - -## Gotchas - -- Middleware is a function returning `http.Middleware`, not a struct with a `Handle` method. -- Always call `ctx.Request().Next()` to pass control to the next middleware or handler. Omitting it silently stops the chain. -- Returning from the middleware function after `Abort` is required to stop execution. -- Global middleware registered with `WithMiddleware` runs on all HTTP routes. Use route-level middleware for scoped behavior. diff --git a/.ai/prompt/migration.md b/.ai/prompt/migration.md deleted file mode 100644 index 25a80469e..000000000 --- a/.ai/prompt/migration.md +++ /dev/null @@ -1,346 +0,0 @@ -# Goravel Migrations - -## Configuration - -```go -// config/database.go -"migrations": map[string]any{ - "table": "migrations", // customize the migration tracking table name -}, -``` - ---- - -## Create Migration - -```shell -# Interactive (prompts for name) -./artisan make:migration - -# Named -./artisan make:migration create_users_table - -# From model struct (auto-generates columns from model definition) -# Model must be registered via facades.Schema().Extend() in WithCallback -./artisan make:migration create_users_table -m User -./artisan make:migration create_users_table --model=User -``` - -### Auto-generation Naming Rules - -The command detects intent from the migration name: - -| Pattern | Result | -|---------|--------| -| `^create_(\w+)_table$` or `^create_(\w+)$` | Creates table with infrastructure | -| `_(to\|from\|in)_(\w+)_table$` or `_(to\|from\|in)_(\w+)$` | Adds/removes columns on existing table | -| Anything else | Empty migration file | - -Examples: -- `create_users_table` → auto-creates `users` table with `id` + `timestamps` -- `add_avatar_to_users_table` → auto-adds column to `users` -- `remove_avatar_from_users` → auto-removes column from `users` - -### Register Models for -m Flag - -```go -// bootstrap/app.go -WithCallback(func() { - facades.Schema().Extend(schema.Extension{ - Models: []any{models.User{}}, - }) -}) -``` - ---- - -## Migration File Structure - -```go -package migrations - -import ( - "github.com/goravel/framework/contracts/database/schema" - "goravel/app/facades" -) - -type M20241207095921CreateUsersTable struct{} - -// Signature must be unique — timestamp + name -func (r *M20241207095921CreateUsersTable) Signature() string { - return "20241207095921_create_users_table" -} - -// Up: add tables/columns/indexes -func (r *M20241207095921CreateUsersTable) Up() error { - if !facades.Schema().HasTable("users") { - return facades.Schema().Create("users", func(table schema.Blueprint) { - table.ID() - table.String("name").Nullable() - table.String("email").Nullable() - table.Timestamps() - }) - } - return nil -} - -// Down: reverse the Up operation -func (r *M20241207095921CreateUsersTable) Down() error { - return facades.Schema().DropIfExists("users") -} - -// Optional: use a non-default DB connection -func (r *M20241207095921CreateUsersTable) Connection() string { - return "postgres" -} -``` - ---- - -## Registration - -Migrations created via `make:migration` are auto-registered in `bootstrap/migrations.go`. Manual migrations must be added by hand: - -```go -// bootstrap/app.go -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithMigrations(Migrations). - WithConfig(config.Boot). - Create() -} -``` - ---- - -## Run Migrations - -```shell -./artisan migrate # run all pending -./artisan migrate:status # show which have run -./artisan migrate:rollback # BREAKING v1.17: rolls back entire last batch -./artisan migrate:rollback --batch=2 # roll back specific batch number -./artisan migrate:rollback --step=5 # roll back last 5 migrations -./artisan migrate:reset # roll back all -./artisan migrate:refresh # roll back all + re-migrate -./artisan migrate:refresh --step=5 # roll back + re-migrate last 5 -./artisan migrate:fresh # drop all tables + migrate -``` - ---- - -## Tables - -```go -// Create -facades.Schema().Create("users", func(table schema.Blueprint) { - table.ID() - table.String("name").Nullable() - table.Timestamps() -}) - -// Check existence -facades.Schema().HasTable("users") -facades.Schema().HasColumn("users", "email") -facades.Schema().HasColumns("users", []string{"name", "email"}) -facades.Schema().HasIndex("users", "email_unique") - -// Use specific DB connection -facades.Schema().Connection("sqlite").Create("users", func(table schema.Blueprint) { - table.ID() -}) - -// Modify existing table -facades.Schema().Table("users", func(table schema.Blueprint) { - table.String("avatar").Nullable() -}) - -// Rename column -facades.Schema().Table("users", func(table schema.Blueprint) { - table.RenameColumn("old_name", "new_name") -}) - -// Add table comment -facades.Schema().Table("users", func(table schema.Blueprint) { - table.Comment("user table") -}) - -// Rename / Drop -facades.Schema().Rename("users", "new_users") -facades.Schema().Drop("users") -facades.Schema().DropIfExists("users") -``` - ---- - -## Column Types - -### Boolean -```go -table.Boolean("active") -``` - -### String & Text -```go -table.Char("code", 4) -table.String("name") // default 255 length -table.String("name", 100) -table.Text("bio") -table.TinyText("note") -table.MediumText("body") -table.LongText("content") -table.Json("metadata") -table.Uuid("uuid") -table.Ulid("ulid") -``` - -### Numeric -```go -table.ID() // alias for BigIncrements, column name "id" -table.ID("user_id") // custom name -table.BigIncrements("id") -table.Increments("id") // uint auto-increment -table.IntegerIncrements("id") -table.MediumIncrements("id") -table.SmallIncrements("id") -table.TinyIncrements("id") -table.BigInteger("views") -table.Integer("age") -table.MediumInteger("score") -table.SmallInteger("rating") -table.TinyInteger("status") -table.UnsignedBigInteger("user_id") -table.UnsignedInteger("count") -table.UnsignedMediumInteger("x") -table.UnsignedSmallInteger("y") -table.UnsignedTinyInteger("z") -table.Float("amount") -table.Double("price") -table.Decimal("total") -``` - -### Date & Time -```go -table.Date("birthday") -table.DateTime("scheduled_at") -table.DateTimeTz("scheduled_at") -table.Time("starts_at") -table.TimeTz("starts_at") -table.Timestamp("created_at") -table.TimestampTz("created_at") -table.Timestamps() // created_at + updated_at -table.TimestampsTz() -table.SoftDeletes() // deleted_at nullable TIMESTAMP -table.SoftDeletesTz() -``` - -### Enum -```go -// MySQL: actual ENUM type; Postgres/SQLite/Sqlserver: stored as string -table.Enum("difficulty", []any{"easy", "hard"}) -table.Enum("num", []any{1, 2}) -``` - -### Polymorphic Morphs -```go -table.Morphs("taggable") // taggable_id (uint) + taggable_type (string) -table.NullableMorphs("taggable") -table.NumericMorphs("taggable") -table.UuidMorphs("taggable") -table.UlidMorphs("taggable") -``` - -### Custom Column Type -```go -table.Column("geometry", "geometry") -``` - ---- - -## Column Modifiers - -Chain after any column type: - -| Modifier | Description | -|----------|-------------| -| `.Always()` | DB-generated always (PostgreSQL only) | -| `.AutoIncrement()` | Auto-increment primary key | -| `.After("column")` | Position after column (MySQL only) | -| `.Comment("text")` | Column comment (MySQL/PostgreSQL) | -| `.Change()` | Modify column structure (MySQL/PostgreSQL/Sqlserver) | -| `.Default(value)` | Default value | -| `.First()` | Place as first column (MySQL only) | -| `.GeneratedAs()` | DB-generated value (PostgreSQL only) | -| `.Nullable()` | Allow NULL | -| `.Unsigned()` | UNSIGNED integer (MySQL only) | -| `.UseCurrent()` | Default CURRENT_TIMESTAMP | -| `.UseCurrentOnUpdate()` | Update to CURRENT_TIMESTAMP on row update (MySQL only) | - -```go -table.String("name").Nullable().Default("anonymous").Comment("user name") -table.Timestamp("created_at").UseCurrent() -``` - ---- - -## Drop Columns - -```go -facades.Schema().Table("users", func(table schema.Blueprint) { - table.DropColumn("name") - table.DropColumn("name", "age") -}) -``` - ---- - -## Indexes - -```go -facades.Schema().Table("users", func(table schema.Blueprint) { - // Primary key - table.Primary("id") - table.Primary("id", "name") // composite - - // Unique index - table.Unique("name") - table.Unique("name", "age") // composite - - // Regular index - table.Index("name") - table.Index("name", "age") - - // Fulltext index - table.FullText("name") - table.FullText("name", "age") - - // Rename index - table.RenameIndex("users_name_index", "users_name") - - // Drop indexes - table.DropPrimary("id") - table.DropUnique("name") - table.DropUniqueByName("name_unique") - table.DropIndex("name") - table.DropIndexByName("name_index") - table.DropFullText("name") - table.DropFullTextByName("name_fulltext") -}) -``` - ---- - -## Foreign Keys - -```go -facades.Schema().Table("posts", func(table schema.Blueprint) { - table.UnsignedBigInteger("user_id") - table.Foreign("user_id").References("id").On("users") -}) - -// Drop foreign key -facades.Schema().Table("posts", func(table schema.Blueprint) { - table.DropForeign("user_id") - table.DropForeignByName("posts_user_id_foreign") -}) -``` diff --git a/.ai/prompt/models.md b/.ai/prompt/models.md deleted file mode 100644 index a7120905c..000000000 --- a/.ai/prompt/models.md +++ /dev/null @@ -1,920 +0,0 @@ -# Goravel ORM Models, Query Builder, Relationships, and Migrations - -## Model Definition - -Models live in `app/models/`. Model name is PascalCase; table name is plural snake_case. -`UserOrder` model maps to `user_orders` table. - -```go -package models - -import "github.com/goravel/framework/database/orm" - -type User struct { - orm.Model // adds id, created_at, updated_at - Name string - Avatar string - orm.SoftDeletes // adds deleted_at for soft delete support -} -``` - -### Custom table name - -```go -func (r *User) TableName() string { - return "goravel_user" -} -``` - -### Custom connection - -```go -func (r *User) Connection() string { - return "postgres" -} -``` - -### JSON field - -```go -import ( - "database/sql/driver" - "encoding/json" - "github.com/goravel/framework/database/orm" - "gorm.io/datatypes" -) - -type User struct { - orm.Model - Metadata datatypes.JSONMap `gorm:"type:json" json:"metadata"` - Profile *UserProfile `gorm:"type:json;serializer:json" json:"profile"` -} - -type UserProfile struct { - Bio string `json:"bio"` -} - -func (r *UserProfile) Value() (driver.Value, error) { - return json.Marshal(r) -} - -func (r *UserProfile) Scan(value any) error { - if data, ok := value.([]byte); ok && len(data) > 0 { - return json.Unmarshal(data, r) - } - return nil -} -``` - -### `any` field type - -```go -type User struct { - orm.Model - Detail any `gorm:"type:text"` -} -``` - -### Global scopes - -```go -import contractsorm "github.com/goravel/framework/contracts/database/orm" - -func (r *User) GlobalScopes() map[string]func(contractsorm.Query) contractsorm.Query { - return map[string]func(contractsorm.Query) contractsorm.Query{ - "active": func(query contractsorm.Query) contractsorm.Query { - return query.Where("active", true) - }, - } -} -``` - -Remove global scopes: - -```go -facades.Orm().Query().WithoutGlobalScopes().Get(&users) -facades.Orm().Query().WithoutGlobalScopes("active").Get(&users) -``` - -### Generate model - -```shell -./artisan make:model User -./artisan make:model user/User -./artisan make:model --table=users User -``` - ---- - -## Getting a Query Instance - -```go -facades.Orm().Query() -facades.Orm().Connection("mysql").Query() -facades.Orm().WithContext(ctx).Query() -``` - ---- - -## Querying - -### Find one by ID - -```go -var user models.User -facades.Orm().Query().Find(&user, 1) -// err is nil even when record does not exist -``` - -### Find or fail - -```go -var user models.User -err := facades.Orm().Query().FindOrFail(&user, 1) -// err is non-nil when record does not exist -``` - -### Find multiple by IDs - -```go -var users []models.User -facades.Orm().Query().Find(&users, []int{1, 2, 3}) -``` - -### First - -```go -var user models.User -facades.Orm().Query().First(&user) -// ordered by primary key, no error on missing record -``` - -### First or fail - -```go -import "github.com/goravel/framework/errors" - -var user models.User -err := facades.Orm().Query().FirstOrFail(&user) -if errors.Is(err, errors.OrmRecordNotFound) { - // not found -} -``` - -### First or default via callback - -```go -facades.Orm().Query().Where("name", "tom").FirstOr(&user, func() error { - user.Name = "default" - return nil -}) -``` - -### Get all matching - -```go -var users []models.User -facades.Orm().Query().Where("active", true).Get(&users) -``` - ---- - -## Where Conditions - -```go -facades.Orm().Query().Where("name", "tom") -facades.Orm().Query().Where("name = ?", "tom") -facades.Orm().Query().Where("age > ?", 18) - -facades.Orm().Query().WhereBetween("age", 1, 10) -facades.Orm().Query().WhereNotBetween("age", 1, 10) -facades.Orm().Query().WhereIn("name", []any{"a", "b"}) -facades.Orm().Query().WhereNotIn("name", []any{"a", "b"}) -facades.Orm().Query().WhereNull("deleted_at") - -facades.Orm().Query().OrWhere("name", "tim") -facades.Orm().Query().OrWhereIn("name", []any{"a", "b"}) -facades.Orm().Query().OrWhereNull("avatar") - -// All must match -facades.Orm().Query().WhereAll([]string{"weight", "height"}, "=", 200).Find(&products) - -// Any must match -facades.Orm().Query().WhereAny([]string{"name", "email"}, "=", "john").Find(&users) - -// None must match -facades.Orm().Query().WhereNone([]string{"age", "score"}, ">", 18).Find(&products) -``` - -### JSON column conditions - -```go -facades.Orm().Query().Where("preferences->dining->meal", "salad").First(&user) -facades.Orm().Query().WhereJsonContains("options->languages", "en").First(&user) -facades.Orm().Query().WhereJsonDoesntContain("options->languages", "en").First(&user) -facades.Orm().Query().WhereJsonContainsKey("contacts->personal->email").First(&user) -facades.Orm().Query().WhereJsonLength("options->languages", 1).First(&user) -``` - ---- - -## Ordering, Limit, Offset, Paginate - -```go -facades.Orm().Query().Order("name asc").Order("id desc").Get(&users) -facades.Orm().Query().OrderBy("name").Get(&users) // asc -facades.Orm().Query().OrderByDesc("name").Get(&users) // desc -facades.Orm().Query().InRandomOrder().Get(&users) - -facades.Orm().Query().Limit(10).Get(&users) -facades.Orm().Query().Offset(20).Limit(10).Get(&users) - -var total int64 -facades.Orm().Query().Paginate(1, 10, &users, &total) -``` - ---- - -## Select, Count, Aggregates - -```go -facades.Orm().Query().Select("name", "age").Get(&users) - -count, err := facades.Orm().Query().Model(&models.User{}).Count() -count, err := facades.Orm().Query().Table("users").Count() - -var sum int -facades.Orm().Query().Model(models.User{}).Sum("id", &sum) - -var avg float64 -facades.Orm().Query().Model(models.User{}).Average("age", &avg) - -var max int -facades.Orm().Query().Model(models.User{}).Max("age", &max) - -var min int -facades.Orm().Query().Model(models.User{}).Min("age", &min) -``` - ---- - -## Pluck, Distinct - -```go -var ages []int64 -facades.Orm().Query().Model(&models.User{}).Pluck("age", &ages) - -var users []models.User -facades.Orm().Query().Distinct("name").Find(&users) -``` - ---- - -## Group By / Having / Join - -```go -type Result struct { - Name string - Total int -} - -var result Result -facades.Orm().Query().Model(&models.User{}). - Select("name", "sum(age) as total"). - Group("name"). - Having("name = ?", "tom"). - Get(&result) -``` - -```go -facades.Orm().Query().Model(&models.User{}). - Select("users.name", "emails.email"). - Join("left join emails on emails.user_id = users.id"). - Scan(&result) -``` - ---- - -## Create - -```go -user := models.User{Name: "tom", Avatar: "avatar.png"} -err := facades.Orm().Query().Create(&user) -// user.ID is populated after create - -// Batch create -users := []models.User{{Name: "tom"}, {Name: "tim"}} -err := facades.Orm().Query().Create(&users) - -// Create via map (no model events) -err := facades.Orm().Query().Table("users").Create(map[string]any{"name": "Goravel"}) - -// Create via map (with model events) -err := facades.Orm().Query().Model(&models.User{}).Create(map[string]any{"name": "Goravel"}) -``` - ---- - -## Update - -```go -// Full save (updates all fields) -var user models.User -facades.Orm().Query().First(&user) -user.Name = "updated" -facades.Orm().Query().Save(&user) - -// Update single column -facades.Orm().Query().Model(&models.User{}).Where("name", "tom").Update("name", "hello") - -// Update via struct (non-zero fields only) -facades.Orm().Query().Model(&models.User{}).Where("id", 1).Update(models.User{Name: "hello"}) - -// Update via map (all fields including zeros) -facades.Orm().Query().Model(&models.User{}).Where("id", 1).Update(map[string]any{ - "name": "hello", - "age": 0, -}) - -// Update JSON field -facades.Orm().Query().Model(&models.User{}).Where("id", 1).Update("options->enabled", true) - -// Update or create -facades.Orm().Query().UpdateOrCreate(&user, models.User{Name: "tom"}, models.User{Avatar: "new.png"}) -``` - ---- - -## FirstOrCreate / FirstOrNew - -```go -// Find by conditions, create if not found -var user models.User -facades.Orm().Query().Where("gender", 1).FirstOrCreate(&user, models.User{Name: "tom"}) -facades.Orm().Query().Where("gender", 1).FirstOrCreate(&user, models.User{Name: "tom"}, models.User{Avatar: "avatar.png"}) - -// Like FirstOrCreate but does not save -facades.Orm().Query().Where("gender", 1).FirstOrNew(&user, models.User{Name: "tom"}) -// must call Save manually -facades.Orm().Query().Save(&user) -``` - ---- - -## Delete - -```go -var user models.User -facades.Orm().Query().Find(&user, 1) -res, err := facades.Orm().Query().Delete(&user) - -// Delete with conditions -res, err := facades.Orm().Query().Model(&models.User{}).Where("id", 1).Delete() - -// Force delete (skip soft delete) -facades.Orm().Query().Where("name", "tom").ForceDelete(&models.User{}) -facades.Orm().Query().Model(&models.User{}).Where("name", "tom").ForceDelete() - -num := res.RowsAffected -``` - ---- - -## Soft Delete - -```go -// Query including soft-deleted records -var user models.User -facades.Orm().Query().WithTrashed().First(&user) - -// Restore -facades.Orm().Query().WithTrashed().Restore(&models.User{ID: 1}) -facades.Orm().Query().Model(&models.User{ID: 1}).WithTrashed().Restore() -``` - ---- - -## Transactions - -```go -import "github.com/goravel/framework/contracts/database/orm" - -err := facades.Orm().Transaction(func(tx orm.Query) error { - var user models.User - if err := tx.Find(&user, 1); err != nil { - return err - } - return tx.Model(&user).Update("name", "updated") -}) -``` - -Manual transaction: - -```go -tx, err := facades.Orm().Query().BeginTransaction() -if err := tx.Create(&user); err != nil { - tx.Rollback() -} else { - tx.Commit() -} -``` - ---- - -## Raw SQL - -```go -type Result struct { - ID int - Name string -} - -var result Result -facades.Orm().Query().Raw("SELECT id, name FROM users WHERE name = ?", "tom").Scan(&result) - -// Raw update -res, err := facades.Orm().Query().Exec("DROP TABLE users") -num := res.RowsAffected -``` - ---- - -## Raw Expressions in Updates - -```go -import "github.com/goravel/framework/database/db" - -facades.Orm().Query().Model(&user).Update("age", db.Raw("age - ?", 1)) -``` - ---- - -## Scopes - -```go -import "github.com/goravel/framework/contracts/database/orm" - -func Paginator(page, limit int) func(orm.Query) orm.Query { - return func(query orm.Query) orm.Query { - offset := (page - 1) * limit - return query.Offset(offset).Limit(limit) - } -} - -facades.Orm().Query().Scopes(Paginator(2, 10)).Find(&users) -``` - ---- - -## Exists - -```go -exists, err := facades.Orm().Query().Model(&models.User{}).Where("name", "tom").Exists() -``` - ---- - -## Cursor (low-memory iteration) - -```go -cursor, err := facades.Orm().Query().Model(models.User{}).Cursor() -if err != nil { - return err -} -for row := range cursor { - var user models.User - if err := row.Scan(&user); err != nil { - return err - } -} -``` - ---- - -## Pessimistic Locking - -```go -facades.Orm().Query().Where("votes > ?", 100).SharedLock().Get(&users) -facades.Orm().Query().Where("votes > ?", 100).LockForUpdate().Get(&users) -``` - ---- - -## Relationships - -### One to One - -```go -type User struct { - orm.Model - Name string - Phone *Phone -} - -type Phone struct { - orm.Model - UserID uint - Name string -} -``` - -Custom foreign key: - -```go -type User struct { - orm.Model - Name string - Phone *Phone `gorm:"foreignKey:UserName"` -} - -type Phone struct { - orm.Model - UserName string - Name string -} -``` - -### One to Many - -```go -type Post struct { - orm.Model - Name string - Comments []*Comment -} - -type Comment struct { - orm.Model - PostID uint - Name string - Post *Post // inverse (belongs to) -} -``` - -### Many to Many - -```go -type User struct { - orm.Model - Name string - Roles []*Role `gorm:"many2many:role_user"` -} - -type Role struct { - orm.Model - Name string - Users []*User `gorm:"many2many:role_user"` -} -``` - -Custom pivot keys: - -```go -type User struct { - orm.Model - Name string - Roles []*Role `gorm:"many2many:role_user;joinForeignKey:UserName;joinReferences:RoleName"` -} -``` - -### Polymorphic - -```go -type Post struct { - orm.Model - Name string - Image *Image `gorm:"polymorphic:Imageable"` - Comments []*Comment `gorm:"polymorphic:Commentable"` -} - -type Image struct { - orm.Model - Name string - ImageableID uint - ImageableType string -} -``` - -Custom polymorphic value: - -```go -type Post struct { - orm.Model - Image *Image `gorm:"polymorphic:Imageable;polymorphicValue:master"` -} -``` - ---- - -## Eager Loading - -```go -// Eager load single relation -var books []models.Book -facades.Orm().Query().With("Author").Find(&books) - -// Multiple relations -facades.Orm().Query().With("Author").With("Publisher").Find(&book) - -// Nested -facades.Orm().Query().With("Author.Contacts").Find(&book) - -// With constraints -facades.Orm().Query().With("Author", "name = ?", "goravel").Find(&book) - -facades.Orm().Query().With("Author", func(query orm.Query) orm.Query { - return query.Where("active", true) -}).Find(&book) -``` - -### Lazy Eager Loading - -```go -var books []models.Book -facades.Orm().Query().Find(&books) - -for _, book := range books { - facades.Orm().Query().Load(&book, "Author") -} - -// With constraints -facades.Orm().Query().Load(&book, "Author", "name = ?", "goravel") - -// Only if not already loaded -facades.Orm().Query().LoadMissing(&book, "Author") -``` - ---- - -## Association Operations - -```go -var user models.User -facades.Orm().Query().Find(&user, 1) - -// Find all related -var posts []models.Post -facades.Orm().Query().Model(&user).Association("Posts").Find(&posts) - -// Append -facades.Orm().Query().Model(&user).Association("Posts").Append(&models.Post{Name: "new post"}) - -// Replace -facades.Orm().Query().Model(&user).Association("Posts").Replace([]*models.Post{post1, post2}) - -// Delete (removes relationship, not the record) -facades.Orm().Query().Model(&user).Association("Posts").Delete(post1) - -// Clear all -facades.Orm().Query().Model(&user).Association("Posts").Clear() - -// Count -count := facades.Orm().Query().Model(&user).Association("Posts").Count() -``` - ---- - -## ORM Events - -```go -import ( - contractsorm "github.com/goravel/framework/contracts/database/orm" - "github.com/goravel/framework/database/orm" -) - -type User struct { - orm.Model - Name string -} - -func (u *User) DispatchesEvents() map[contractsorm.EventType]func(contractsorm.Event) error { - return map[contractsorm.EventType]func(contractsorm.Event) error{ - contractsorm.EventCreating: func(event contractsorm.Event) error { - return nil - }, - contractsorm.EventCreated: func(event contractsorm.Event) error { - return nil - }, - contractsorm.EventUpdating: func(event contractsorm.Event) error { - return nil - }, - contractsorm.EventUpdated: func(event contractsorm.Event) error { - return nil - }, - contractsorm.EventDeleting: func(event contractsorm.Event) error { - return nil - }, - contractsorm.EventDeleted: func(event contractsorm.Event) error { - return nil - }, - } -} -``` - -### Observer - -```go -package observers - -import "github.com/goravel/framework/contracts/database/orm" - -type UserObserver struct{} - -func (u *UserObserver) Created(event orm.Event) error { return nil } -func (u *UserObserver) Updated(event orm.Event) error { return nil } -func (u *UserObserver) Deleted(event orm.Event) error { return nil } -``` - -Register observer in `WithCallback`: - -```go -WithCallback(func() { - facades.Orm().Observe(models.User{}, &observers.UserObserver{}) -}) -``` - -### Muting events - -```go -facades.Orm().Query().WithoutEvents().Find(&user, 1) -facades.Orm().Query().SaveQuietly(&user) -``` - ---- - -## Migrations - -### Create migration - -```shell -./artisan make:migration create_users_table -./artisan make:migration add_avatar_to_users_table -./artisan make:migration create_users_table -m User -``` - -### Migration struct - -```go -package migrations - -import ( - "github.com/goravel/framework/contracts/database/schema" - - "goravel/app/facades" -) - -type M20241207095921CreateUsersTable struct{} - -func (r *M20241207095921CreateUsersTable) Signature() string { - return "20241207095921_create_users_table" -} - -func (r *M20241207095921CreateUsersTable) Up() error { - if !facades.Schema().HasTable("users") { - return facades.Schema().Create("users", func(table schema.Blueprint) { - table.ID() - table.String("name").Nullable() - table.String("email").Nullable() - table.Timestamps() - table.SoftDeletes() - }) - } - return nil -} - -func (r *M20241207095921CreateUsersTable) Down() error { - return facades.Schema().DropIfExists("users") -} -``` - -Optional connection method: - -```go -func (r *M20241207095921CreateUsersTable) Connection() string { - return "postgres" -} -``` - -### Register migrations - -Generated migrations auto-register in `bootstrap/migrations.go`. Manual registration: - -```go -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithMigrations(migrations.Migrations). - WithConfig(config.Boot). - Create() -} -``` - -### Schema operations - -```go -// Create -facades.Schema().Create("users", func(table schema.Blueprint) { - table.ID() - table.String("name").Nullable() - table.String("email").Unique() - table.Timestamps() - table.SoftDeletes() -}) - -// Modify -facades.Schema().Table("users", func(table schema.Blueprint) { - table.String("avatar").Nullable() -}) - -// Rename column -facades.Schema().Table("users", func(table schema.Blueprint) { - table.RenameColumn("old_name", "new_name") -}) - -// Drop column -facades.Schema().Table("users", func(table schema.Blueprint) { - table.DropColumn("avatar") -}) - -// Drop table -facades.Schema().Drop("users") -facades.Schema().DropIfExists("users") -facades.Schema().Rename("users", "new_users") - -// Checks -facades.Schema().HasTable("users") -facades.Schema().HasColumn("users", "email") -facades.Schema().HasColumns("users", []string{"name", "email"}) -facades.Schema().HasIndex("users", "email_unique") -``` - -### Column types - -Boolean, Char, String, Text, MediumText, LongText, TinyText, Json, ID, BigIncrements, Integer, BigInteger, Decimal, Float, Double, Date, DateTime, Timestamp, Timestamps, SoftDeletes, Enum, Uuid, Ulid - -```go -table.ID() -table.String("name") -table.String("code", 10) -table.Text("body") -table.Integer("age") -table.BigInteger("views") -table.Boolean("active") -table.Decimal("price", 8, 2) -table.Timestamps() -table.SoftDeletes() -table.Enum("status", []any{"active", "inactive"}) -table.UnsignedBigInteger("user_id") -table.Column("geometry", "geometry") // custom type -``` - -### Column modifiers - -```go -table.String("name").Nullable() -table.String("role").Default("user") -table.Integer("sort").Default(0) -table.String("code").Unique() -table.Timestamp("published_at").UseCurrent() -``` - -### Indexes and foreign keys - -```go -// Indexes -table.Primary("id") -table.Unique("email") -table.Index("name") -table.FullText("body") - -// Foreign key -table.UnsignedBigInteger("user_id") -table.Foreign("user_id").References("id").On("users") - -// Drop index -table.DropUnique("email") -table.DropForeign("user_id") -``` - -### Migration commands - -```shell -./artisan migrate -./artisan migrate:rollback -./artisan migrate:rollback --step=5 -./artisan migrate:rollback --batch=2 -./artisan migrate:reset -./artisan migrate:refresh -./artisan migrate:fresh -./artisan migrate:status -``` - ---- - -## Gotchas - -- `Find` never errors on missing record. Always use `FindOrFail` when you need to confirm existence. -- `First` never errors on missing record. Use `FirstOrFail` instead. -- Struct-based `Update` silently skips zero values. Use `map[string]any` when zero values matter. -- Model events only fire when a model instance is passed to `Model()`. Batch operations without a model skip events. -- `WithTrashed()` must be chained before the query method to include soft-deleted records. -- Do not use `DispatchesEvents` and an observer on the same model: only `DispatchesEvents` applies when both are set. diff --git a/.ai/prompt/orm.md b/.ai/prompt/orm.md deleted file mode 100644 index 45951024a..000000000 --- a/.ai/prompt/orm.md +++ /dev/null @@ -1,474 +0,0 @@ -# Goravel ORM - -## Model Definition - -```go -package models - -import "github.com/goravel/framework/database/orm" - -type User struct { - orm.Model // provides: ID, CreatedAt, UpdatedAt - Name string - Avatar string - Detail any `gorm:"type:text"` - orm.SoftDeletes // adds: DeletedAt (soft delete) -} - -// Optional: override table name -func (r *User) TableName() string { - return "goravel_user" -} - -// Optional: specify database connection -func (r *User) Connection() string { - return "postgres" -} -``` - -Convention: model `UserOrder` → table `user_orders`. - -Custom primary key (when not using `orm.Model`): - -```go -type User struct { - ID uint `gorm:"primaryKey"` - Name string -} -``` - -Generate model: - -```shell -./artisan make:model User -./artisan make:model --table=users User # generate from existing table -./artisan make:model --table=users -f User # force overwrite -``` - -### JSON fields - -```go -import ( - "database/sql/driver" - "encoding/json" - "gorm.io/datatypes" -) - -type User struct { - orm.Model - Json1 datatypes.JSONMap `gorm:"type:json" json:"json1"` - Json2 *UserData `gorm:"type:json;serializer:json" json:"json2"` -} -``` - ---- - -## Global Scopes (v1.17) - -// BREAKING v1.17: GlobalScopes() must return map[string]func(orm.Query) orm.Query, NOT []func(...) - -```go -import contractsorm "github.com/goravel/framework/contracts/database/orm" - -func (r *User) GlobalScopes() map[string]func(contractsorm.Query) contractsorm.Query { - return map[string]func(contractsorm.Query) contractsorm.Query{ - "active": func(query contractsorm.Query) contractsorm.Query { - return query.Where("active", 1) - }, - } -} -``` - -Remove global scopes in a query: - -```go -// Remove all global scopes -facades.Orm().Query().WithoutGlobalScopes().Get(&users) - -// Remove specific global scope by name -facades.Orm().Query().WithoutGlobalScopes("active").Get(&users) -``` - ---- - -## Query Operations - -### Get query instance - -```go -facades.Orm().Query() -facades.Orm().Connection("mysql").Query() -facades.Orm().WithContext(ctx).Query() -``` - -### Find by ID - -```go -var user models.User -facades.Orm().Query().Find(&user, 1) - -var users []models.User -facades.Orm().Query().Find(&users, []int{1, 2, 3}) -``` - -Note: `Find` returns nil error when record is not found. Use `FindOrFail` to error on missing. - -### FindOrFail (errors if not found) - -```go -// BREAKING: Find returns nil error even when not found -err := facades.Orm().Query().FindOrFail(&user, 1) -``` - -### First - -```go -var user models.User -facades.Orm().Query().First(&user) -facades.Orm().Query().Where("name", "tom").First(&user) - -// First or fail -err := facades.Orm().Query().FirstOrFail(&user) - -// First or execute closure -facades.Orm().Query().Where("name", "tom").FirstOr(&user, func() error { - user.Name = "default" - return nil -}) -``` - -### Get (multiple) - -```go -var users []models.User -facades.Orm().Query().Where("id in ?", []int{1, 2, 3}).Get(&users) -``` - -### FirstOrCreate / FirstOrNew - -```go -var user models.User -// Find by conditions, or create: -facades.Orm().Query().Where("gender", 1).FirstOrCreate(&user, models.User{Name: "tom"}) -facades.Orm().Query().Where("gender", 1).FirstOrCreate(&user, models.User{Name: "tom"}, models.User{Avatar: "avatar"}) - -// Find or return new (unsaved) model: -facades.Orm().Query().Where("gender", 1).FirstOrNew(&user, models.User{Name: "tom"}) -``` - ---- - -## Where Clauses - -```go -facades.Orm().Query().Where("name", "tom") -facades.Orm().Query().Where("name = ?", "tom") -facades.Orm().Query().WhereBetween("age", 1, 10) -facades.Orm().Query().WhereNotBetween("age", 1, 10) -facades.Orm().Query().WhereIn("name", []any{"a", "b"}) -facades.Orm().Query().WhereNotIn("name", []any{"a"}) -facades.Orm().Query().WhereNull("name") -facades.Orm().Query().OrWhere("name", "tim") -facades.Orm().Query().OrWhereIn("name", []any{"a"}) -facades.Orm().Query().OrWhereNull("name") - -// v1.17: new Where methods -facades.Orm().Query().WhereAll([]string{"weight", "height"}, "=", 200) // AND all columns match -facades.Orm().Query().WhereAny([]string{"name", "email"}, "=", "John") // OR any column matches -facades.Orm().Query().WhereNone([]string{"age", "score"}, ">", 18) // NOT any column matches -``` - -### JSON column queries - -```go -facades.Orm().Query().Where("preferences->dining->meal", "salad").First(&user) -facades.Orm().Query().WhereJsonContains("options->languages", "en").First(&user) -facades.Orm().Query().WhereJsonContainsKey("contacts->personal->email").First(&user) -facades.Orm().Query().WhereJsonLength("options->languages", 1).First(&user) -``` - ---- - -## Select, Order, Limit, Offset - -```go -facades.Orm().Query().Select("name", "age").Get(&users) -facades.Orm().Query().Order("sort asc").Order("id desc").Get(&users) -facades.Orm().Query().OrderBy("sort").Get(&users) -facades.Orm().Query().OrderByDesc("sort").Get(&users) -facades.Orm().Query().InRandomOrder().Get(&users) -facades.Orm().Query().Limit(10).Get(&users) -facades.Orm().Query().Offset(20).Limit(10).Get(&users) -``` - -### Paginate - -```go -var users []models.User -var total int64 -facades.Orm().Query().Paginate(1, 10, &users, &total) -``` - -### Count, Exists - -```go -count, err := facades.Orm().Query().Model(&models.User{}).Count() -exists, err := facades.Orm().Query().Model(&models.User{}).Where("name", "tom").Exists() -``` - -### Aggregates (v1.17) - -```go -// BREAKING v1.17: Sum signature changed — Sum(column string, dest any) error (was int64, error) -var sum int -err := facades.Orm().Query().Model(models.User{}).Sum("id", &sum) - -var avg float64 -err = facades.Orm().Query().Model(models.User{}).Average("age", &avg) - -var max, min int -err = facades.Orm().Query().Model(models.User{}).Max("age", &max) -err = facades.Orm().Query().Model(models.User{}).Min("age", &min) -``` - -### Group By / Having / Join - -```go -facades.Orm().Query().Model(&models.User{}). - Select("name", "sum(age) as total"). - Group("name"). - Having("name = ?", "tom"). - Get(&result) - -facades.Orm().Query().Model(&models.User{}). - Select("users.name", "emails.email"). - Join("left join emails on emails.user_id = users.id"). - Scan(&result) -``` - -### Pluck (single column) - -```go -var ages []int64 -facades.Orm().Query().Model(&models.User{}).Pluck("age", &ages) -``` - -### Cursor (memory-efficient iteration) - -```go -cursor, err := facades.Orm().Query().Model(models.User{}).Cursor() -for row := range cursor { - var user models.User - if err := row.Scan(&user); err != nil { - return err - } -} -``` - -### Raw SQL - -```go -facades.Orm().Query().Raw("SELECT id, name FROM users WHERE name = ?", "tom").Scan(&result) - -res, err := facades.Orm().Query().Exec("DROP TABLE users") -num := res.RowsAffected -``` - ---- - -## Create - -```go -user := models.User{Name: "tom", Age: 18} -err := facades.Orm().Query().Create(&user) - -// Batch create -users := []models.User{{Name: "tom"}, {Name: "tim"}} -err = facades.Orm().Query().Create(&users) - -// Create via map (no model events) -err = facades.Orm().Query().Table("users").Create(map[string]any{"name": "Goravel"}) - -// Create via map (with model events) -err = facades.Orm().Query().Model(&models.User{}).Create(map[string]any{"name": "Goravel"}) -``` - ---- - -## Update - -```go -// Update single column -facades.Orm().Query().Model(&models.User{}).Where("name", "tom").Update("name", "hello") - -// Update via struct (zero values are skipped) -facades.Orm().Query().Model(&models.User{}).Where("name", "tom").Update(models.User{Name: "hello"}) - -// Update via map (zero values ARE updated) -facades.Orm().Query().Model(&models.User{}).Where("name", "tom").Update(map[string]any{"name": "hello", "age": 0}) - -// Save (full update of existing model) -var user models.User -facades.Orm().Query().First(&user) -user.Name = "new-name" -facades.Orm().Query().Save(&user) - -// UpdateOrCreate -facades.Orm().Query().UpdateOrCreate(&user, models.User{Name: "name"}, models.User{Avatar: "avatar"}) - -// Raw expression in update -import "github.com/goravel/framework/database/db" -facades.Orm().Query().Model(&user).Update("age", db.Raw("age - ?", 1)) -``` - ---- - -## Delete - -```go -// Soft delete -facades.Orm().Query().Delete(&user) -facades.Orm().Query().Model(&models.User{}).Where("id", 1).Delete() - -// Force delete (bypass soft delete) -facades.Orm().Query().ForceDelete(&models.User{}, 1) -facades.Orm().Query().Model(&models.User{}).Where("name", "tom").ForceDelete() - -// Query with soft-deleted records -facades.Orm().Query().WithTrashed().First(&user) - -// Restore soft-deleted record -facades.Orm().Query().WithTrashed().Restore(&models.User{ID: 1}) -``` - ---- - -## Transactions - -```go -// Automatic (closure-based) -return facades.Orm().Transaction(func(tx orm.Query) error { - var user models.User - return tx.Find(&user, user.ID) -}) - -// Manual -tx, err := facades.Orm().Query().BeginTransaction() -if err := tx.Create(&user); err != nil { - tx.Rollback() -} else { - tx.Commit() -} -``` - ---- - -## Scopes - -```go -func Paginator(page, limit int) func(orm.Query) orm.Query { - return func(query orm.Query) orm.Query { - offset := (page - 1) * limit - return query.Offset(offset).Limit(limit) - } -} - -facades.Orm().Query().Scopes(Paginator(2, 10)).Find(&users) -``` - ---- - -## Pessimistic Locking - -```go -facades.Orm().Query().Where("votes > ?", 100).SharedLock().Get(&users) -facades.Orm().Query().Where("votes > ?", 100).LockForUpdate().Get(&users) -``` - ---- - -## Model Events (DispatchesEvents) - -```go -import contractsorm "github.com/goravel/framework/contracts/database/orm" - -func (u *User) DispatchesEvents() map[contractsorm.EventType]func(contractsorm.Event) error { - return map[contractsorm.EventType]func(contractsorm.Event) error{ - contractsorm.EventCreating: func(event contractsorm.Event) error { return nil }, - contractsorm.EventCreated: func(event contractsorm.Event) error { return nil }, - contractsorm.EventUpdating: func(event contractsorm.Event) error { return nil }, - contractsorm.EventUpdated: func(event contractsorm.Event) error { return nil }, - contractsorm.EventDeleting: func(event contractsorm.Event) error { return nil }, - contractsorm.EventDeleted: func(event contractsorm.Event) error { return nil }, - contractsorm.EventSaving: func(event contractsorm.Event) error { return nil }, - contractsorm.EventSaved: func(event contractsorm.Event) error { return nil }, - contractsorm.EventRetrieved: func(event contractsorm.Event) error { return nil }, - contractsorm.EventRestored: func(event contractsorm.Event) error { return nil }, - } -} -``` - ---- - -## Observers - -Generate: - -```shell -./artisan make:observer UserObserver -``` - -```go -package observers - -import "github.com/goravel/framework/contracts/database/orm" - -type UserObserver struct{} - -func (u *UserObserver) Created(event orm.Event) error { return nil } -func (u *UserObserver) Updated(event orm.Event) error { return nil } -func (u *UserObserver) Deleted(event orm.Event) error { return nil } -func (u *UserObserver) Restored(event orm.Event) error { return nil } -``` - -Register in `WithCallback`: - -```go -WithCallback(func() { - facades.Orm().Observe(models.User{}, &observers.UserObserver{}) -}) -``` - -Event parameter methods: `Context()`, `GetAttribute(key)`, `GetOriginal(key)`, `IsDirty(key)`, `IsClean(key)`, `Query()`, `SetAttribute(key, val)`. - ---- - -## Muting Events - -```go -facades.Orm().Query().WithoutEvents().Find(&user, 1) - -// Save without triggering events -facades.Orm().Query().SaveQuietly(&user) -``` - ---- - -## Connection Pool - -```go -db, err := facades.Orm().DB() -db.SetMaxIdleConns(10) -db.SetMaxOpenConns(100) -db.SetConnMaxLifetime(time.Hour) -``` - ---- - -## Gotchas - -- `Find(&model, id)` returns nil error even when no record is found. Use `FindOrFail` to get an error on missing. -- Struct updates with `Update(struct{})` skip zero-value fields. Use `map[string]any` to set zero values. -- `GlobalScopes()` must return `map[string]func(orm.Query) orm.Query` — not a slice. -- `Sum(column, &dest)` signature: `error` only (was `(int64, error)` in v1.16). -- Model events only trigger when operating on a model instance. Batch operations do not trigger events. diff --git a/.ai/prompt/process.md b/.ai/prompt/process.md deleted file mode 100644 index 909a0687e..000000000 --- a/.ai/prompt/process.md +++ /dev/null @@ -1,207 +0,0 @@ -# Goravel Process Facade (v1.17) - -The Process facade provides a fluent API for executing external commands via `facades.Process()`. - -## Synchronous Execution - -```go -import "goravel/app/facades" - -// Run with separate args -result := facades.Process().Run("ls", "-la") - -// Run as shell string (spaces/&/| trigger /bin/sh -c on Linux, cmd /C on Windows) -result = facades.Process().Run("echo Hello, World!") - -if result.Failed() { - panic(result.Error()) -} -fmt.Println(result.Output()) -``` - -### Result Interface - -```go -result.Command() // string: original command -result.Output() // string: stdout -result.ErrorOutput() // string: stderr -result.ExitCode() // int: exit code -result.Failed() // bool: true if exit code != 0 -result.Error() // error: from command execution -result.SeeInOutput("go.mod") // bool -result.SeeInErrorOutput("error") // bool -``` - ---- - -## Process Options - -```go -facades.Process(). - Path("/var/www/html"). // working directory - Timeout(10 * time.Minute). // kill after duration - Env(map[string]string{ // add env vars (inherits system envs) - "FOO": "BAR", - "API_KEY": "secret", - }). - Input(strings.NewReader("stdin data")). // pipe stdin - Run("command", "arg1") -``` - ---- - -## Real-time Output (Streaming) - -```go -import "github.com/goravel/framework/contracts/process" - -result := facades.Process().OnOutput(func(typ process.OutputType, b []byte) { - fmt.Print(string(b)) -}).Run("ls", "-la") -``` - ---- - -## Suppress / Disable Output - -```go -// Captures output but doesn't print during execution -facades.Process().Quietly().Run("command") - -// Does not capture in memory (saves memory); use OnOutput to stream -facades.Process().DisableBuffering().OnOutput(func(typ process.OutputType, b []byte) { - // stream here -}).Run("command") -``` - ---- - -## Pipelines - -```go -import "github.com/goravel/framework/contracts/process" - -result := facades.Process().Pipe(func(pipe process.Pipe) { - pipe.Command("echo", "Hello, World!") - pipe.Command("grep World") // shell string - pipe.Command("tr", "a-z", "A-Z") -}).Run() - -// Options must be applied AFTER Pipe(), not before: -result = facades.Process().Pipe(func(pipe process.Pipe) { - pipe.Command("cat", "file.txt").As("source") - pipe.Command("grep error").As("filter") -}).Timeout(30 * time.Second).OnOutput(func(typ process.OutputType, line []byte, key string) { - fmt.Printf("[%s] %s", key, string(line)) -}).Run() -``` - ---- - -## Asynchronous Processes - -```go -running, err := facades.Process().Timeout(10 * time.Second).Start("sleep", "5") - -// Continue doing other work... - -result := running.Wait() // must always call Wait() - -// Non-blocking check via channel -select { -case <-running.Done(): - // finished -case <-time.After(1 * time.Second): - // still running -} -result = running.Wait() -``` - -### Inspect and Signal - -```go -running, err := facades.Process().Start("sleep", "60") - -pid := running.PID() -running.Running() // bool: process active? -running.Signal(os.Interrupt) // send specific signal -running.Stop(5 * time.Second) // SIGTERM, then SIGKILL if still alive after timeout -``` - ---- - -## Concurrent Processes (Pool) - -```go -import "github.com/goravel/framework/contracts/process" - -results, err := facades.Process().Pool(func(pool process.Pool) { - pool.Command("sleep", "1").As("first") - pool.Command("sleep 2").As("second") -}).Run() - -fmt.Println(results["first"].Output()) -fmt.Println(results["second"].Output()) -``` - -### Pool Options - -```go -facades.Process().Pool(func(pool process.Pool) { - for i := 0; i < 10; i++ { - pool.Command("worker", fmt.Sprintf("%d", i)).As(fmt.Sprintf("worker-%d", i)) - } -}). - Concurrency(3). // max 3 concurrent - Timeout(1 * time.Minute). // global timeout for entire pool - OnOutput(func(typ process.OutputType, line []byte, key string) { - fmt.Printf("[%s] %s", key, string(line)) - }). - Run() -``` - -### Per-process Options in Pool - -```go -pool.Command("find", "/", "-name", "*.log"). - As("search"). - Path("/var/www"). - Timeout(10 * time.Second). - Env(map[string]string{"DEBUG": "1"}). - Quietly(). - DisableBuffering() -``` - -### Async Pool - -```go -runningPool, err := facades.Process().Pool(func(pool process.Pool) { - pool.Command("sleep", "5").As("long_task") -}).Start() - -if runningPool.Running() { - fmt.Println("Pool active...") -} - -// Interact -pids := runningPool.PIDs() -runningPool.Signal(os.Interrupt) -runningPool.Stop(2 * time.Second) - -select { -case <-runningPool.Done(): - // all finished -case <-time.After(10 * time.Second): - runningPool.Stop(1 * time.Second) -} - -results := runningPool.Wait() -``` - ---- - -## Gotchas - -- Always call `running.Wait()` after `Start()` even if you use `Done()` — it reaps OS resources. -- Pool `OnOutput` callback is called from multiple goroutines; make it thread-safe. -- Process options (`Timeout`, `Env`, `Input`) set before `Pipe()` are ignored — apply them after `Pipe()`. diff --git a/.ai/prompt/queue.md b/.ai/prompt/queue.md deleted file mode 100644 index 80f6855b8..000000000 --- a/.ai/prompt/queue.md +++ /dev/null @@ -1,180 +0,0 @@ -# Goravel Queue - -## Configuration - -Configure in `config/queue.go`. Default driver: `sync` (runs inline, no queue). - -// BREAKING v1.17: Machinery driver is completely removed. Migrate to `redis`, `database`, or `sync`. - -Available drivers: `sync`, `database`, `redis` (external package), `custom`. - ---- - -## Define a Job - -```shell -./artisan make:job ProcessPodcast -./artisan make:job user/ProcessPodcast -``` - -```go -package jobs - -import "time" - -type ProcessPodcast struct{} - -// Signature is the unique identifier for the job -func (r *ProcessPodcast) Signature() string { - return "process_podcast" -} - -// Handle executes the job; args come from dispatch -func (r *ProcessPodcast) Handle(args ...any) error { - // process args - return nil -} - -// ShouldRetry (optional) controls retry behavior on failure -func (r *ProcessPodcast) ShouldRetry(err error, attempt int) (retryable bool, delay time.Duration) { - return true, 10 * time.Second -} -``` - ---- - -## Register Jobs - -Jobs created by `make:job` auto-register in `bootstrap/jobs.go`. Register in `bootstrap/app.go`: - -```go -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithJobs(Jobs). - WithConfig(config.Boot). - Create() -} -``` - ---- - -## Dispatch Jobs - -```go -import ( - "github.com/goravel/framework/contracts/queue" - "goravel/app/facades" - "goravel/app/jobs" -) - -// Default connection and queue -err := facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{ - {Type: "string", Value: "podcast.mp3"}, - {Type: "int", Value: 42}, -}).Dispatch() - -// Synchronous dispatch (runs immediately, no queue) -err = facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}).DispatchSync() - -// Specific queue -err = facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}).OnQueue("processing").Dispatch() - -// Specific connection -err = facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}).OnConnection("redis").Dispatch() - -// Connection + queue -err = facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}). - OnConnection("redis").OnQueue("processing").Dispatch() - -// Delayed dispatch -err = facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}). - Delay(time.Now().Add(100 * time.Second)).Dispatch() -``` - ---- - -## Job Chaining - -Jobs run in order; if one fails, remaining jobs are not executed: - -```go -err := facades.Queue().Chain([]queue.Jobs{ - { - Job: &jobs.ProcessPodcast{}, - Args: []queue.Arg{{Type: "int", Value: 1}}, - }, - { - Job: &jobs.NotifySubscribers{}, - Args: []queue.Arg{{Type: "int", Value: 2}}, - }, -}).Dispatch() -``` - ---- - -## Supported `queue.Arg.Type` Values - -``` -bool, int, int8, int16, int32, int64, -uint, uint8, uint16, uint32, uint64, -float32, float64, string, -[]bool, []int, []int8, []int16, []int32, []int64, -[]uint, []uint8, []uint16, []uint32, []uint64, -[]float32, []float64, []string -``` - ---- - -## Database Driver Setup - -For the `database` driver, create the jobs table using the migration at: -`database/migrations/20210101000002_create_jobs_table.go` - ---- - -## Custom Driver - -```go -// config/queue.go -"connections": map[string]any{ - "redis": map[string]any{ - "driver": "custom", - "connection": "default", - "queue": "default", - "via": func() (queue.Driver, error) { - return redisfacades.Queue("redis"), nil - }, - }, -}, -``` - ---- - -## Failed Jobs - -```shell -# View failed jobs -./artisan queue:failed - -# Retry a specific job (UUID from failed_jobs table) -./artisan queue:retry 4427387e-c75a-4295-afb3-2f3d0e410494 - -# Retry all failed jobs -./artisan queue:retry all - -# Retry by connection or queue -./artisan queue:retry --connection=redis -./artisan queue:retry --queue=processing -``` - ---- - -## Custom Queue Runner - -```go -WithRunners(func() []contractsfoundation.Runner { - return []contractsfoundation.Runner{ - YourCustomQueueRunner, - } -}) -``` diff --git a/.ai/prompt/queues.md b/.ai/prompt/queues.md deleted file mode 100644 index 016733a2a..000000000 --- a/.ai/prompt/queues.md +++ /dev/null @@ -1,260 +0,0 @@ -# Goravel Queues - -## Job Definition - -Jobs live in `app/jobs/`. - -```go -package jobs - -type ProcessPodcast struct{} - -func (r *ProcessPodcast) Signature() string { - return "process_podcast" -} - -func (r *ProcessPodcast) Handle(args ...any) error { - // args are positional, matching the []queue.Arg slice passed at dispatch - return nil -} -``` - -### With retry control - -```go -import "time" - -func (r *ProcessPodcast) ShouldRetry(err error, attempt int) (retryable bool, delay time.Duration) { - return true, 10 * time.Second -} -``` - -### Generate job - -```shell -./artisan make:job ProcessPodcast -./artisan make:job user/ProcessPodcast -``` - ---- - -## Register Jobs - -Generated jobs auto-register in `bootstrap/jobs.go`. Manual registration: - -```go -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithJobs(jobs.Jobs). - WithConfig(config.Boot). - Create() -} -``` - ---- - -## Dispatching Jobs - -```go -import ( - "github.com/goravel/framework/contracts/queue" - "goravel/app/facades" - "goravel/app/jobs" -) - -// Dispatch to default queue -err := facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{ - {Type: "int", Value: 1}, - {Type: "string", Value: "example"}, -}).Dispatch() - -// Dispatch synchronously (runs immediately in current process) -err := facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{ - {Type: "int", Value: 1}, -}).DispatchSync() -``` - ---- - -## Dispatch Options - -### Named queue - -```go -err := facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}).OnQueue("emails").Dispatch() -``` - -### Named connection - -```go -err := facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}).OnConnection("redis").Dispatch() -``` - -### Connection and queue together - -```go -err := facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}). - OnConnection("redis"). - OnQueue("high"). - Dispatch() -``` - -### Delayed dispatch - -```go -import "time" - -err := facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{}). - Delay(time.Now().Add(100 * time.Second)). - Dispatch() -``` - ---- - -## Job Chaining - -Executes jobs in order. Stops on first failure. - -```go -err := facades.Queue().Chain([]queue.Jobs{ - { - Job: &jobs.ProcessPodcast{}, - Args: []queue.Arg{ - {Type: "int", Value: 1}, - }, - }, - { - Job: &jobs.SendPodcastEmail{}, - Args: []queue.Arg{ - {Type: "string", Value: "user@example.com"}, - }, - }, -}).Dispatch() -``` - ---- - -## Supported Arg Types - -``` -bool, int, int8, int16, int32, int64 -uint, uint8, uint16, uint32, uint64 -float32, float64 -string -[]bool, []int, []int8, []int16, []int32, []int64 -[]uint, []uint8, []uint16, []uint32, []uint64 -[]float32, []float64 -[]string -``` - ---- - -## Drivers - -Configure in `config/queue.go`. - -| Driver | Description | -|--------|-------------| -| `sync` | Runs in current process, no queue (default) | -| `database` | Stores jobs in database table | -| custom | Implement `contracts/queue/driver.go` interface | - -### Database driver setup - -Create the jobs table migration: `20210101000002_create_jobs_table.go` (included in the default goravel template). - -### Custom driver configuration - -```go -// config/queue.go -"connections": map[string]any{ - "redis": map[string]any{ - "driver": "custom", - "connection": "default", - "queue": "default", - "via": func() (queue.Driver, error) { - return redisfacades.Queue("redis") - }, - }, -}, -``` - ---- - -## Failed Jobs - -View failed jobs: - -```shell -./artisan queue:failed -``` - -Retry failed jobs: - -```shell -# Single job by UUID -./artisan queue:retry 4427387e-c75a-4295-afb3-2f3d0e410494 - -# Multiple jobs -./artisan queue:retry uuid1 uuid2 - -# All failed jobs -./artisan queue:retry all - -# By connection -./artisan queue:retry --connection=redis - -# By queue -./artisan queue:retry --queue=processing -``` - ---- - -## Reading Args in Handle - -Args are passed positionally. Match by index: - -```go -func (r *ProcessPodcast) Handle(args ...any) error { - podcastID := args[0].(int) - title := args[1].(string) - return nil -} -``` - -Dispatched with: - -```go -facades.Queue().Job(&jobs.ProcessPodcast{}, []queue.Arg{ - {Type: "int", Value: podcastID}, - {Type: "string", Value: title}, -}).Dispatch() -``` - ---- - -## Custom Queue Workers (Runners) - -The default queue worker is started automatically. To run additional workers with different config, add a runner: - -```go -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithConfig(config.Boot). - WithRunners(func() []contractsfoundation.Runner { - return []contractsfoundation.Runner{ - NewCustomQueueRunner(), - } - }). - Create() -} -``` - ---- - -## Gotchas - -- `Type` in `queue.Arg` must be an exact string from the supported list. Invalid types cause silent failures. -- Arguments in `Handle(args ...any)` are positional. Order must match the `[]queue.Arg` slice passed at dispatch. -- The sync driver runs jobs synchronously in the same process. It does not queue anything. Use it for testing or development only. -- For database driver, create the `jobs` and `failed_jobs` tables before dispatching. diff --git a/.ai/prompt/route.md b/.ai/prompt/route.md deleted file mode 100644 index 4bb967874..000000000 --- a/.ai/prompt/route.md +++ /dev/null @@ -1,360 +0,0 @@ -# Goravel Routing - -## Basic Routing - -```go -import "github.com/goravel/framework/contracts/http" - -facades.Route().Get("/", func(ctx http.Context) http.Response { - return ctx.Response().Json(http.StatusOK, http.Json{"Hello": "Goravel"}) -}) -facades.Route().Post("/", userController.Show) -facades.Route().Put("/", userController.Show) -facades.Route().Delete("/", userController.Show) -facades.Route().Patch("/", userController.Show) -facades.Route().Options("/", userController.Show) -facades.Route().Any("/", userController.Show) -``` - -Every handler must have signature: `func(ctx http.Context) http.Response` - ---- - -## Route Parameters - -```go -facades.Route().Get("/input/{id}", func(ctx http.Context) http.Response { - return ctx.Response().Success().Json(http.Json{ - "id": ctx.Request().Input("id"), - }) -}) -``` - ---- - -## Resource Routing - -```go -import "github.com/goravel/framework/contracts/http" - -resourceController := NewResourceController() -facades.Route().Resource("/resource", resourceController) - -type ResourceController struct{} -func NewResourceController() *ResourceController { return &ResourceController{} } - -// GET /resource → Index -// GET /resource/{id} → Show -// POST /resource → Store -// PUT /resource/{id} → Update -// DELETE /resource/{id} → Destroy -func (c *ResourceController) Index(ctx http.Context) http.Response { ... } -func (c *ResourceController) Show(ctx http.Context) http.Response { ... } -func (c *ResourceController) Store(ctx http.Context) http.Response { ... } -func (c *ResourceController) Update(ctx http.Context) http.Response { ... } -func (c *ResourceController) Destroy(ctx http.Context) http.Response { ... } -``` - ---- - -## Group Routing - -```go -import "github.com/goravel/framework/contracts/route" - -facades.Route().Group(func(router route.Router) { - router.Get("group/{id}", func(ctx http.Context) http.Response { - return ctx.Response().Success().String(ctx.Request().Query("id", "1")) - }) -}) -``` - ---- - -## Routing Prefix - -```go -facades.Route().Prefix("users").Get("/", userController.Show) -``` - ---- - -## Middleware on Routes - -```go -import "github.com/goravel/framework/http/middleware" - -facades.Route().Middleware(middleware.Cors()).Get("users", userController.Show) - -// Multiple middleware: -facades.Route().Middleware(middleware.Auth(), middleware.Throttle("api")).Get("profile", profileController.Show) -``` - ---- - -## Route Naming - -```go -facades.Route().Get("users", userController.Index).Name("users.index") -``` - -## Get Route Info by Name - -```go -route := facades.Route().Info("users.index") -``` - -## Get All Routes - -```go -routes := facades.Route().GetRoutes() -``` - -## List Routes (CLI) - -```shell -./artisan route:list -``` - ---- - -## Fallback Routes - -```go -facades.Route().Fallback(func(ctx http.Context) http.Response { - return ctx.Response().String(404, "not found") -}) -``` - ---- - -## File Routing - -```go -import "net/http" - -facades.Route().Static("static", "./public") -facades.Route().StaticFile("static-file", "./public/logo.png") -facades.Route().StaticFS("static-fs", http.Dir("./public")) -``` - ---- - -## Default Route File Registration - -```go -// bootstrap/app.go -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithRouting(func() { - routes.Web() - routes.Api() - }). - WithConfig(config.Boot). - Create() -} -``` - ---- - -## Rate Limiting - -### Define Rate Limiters - -Define in `WithCallback` in `bootstrap/app.go`: - -```go -import ( - contractshttp "github.com/goravel/framework/contracts/http" - "github.com/goravel/framework/http/limit" -) - -WithCallback(func() { - // Simple per-minute limit - facades.RateLimiter().For("global", func(ctx contractshttp.Context) contractshttp.Limit { - return limit.PerMinute(1000) - }) - - // Custom response on limit exceeded - facades.RateLimiter().For("api", func(ctx contractshttp.Context) contractshttp.Limit { - return limit.PerMinute(60).Response(func(ctx contractshttp.Context) { - ctx.Request().AbortWithStatus(http.StatusTooManyRequests) - }) - }) - - // Dynamic limit based on request - facades.RateLimiter().For("dynamic", func(ctx contractshttp.Context) contractshttp.Limit { - if is_vip() { - return limit.PerMinute(100) - } - return nil - }) - - // Segment by IP - facades.RateLimiter().For("per-ip", func(ctx contractshttp.Context) contractshttp.Limit { - if is_vip() { - return limit.PerMinute(100).By(ctx.Request().Ip()) - } - return nil - }) - - // Segment by user/IP with fallback - facades.RateLimiter().For("user-or-ip", func(ctx contractshttp.Context) contractshttp.Limit { - if userID != 0 { - return limit.PerMinute(100).By(userID) - } - return limit.PerMinute(10).By(ctx.Request().Ip()) - }) -}) -``` - -### Multiple Rate Limits - -```go -facades.RateLimiter().ForWithLimits("login", func(ctx contractshttp.Context) []contractshttp.Limit { - return []contractshttp.Limit{ - limit.PerMinute(500), - limit.PerMinute(100).By(ctx.Request().Ip()), - } -}) -``` - -### Attach Rate Limiter to Route - -```go -import "github.com/goravel/framework/http/middleware" - -facades.Route().Middleware(middleware.Throttle("global")).Get("/", func(ctx http.Context) http.Response { - return ctx.Response().Json(200, http.Json{"Hello": "Goravel"}) -}) -``` - ---- - -## CORS - -CORS is enabled by default. Configure in `config/cors.go`: - -```go -// config/cors.go -config.Add("cors", map[string]any{ - "paths": []string{}, // paths to apply CORS to (empty = all) - "allowed_methods": []string{"*"}, // or []string{"GET", "POST", "PUT", "DELETE"} - "allowed_origins": []string{"*"}, // or []string{"https://example.com"} - "allowed_headers": []string{"*"}, // or []string{"Content-Type", "Authorization"} - "exposed_headers": []string{}, - "max_age": 0, // preflight cache seconds (0 = no cache) - "supports_credentials": false, // true enables cookies/auth headers -}) -``` - -Apply CORS middleware on specific routes: - -```go -import "github.com/goravel/framework/http/middleware" - -facades.Route().Middleware(middleware.Cors()).Get("users", userController.Show) -``` - ---- - -## HTTP Drivers - -Install via artisan: - -```shell -./artisan package:install github.com/goravel/gin -./artisan package:install github.com/goravel/fiber -``` - -### Gin (default) - -```go -// config/http.go -import ( - "github.com/gin-gonic/gin/render" - "github.com/goravel/framework/contracts/route" - "github.com/goravel/gin" - ginfacades "github.com/goravel/gin/facades" -) - -config.Add("http", map[string]any{ - "default": "gin", - "drivers": map[string]any{ - "gin": map[string]any{ - "body_limit": 4096, // KB, default 4096 - "header_limit": 4096, // KB, default 4096 - "route": func() (route.Route, error) { - return ginfacades.Route("gin"), nil - }, - // Optional: custom HTML template renderer - "template": func() (render.HTMLRender, error) { - return gin.DefaultTemplate() - }, - }, - }, - "url": config.Env("APP_URL", "http://localhost"), - "host": config.Env("APP_HOST", "127.0.0.1"), - "port": config.Env("APP_PORT", "3000"), - "request_timeout": 3, // seconds - "tls": map[string]any{ - "host": config.Env("APP_HOST", "127.0.0.1"), - "port": config.Env("APP_PORT", "3000"), - "ssl": map[string]any{ - "cert": "", // path to cert file - "key": "", // path to key file - }, - }, - // HTTP client configuration (for facades.Http()) - "default_client": config.Env("HTTP_CLIENT_DEFAULT", "default"), - "clients": map[string]any{ - "default": map[string]any{ - "base_url": config.Env("HTTP_CLIENT_BASE_URL", ""), - "timeout": config.Env("HTTP_CLIENT_TIMEOUT", "30s"), - "max_idle_conns": config.Env("HTTP_CLIENT_MAX_IDLE_CONNS", 100), - "max_idle_conns_per_host": config.Env("HTTP_CLIENT_MAX_IDLE_CONNS_PER_HOST", 2), - "max_conns_per_host": config.Env("HTTP_CLIENT_MAX_CONN_PER_HOST", 0), - "idle_conn_timeout": config.Env("HTTP_CLIENT_IDLE_CONN_TIMEOUT", "90s"), - }, - }, -}) -``` - -### Fiber - -```go -// config/http.go -import ( - "github.com/gofiber/fiber/v2" - "github.com/gofiber/template/html/v2" - "github.com/goravel/framework/contracts/route" - "github.com/goravel/framework/support/path" - fiberfacades "github.com/goravel/fiber/facades" -) - -config.Add("http", map[string]any{ - "default": "fiber", - "drivers": map[string]any{ - "fiber": map[string]any{ - // WARNING: immutable mode — only disable if you understand zero-allocation implications - "immutable": true, - "prefork": false, - "body_limit": 4096, // KB - "header_limit": 4096, // KB - "route": func() (route.Route, error) { - return fiberfacades.Route("fiber"), nil - }, - // Optional: custom template engine - "template": func() (fiber.Views, error) { - return html.New(path.Resource("views"), ".tmpl"), nil - }, - }, - }, - // ... same host/port/tls/clients as Gin config above -}) -``` - -| Driver | Package | -|--------|---------| -| Gin | github.com/goravel/gin | -| Fiber | github.com/goravel/fiber | diff --git a/.ai/prompt/routing.md b/.ai/prompt/routing.md deleted file mode 100644 index 332affebd..000000000 --- a/.ai/prompt/routing.md +++ /dev/null @@ -1,267 +0,0 @@ -# Goravel Routing - -## Setup - -Routes are defined in `routes/` and registered in `bootstrap/app.go`: - -```go -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithRouting(func() { - routes.Web() - routes.Api() - }). - WithConfig(config.Boot). - Create() -} -``` - -Route files call `facades.Route()` methods: - -```go -package routes - -import "goravel/app/facades" - -func Web() { - facades.Route().Get("/", homeController.Index) -} -``` - ---- - -## HTTP Methods - -```go -facades.Route().Get("/", handler) -facades.Route().Post("/", handler) -facades.Route().Put("/", handler) -facades.Route().Delete("/", handler) -facades.Route().Patch("/", handler) -facades.Route().Options("/", handler) -facades.Route().Any("/", handler) -``` - -Handler signature: - -```go -import "github.com/goravel/framework/contracts/http" - -func(ctx http.Context) http.Response -``` - ---- - -## Inline Handler - -```go -facades.Route().Get("/", func(ctx http.Context) http.Response { - return ctx.Response().Json(http.StatusOK, http.Json{ - "Hello": "Goravel", - }) -}) -``` - ---- - -## Route Parameters - -```go -facades.Route().Get("/users/{id}", func(ctx http.Context) http.Response { - id := ctx.Request().Input("id") - return ctx.Response().Success().Json(http.Json{"id": id}) -}) -``` - ---- - -## Group Routing - -```go -import "github.com/goravel/framework/contracts/route" - -facades.Route().Group(func(router route.Router) { - router.Get("users/{id}", userController.Show) - router.Post("users", userController.Store) -}) -``` - ---- - -## Routing Prefix - -```go -facades.Route().Prefix("api/v1").Get("users", userController.Index) - -facades.Route().Prefix("api").Group(func(router route.Router) { - router.Get("users", userController.Index) - router.Post("users", userController.Store) -}) -``` - ---- - -## Resource Routing - -```go -import "github.com/goravel/framework/contracts/http" - -resourceController := controllers.NewPhotoController() -facades.Route().Resource("photos", resourceController) -``` - -Resource controller must implement these methods: - -```go -type PhotoController struct{} - -func NewPhotoController() *PhotoController { - return &PhotoController{} -} - -// GET /photos -func (c *PhotoController) Index(ctx http.Context) http.Response {} - -// GET /photos/{photo} -func (c *PhotoController) Show(ctx http.Context) http.Response {} - -// POST /photos -func (c *PhotoController) Store(ctx http.Context) http.Response {} - -// PUT/PATCH /photos/{photo} -func (c *PhotoController) Update(ctx http.Context) http.Response {} - -// DELETE /photos/{photo} -func (c *PhotoController) Destroy(ctx http.Context) http.Response {} -``` - ---- - -## Middleware on Routes - -```go -import "github.com/goravel/framework/http/middleware" - -facades.Route().Middleware(middleware.Cors()).Get("users", userController.Show) -facades.Route().Middleware(middleware.Auth(), middleware.Throttle("api")).Get("profile", profileController.Show) -``` - ---- - -## File Serving - -```go -import "net/http" - -facades.Route().Static("static", "./public") -facades.Route().StaticFile("static-file", "./public/logo.png") -facades.Route().StaticFS("static-fs", http.Dir("./public")) -``` - ---- - -## Named Routes - -```go -facades.Route().Get("users", userController.Index).Name("users.index") - -// Get route info by name -route := facades.Route().Info("users.index") -``` - ---- - -## Fallback Route - -```go -facades.Route().Fallback(func(ctx http.Context) http.Response { - return ctx.Response().String(404, "not found") -}) -``` - ---- - -## Get All Routes - -```go -routes := facades.Route().GetRoutes() -``` - ---- - -## Rate Limiting - -### Define a rate limiter - -Rate limiters must be defined inside `WithCallback` in `bootstrap/app.go`: - -```go -import ( - contractshttp "github.com/goravel/framework/contracts/http" - "github.com/goravel/framework/http/limit" -) - -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithConfig(config.Boot). - WithCallback(func() { - facades.RateLimiter().For("global", func(ctx contractshttp.Context) contractshttp.Limit { - return limit.PerMinute(1000) - }) - - // Per-IP limit - facades.RateLimiter().For("api", func(ctx contractshttp.Context) contractshttp.Limit { - return limit.PerMinute(60).By(ctx.Request().Ip()) - }) - - // Multiple limits - facades.RateLimiter().ForWithLimits("login", func(ctx contractshttp.Context) []contractshttp.Limit { - return []contractshttp.Limit{ - limit.PerMinute(500), - limit.PerMinute(5).By(ctx.Request().Ip()), - } - }) - }). - Create() -} -``` - -### Attach to route - -```go -import "github.com/goravel/framework/http/middleware" - -facades.Route().Middleware(middleware.Throttle("global")).Get("/", handler) -``` - -### Custom rate limit response - -```go -facades.RateLimiter().For("global", func(ctx contractshttp.Context) contractshttp.Limit { - return limit.PerMinute(1000).Response(func(ctx contractshttp.Context) { - ctx.Request().AbortWithStatus(http.StatusTooManyRequests) - }) -}) -``` - ---- - -## CORS - -CORS is enabled by default. Configure in `config/cors.go`. - ---- - -## List Routes - -```shell -./artisan route:list -``` - ---- - -## Gotchas - -- Route parameters use `ctx.Request().Input("param")` not `ctx.Request().Route("param")` for basic use. `Route()` is only needed for explicit route-segment access. -- `Prefix` and `Group` can be chained: `facades.Route().Prefix("api").Group(...)` -- Middleware registered via `WithMiddleware` in `bootstrap/app.go` applies globally to all HTTP requests. diff --git a/.ai/prompt/session.md b/.ai/prompt/session.md deleted file mode 100644 index eb284f9d1..000000000 --- a/.ai/prompt/session.md +++ /dev/null @@ -1,265 +0,0 @@ -# Goravel Session - -## Configuration - -Full `config/session.go`: - -```go -import ( - "github.com/goravel/framework/support/path" - "github.com/goravel/framework/support/str" - "goravel/app/facades" -) - -config.Add("session", map[string]any{ - "default": "file", // driver name to use - - "drivers": map[string]any{ - "file": map[string]any{ - "driver": "file", - }, - // Redis driver (requires goravel/redis package): - // "redis": map[string]any{ - // "driver": "custom", - // "connection": "default", - // "via": func() (session.Driver, error) { - // return redisfacades.Session("redis"), nil - // }, - // }, - }, - - // Session lifetime in minutes - "lifetime": config.Env("SESSION_LIFETIME", 120), - "expire_on_close": config.Env("SESSION_EXPIRE_ON_CLOSE", false), - - // File session storage path (only for file driver) - "files": path.Storage("framework/sessions"), - - // Garbage collection interval in minutes (-1 to disable) - "gc_interval": config.Env("SESSION_GC_INTERVAL", 30), - - // Cookie name (defaults to app_name_session) - "cookie": config.Env("SESSION_COOKIE", str.Of(config.GetString("app.name")).Snake().Lower().String()+"_session"), - "path": config.Env("SESSION_PATH", "/"), - "domain": config.Env("SESSION_DOMAIN", ""), - "secure": config.Env("SESSION_SECURE", false), // HTTPS only - "http_only": config.Env("SESSION_HTTP_ONLY", true), // prevent JS access - "same_site": config.Env("SESSION_SAME_SITE", "lax"), // "lax", "strict", "none" -}) -``` - -### Redis Session Driver - -Install `goravel/redis`: - -```shell -./artisan package:install github.com/goravel/redis -``` - -```go -// config/session.go -import ( - "github.com/goravel/framework/contracts/session" - redisfacades "github.com/goravel/redis/facades" -) - -"default": "redis", - -"drivers": map[string]any{ - "redis": map[string]any{ - "driver": "custom", - "connection": "default", - "via": func() (session.Driver, error) { - return redisfacades.Session("redis"), nil - }, - }, -}, -``` - -Redis connection must be defined in `config/database.go`: - -```go -"redis": map[string]any{ - "default": map[string]any{ - "host": config.Env("REDIS_HOST", "127.0.0.1"), - "password": config.Env("REDIS_PASSWORD", ""), - "port": config.Env("REDIS_PORT", 6379), - "database": config.Env("REDIS_DB", 0), - }, -}, -``` - -### Register Session Middleware - -Configure driver in `config/session.go`. Default driver: `file` (stores in `storage/framework/sessions`). - -Register session middleware: - -```go -import "github.com/goravel/framework/session/middleware" - -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithMiddleware(func(handler configuration.Middleware) { - handler.Append(middleware.StartSession()) - }). - WithConfig(config.Boot). - Create() -} -``` - ---- - -## Session Operations - -All operations via `ctx.Request().Session()`: - -### Read - -```go -value := ctx.Request().Session().Get("key") -value := ctx.Request().Session().Get("key", "default") -data := ctx.Request().Session().All() -data := ctx.Request().Session().Only([]string{"username", "email"}) -``` - -### Check existence - -```go -ctx.Request().Session().Has("user") // true if present and not nil -ctx.Request().Session().Exists("user") // true even if nil -ctx.Request().Session().Missing("user") // true if absent -``` - -### Write - -```go -ctx.Request().Session().Put("key", "value") -``` - -### Retrieve and delete - -```go -value := ctx.Request().Session().Pull("key") -``` - -### Delete - -```go -ctx.Request().Session().Forget("username", "email") -ctx.Request().Session().Flush() -``` - -### Regenerate session ID (prevents session fixation) - -```go -ctx.Request().Session().Regenerate() -``` - -### Invalidate (regenerate ID + clear all data) - -```go -ctx.Request().Session().Invalidate() - -// After invalidating, save the new session ID to cookie: -ctx.Response().Cookie(http.Cookie{ - Name: ctx.Request().Session().GetName(), - Value: ctx.Request().Session().GetID(), - MaxAge: facades.Config().GetInt("session.lifetime") * 60, - Path: facades.Config().GetString("session.path"), - Domain: facades.Config().GetString("session.domain"), - Secure: facades.Config().GetBool("session.secure"), - HttpOnly: facades.Config().GetBool("session.http_only"), - SameSite: facades.Config().GetString("session.same_site"), -}) -``` - ---- - -## Flash Data - -Flash data is only available for the next request, then deleted automatically. - -```go -ctx.Request().Session().Flash("status", "Task was successful!") - -// Keep flash data for one more request -ctx.Request().Session().Reflash() - -// Keep specific flash keys for one more request -ctx.Request().Session().Keep("status", "username") - -// Flash data available immediately in current request -ctx.Request().Session().Now("status", "Task was successful!") -``` - ---- - -## Session Manager - -### Build a custom session - -```go -session := facades.Session().BuildSession(driver) -session := facades.Session().BuildSession(driver, "custom-session-id") -``` - -### Get a driver instance - -```go -driver, err := facades.Session().Driver("file") -``` - -### Start and save manually - -```go -session := facades.Session().BuildSession(driver) -session.Start() -session.Save() -``` - -### Attach session to request - -```go -ctx.Request().SetSession(session) -``` - -### Check if request has a session - -```go -if ctx.Request().HasSession() { - // ... -} -``` - ---- - -## Custom Session Driver - -Implement the `contracts/session/driver` interface: - -```go -type Driver interface { - Close() error - Destroy(id string) error - Gc(maxLifetime int) error - Open(path string, name string) error - Read(id string) (string, error) - Write(id string, data string) error -} -``` - -Register in `config/session.go`: - -```go -"default": "custom", - -"drivers": map[string]any{ - "custom": map[string]any{ - "driver": "custom", - "via": func() (session.Driver, error) { - return &MyDriver{}, nil - }, - }, -}, -``` diff --git a/.ai/prompt/storage.md b/.ai/prompt/storage.md deleted file mode 100644 index bd09b2ae1..000000000 --- a/.ai/prompt/storage.md +++ /dev/null @@ -1,213 +0,0 @@ -# Goravel Storage / Filesystem - -## Configuration - -Configure disks in `config/filesystems.go`. Default disk: `local` (stores in `storage/app`). Storage directory is configurable via `WithPaths`. - -Available drivers: - -| Driver | Package | -|--------|---------| -| local | built-in | -| S3 | github.com/goravel/s3 | -| OSS | github.com/goravel/oss | -| COS | github.com/goravel/cos | -| Minio | github.com/goravel/minio | - ---- - -## Basic Usage - -```go -// Default disk -err := facades.Storage().Put("file.jpg", contents) -content := facades.Storage().Get("file.jpg") - -// Specific disk -facades.Storage().Disk("s3").Put("avatars/1.png", "Contents") - -// Inject context -facades.Storage().WithContext(ctx).Put("avatars/1.png", "Contents") -``` - ---- - -## File Existence - -```go -if facades.Storage().Disk("s3").Exists("file.jpg") { - // file exists -} - -if facades.Storage().Disk("s3").Missing("file.jpg") { - // file missing -} -``` - ---- - -## File URLs - -```go -url := facades.Storage().Url("file.jpg") - -// Temporary URL (non-local drivers) -url, err := facades.Storage().TemporaryUrl("file.jpg", time.Now().Add(5*time.Minute)) -``` - ---- - -## File Metadata - -```go -size := facades.Storage().Size("file.jpg") -lastModified, err := facades.Storage().LastModified("file.jpg") -mime, err := facades.Storage().MimeType("file.jpg") -path := facades.Storage().Path("file.jpg") -``` - -Using `NewFile`: - -```go -import "github.com/goravel/framework/filesystem" - -file, err := filesystem.NewFile("./logo.png") -size, _ := file.Size() -lastModified, _ := file.LastModified() -mime, _ := file.MimeType() -``` - ---- - -## Storing Files - -```go -// Put raw content -err := facades.Storage().Put("file.jpg", contents) - -// PutFile (auto-generates unique filename) -import "github.com/goravel/framework/filesystem" -file, err := filesystem.NewFile("./logo.png") -path := facades.Storage().PutFile("photos", file) - -// PutFileAs (specify filename) -path = facades.Storage().PutFileAs("photos", file, "photo.jpg") -``` - ---- - -## File Uploads (from HTTP request) - -```go -func (r *UserController) Store(ctx http.Context) http.Response { - file, err := ctx.Request().File("avatar") - - // Auto-generated filename - path, err := file.Store("avatars") - - // Custom filename - path, err = file.StoreAs("avatars", "custom-name") - - // Specific disk - path, err = file.Disk("s3").Store("avatars") - - // Get client-provided info (unsafe — may be tampered) - name := file.GetClientOriginalName() - ext := file.GetClientOriginalExtension() - - // Safe alternatives - name = file.HashName() - ext, err = file.Extension() // determined from MIME type - - return ctx.Response().Success().Json(http.Json{"path": path}) -} -``` - ---- - -## Copy, Move, Delete - -```go -err := facades.Storage().Copy("old/file.jpg", "new/file.jpg") -err = facades.Storage().Move("old/file.jpg", "new/file.jpg") - -// Delete one or multiple files -err = facades.Storage().Delete("file.jpg") -err = facades.Storage().Delete("file.jpg", "file2.jpg") -err = facades.Storage().Disk("s3").Delete("file.jpg") -``` - ---- - -## Directories - -```go -// List files -files, err := facades.Storage().Disk("s3").Files("directory") -files, err = facades.Storage().Disk("s3").AllFiles("directory") - -// List directories -dirs, err := facades.Storage().Disk("s3").Directories("directory") -dirs, err = facades.Storage().Disk("s3").AllDirectories("directory") - -// Create/delete directories -err = facades.Storage().MakeDirectory("new/directory") -err = facades.Storage().DeleteDirectory("old/directory") -``` - ---- - -## Public Disk - -Serve publicly accessible files: - -```go -// config/filesystems.go: public disk uses local driver → storage/app/public - -// Serve via route: -facades.Route().Static("storage", "./storage/app/public") -``` - ---- - -## Custom Driver - -```go -// config/filesystems.go -"custom": map[string]any{ - "driver": "custom", - "via": filesystems.NewLocal(), -}, -``` - -Implement `contracts/filesystem/Driver` interface: - -```go -type Driver interface { - AllDirectories(path string) ([]string, error) - AllFiles(path string) ([]string, error) - Copy(oldFile, newFile string) error - Delete(file ...string) error - DeleteDirectory(directory string) error - Directories(path string) ([]string, error) - Exists(file string) bool - Files(path string) ([]string, error) - Get(file string) (string, error) - GetBytes(file string) ([]byte, error) - LastModified(file string) (time.Time, error) - MakeDirectory(directory string) error - MimeType(file string) (string, error) - Missing(file string) bool - Move(oldFile, newFile string) error - Path(file string) string - Put(file, content string) error - PutFile(path string, source File) (string, error) - PutFileAs(path string, source File, name string) (string, error) - Size(file string) (int64, error) - TemporaryUrl(file string, time time.Time) (string, error) - WithContext(ctx context.Context) Driver - Url(file string) string -} -``` - -Use `facades.Config().Env()` (not `facades.Config().Get()`) inside custom driver — config is not yet loaded when the driver is registered. diff --git a/.ai/prompt/testing.md b/.ai/prompt/testing.md deleted file mode 100644 index c679109db..000000000 --- a/.ai/prompt/testing.md +++ /dev/null @@ -1,421 +0,0 @@ -# Goravel Testing - -## Setup - -Uses [stretchr/testify](https://github.com/stretchr/testify) suite. Framework auto-bootstraps the app before tests run. - -```shell -./artisan make:test feature/UserTest -``` - -```go -package feature - -import ( - "testing" - - "github.com/stretchr/testify/suite" - - "goravel/tests" -) - -type ExampleTestSuite struct { - suite.Suite - tests.TestCase -} - -func TestExampleTestSuite(t *testing.T) { - suite.Run(t, new(ExampleTestSuite)) -} - -// SetupTest runs before each test -func (s *ExampleTestSuite) SetupTest() {} - -// TearDownTest runs after each test -func (s *ExampleTestSuite) TearDownTest() {} - -func (s *ExampleTestSuite) TestIndex() { - s.True(true) -} -``` - ---- - -## Environment - -- Default: reads `.env` from root -- Package-level override: place `.env` in the test package directory (read first) -- Named env file: `go test ./... --env=.env.testing` or `-e=.env.testing` - ---- - -## HTTP Tests - -### Make Requests - -```go -func (s *ExampleTestSuite) TestIndex() { - response, err := s.Http(s.T()).Get("/users/1") - s.Nil(err) - response.AssertStatus(200) -} -``` - -### Set Headers - -```go -response, err := s.Http(s.T()).WithHeader("X-Custom-Header", "Value").Get("/users/1") - -response, err := s.Http(s.T()).WithHeaders(map[string]string{ - "X-Custom-Header": "Value", - "Accept": "application/json", -}).Get("/users/1") -``` - -### Set Cookies - -```go -import "github.com/goravel/framework/testing/http" - -response, err := s.Http(s.T()).WithCookie(http.Cookie("name", "value")).Get("/users/1") - -response, err := s.Http(s.T()).WithCookies(http.Cookies(map[string]string{ - "name": "value", - "lang": "en", -})).Get("/users/1") -``` - -### Set Session - -```go -response, err := s.Http(s.T()).WithSession(map[string]any{"role": "admin"}).Get("/users/1") -``` - -### Build Request Body (POST/PUT/DELETE) - -```go -import "github.com/goravel/framework/support/http" - -builder := http.NewBody().SetField("name", "goravel") -body, err := builder.Build() - -response, err := s.Http(s.T()). - WithHeader("Content-Type", body.ContentType()). - Post("/users", body) -``` - -### Inspect Response - -```go -content, err := response.Content() // raw response body string -cookies := response.Cookies() -headers := response.Headers() -json, err := response.Json() // map[string]any -session, err := response.Session() // all session values -``` - ---- - -## JSON Assertions - -```go -// Contains subset (does not require exact match) -response.AssertJson(map[string]any{"created": true}) - -// Must match exactly (no extra/missing fields) -response.AssertExactJson(map[string]any{"created": true}) - -// JSON missing -response.AssertJsonMissing(map[string]any{"created": false}) -``` - -### Fluent JSON - -```go -import contractstesting "github.com/goravel/framework/contracts/testing" - -response.AssertFluentJson(func(json contractstesting.AssertableJSON) { - json.Where("id", float64(1)). - Where("name", "goravel"). - WhereNot("lang", "en"). - Missing("password"). - Has("email"). - HasAny([]string{"username", "email"}). - MissingAll([]string{"secret", "token"}) -}) -``` - -### JSON Collections - -```go -response.AssertFluentJson(func(json contractstesting.AssertableJSON) { - // Count + First element - json.Count("items", 2). - First("items", func(json contractstesting.AssertableJSON) { - json.Where("id", float64(1)) - }) - - // Iterate all - json.Each("items", func(json contractstesting.AssertableJSON) { - json.Has("id") - }) - - // Count + First combined - json.HasWithScope("items", 2, func(json contractstesting.AssertableJSON) { - json.Where("id", float64(1)) - }) -}) -``` - ---- - -## Response Assertions - -```go -response.AssertStatus(200) -response.AssertOk() // 200 -response.AssertCreated() // 201 -response.AssertAccepted() // 202 -response.AssertNoContent() // 204 -response.AssertPartialContent() // 206 -response.AssertMovedPermanently() // 301 -response.AssertFound() // 302 -response.AssertNotModified() // 304 -response.AssertTemporaryRedirect() // 307 -response.AssertBadRequest() // 400 -response.AssertUnauthorized() // 401 -response.AssertPaymentRequired() // 402 -response.AssertForbidden() // 403 -response.AssertNotFound() // 404 -response.AssertMethodNotAllowed() // 405 -response.AssertRequestTimeout() // 408 -response.AssertConflict() // 409 -response.AssertGone() // 410 -response.AssertUnprocessableEntity() // 422 -response.AssertTooManyRequests() // 429 -response.AssertInternalServerError() // 500 -response.AssertServiceUnavailable() // 503 -response.AssertSuccessful() // 2xx -response.AssertServerError() // 5xx - -// Header assertions -response.AssertHeader("Content-Type", "application/json") -response.AssertHeaderMissing("X-Custom") - -// Cookie assertions -response.AssertCookie("name", "value") -response.AssertCookieExpired("name") -response.AssertCookieMissing("name") -response.AssertCookieNotExpired("name") - -// Body content -response.AssertSee([]string{"
"}, false) // second param: escape HTML -response.AssertDontSee([]string{"error"}, true) -response.AssertSeeInOrder([]string{"First", "Second"}, false) -``` - ---- - -## Database Testing - -### Factories - -```go -var user models.User -err := facades.Orm().Factory().Create(&user) -``` - -### Seeders - -```go -func (s *ExampleTestSuite) TestIndex() { - s.Seed() // runs DatabaseSeeder - s.Seed(&seeders.UserSeeder{}, &seeders.PhotoSeeder{}) // specific seeders -} -``` - -### Refresh Database (per-test) - -```go -func (s *ExampleTestSuite) SetupTest() { - s.RefreshDatabase() -} -``` - ---- - -## Docker Testing - -For parallel package tests that need isolated databases/caches. - -> Docker testing does not work on Windows. - -### Full Example - -```go -// tests/feature/main_test.go -package feature - -import ( - "fmt" - "os" - "testing" - - "goravel/app/facades" - "goravel/database/seeders" -) - -func TestMain(m *testing.M) { - database, err := facades.Testing().Docker().Database() - if err != nil { - panic(err) - } - - if err := database.Build(); err != nil { - panic(err) - } - if err := database.Ready(); err != nil { - panic(err) - } - if err := database.Migrate(); err != nil { - panic(err) - } - - if err := facades.App().Restart(); err != nil { - panic(err) - } - - exit := m.Run() - - if err := database.Shutdown(); err != nil { - panic(err) - } - - os.Exit(exit) -} -``` - -### Docker API - -```go -// Create images -database, err := facades.Testing().Docker().Database() -database, err := facades.Testing().Docker().Database("postgres") - -cache, err := facades.Testing().Docker().Cache() -cache, err := facades.Testing().Docker().Cache("redis") - -// Custom image -import contractstesting "github.com/goravel/framework/contracts/testing" - -image, err := facades.Testing().Docker().Image(contractstesting.Image{ - Repository: "mysql", - Tag: "5.7", - Env: []string{"MYSQL_ROOT_PASSWORD=secret", "MYSQL_DATABASE=goravel"}, - ExposedPorts: []string{"3306"}, -}) - -// Build and configure -err := database.Build() -config := database.Config() // get connection config - -// Seed -err := database.Seed() -err := database.Seed(&seeders.UserSeeder{}) - -// Refresh (serial tests only — not safe for parallel) -err := database.Fresh() -err := cache.Fresh() - -// Shutdown (auto-uninstalls after 1 hour if not called) -err := database.Shutdown() -``` - ---- - -## Mock Testing - -All facades can be mocked via `mock.Factory()`: - -```go -import "github.com/goravel/framework/testing/mock" -``` - -### Mock Pattern - -```go -func TestSomething(t *testing.T) { - mockFactory := mock.Factory() - mockCache := mockFactory.Cache() - mockCache.On("Put", "name", "goravel", mock.Anything).Return(nil).Once() - mockCache.On("Get", "name", "test").Return("Goravel").Once() - - res := MyFunction() - assert.Equal(t, "Goravel", res) - - mockCache.AssertExpectations(t) -} -``` - -### Available Mocks - -| Mock | Factory Method | -|------|---------------| -| App | `mockFactory.App()` | -| Artisan | `mockFactory.Artisan()` | -| Auth | `mockFactory.Auth()` | -| Cache | `mockFactory.Cache()` | -| Config | `mockFactory.Config()` | -| Crypt | `mockFactory.Crypt()` | -| Event | `mockFactory.Event()` + `mockFactory.EventTask()` | -| Gate | `mockFactory.Gate()` | -| Grpc | `mockFactory.Grpc()` | -| Hash | `mockFactory.Hash()` | -| Lang | `mockFactory.Lang()` | -| Log | `mockFactory.Log()` (uses fmt, not real log) | -| Mail | `mockFactory.Mail()` | -| Orm | `mockFactory.Orm()` + `mockFactory.OrmQuery()` | -| Queue | `mockFactory.Queue()` + `mockFactory.QueueTask()` | -| Storage | `mockFactory.Storage()` + `mockFactory.StorageDriver()` | -| Validation | `mockFactory.Validation()` + `mockFactory.ValidationValidator()` + `mockFactory.ValidationErrors()` | -| View | `mockFactory.View()` | - -### Mock ORM Transaction - -```go -func TestTransaction(t *testing.T) { - mockFactory := mock.Factory() - mockOrm := mockFactory.Orm() - mockOrmTransaction := mockFactory.OrmTransaction() - mockOrm.On("Transaction", mock.Anything).Return(func(txFunc func(tx orm.Transaction) error) error { - return txFunc(mockOrmTransaction) - }) - - var test Test - mockOrmTransaction.On("Create", &test).Return(func(test2 interface{}) error { - test2.(*Test).ID = 1 - return nil - }).Once() - mockOrmTransaction.On("Where", "id = ?", uint(1)).Return(mockOrmTransaction).Once() - mockOrmTransaction.On("Find", mock.Anything).Return(nil).Once() - - assert.Nil(t, Transaction()) -} -``` - -### Mock Event - -```go -func TestEvent(t *testing.T) { - mockFactory := mock.Factory() - mockEvent := mockFactory.Event() - mockTask := mockFactory.EventTask() - mockEvent.On("Job", mock.Anything, mock.Anything).Return(mockTask).Once() - mockTask.On("Dispatch").Return(nil).Once() - - assert.Nil(t, Event()) - - mockEvent.AssertExpectations(t) - mockTask.AssertExpectations(t) -} -``` diff --git a/.ai/prompt/validation.md b/.ai/prompt/validation.md deleted file mode 100644 index 990871456..000000000 --- a/.ai/prompt/validation.md +++ /dev/null @@ -1,461 +0,0 @@ -# Goravel Validation - -## Inline Validation - -```go -func (r *PostController) Store(ctx http.Context) http.Response { - validator, err := ctx.Request().Validate(map[string]string{ - "title": "required|max_len:255", - "body": "required", - "code": "required|regex:^\\d{4,6}$", - }) - - if err != nil { - return ctx.Response().Json(http.StatusBadRequest, http.Json{"error": err.Error()}) - } - - if validator.Fails() { - return ctx.Response().Json(http.StatusUnprocessableEntity, validator.Errors().All()) - } - - var post models.Post - err = validator.Bind(&post) - ... -} -``` - -### Nested attributes (dot notation) - -```go -validator, err := ctx.Request().Validate(map[string]string{ - "author.name": "required", - "author.description": "required", -}) -``` - -### Array / slice validation - -```go -validator, err := ctx.Request().Validate(map[string]string{ - "tags.*": "required", -}) -``` - ---- - -## Form Request Validation - -### Generate form request - -```shell -./artisan make:request StorePostRequest -./artisan make:request user/StorePostRequest -``` - -### Define form request - -```go -package requests - -import ( - "mime/multipart" - - "github.com/goravel/framework/contracts/http" - "github.com/goravel/framework/contracts/validation" -) - -type StorePostRequest struct { - Name string `form:"name" json:"name"` - File *multipart.FileHeader `form:"file" json:"file"` - Files []*multipart.FileHeader `form:"files" json:"files"` -} - -func (r *StorePostRequest) Authorize(ctx http.Context) error { - return nil -} - -func (r *StorePostRequest) Rules(ctx http.Context) map[string]string { - return map[string]string{ - "name": "required|max_len:255", - "file": "required|file", - "files": "required|array", - "files.*": "required|file", - } -} - -// Optional - filter/transform input before validation -func (r *StorePostRequest) Filters(ctx http.Context) map[string]string { - return map[string]string{ - "name": "trim", - } -} - -// Optional - custom error messages -func (r *StorePostRequest) Messages() map[string]string { - return map[string]string{ - "name.required": "A name is required.", - } -} - -// Optional - custom attribute names in error messages -func (r *StorePostRequest) Attributes() map[string]string { - return map[string]string{ - "name": "full name", - } -} - -// Optional - modify data before rules run -func (r *StorePostRequest) PrepareForValidation(ctx http.Context, data validation.Data) error { - if name, exist := data.Get("name"); exist { - return data.Set("name", strings.TrimSpace(name.(string))) - } - return nil -} -``` - -### Use form request in controller - -```go -func (r *PostController) Store(ctx http.Context) http.Response { - var storePost requests.StorePostRequest - errors, err := ctx.Request().ValidateRequest(&storePost) - - if err != nil { - return ctx.Response().Json(http.StatusBadRequest, http.Json{"error": err.Error()}) - } - - if errors != nil { - return ctx.Response().Json(http.StatusUnprocessableEntity, errors.All()) - } - - // storePost.Name is populated - fmt.Println(storePost.Name) - ... -} -``` - -### Authorization in form request - -```go -func (r *StorePostRequest) Authorize(ctx http.Context) error { - var comment models.Comment - facades.Orm().Query().First(&comment) - - if comment.ID == 0 { - return errors.New("comment not found") - } - - if !facades.Gate().Allows("update", map[string]any{"comment": comment}) { - return errors.New("unauthorized") - } - - return nil -} -``` - -The error from `Authorize` is returned as the first return value of `ValidateRequest`. - ---- - -## Manual Validator - -```go -import "goravel/app/facades" - -validator, err := facades.Validation().Make( - ctx, - map[string]any{ - "name": "Goravel", - }, - map[string]string{ - "name": "required|max_len:255", - }, -) - -if validator.Fails() { - // handle errors -} - -var user models.User -err = validator.Bind(&user) -``` - -### Custom messages with Make - -```go -import "github.com/goravel/framework/validation" - -validator, err := facades.Validation().Make(ctx, input, rules, - validation.Messages(map[string]string{ - "required": "The :attribute field is required.", - "email.required": "We need your email address.", - }), -) -``` - -### Custom attributes with Make - -```go -validator, err := facades.Validation().Make(ctx, input, rules, - validation.Attributes(map[string]string{ - "email": "email address", - }), -) -``` - -### PrepareForValidation with Make - -```go -import ( - validationcontract "github.com/goravel/framework/contracts/validation" - "github.com/goravel/framework/validation" -) - -validator, err := facades.Validation().Make(ctx, input, rules, - validation.PrepareForValidation(func(ctx http.Context, data validationcontract.Data) error { - if name, exist := data.Get("name"); exist { - return data.Set("name", strings.TrimSpace(name.(string))) - } - return nil - }), -) -``` - ---- - -## Working with Errors - -```go -// Check if any errors -if validator.Fails() {} - -// One message for a field (random if multiple) -msg := validator.Errors().One("email") - -// All messages for a field -msgs := validator.Errors().Get("email") - -// All messages for all fields -all := validator.Errors().All() - -// Check if a specific field has errors -if validator.Errors().Has("email") {} -``` - ---- - -## Bind Validated Data - -```go -// Bind to struct after inline Validate -validator, err := ctx.Request().Validate(rules) -var user models.User -err = validator.Bind(&user) - -// Data auto-bound when using ValidateRequest -var storePost requests.StorePostRequest -errors, err := ctx.Request().ValidateRequest(&storePost) -fmt.Println(storePost.Name) -``` - ---- - -## Available Validation Rules - -| Rule | Usage | -|------|-------| -| `required` | Field must be present and not zero value | -| `required_if:field,value` | Required when another field equals value | -| `required_unless:field,value` | Required unless another field equals value | -| `required_with:foo,bar` | Required if any of the listed fields are present | -| `required_with_all:foo,bar` | Required if all of the listed fields are present | -| `required_without:foo,bar` | Required if any of the listed fields are absent | -| `required_without_all:foo,bar` | Required if all of the listed fields are absent | -| `int` | Integer type, optionally `int:min` or `int:min,max` | -| `uint` | Unsigned integer, value >= 0 | -| `bool` | Boolean string (1/0/true/false/yes/no/on/off) | -| `string` | String type, optionally `string:min` or `string:min,max` | -| `float` | Float type | -| `slice` | Slice type | -| `in:a,b,c` | Value must be in list | -| `not_in:a,b,c` | Value must not be in list | -| `starts_with:foo` | Must start with substring | -| `ends_with:foo` | Must end with substring | -| `between:min,max` | Numeric value within range | -| `min:value` | Minimum numeric value | -| `max:value` | Maximum numeric value | -| `eq:value` | Equal to value | -| `ne:value` | Not equal to value | -| `lt:value` | Less than value | -| `gt:value` | Greater than value | -| `len:value` | Exact length (string/array/slice/map) | -| `min_len:value` | Minimum length | -| `max_len:value` | Maximum length | -| `email` | Valid email address | -| `array` | Array or slice | -| `map` | Map type | -| `eq_field:field` | Equal to another field | -| `ne_field:field` | Not equal to another field | -| `gt_field:field` | Greater than another field | -| `gte_field:field` | Greater than or equal to another field | -| `lt_field:field` | Less than another field | -| `lte_field:field` | Less than or equal to another field | -| `file` | Uploaded file | -| `image` | Uploaded image file | -| `date` | Date string | -| `gt_date:value` | After given date | -| `lt_date:value` | Before given date | -| `gte_date:value` | On or after given date | -| `lte_date:value` | On or before given date | -| `alpha` | Letters only | -| `alpha_num` | Letters and numbers only | -| `alpha_dash` | Letters, numbers, dashes, underscores | -| `json` | Valid JSON string | -| `number` | Numeric string >= 0 | -| `full_url` | Full URL starting with http or https | -| `ip` | IPv4 or IPv6 | -| `ipv4` | IPv4 | -| `ipv6` | IPv6 | -| `regex:pattern` | Matches regular expression | -| `uuid` | UUID string | -| `uuid3` | UUID v3 | -| `uuid4` | UUID v4 | -| `uuid5` | UUID v5 | - ---- - -## Available Filters - -| Filter | Effect | -|--------|--------| -| `trim` / `trimSpace` | Remove surrounding whitespace | -| `ltrim` / `trimLeft` | Remove left whitespace | -| `rtrim` / `trimRight` | Remove right whitespace | -| `int` / `toInt` | Convert to int | -| `uint` / `toUint` | Convert to uint | -| `int64` / `toInt64` | Convert to int64 | -| `float` / `toFloat` | Convert to float | -| `bool` / `toBool` | Convert to bool | -| `lower` / `lowercase` | Lowercase | -| `upper` / `uppercase` | Uppercase | -| `camel` / `camelCase` | camelCase | -| `snake` / `snakeCase` | snake_case | -| `escapeHtml` / `escapeHTML` | Escape HTML | -| `str2ints` / `strToInts` | String to `[]int` | -| `str2arr` / `strToArray` | String to `[]string` | - ---- - -## Custom Validation Rules - -### Generate rule - -```shell -./artisan make:rule Uppercase -``` - -### Define rule - -```go -package rules - -import ( - "context" - "strings" - - "github.com/goravel/framework/contracts/validation" -) - -type Uppercase struct{} - -func (r *Uppercase) Signature() string { - return "uppercase" -} - -func (r *Uppercase) Passes(ctx context.Context, data validation.Data, val any, options ...any) bool { - s, ok := val.(string) - if !ok { - return false - } - return strings.ToUpper(s) == s -} - -func (r *Uppercase) Message(ctx context.Context) string { - return "The :attribute must be uppercase." -} -``` - -### Register rules - -Generated rules auto-register in `bootstrap/rules.go`. Manual registration: - -```go -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithRules(rules.Rules). - WithConfig(config.Boot). - Create() -} -``` - -Use the rule: - -```go -validator, err := ctx.Request().Validate(map[string]string{ - "name": "required|uppercase", -}) -``` - ---- - -## Custom Filters - -### Generate filter - -```shell -./artisan make:filter ToInt -``` - -### Define filter - -```go -package filters - -import ( - "context" - - "github.com/spf13/cast" -) - -type ToInt struct{} - -func (r *ToInt) Signature() string { - return "ToInt" -} - -func (r *ToInt) Handle(ctx context.Context) any { - return func(val any) int { - return cast.ToInt(val) - } -} -``` - -### Register filters - -```go -func Boot() contractsfoundation.Application { - return foundation.Setup(). - WithFilters(filters.Filters). - WithConfig(config.Boot). - Create() -} -``` - ---- - -## Gotchas - -- When using `ctx.Request().Validate(rules)` (inline), JSON-decoded `int` values arrive as `float64`. The `int` rule will fail. Fix: use `facades.Validation().Make()` instead, or add `PrepareForValidation` to convert them. -- Form fields bind as `string` by default. Use JSON if you need typed numeric or boolean fields in a form request struct. -- `Authorize()` error is distinct from validation errors. It is returned as the first return value from `ValidateRequest`, not inside `Errors()`. -- `validator.Bind()` binds all incoming data, not just the validated fields. Filter what you need after binding. diff --git a/.ai/prompt/view.md b/.ai/prompt/view.md deleted file mode 100644 index f92fed551..000000000 --- a/.ai/prompt/view.md +++ /dev/null @@ -1,167 +0,0 @@ -# Goravel Views - -## Template Files - -Default template engine: `html/template`. Files use `.tmpl` extension, stored in `resources/views/` (configurable via `WithPaths`). - -``` -// resources/views/welcome.tmpl -{{ define "welcome.tmpl" }} - - -

Hello, {{ .name }}

- - -{{ end }} -``` - -Nested views: - -``` -// resources/views/admin/profile.tmpl -{{ define "admin/profile.tmpl" }} -

Welcome to the Admin Panel

-{{ end }} -``` - ---- - -## Rendering Views - -```go -facades.Route().Get("/", func(ctx http.Context) http.Response { - return ctx.Response().View().Make("welcome.tmpl", map[string]any{ - "name": "Goravel", - }) -}) -``` - -Nested view: - -```go -return ctx.Response().View().Make("admin/profile.tmpl", map[string]any{ - "name": "Goravel", -}) -``` - -First available view: - -```go -return ctx.Response().View().First([]string{"custom/admin.tmpl", "admin.tmpl"}, map[string]any{ - "name": "Goravel", -}) -``` - ---- - -## Check View Exists - -```go -if facades.View().Exist("welcome.tmpl") { - // ... -} -``` - ---- - -## Sharing Data With All Views - -Call in `WithCallback` in `bootstrap/app.go`: - -```go -WithCallback(func() { - facades.View().Share("appName", "MyApp") - facades.View().Share("version", "1.0") -}) -``` - ---- - -## CSRF Token Middleware (v1.17) - -1. Register `middleware.VerifyCsrfToken(exceptPaths)` globally or on specific routes. -2. Include the CSRF token in forms: - -```html - -``` - -Or in request headers: - -``` -X-CSRF-TOKEN: {{ .csrf_token }} -``` - -Registration: - -```go -import "github.com/goravel/framework/http/middleware" - -handler.Append(middleware.VerifyCsrfToken([]string{ - "api/*", - "webhook/*", -})) -``` - ---- - -## Custom Delimiters and Functions (Gin Driver) - -```go -// config/http.go -import ( - "html/template" - "github.com/gin-gonic/gin/render" - "github.com/goravel/gin" -) - -"template": func() (render.HTMLRender, error) { - return gin.NewTemplate(gin.RenderOptions{ - Delims: &gin.Delims{ - Left: "{{", - Right: "}}", - }, - FuncMap: template.FuncMap{ - "upper": strings.ToUpper, - }, - }) -}, -``` - ---- - -## Custom Template Engine (Fiber Driver) - -```go -// config/http.go -import ( - "github.com/gofiber/fiber/v2" - "github.com/gofiber/template/html/v2" - "github.com/goravel/framework/support/path" -) - -"template": func() (fiber.Views, error) { - engine := &html.Engine{ - Engine: template.Engine{ - Left: "{{", - Right: "}}", - Directory: path.Resource("views"), - Extension: ".tmpl", - LayoutName: "embed", - Funcmap: make(map[string]interface{}), - }, - } - engine.AddFunc(engine.LayoutName, func() error { - return fmt.Errorf("layoutName called unexpectedly") - }) - return engine, nil -}, -``` - ---- - -## Gotchas - -- View template `define` name must match the path passed to `Make`. Nested views must use `define "admin/profile.tmpl"` to match the `Make("admin/profile.tmpl", ...)` call. -- `Share` data is available in all views; per-request data is passed via `Make`'s second argument. -- View resources directory is configurable via `paths.Resources("views-root")` in `WithPaths`. diff --git a/scripts/generate-agents/config.json b/scripts/generate-agents/config.json index eeafcdd50..77b597f10 100644 --- a/scripts/generate-agents/config.json +++ b/scripts/generate-agents/config.json @@ -3,7 +3,6 @@ { "output": ".ai/AGENTS.md", "sources": [ - "en/upgrade/v1.17.md", "en/getting-started/installation.md", "en/getting-started/configuration.md", "en/getting-started/directory-structure.md", @@ -16,7 +15,7 @@ ] }, { - "output": ".ai/prompt/bootstrap.md", + "output": ".ai/knowledge/bootstrap.md", "sources": [ "en/architecture-concepts/request-lifecycle.md", "en/architecture-concepts/service-container.md", @@ -27,21 +26,11 @@ ] }, { - "output": ".ai/prompt/route.md", - "sources": [ - "en/the-basics/routing.md", - "en/the-basics/middleware.md" - ] + "output": ".ai/knowledge/route.md", + "sources": ["en/the-basics/routing.md", "en/the-basics/middleware.md"] }, { - "output": ".ai/prompt/middleware.md", - "sources": [ - "en/the-basics/middleware.md", - "en/the-basics/views.md" - ] - }, - { - "output": ".ai/prompt/controller.md", + "output": ".ai/knowledge/request-response.md", "sources": [ "en/the-basics/controllers.md", "en/the-basics/request.md", @@ -49,49 +38,42 @@ ] }, { - "output": ".ai/prompt/view.md", - "sources": [ - "en/the-basics/views.md" - ] + "output": ".ai/knowledge/view.md", + "sources": ["en/the-basics/views.md"] }, { - "output": ".ai/prompt/session.md", - "sources": [ - "en/the-basics/session.md" - ] + "output": ".ai/knowledge/session.md", + "sources": ["en/the-basics/session.md"] }, { - "output": ".ai/prompt/validation.md", - "sources": [ - "en/the-basics/validation.md" - ] + "output": ".ai/knowledge/validation.md", + "sources": ["en/the-basics/validation.md"] }, { - "output": ".ai/prompt/log.md", - "sources": [ - "en/the-basics/logging.md" - ] + "output": ".ai/knowledge/log.md", + "sources": ["en/the-basics/logging.md"] }, { - "output": ".ai/prompt/grpc.md", - "sources": [ - "en/the-basics/grpc.md" - ] + "output": ".ai/knowledge/grpc.md", + "sources": ["en/the-basics/grpc.md"] }, { - "output": ".ai/prompt/orm.md", + "output": ".ai/knowledge/orm.md", "sources": [ "en/orm/getting-started.md", "en/orm/relationships.md", "en/orm/factories.md", "en/database/getting-started.md", "en/database/queries.md", - "en/database/migrations.md", "en/database/seeding.md" ] }, { - "output": ".ai/prompt/auth.md", + "output": ".ai/knowledge/migration.md", + "sources": ["en/database/migrations.md"] + }, + { + "output": ".ai/knowledge/auth.md", "sources": [ "en/security/authentication.md", "en/security/authorization.md", @@ -100,68 +82,51 @@ ] }, { - "output": ".ai/prompt/artisan.md", - "sources": [ - "en/digging-deeper/artisan-console.md", - "en/digging-deeper/task-scheduling.md" - ] + "output": ".ai/knowledge/artisan.md", + "sources": ["en/digging-deeper/artisan-console.md"] }, { - "output": ".ai/prompt/cache.md", - "sources": [ - "en/digging-deeper/cache.md" - ] + "output": ".ai/knowledge/schedule.md", + "sources": ["en/digging-deeper/task-scheduling.md"] }, { - "output": ".ai/prompt/event.md", - "sources": [ - "en/digging-deeper/event.md" - ] + "output": ".ai/knowledge/cache.md", + "sources": ["en/digging-deeper/cache.md"] }, { - "output": ".ai/prompt/queue.md", - "sources": [ - "en/digging-deeper/queues.md" - ] + "output": ".ai/knowledge/event.md", + "sources": ["en/digging-deeper/event.md"] }, { - "output": ".ai/prompt/storage.md", - "sources": [ - "en/digging-deeper/filesystem.md" - ] + "output": ".ai/knowledge/queue.md", + "sources": ["en/digging-deeper/queues.md"] }, { - "output": ".ai/prompt/mail.md", - "sources": [ - "en/digging-deeper/mail.md" - ] + "output": ".ai/knowledge/storage.md", + "sources": ["en/digging-deeper/filesystem.md"] }, { - "output": ".ai/prompt/http.md", - "sources": [ - "en/digging-deeper/http-client.md" - ] + "output": ".ai/knowledge/mail.md", + "sources": ["en/digging-deeper/mail.md"] }, { - "output": ".ai/prompt/process.md", - "sources": [ - "en/digging-deeper/processes.md" - ] + "output": ".ai/knowledge/http-client.md", + "sources": ["en/digging-deeper/http-client.md"] }, { - "output": ".ai/prompt/localization.md", - "sources": [ - "en/digging-deeper/localization.md" - ] + "output": ".ai/knowledge/process.md", + "sources": ["en/digging-deeper/processes.md"] }, { - "output": ".ai/prompt/migration.md", - "sources": [ - "en/database/migrations.md" - ] + "output": ".ai/knowledge/localization.md", + "sources": ["en/digging-deeper/localization.md"] + }, + { + "output": ".ai/knowledge/hash-crypt.md", + "sources": ["en/security/hashing.md", "en/security/encryption.md"] }, { - "output": ".ai/prompt/testing.md", + "output": ".ai/knowledge/testing.md", "sources": [ "en/testing/getting-started.md", "en/testing/http-tests.md", @@ -169,39 +134,19 @@ ] }, { - "output": ".ai/prompt/helpers.md", + "output": ".ai/knowledge/helpers.md", + "sources": ["en/digging-deeper/helpers.md", "en/digging-deeper/color.md"] + }, + { + "output": ".ai/knowledge/str.md", "sources": [ - "en/digging-deeper/helpers.md", "en/digging-deeper/strings.md", - "en/digging-deeper/color.md", "en/digging-deeper/pluralization.md" ] }, { - "output": ".ai/prompt/best-practices.md", - "sources": [ - "en/upgrade/v1.17.md", - "en/orm/getting-started.md", - "en/orm/relationships.md", - "en/the-basics/routing.md", - "en/the-basics/middleware.md", - "en/the-basics/controllers.md", - "en/the-basics/request.md", - "en/the-basics/response.md", - "en/the-basics/session.md", - "en/the-basics/validation.md", - "en/security/authentication.md", - "en/security/authorization.md", - "en/security/hashing.md", - "en/digging-deeper/queues.md", - "en/digging-deeper/cache.md", - "en/digging-deeper/event.md", - "en/testing/getting-started.md", - "en/testing/http-tests.md", - "en/testing/mock.md", - "en/architecture-concepts/service-container.md", - "en/architecture-concepts/service-providers.md" - ] + "output": ".ai/knowledge/carbon.md", + "sources": ["en/digging-deeper/helpers.md"] } ] }