This is the full developer documentation for Gortexa
# Gortexa
## Features
### Three protocols, one port
h2c multiplexing on Go's native `http.Server.Protocols`. One port accepts gRPC, HTTP/JSON and MCP.
### Fixed-order interceptor chain
recover → request-id → logger → load-shed → rate-limit → circuit-breaker → auth → validation. Eight stages in a fixed order; a missing stage fails startup.
### One error model
A single mapping table produces the gRPC status, HTTP status and MCP error envelope. Internal causes never leak.
### AI tool calls go through the same chain
MCP `tools/call` dispatches over an in-process loopback, so AI calls pass auth and validation like any other request.
### Batteries included
Layered config, three-state health, slog + OTLP, pgx + sqlc, cache (in-memory by default), NATS/JetStream.
### Importable as a module
`go get` the framework packages directly — kernel/interceptor/mq for plain services, plus the mcp bridge and gortexa.ai.v1 annotations for AI ones. No scaffold required. See [Components](/en/components/).
## Get started
To have a coding agent build a runnable service scaffold from a prompt, use [Quickstart with AI](/en/ai-quickstart/). It pins the release, verifies the toolchain, removes the usable secret generated by the scaffold, and runs HTTP/MCP smoke checks in one shell. For a manual walkthrough, use [Quickstart](/en/quickstart/).
Both paths create a prototype only; see [Deployment](/en/deployment/) for the work required before production.
Or consume the framework as a Go module — `go get` it and import the packages directly, no scaffold required:
```bash
go get github.com/yshengliao/gortexa@latest
```
See [Components](/en/components/) for each package's usage, or [Use as a module](/en/quickstart/#use-as-a-module-no-scaffold) for a minimal app.
After either flow starts the server on :8080, hit each protocol once:
```bash
curl localhost:8080/healthz # 200: health check is open
curl -XPOST localhost:8080/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' # MCP: list AI tools defined in proto
curl localhost:8080/v1/resources/x # 401: auth is shared across all three
```
# Quickstart with AI
Gortexa lets a coding agent with terminal and file-system access quickly create a runnable, contract-first API prototype: proto, gRPC, HTTP/JSON, MCP, validation, and server wiring are generated together. To use the framework packages as a plain library instead (no scaffolded project), see [Use as a module](/en/quickstart/#use-as-a-module-no-scaffold).
**Prototype is not production**
The generated logic is an in-memory stub. It does not automatically provide persistence, tenant isolation, authorization, secret lifecycle, real readiness checks, or a deployable production design. A service that starts is not automatically safe to launch.
## Prerequisites
- The agent can run shell commands and read/write the local project.
- Go 1.21+ (as the bootstrap; the framework downloads Go 1.26.5), Git, curl, OpenSSL, and Perl are installed, and GitHub plus the Go module proxy are reachable.
- The shell may use `GOTOOLCHAIN=auto` so Go can download the toolchain required by the framework.
- Start with an empty workspace. The agent must not overwrite projects, push, deploy, or commit secrets unless you explicitly authorize it.
## Paste this: create a reliable prototype
This prompt builds the toolchain, CLI, and project layout from the **same verified release tag**. That avoids drift between an @latest CLI and a floating main layout. It records the checkout SHA and avoids make bootstrap inside the generated project because the toolchain is already installed from the framework checkout.
~~~text
Help me create a runnable Gortexa prototype, not a production service. If I have
not supplied SERVICE_NAME, GO_MODULE, API_DOMAIN/API_VERSION, and ENTITY, ask only
for those four values first. Do not overwrite an existing directory, git push,
deploy, commit, or reveal any secret.
Execute the following steps in one shell session, in order, and report each result.
Run set -euo pipefail first; stop on any failure rather than guessing or skipping it.
set -euo pipefail
WORKSPACE="$HOME/src"
SERVICE_NAME="myapp"
GO_MODULE="github.com/me/myapp"
API_DOMAIN="billing"
API_VERSION="v1"
ENTITY="Invoice"
FRAMEWORK_REF="v0.27.0" # change only after testing a newer release
FRAMEWORK="$WORKSPACE/gortexa-framework"
PROJECT="$WORKSPACE/$SERVICE_NAME"
1. mkdir -p "$WORKSPACE"
git clone --depth 1 --branch "$FRAMEWORK_REF" https://github.com/yshengliao/gortexa.git "$FRAMEWORK"
cd "$FRAMEWORK"
export GOTOOLCHAIN=auto
bash install.sh
export PATH="$(go env GOPATH)/bin:$PATH"
mkdir -p "$FRAMEWORK/bin"
go build -o "$FRAMEWORK/bin/gortexa" ./cmd/gortexa
export PATH="$FRAMEWORK/bin:$PATH"
CLI="$FRAMEWORK/bin/gortexa"
"$CLI" doctor
FRAMEWORK_COMMIT="$(git rev-parse HEAD)"
printf 'framework ref=%s commit=%s\n' "$FRAMEWORK_REF" "$FRAMEWORK_COMMIT"
2. "$CLI" create "$PROJECT" --module "$GO_MODULE" --repo "file://$FRAMEWORK" --ref "$FRAMEWORK_REF"
cd "$PROJECT"
Without printing the old value, reset the JWT secret written by create to the
placeholder that refuses startup:
perl -0pi -e 's/(jwt_secret:\s*")[^"]*(")/${1}dev-only-insecure-secret-change-me-please${2}/' etc/config.yaml
grep -Fq 'jwt_secret: "dev-only-insecure-secret-change-me-please"' etc/config.yaml
Read all four skill files under .skills/ before changing code.
Do not run make bootstrap in this new project; reuse the toolchain verified above.
3. "$CLI" gen "$API_DOMAIN/$API_VERSION" "$ENTITY"
Run go build ./... && go test -race ./... && go vet ./...
Explain that generated logic is an in-memory prototype stub and identify what
must be replaced for a real service.
4. For a local prototype only, generate a short-lived runtime secret. To clean up
reliably from one shell, start the generated server binary directly ("$CLI" run
is the foreground development command):
export GORTEXA_AUTH__JWT_SECRET="$(openssl rand -hex 32)"
go build -o "$PROJECT/.gortexa-prototype-server" ./cmd/server
"$PROJECT/.gortexa-prototype-server" >"$PROJECT/gortexa-prototype.log" 2>&1 &
SERVER_PID=$!
cleanup() { kill -TERM "$SERVER_PID" 2>/dev/null || true; wait "$SERVER_PID" 2>/dev/null || true; rm -f "$PROJECT/.gortexa-prototype-server" "$PROJECT/gortexa-prototype.log"; }
trap cleanup EXIT INT TERM
for _ in $(seq 1 60); do
if curl -fsS http://127.0.0.1:8080/healthz >/dev/null 2>&1; then break; fi
sleep 0.5
done
curl -fsS http://127.0.0.1:8080/healthz
test "$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/v1/resources/x)" = 401
curl -fsS -X POST localhost:8080/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
cleanup
trap - EXIT INT TERM
5. Stop after prototype verification. Do not git add or build a Docker image until
I confirm that etc/config.yaml remains a placeholder, secrets are externalized,
and persistence plus authorization are done.
Rules: proto is the single source of truth; after proto changes run "$CLI" regen;
never hand-edit gen/. Read https://gortexa.sheng.page/llms.txt first, then only
the documentation pages needed for the task.
~~~
Success means doctor, build, race tests, and vet pass; healthz returns 200; an unauthorized HTTP-gateway request returns 401; and MCP tools/list exposes the generated tool. That validates the **development scaffold, HTTP auth surface, and MCP discovery**. It does not perform a live gRPC E2E test or complete the data and authorization models.
## Paste this: plan a production foundation
Use this when you intend to ship the service. It forces the agent to collect the inputs that materially change the design instead of guessing tenants, authorization, or retention rules.
~~~text
Move the current Gortexa prototype toward a production foundation, but do not
write code yet. First confirm: actors and tenant model, role/permission matrix,
database and migrations, transactions/idempotency/pagination, cache/MQ retry and
DLQ semantics, secret rotation, TLS ingress/trusted proxies/rate limits,
SLOs/observability, replicas/readiness, rollback, and acceptance tests.
Ownership must be derived from an authenticated principal/tenant, never trusted
from an owner value in the request body. After I answer, propose a phased plan;
do not retain generated in-memory logic as the production implementation. Include
external secret injection, persistent repositories/migrations, tenant-aware
authorization, real readiness, cross-tenant negative tests, integration/E2E/failure
tests, a non-root Docker runtime, and TLS-ingress verification. Run tests after
each phase and report remaining risks.
~~~
See [Deployment](/en/deployment/) and [Security & boundaries](/en/examples/security/).
## Skills and agent docs
Every gortexa create project contains .skills/:
| Skill | It helps with | It does not replace |
| --- | --- | --- |
| bootstrapping-environment | Installing and checking the pinned toolchain | release and production readiness |
| scaffolding-projects | Creating the fixed project layout | product requirements and an upgrade strategy |
| generating-apis | proto, validation, AI annotations, logic stub, server wiring | persistence, authorization, business policy |
| proto-regen | regenerating stubs, gateway, OpenAPI, MCP code | schema and data-migration strategy |
In the same shell used by the prompt, `"$CLI" skills install` can wire these skills into Claude Code, Codex, Copilot, and Antigravity. Reading .skills/ directly in the project is sufficient for an agent to follow the workflow.
- [/llms.txt](/llms.txt): the site index; read it first, then select the needed pages.
- [/llms-full.txt](/llms-full.txt): complete English documentation; use it only when the larger context is justified.
## Let an agent call the local service
The service is an MCP server. /mcp exposes proto methods annotated with gortexa.ai.v1; tools/call goes through auth and validation, but application authorization remains your responsibility.
~~~bash
curl -X POST localhost:8080/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
~~~
Export a tool schema with:
~~~bash
"$CLI" export --format=mcp
~~~
Details are in [the MCP transport](/en/transports/mcp/).
# Quickstart
## Install
The dev toolchain (buf, protoc plugins, sqlc, govulncheck — all pinned):
```bash
GORTEXA_VERSION="v0.27.0" # update only after testing a newer release
export GORTEXA_REF="$GORTEXA_VERSION"
export GOTOOLCHAIN=auto
/bin/bash -c "$(curl -fsSL "https://raw.githubusercontent.com/yshengliao/gortexa/$GORTEXA_VERSION/install.sh")"
go install github.com/yshengliao/gortexa/cmd/gortexa@"$GORTEXA_VERSION"
```
Run outside a checkout, the script clones the repo into `./gortexa` (override with `GORTEXA_DIR`, delete afterwards if unwanted); it only writes persistent `go env` settings when it detects the broken dev-container values — a normal machine's Go config is left untouched.
`go install` puts the binary in `$(go env GOPATH)/bin`, which most shells do not have on PATH by default — without it, the next step fails with `command not found: gortexa`:
```bash
export PATH="$(go env GOPATH)/bin:$PATH" # add to your shell profile
```
Run `gortexa doctor` afterwards to check the Go toolchain and proto tools.
## Create a project
```bash
gortexa create myapp --module github.com/me/myapp \
--repo https://github.com/yshengliao/gortexa.git --ref v0.27.0
cd myapp
# Do not print the old value: remove the usable secret generated by create.
# The placeholder refuses startup without an environment override.
perl -0pi -e 's/(jwt_secret:\s*")[^"]*(")/${1}dev-only-insecure-secret-change-me-please${2}/' etc/config.yaml
grep -Fq 'jwt_secret: "dev-only-insecure-secret-change-me-please"' etc/config.yaml
```
`create` copies the framework layout, rewrites the module path and drops the git history. The result ships a sample resource, the full interceptor chain, config, health and the MCP bridge.
**This is a runnable demo, not a data service**
The next gen command creates process-local in-memory logic; data disappears on restart and is not shared between replicas. Treat it as a contract and transport scaffold. A real service must add persistence, authorization, and real readiness. If the toolchain was installed from a framework checkout, validate it in the new project with gortexa doctor rather than assuming make bootstrap can reinstall it there.
## Generate your first API
```bash
gortexa gen billing/v1 Invoice
```
This produces the proto contract (with validation and AI annotations), an in-memory logic stub and the server wiring, then runs buf lint → breaking → generate. After later proto edits, run `gortexa regen`.
Everything under `gen/` is generated code: never hand-edited, produced only by `make gen` (committed, following the framework convention — the copied CI has a drift guard).
## Run and verify
The following single-shell smoke test uses a short-lived runtime secret, starts
the same server entrypoint used by `gortexa run`, waits for readiness, and registers
cleanup for its binary and log. The server remains for the next token flow; do not
change shells between these commands.
```bash
set -euo pipefail
export GORTEXA_AUTH__JWT_SECRET="$(openssl rand -hex 32)"
go build -o ./.gortexa-prototype-server ./cmd/server
./.gortexa-prototype-server >./gortexa-prototype.log 2>&1 &
SERVER_PID=$!
cleanup() { kill -TERM "$SERVER_PID" 2>/dev/null || true; wait "$SERVER_PID" 2>/dev/null || true; rm -f ./.gortexa-prototype-server ./gortexa-prototype.log; }
trap cleanup EXIT INT TERM
for _ in $(seq 1 60); do
if curl -fsS http://127.0.0.1:8080/healthz >/dev/null 2>&1; then break; fi
sleep 0.5
done
curl -fsS http://127.0.0.1:8080/healthz
test "$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/v1/resources/x)" = 401
curl -fsS -X POST http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
The final HTTP-gateway request returns 401 by design; it shares authentication with gRPC and MCP. This smoke test does not replace a live gRPC E2E or authorization test. If you will not continue to the token flow, run `cleanup; trap - EXIT INT TERM` now to stop the server and remove temporary files. For normal interactive development, run `gortexa run` in the same shell with the exported secret, then stop it with Ctrl-C.
## Getting a dev token
The server remains running in the same shell from the previous block. First ensure the temporary command does not already exist, then create its directory:
```bash
test ! -e cmd/devtoken
mkdir -p cmd/devtoken
```
Save the following secret-free program as `cmd/devtoken/main.go`. Replace the import if your module is not `github.com/me/myapp`:
```go
package main
import (
"fmt"
"os"
"time"
"github.com/me/myapp/auth"
)
func main() {
secret := os.Getenv("GORTEXA_AUTH__JWT_SECRET")
v, err := auth.NewVerifier([]byte(secret), "gortexa")
if err != nil {
panic(err)
}
tok, err := v.Sign("dev", []string{"admin"}, time.Hour)
if err != nil {
panic(err)
}
fmt.Println(tok)
}
```
Run it, verify an authenticated request, then remove the temporary command and stop the server:
```bash
TOKEN="$(go run ./cmd/devtoken)"
AUTH_STATUS="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/v1/resources/x -H "Authorization: Bearer $TOKEN")"
test "$AUTH_STATUS" = 200 || test "$AUTH_STATUS" = 404
printf 'authenticated HTTP status=%s\n' "$AUTH_STATUS"
rm cmd/devtoken/main.go
rmdir cmd/devtoken
cleanup
trap - EXIT INT TERM
```
Do not copy, commit, or bake a real secret from `etc/config.yaml` into an image. In production, inject and rotate it through a secret manager, mounted file, or runtime environment.
## Use as a module (no scaffold)
Every framework package is importable directly with `go get` — no scaffolded project required:
```bash
go get github.com/yshengliao/gortexa@latest
```
A minimal app — one h2c port with gRPC health, `/healthz`, `/readyz` — needs no code generation at all:
```go
package main
import (
"context"
"log"
"os/signal"
"syscall"
"github.com/yshengliao/gortexa/health"
"github.com/yshengliao/gortexa/kernel"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
app, err := kernel.New() // defaults: h2c on :8080, graceful shutdown
if err != nil {
log.Fatal(err)
}
app.Health().Register("self", func(context.Context) health.State { return health.Healthy })
if err := app.Run(ctx); err != nil {
log.Fatal(err)
}
}
```
From there, both the AI and the non-AI path are first-class:
- Plain services: `config` for layered config, `interceptor` for the governed chain, `httpcompat` for the gateway mux, `apperr` for the three-transport error model, plus `mq` / `cache` / `storage` / `client`.
- AI services: annotate your protos with `gortexa/ai/v1/annotations.proto` (its Go bindings ship in the module at `gen/gortexa/ai/v1`) and wire `mcp.NewBridge` to expose the annotated RPCs as MCP tools behind the same interceptor chain.
For each public component's purpose, import path and a minimal example, see [Components](/en/components/).
Note: a project created by `gortexa create` already contains the framework source under its own module path — never also `go get` the framework there, or both copies register `gortexa/ai/v1/annotations.proto` and the binary panics at init.
## Working inside the framework checkout
When developing the framework itself, use make:
```bash
make bootstrap # install the pinned toolchain
make gen # buf lint → breaking → generate
make test # go test -race
make run # start the sample server (injects a local dev JWT secret)
make dev # docker compose with Jaeger, Grafana, Prometheus
```
## Next steps
- [Core concepts](/en/concepts/): how single-port multiplexing, the interceptor chain and the error model work.
- [Configuration](/en/configuration/): every config.yaml field and the environment-variable overrides.
- [Examples](/en/examples/): 11 runnable verification examples.
# Core concepts
## Contract-first
Protobuf is the single source of truth. You write two things: proto contracts and logic implementations. gRPC stubs, the HTTP/JSON gateway, the OpenAPI spec and MCP tool schemas are all produced by `gortexa regen`, which always runs buf lint → buf breaking → buf generate.
- Everything under `gen/` is an artifact: not committed, never hand-edited.
- The breaking-change gate is built into the flow: proto edits that break compatibility are rejected; additive edits pass.
## Single h2c port multiplexing
One cleartext HTTP/2 port, dispatched by Content-Type and path:
| Request | Destination |
| --- | --- |
| `Content-Type: application/grpc` | gRPC (grpc-go's `ServeHTTP` mode) |
| Path `/mcp` | MCP bridge (Streamable HTTP) |
| Everything else | grpc-gateway (HTTP/JSON) and built-in routes (`/healthz`, `/openapi.json`) |
h2c uses Go's native `http.Server.Protocols`, not the deprecated `x/net/h2c`. Cleartext means TLS must be terminated in front of the service in production — see [Deployment](/en/deployment/).
## The interceptor chain
Eight stages, fixed order:
```
recover → request-id → logger → load-shed → rate-limit → circuit-breaker → auth → validation
```
An incomplete chain fails startup (fail-loud) rather than silently dropping a stage. OTel attaches as a gRPC `StatsHandler`, covering unary and streaming.
The order is fixed, but the auth stage is pluggable: the default is HS256 JWT (`interceptor.Config.Verifier`), yet the stage actually runs an `auth.Authenticator` — a non-JWT scheme (static bearer, mTLS, API key) just sets `Config.Authenticator`, leaving the eight-stage order intact. To add your own interceptor, use `kernel.WithServerOptions(...)` (gRPC chains it after the framework chain, before the handler, still within recover) rather than editing the eight stages.
The defaults for the three governance stages are defined in `cmd/server/main.go` (not in config.yaml — to change them, edit the `interceptor.NewSet` parameters and recompile):
| Stage | Defaults | On limit |
| --- | --- | --- |
| rate-limit | 200 RPS per peer, burst 100, tracking TTL 10 minutes | `RESOURCE_EXHAUSTED` (HTTP 429) |
| circuit-breaker | opens after 5 consecutive failures for 10s, half-open admits 2 probes | `UNAVAILABLE` (HTTP 503) |
| load-shed | in-flight cap 1,024 (gRPC health checks exempt) | `RESOURCE_EXHAUSTED` (HTTP 429) |
Measured numbers (benchmarks in the framework README, Apple M1 / go1.26.5): a full unary pass through the chain is about 1,552 ns and 27 allocs; the rate-limiter `Allow` hot path is 0 allocs (allocs are machine-independent).
## One error model
A single mapping table decides how the same error appears on all three protocols: gRPC status, HTTP status, MCP error envelope. The rule is that internal causes never leak — only invalid-argument and unauthenticated forward their messages to the caller; every other category returns a safe message from the registry.
The error-resolution hot path is about 105 ns and 2 allocs (using Go 1.26's `errors.AsType`).
## Batteries
| Component | Notes |
| --- | --- |
| Config | Layered loading, fail-loud, masked secrets. See [Configuration](/en/configuration/) |
| Health | Three-state health mapped to gRPC Health; `app.Health().Register(...)` wires real dependencies |
| Logging/Observability | Structured logs via slog + OTLP export |
| Database | PgBouncer-safe pgx pool + sqlc |
| Cache | Process-local in-memory by default; `cache.driver: redis` switches to distributed (zero-dependency RESP client) |
| MQ | NATS (core or JetStream); empty `mq.group_id` = fanout, non-empty = load-balance |
**Before relying on a battery**
- Cache: the default in-memory backend is process-local — not shared across replicas, with no size cap or eviction. Fine for a single instance or local development; switch to `cache.driver: redis` when replicas must share a cache. See [Configuration](/en/configuration/).
- Config: the placeholder `jwt_secret` is refused at startup, and secret fields are masked in logs and error output. Real secrets come in via environment variables.
- MQ: `group_id` sets the delivery mode — empty is fanout (every subscriber receives each message), non-empty is load-balance (one handler per message).
# gRPC
gRPC is Gortexa's native protocol. External connections arrive on the same h2c port; the mux dispatches on `Content-Type: application/grpc` to grpc-go's `ServeHTTP` mode.
## Service definition and generated code
Services are defined in proto. The built-in sample resource:
```protobuf
service ResourceService {
rpc CreateResource(CreateResourceRequest) returns (Resource);
rpc GetResource(GetResourceRequest) returns (Resource);
rpc ListResources(ListResourcesRequest) returns (ListResourcesResponse);
rpc UpdateResource(UpdateResourceRequest) returns (Resource);
rpc DeleteResource(DeleteResourceRequest) returns (google.protobuf.Empty);
}
```
`gortexa regen` writes stubs to `gen/`; logic implementations live in internal packages. The two never mix.
## Calling the service
For local grpcurl testing, enable reflection first (it is off by default):
```bash
GORTEXA_SERVER__REFLECTION=true gortexa run
```
```bash
grpcurl -plaintext \
-H 'authorization: Bearer ' \
-d '{"id":"x"}' \
localhost:8080 resource.v1.ResourceService/GetResource
```
Without a token you get `UNAUTHENTICATED`: auth treats all three protocols the same way.
## Health
The three-state health model maps to the standard gRPC Health protocol. Register real dependency checks with `app.Health().Register(...)` so `/readyz` and gRPC Health reflect actual DB, cache and MQ reachability. Each check runs under a 5-second timeout, so one slow dependency cannot stall the whole query.
## Reflection
gRPC reflection is off by default. Enable it via config or an environment variable when needed:
```bash
GORTEXA_SERVER__REFLECTION=true
```
Keep it off in production — see [Deployment](/en/deployment/).
# HTTP/JSON
The HTTP/JSON surface comes from grpc-gateway: routes and payload shapes are generated from `google.api.http` annotations in the proto. No handlers are written by hand.
## Route mapping
Annotations sit on each rpc, for example in the sample resource:
```protobuf
rpc GetResource(GetResourceRequest) returns (Resource) {
option (google.api.http) = {get: "/v1/resources/{id}"};
}
```
The full mapping for the sample resource:
| RPC | HTTP |
| --- | --- |
| CreateResource | `POST /v1/resources` |
| GetResource | `GET /v1/resources/{id}` |
| ListResources | `GET /v1/resources` |
| UpdateResource | `PATCH /v1/resources/{id}` |
| DeleteResource | `DELETE /v1/resources/{id}` |
## Calling and auth
```bash
curl localhost:8080/v1/resources/x \
-H 'Authorization: Bearer '
```
Without a token the response is 401. The gateway shares the interceptor chain with gRPC and MCP, and error bodies come from the unified error model — internal causes are not included.
## Request body limits
The gateway caps request bodies at 1 MB with a 30-second read timeout: oversized bodies get 413, slow reads get 408.
## CORS and OpenAPI
- `server.enable_cors: true` is only the master switch; the actual allowlist is `server.cors_origins`. Origins not listed there receive no CORS headers at all — enabling `enable_cors` without setting origins still blocks browsers. Use `"*"` to allow all, or list explicit origins (`GORTEXA_SERVER__CORS_ORIGINS` accepts a comma-separated override). Preflight OPTIONS requests are short-circuited.
- With `server.openapi: true`, the OpenAPI spec is served at `/openapi.json`. The spec is a build artifact of `make gen`, read once at startup and served from memory.
## Response headers
Response headers pass through an allowlist: internal headers do not leak, and `X-Request-Id` is preserved for correlation.
# MCP
The MCP (Model Context Protocol) bridge exposes proto methods annotated with `gortexa.ai.v1` as tools. An AI agent sends JSON-RPC to `/mcp`, and the bridge calls the original service over an in-process gRPC loopback — so AI calls pass through the same interceptor chain as everything else, auth and validation included.
## Annotating methods
Add the `gortexa.ai.v1.ai_tool` annotation to an rpc:
```protobuf
rpc GetResource(GetResourceRequest) returns (Resource) {
option (google.api.http) = {get: "/v1/resources/{id}"};
option (gortexa.ai.v1.ai_tool) = {
expose: true
name: "get_resource"
description: "Fetch a single resource by id and return its current state."
read_only: true
};
}
```
`read_only` and `destructive` feed the behaviour hints in the tool schema. Methods without `expose: true` do not appear in the tools list.
## Endpoint
- Path: `/mcp` (Streamable HTTP)
- Protocol versions: `2025-03-26` (default) and `2024-11-05`
- Request bodies capped at 1 MB, read timeout 30 seconds
- For browser-based clients, enable `server.enable_cors` and list the allowed origins in `server.cors_origins` — that allowlist is the DNS-rebinding guard; unlisted origins get no CORS headers
## tools/list
```bash
curl -XPOST localhost:8080/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
Returns the tool schema for every annotated method. The list is memoized (about 285 ns per call), not recomputed on every request.
## tools/call
Arguments to `tools/call` are serialized into a protobuf message and dispatched to the original service over the loopback; the result is converted back to JSON-RPC. A call that fails auth gets an unauthenticated error envelope, consistent with gRPC and HTTP behaviour.
## Schema export
The same `gortexa.ai.v1` annotations can be exported offline in provider-ready formats:
```bash
gortexa export --format=mcp
gortexa export --format=openai # OpenAI strict function calling
gortexa export --format=gemini
```
All three outputs are locked by golden tests: when the proto changes, the schemas change, and unexpected drift fails the tests.
# Components
Every framework package is importable directly after `go get`. Fetch the module:
```bash
go get github.com/yshengliao/gortexa@v0.27.0
```
This section lists the public components by category — each with its purpose, import path and a minimal usage snippet. All snippets are compile-verified against v0.27.0.
- [Core](/en/components/core/): `kernel`, `config`, `interceptor`, `auth`, `apperr` — assembly, config, the interceptor chain, JWT and the error model.
- [HTTP & MCP](/en/components/http-mcp/): `httpcompat`, `mcp` — the grpc-gateway HTTP/JSON layer and the MCP bridge.
- [Batteries](/en/components/batteries/): `mq`, `cache`, `storage`, `client` — messaging, caching, the database pool and outbound clients.
- [Observability & health](/en/components/observability/): `health`, `observability` — three-state health and OTLP logs/traces/metrics.
For a bare minimal app (no gateway or MCP), see [Use as a module](/en/quickstart/#use-as-a-module-no-scaffold).
Note: a project created by `gortexa create` already contains the framework source, and must never also `go get` the framework. This section is for using the framework as a plain library, without the scaffold.
## Full wiring
`cmd/server/main.go` in the framework repo is the canonical example of assembling every component into a service, in this order:
1. `config.MustBuild(...)` — fail-loud config load.
2. `observability.SetupLogs / SetupTracing / SetupMetrics` — each returns a `ShutdownFunc`; `observability.NewGovernanceMetrics()`.
3. `auth.NewVerifier(...)`.
4. `interceptor.NewSet(interceptor.Config{...})`.
5. `kernel.New(WithConfig, WithLogger, WithInterceptors, WithStatsHandler(observability.ServerStatsHandler()), WithHTTPWrap(...), WithShutdownHook×3)`.
6. register services on `app.GRPCServer()`; `app.Health().Register(...)`.
7. `conn := app.Loopback()` → `httpcompat.NewServeMux` → `app.SetGateway(...)`.
8. `mcp.ServiceDescriptors(...)` → `mcp.NewBridge(conn, descs, apperr.Default, cfg.Observ)` → `app.SetMCPHandler(bridge.Handler())`.
9. `app.Run(ctx)`.
`app.Loopback()` must be called after `kernel.New` and before `Run`; both the gateway and the MCP bridge dial it, so they share the one interceptor chain.
## testutil — test helpers (test-only)
In-process test helpers: a bufconn gRPC server wired with the full interceptor chain, golden-file comparison, and goroutine-leak assertions.
```go
import "github.com/yshengliao/gortexa/testutil"
conn := testutil.NewTestServer(t, func(s *grpc.Server) {
pb.RegisterThingServiceServer(s, myService)
}, testutil.WithAuthSecret(secret))
// testutil.Golden(t, name, got) compares against testdata/.golden
// set GORTEXA_UPDATE_GOLDEN to rewrite golden files (there is no -update flag)
```
# Core
Fetch the module first: `go get github.com/yshengliao/gortexa@v0.27.0`. Every snippet below is compile-verified against v0.27.0.
## kernel
The composition root: `App` owns the single h2c listener (multiplexing gRPC, HTTP/JSON and MCP), the gRPC server, the health registry, an in-process loopback, and graceful shutdown. Build it with `kernel.New(opts...)`; options include `WithConfig`, `WithLogger`, `WithInterceptors` (takes an `interceptor.Set` by value, not a pointer), `WithStatsHandler`, `WithHTTPWrap`, `WithGateway`, `WithMCPHandler`, `WithShutdownHook`, `WithServerOptions` (append gRPC ServerOptions to add your own interceptors/stats, chained after the framework's), `WithAdminListener` (opt-in separate ops port serving only /healthz + /readyz), `WithExtraListener` (serve any handler on a caller-owned listener). The main h2c port stays single-port-multiplexed; `app.AdminAddr()` reports the bound admin address.
```go
import (
"context"
"github.com/yshengliao/gortexa/config"
"github.com/yshengliao/gortexa/health"
"github.com/yshengliao/gortexa/kernel"
)
cfg := config.MustBuild()
app, err := kernel.New(kernel.WithConfig(cfg))
if err != nil {
return err
}
// register your gRPC services on app.GRPCServer() here
app.Health().Register("self", func(context.Context) health.State { return health.Healthy })
return app.Run(ctx) // /healthz and /readyz are built in
```
`App` methods: `GRPCServer()`, `Health()`, `Loopback()`, `Logger()`, `SetGateway()`, `SetMCPHandler()`, `Run(ctx)`, `Shutdown(ctx)`.
## config
Fixed precedence: built-in defaults < YAML file < .env file < environment (`GORTEXA_` prefix, `__` for nesting). The entry points are `MustBuild` (fail-loud: it panics when jwt_secret is empty, the dev placeholder, or < 32 bytes, and when issuer is blank) and `BuildUnvalidated`. There is no `Load`.
```go
import "github.com/yshengliao/gortexa/config"
cfg := config.MustBuild(config.WithConfigFile("etc/config.yaml"))
dsn := cfg.DB.DSN.Reveal() // a Secret is revealed only at the point of use
```
The `config.Secret` type masks itself to `****` in `String()`, logs and JSON; call `Reveal()` for the real value. `Config`'s sub-structs: `Server`, `Auth`, `DB`, `Cache`, `MQ`, `Log`, `Observ` (all fields documented in [Configuration](/en/configuration/)).
## interceptor
`NewSet(Config)` builds the eight-stage fixed-order chain (recover → request-id → logger → load-shed → rate-limit → circuit-breaker → auth → validation), which you pass to `kernel.WithInterceptors`.
```go
import (
"time"
"github.com/yshengliao/gortexa/auth"
"github.com/yshengliao/gortexa/interceptor"
)
set, err := interceptor.NewSet(interceptor.Config{
Verifier: auth.MustNewVerifier([]byte(secret), "gortexa"),
RateLimit: interceptor.RateLimitConfig{RPS: 200, Burst: 100, TTL: 10 * time.Minute},
})
// pass set to kernel.WithInterceptors(set)
```
`Config` also carries `Logger`, `AuthSkip`, `CircuitBreaker`, `LoadShedding`, and `Metrics`. `Set` exposes `UnaryChain()` / `StreamChain()`.
The auth stage is pluggable: `Verifier` uses the built-in HS256 JWT; a non-JWT scheme sets `Authenticator` instead (an `auth.Authenticator`, `Authenticate(ctx) (ctx, error)`) — static bearer, mTLS, API key. `Authenticator` wins when both are set. JWT is also available explicitly via `auth.NewJWTAuthenticator(v)`.
## auth
HS256 JWT verification/signing plus context helpers. The secret must be ≥ 32 bytes.
```go
import "github.com/yshengliao/gortexa/auth"
v := auth.MustNewVerifier([]byte(secret), "gortexa")
tok, err := v.Sign("user-123", []string{"admin"}, time.Hour)
// later, inside a handler, retrieve the verified claims:
if claims, ok := auth.ClaimsFrom(ctx); ok {
_ = claims.Subject // "user-123"
}
```
`Verify(token)` returns `*Claims` (with `Roles` plus embedded `jwt.RegisteredClaims`); `BearerToken(header)` extracts a token from the Authorization header.
## apperr
A single mapping table drives the gRPC status, HTTP status and MCP error envelope. `*apperr.Error` implements `GRPCStatus()`, so a handler just returns it.
```go
import "github.com/yshengliao/gortexa/apperr"
func (s *svc) GetThing(ctx context.Context, req *pb.GetThingRequest) (*pb.Thing, error) {
t, err := s.repo.Find(ctx, req.GetId())
if err != nil {
return nil, apperr.Wrap(apperr.CatNotFound, "thing not found", err)
}
return t, nil
}
```
Only `CatInvalidArgument` and `CatUnauthenticated` forward the developer message to the caller; every other category exposes only the registry SafeMessage, so internal causes never leak. The category constants cover the full gRPC set (`CatNotFound`, `CatPermissionDenied`, `CatFailedPrecondition`, `CatInternal`, and so on).
# HTTP & MCP
Fetch the module first: `go get github.com/yshengliao/gortexa@v0.27.0`. Every snippet below is compile-verified against v0.27.0.
## httpcompat
Builds a ServeMux whose error handler funnels through `apperr`, with a protojson marshaler and Authorization-header forwarding.
```go
import (
"github.com/yshengliao/gortexa/apperr"
"github.com/yshengliao/gortexa/httpcompat"
)
gateway := httpcompat.NewServeMux(apperr.Default)
// register grpc-gateway handlers against gateway + the loopback conn
handler := httpcompat.MaxBodyBytes(gateway) // caps the body at 1 MiB
handler = httpcompat.CORS(handler, cfg.Server)
```
`OpenAPIRoute(next, cfg, specPath)` mounts an OpenAPI spec.
## mcp
Exposes RPCs annotated with `gortexa.ai.v1` as MCP tools, dispatched over the loopback through the same interceptor chain.
```go
import (
"github.com/yshengliao/gortexa/apperr"
"github.com/yshengliao/gortexa/mcp"
)
descs, err := mcp.ServiceDescriptors("resource.v1.ResourceService")
if err != nil {
return err
}
bridge, err := mcp.NewBridge(conn, descs, apperr.Default, cfg.Observ)
if err != nil {
return err
}
bridge.SetAllowedOrigins(cfg.Server.CORSOrigins)
app.SetMCPHandler(bridge.Handler())
```
Export tool schemas offline with `mcp.ExportSchemas(format, descs)`, where format is `mcp` / `openai` / `gemini`.
# Batteries
Fetch the module first: `go get github.com/yshengliao/gortexa@v0.27.0`. Every snippet below is compile-verified against v0.27.0.
## mq
NATS-backed pluggable pub/sub. `cfg.MQ.Driver` selects `nats` (core, at-most-once) or `jetstream` (durable, at-least-once, handlers must be idempotent). An empty `group_id` fans out; a non-empty one load-balances.
```go
import "github.com/yshengliao/gortexa/mq"
pub, sub, err := mq.New(cfg.MQ)
if err != nil {
return err
}
err = sub.Subscribe(ctx, "orders", func(ctx context.Context, m mq.Message) error {
return handle(m.Value)
})
err = pub.Publish(ctx, "orders", mq.Message{Value: []byte("hello")})
```
`Message` carries `Key` / `Value` / `Headers`. You can also call `mq.NewNATS` / `mq.NewJetStream` to pick a driver directly.
## cache
Process-local in-memory by default; `cfg.Cache.Driver: redis` opts into a distributed backend.
```go
import (
"errors"
"github.com/yshengliao/gortexa/cache"
)
c, err := cache.New(cfg.Cache)
if err != nil {
return err
}
defer c.Close()
if err := c.Set(ctx, "k", []byte("v"), time.Minute); err != nil {
return err
}
b, err := c.Get(ctx, "k")
if errors.Is(err, cache.ErrCacheMiss) {
// key absent
}
```
## storage
Builds a PgBouncer-safe pgx connection pool plus a query tracer that emits OTel DB spans. It returns a `*pgxpool.Pool`.
```go
import (
"go.opentelemetry.io/otel"
"github.com/yshengliao/gortexa/storage"
)
tracer := storage.NewDBTracer(otel.GetTracerProvider())
pool, err := storage.NewPool(ctx, cfg.DB, tracer) // pass nil for no spans
if err != nil {
return err
}
defer pool.Close()
```
## client
Outbound client constructors with OTel instrumentation already wired in.
```go
import "github.com/yshengliao/gortexa/client"
conn, err := client.NewGRPCConn(client.GRPCClientConfig{Target: "svc:50051", Insecure: true})
if err != nil {
return err
}
defer conn.Close()
hc := client.NewHTTPClient(client.HTTPClientConfig{Timeout: 5 * time.Second}) // zero defaults to 30s
```
`Insecure: true` is for local development only; use TLS in production.
# Observability & health
Fetch the module first: `go get github.com/yshengliao/gortexa@v0.27.0`. Every snippet below is compile-verified against v0.27.0.
## health
A three-state registry (Healthy / Degraded / Unhealthy; Degraded still counts as serving, only Unhealthy does not) plus a bridge to the standard gRPC health protocol. Normally you use the App's own registry via `app.Health()` rather than building your own.
```go
import "github.com/yshengliao/gortexa/health"
app.Health().Register("db", func(ctx context.Context) health.State {
return health.Healthy
})
overall := app.Health().Overall(ctx) // health.Healthy / Degraded / Unhealthy
```
Registered checks are aggregated by `/readyz`; `Names()`, `Snapshot(ctx)` and `State(ctx, name)` query them.
## observability
The gRPC instrumentation is a StatsHandler (not interceptors). Tracing/metrics export via OTLP when an endpoint is configured, and are no-ops otherwise. Each `Setup*` returns a `ShutdownFunc`.
```go
import "github.com/yshengliao/gortexa/observability"
log, logShutdown, err := observability.SetupLogs(ctx, cfg.Log, cfg.Observ)
traceShutdown, err := observability.SetupTracing(ctx, cfg.Observ)
metricShutdown, err := observability.SetupMetrics(ctx, cfg.Observ)
// pass observability.ServerStatsHandler() to kernel.WithStatsHandler,
// and the three ShutdownFuncs to kernel.WithShutdownHook.
```
To export to an OTLP backend that needs an auth header (Uptrace, for example), the OTel SDK reads the standard `OTEL_EXPORTER_OTLP_HEADERS` env var (e.g. `uptrace-dsn=`), or relay through a collector.
# Configuration
Configuration lives in `etc/config.yaml` by default; if the file is absent, that layer is skipped and built-in defaults plus environment variables apply. Loading is otherwise fail-loud: a mistyped field or a non-compliant secret fails startup instead of running degraded.
## How configuration loads
Configuration is three layers, each overriding the previous:
1. Built-in defaults (in code; e.g. `server.addr` is `:8080`, `cache.driver` is `memory`).
2. `etc/config.yaml` — point elsewhere with `GORTEXA_CONFIG=/path/to/config.yaml`; the layer is skipped when the file is absent.
3. Environment variables, in the form `GORTEXA___` (double underscore between section and field).
When the same field is set in more than one layer, the environment variable wins. Real secrets are injected only through environment variables — never written into `config.yaml`, never committed.
## config.yaml
```yaml
server:
addr: ":8080"
shutdown_timeout: 20s
enable_cors: true
cors_origins: [] # CORS allowlist; enable_cors is only the master switch — origins not listed here get no CORS headers
openapi: true
reflection: false # gRPC reflection off by default
auth:
jwt_secret: "dev-only-insecure-secret-change-me-please"
issuer: "gortexa"
# audience: "myapp" # optional: stamps aud into signed tokens and requires it — isolates services sharing a secret
ttl: 1h
db:
dsn: "" # PostgreSQL DSN (Secret, masked in logs); inject via env: GORTEXA_DB__DSN
max_conns: 10
cache:
driver: "memory" # "memory" (default, process-local) or "redis"
mq:
driver: "nats" # "nats" (core, at-most-once) or "jetstream" (durable, at-least-once)
url: "" # comma-separated servers, e.g. "nats://a:4222,nats://b:4222"
group_id: "" # empty = fanout; non-empty = load-balance
log:
level: "info"
format: "json"
observ:
service_name: "gortexa"
service_version: "dev"
# tracing_otlp: "localhost:4317" # OTLP trace endpoint; empty disables
# metrics_otlp: "localhost:4317" # OTLP metrics endpoint; empty disables (rate-limit/circuit-breaker metrics need it)
# logs_otlp: "localhost:4317" # OTLP log endpoint; empty disables
otlp_insecure: true # local docker compose only; never true for remote
sample_ratio: 1.0
genai_capture_content: false # when true, MCP tool-call arguments are recorded in telemetry
# genai_mask_fields: ["password", "token", "secret", "authorization", "api_key"]
```
`genai_capture_content: true` sends AI tool-call arguments into telemetry; the default mask list only covers common field names. Combined with `otlp_insecure: true`, that content leaves the host in cleartext — do not enable both in production.
## Cache: memory and redis
`cache.driver` selects the backend. The default `memory` needs no external service; `redis` is the only driver that reads `addr` and the fields below it. Any value other than `memory` or `redis` fails startup with `cache: unsupported driver`.
#### memory (default)
```yaml
cache:
driver: "memory"
```
Process-local, no external dependency. Good for single-instance deployments and local development.
#### redis
```yaml
cache:
driver: "redis"
addr: "localhost:6379"
password: "" # inject via env: GORTEXA_CACHE__PASSWORD
db: 0
# The client tunables below are all optional; omit to use the built-in default
dial_timeout: 5s # default 5s
read_timeout: 3s # default 3s
write_timeout: 3s # default 3s
pool_size: 10 # default 10; must not be negative
```
On redis, the framework's built-in zero-dependency RESP client connects — no third-party redis package.
Each tunable's zero value means "use the built-in default", so set only the one you need to change:
| Field | Default | Purpose |
| --- | --- | --- |
| `dial_timeout` | 5s | Timeout for establishing a connection |
| `read_timeout` | 3s | Timeout for reading a single reply |
| `write_timeout` | 3s | Timeout for writing a single command |
| `pool_size` | 10 | Connection pool size; `0` uses the default, negative is rejected |
Behind a load balancer, shortening the timeouts fails fast instead of stalling the whole request; a busy service can raise `pool_size`.
## Database (PostgreSQL)
`db.dsn` is the pgx connection string (a Secret field, always masked in logs); `db.max_conns` defaults to 10. Inject the real DSN via an environment variable:
```bash
GORTEXA_DB__DSN=postgres://user:pass@pgbouncer:6432/app
```
The pool is PgBouncer-safe: `QueryExecModeExec` with statement and description caches disabled, so it works behind PgBouncer in transaction-pooling mode without prepared-statement errors.
The development flow: schema goes in `db/migrations/`, SQL in `db/queries/`, and `make sqlc` generates type-safe query code into `internal/storage/db/`. `deploy/docker-compose.yaml` already ships postgres and PgBouncer for local verification.
## Environment overrides
Any field can be overridden with an environment variable, using `GORTEXA___`:
```bash
GORTEXA_SERVER__ADDR=:9090
GORTEXA_AUTH__JWT_SECRET=
GORTEXA_CACHE__DRIVER=redis
GORTEXA_CACHE__ADDR=redis.internal:6379
GORTEXA_SERVER__REFLECTION=true
```
## Secret rules
- The placeholder `dev-only-insecure-secret-change-me-please` is refused at startup — it cannot ship by accident.
- JWT secrets shorter than 32 bytes are also refused.
- Services sharing a secret and the default issuer accept each other's tokens — set a distinct `auth.audience` per service to isolate them (a token minted for A is rejected by B).
- Secret fields such as the redis password and the MQ URL (which can embed credentials) are masked in logs and error output.
- Real secrets are injected via environment variables, never committed.
## MQ semantics
`mq.group_id` selects the delivery mode, identically on both drivers:
| group_id | NATS (core) | JetStream | Semantics |
| --- | --- | --- | --- |
| Empty (default) | Subscribe | Ephemeral consumer | Fanout: every subscriber receives each message |
| Non-empty | QueueSubscribe | Durable consumer (named after group_id, acked only after the handler succeeds) | Load-balance: one handler per message |
The drivers differ in the delivery guarantee: core NATS is at-most-once (no redelivery — a message whose handler fails is gone), while `driver: "jetstream"` is at-least-once — publish blocks on the server's storage ack and a handler error triggers a delayed redelivery, so handlers must be idempotent. JetStream needs the server started with `-js`; the framework lazily creates a stream per topic (24h retention), and an operator may pre-create a stream with different retention — the framework adopts it and never rewrites an existing stream.
## Diagnostics
```bash
gortexa doctor # check the Go toolchain and proto tools
```
# Deployment
The framework repo ships reference material under `deploy/`: a multi-stage distroless Dockerfile (proto regenerated inside the image with pinned tools) and a docker-compose with otelcol, Prometheus, Grafana and PgBouncer configuration. The three items below are deliberately left to the operator — the framework does not do them for you.
**Wire these before going live**
TLS termination, authorization and readiness dependency checks are not done for you. Skip any one and a service facing the public internet silently loses transport security or authorization — no error, no warning.
## Scaffold secret preflight
A new scaffold carries local development configuration. Before the first git add, Docker build, or CI handoff, verify that every secret is externalized. **Do not leave a usable JWT signing secret in version control or an image layer.**
- Supply real secrets through a secret manager, mounted file, or runtime environment using GORTEXA_AUTH__JWT_SECRET.
- Keep only an unusable placeholder or a secret-free example configuration in the repository; ignore local overrides.
- Make sure both the Docker build context and runtime image exclude local secret configuration, and verify the image starts only with runtime injection.
- Plan rotation, issuer, and audience; services sharing a secret should use different audiences.
This is a deployment gate, not optional later work. Likewise, the scaffold's in-memory resource is only a demo; establish real storage, cache, and MQ lifecycle before treating readiness as meaningful.
## Terminate TLS in front
The service port is cleartext h2c; the server does no TLS itself. Bearer tokens crossing this port are unencrypted. Put the service behind a reverse proxy, service mesh or load balancer that terminates TLS. Do not expose the h2c port to the public internet.
## Wire readiness to real dependencies
`/readyz` only reflects that the process is alive until you register real checks with `app.Health().Register(...)`:
```go
app.Health().Register("postgres", func(ctx context.Context) health.State {
if err := pool.Ping(ctx); err != nil {
return health.Unhealthy
}
return health.Healthy
})
```
Health probes can target a private ops port, separate from the public one: `kernel.WithAdminListener(addr)` serves only `/healthz` and `/readyz` on a separate plain-HTTP port, so an orchestrator can probe health without reaching the public h2c port (`app.AdminAddr()` reports the bound address). To expose pprof or a custom ops handler on a separate port, use `kernel.WithExtraListener(lis, h)`. Both are opt-in; the main h2c port's single-port multiplexing is unchanged.
Each check runs under a 5-second timeout, so a single slow dependency cannot stall the query.
## Authorization is the application's job
The framework does authentication only: it verifies the JWT signature and puts the claims into the context. Who may do what (roles, permission policy) is decided by the application, in handlers or its own interceptor:
```go
claims, ok := auth.ClaimsFrom(ctx)
if !ok || !slices.Contains(claims.Roles, "admin") {
// reject
}
```
## Graceful shutdown
On SIGTERM the server drains in-flight requests within `server.shutdown_timeout` (default 20s) before exiting. Interruption under authenticated load is verified by [example 05](/en/examples/resilience/).
## Production flags
- Keep `server.reflection` at `false`: do not expose the service structure.
- `observ.otlp_insecure` must be `false` for any remote collector.
- Real secrets use runtime injection and must not exist in Git or an image layer — see [Configuration](/en/configuration/).
# Examples overview
These examples are the framework's verification matrix: 11 independent scenarios, each proving one concrete behaviour with a `run.sh`. The sub-pages below list the key checks and copyable snippets, so you can verify the same properties in your own project.
## Shared setup
- **Test token**: scenarios that pass auth need a JWT. Sign one with your project's `auth` — see [Getting a dev token](/en/quickstart/#getting-a-dev-token). The example set also ships a `tools/mint` HS256 minter, resolving the secret from `-secret` → `GORTEXA_AUTH__JWT_SECRET` → `etc/config.yaml`.
- **Ports**: server scenarios run from `:18080` up (each uses a distinct port to avoid clashes), polling `/readyz` before probing.
- **Cleanup**: each scenario tears down its server via `trap cleanup EXIT` or `docker compose stop`; all are re-runnable.
- **Dependencies**: only the two NATS-family scenarios (core and JetStream) need docker; the rest are pure local binaries.
## Sub-pages
- [Developer flow](/en/examples/workflow/): the full scaffold loop, one entity across three protocols.
- [Security & boundaries](/en/examples/security/): auth rejections leak nothing, secret startup safety, the header allowlist, the reflection gate.
- [Contract & resilience](/en/examples/resilience/): the buf breaking gate, graceful drain on SIGTERM.
- [AI & messaging](/en/examples/messaging/): export tool schemas, NATS/JetStream integration.
- [Full example](/en/examples/full-example/): after `go get`, wire the public packages into a service with a complete, annotated `main.go`.
## Suggested path
1. [Developer flow](/en/examples/workflow/) — walk the full development loop once.
2. See one proto become three protocols.
3. [Security & boundaries](/en/examples/security/) — the error model and interceptor chain under adversarial input.
# Developer flow
## 01 scaffold-smoke — the full developer loop
Proves create → gen → build → test runs end to end and still holds after adding a second API. This is a scaffold transport smoke test; checking that create replaces the placeholder does **not** authorize committing or baking the resulting usable secret into an image.
```bash
gortexa create myapp --module github.com/me/myapp
cd myapp
make gen # buf lint → breaking → generate
go build ./... && go test ./...
gortexa gen shop/v1 Product # add a second API
make gen && go build ./... && go test ./...
```
Key assertions: `proto/shop/v1/product.proto` and `internal/logic/product.go` are generated, `cmd/server/main.go` is wired for product, and finding `dev-only-insecure-secret-change-me-please` in `etc/config.yaml` fails the run. That only proves local startup; before Git or Docker, move to external secret injection and replace the in-memory logic with a real data layer.
## 02 multi-protocol-e2e — one entity across three protocols
On a single h2c port (`:18080`), an entity created over gRPC is fetchable over HTTP, and one created over MCP shows up in the list — all three share one store. `$TOK` is a dev token, `$SCHEMA` is the descriptor from `buf build`.
Create over gRPC:
```bash
buf curl --protocol grpc --http2-prior-knowledge --schema $SCHEMA \
-H "authorization: Bearer $TOK" \
-d '{"resource":{"name":"grpc-entity","owner":"owner-e2e"}}' \
http://localhost:18080/resource.v1.ResourceService/CreateResource
```
Fetch the same id over the HTTP gateway, then create a third over MCP `tools/call`:
```bash
curl -H "authorization: Bearer $TOK" http://localhost:18080/v1/resources/$RID # 200, same entity
curl -XPOST http://localhost:18080/mcp -H 'Content-Type: application/json' \
-H "authorization: Bearer $TOK" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"create_resource",
"arguments":{"resource":{"name":"mcp-entity","owner":"owner-e2e"}}}}'
```
Listing `owner-e2e` at the end shows the entities created over gRPC, HTTP and MCP together — proof the three surfaces sit on one store.
# Security & boundaries
## 03 security-probe — rejection paths leak nothing
Adversarial input across all three protocols, asserting no error body carries internal detail.
```bash
# missing/bad/expired token → 401 UNAUTHENTICATED
curl -H "authorization: Bearer xxx" http://localhost:18080/v1/resources # 401
# evil origin → response carries no Access-Control-Allow-Origin
curl -D - -H "Origin: https://evil.example" http://localhost:18080/v1/resources
# >1MB body to /mcp → 413, clean
curl -XPOST http://localhost:18080/mcp -H 'Content-Type: application/json' \
--data-binary @big.json # 413
# bad version / unknown tool / huge id / not-JSON → structured JSON-RPC error, no panic
curl -XPOST http://localhost:18080/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"no_such_tool","arguments":{}}}'
```
A final scan over every rejection body asserts no stack traces, internal paths, or unmasked causes.
## 06 config-matrix — JWT secret startup safety
A placeholder or too-short secret is refused at startup (fail-loud), and the error never echoes the secret itself.
```bash
GORTEXA_AUTH__JWT_SECRET="dev-only-insecure-secret-change-me-please" ./server # exit 1, no port bound
GORTEXA_AUTH__JWT_SECRET='tooshort' ./server # exit 1
GORTEXA_AUTH__JWT_SECRET="<45-byte real secret>" ./server # starts; env overrides file secret
```
Rejection happens before listen, so (a) and (b) never bind a port.
## 08 header-leak-probe — response header allowlist
Response headers stay within a fixed allowlist: no `Grpc-Metadata-*` passthrough, while `X-Request-Id` must survive (the allowlist must not overshoot).
```bash
curl -D - -H "authorization: Bearer $TOK" http://localhost:18082/v1/resources/x
# asserts: no grpc-metadata-* headers, nothing outside the allowlist, X-Request-Id present
```
## 09 reflection-probe — reflection off by default, config-gated
gRPC reflection is off by default; the same call succeeds only once it is enabled via env.
```bash
buf curl --protocol grpc --http2-prior-knowledge --reflect ... # default: fails (service not found)
GORTEXA_SERVER__REFLECTION=true ./server
buf curl --protocol grpc --http2-prior-knowledge --reflect ... # enabled: succeeds
```
Keep it off in production — see the production flags in [Deployment](/en/deployment/).
# Contract & resilience
## 04 breaking-gate — the contract-breaking gate
Proves `gortexa regen`'s buf breaking gate blocks compatibility-breaking proto edits and allows additive ones. This scenario also exercises `regen`'s `isGitToplevel()` path (a macOS `/tmp` → `/private/tmp` symlink once made the gate skip silently; fixed).
```bash
gortexa create bg --module github.com/me/bg --repo "$REPO" --ref main
cd bg && make gen # first gen: no main ref yet, gate skips (expected)
git add -A && git commit -m base && git branch -M main
# delete Resource's owner field (tag 3) — a breaking edit
gortexa regen # non-zero exit: gate BLOCKS the breaking edit
```
An additive edit (a new field) passes `regen` cleanly. The gate reads proto from the git `main` ref, not from `gen/`.
## 05 resilience-shutdown — graceful drain on SIGTERM
Authenticated mixed load is interrupted mid-flight with SIGTERM; the server must drain in-flight requests within `shutdown_timeout`, exit 0, not panic, and recover on restart.
```bash
./server & # a real binary, so SIGTERM lands on the server (not a go run wrapper)
./loadgen ... # authenticated load
kill -TERM $SRV # SIGTERM while load is in flight
wait $SRV # exit 0, drain complete, drain time <= shutdown_timeout
```
A real binary (not `go run`) is used so the exit code is unambiguous — `go run` wraps the server in an extra process.
# AI & messaging
## 10 export-cli — AI tool schema export
`gortexa export` emits valid JSON schemas in three provider formats for every `gortexa.ai.v1`-annotated method, without a running server.
```bash
for fmt in mcp openai gemini; do
gortexa export --format "$fmt" # each emits valid JSON containing the resource tools
done
gortexa export --format nope # unknown format: non-zero exit, clean failure
```
All three outputs are locked by golden tests: change the proto and the schemas change, so drift is caught.
## 11 nats-integration — against a real NATS broker
Starts a real NATS broker from the scenario's own `docker-compose.yaml` and runs the mq core-NATS integration tests. With `NATS_URL` set, the tests connect to the container's broker rather than an in-process embedded server, and a connection failure fails — a skip counts as failure too.
```bash
docker compose -f docker-compose.yaml up -d nats
NATS_URL=nats://127.0.0.1:4222 go test -tags integration -race -count=1 \
-run 'TestNATS' ./mq/
```
Asserts `TestNATSPubSub` and `TestNATSQueueGroupLoadBalance` PASS — the latter verifies the load-balance semantics of a non-empty `group_id` (see [Configuration](/en/configuration/#mq-semantics)).
## 12 jetstream-integration — against a real JetStream broker
Same structure as 11 with the JetStream driver: the broker starts with `-js` and the tests use the `JETSTREAM_URL` env var. Beyond the fanout and load-balance semantics shared with core NATS, it verifies the JetStream-specific at-least-once property — a handler error triggers a delayed redelivery.
```bash
docker compose -f docker-compose.yaml up -d nats # nats:2 with command: ["-js"]
JETSTREAM_URL=nats://127.0.0.1:4222 go test -tags integration -race -count=1 \
-run 'TestJetStream' ./mq/
```
Asserts `TestJetStreamPubSub`, `TestJetStreamQueueGroupLoadBalance` and `TestJetStreamRedeliveryOnHandlerError` PASS — the last one pins the at-least-once redelivery (which is why handlers must be idempotent, see [Configuration](/en/configuration/#mq-semantics)).
# Full example: assembling a service as a module
Wire the public packages into one runnable service — after `go get`, no
scaffold. For each package's usage see [Components](/en/components/). Fetch the
module:
```bash
go get github.com/yshengliao/gortexa@v0.27.0
```
How each component is enabled (all driven by config):
- **config**: `MustBuild` layers `etc/config.yaml` and the environment (`GORTEXA_` prefix).
- **observability**: exports OTLP only when `observ.tracing_otlp` / `metrics_otlp` / `logs_otlp` is set; a no-op otherwise.
- **auth**: set `auth.jwt_secret` (≥ 32 bytes) and `auth.issuer`.
- **interceptor**: `NewSet` builds the fixed eight-stage chain.
- **health**: `app.Health().Register` adds a check that `/readyz` aggregates.
- **gateway (HTTP/JSON)**: `httpcompat.NewServeMux` plus your gateway handler.
- **mcp**: `mcp.NewBridge` turns `gortexa.ai.v1`-annotated RPCs into MCP tools.
- **mq / cache / storage / client**: once `mq.url` / `cache.driver: redis` / `db.dsn` are set, use `mq.New` / `cache.New` / `storage.NewPool` / `client.NewGRPCConn` inside your handlers.
The complete `main.go` (your own generated service is a commented placeholder; the framework wiring in this snippet is compile-verified against v0.27.0):
```go
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"google.golang.org/protobuf/reflect/protoreflect"
"github.com/yshengliao/gortexa/apperr"
"github.com/yshengliao/gortexa/auth"
"github.com/yshengliao/gortexa/config"
"github.com/yshengliao/gortexa/health"
"github.com/yshengliao/gortexa/httpcompat"
"github.com/yshengliao/gortexa/interceptor"
"github.com/yshengliao/gortexa/kernel"
"github.com/yshengliao/gortexa/mcp"
"github.com/yshengliao/gortexa/observability"
// billingpb "github.com/me/myapp/gen/billing/v1" // your own generated service
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "fatal:", err)
os.Exit(1)
}
}
func run() error {
// 1. Config — layered: defaults < YAML < .env < env (GORTEXA_ prefix).
// MustBuild fails loud on a missing/short jwt_secret or blank issuer.
cfg := config.MustBuild(config.WithConfigFile("etc/config.yaml"))
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// 2. Observability — logs/traces/metrics. Each exports over OTLP only when
// its observ.*_otlp endpoint is set, and is a no-op otherwise. Each
// returns a ShutdownFunc that flushes buffered telemetry on exit.
log, logShutdown, err := observability.SetupLogs(ctx, cfg.Log, cfg.Observ)
if err != nil {
return fmt.Errorf("setup logs: %w", err)
}
traceShutdown, err := observability.SetupTracing(ctx, cfg.Observ)
if err != nil {
return fmt.Errorf("setup tracing: %w", err)
}
metricShutdown, err := observability.SetupMetrics(ctx, cfg.Observ)
if err != nil {
return fmt.Errorf("setup metrics: %w", err)
}
govMetrics, err := observability.NewGovernanceMetrics()
if err != nil {
return fmt.Errorf("governance metrics: %w", err)
}
// 3. Auth — HS256 verifier from the configured secret / issuer / audience.
verifier, err := auth.NewVerifier([]byte(cfg.Auth.JWTSecret.Reveal()), cfg.Auth.Issuer, cfg.Auth.Audience)
if err != nil {
return fmt.Errorf("auth verifier: %w", err)
}
// 4. Interceptor chain — recover → request-id → logger → load-shed →
// rate-limit → circuit-breaker → auth → validation. Health probes carry
// no token, so skip auth for them.
set, err := interceptor.NewSet(interceptor.Config{
Logger: log,
Verifier: verifier,
Metrics: govMetrics,
AuthSkip: func(method string) bool { return strings.HasPrefix(method, "/grpc.health.") },
RateLimit: interceptor.RateLimitConfig{RPS: 200, Burst: 100, TTL: 10 * time.Minute},
CircuitBreaker: interceptor.CBConfig{MaxFailures: 5, OpenInterval: 10 * time.Second, HalfOpenMax: 2},
LoadShedding: interceptor.LoadSheddingConfig{MaxInflight: 1024},
})
if err != nil {
return fmt.Errorf("interceptors: %w", err)
}
// 5. Kernel — the App owns the single h2c port. StatsHandler carries OTel
// (not interceptors); HTTPWrap adds CORS around the HTTP surface;
// shutdown hooks flush the three telemetry providers on exit.
app, err := kernel.New(
kernel.WithConfig(cfg),
kernel.WithLogger(log),
kernel.WithInterceptors(set),
kernel.WithStatsHandler(observability.ServerStatsHandler()),
kernel.WithHTTPWrap(func(h http.Handler) http.Handler { return httpcompat.CORS(h, cfg.Server) }),
kernel.WithShutdownHook(traceShutdown),
kernel.WithShutdownHook(metricShutdown),
kernel.WithShutdownHook(logShutdown),
)
if err != nil {
return fmt.Errorf("build app: %w", err)
}
// 6. Register your generated gRPC service, plus a health check that /readyz
// aggregates. StartMetricsExport publishes component health as an OTel gauge.
// billingpb.RegisterInvoiceServiceServer(app.GRPCServer(), billing.NewInvoiceService())
app.Health().Register("self", func(context.Context) health.State { return health.Healthy })
app.Health().StartMetricsExport(ctx, govMetrics, 0)
// 7. Loopback — the gateway and MCP bridge dial the in-process server, so
// HTTP/JSON and MCP calls go through the same interceptor chain as gRPC.
conn, err := app.Loopback()
if err != nil {
return fmt.Errorf("loopback: %w", err)
}
// 8. HTTP/JSON via grpc-gateway. Register your service's gateway handler,
// then cap the body at 1 MiB.
gateway := httpcompat.NewServeMux(apperr.Default)
// _ = billingpb.RegisterInvoiceServiceHandler(ctx, gateway, conn)
app.SetGateway(httpcompat.MaxBodyBytes(gateway))
// 9. MCP bridge — exposes every gortexa.ai.v1-annotated RPC in the listed
// services as an MCP tool. SetAllowedOrigins is the DNS-rebinding guard.
descs, err := mcp.ServiceDescriptors(mcpServices()...)
if err != nil {
return fmt.Errorf("mcp descriptors: %w", err)
}
bridge, err := mcp.NewBridge(conn, descs, apperr.Default, cfg.Observ)
if err != nil {
return fmt.Errorf("mcp bridge: %w", err)
}
bridge.SetAllowedOrigins(cfg.Server.CORSOrigins)
app.SetMCPHandler(bridge.Handler())
// 10. Run until SIGINT/SIGTERM; graceful shutdown drains in-flight work and
// runs the shutdown hooks. Optional batteries — mq.New(cfg.MQ),
// cache.New(cfg.Cache), storage.NewPool(ctx, cfg.DB, tracer),
// client.NewGRPCConn(...) — are wired here as your handlers need them.
log.Info("starting", "addr", cfg.Server.Addr)
return app.Run(ctx)
}
// mcpServices lists the services exposed over the MCP bridge (and schema export).
func mcpServices() []protoreflect.FullName {
return []protoreflect.FullName{
"billing.v1.InvoiceService",
}
}
```