refactor(API): ✨ Relspect integration
This commit is contained in:
557
tooldoc/BUN_ORM.md
Normal file
557
tooldoc/BUN_ORM.md
Normal file
@@ -0,0 +1,557 @@
|
||||
# BUN ORM Integration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
BUN is a fast and lightweight SQL-first ORM for Go. For WhatsHooked Phase 2, we use BUN with PostgreSQL and SQLite, integrated with ResolveSpec for REST API generation.
|
||||
|
||||
Official Documentation: https://bun.uptrace.dev/
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/uptrace/bun
|
||||
go get github.com/uptrace/bun/driver/pgdriver # PostgreSQL
|
||||
go get github.com/uptrace/bun/driver/sqliteshim # SQLite
|
||||
go get github.com/uptrace/bun/dialect/pgdialect
|
||||
go get github.com/uptrace/bun/dialect/sqlitedialect
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### Database Connection
|
||||
|
||||
```go
|
||||
import (
|
||||
"database/sql"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/driver/pgdriver"
|
||||
"github.com/uptrace/bun/dialect/pgdialect"
|
||||
)
|
||||
|
||||
// PostgreSQL
|
||||
func NewPostgresDB(dsn string) *bun.DB {
|
||||
sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn)))
|
||||
db := bun.NewDB(sqldb, pgdialect.New())
|
||||
return db
|
||||
}
|
||||
|
||||
// SQLite
|
||||
import (
|
||||
"github.com/uptrace/bun/driver/sqliteshim"
|
||||
"github.com/uptrace/bun/dialect/sqlitedialect"
|
||||
)
|
||||
|
||||
func NewSQLiteDB(path string) *bun.DB {
|
||||
sqldb, _ := sql.Open(sqliteshim.ShimName, path)
|
||||
db := bun.NewDB(sqldb, sqlitedialect.New())
|
||||
return db
|
||||
}
|
||||
```
|
||||
|
||||
### Model Definition
|
||||
|
||||
BUN models use struct tags:
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
bun.BaseModel `bun:"table:users,alias:u"`
|
||||
|
||||
ID string `bun:"id,pk,type:varchar(36)"`
|
||||
Username string `bun:"username,unique,notnull"`
|
||||
Email string `bun:"email,unique,notnull"`
|
||||
Password string `bun:"password,notnull"`
|
||||
Role string `bun:"role,notnull,default:'user'"`
|
||||
Active bool `bun:"active,notnull,default:true"`
|
||||
CreatedAt time.Time `bun:"created_at,notnull,default:now()"`
|
||||
UpdatedAt time.Time `bun:"updated_at,notnull,default:now()"`
|
||||
DeletedAt bun.NullTime `bun:"deleted_at,soft_delete"`
|
||||
|
||||
// Relationships
|
||||
APIKeys []*APIKey `bun:"rel:has-many,join:id=user_id"`
|
||||
}
|
||||
```
|
||||
|
||||
## CRUD Operations
|
||||
|
||||
### Create
|
||||
|
||||
```go
|
||||
user := &User{
|
||||
ID: uuid.New().String(),
|
||||
Username: "john",
|
||||
Email: "john@example.com",
|
||||
Password: hashedPassword,
|
||||
Role: "user",
|
||||
Active: true,
|
||||
}
|
||||
|
||||
_, err := db.NewInsert().
|
||||
Model(user).
|
||||
Exec(ctx)
|
||||
```
|
||||
|
||||
### Read
|
||||
|
||||
```go
|
||||
// Single record
|
||||
user := new(User)
|
||||
err := db.NewSelect().
|
||||
Model(user).
|
||||
Where("id = ?", userID).
|
||||
Scan(ctx)
|
||||
|
||||
// Multiple records
|
||||
var users []User
|
||||
err := db.NewSelect().
|
||||
Model(&users).
|
||||
Where("active = ?", true).
|
||||
Order("created_at DESC").
|
||||
Limit(10).
|
||||
Scan(ctx)
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
```go
|
||||
// Update specific fields
|
||||
_, err := db.NewUpdate().
|
||||
Model(&user).
|
||||
Column("username", "email").
|
||||
Where("id = ?", user.ID).
|
||||
Exec(ctx)
|
||||
|
||||
// Update all fields
|
||||
_, err := db.NewUpdate().
|
||||
Model(&user).
|
||||
WherePK().
|
||||
Exec(ctx)
|
||||
```
|
||||
|
||||
### Delete
|
||||
|
||||
```go
|
||||
// Soft delete (if model has soft_delete tag)
|
||||
_, err := db.NewDelete().
|
||||
Model(&user).
|
||||
Where("id = ?", userID).
|
||||
Exec(ctx)
|
||||
|
||||
// Hard delete
|
||||
_, err := db.NewDelete().
|
||||
Model(&user).
|
||||
Where("id = ?", userID).
|
||||
ForceDelete().
|
||||
Exec(ctx)
|
||||
```
|
||||
|
||||
## Relationships
|
||||
|
||||
### Has-Many
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
bun.BaseModel `bun:"table:users"`
|
||||
ID string `bun:"id,pk"`
|
||||
APIKeys []*APIKey `bun:"rel:has-many,join:id=user_id"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
bun.BaseModel `bun:"table:api_keys"`
|
||||
ID string `bun:"id,pk"`
|
||||
UserID string `bun:"user_id,notnull"`
|
||||
User *User `bun:"rel:belongs-to,join:user_id=id"`
|
||||
}
|
||||
|
||||
// Load with relations
|
||||
var users []User
|
||||
err := db.NewSelect().
|
||||
Model(&users).
|
||||
Relation("APIKeys").
|
||||
Scan(ctx)
|
||||
```
|
||||
|
||||
### Belongs-To
|
||||
|
||||
```go
|
||||
var apiKey APIKey
|
||||
err := db.NewSelect().
|
||||
Model(&apiKey).
|
||||
Relation("User").
|
||||
Where("api_key.id = ?", keyID).
|
||||
Scan(ctx)
|
||||
```
|
||||
|
||||
### Many-to-Many
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID string `bun:"id,pk"`
|
||||
Roles []*Role `bun:"m2m:user_roles,join:User=Role"`
|
||||
}
|
||||
|
||||
type Role struct {
|
||||
ID string `bun:"id,pk"`
|
||||
Users []*User `bun:"m2m:user_roles,join:Role=User"`
|
||||
}
|
||||
|
||||
type UserRole struct {
|
||||
UserID string `bun:"user_id,pk"`
|
||||
RoleID string `bun:"role_id,pk"`
|
||||
User *User `bun:"rel:belongs-to,join:user_id=id"`
|
||||
Role *Role `bun:"rel:belongs-to,join:role_id=id"`
|
||||
}
|
||||
```
|
||||
|
||||
## Queries
|
||||
|
||||
### Filtering
|
||||
|
||||
```go
|
||||
// WHERE clauses
|
||||
err := db.NewSelect().
|
||||
Model(&users).
|
||||
Where("role = ?", "admin").
|
||||
Where("active = ?", true).
|
||||
Scan(ctx)
|
||||
|
||||
// OR conditions
|
||||
err := db.NewSelect().
|
||||
Model(&users).
|
||||
WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.
|
||||
Where("role = ?", "admin").
|
||||
Where("role = ?", "moderator")
|
||||
}).
|
||||
Scan(ctx)
|
||||
|
||||
// IN clause
|
||||
err := db.NewSelect().
|
||||
Model(&users).
|
||||
Where("role IN (?)", bun.In([]string{"admin", "moderator"})).
|
||||
Scan(ctx)
|
||||
|
||||
// LIKE
|
||||
err := db.NewSelect().
|
||||
Model(&users).
|
||||
Where("username LIKE ?", "john%").
|
||||
Scan(ctx)
|
||||
```
|
||||
|
||||
### Sorting
|
||||
|
||||
```go
|
||||
err := db.NewSelect().
|
||||
Model(&users).
|
||||
Order("created_at DESC").
|
||||
Order("username ASC").
|
||||
Scan(ctx)
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
```go
|
||||
// Offset/Limit
|
||||
err := db.NewSelect().
|
||||
Model(&users).
|
||||
Limit(20).
|
||||
Offset(40).
|
||||
Scan(ctx)
|
||||
|
||||
// Count
|
||||
count, err := db.NewSelect().
|
||||
Model((*User)(nil)).
|
||||
Where("active = ?", true).
|
||||
Count(ctx)
|
||||
```
|
||||
|
||||
### Aggregations
|
||||
|
||||
```go
|
||||
// COUNT
|
||||
count, err := db.NewSelect().
|
||||
Model((*User)(nil)).
|
||||
Count(ctx)
|
||||
|
||||
// GROUP BY
|
||||
type Result struct {
|
||||
Role string `bun:"role"`
|
||||
Count int `bun:"count"`
|
||||
}
|
||||
|
||||
var results []Result
|
||||
err := db.NewSelect().
|
||||
Model((*User)(nil)).
|
||||
Column("role").
|
||||
ColumnExpr("COUNT(*) as count").
|
||||
Group("role").
|
||||
Scan(ctx, &results)
|
||||
```
|
||||
|
||||
## Transactions
|
||||
|
||||
```go
|
||||
err := db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
// Create user
|
||||
_, err := tx.NewInsert().
|
||||
Model(&user).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create API key
|
||||
_, err = tx.NewInsert().
|
||||
Model(&apiKey).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
## Migrations
|
||||
|
||||
### Create Table
|
||||
|
||||
```go
|
||||
_, err := db.NewCreateTable().
|
||||
Model((*User)(nil)).
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
```
|
||||
|
||||
### Add Column
|
||||
|
||||
```go
|
||||
_, err := db.NewAddColumn().
|
||||
Model((*User)(nil)).
|
||||
ColumnExpr("phone VARCHAR(50)").
|
||||
Exec(ctx)
|
||||
```
|
||||
|
||||
### Drop Table
|
||||
|
||||
```go
|
||||
_, err := db.NewDropTable().
|
||||
Model((*User)(nil)).
|
||||
IfExists().
|
||||
Exec(ctx)
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
### Before/After Hooks
|
||||
|
||||
```go
|
||||
var _ bun.BeforeAppendModelHook = (*User)(nil)
|
||||
|
||||
func (u *User) BeforeAppendModel(ctx context.Context, query bun.Query) error {
|
||||
switch query.(type) {
|
||||
case *bun.InsertQuery:
|
||||
u.CreatedAt = time.Now()
|
||||
case *bun.UpdateQuery:
|
||||
u.UpdatedAt = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## ResolveSpec Integration
|
||||
|
||||
### Setup with BUN
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/bitechdev/ResolveSpec/pkg/restheadspec"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/models"
|
||||
)
|
||||
|
||||
// Create ResolveSpec handler with BUN
|
||||
handler := restheadspec.NewHandlerWithBun(db)
|
||||
|
||||
// Setup routes (models are automatically discovered)
|
||||
router := mux.NewRouter()
|
||||
restheadspec.SetupMuxRoutes(router, handler, nil)
|
||||
```
|
||||
|
||||
### Custom Queries
|
||||
|
||||
```go
|
||||
// Use BUN directly for complex queries
|
||||
var results []struct {
|
||||
UserID string
|
||||
HookCount int
|
||||
}
|
||||
|
||||
err := db.NewSelect().
|
||||
Model((*models.Hook)(nil)).
|
||||
Column("user_id").
|
||||
ColumnExpr("COUNT(*) as hook_count").
|
||||
Group("user_id").
|
||||
Scan(ctx, &results)
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
### 1. Use Column Selection
|
||||
|
||||
```go
|
||||
// Don't load unnecessary columns
|
||||
err := db.NewSelect().
|
||||
Model(&users).
|
||||
Column("id", "username", "email").
|
||||
Scan(ctx)
|
||||
```
|
||||
|
||||
### 2. Batch Operations
|
||||
|
||||
```go
|
||||
// Insert multiple records
|
||||
_, err := db.NewInsert().
|
||||
Model(&users).
|
||||
Exec(ctx)
|
||||
```
|
||||
|
||||
### 3. Use Indexes
|
||||
|
||||
```dbml
|
||||
indexes {
|
||||
(user_id) [name: 'idx_hooks_user_id']
|
||||
(created_at) [name: 'idx_hooks_created_at']
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Connection Pooling
|
||||
|
||||
```go
|
||||
sqldb.SetMaxOpenConns(25)
|
||||
sqldb.SetMaxIdleConns(25)
|
||||
sqldb.SetConnMaxLifetime(5 * time.Minute)
|
||||
```
|
||||
|
||||
### 5. Prepared Statements
|
||||
|
||||
BUN automatically uses prepared statements for better performance.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Repository Pattern
|
||||
|
||||
```go
|
||||
type UserRepository struct {
|
||||
db *bun.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *bun.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByID(ctx context.Context, id string) (*models.User, error) {
|
||||
user := new(models.User)
|
||||
err := r.db.NewSelect().
|
||||
Model(user).
|
||||
Where("id = ?", id).
|
||||
Scan(ctx)
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*models.User, error) {
|
||||
user := new(models.User)
|
||||
err := r.db.NewSelect().
|
||||
Model(user).
|
||||
Where("username = ?", username).
|
||||
Scan(ctx)
|
||||
return user, err
|
||||
}
|
||||
```
|
||||
|
||||
### Soft Deletes
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
bun.BaseModel `bun:"table:users"`
|
||||
ID string `bun:"id,pk"`
|
||||
DeletedAt bun.NullTime `bun:"deleted_at,soft_delete"`
|
||||
}
|
||||
|
||||
// Automatically filters out soft-deleted records
|
||||
var users []User
|
||||
err := db.NewSelect().
|
||||
Model(&users).
|
||||
Scan(ctx)
|
||||
|
||||
// Include soft-deleted records
|
||||
err := db.NewSelect().
|
||||
Model(&users).
|
||||
WhereAllWithDeleted().
|
||||
Scan(ctx)
|
||||
```
|
||||
|
||||
### Multi-Tenancy
|
||||
|
||||
```go
|
||||
// Filter by user_id for all queries
|
||||
err := db.NewSelect().
|
||||
Model(&hooks).
|
||||
Where("user_id = ?", currentUserID).
|
||||
Scan(ctx)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```go
|
||||
import "github.com/uptrace/bun/driver/pgdriver"
|
||||
|
||||
err := db.NewSelect().Model(&user).Where("id = ?", id).Scan(ctx)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
// Not found
|
||||
case err != nil:
|
||||
var pgErr pgdriver.Error
|
||||
if errors.As(err, &pgErr) {
|
||||
// PostgreSQL specific error
|
||||
if pgErr.IntegrityViolation() {
|
||||
// Handle constraint violation
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dbfixture"
|
||||
)
|
||||
|
||||
func TestUserRepository(t *testing.T) {
|
||||
// Use in-memory SQLite for tests
|
||||
db := NewSQLiteDB(":memory:")
|
||||
defer db.Close()
|
||||
|
||||
// Create tables
|
||||
ctx := context.Background()
|
||||
_, err := db.NewCreateTable().
|
||||
Model((*models.User)(nil)).
|
||||
Exec(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test repository
|
||||
repo := NewUserRepository(db)
|
||||
user := &models.User{...}
|
||||
err = repo.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- BUN Documentation: https://bun.uptrace.dev/
|
||||
- BUN GitHub: https://github.com/uptrace/bun
|
||||
- PostgreSQL Driver: https://bun.uptrace.dev/postgres/
|
||||
- SQLite Driver: https://bun.uptrace.dev/sqlite/
|
||||
- ResolveSpec: https://github.com/bitechdev/ResolveSpec
|
||||
259
tooldoc/CODE_GUIDELINES.md
Normal file
259
tooldoc/CODE_GUIDELINES.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# Code Guidelines for WhatsHooked
|
||||
|
||||
## General Principles
|
||||
|
||||
- Write clean, idiomatic Go code following standard conventions
|
||||
- Use meaningful variable and function names
|
||||
- Keep functions small and focused on a single responsibility
|
||||
- Document exported functions, types, and packages
|
||||
- Handle errors explicitly, never ignore them
|
||||
- Use context.Context for cancellation and timeout handling
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
whatshooked/
|
||||
├── cmd/ # Command-line entry points
|
||||
│ ├── server/ # HTTP server command
|
||||
│ └── cli/ # CLI tools
|
||||
├── pkg/ # Public packages
|
||||
│ ├── auth/ # Authentication & authorization
|
||||
│ ├── api/ # API endpoints
|
||||
│ ├── cache/ # Caching layer
|
||||
│ ├── config/ # Configuration management
|
||||
│ ├── events/ # Event system
|
||||
│ ├── eventlogger/ # Event logging
|
||||
│ ├── handlers/ # HTTP handlers
|
||||
│ ├── hooks/ # Webhook management
|
||||
│ ├── logging/ # Structured logging
|
||||
│ ├── storage/ # Database storage layer
|
||||
│ ├── utils/ # Utility functions
|
||||
│ ├── webserver/ # Web server with ResolveSpec
|
||||
│ ├── whatsapp/ # WhatsApp integration
|
||||
│ └── whatshooked/ # Core application logic
|
||||
└── tooldoc/ # Tool & library documentation
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Packages
|
||||
- Use lowercase, single-word package names
|
||||
- Use descriptive names that reflect the package's purpose
|
||||
- Avoid generic names like `util` or `common` (use `utils` with specific subdirectories if needed)
|
||||
|
||||
### Files
|
||||
- Use snake_case for file names: `user_service.go`, `auth_middleware.go`
|
||||
- Test files: `user_service_test.go`
|
||||
- Keep related functionality in the same file
|
||||
|
||||
### Variables & Functions
|
||||
- Use camelCase for private: `userService`, `handleRequest`
|
||||
- Use PascalCase for exported: `UserService`, `HandleRequest`
|
||||
- Use descriptive names: prefer `userRepository` over `ur`
|
||||
- Boolean variables should be prefixed: `isValid`, `hasPermission`, `canAccess`
|
||||
|
||||
### Constants
|
||||
- Use PascalCase for exported: `DefaultTimeout`
|
||||
- Use camelCase for private: `defaultTimeout`
|
||||
- Group related constants together
|
||||
|
||||
### Interfaces
|
||||
- Name interfaces with -er suffix when appropriate: `Reader`, `Writer`, `Handler`
|
||||
- Use descriptive names: `UserRepository`, `AuthService`
|
||||
|
||||
## Error Handling
|
||||
|
||||
```go
|
||||
// Wrap errors with context
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load user: %w", err)
|
||||
}
|
||||
|
||||
// Check specific errors
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
// Custom error types
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return fmt.Sprintf("%s: %s", e.Field, e.Message)
|
||||
}
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
Use zerolog for structured logging:
|
||||
|
||||
```go
|
||||
import "github.com/rs/zerolog/log"
|
||||
|
||||
log.Info().
|
||||
Str("user_id", userID).
|
||||
Msg("User logged in")
|
||||
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("operation", "database_query").
|
||||
Msg("Failed to query database")
|
||||
```
|
||||
|
||||
## Context Usage
|
||||
|
||||
Always pass context as the first parameter:
|
||||
|
||||
```go
|
||||
func ProcessRequest(ctx context.Context, userID string) error {
|
||||
// Check for cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Pass context to downstream calls
|
||||
user, err := userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- Write table-driven tests
|
||||
- Use descriptive test names: `TestUserService_Create_WithValidData_Success`
|
||||
- Mock external dependencies
|
||||
- Aim for high coverage of business logic
|
||||
- Use t.Run for subtests
|
||||
|
||||
```go
|
||||
func TestUserService_Create(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *User
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid user",
|
||||
input: &User{Name: "John", Email: "john@example.com"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing email",
|
||||
input: &User{Name: "John"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
service := NewUserService()
|
||||
err := service.Create(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Create() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Operations
|
||||
|
||||
- Use transactions for multiple related operations
|
||||
- Always use prepared statements
|
||||
- Handle NULL values properly
|
||||
- Use meaningful struct tags
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
## ResolveSpec Integration
|
||||
|
||||
When using ResolveSpec:
|
||||
- Register all models in the registry
|
||||
- Use schema.table format: "public.users", "core.accounts"
|
||||
- Implement hooks for auth and validation
|
||||
- Use lifecycle hooks for audit logging
|
||||
|
||||
## API Design
|
||||
|
||||
- Use RESTful conventions
|
||||
- Return appropriate HTTP status codes
|
||||
- Include meaningful error messages
|
||||
- Version your APIs: `/api/v1/users`
|
||||
- Document all endpoints
|
||||
|
||||
## Security
|
||||
|
||||
- Never log sensitive data (passwords, tokens, etc.)
|
||||
- Validate all input
|
||||
- Use parameterized queries
|
||||
- Implement rate limiting
|
||||
- Use HTTPS in production
|
||||
- Sanitize user input
|
||||
- Implement proper CORS policies
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Keep dependencies minimal
|
||||
- Pin versions in go.mod
|
||||
- Regularly update dependencies
|
||||
- Document why each dependency is needed
|
||||
|
||||
## Documentation
|
||||
|
||||
- Document all exported functions, types, and packages
|
||||
- Use godoc format
|
||||
- Include examples in documentation
|
||||
- Keep README.md up to date
|
||||
- Document configuration options
|
||||
|
||||
## Comments
|
||||
|
||||
```go
|
||||
// Package auth provides authentication and authorization functionality.
|
||||
package auth
|
||||
|
||||
// User represents a system user.
|
||||
type User struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
// Authenticate verifies user credentials and returns a token.
|
||||
// Returns ErrInvalidCredentials if authentication fails.
|
||||
func Authenticate(ctx context.Context, username, password string) (string, error) {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
- Use connection pools for databases
|
||||
- Implement caching where appropriate
|
||||
- Avoid N+1 queries
|
||||
- Use batch operations when possible
|
||||
- Profile critical paths
|
||||
- Set appropriate timeouts
|
||||
|
||||
## Git Workflow
|
||||
|
||||
- Write clear commit messages
|
||||
- Keep commits atomic and focused
|
||||
- Use conventional commits format
|
||||
- Create feature branches
|
||||
- Run tests before committing
|
||||
- Review your own changes before pushing
|
||||
316
tooldoc/ORANGURU.md
Normal file
316
tooldoc/ORANGURU.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# Oranguru Integration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Oranguru is a React component library that provides enhanced Mantine-based components with advanced features and state management capabilities. For WhatsHooked, we'll use it to build data grids and forms for the admin interface.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @warkypublic/oranguru
|
||||
```
|
||||
|
||||
### Peer Dependencies
|
||||
|
||||
```bash
|
||||
npm install react zustand @mantine/core @mantine/hooks @warkypublic/artemis-kit @warkypublic/zustandsyncstore use-sync-external-store
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Enhanced Context Menus
|
||||
Oranguru provides better menu positioning and visibility control than standard Mantine menus.
|
||||
|
||||
### Custom Rendering
|
||||
Support for custom menu item renderers and complete menu rendering.
|
||||
|
||||
### State Management
|
||||
Uses Zustand for component state management with sync capabilities.
|
||||
|
||||
## Basic Setup
|
||||
|
||||
```tsx
|
||||
import { MantineBetterMenusProvider } from '@warkypublic/oranguru';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<MantineProvider>
|
||||
<MantineBetterMenusProvider>
|
||||
{/* Your app content */}
|
||||
</MantineBetterMenusProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using Context Menus
|
||||
|
||||
```tsx
|
||||
import { useMantineBetterMenus } from '@warkypublic/oranguru';
|
||||
|
||||
function DataGrid() {
|
||||
const { show, hide } = useMantineBetterMenus();
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, record: any) => {
|
||||
e.preventDefault();
|
||||
show('record-menu', {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
items: [
|
||||
{
|
||||
label: 'Edit',
|
||||
onClick: () => handleEdit(record)
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: () => handleDelete(record)
|
||||
},
|
||||
{
|
||||
isDivider: true
|
||||
},
|
||||
{
|
||||
label: 'View Details',
|
||||
onClick: () => handleViewDetails(record)
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<table>
|
||||
{records.map(record => (
|
||||
<tr key={record.id} onContextMenu={(e) => handleContextMenu(e, record)}>
|
||||
<td>{record.name}</td>
|
||||
</tr>
|
||||
))}
|
||||
</table>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Async Actions
|
||||
|
||||
```tsx
|
||||
const asyncMenuItem = {
|
||||
label: 'Sync Data',
|
||||
onClickAsync: async () => {
|
||||
await fetch('/api/sync', { method: 'POST' });
|
||||
// Shows loading state automatically
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Custom Menu Items
|
||||
|
||||
```tsx
|
||||
const customItem = {
|
||||
renderer: ({ loading }: any) => (
|
||||
<div style={{ padding: '8px 12px' }}>
|
||||
{loading ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<Group>
|
||||
<IconCheck size={16} />
|
||||
<Text>Custom Action</Text>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
onClickAsync: async () => {
|
||||
await performAction();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Integration with Data Grids
|
||||
|
||||
While Oranguru doesn't provide a built-in data grid component yet, it works excellently with Mantine's DataTable or custom table implementations:
|
||||
|
||||
```tsx
|
||||
import { useMantineBetterMenus } from '@warkypublic/oranguru';
|
||||
import { DataTable } from '@mantine/datatable';
|
||||
|
||||
function UserGrid() {
|
||||
const { show } = useMantineBetterMenus();
|
||||
const [users, setUsers] = useState([]);
|
||||
|
||||
const columns = [
|
||||
{ accessor: 'name', title: 'Name' },
|
||||
{ accessor: 'email', title: 'Email' },
|
||||
{ accessor: 'status', title: 'Status' }
|
||||
];
|
||||
|
||||
const handleRowContextMenu = (e: React.MouseEvent, user: User) => {
|
||||
e.preventDefault();
|
||||
show('user-menu', {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
items: [
|
||||
{
|
||||
label: 'Edit User',
|
||||
onClick: () => navigate(`/users/${user.id}/edit`)
|
||||
},
|
||||
{
|
||||
label: 'Deactivate',
|
||||
onClickAsync: async () => {
|
||||
await fetch(`/api/users/${user.id}/deactivate`, { method: 'POST' });
|
||||
await refreshUsers();
|
||||
}
|
||||
},
|
||||
{
|
||||
isDivider: true
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: () => handleDelete(user.id)
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
records={users}
|
||||
onRowContextMenu={({ event, record }) => handleRowContextMenu(event, record)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Form Integration
|
||||
|
||||
For forms, use Mantine's form components with Oranguru's menu system for enhanced UX:
|
||||
|
||||
```tsx
|
||||
import { useForm } from '@mantine/form';
|
||||
import { TextInput, Select, Button } from '@mantine/core';
|
||||
|
||||
function UserForm() {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
role: ''
|
||||
},
|
||||
validate: {
|
||||
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
|
||||
name: (value) => (value.length < 2 ? 'Name too short' : null)
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
try {
|
||||
await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(values)
|
||||
});
|
||||
notifications.show({ message: 'User created successfully' });
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
message: 'Failed to create user',
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="John Doe"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="john@example.com"
|
||||
{...form.getInputProps('email')}
|
||||
/>
|
||||
<Select
|
||||
label="Role"
|
||||
data={['Admin', 'User', 'Viewer']}
|
||||
{...form.getInputProps('role')}
|
||||
/>
|
||||
<Button type="submit">Create User</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Provider Placement**: Place MantineBetterMenusProvider at the root of your app
|
||||
2. **Menu IDs**: Use descriptive, unique IDs for each menu type
|
||||
3. **Context Menus**: Always prevent default on right-click events
|
||||
4. **Loading States**: Use onClickAsync for async operations to get automatic loading states
|
||||
5. **Custom Renderers**: Use custom renderers for complex menu items with icons, badges, etc.
|
||||
6. **Portal Rendering**: Oranguru uses portals for proper z-index handling
|
||||
7. **State Management**: Leverage Zustand store for complex menu state
|
||||
8. **Accessibility**: Ensure keyboard navigation works with context menus
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Multi-Select Context Menu
|
||||
```tsx
|
||||
const handleBulkContextMenu = (e: React.MouseEvent, selectedIds: string[]) => {
|
||||
e.preventDefault();
|
||||
show('bulk-menu', {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
items: [
|
||||
{
|
||||
label: `Delete ${selectedIds.length} items`,
|
||||
onClickAsync: async () => {
|
||||
await bulkDelete(selectedIds);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Export selection',
|
||||
onClick: () => exportItems(selectedIds)
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Conditional Menu Items
|
||||
```tsx
|
||||
const menuItems = [
|
||||
{
|
||||
label: 'Edit',
|
||||
onClick: () => handleEdit(record)
|
||||
},
|
||||
canDelete(record) && {
|
||||
label: 'Delete',
|
||||
onClick: () => handleDelete(record)
|
||||
},
|
||||
{
|
||||
isDivider: true
|
||||
},
|
||||
isAdmin && {
|
||||
label: 'Admin Actions',
|
||||
onClick: () => showAdminActions(record)
|
||||
}
|
||||
].filter(Boolean); // Remove falsy items
|
||||
```
|
||||
|
||||
### Nested Menus (Future Feature)
|
||||
While not yet supported, Oranguru is designed to support nested menus in future versions.
|
||||
|
||||
## Integration with WhatsHooked
|
||||
|
||||
For WhatsHooked's admin interface:
|
||||
|
||||
1. **User Management Grid**: Use DataTable with context menus for user actions
|
||||
2. **Hook Configuration**: Form with validation and async submission
|
||||
3. **WhatsApp Account Management**: Grid with QR code display and pairing actions
|
||||
4. **API Key Management**: Grid with copy-to-clipboard and revoke actions
|
||||
5. **Event Logs**: Read-only grid with filtering and export
|
||||
|
||||
## References
|
||||
|
||||
- Official Repository: https://git.warky.dev/wdevs/oranguru
|
||||
- Mantine Documentation: https://mantine.dev
|
||||
- Mantine DataTable: https://icflorescu.github.io/mantine-datatable/
|
||||
504
tooldoc/REACT_MANTINE_TANSTACK.md
Normal file
504
tooldoc/REACT_MANTINE_TANSTACK.md
Normal file
@@ -0,0 +1,504 @@
|
||||
# React + Mantine + TanStack Start Integration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
For WhatsHooked's admin interface, we'll use:
|
||||
- **React 19**: Modern React with hooks and suspense
|
||||
- **Mantine**: Component library for UI
|
||||
- **TanStack Start**: Full-stack React framework with server-side rendering
|
||||
- **Oranguru**: Enhanced Mantine components for grids and forms
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── app/
|
||||
│ ├── routes/
|
||||
│ │ ├── __root.tsx # Root layout
|
||||
│ │ ├── index.tsx # Dashboard
|
||||
│ │ ├── login.tsx # Login page
|
||||
│ │ ├── users/
|
||||
│ │ │ ├── index.tsx # User list
|
||||
│ │ │ ├── new.tsx # Create user
|
||||
│ │ │ └── $id/
|
||||
│ │ │ ├── index.tsx # User details
|
||||
│ │ │ └── edit.tsx # Edit user
|
||||
│ │ ├── hooks/
|
||||
│ │ │ ├── index.tsx # Hook list
|
||||
│ │ │ └── ...
|
||||
│ │ └── accounts/
|
||||
│ │ ├── index.tsx # WhatsApp accounts
|
||||
│ │ └── ...
|
||||
│ ├── components/
|
||||
│ │ ├── Layout.tsx
|
||||
│ │ ├── Navbar.tsx
|
||||
│ │ ├── UserGrid.tsx
|
||||
│ │ └── ...
|
||||
│ ├── lib/
|
||||
│ │ ├── api.ts # API client
|
||||
│ │ ├── auth.ts # Auth utilities
|
||||
│ │ └── types.ts # TypeScript types
|
||||
│ └── styles/
|
||||
│ └── global.css
|
||||
├── public/
|
||||
│ └── assets/
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── vite.config.ts
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm create @tanstack/start@latest
|
||||
cd whatshooked-admin
|
||||
npm install @mantine/core @mantine/hooks @mantine/notifications @mantine/form @mantine/datatable
|
||||
npm install @warkypublic/oranguru
|
||||
npm install @tanstack/react-query axios
|
||||
npm install -D @types/react @types/react-dom
|
||||
```
|
||||
|
||||
## Basic Setup
|
||||
|
||||
### app/routes/__root.tsx
|
||||
|
||||
```tsx
|
||||
import { createRootRoute, Outlet } from '@tanstack/react-router';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { MantineBetterMenusProvider } from '@warkypublic/oranguru';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider>
|
||||
<MantineBetterMenusProvider>
|
||||
<Notifications />
|
||||
<Outlet />
|
||||
</MantineBetterMenusProvider>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
### app/lib/api.ts
|
||||
|
||||
```typescript
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8825/api/v1',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Handle auth errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
||||
// API methods
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
api.post('/auth/login', { username, password }),
|
||||
|
||||
logout: () =>
|
||||
api.post('/auth/logout'),
|
||||
|
||||
getProfile: () =>
|
||||
api.get('/auth/profile'),
|
||||
};
|
||||
|
||||
export const usersApi = {
|
||||
list: (params?: any) =>
|
||||
api.get('/users', { params }),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get(`/users/${id}`),
|
||||
|
||||
create: (data: any) =>
|
||||
api.post('/users', data),
|
||||
|
||||
update: (id: string, data: any) =>
|
||||
api.put(`/users/${id}`, data),
|
||||
|
||||
delete: (id: string) =>
|
||||
api.delete(`/users/${id}`),
|
||||
};
|
||||
|
||||
export const hooksApi = {
|
||||
list: () =>
|
||||
api.get('/hooks'),
|
||||
|
||||
create: (data: any) =>
|
||||
api.post('/hooks', data),
|
||||
|
||||
update: (id: string, data: any) =>
|
||||
api.put(`/hooks/${id}`, data),
|
||||
|
||||
delete: (id: string) =>
|
||||
api.delete(`/hooks/${id}`),
|
||||
};
|
||||
|
||||
export const accountsApi = {
|
||||
list: () =>
|
||||
api.get('/accounts'),
|
||||
|
||||
pair: (accountId: string) =>
|
||||
api.post(`/accounts/${accountId}/pair`),
|
||||
|
||||
disconnect: (accountId: string) =>
|
||||
api.post(`/accounts/${accountId}/disconnect`),
|
||||
|
||||
getQRCode: (accountId: string) =>
|
||||
api.get(`/accounts/${accountId}/qr`, { responseType: 'blob' }),
|
||||
};
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
### app/routes/login.tsx
|
||||
|
||||
```tsx
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { TextInput, PasswordInput, Button, Paper, Title, Container } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { authApi } from '../lib/api';
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
component: LoginPage,
|
||||
});
|
||||
|
||||
function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
validate: {
|
||||
username: (value) => (value.length < 3 ? 'Username too short' : null),
|
||||
password: (value) => (value.length < 6 ? 'Password too short' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
try {
|
||||
const response = await authApi.login(values.username, values.password);
|
||||
localStorage.setItem('auth_token', response.data.token);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Logged in successfully',
|
||||
color: 'green',
|
||||
});
|
||||
navigate({ to: '/' });
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Invalid credentials',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title ta="center">WhatsHooked Admin</Title>
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="admin"
|
||||
required
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
required
|
||||
mt="md"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
<Button fullWidth mt="xl" type="submit">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Data Grid with Oranguru
|
||||
|
||||
### app/routes/users/index.tsx
|
||||
|
||||
```tsx
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DataTable } from '@mantine/datatable';
|
||||
import { useMantineBetterMenus } from '@warkypublic/oranguru';
|
||||
import { Button, Group, Text } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { usersApi } from '../../lib/api';
|
||||
|
||||
export const Route = createFileRoute('/users/')({
|
||||
component: UsersPage,
|
||||
});
|
||||
|
||||
function UsersPage() {
|
||||
const { show } = useMantineBetterMenus();
|
||||
|
||||
const { data: users, isLoading, refetch } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: async () => {
|
||||
const response = await usersApi.list();
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, user: any) => {
|
||||
e.preventDefault();
|
||||
show('user-menu', {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
items: [
|
||||
{
|
||||
label: 'Edit',
|
||||
onClick: () => navigate({ to: `/users/${user.id}/edit` }),
|
||||
},
|
||||
{
|
||||
label: 'View Details',
|
||||
onClick: () => navigate({ to: `/users/${user.id}` }),
|
||||
},
|
||||
{
|
||||
isDivider: true,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClickAsync: async () => {
|
||||
await usersApi.delete(user.id);
|
||||
notifications.show({
|
||||
message: 'User deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ accessor: 'name', title: 'Name' },
|
||||
{ accessor: 'email', title: 'Email' },
|
||||
{ accessor: 'role', title: 'Role' },
|
||||
{
|
||||
accessor: 'actions',
|
||||
title: '',
|
||||
render: (user: any) => (
|
||||
<Group gap="xs">
|
||||
<Button size="xs" onClick={() => navigate({ to: `/users/${user.id}/edit` })}>
|
||||
Edit
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text size="xl" fw={700}>Users</Text>
|
||||
<Button onClick={() => navigate({ to: '/users/new' })}>
|
||||
Create User
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
records={users || []}
|
||||
fetching={isLoading}
|
||||
onRowContextMenu={({ event, record }) => handleContextMenu(event, record)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Forms
|
||||
|
||||
### app/routes/users/new.tsx
|
||||
|
||||
```tsx
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { TextInput, Select, Button, Paper } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { usersApi } from '../../lib/api';
|
||||
|
||||
export const Route = createFileRoute('/users/new')({
|
||||
component: NewUserPage,
|
||||
});
|
||||
|
||||
function NewUserPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user',
|
||||
},
|
||||
validate: {
|
||||
name: (value) => (value.length < 2 ? 'Name too short' : null),
|
||||
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
|
||||
password: (value) => (value.length < 6 ? 'Password too short' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
try {
|
||||
await usersApi.create(values);
|
||||
notifications.show({
|
||||
message: 'User created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
navigate({ to: '/users' });
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
message: 'Failed to create user',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper p="md">
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="John Doe"
|
||||
required
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="john@example.com"
|
||||
required
|
||||
mt="md"
|
||||
{...form.getInputProps('email')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Password"
|
||||
required
|
||||
mt="md"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
<Select
|
||||
label="Role"
|
||||
data={['admin', 'user', 'viewer']}
|
||||
required
|
||||
mt="md"
|
||||
{...form.getInputProps('role')}
|
||||
/>
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="subtle" onClick={() => navigate({ to: '/users' })}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Create</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Layout with Navigation
|
||||
|
||||
### app/components/Layout.tsx
|
||||
|
||||
```tsx
|
||||
import { AppShell, NavLink, Group, Title } from '@mantine/core';
|
||||
import { IconUsers, IconWebhook, IconBrandWhatsapp, IconKey } from '@tabler/icons-react';
|
||||
import { Link, useLocation } from '@tanstack/react-router';
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation();
|
||||
|
||||
const navItems = [
|
||||
{ icon: IconUsers, label: 'Users', to: '/users' },
|
||||
{ icon: IconWebhook, label: 'Hooks', to: '/hooks' },
|
||||
{ icon: IconBrandWhatsapp, label: 'WhatsApp Accounts', to: '/accounts' },
|
||||
{ icon: IconKey, label: 'API Keys', to: '/api-keys' },
|
||||
];
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
navbar={{ width: 250, breakpoint: 'sm' }}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Navbar p="md">
|
||||
<Title order={3} mb="md">WhatsHooked</Title>
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
component={Link}
|
||||
to={item.to}
|
||||
label={item.label}
|
||||
leftSection={<item.icon size={20} />}
|
||||
active={location.pathname.startsWith(item.to)}
|
||||
/>
|
||||
))}
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>{children}</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Code Splitting**: Use lazy loading for routes
|
||||
2. **Error Boundaries**: Wrap components in error boundaries
|
||||
3. **Loading States**: Show loading indicators with Suspense
|
||||
4. **Optimistic Updates**: Update UI before API response
|
||||
5. **Form Validation**: Use Mantine form with validation
|
||||
6. **Type Safety**: Use TypeScript for all API calls
|
||||
7. **Query Invalidation**: Refetch data after mutations
|
||||
8. **Auth Protection**: Protect routes with auth guards
|
||||
|
||||
## References
|
||||
|
||||
- TanStack Start: https://tanstack.com/start
|
||||
- Mantine: https://mantine.dev
|
||||
- TanStack Query: https://tanstack.com/query
|
||||
- Oranguru: See ORANGURU.md
|
||||
295
tooldoc/RELSPECGO.md
Normal file
295
tooldoc/RELSPECGO.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# relspecgo - DBML to BUN Model Generator
|
||||
|
||||
## Overview
|
||||
|
||||
relspecgo is a code generator that converts DBML (Database Markup Language) schema files into BUN ORM models for Go. It automates the creation of model structs with proper BUN tags, relationships, and indexes.
|
||||
|
||||
Repository: https://git.warky.dev/wdevs/relspecgo
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go install git.warky.dev/wdevs/relspecgo@latest
|
||||
```
|
||||
|
||||
Or via Makefile:
|
||||
```bash
|
||||
make install-relspecgo
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Command
|
||||
|
||||
```bash
|
||||
relspecgo generate --input=sql/schema.dbml --output=pkg/models --orm=bun
|
||||
```
|
||||
|
||||
### Via Makefile
|
||||
|
||||
```bash
|
||||
make generate-models
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Read `sql/schema.dbml`
|
||||
2. Generate BUN models in `pkg/models/`
|
||||
3. Create proper Go structs with BUN tags
|
||||
|
||||
## DBML Schema Format
|
||||
|
||||
### Table Definition
|
||||
|
||||
```dbml
|
||||
Table users {
|
||||
id varchar(36) [primary key]
|
||||
username varchar(255) [unique, not null]
|
||||
email varchar(255) [unique, not null]
|
||||
password varchar(255) [not null]
|
||||
role varchar(50) [not null, default: 'user']
|
||||
active boolean [not null, default: true]
|
||||
created_at timestamp [not null, default: `now()`]
|
||||
updated_at timestamp [not null, default: `now()`]
|
||||
deleted_at timestamp [null]
|
||||
|
||||
indexes {
|
||||
(deleted_at) [name: 'idx_users_deleted_at']
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Relationships
|
||||
|
||||
```dbml
|
||||
Table api_keys {
|
||||
id varchar(36) [primary key]
|
||||
user_id varchar(36) [not null, ref: > users.id]
|
||||
...
|
||||
}
|
||||
|
||||
// Explicit relationship with cascade delete
|
||||
Ref: api_keys.user_id > users.id [delete: cascade]
|
||||
```
|
||||
|
||||
### Supported Field Types
|
||||
|
||||
- `varchar(n)` → `string`
|
||||
- `text` → `string`
|
||||
- `int`, `integer` → `int`
|
||||
- `bigint` → `int64`
|
||||
- `boolean`, `bool` → `bool`
|
||||
- `timestamp`, `datetime` → `time.Time`
|
||||
- `json`, `jsonb` → `json.RawMessage` or custom type
|
||||
|
||||
### Field Attributes
|
||||
|
||||
- `[primary key]` → BUN primary key tag
|
||||
- `[not null]` → Required field
|
||||
- `[unique]` → Unique constraint
|
||||
- `[default: value]` → Default value
|
||||
- `[note: 'text']` → Documentation comment
|
||||
- `[ref: > table.column]` → Foreign key relationship
|
||||
|
||||
## Generated BUN Models
|
||||
|
||||
### Example Output
|
||||
|
||||
```go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
bun.BaseModel `bun:"table:users,alias:u"`
|
||||
|
||||
ID string `bun:"id,pk,type:varchar(36)" json:"id"`
|
||||
Username string `bun:"username,unique,notnull,type:varchar(255)" json:"username"`
|
||||
Email string `bun:"email,unique,notnull,type:varchar(255)" json:"email"`
|
||||
Password string `bun:"password,notnull,type:varchar(255)" json:"-"`
|
||||
FullName string `bun:"full_name,type:varchar(255)" json:"full_name,omitempty"`
|
||||
Role string `bun:"role,notnull,default:'user',type:varchar(50)" json:"role"`
|
||||
Active bool `bun:"active,notnull,default:true" json:"active"`
|
||||
CreatedAt time.Time `bun:"created_at,notnull,default:now()" json:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at,notnull,default:now()" json:"updated_at"`
|
||||
DeletedAt time.Time `bun:"deleted_at,soft_delete" json:"deleted_at,omitempty"`
|
||||
|
||||
// Relationships
|
||||
APIKeys []*APIKey `bun:"rel:has-many,join:id=user_id" json:"api_keys,omitempty"`
|
||||
Hooks []*Hook `bun:"rel:has-many,join:id=user_id" json:"hooks,omitempty"`
|
||||
WhatsAppAccounts []*WhatsAppAccount `bun:"rel:has-many,join:id=user_id" json:"whatsapp_accounts,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### BUN Tags
|
||||
|
||||
- `bun:"table:users,alias:u"` - Table name and alias
|
||||
- `bun:"id,pk"` - Primary key
|
||||
- `bun:"username,unique"` - Unique constraint
|
||||
- `bun:"password,notnull"` - NOT NULL constraint
|
||||
- `bun:"role,default:'user'"` - Default value
|
||||
- `bun:"type:varchar(255)"` - Explicit column type
|
||||
- `bun:"deleted_at,soft_delete"` - Soft delete support
|
||||
- `bun:"rel:has-many,join:id=user_id"` - Has-many relationship
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
sql/
|
||||
├── schema.dbml # Main schema definition
|
||||
├── postgres/ # PostgreSQL specific migrations
|
||||
│ ├── 20240101_init.up.sql
|
||||
│ └── 20240101_init.down.sql
|
||||
└── sqlite/ # SQLite specific migrations
|
||||
├── 20240101_init.up.sql
|
||||
└── 20240101_init.down.sql
|
||||
|
||||
pkg/
|
||||
└── models/ # Generated BUN models
|
||||
├── user.go
|
||||
├── api_key.go
|
||||
├── hook.go
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Define Schema
|
||||
|
||||
Create or update `sql/schema.dbml`:
|
||||
|
||||
```dbml
|
||||
Table products {
|
||||
id int [primary key, increment]
|
||||
name varchar(255) [not null]
|
||||
price decimal(10,2) [not null]
|
||||
created_at timestamp [not null, default: `now()`]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Generate Models
|
||||
|
||||
```bash
|
||||
make generate-models
|
||||
```
|
||||
|
||||
### 3. Create Migrations
|
||||
|
||||
```bash
|
||||
make migrate-create NAME=add_products_table
|
||||
```
|
||||
|
||||
Edit generated migration files in `sql/postgres/` and `sql/sqlite/`
|
||||
|
||||
### 4. Run Migrations
|
||||
|
||||
```bash
|
||||
make migrate-up
|
||||
```
|
||||
|
||||
### 5. Use in Code
|
||||
|
||||
```go
|
||||
import "git.warky.dev/wdevs/whatshooked/pkg/models"
|
||||
|
||||
// Query with BUN
|
||||
var users []models.User
|
||||
err := db.NewSelect().
|
||||
Model(&users).
|
||||
Relation("APIKeys").
|
||||
Where("active = ?", true).
|
||||
Scan(ctx)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Single Source of Truth**: Keep DBML as the source of truth for schema
|
||||
2. **Regenerate After Changes**: Always run `make generate-models` after DBML changes
|
||||
3. **Don't Edit Generated Files**: Modify DBML instead, then regenerate
|
||||
4. **Version Control**: Commit both DBML and generated models
|
||||
5. **Migrations**: Create migrations for schema changes
|
||||
6. **Relationships**: Define relationships in DBML for proper code generation
|
||||
7. **Indexes**: Specify indexes in DBML for performance
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Generate models from DBML
|
||||
make generate-models
|
||||
|
||||
# Create new migration
|
||||
make migrate-create NAME=add_users_table
|
||||
|
||||
# Run migrations
|
||||
make migrate-up
|
||||
|
||||
# Rollback migrations
|
||||
make migrate-down
|
||||
|
||||
# Install relspecgo
|
||||
make install-relspecgo
|
||||
```
|
||||
|
||||
## DBML to SQL Conversion
|
||||
|
||||
relspecgo can also generate SQL from DBML:
|
||||
|
||||
```bash
|
||||
relspecgo sql --input=sql/schema.dbml --output=sql/postgres/schema.sql --dialect=postgres
|
||||
relspecgo sql --input=sql/schema.dbml --output=sql/sqlite/schema.sql --dialect=sqlite
|
||||
```
|
||||
|
||||
## Advantages
|
||||
|
||||
1. **Type Safety**: Generated Go structs are type-safe
|
||||
2. **Consistency**: Same schema definition for all models
|
||||
3. **Documentation**: DBML serves as schema documentation
|
||||
4. **Validation**: Catches schema errors before runtime
|
||||
5. **IDE Support**: Full IDE autocomplete and type checking
|
||||
6. **Relationships**: Automatic relationship setup
|
||||
7. **Migration Friendly**: Easy to track schema changes
|
||||
|
||||
## Integration with ResolveSpec
|
||||
|
||||
Generated BUN models work seamlessly with ResolveSpec:
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/bitechdev/ResolveSpec/pkg/restheadspec"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/models"
|
||||
)
|
||||
|
||||
// Create handler with BUN
|
||||
handler := restheadspec.NewHandlerWithBun(db)
|
||||
|
||||
// Models are automatically discovered from BUN's table names
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Models Not Generated
|
||||
|
||||
- Check DBML syntax
|
||||
- Ensure relspecgo is installed: `make install-relspecgo`
|
||||
- Verify input/output paths
|
||||
|
||||
### Compilation Errors
|
||||
|
||||
- Run `go mod tidy` to update dependencies
|
||||
- Check for missing imports
|
||||
- Verify BUN version compatibility
|
||||
|
||||
### Relationship Issues
|
||||
|
||||
- Ensure foreign keys are properly defined in DBML
|
||||
- Check `Ref:` declarations
|
||||
- Verify join conditions
|
||||
|
||||
## References
|
||||
|
||||
- relspecgo: https://git.warky.dev/wdevs/relspecgo
|
||||
- DBML Syntax: https://dbml.dbdiagram.io/docs/
|
||||
- BUN ORM: https://bun.uptrace.dev/
|
||||
- ResolveSpec: https://github.com/bitechdev/ResolveSpec
|
||||
359
tooldoc/RESOLVESPEC.md
Normal file
359
tooldoc/RESOLVESPEC.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# ResolveSpec Integration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
ResolveSpec is a flexible REST API framework that provides GraphQL-like capabilities while maintaining REST simplicity. It offers two approaches:
|
||||
1. **ResolveSpec** - Body-based API with JSON request options
|
||||
2. **RestHeadSpec** - Header-based API where query options are passed via HTTP headers
|
||||
|
||||
For WhatsHooked, we'll use both approaches to provide maximum flexibility.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/bitechdev/ResolveSpec
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Models
|
||||
Models are Go structs that represent database tables. Use GORM tags for database mapping.
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
### Registry
|
||||
The registry maps schema.table names to Go models.
|
||||
|
||||
```go
|
||||
handler := resolvespec.NewHandlerWithGORM(db)
|
||||
handler.Registry.RegisterModel("public.users", &User{})
|
||||
handler.Registry.RegisterModel("public.hooks", &Hook{})
|
||||
```
|
||||
|
||||
### Routing
|
||||
ResolveSpec generates routes automatically for registered models:
|
||||
- `/public/users` - Collection endpoints
|
||||
- `/public/users/:id` - Individual resource endpoints
|
||||
|
||||
## ResolveSpec (Body-Based)
|
||||
|
||||
Request format:
|
||||
```json
|
||||
POST /public/users
|
||||
{
|
||||
"operation": "read|create|update|delete",
|
||||
"data": {
|
||||
// For create/update operations
|
||||
},
|
||||
"options": {
|
||||
"columns": ["id", "name", "email"],
|
||||
"filters": [
|
||||
{"column": "status", "operator": "eq", "value": "active"}
|
||||
],
|
||||
"preload": ["hooks:id,url,events"],
|
||||
"sort": ["-created_at", "+name"],
|
||||
"limit": 50,
|
||||
"offset": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Setup with Gorilla Mux
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/resolvespec"
|
||||
)
|
||||
|
||||
func SetupResolveSpec(db *gorm.DB) *mux.Router {
|
||||
handler := resolvespec.NewHandlerWithGORM(db)
|
||||
|
||||
// Register models
|
||||
handler.Registry.RegisterModel("public.users", &User{})
|
||||
handler.Registry.RegisterModel("public.hooks", &Hook{})
|
||||
|
||||
// Setup routes
|
||||
router := mux.NewRouter()
|
||||
resolvespec.SetupMuxRoutes(router, handler, nil)
|
||||
|
||||
return router
|
||||
}
|
||||
```
|
||||
|
||||
## RestHeadSpec (Header-Based)
|
||||
|
||||
Request format:
|
||||
```http
|
||||
GET /public/users HTTP/1.1
|
||||
X-Select-Fields: id,name,email
|
||||
X-FieldFilter-Status: active
|
||||
X-Preload: hooks:id,url,events
|
||||
X-Sort: -created_at,+name
|
||||
X-Limit: 50
|
||||
X-Offset: 0
|
||||
X-DetailApi: true
|
||||
```
|
||||
|
||||
### Setup with Gorilla Mux
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/restheadspec"
|
||||
)
|
||||
|
||||
func SetupRestHeadSpec(db *gorm.DB) *mux.Router {
|
||||
handler := restheadspec.NewHandlerWithGORM(db)
|
||||
|
||||
// Register models
|
||||
handler.Registry.RegisterModel("public.users", &User{})
|
||||
handler.Registry.RegisterModel("public.hooks", &Hook{})
|
||||
|
||||
// Setup routes
|
||||
router := mux.NewRouter()
|
||||
restheadspec.SetupMuxRoutes(router, handler, nil)
|
||||
|
||||
return router
|
||||
}
|
||||
```
|
||||
|
||||
## Lifecycle Hooks (RestHeadSpec)
|
||||
|
||||
Add hooks for authentication, validation, and audit logging:
|
||||
|
||||
```go
|
||||
handler.OnBeforeRead(func(ctx context.Context, req *restheadspec.Request) error {
|
||||
// Check permissions
|
||||
userID := ctx.Value("user_id").(string)
|
||||
if !canRead(userID, req.Schema, req.Entity) {
|
||||
return fmt.Errorf("unauthorized")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
handler.OnAfterCreate(func(ctx context.Context, req *restheadspec.Request, result interface{}) error {
|
||||
// Audit log
|
||||
log.Info().
|
||||
Str("user_id", ctx.Value("user_id").(string)).
|
||||
Str("entity", req.Entity).
|
||||
Msg("Created record")
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
## Authentication Integration
|
||||
|
||||
```go
|
||||
// Middleware to extract user from JWT
|
||||
func AuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
user, err := ValidateToken(token)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), "user_id", user.ID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// Apply to routes
|
||||
router.Use(AuthMiddleware)
|
||||
```
|
||||
|
||||
## Filtering
|
||||
|
||||
### Field Filters (RestHeadSpec)
|
||||
```http
|
||||
X-FieldFilter-Status: active
|
||||
X-FieldFilter-Age: 18
|
||||
```
|
||||
|
||||
### Search Operators (RestHeadSpec)
|
||||
```http
|
||||
X-SearchOp-Gte-Age: 18
|
||||
X-SearchOp-Like-Name: john
|
||||
```
|
||||
|
||||
### Body Filters (ResolveSpec)
|
||||
```json
|
||||
{
|
||||
"options": {
|
||||
"filters": [
|
||||
{"column": "status", "operator": "eq", "value": "active"},
|
||||
{"column": "age", "operator": "gte", "value": 18},
|
||||
{"column": "name", "operator": "like", "value": "john%"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
### Offset-Based
|
||||
```http
|
||||
X-Limit: 50
|
||||
X-Offset: 100
|
||||
```
|
||||
|
||||
### Cursor-Based (RestHeadSpec)
|
||||
```http
|
||||
X-Cursor: eyJpZCI6IjEyMyIsImNyZWF0ZWRfYXQiOiIyMDI0LTAxLTAxIn0=
|
||||
X-Limit: 50
|
||||
```
|
||||
|
||||
## Preloading Relationships
|
||||
|
||||
Load related entities with custom columns:
|
||||
|
||||
```http
|
||||
X-Preload: hooks:id,url,events,posts:id,title
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"options": {
|
||||
"preload": ["hooks:id,url,events", "posts:id,title"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Sorting
|
||||
|
||||
```http
|
||||
X-Sort: -created_at,+name
|
||||
```
|
||||
|
||||
Prefix with `-` for descending, `+` for ascending.
|
||||
|
||||
## Response Formats (RestHeadSpec)
|
||||
|
||||
### Simple Format (default)
|
||||
```http
|
||||
X-DetailApi: false
|
||||
```
|
||||
Returns: `[{...}, {...}]`
|
||||
|
||||
### Detailed Format
|
||||
```http
|
||||
X-DetailApi: true
|
||||
```
|
||||
Returns:
|
||||
```json
|
||||
{
|
||||
"data": [{...}, {...}],
|
||||
"meta": {
|
||||
"total": 100,
|
||||
"limit": 50,
|
||||
"offset": 0,
|
||||
"cursor": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CORS Configuration
|
||||
|
||||
```go
|
||||
corsConfig := &common.CORSConfig{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
ExposedHeaders: []string{"X-Total-Count", "X-Cursor"},
|
||||
}
|
||||
|
||||
restheadspec.SetupMuxRoutes(router, handler, corsConfig)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
ResolveSpec returns standard HTTP error codes:
|
||||
- 200: Success
|
||||
- 400: Bad Request
|
||||
- 401: Unauthorized
|
||||
- 404: Not Found
|
||||
- 500: Internal Server Error
|
||||
|
||||
Error response format:
|
||||
```json
|
||||
{
|
||||
"error": "error message",
|
||||
"details": "additional context"
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Register models before routes**: Always register all models before calling SetupMuxRoutes
|
||||
2. **Use lifecycle hooks**: Implement authentication and validation in hooks
|
||||
3. **Schema naming**: Use `schema.table` format consistently
|
||||
4. **Transactions**: Use database transactions for multi-record operations
|
||||
5. **Validation**: Validate input in OnBeforeCreate/OnBeforeUpdate hooks
|
||||
6. **Audit logging**: Use OnAfter* hooks for audit trails
|
||||
7. **Performance**: Use preloading instead of N+1 queries
|
||||
8. **Security**: Implement row-level security in hooks
|
||||
9. **Rate limiting**: Add rate limiting middleware
|
||||
10. **Monitoring**: Log all operations for monitoring
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### User Filtering (Multi-tenancy)
|
||||
```go
|
||||
handler.OnBeforeRead(func(ctx context.Context, req *restheadspec.Request) error {
|
||||
userID := ctx.Value("user_id").(string)
|
||||
|
||||
// Add user_id filter
|
||||
req.Options.Filters = append(req.Options.Filters, Filter{
|
||||
Column: "user_id",
|
||||
Operator: "eq",
|
||||
Value: userID,
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Soft Deletes
|
||||
```go
|
||||
handler.OnBeforeDelete(func(ctx context.Context, req *restheadspec.Request) error {
|
||||
// Convert to update with deleted_at
|
||||
req.Operation = "update"
|
||||
req.Data = map[string]interface{}{
|
||||
"deleted_at": time.Now(),
|
||||
}
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Validation
|
||||
```go
|
||||
handler.OnBeforeCreate(func(ctx context.Context, req *restheadspec.Request) error {
|
||||
user := req.Data.(*User)
|
||||
|
||||
if user.Email == "" {
|
||||
return fmt.Errorf("email is required")
|
||||
}
|
||||
|
||||
if !isValidEmail(user.Email) {
|
||||
return fmt.Errorf("invalid email format")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- Official Docs: https://github.com/bitechdev/ResolveSpec
|
||||
- ResolveSpec README: /pkg/resolvespec/README.md
|
||||
- RestHeadSpec README: /pkg/restheadspec/README.md
|
||||
Reference in New Issue
Block a user