diff --git a/.ai/AGENTS.md b/.ai/AGENTS.md new file mode 100644 index 000000000..172ca5e69 --- /dev/null +++ b/.ai/AGENTS.md @@ -0,0 +1,154 @@ +# Goravel Agent Reference + +Go-first framework. Not Laravel. Do not port PHP patterns directly. + +--- + +## Setup + +Knowledge files are installed per-facade via the framework CLI: + +```bash +# Install knowledge for specific facades you are using +./artisan agents:install --facade=Orm,Route,Auth + +# Install all knowledge files +./artisan agents:install --all + +# Update already-installed files to latest +./artisan agents:install --update +``` + +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 + +```go +// WRONG: import "github.com/goravel/framework/facades" (does not exist) +// RIGHT: import path from go.mod module name +import "yourmodule/app/facades" +``` + +Your installed facades are in `app/facades/`. Check which ones exist before using them. +Not all facades are installed in every project. + +--- + +## 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)` | + +--- + +## Available Facades + +``` +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 +``` + +`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/` + +--- + +## 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). + +--- + +## 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. + +--- + +## 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/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..77b597f10 --- /dev/null +++ b/scripts/generate-agents/config.json @@ -0,0 +1,152 @@ +{ + "topics": [ + { + "output": ".ai/AGENTS.md", + "sources": [ + "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/knowledge/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/knowledge/route.md", + "sources": ["en/the-basics/routing.md", "en/the-basics/middleware.md"] + }, + { + "output": ".ai/knowledge/request-response.md", + "sources": [ + "en/the-basics/controllers.md", + "en/the-basics/request.md", + "en/the-basics/response.md" + ] + }, + { + "output": ".ai/knowledge/view.md", + "sources": ["en/the-basics/views.md"] + }, + { + "output": ".ai/knowledge/session.md", + "sources": ["en/the-basics/session.md"] + }, + { + "output": ".ai/knowledge/validation.md", + "sources": ["en/the-basics/validation.md"] + }, + { + "output": ".ai/knowledge/log.md", + "sources": ["en/the-basics/logging.md"] + }, + { + "output": ".ai/knowledge/grpc.md", + "sources": ["en/the-basics/grpc.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/seeding.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", + "en/security/hashing.md", + "en/security/encryption.md" + ] + }, + { + "output": ".ai/knowledge/artisan.md", + "sources": ["en/digging-deeper/artisan-console.md"] + }, + { + "output": ".ai/knowledge/schedule.md", + "sources": ["en/digging-deeper/task-scheduling.md"] + }, + { + "output": ".ai/knowledge/cache.md", + "sources": ["en/digging-deeper/cache.md"] + }, + { + "output": ".ai/knowledge/event.md", + "sources": ["en/digging-deeper/event.md"] + }, + { + "output": ".ai/knowledge/queue.md", + "sources": ["en/digging-deeper/queues.md"] + }, + { + "output": ".ai/knowledge/storage.md", + "sources": ["en/digging-deeper/filesystem.md"] + }, + { + "output": ".ai/knowledge/mail.md", + "sources": ["en/digging-deeper/mail.md"] + }, + { + "output": ".ai/knowledge/http-client.md", + "sources": ["en/digging-deeper/http-client.md"] + }, + { + "output": ".ai/knowledge/process.md", + "sources": ["en/digging-deeper/processes.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/knowledge/testing.md", + "sources": [ + "en/testing/getting-started.md", + "en/testing/http-tests.md", + "en/testing/mock.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/strings.md", + "en/digging-deeper/pluralization.md" + ] + }, + { + "output": ".ai/knowledge/carbon.md", + "sources": ["en/digging-deeper/helpers.md"] + } + ] +}