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
|
## Reference Documentation
|
||||||
|
|
||||||
Detailed guides for specific subsystems live in `contrib/claude/`:
|
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/authorization.md`](contrib/claude/authorization.md) — IAM policy-based authorization (policies, conditions, roles, AuthorizationAttributer)
|
||||||
- [`contrib/claude/graphql.md`](contrib/claude/graphql.md) — Go GraphQL backend (gqlgen, @goModel, connection types, cursor pagination)
|
- [`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/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-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-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/graphql.md`](contrib/claude/graphql.md) — Go GraphQL backend (gqlgen, @goModel, connection types, cursor pagination)
|
||||||
- [`contrib/claude/sandbox.md`](contrib/claude/sandbox.md) — Lima sandbox environments (create, manage, access services)
|
- [`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/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/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
|
## 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
|
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)
|
return 0, gqlutils.Internal(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1771,7 +1771,7 @@ func (r *mailingListSubscriberConnectionResolver) TotalCount(ctx context.Context
|
|||||||
return count, nil
|
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)
|
return 0, gqlutils.Internal(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7278,7 +7278,7 @@ func (r *profileConnectionResolver) TotalCount(ctx context.Context, obj *types.P
|
|||||||
return count, nil
|
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)
|
return 0, gqlutils.Internal(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user