Fix logging format string bugs and add missing contributor guides
Replace three instances of leftover %T format verbs in logger.ErrorCtx() calls with proper structured logging fields. Add alphabetically-sorted reference documentation for six new contrib/claude/ guides and reorder the existing list. Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
15
AGENTS.md
15
AGENTS.md
@@ -26,16 +26,21 @@ GraphQL and MCP codegen is triggered by `go generate`:
|
||||
## Reference Documentation
|
||||
|
||||
Detailed guides for specific subsystems live in `contrib/claude/`:
|
||||
- [`contrib/claude/relay.md`](contrib/claude/relay.md) — Frontend Relay client (queries, fragments, mutations, pagination)
|
||||
- [`contrib/claude/graphql.md`](contrib/claude/graphql.md) — Go GraphQL backend (gqlgen, @goModel, connection types, cursor pagination)
|
||||
- [`contrib/claude/authorization.md`](contrib/claude/authorization.md) — IAM policy-based authorization (policies, conditions, roles, AuthorizationAttributer)
|
||||
- [`contrib/claude/cli.md`](contrib/claude/cli.md) — CLI command patterns (cobra, huh prompts, pagination, output formatting)
|
||||
- [`contrib/claude/commit.md`](contrib/claude/commit.md) — Commit message conventions
|
||||
- [`contrib/claude/license.md`](contrib/claude/license.md) — ISC license header (all file types)
|
||||
- [`contrib/claude/coredata.md`](contrib/claude/coredata.md) — Data access layer (Scoper, SQL patterns, filters, order fields, migrations)
|
||||
- [`contrib/claude/e2e.md`](contrib/claude/e2e.md) — End-to-end testing (factory builders, RBAC tests, tenant isolation, assertions)
|
||||
- [`contrib/claude/go-service.md`](contrib/claude/go-service.md) — Go service orchestration (Run, graceful shutdown, crash propagation)
|
||||
- [`contrib/claude/go-testing.md`](contrib/claude/go-testing.md) — Go test conventions (parallel, require vs assert, naming)
|
||||
- [`contrib/claude/go-worker.md`](contrib/claude/go-worker.md) — Go worker pattern (poll-based, bounded concurrency, FOR UPDATE SKIP LOCKED)
|
||||
- [`contrib/claude/go-service.md`](contrib/claude/go-service.md) — Go service orchestration (Run, graceful shutdown, crash propagation)
|
||||
- [`contrib/claude/sandbox.md`](contrib/claude/sandbox.md) — Lima sandbox environments (create, manage, access services)
|
||||
- [`contrib/claude/graphql.md`](contrib/claude/graphql.md) — Go GraphQL backend (gqlgen, @goModel, connection types, cursor pagination)
|
||||
- [`contrib/claude/license.md`](contrib/claude/license.md) — ISC license header (all file types)
|
||||
- [`contrib/claude/mcp.md`](contrib/claude/mcp.md) — MCP API patterns (specification.yaml, mcpgen, resolvers, type helpers)
|
||||
- [`contrib/claude/relay.md`](contrib/claude/relay.md) — Frontend Relay client (queries, fragments, mutations, pagination)
|
||||
- [`contrib/claude/release.md`](contrib/claude/release.md) — Release process (version bump, changelog, tag, push)
|
||||
- [`contrib/claude/sandbox.md`](contrib/claude/sandbox.md) — Lima sandbox environments (create, manage, access services)
|
||||
- [`contrib/claude/validation.md`](contrib/claude/validation.md) — Validation framework (fluent API, validators, error codes, propagation)
|
||||
|
||||
## API Surface Rules
|
||||
|
||||
|
||||
167
contrib/claude/authorization.md
Normal file
167
contrib/claude/authorization.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Authorization — IAM & Policy
|
||||
|
||||
Policy-based authorization in `pkg/iam/` using an evaluation model similar to AWS IAM. Explicit deny > explicit allow > implicit deny.
|
||||
|
||||
## Core concepts
|
||||
|
||||
**Policy** — a named collection of statements:
|
||||
```go
|
||||
policy.NewPolicy("vendor-crud", "Vendor CRUD",
|
||||
policy.Allow(ActionVendorGet, ActionVendorList).WithSID("read-vendors"),
|
||||
policy.Deny(ActionVendorDelete).WithSID("deny-vendor-delete"),
|
||||
).WithDescription("Standard vendor access")
|
||||
```
|
||||
|
||||
**Statement** — a single permission rule with effect (allow/deny), actions, optional resources, and optional conditions.
|
||||
|
||||
**Action format** — `SERVICE:RESOURCE:OPERATION` with wildcard support:
|
||||
```
|
||||
core:vendor:create # specific action
|
||||
core:vendor:* # all vendor actions
|
||||
core:* # all core actions
|
||||
* # everything
|
||||
```
|
||||
|
||||
## Policy evaluation
|
||||
|
||||
The evaluator processes all statements against a request:
|
||||
|
||||
1. If any statement explicitly denies → `DecisionDeny`
|
||||
2. If any statement explicitly allows → `DecisionAllow`
|
||||
3. No match → `DecisionNoMatch` (implicit deny)
|
||||
|
||||
## Authorizer flow
|
||||
|
||||
`Authorizer` is the main orchestrator in `pkg/iam/authorizer.go`:
|
||||
|
||||
```go
|
||||
err := iamService.Authorizer.Authorize(ctx, iam.AuthorizeParams{
|
||||
Principal: identityID, // who
|
||||
Resource: vendorID, // what
|
||||
Action: probo.ActionVendorGet, // which action
|
||||
ResourceAttributes: map[string]string{}, // optional extra attributes
|
||||
})
|
||||
```
|
||||
|
||||
The flow:
|
||||
1. Load organization membership for the resource's organization
|
||||
2. Load principal attributes (identity + membership role)
|
||||
3. Load resource attributes via `AuthorizationAttributes()` on the entity
|
||||
4. Build policies: identity-scoped + role-specific
|
||||
5. Evaluate all policies
|
||||
6. Return `ErrInsufficientPermissions` if no allow match
|
||||
|
||||
## PolicySet
|
||||
|
||||
Policies are organized into identity-scoped (applied to all authenticated users) and role-based:
|
||||
|
||||
```go
|
||||
ps := iam.NewPolicySet().
|
||||
AddRolePolicy("OWNER", OwnerPolicy).
|
||||
AddRolePolicy("ADMIN", AdminPolicy).
|
||||
AddRolePolicy("VIEWER", ViewerPolicy).
|
||||
AddIdentityScopedPolicy(SelfManagePolicy)
|
||||
```
|
||||
|
||||
Register during service initialization:
|
||||
```go
|
||||
iamService.Authorizer.RegisterPolicySet(ProboPolicySet())
|
||||
```
|
||||
|
||||
## Conditions (attribute-based access control)
|
||||
|
||||
Conditions constrain when a statement applies. All conditions must be satisfied.
|
||||
|
||||
```go
|
||||
// Users can only access resources in their organization
|
||||
organizationCondition := policy.Equals("principal.organization_id", "resource.organization_id")
|
||||
|
||||
policy.Allow(ActionVendorGet).
|
||||
WithSID("view-vendor").
|
||||
When(organizationCondition)
|
||||
```
|
||||
|
||||
| Operator | Purpose |
|
||||
|----------|---------|
|
||||
| `policy.Equals(key, value)` | Key equals value |
|
||||
| `policy.NotEquals(key, value)` | Key does not equal value |
|
||||
| `policy.In(key, value)` | Key in list (supports comma-separated DB fields) |
|
||||
| `policy.NotIn(key, value)` | Key not in list |
|
||||
|
||||
Key paths use `principal.ATTR` or `resource.ATTR` (e.g., `principal.organization_id`, `resource.source`).
|
||||
|
||||
## AuthorizationAttributer interface
|
||||
|
||||
Resources that support authorization must implement this interface in `pkg/coredata/`:
|
||||
|
||||
```go
|
||||
func (v *Vendor) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
||||
q := `SELECT organization_id FROM vendors WHERE id = $1 LIMIT 1;`
|
||||
var organizationID gid.GID
|
||||
if err := conn.QueryRow(ctx, q, v.ID).Scan(&organizationID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("cannot query vendor authorization attributes: %w", err)
|
||||
}
|
||||
return map[string]string{"organization_id": organizationID.String()}, nil
|
||||
}
|
||||
```
|
||||
|
||||
The returned map provides attributes for condition evaluation (e.g., `resource.organization_id`).
|
||||
|
||||
## Error types
|
||||
|
||||
```go
|
||||
var (
|
||||
ErrInsufficientPermissions // access denied
|
||||
ErrAssumptionRequired // session assumption needed
|
||||
ErrUnsupportedPrincipalType // principal is not an Identity
|
||||
)
|
||||
```
|
||||
|
||||
## Integration in resolvers
|
||||
|
||||
**GraphQL resolvers** use `AuthorizeFunc` from `pkg/server/api/authz/`:
|
||||
```go
|
||||
if err := authorize(ctx, vendorID, probo.ActionVendorGet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
```
|
||||
|
||||
**MCP resolvers** use `MustAuthorize` which panics (caught by middleware):
|
||||
```go
|
||||
r.MustAuthorize(ctx, input.ID, probo.ActionVendorGet)
|
||||
```
|
||||
|
||||
## Action constants
|
||||
|
||||
IAM actions live in `pkg/iam/iam_actions.go`, probo actions in `pkg/probo/actions.go`. Follow the naming pattern:
|
||||
|
||||
```go
|
||||
const (
|
||||
ActionVendorGet = "core:vendor:get"
|
||||
ActionVendorList = "core:vendor:list"
|
||||
ActionVendorCreate = "core:vendor:create"
|
||||
ActionVendorUpdate = "core:vendor:update"
|
||||
ActionVendorDelete = "core:vendor:delete"
|
||||
)
|
||||
```
|
||||
|
||||
## Built-in role policies
|
||||
|
||||
| Role | Access level |
|
||||
|------|-------------|
|
||||
| `OWNER` | Full access to all features including org management |
|
||||
| `ADMIN` | Full access to core features, restricted org management |
|
||||
| `VIEWER` | Read-only access to most entities |
|
||||
| `AUDITOR` | Read-only, excludes internal/employee content |
|
||||
| `EMPLOYEE` | Can sign documents and view internal content |
|
||||
|
||||
## Key patterns
|
||||
|
||||
- **Always use `organization_id` condition** — most policies scope access to the principal's organization
|
||||
- **SID every statement** — `.WithSID("description")` for debugging
|
||||
- **Explicit denies for restrictions** — even if allow would match, deny takes precedence
|
||||
- **Identity-scoped for self-management** — cross-org permissions like managing own profile
|
||||
- **Role-based for org features** — CRUD operations on domain entities
|
||||
231
contrib/claude/cli.md
Normal file
231
contrib/claude/cli.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# CLI Command Patterns
|
||||
|
||||
CLI commands use [cobra](https://github.com/spf13/cobra) with `pkg/cmd/cmdutil.Factory` for shared dependencies. Each resource gets a group command with verb subcommands (`list`, `create`, `view`, `update`, `delete`).
|
||||
|
||||
## Directory structure
|
||||
|
||||
```
|
||||
pkg/cmd/<resource>/<resource>.go # Group command, wires verbs
|
||||
pkg/cmd/<resource>/list/list.go # List verb
|
||||
pkg/cmd/<resource>/create/create.go # Create verb
|
||||
pkg/cmd/<resource>/view/view.go # View verb
|
||||
pkg/cmd/<resource>/update/update.go # Update verb
|
||||
pkg/cmd/<resource>/delete/delete.go # Delete verb
|
||||
pkg/cmd/cmdutil/ # Factory, flags, output helpers
|
||||
pkg/cmd/iostreams/ # Terminal I/O abstraction
|
||||
pkg/cli/api/ # GraphQL client, pagination
|
||||
```
|
||||
|
||||
Register group commands in `pkg/cmd/root/root.go` with `cmd.AddCommand()`.
|
||||
|
||||
## Leaf command pattern
|
||||
|
||||
Every leaf command follows this structure:
|
||||
|
||||
```go
|
||||
package list
|
||||
|
||||
const listQuery = `query($id: ID!, $first: Int, $after: CursorKey) { ... }`
|
||||
|
||||
type listResponse struct { ... } // unexported, shaped to match GraphQL response
|
||||
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
flagOrg string
|
||||
flagLimit int
|
||||
flagOutput *string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List resources",
|
||||
Aliases: []string{"ls"},
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// 1. Validate output flag
|
||||
// 2. Load config, get host + token
|
||||
// 3. Create api.Client
|
||||
// 4. Resolve --org (flag or config default)
|
||||
// 5. Call API
|
||||
// 6. Output results
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
|
||||
cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of items")
|
||||
flagOutput = cmdutil.AddOutputFlag(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
```
|
||||
|
||||
## Pagination with `api.Paginate[T]`
|
||||
|
||||
```go
|
||||
resources, totalCount, err := api.Paginate(
|
||||
client,
|
||||
listQuery,
|
||||
variables,
|
||||
flagLimit,
|
||||
func(data json.RawMessage) (*api.Connection[resource], error) {
|
||||
var resp struct {
|
||||
Node *struct {
|
||||
Typename string `json:"__typename"`
|
||||
Resources api.Connection[resource] `json:"resources"`
|
||||
} `json:"node"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Node == nil {
|
||||
return nil, fmt.Errorf("organization %s not found", flagOrg)
|
||||
}
|
||||
return &resp.Node.Resources, nil
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## Output formatting
|
||||
|
||||
**JSON output:**
|
||||
```go
|
||||
if *flagOutput == cmdutil.OutputJSON {
|
||||
return cmdutil.PrintJSON(f.IOStreams.Out, resources)
|
||||
}
|
||||
```
|
||||
|
||||
**Table output:**
|
||||
```go
|
||||
rows := make([][]string, 0, len(resources))
|
||||
for _, r := range resources {
|
||||
rows = append(rows, []string{r.ID, r.Name})
|
||||
}
|
||||
t := cmdutil.NewTable("ID", "NAME").Rows(rows...)
|
||||
_, _ = fmt.Fprintln(f.IOStreams.Out, t)
|
||||
```
|
||||
|
||||
**View detail output with lipgloss:**
|
||||
```go
|
||||
bold := lipgloss.NewStyle().Bold(true)
|
||||
label := lipgloss.NewStyle().Foreground(lipgloss.Color("242")).Width(22)
|
||||
|
||||
_, _ = fmt.Fprintf(out, "%s\n\n", bold.Render(r.Name))
|
||||
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), r.ID)
|
||||
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(r.CreatedAt))
|
||||
```
|
||||
|
||||
**Truncation message** (stderr, not stdout):
|
||||
```go
|
||||
if totalCount > len(resources) {
|
||||
_, _ = fmt.Fprintf(f.IOStreams.ErrOut, "\nShowing %d of %d resources\n", len(resources), totalCount)
|
||||
}
|
||||
```
|
||||
|
||||
## Interactive prompts with `charmbracelet/huh`
|
||||
|
||||
Gate all prompts behind interactivity check, then validate:
|
||||
|
||||
```go
|
||||
if f.IOStreams.IsInteractive() {
|
||||
if flagName == "" {
|
||||
err := huh.NewInput().Title("Resource name").Value(&flagName).Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if flagCategory == "" {
|
||||
err := huh.NewSelect[string]().
|
||||
Title("Category").
|
||||
Options(
|
||||
huh.NewOption("Cloud Provider", "CLOUD_PROVIDER"),
|
||||
huh.NewOption("SaaS", "SAAS"),
|
||||
).
|
||||
Value(&flagCategory).Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if flagName == "" {
|
||||
return fmt.Errorf("name is required; pass --name or run interactively")
|
||||
}
|
||||
```
|
||||
|
||||
Available prompt types: `huh.NewInput()` (text), `huh.NewText()` (multiline), `huh.NewSelect[T]()` (dropdown), `huh.NewConfirm()` (yes/no).
|
||||
|
||||
## Update commands
|
||||
|
||||
Only include fields that were explicitly changed:
|
||||
|
||||
```go
|
||||
input := map[string]any{"id": args[0]}
|
||||
|
||||
if cmd.Flags().Changed("name") {
|
||||
input["name"] = flagName
|
||||
}
|
||||
if cmd.Flags().Changed("description") {
|
||||
input["description"] = flagDescription
|
||||
}
|
||||
|
||||
if len(input) == 1 {
|
||||
return fmt.Errorf("at least one field must be specified for update")
|
||||
}
|
||||
```
|
||||
|
||||
## Delete commands
|
||||
|
||||
Require confirmation via `--yes` flag or interactive prompt:
|
||||
|
||||
```go
|
||||
if !flagYes {
|
||||
if !f.IOStreams.IsInteractive() {
|
||||
return fmt.Errorf("cannot delete resource: confirmation required, use --yes to confirm")
|
||||
}
|
||||
var confirmed bool
|
||||
err := huh.NewConfirm().Title(fmt.Sprintf("Delete %s?", args[0])).Value(&confirmed).Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !confirmed {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Organization resolution
|
||||
|
||||
```go
|
||||
if flagOrg == "" {
|
||||
flagOrg = hc.Organization
|
||||
}
|
||||
if flagOrg == "" {
|
||||
return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'")
|
||||
}
|
||||
```
|
||||
|
||||
## Flag conventions
|
||||
|
||||
- Use kebab-case: `--order-by`, `--inherent-likelihood`
|
||||
- Short flags for common options: `-L` (limit), `-o` (output), `-q` (query), `-y` (yes)
|
||||
- `StringVar`/`IntVar`/`BoolVar` for flags, positional args only for IDs in view/update/delete
|
||||
- Use `cmd.MarkFlagRequired()` for mandatory flags
|
||||
|
||||
## IOStreams
|
||||
|
||||
```go
|
||||
f.IOStreams.Out // stdout — primary output
|
||||
f.IOStreams.ErrOut // stderr — status messages, truncation info
|
||||
f.IOStreams.IsInteractive() // true if TTY and not forced non-interactive
|
||||
```
|
||||
|
||||
Environment variables: `PROBO_NO_INTERACTIVE=1`, `CI=true`, `TERM=dumb` (non-interactive), `NO_COLOR` (disable color).
|
||||
|
||||
## New resource command checklist
|
||||
|
||||
1. **Group command** — `pkg/cmd/<resource>/<resource>.go` with `NewCmd<Resource>(f)`, wiring all verb subcommands
|
||||
2. **Leaf commands** — one file per verb in `pkg/cmd/<resource>/<verb>/<verb>.go`
|
||||
3. **Each leaf file** — ISC license header, GraphQL const, unexported response struct, `NewCmd<Verb>(f)` function
|
||||
4. **Register in root** — import and `cmd.AddCommand()` in `pkg/cmd/root/root.go`
|
||||
5. **API surface** — update GraphQL schema, MCP tools, and e2e tests
|
||||
169
contrib/claude/coredata.md
Normal file
169
contrib/claude/coredata.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Coredata — Data Access Layer
|
||||
|
||||
All raw SQL lives in `pkg/coredata` — never in service, handler, or resolver packages. One file per entity, with companion `*_filter.go` and `*_order_field.go` files when needed.
|
||||
|
||||
## Entity struct pattern
|
||||
|
||||
Every entity uses `gid.GID` for its ID, `db` tags for pgx mapping, and `CreatedAt`/`UpdatedAt` timestamps. The `tenant_id` column exists in the database but is **never** stored on the Go struct — it is injected at query time via `Scoper`.
|
||||
|
||||
```go
|
||||
type (
|
||||
Asset struct {
|
||||
ID gid.GID `db:"id"`
|
||||
SnapshotID *gid.GID `db:"snapshot_id"`
|
||||
Name string `db:"name"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
AssetType AssetType `db:"asset_type"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
Assets []*Asset
|
||||
)
|
||||
```
|
||||
|
||||
Use pointer types (`*T`) for nullable database columns.
|
||||
|
||||
## Scoper interface
|
||||
|
||||
`Scoper` provides tenant isolation. Two implementations:
|
||||
|
||||
| Type | Constructor | `SQLFragment()` | `GetTenantID()` | Use case |
|
||||
|------|-------------|-----------------|-----------------|----------|
|
||||
| `Scope` | `NewScope(tenantID)` or `NewScopeFromObjectID(gid)` | `"tenant_id = @tenant_id"` | Returns tenant ID | Multi-tenant queries (default) |
|
||||
| `NoScope` | `NewNoScope()` | `"TRUE"` | **Panics** — never call | Cross-tenant / administrative queries |
|
||||
|
||||
Always inject `tenant_id` at INSERT time using `scope.GetTenantID()`, never from the struct.
|
||||
|
||||
## SQL query composition
|
||||
|
||||
All queries use `fmt.Sprintf` to inject scope/filter/cursor fragments, then `pgx.StrictNamedArgs` for parameters. Merge args with `maps.Copy`.
|
||||
|
||||
```go
|
||||
q := `
|
||||
SELECT id, name, created_at, updated_at
|
||||
FROM assets
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND %s
|
||||
AND %s
|
||||
LIMIT %d;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment(), cursor.Limit())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, filter.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
```
|
||||
|
||||
**All SQL must be static** after `fmt.Sprintf()` injection — no conditional string building. Use `CASE WHEN` in SQL for optional filter logic.
|
||||
|
||||
## Standard method signatures
|
||||
|
||||
| Method | Receiver | Returns | Purpose |
|
||||
|--------|----------|---------|---------|
|
||||
| `LoadByID(ctx, conn, scope, id)` | `*Entity` | `error` | Single entity by ID |
|
||||
| `LoadBy*(ctx, conn, scope, key)` | `*Entity` | `error` | Single entity by unique key |
|
||||
| `LoadAllBy*(ctx, conn, scope, parentID, cursor, filter)` | `*Entities` | `error` | Paginated list |
|
||||
| `CountBy*(ctx, conn, scope, parentID, filter)` | `*Entities` | `(int, error)` | Count matching rows |
|
||||
| `Insert(ctx, conn, scope)` | `*Entity` | `error` | Insert, uses `scope.GetTenantID()` |
|
||||
| `Update(ctx, conn, scope)` | `*Entity` | `error` | Update with `RETURNING` |
|
||||
| `Delete(ctx, conn, scope)` | `*Entity` | `error` | Delete entity |
|
||||
| `CursorKey(orderField)` | `*Entity` | `page.CursorKey` | Cursor for pagination |
|
||||
| `AuthorizationAttributes(ctx, conn)` | `*Entity` | `(map[string]string, error)` | Attributes for IAM policy evaluation |
|
||||
|
||||
## Row collection
|
||||
|
||||
```go
|
||||
// Single row
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
asset, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Asset])
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
*a = asset
|
||||
|
||||
// Multiple rows
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
assets, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Asset])
|
||||
*a = assets
|
||||
```
|
||||
|
||||
## Sentinel errors
|
||||
|
||||
```go
|
||||
var (
|
||||
ErrResourceNotFound = errors.New("resource not found")
|
||||
ErrResourceAlreadyExists = errors.New("resource already exists")
|
||||
ErrResourceInUse = errors.New("resource is in use")
|
||||
)
|
||||
```
|
||||
|
||||
Map `pgx.ErrNoRows` to `ErrResourceNotFound`. Check unique constraint violations for `ErrResourceAlreadyExists`, foreign key violations for `ErrResourceInUse`.
|
||||
|
||||
## Filters
|
||||
|
||||
Filters implement `SQLFragment() string` and `SQLArguments() pgx.NamedArgs`. Use double pointers for three-state filtering: `nil` = no filter, `*nil` = IS NULL, `*val` = equals.
|
||||
|
||||
```go
|
||||
type AssetFilter struct {
|
||||
snapshotID **gid.GID
|
||||
}
|
||||
|
||||
func NewAssetFilter(snapshotID **gid.GID) *AssetFilter {
|
||||
return &AssetFilter{snapshotID: snapshotID}
|
||||
}
|
||||
|
||||
func (f *AssetFilter) SQLFragment() string {
|
||||
if f.snapshotID == nil {
|
||||
return "TRUE"
|
||||
}
|
||||
if *f.snapshotID == nil {
|
||||
return "snapshot_id IS NULL"
|
||||
}
|
||||
return "snapshot_id = @filter_snapshot_id"
|
||||
}
|
||||
|
||||
func (f *AssetFilter) SQLArguments() pgx.NamedArgs {
|
||||
if f.snapshotID == nil || *f.snapshotID == nil {
|
||||
return pgx.NamedArgs{}
|
||||
}
|
||||
return pgx.NamedArgs{"filter_snapshot_id": **f.snapshotID}
|
||||
}
|
||||
```
|
||||
|
||||
For complex multi-field filters, use `CASE WHEN` in SQL and always declare all argument keys in every code path (use `nil` for inactive ones).
|
||||
|
||||
## Order fields
|
||||
|
||||
String-based enums with `Column()`, `IsValid()`, `String()`, and `MarshalText`/`UnmarshalText`:
|
||||
|
||||
```go
|
||||
type AssetOrderField string
|
||||
|
||||
const (
|
||||
AssetOrderFieldCreatedAt AssetOrderField = "CREATED_AT"
|
||||
AssetOrderFieldName AssetOrderField = "NAME"
|
||||
)
|
||||
```
|
||||
|
||||
Each entity implements `CursorKey(field)` returning `page.NewCursorKey(entity.ID, sortValue)`, with a `panic` on unknown fields.
|
||||
|
||||
## Entity type registry
|
||||
|
||||
Each entity gets a unique `uint16` constant in `entity_type_reg.go`. **Never reuse** removed type numbers — use `_` placeholder. Register new entities in the `NewEntityFromID` switch statement.
|
||||
|
||||
## Migrations
|
||||
|
||||
Files in `pkg/coredata/migrations/` use timestamp naming: `YYYYMMDDTHHMMSSZ.sql` (UTC). One logical change per file. Always create indexes for frequently queried columns.
|
||||
|
||||
## New entity checklist
|
||||
|
||||
1. **Entity file** (`entity.go`) — struct with `db` tags, slice type alias, `LoadByID`, `Insert`, `Update`, `Delete`, `CursorKey`, `AuthorizationAttributes`
|
||||
2. **Filter file** (`entity_filter.go`) — filter struct, `NewEntityFilter`, `SQLFragment`, `SQLArguments`
|
||||
3. **Order field file** (`entity_order_field.go`) — order field type, constants, `Column`, `IsValid`, marshaling
|
||||
4. **Entity type constant** — add to `entity_type_reg.go` and `NewEntityFromID`
|
||||
5. **Migration** — `YYYYMMDDTHHMMSSZ.sql` with CREATE TABLE
|
||||
237
contrib/claude/e2e.md
Normal file
237
contrib/claude/e2e.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# End-to-End Testing
|
||||
|
||||
E2E tests live in `e2e/console/` (package `console_test`) and run against a live `bin/probod` instance. The test infrastructure handles server lifecycle, authentication, and test data creation.
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
SKIP_APPS=1 make build # Build the binary (backend only)
|
||||
make test-e2e # Run all e2e tests
|
||||
```
|
||||
|
||||
## Client setup
|
||||
|
||||
**Standalone user (new organization):**
|
||||
```go
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
```
|
||||
|
||||
**User in existing organization:**
|
||||
```go
|
||||
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
||||
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||
```
|
||||
|
||||
Each call creates a unique identity with a fresh email. Available roles: `RoleOwner`, `RoleAdmin`, `RoleViewer`.
|
||||
|
||||
## Client methods
|
||||
|
||||
| Method | API | Purpose |
|
||||
|--------|-----|---------|
|
||||
| `c.Execute(query, vars, &result)` | Console | Execute and unmarshal into result |
|
||||
| `c.MustExecute(query, vars, &result)` | Console | Execute, fail test on error |
|
||||
| `c.ExecuteShouldFail(query, vars)` | Console | Expect error, fail if succeeds |
|
||||
| `c.Do(query, vars)` | Console | Low-level, returns raw response |
|
||||
| `c.ExecuteConnect(query, vars, &result)` | Connect | For auth operations |
|
||||
| `c.ExecuteWithFile(query, vars, path, file, &result)` | Console | Single file upload |
|
||||
| `c.GetOrganizationID()` | — | Current org GID |
|
||||
| `c.GetUserID()` | — | Current user GID |
|
||||
|
||||
## Test data factories
|
||||
|
||||
Two patterns in `e2e/internal/factory/`:
|
||||
|
||||
**Builder pattern (preferred):**
|
||||
```go
|
||||
vendorID := factory.NewVendor(owner).
|
||||
WithName("Stripe").
|
||||
WithCategory("CLOUD_PROVIDER").
|
||||
Create()
|
||||
|
||||
frameworkID := factory.NewFramework(owner).
|
||||
WithName("SOC 2").
|
||||
Create()
|
||||
|
||||
controlID := factory.NewControl(owner, frameworkID).
|
||||
WithName("Access Control").
|
||||
Create()
|
||||
```
|
||||
|
||||
**Simple factory functions:**
|
||||
```go
|
||||
vendorID := factory.CreateVendor(c, factory.Attrs{"name": "Acme"})
|
||||
taskID := factory.CreateTask(c, &measureID, factory.Attrs{"name": "Task 1"})
|
||||
```
|
||||
|
||||
Use `factory.SafeName("prefix")` for unique names and `factory.SafeEmail()` for unique emails.
|
||||
|
||||
## Test structure
|
||||
|
||||
Every test and subtest **must** call `t.Parallel()`. One test file per entity in `e2e/console/`. Function naming: `TestEntity_Operation`.
|
||||
|
||||
```go
|
||||
func TestVendor_Create(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
t.Run("with required fields", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const query = `
|
||||
mutation CreateVendor($input: CreateVendorInput!) {
|
||||
createVendor(input: $input) {
|
||||
vendorEdge {
|
||||
node { id name }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var result struct {
|
||||
CreateVendor struct {
|
||||
VendorEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorEdge"`
|
||||
} `json:"createVendor"`
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": owner.GetOrganizationID().String(),
|
||||
"name": factory.SafeName("Vendor"),
|
||||
},
|
||||
}, &result)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, result.CreateVendor.VendorEdge.Node.ID)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Authorization (RBAC) testing
|
||||
|
||||
Test each role's access to each operation:
|
||||
|
||||
```go
|
||||
t.Run("viewer cannot create", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||
|
||||
_, err := viewer.Do(createQuery, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": viewer.GetOrganizationID().String(),
|
||||
"name": "Test",
|
||||
},
|
||||
})
|
||||
testutil.RequireForbiddenError(t, err, "viewer cannot create")
|
||||
})
|
||||
```
|
||||
|
||||
## Tenant isolation testing
|
||||
|
||||
```go
|
||||
t.Run("other org cannot access", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner1 := testutil.NewClient(t, testutil.RoleOwner)
|
||||
owner2 := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
vendorID := factory.NewVendor(owner1).WithName("Vendor").Create()
|
||||
|
||||
var result struct {
|
||||
Node *struct{ ID string } `json:"node"`
|
||||
}
|
||||
err := owner2.Execute(nodeQuery, map[string]any{"id": vendorID}, &result)
|
||||
testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "Vendor")
|
||||
})
|
||||
```
|
||||
|
||||
## Assertion helpers
|
||||
|
||||
**Pagination:**
|
||||
```go
|
||||
testutil.AssertFirstPage(t, edgeCount, pageInfo, expectedCount, expectMore)
|
||||
testutil.AssertMiddlePage(t, edgeCount, pageInfo, expectedCount)
|
||||
testutil.AssertLastPage(t, edgeCount, pageInfo, expectedCount, expectPrevious)
|
||||
```
|
||||
|
||||
**Timestamps:**
|
||||
```go
|
||||
testutil.AssertTimestampsOnCreate(t, createdAt, updatedAt, beforeCreate)
|
||||
testutil.AssertTimestampsOnUpdate(t, createdAt, updatedAt, origCreatedAt, origUpdatedAt)
|
||||
```
|
||||
|
||||
**Ordering:**
|
||||
```go
|
||||
testutil.AssertOrderedAscending[T](t, values, "fieldName")
|
||||
testutil.AssertOrderedDescending[T](t, values, "fieldName")
|
||||
testutil.AssertTimesOrderedDescending(t, times, "createdAt")
|
||||
```
|
||||
|
||||
**Authorization:**
|
||||
```go
|
||||
testutil.RequireForbiddenError(t, err, "message")
|
||||
testutil.RequireErrorCode(t, err, "CODE_NAME", "message")
|
||||
```
|
||||
|
||||
**Optional fields:**
|
||||
```go
|
||||
testutil.AssertOptionalStringEqual(t, expected, actual, "fieldName")
|
||||
```
|
||||
|
||||
## Validation testing
|
||||
|
||||
Use table-driven tests for validation scenarios:
|
||||
|
||||
```go
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]any
|
||||
wantErrorContains string
|
||||
}{
|
||||
{name: "missing name", input: map[string]any{}, wantErrorContains: "name"},
|
||||
{name: "HTML injection", input: map[string]any{"name": "<script>xss</script>"}, wantErrorContains: "HTML"},
|
||||
{name: "control char", input: map[string]any{"name": "Test\x00"}, wantErrorContains: "control"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input := map[string]any{"organizationId": owner.GetOrganizationID().String()}
|
||||
maps.Copy(input, tt.input)
|
||||
|
||||
_, err := owner.Do(query, map[string]any{"input": input})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErrorContains)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## File uploads
|
||||
|
||||
```go
|
||||
err := owner.ExecuteWithFile(
|
||||
uploadQuery,
|
||||
map[string]any{"input": map[string]any{"vendorId": vendorID, "file": nil}},
|
||||
"input.file",
|
||||
testutil.UploadFile{
|
||||
Filename: "report.pdf",
|
||||
ContentType: "application/pdf",
|
||||
Content: pdfBytes,
|
||||
},
|
||||
&result,
|
||||
)
|
||||
```
|
||||
|
||||
## New entity e2e test checklist
|
||||
|
||||
1. **File** — `e2e/console/<entity>_test.go`, package `console_test`
|
||||
2. **CRUD** — create (required fields, all fields), update, delete, get by ID, list
|
||||
3. **Validation** — required fields, empty strings, HTML injection, control chars, max length, invalid enums
|
||||
4. **RBAC** — owner/admin/viewer access for create, update, delete, read
|
||||
5. **Tenant isolation** — cross-org user cannot access resource
|
||||
6. **Timestamps** — `createdAt == updatedAt` on create, `updatedAt` advances on update
|
||||
7. **Sub-resolvers** — parent references, child collections
|
||||
8. **Parallel** — `t.Parallel()` on every test and subtest
|
||||
194
contrib/claude/mcp.md
Normal file
194
contrib/claude/mcp.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# MCP API Patterns
|
||||
|
||||
MCP tools are defined in `pkg/server/api/mcp/v1/specification.yaml` and generated with `mcpgen`. The schema is hand-written; Go types, server registration, and resolver stubs are generated.
|
||||
|
||||
## File organization
|
||||
|
||||
**Hand-written** (edit these):
|
||||
- `specification.yaml` — tool definitions, input/output schemas, component schemas
|
||||
- `resolver.go` — `Resolver` struct, `MustAuthorize`, service accessors
|
||||
- `helpers.go` — pagination helpers, `UnwrapOmittable`
|
||||
- `types/*.go` (except `types/types.go`) — type conversion helpers (`NewVendor()`, `NewListVendorsOutput()`, etc.)
|
||||
- `schema.resolvers.go` — tool implementation bodies (stubs generated, you edit the bodies)
|
||||
|
||||
**Generated** (do not edit):
|
||||
- `server/server.go` — tool registration, `ResolverInterface`
|
||||
- `types/types.go` — type definitions and JSON schemas
|
||||
|
||||
After modifying `specification.yaml`, run:
|
||||
```bash
|
||||
go generate ./pkg/server/api/mcp/v1
|
||||
```
|
||||
|
||||
## Tool definition in specification.yaml
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
- name: listVendors
|
||||
description: List all vendors for the organization
|
||||
hints:
|
||||
readonly: true
|
||||
idempotent: true
|
||||
destructive: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/ListVendorsInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/ListVendorsOutput"
|
||||
```
|
||||
|
||||
Input/output schemas reference `components/schemas`. Map custom Go types with the `go.probo.inc/mcpgen/type` extension:
|
||||
|
||||
```yaml
|
||||
components:
|
||||
schemas:
|
||||
GID:
|
||||
type: string
|
||||
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/gid.GID
|
||||
```
|
||||
|
||||
## Resolver signature
|
||||
|
||||
Generated stubs follow this pattern:
|
||||
|
||||
```go
|
||||
func (r *Resolver) ListVendorsTool(
|
||||
ctx context.Context,
|
||||
req *mcp.CallToolRequest,
|
||||
input *types.ListVendorsInput,
|
||||
) (*mcp.CallToolResult, types.ListVendorsOutput, error)
|
||||
```
|
||||
|
||||
First return is always `nil`. Errors are either returned (for recoverable) or panicked (for authorization and unexpected failures).
|
||||
|
||||
## Authorization
|
||||
|
||||
Use `MustAuthorize` which panics on failure (caught by middleware):
|
||||
|
||||
```go
|
||||
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorList)
|
||||
```
|
||||
|
||||
## Common resolver patterns
|
||||
|
||||
**List with pagination:**
|
||||
```go
|
||||
func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListVendorsInput) (*mcp.CallToolResult, types.ListVendorsOutput, error) {
|
||||
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorList)
|
||||
|
||||
prb := r.ProboService(ctx, input.OrganizationID)
|
||||
|
||||
pageOrderBy := page.OrderBy[coredata.VendorOrderField]{
|
||||
Field: coredata.VendorOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
}
|
||||
if input.OrderBy != nil {
|
||||
pageOrderBy = page.OrderBy[coredata.VendorOrderField]{
|
||||
Field: input.OrderBy.Field,
|
||||
Direction: input.OrderBy.Direction,
|
||||
}
|
||||
}
|
||||
|
||||
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||
|
||||
page, err := prb.Vendors.ListForOrganizationID(ctx, input.OrganizationID, cursor, coredata.NewVendorFilter(nil, nil))
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list vendors: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.NewListVendorsOutput(page), nil
|
||||
}
|
||||
```
|
||||
|
||||
**Get single resource:**
|
||||
```go
|
||||
func (r *Resolver) GetRiskTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetRiskInput) (*mcp.CallToolResult, types.GetRiskOutput, error) {
|
||||
r.MustAuthorize(ctx, input.ID, probo.ActionRiskGet)
|
||||
prb := r.ProboService(ctx, input.ID)
|
||||
|
||||
risk, err := prb.Risks.Get(ctx, input.ID)
|
||||
if err != nil {
|
||||
return nil, types.GetRiskOutput{}, fmt.Errorf("failed to get risk: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.GetRiskOutput{Risk: types.NewRisk(risk)}, nil
|
||||
}
|
||||
```
|
||||
|
||||
**Create:**
|
||||
```go
|
||||
func (r *Resolver) AddRiskTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddRiskInput) (*mcp.CallToolResult, types.AddRiskOutput, error) {
|
||||
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionRiskCreate)
|
||||
svc := r.ProboService(ctx, input.OrganizationID)
|
||||
|
||||
risk, err := svc.Risks.Create(ctx, probo.CreateRiskRequest{
|
||||
OrganizationID: input.OrganizationID,
|
||||
Name: input.Name,
|
||||
Description: input.Description,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, types.AddRiskOutput{}, fmt.Errorf("failed to create risk: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.AddRiskOutput{Risk: types.NewRisk(risk)}, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Optional fields with Omittable
|
||||
|
||||
For nullable update fields, use `go.probo.inc/mcpgen/omittable: true` in the schema:
|
||||
|
||||
```yaml
|
||||
description:
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
go.probo.inc/mcpgen/omittable: true
|
||||
```
|
||||
|
||||
In resolvers, unwrap with `UnwrapOmittable`:
|
||||
|
||||
```go
|
||||
Description: UnwrapOmittable(input.Description),
|
||||
```
|
||||
|
||||
## Type conversion helpers
|
||||
|
||||
Live in `types/*.go` (not the generated `types/types.go`). One file per entity:
|
||||
|
||||
```go
|
||||
func NewVendor(v *coredata.Vendor) *Vendor {
|
||||
return &Vendor{
|
||||
ID: v.ID,
|
||||
OrganizationID: v.OrganizationID,
|
||||
Name: v.Name,
|
||||
CreatedAt: v.CreatedAt,
|
||||
UpdatedAt: v.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func NewListVendorsOutput(vendorPage *page.Page[*coredata.Vendor, coredata.VendorOrderField]) ListVendorsOutput {
|
||||
vendors := make([]*Vendor, 0, len(vendorPage.Data))
|
||||
for _, v := range vendorPage.Data {
|
||||
vendors = append(vendors, NewVendor(v))
|
||||
}
|
||||
|
||||
var nextCursor *page.CursorKey
|
||||
if len(vendorPage.Data) > 0 {
|
||||
cursorKey := vendorPage.Data[len(vendorPage.Data)-1].CursorKey(vendorPage.Cursor.OrderBy.Field)
|
||||
nextCursor = &cursorKey
|
||||
}
|
||||
|
||||
return ListVendorsOutput{
|
||||
NextCursor: nextCursor,
|
||||
Vendors: vendors,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Adding a new MCP tool — checklist
|
||||
|
||||
1. **Schema** — add input/output schemas and tool definition in `specification.yaml`
|
||||
2. **Codegen** — `go generate ./pkg/server/api/mcp/v1`
|
||||
3. **Resolver** — implement the tool body in `schema.resolvers.go` (authorize, call service, convert types)
|
||||
4. **Type helpers** — add `New<Entity>()` and `New<Output>()` in `types/<entity>.go`
|
||||
5. **Verify** — tool is automatically registered via generated `server/server.go`
|
||||
144
contrib/claude/validation.md
Normal file
144
contrib/claude/validation.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Validation Framework
|
||||
|
||||
Custom fluent validation API in `pkg/validator/`. Used in every service method to validate request structs before processing.
|
||||
|
||||
## Basic pattern
|
||||
|
||||
Create a validator, chain `Check()` calls for each field, then call `Error()` to get accumulated errors:
|
||||
|
||||
```go
|
||||
func (req *CreateVendorRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(req.OrganizationID, "organization_id",
|
||||
validator.Required(),
|
||||
validator.GID(coredata.OrganizationEntityType),
|
||||
)
|
||||
v.Check(req.Name, "name",
|
||||
validator.Required(),
|
||||
validator.SafeTextNoNewLine(TitleMaxLength),
|
||||
)
|
||||
v.Check(req.Category, "category",
|
||||
validator.OneOfSlice(coredata.VendorCategories()),
|
||||
)
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
```
|
||||
|
||||
`Check(value, fieldName, validators...)` runs validators sequentially on a value. Multiple `Check()` calls accumulate all errors. `Error()` returns `nil` if clean, or `ValidationErrors` (which implements `error`).
|
||||
|
||||
## Available validators
|
||||
|
||||
### Common
|
||||
- `Required()` — value must not be nil, empty string, or empty slice
|
||||
- `NotEmpty()` — value cannot be empty/nil (only checks content, not presence)
|
||||
|
||||
### String
|
||||
- `MinLen(n)` — at least n characters
|
||||
- `MaxLen(n)` — at most n characters
|
||||
- `OneOfSlice[T](allowed)` — value must be in allowed list
|
||||
|
||||
### Numeric
|
||||
- `Min(n)` — value >= n
|
||||
- `Max(n)` — value <= n
|
||||
|
||||
### Format
|
||||
- `URL()` — valid HTTP/HTTPS URL with host
|
||||
- `HTTPSUrl()` — HTTPS-only URL
|
||||
- `Domain()` — valid RFC-compliant domain name
|
||||
- `GID(entityTypes...)` — valid GID, optionally restricted to specific entity types
|
||||
|
||||
### Security
|
||||
- `NoHTML()` — rejects HTML tags
|
||||
- `PrintableText()` — rejects invisible/harmful Unicode (control chars, bidi overrides, zero-width)
|
||||
- `NoNewLine()` — rejects `\n` and `\r`
|
||||
- `SafeText(maxLen)` — combines NotEmpty + MaxLen + NoHTML + PrintableText (allows newlines)
|
||||
- `SafeTextNoNewLine(maxLen)` — same as SafeText but also rejects newlines (for single-line fields)
|
||||
|
||||
### Time
|
||||
- `After(refTime)` — time must be after reference
|
||||
- `Before(refTime)` — time must be before reference
|
||||
- `RangeDuration(min, max)` — duration between min and max inclusive
|
||||
|
||||
## Pointer handling
|
||||
|
||||
The framework automatically dereferences pointers at any level. Nil pointers pass all non-`Required` validators:
|
||||
|
||||
```go
|
||||
v.Check(stringValue, "field", validator.Required()) // Direct value
|
||||
v.Check(&stringValue, "field", validator.Required()) // Pointer — auto-dereferenced
|
||||
v.Check(nilPointer, "field", validator.MinLen(5)) // Nil passes (not Required)
|
||||
v.Check(nilPointer, "field", validator.Required()) // Nil fails Required
|
||||
```
|
||||
|
||||
## Collection validation
|
||||
|
||||
Use `CheckEach` to validate each item in a slice:
|
||||
|
||||
```go
|
||||
v.CheckEach(ids, "ids", func(index int, item any) {
|
||||
gidValue := item.(gid.GID)
|
||||
v.Check(gidValue, fmt.Sprintf("ids[%d]", index),
|
||||
validator.Required(),
|
||||
validator.GID(coredata.VendorEntityType),
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Nil or empty slices are silently skipped.
|
||||
|
||||
## Error types
|
||||
|
||||
```go
|
||||
type ValidationError struct {
|
||||
Field string // e.g. "email"
|
||||
Code ErrorCode // e.g. ErrorCodeRequired
|
||||
Message string // human-readable
|
||||
Value any // the problematic value
|
||||
}
|
||||
```
|
||||
|
||||
Error codes:
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `REQUIRED` | Field is missing or empty |
|
||||
| `INVALID_FORMAT` | Value does not match expected format |
|
||||
| `OUT_OF_RANGE` | Numeric value outside bounds |
|
||||
| `TOO_SHORT` | String below minimum length |
|
||||
| `TOO_LONG` | String above maximum length |
|
||||
| `INVALID_EMAIL` | Invalid email address |
|
||||
| `INVALID_URL` | Invalid URL |
|
||||
| `INVALID_ENUM` | Value not in allowed set |
|
||||
| `INVALID_GID` | Invalid GID or wrong entity type |
|
||||
| `UNSAFE_CONTENT` | HTML, control chars, or harmful Unicode |
|
||||
| `CUSTOM` | Custom validation error |
|
||||
|
||||
`ValidationErrors` is a slice with query methods:
|
||||
|
||||
```go
|
||||
errs := err.(validator.ValidationErrors)
|
||||
errs.HasErrors() // bool
|
||||
errs.Fields() // unique field names
|
||||
errs.ByField("name") // filter by field
|
||||
errs.ByCode(ErrorCodeRequired) // filter by code
|
||||
errs.First() // first error
|
||||
```
|
||||
|
||||
## Error propagation
|
||||
|
||||
Validation errors flow naturally through Go's error interface:
|
||||
|
||||
1. Request struct's `Validate()` returns `ValidationErrors` or `nil`
|
||||
2. Service method checks error before processing
|
||||
3. GraphQL/HTTP handlers convert `ValidationErrors` to appropriate response format
|
||||
|
||||
```go
|
||||
func (s *Service) CreateVendor(ctx context.Context, req CreateVendorRequest) (*coredata.Vendor, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// proceed with business logic
|
||||
}
|
||||
```
|
||||
@@ -86,7 +86,7 @@ func (r *applicabilityStatementConnectionResolver) TotalCount(ctx context.Contex
|
||||
return count, nil
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "not implemented: TotalCount for parent type %T")
|
||||
r.logger.ErrorCtx(ctx, "unsupported resolver for applicability statement connection", log.String("resolver", fmt.Sprintf("%T", obj.Resolver)))
|
||||
return 0, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
@@ -1771,7 +1771,7 @@ func (r *mailingListSubscriberConnectionResolver) TotalCount(ctx context.Context
|
||||
return count, nil
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "not implemented: TotalCount for parent type %T")
|
||||
r.logger.ErrorCtx(ctx, "unsupported resolver for mailing list subscriber connection", log.String("resolver", fmt.Sprintf("%T", obj.Resolver)))
|
||||
return 0, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
@@ -7278,7 +7278,7 @@ func (r *profileConnectionResolver) TotalCount(ctx context.Context, obj *types.P
|
||||
return count, nil
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "not implemented: TotalCount for parent type %T")
|
||||
r.logger.ErrorCtx(ctx, "unsupported resolver for profile connection", log.String("resolver", fmt.Sprintf("%T", obj.Resolver)))
|
||||
return 0, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user