Help us shape the future of Go! Take the 2026 Go Developer Survey

gable

package module
v0.0.0-...-946afca Latest Latest
Warning

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

Go to latest
Published: Nov 2, 2026 License: MIT Imports: 9 Imported by: 0

README

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.

Conversations with tools

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.

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.

Documentation

Overview

Package gable is a code-first application framework for Go services.

Status: experimental scaffold (v0.0.0-dev). The API is not stable yet.

The framework deliberately keeps three things explicit: the container that owns application dependencies, the lifecycle of modules, and the configuration they read. There is no package-level registry.

Index

Constants

View Source
const DefaultStopTimeout = 30 * time.Second

DefaultStopTimeout bounds Run's shutdown when the caller's context ends.

View Source
const Version = "0.0.0-dev"

Version is the semantic version of this library.

Variables

This section is empty.

Functions

func MustResolve

func MustResolve[T any](c *Container, key string) T

MustResolve returns the value under key asserted to T and panics otherwise.

It is meant for wiring code that runs during Register, where a missing dependency is a programming error rather than a runtime condition.

func Resolve

func Resolve[T any](c *Container, key string) (T, bool)

Resolve returns the value under key asserted to T.

The second result is false when the key is missing or holds another type.

Types

type App

type App struct {
	// contains filtered or unexported fields
}

App owns the container, the module lifecycle and the configuration.

func New

func New(opts ...Option) *App

New returns an application with development defaults.

func (*App) Config

func (a *App) Config() *config.Config

Config returns the active configuration.

func (*App) Container

func (a *App) Container() *Container

Container returns the application container.

func (*App) Logger

func (a *App) Logger() *glogs.Logger

Logger returns the active logger, or nil before Start.

func (*App) Namespace

func (a *App) Namespace() config.Namespace

Namespace returns the derived namespace configuration. It is populated by Start.

func (*App) Register

func (a *App) Register(module Module) error

Register adds a module. Registration happens before Start, so modules can declare dependencies on each other without a global registry.

func (*App) Run

func (a *App) Run(ctx context.Context) error

Run starts the application, waits for ctx to end and shuts it down.

func (*App) Start

func (a *App) Start(ctx context.Context) error

Start validates the configuration, registers modules in dependency order and starts them. It does not block.

func (*App) Stop

func (a *App) Stop(ctx context.Context) error

Stop shuts down started modules in reverse order and closes the logger the App created. It is safe to call before Start.

type Container

type Container struct {
	// contains filtered or unexported fields
}

Container is an explicit dependency container.

Dependencies are registered by modules during Register and resolved by consumers. Keys are namespaced by convention (for example "service.user") so that two modules cannot silently overwrite each other's values.

func NewContainer

func NewContainer() *Container

NewContainer returns an empty container.

func (*Container) Delete

func (c *Container) Delete(key string)

Delete removes key from the container.

func (*Container) Get

func (c *Container) Get(key string) (any, bool)

Get returns the value stored under key.

func (*Container) Keys

func (c *Container) Keys() []string

Keys returns the registered keys in sorted order.

func (*Container) Set

func (c *Container) Set(key string, value any)

Set stores value under key. It panics on an empty key because an unnamed dependency cannot be resolved or diagnosed.

type Dependent

type Dependent interface {
	DependsOn() []string
}

Dependent is an optional interface a Module implements to declare the modules it needs started first.

type Module

type Module interface {
	ID() string
	Register(*Container) error
	Start(context.Context) error
	Stop(context.Context) error
}

Module is a unit of application wiring and lifecycle.

Register runs once, before any module starts. Start and Stop are called in dependency order and in reverse order respectively. A module must tolerate Stop being called when Start was never reached.

type Option

type Option func(*App)

Option configures an App at construction time.

func WithConfig

func WithConfig(cfg *config.Config) Option

WithConfig replaces the configuration. A nil config is ignored.

func WithLogger

func WithLogger(logger *glogs.Logger) Option

WithLogger uses an existing glogs logger. The App does not close it.

Directories

Path Synopsis
Package ai runs a conversation against a model, executing the tools it asks for.
Package ai runs a conversation against a model, executing the tools it asks for.
Package auth provides password login, JWT/opaque access tokens, rotating opaque refresh tokens, bearer middleware and OAuth2-compatible endpoints.
Package auth provides password login, JWT/opaque access tokens, rotating opaque refresh tokens, bearer middleware and OAuth2-compatible endpoints.
oauthclient
Package oauthclient implements the OAuth2 authorization-code + PKCE client side used for social login.
Package oauthclient implements the OAuth2 authorization-code + PKCE client side used for social login.
Package cache builds a gcache instance with the application namespace.
Package cache builds a gcache instance with the application namespace.
cmd
gable command
Package config loads and validates typed application configuration.
Package config loads and validates typed application configuration.
Package dsl loads, validates and previews gable's declarative resources.
Package dsl loads, validates and previews gable's declarative resources.
Package errors defines the framework's stable error contract.
Package errors defines the framework's stable error contract.
examples
auth command
hello command
pilot command
tenant command
Package log builds the application logger from configuration.
Package log builds the application logger from configuration.
Package mcp exposes an application's operations as MCP tools.
Package mcp exposes an application's operations as MCP tools.
Package namespace adapts gcache to the gns.Store SPI.
Package namespace adapts gcache to the gns.Store SPI.
Package orm is a small SQL-first ORM built on database/sql and sqlx.
Package orm is a small SQL-first ORM built on database/sql and sqlx.
rpc
Package rpc defines the inter-service RPC abstraction: one typed Client, two bindings (in-process and HTTP/JSON), and one wire contract shared by both.
Package rpc defines the inter-service RPC abstraction: one typed Client, two bindings (in-process and HTTP/JSON), and one wire contract shared by both.
nacos
Package nacos resolves RPC service names through Nacos.
Package nacos resolves RPC service names through Nacos.
Package tenant resolves and carries the tenant of a request.
Package tenant resolves and carries the tenant of a request.

Jump to

Keyboard shortcuts

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