English | 简体中文
gable
A code-first application framework for Go services: an explicit dependency
container, module lifecycle, typed configuration, glogs logging and stable
error codes. It composes the g* library family instead of hiding it.
Status: experimental scaffold (v0.0.0-dev). The API is not stable yet.
Why another framework
- No package-level registry. Dependencies live in an
App container and are
wired by modules, so tests can build an app without global state.
- Lifecycle is explicit. Modules register, start in dependency order and stop
in reverse order; a failed start rolls back what already started.
- Configuration is typed. YAML with
${ENV} expansion and GABLE_*
overrides, validated before anything starts.
- Errors carry a stable code. Transport layers can map a code to an HTTP
status without importing business packages.
- Web and docs are included. Gin-backed routing, a six-field response
envelope, binding/validation, request ids, panic recovery and access logs;
springdoc-shaped OpenAPI 3.0.1 at
/v3/api-docs with grouped documents and
a Swagger UI.
- Multi-tenancy is configurable.
single runs as the root tenant; saas
resolves the login tenant from the host and the request tenant from the
principal, with optional operator impersonation. Tenant scope fails closed.
- A SQL-first ORM.
sqlx + a small dialect layer, typed repositories,
tenant-scoped queries, gcosid keys and AutoMigrate for MySQL and
PostgreSQL.
- One namespace, derived in the framework. gable keeps the cache, Flyway,
CosID and cluster prefixes;
gns only carries the namespace value/context and
the Store SPI used to persist namespace definitions.
- Authentication and OAuth2. Password login, JWT or opaque access tokens,
rotating opaque refresh tokens, bearer middleware, passport-compatible
responses, an OAuth2 authorization server (code + PKCE, consent callback,
metadata), OIDC id_token/JWKS/userinfo, token/introspection endpoints and a
PKCE client with social callbacks.
- A validated DSL for low-code resources.
gable/v1 Model/Table/Form/Page
resources, lint, DDL and lcdp.ir/v1 previews, an idempotent apply, a
dynamic /dsl/data/<model> CRUD API and MCP tools. This is the contract an
AI can generate, inspect, apply and drive without touching Go code.
Quick start
package main
import (
"context"
"log"
"os"
"os/signal"
"github.com/maxid/gable"
)
type hello struct{}
func (hello) ID() string { return "hello" }
func (hello) Register(c *gable.Container) error { c.Set("hello.greeting", "world"); return nil }
func (hello) Start(context.Context) error { return nil }
func (hello) Stop(context.Context) error { return nil }
func main() {
app := gable.New()
if err := app.Register(hello{}); err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
if err := app.Run(ctx); err != nil {
log.Fatal(err)
}
}
Managing capabilities
Capability selection lives in gplugin, shared with the
protocol libraries. It takes the container type as a parameter, so gable.Container is just
one of its uses:
set := gplugin.NewSet[*Container]()
set.Add(gplugin.Static("log", "1.0.0", logCapability))
set.Add(gplugin.Static("cache", "1.0.0", cacheCapability)) // DependsOn: log
set.Add(gplugin.Static("web", "1.0.0", webCapability)) // DependsOn: cache
assembly, err := set.Select("web") // -> log, cache, web (dependency order)
err = assembly.Start(ctx, container) // register + start; a failure rolls back
defer assembly.Stop(ctx)
Three properties matter:
- Deterministic order — ties break on id, so a rolling restart cannot reorder
capabilities;
- Selection is validated at startup — an unknown capability, a dependency that is not
in the binary, or a cycle is an error, never a quietly smaller assembly;
- Lifecycle is optional — a wiring-only capability needs no empty
Start/Stop.
Unlike the platform's internal/plugin, there is no package-level registry and no panic
on duplicates: wiring stays explicit so a test can build an app in isolation.
A module that already has its own shape — takes its own container type, or carries its own
lifecycle interface — joins without being rewritten, by describing it with closures:
set := gplugin.NewSet[*platform.Container]()
module, err := gplugin.From(gplugin.Spec[*platform.Container]{
ID: "cache",
Version: "1.0.0",
DependsOn: []string{"log"},
Register: cache.Register,
Start: cache.Start,
Stop: cache.Stop,
})
set.Add(module)
Everything the manifest shows comes from the Spec — nothing is inferred — and a spec
without hooks stays wiring-only, so it never appears as started.
A module that declared a lifecycle satisfies gable.Module as-is — the method sets are
identical — so an assembly can also be registered on the application directly.
service, err := ai.New(ai.Config{
Provider: "openai", BaseURL: "https://api.deepseek.com/v1",
APIKey: key, Model: "deepseek-chat",
SystemPrompt: "be terse",
}, ai.WithTools(ai.Tool{
Name: "order_status", Schema: schema,
Handler: func(ctx context.Context, args json.RawMessage) (any, error) { /* ... */ },
}))
result, err := service.Ask(ctx, sessionID, "where is order 42?")
// result.Text, result.ToolsUsed, result.Iterations
The loop runs the tools the model asks for and feeds the results back. Two bounds are
not configurable into "off": MaxIterations (a confused model must not become an
infinite bill) and MaxHistory (history is trimmed at a user turn, so a tool call is
never separated from its result).
Schema from DSL
gable migrate --config gable.yaml --dir ./dsl --dry-run # print the DDL
gable migrate --config gable.yaml --dir ./dsl # create missing tables
migrate lints the DSL first: a model without a primary key parses fine but would
create a table nobody designed, so the command refuses it. It only creates missing
tables — altering one is a data-loss decision that belongs to explicit migrations.
Packages
| Package |
Purpose |
gable |
App, Container, module lifecycle |
config |
typed YAML + environment configuration |
log |
glogs logger construction; glogs/bridge captures non-slog dependencies |
errors |
stable error codes and HTTP status |
web |
Gin-backed MVC, envelope, binding/validation, OpenAPI + Swagger UI |
tenant |
single/saas resolution, context, host login rules, impersonation |
orm |
sqlx-backed repositories, tenant guard, migrations, gcosid keys |
rpc |
typed Client with local and HTTP/JSON bindings, shared-key auth, pluggable resolver; rpc/nacos resolves through the registry |
mcp |
exposes operations as MCP tools over Streamable HTTP, with a shared-key transport check |
ai |
tool-calling conversation loop over gai, with bounded rounds and bounded history |
dsl |
gable/v1 resources, lint, DDL, low-code IR, apply, file/database store |
auth |
password/JWT/opaque tokens, refresh rotation, passport endpoints, OAuth2 authorize/token |
cache |
gcache constructor that inherits the application namespace |
namespace |
gcache-backed gns.Store implementation (memory or Redis) |
auth/oauthclient |
authorization-code + PKCE client for social providers |
HTTP in five lines
server := web.New(web.WithTitle("demo"))
server.Register(myController{}) // Routes(r *web.Router)
app.Register(web.NewModule(server, "")) // listens on config.http.addr
GET /v3/api-docs serves the OpenAPI document, GET /v3/api-docs/{group} a
group, and GET /swagger-ui/index.html the UI.
Multi-tenancy
// single: every request is the root tenant
server.UseTenant(tenant.NewSingleResolver(cfg.Tenant))
// saas: resolve the request tenant from the authenticated principal
source := tenant.SourceFunc(func(ctx context.Context, r *http.Request) (tenant.Principal, bool, error) {
return principalFromToken(r) // your auth middleware
})
resolver, _ := tenant.NewSaaSResolver(cfg.Tenant, store, source)
server.UseTenant(resolver)
tenant.NewSaaSLoginResolver implements the login order: exact host, label
under base_domain, leftmost DNS label, explicit tenantCode, then the root
tenant. A conflicting or unknown explicit code is rejected, never silently
mapped to the root. Operators may impersonate with the configured header
(X-Tenant-Id by default), validated against the store.
ORM
type User struct {
ID int64 `gable:"pk"`
TenantID int64 `gable:"tenant"`
Name string `gable:"size=64"`
Created time.Time `gable:"auto_create"`
}
repo, _ := orm.NewRepo[User](db, "sys_user", orm.WithIDGenerator(ids))
users, err := repo.Query().Where(orm.Eq("name", "alice")).All(ctx)
The tenant predicate is injected from tenant.FromContext; a missing or
mismatched tenant fails closed. repo.Unscoped() is the explicit escape hatch.
db.AutoMigrate(model) creates missing tables only. MySQL DSNs should set
parseTime=true so DATETIME/TIMESTAMP columns scan into time.Time.
A row can be hidden instead of removed, in the two spellings the platform uses:
type Doc struct {
ID int64 `gable:"pk"`
Title string `gable:"size=128"`
DeletedAt *time.Time `gable:"soft_delete"` // hidden once it is set
}
type Flag struct { // or with a flag
ID int64 `gable:"pk"`
Name string
Retain bool `gable:"retain"` // hidden once it is true
}
Declaring either one adds deleted_at IS NULL / retain = false to every
scoped read, update and delete, so no caller has to remember it. Delete
writes the marker, Restore clears it, and Purge is the only call that
removes the row for good. Unscoped() lifts the guard; Update never touches
the marker, so a stale entity cannot resurrect a row.
Relations are declared on the entity and loaded only when asked for:
type Order struct {
ID int64 `gable:"pk"`
UserID int64
User *User `gable:"belongs_to=sys_user,foreign_key=user_id"`
Items []Item `gable:"has_many=order_item,foreign_key=order_id"`
}
orders, err := orders.Query().
Preload("User", users).
Preload("Items", items).
All(ctx)
A relation is metadata, not a column. Preload costs one query per relation
for the whole result set — never one per row — and the related read keeps the
same tenant and delete-marker guards as the query that asked for it. There is
no lazy loading, so a handler cannot accidentally issue a query per row.
Schema migrations live in the independent gmigrate library; gable only keeps AutoMigrate for development.
DSL
gable dsl lint --dir resources
gable dsl preview Model/task --dir resources --dialect postgres
gable dsl preview Page/task.page --dir resources --format yaml
gable dsl apply --dir resources --out .gable/dsl
The first slice covers Model, Table, Form and Page:
Model → normalized fields plus a CREATE TABLE statement.
Table/Form → lcdp.ir/v1 TableDef/FormDef.
Page → a UiPage widget tree for vamis.
apply validates, writes normalized resources and is idempotent. The runtime
data API and MCP tools are available too:
# dynamic CRUD: GET/POST /dsl/data/task, GET/PUT/DELETE /dsl/data/task/{id}
gable dsl serve --dir resources --dsn 'user:pass@tcp(127.0.0.1:3306)/app?parseTime=true'
# AI tool surface over MCP stdio: dsl.schema / dsl.lint / dsl.preview / dsl.apply
gable dsl mcp --dir resources
The data API accepts vamis CRUD parameters (page, perPage, orderBy,
orderDir) and enforces the Model's tenant guard.
Storing resources in a database
A directory is the GitOps path; a database is the path for several instances that
share one definition or edit resources at runtime. Both store the same normalized
YAML, so a resource looks identical whichever store produced it.
# directory -> database (linted first, only changed documents are written)
gable dsl push --dir resources --dsn 'user:pass@tcp(127.0.0.1:3306)/app?parseTime=true'
# serve what the push stored, instead of the directory
gable dsl serve --load db --dsn 'user:pass@tcp(127.0.0.1:3306)/app?parseTime=true'
dsl.NewDBStore is the library side (Migrate, Load, Save). Two tables back it:
gable_dsl_resource (one row per Kind/name, with the current document and revision)
and gable_dsl_revision (the same document at every revision it was written, for the
diff/history slice). Save lints the whole set, skips documents that did not change,
and writes the rest in one transaction — the revision number is assigned by the
database, so two instances saving at once cannot hand out the same one. Deleting a
resource is not implicit: dropping it from the set leaves the row in place.
The diff/dry-run over those revisions and the Api/Mcp kinds are the next slice.
Namespaces
namespace:
root: app
cache: app # inherited when omitted
flyway: app
cosid: none # keep existing segment names
cluster: app
timeSeries: app
app.Namespace() returns gable's config.Namespace:
ns := app.Namespace()
ns.CacheName() // gcache namespace
ns.FlywayTable("schema_migrations") // app_schema_migrations
ns.SegmentKey("sys_dict") // app.sys_dict (or sys_dict with cosid: none)
ns.CosIDName() // app (or "" with cosid: none)
ns.Topic("iothub_raw") // app.iothub_raw
gable/cache.New passes CacheName to gcache and auth token-store keys use the
same namespace. gns does not derive these values: it only carries
gns.Namespace in a context and exposes a Store SPI. The namespace package
implements that SPI over gcache, so the same adapter works with memory or Redis:
store, _ := namespace.NewStore(cache)
manager, _ := gns.NewManager(store)
ctx, err := manager.Bind(ctx, "app")
Authentication
service, _ := auth.NewTokenService(auth.Config{Secret: "change-me"}, users, clients, nil)
handlers := auth.NewHandlers(service, tenant.NewSingleLoginResolver(cfg.Tenant))
server.Register(handlers)
server.Use(handlers.Middleware()) // protected routes registered after this
POST /passport/login returns the frontend's camelCase TokenVO
(accessToken, refreshToken, expiresIn, key, needChangePassword, …).
AUTH_FORMAT=opaque (or AccessOpaque) issues the platform's
client:username:userType:uuid access token; JWT is the default.
POST /oauth2/token supports authorization_code, password,
refresh_token and client_credentials; /oauth2/introspect and
/oauth2/revoke are included.
GET/POST /oauth2/authorize implements code + PKCE with a Consent
(Authorizer) callback and exact redirect-uri matching; metadata is served at
/.well-known/oauth-authorization-server.
- With an RSA key configured, the code flow also issues an RS256
id_token
for the openid scope; /oauth2/jwks publishes the key and
/oauth2/userinfo returns the profile.
auth/oauthclient.Handlers adds /oauth2/login/:provider and
/oauth2/callback/:provider; the app maps the external profile to a local
principal.
- Refresh tokens rotate; logout removes the session marker and refresh token.
auth/oauthclient handles authorization-code + PKCE for external providers;
the application maps the external profile to a local user and calls
TokenService.
Cookie sessions
A browser cannot put an Authorization header on a page navigation, so a
frontend that the application itself serves needs the session in a cookie:
handlers.Cookies = &auth.CookieSession{Secure: true} // HttpOnly, SameSite=Lax
The cookie carries the same access and refresh tokens the body returns — it is a
transport, not a second session format. The middleware reads the header first and
the cookie second; logout revokes the token and expires both cookies; refresh
accepts the refresh cookie when the body omits it. Note that revoking a JWT only
stops future refresh: a stateless JWT stays valid until it expires, which is why
AccessOpaque is the format to use when logout must be immediate.
Second factor (TOTP)
MFA is per account, and opt-in until enrollment finishes:
mfa, _ := auth.NewMFAService(auth.DefaultTOTPConfig("myapp"), auth.NewMemoryMFAStore())
handlers.MFA = mfa
POST /passport/mfa/enroll returns the base32 secret, an otpauth:// URL and
ten recovery codes — shown once, because a code that can be shown again is not
a second factor;
POST /passport/mfa/activate needs a code from the authenticator, so a
half-finished enrollment cannot lock anyone out;
- once active,
POST /passport/login answers {mfaRequired, ticket} instead of
tokens; POST /passport/mfa/verify exchanges the ticket plus a code for tokens;
- the ticket is unguessable, lives five minutes and is consumed by the first
attempt even when the code is wrong, so it buys exactly one guess;
- recovery codes are stored hashed and each works once;
POST /passport/mfa/disable requires a working code: a stolen session alone
must not be able to remove the factor.
MFAStore and ChallengeStore default to memory; a deployment with more than one
replica needs shared implementations of both.
Examples
examples/hello — minimal HTTP app with OpenAPI and Swagger UI.
examples/tenant — SaaS mode: host login, principal tenant, impersonation.
examples/pilot — tenant-scoped task board: full CRUD, memory/ORM stores,
single/saas modes, OpenAPI; runnable without a database.
examples/dsl — model/table/form/page resources with lint and IR preview.
examples/auth — password login, protected route, OAuth2 token/introspection,
JWT and opaque modes.
Planned
- DSL: database store, diff/revision, Api/Mcp kinds.
ai / mcp: adapters over the gai and gmcp libraries.
- Cookie sessions, MFA; more provider presets.
lowcode: serve the IR and the vamis SDK from an app.
- ORM: relations/preload, soft delete, ClickHouse/DM/TDengine dialects.
cli: gable gen (in addition to new/dev).
Install
go get github.com/maxid/gable
License
MIT. See LICENSE.