codegen

package
v0.1.16 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 30, 2026 License: Apache-2.0 Imports: 32 Imported by: 0

Documentation

Index

Constants

View Source
const DefaultMaxTxOps = 100

DefaultMaxTxOps bounds the number of operations in one POST /api/transaction request — a DoS guard (an unbounded batch is an attack vector). Override with APPXIMO_MAX_TX_OPS.

Variables

View Source
var ErrNoWritableUpdate = fmt.Errorf("no writable fields in request")

ErrNoWritableUpdate means an update resolved to zero writable columns (e.g. the role's allowlist dropped every field in the body). Callers map it to 422.

Functions

func AppendStateTransitionGuard

func AppendStateTransitionGuard(sql string, args []any, res *schema.ResourceSchema, sets map[string]any) (string, []any, error)

AppendStateTransitionGuard appends, for each state-machine field being SET, a race-safe transition guard to an UPDATE's WHERE: the row's CURRENT state must either already equal the new value (a no-op self-set, so a full-object PUT/PATCH that re-sends the unchanged state still succeeds) OR be a state from which the new state is a DECLARED transition. A row whose current state allows neither matches ZERO rows — so an invalid transition (or a terminal state) atomically writes nothing, with no read-modify-write race. It appends NO clause for an update that touches no state-machine field, so a resource without one is byte-identical (the gate). The new value and the origin set are always bound parameters.

Exported (LIBRARY-GAPS-S2, ENG-7): it is the ONE place transitions become SQL, shared by the REST/GraphQL update core, the batch transaction path AND Ctx.Update — a custom route no longer re-states the transition table.

func BuildRouter

func BuildRouter(s *schema.APISchema, tdb *db.TenantDB, hr *extensions.HookRunner, inv CacheInvalidator, hub *events.Hub) *chi.Mux

BuildRouter creates a chi.Mux with real SQL handlers for every resource in the schema. Used by `appximo serve` — no code generation required. inv (nil-able) is the response-cache invalidator called after a successful PUT/PATCH. hub (nil-able) is the SSE pub/sub hub (S45): when nil, /api/{resource}/events returns 503 and post-commit publishes are no-ops.

func CheckFilePoliciesTenant

func CheckFilePoliciesTenant(ctx context.Context, tdb *db.TenantDB, pgSchema string, res *schema.ResourceSchema, vals map[string]any) ([]schema.FieldRuleError, error)

CheckFilePoliciesTenant runs the FILES-1 attach check through the tenant- scoped pool (REST and GraphQL write handlers). Returns the violations (S44 fields) and any infrastructure error (mapped by the caller like any DB error).

func CheckFilePoliciesTx

func CheckFilePoliciesTx(ctx context.Context, tx pgx.Tx, res *schema.ResourceSchema, vals map[string]any) ([]schema.FieldRuleError, error)

CheckFilePoliciesTx is CheckFilePoliciesTenant over an ALREADY-OPEN tenant transaction (search_path set) — the batch executor and Ctx.Insert/Update.

func CollectUpdate

func CollectUpdate(res *schema.ResourceSchema, body map[string]any, put bool, writable func(string) bool) (map[string]any, []schema.FieldRuleError)

CollectUpdate validates body against the resource schema for an update and returns the columns→values to write (excluding the auto updated_at, which RunUpdate forces to NOW()). On a validation failure it returns the violations in the SAME S44 fields[] shape the create path reports — every offending field at once, each with its rule.

It used to return a single flat message (`{"error":"field \"amount\" must be an integer"}`) while create answered the structured `{"error":"validation_failed","fields":[…]}` for the same mistake — two shapes for one error class, so a client could not parse both verbs with one code path, and a generated OpenAPI client modelling the documented ValidationErrorResponse failed on every PATCH type error (ENG-29, ADR-024 rule 9: two paths that accept the same input must answer it the same way). All three callers — REST PUT/PATCH, the GraphQL update mutation, and a batch transaction update op — emit the shared shape now.

  • PUT (put=true): every non-auto required field must be present and non-null; optional fields absent from the body are written as NULL (full replacement).
  • PATCH (put=false): only fields present in the body are written; the rest are left untouched in the DB.

Both reject "id" and any auto-managed field appearing in the body, reject unknown fields, type-check values, and validate enums. Fields the role may not write (per writable) are silently dropped from the result.

func EnforceCreateRBAC

func EnforceCreateRBAC(body map[string]any, ev *rbac.EvalResult) (int, string)

EnforceCreateRBAC applies a role's field allowlist and row-level condition to a CREATE body, in place, identically for the REST POST and the GraphQL create:

  • Field allowlist: fields outside the role's allowlist are DROPPED from the body (silently — the same contract CollectUpdate uses for update; never an error), EXCEPT the row-condition field, which is server-forced below and therefore implicitly allowed.
  • Row-level condition: a row-scoped role (e.g. user_id = $user_id) must create rows attributed to ITSELF. The condition field is FORCED to the principal's resolved value; if the body supplies a DIFFERENT non-null value, the create is REJECTED with 403 — a client can never create a row owned by another principal.

Returns (0,"") to proceed, or (403, msg) to reject. ev may be nil (no policy result — e.g. a test without the RBAC middleware, or the library Ctx path), and a role with neither an allowlist nor a condition (e.g. rrhh-admin) is a cheap no-op, so the unrestricted create path keeps behaving exactly as before (the GATE-WRITE case).

func EnforceUpdateRBAC added in v0.1.10

func EnforceUpdateRBAC(body, sets map[string]any, ev *rbac.EvalResult) (int, string)

EnforceUpdateRBAC applies the identity-column rule to an UPDATE. body is the client's request body as decoded (BEFORE any allowlist projection — the attempt must be judged, not hidden); sets is the column set the update will write (CollectUpdate's result: for a PUT it carries every writable column, absent ones as nil). It returns (403, msg) when the body names the identity column with anything but the caller's own id, and otherwise proceeds — forcing, in place, a PUT-omitted nullable identity column back to the caller's value so a full replacement never orphans the row.

A nil ev, a role without a condition, or a LITERAL condition is a no-op: one nil check on the unscoped hot path (measured no_change).

func ExplainTransitionFailure

func ExplainTransitionFailure(ctx context.Context, tdb *db.TenantDB, pgSchema, tbl, id string, res *schema.ResourceSchema, sets map[string]any) (int, string)

ExplainTransitionFailure runs on the 0-rows update path when a state field was set (the row existed + passed RBAC, per the prior existence check): it reads the row's CURRENT state and returns a precise 422 "invalid transition from X to Y", or a 404 if the row vanished (a race). It is an ERROR-PATH read only — never the hot path.

func ExplainTransitionFailureTx

func ExplainTransitionFailureTx(ctx context.Context, tx pgx.Tx, tbl, id string,
	res *schema.ResourceSchema, sets map[string]any, cond *rbac.WhereCondition) (int, string)

ExplainTransitionFailureTx is ExplainTransitionFailure for a caller holding an OPEN transaction — Ctx.Update (LIBRARY-GAPS-S2, ENG-7). Same read, same classification, plus the role's row condition on the SELECT: the explain must never reveal the state of a row the failed UPDATE itself could not touch (a row-condition-excluded row stays a plain 404, not a 422 that leaks its current state).

func Generate

func Generate(s *schema.APISchema, outputDir string) ([]string, error)

Generate writes handler files and router.go into outputDir/internal/handlers/. Returns the relative paths of all generated files.

func GenerateGraphQL

func GenerateGraphQL(s *schema.APISchema) string

GenerateGraphQL emits a GraphQL SDL schema document from an APISchema.

func GenerateOpenAPI

func GenerateOpenAPI(s *schema.APISchema, baseURL string) ([]byte, error)

GenerateOpenAPI produces an OpenAPI 3.0.3 YAML document from an APISchema. baseURL is set as the server URL (defaults to "/" when empty).

func GenerateOpenAPIJSON

func GenerateOpenAPIJSON(s *schema.APISchema, baseURL string) ([]byte, error)

GenerateOpenAPIJSON produces the SAME OpenAPI 3.0.3 document as GenerateOpenAPI but JSON-encoded — the format Swagger UI and most client generators consume. The engine serves it at GET /openapi.json.

func GenerateOpenAPIJSONWithRoutes

func GenerateOpenAPIJSONWithRoutes(s *schema.APISchema, baseURL string, routes []CustomRoute) ([]byte, error)

GenerateOpenAPIJSONWithRoutes is the JSON encoding of GenerateOpenAPIWithRoutes.

func GenerateOpenAPIWithRoutes

func GenerateOpenAPIWithRoutes(s *schema.APISchema, baseURL string, routes []CustomRoute) ([]byte, error)

GenerateOpenAPIWithRoutes is GenerateOpenAPI plus the app's registered custom routes as path items (ENG-33) — what a framework-mode App serves, so the contract covers the WHOLE surface, not only the generated half.

func GenerateStructs

func GenerateStructs(s *schema.APISchema, packageName string) ([]byte, error)

GenerateStructs returns a gofmt'd Go source file declaring one typed Row struct per resource in s. packageName is written into the package declaration. Each struct always has an ID field; every other field follows the schema definition. Fields that are auto-generated or not required become pointer types with omitempty.

func PrepareCreate added in v0.1.7

func PrepareCreate(res *schema.ResourceSchema, rv *schema.ResourceValidator, body map[string]any, role string) []schema.FieldRuleError

PrepareCreate applies, IN THE GENERATED POST'S EXACT ORDER, everything that must happen to a create body before it reaches the database:

  1. schema defaults for omitted fields — BEFORE validation, so a required field WITH a default is satisfied by it;
  2. the governed-field rule (WRITE-ASYMMETRY-S1) — `id` and `auto` fields in the body are rejected 422 read_only unless the resource's `import` declaration grants them to the caller's role (schema. GovernedFieldViolations, the ONE implementation every door consults) — PLUS the declarative rules (required, enum, min/max, length, pattern, format) AND the value type check, all collected together so one response carries every failing field (the S44 contract — reporting them in phases makes a form UI mark two fields, then two different ones);
  3. the state-machine initial states — a row may only be CREATED in a declared initial state.

role is the caller's RBAC role, consulted ONLY by the governed-field rule (the import grant); pass the authenticated role, never a guess.

It returns every violation at once; an empty slice means the body is ready for RBAC enforcement, the file-policy check and the INSERT.

It deliberately does NOT do RBAC, hooks or the insert itself: those differ legitimately between the paths (a custom handler is mid-transaction and owns its own authorization decisions), and mixing them here would hide that.

func PrepareUpdate added in v0.1.7

func PrepareUpdate(res *schema.ResourceSchema, rv *schema.ResourceValidator, body map[string]any) []schema.FieldRuleError

PrepareUpdate is the update-side counterpart: PATCH semantics (only the fields present are validated), plus the same value type check the create path runs, plus the governed-field rule — `id` and `auto` fields in an update body are ALWAYS rejected (import is create-only; see schema.governed.go). Before WRITE-ASYMMETRY-S1 this pass was missing here, so Ctx.Update accepted `{"id": …}` and generated `SET id = …` — a primary-key rewrite the REST PATCH answers 422 read_only for. Defaults are deliberately absent — they are create-only, on both paths, matching SQL DEFAULT.

The state-machine TRANSITION is not checked here: it is enforced inside the UPDATE's WHERE by AppendStateTransitionGuard (ENG-7), which both paths already share, because doing it in SQL is what makes it race-safe.

func ResolveWriteSurface added in v0.1.7

func ResolveWriteSurface(ctx context.Context, tenantID, resource string, bootRes *schema.ResourceSchema, bootRV *schema.ResourceValidator) (*schema.ResourceSchema, *schema.ResourceValidator)

ResolveWriteSurface is the SAME decision writeSurface makes, exported for the library path (ENG-43): Ctx.Insert/Update/Query resolve the tenant's deployed surface through this — one seam, never a second resolution — so a hot-migrated column's declared rules bind on a custom handler exactly as they do on the generated /api write (and a tenant with nothing deployed keeps the boot pair, the ENG-12 union guarantee).

func RunDelete

func RunDelete(ctx context.Context, tdb *db.TenantDB, tbl, name, tenantID, pgSchema, id string, cond *rbac.WhereCondition, emitDelete bool) (int64, error)

RunDelete executes the DELETE for the row id (with the role's row-level RBAC condition appended — BOLA defence, so a restricted role cannot delete a row it cannot see), emitting the outbox event in the SAME transaction when emitDelete is set — exactly as RunInsert/RunUpdate do for create/update. It returns the affected-row count (0 → 404 / row-condition exclusion → no event). This is the ONE delete core shared by the REST DELETE handler and the GraphQL delete mutation, so both surfaces enqueue an IDENTICAL {resource}.deleted event (same topic, same lean {id,tenant_id,resource,action} payload) atomically with the delete. A resource that did not opt into events:["delete"] takes the plain ExecTenant path — no emission, zero added overhead (the no-opt-in gate).

func RunInsert

func RunInsert(ctx context.Context, tdb *db.TenantDB, tbl, name, tenantID, pgSchema string, body map[string]any, emitCreate bool) ([]map[string]any, error)

RunInsert builds and executes the INSERT … RETURNING * for an already-validated body (after any before_create hook AND EnforceCreateRBAC), emitting the outbox event in the SAME transaction when emitCreate is set — exactly as RunUpdate does for updates. It is the ONE create core shared by the REST POST handler and the GraphQL create mutation, so both surfaces enqueue an IDENTICAL {resource}.created event (same topic, same lean {id,tenant_id,resource,action} payload) atomically with the insert. A resource that did not opt into events:["create"] takes the plain ExecRowsTenant path — no emission, zero added overhead (the no-opt-in gate).

func RunUpdate

func RunUpdate(ctx context.Context, tdb *db.TenantDB, res *schema.ResourceSchema,
	tbl, name, tenantID, pgSchema, id string, sets map[string]any,
	cond *rbac.WhereCondition, emitUpdate bool) ([]map[string]any, error)

RunUpdate builds and executes the partial/full UPDATE for the already-validated column set `sets` (from CollectUpdate, after any before_update hook adjustment), forcing updated_at=NOW() when the resource declares an auto updated_at column, appending the role's row-level RBAC condition (so a restricted role cannot touch a row it cannot see — BOLA defence), and emitting the outbox event in the SAME transaction when emitUpdate is set. It returns the RETURNING * rows (empty when no row matched → 404 / row-condition exclusion). This is the ONE update core shared by the REST PATCH/PUT handler and the GraphQL update mutation, so both surfaces enforce identical RBAC, identifier safety, and event emission.

func StateFieldNullViolations added in v0.1.10

func StateFieldNullViolations(res *schema.ResourceSchema, body map[string]any, put bool) []schema.FieldRuleError

StateFieldNullViolations is the ONE rule "a row is always in a declared state": a state-machine field present as null, or absent from a FULL replacement (put=true — PUT writes every omitted optional column as NULL), is a 422 naming the field. Consumed by CollectUpdate (REST PUT/PATCH, GraphQL update, batch update) and PrepareUpdate (Ctx.Update). Before this rule the nil reached AppendStateTransitionGuard as a non-string and every door answered 500 "internal error" — and a PUT on any resource with a lifecycle failed unless the client re-sent the state. A required state field's null is already reported by the `required` rule, so it is not reported twice. Sorted by field (deterministic responses).

func WithDeployedProvider

func WithDeployedProvider(ctx context.Context, p DeployedProvider) context.Context

WithDeployedProvider puts p in the context so the generated write handlers can prefer a tenant's deployed schema. Installed once, in the middleware chain.

Types

type CacheInvalidator

type CacheInvalidator interface {
	Invalidate(tenantID string)
}

CacheInvalidator drops a tenant's cached GET responses. *cache.ResponseCache implements it. BuildRouter calls it after a successful PUT/PATCH so a follow-up read reflects the write immediately instead of waiting out the response-cache TTL. May be nil (e.g. in tests or when no cache is wired).

type CustomRoute

type CustomRoute struct {
	Method      string // uppercased HTTP method (GET/POST/PUT/PATCH/DELETE)
	Path        string // literal route path, e.g. "/api/checkout"
	Summary     string // optional one-liner from Route.Description ("" = generic)
	Public      bool   // Route.Public — no token required, valid token recognized
	RequireRole string // Route.RequireRole — extra role equality demanded by the route
	ByteServing bool   // Route.ByteServing — response is a byte stream (Ctx.ServeFile)
}

CustomRoute describes one custom endpoint a framework-mode backend registered with (*appximo.App).Register, for publication in the served OpenAPI document (ENG-33, THIRD-PARTY-READY-S1).

Why this exists: before it, the whole custom half of an app's surface — which for a storefront is the whole PUBLIC half — was invisible to a third party. /openapi.json listed only the generated routes, and probing an unknown /api/ path answers 401 (auth runs before routing), so an external agent could not distinguish "this endpoint does not exist" from "this endpoint wants a token". The engine KNOWS the registered routes at boot; this type is how it declares them in the contract.

The split of responsibilities is deliberate and documented in the emitted operations themselves:

  • The ENGINE knows (and publishes): method, path, whether the route is Public (Bearer optional) or authenticated (Bearer + which RBAC virtual-resource action it demands), an extra RequireRole if declared, and whether the response is a byte stream (ByteServing).
  • The AUTHOR may declare (optional): a one-line Summary (appximo.Route.Description). Request/response SHAPES stay the author's job — a Go handler has no declared schema, and inventing one here would publish a guess as a contract. The app's contract sheet (backend-spec §3.6b) remains the authority for shapes; the OpenAPI is the authority for EXISTENCE.

type DeployedProvider

type DeployedProvider interface {
	WriteSurfaceFor(tenantID, resource string) *WriteSurface
}

DeployedProvider resolves the write surface a tenant currently has DEPLOYED. Returning nil means "nothing deployed to prefer" and the caller keeps the boot-compiled surface — the exact previous behavior.

Implementations must be cheap per call (the write path is hot): resolve from an in-memory, invalidation-driven cache, never a query per request.

func DeployedProviderFromCtx

func DeployedProviderFromCtx(ctx context.Context) DeployedProvider

DeployedProviderFromCtx returns the provider installed for this request, if any.

type RelationData

type RelationData struct {
	FieldName   string // e.g. "client_id"
	RelName     string // e.g. "client"  (route segment: FieldName with _id stripped)
	RelResource string // e.g. "clients" (target table)
	RelTitle    string // e.g. "Client"  (PascalCase for method names)
}

RelationData describes a foreign-key relation on a resource field.

type ResourceData

type ResourceData struct {
	Name      string
	Title     string
	Relations []RelationData
}

ResourceData is the per-resource context passed to router.tmpl.

type WriteSurface

type WriteSurface struct {
	Res *schema.ResourceSchema
	RV  *schema.ResourceValidator
}

WriteSurface is what the write path needs about a resource: its field set (for key/type checking) and its compiled declarative validators. The two travel together because they must describe the SAME resource — validating a body against one version's rules and one version's fields is how divergences hide.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL