ANAR Web Framework - Complete Documentation#
Author: Ali Miracle Email: alimiracle@riseup.net
Introduction#
ANAR is a Go web framework built on top of GORM and Gin that provides rapid application development through code generation and scaffolding. It follows the convention-over-configuration principle and offers a CLI tool for managing your web application lifecycle.
An Anar project has two parts:
- The
anarCLI, which creates projects and generates code. anar-core, the application template thatanar newcopies into your project. Each CLI release pins a matching core version.
Meaning of the Name#
The name ANAR carries rich symbolic meaning from multiple linguistic and mythological origins. In Persian, Hindi, and Turkish, Anar literally means "pomegranate", a fruit that symbolizes fertility, abundance, and growth. This reflects the framework's philosophy: just like a pomegranate contains many seeds that can grow into new life, ANAR provides a structured starting point that allows developers to cultivate complete, modular, and scalable applications efficiently.
In Tolkien's Quenya language, Anar means Sun, representing light, energy, and illumination. This aligns with the framework's mission to illuminate the development process, simplify complex tasks, and empower developers to build web applications with clarity and efficiency.
Together, these meanings emphasize growth, vitality, and enlightenment, capturing ANAR's essence as a framework that nurtures development while bringing clarity and structure to every project.
Key Features#
- Rapid Development: Generate models, handlers, migrations and routes with simple commands
- GORM Integration: Full ORM support with automatic model parsing
- Gin Framework: High-performance HTTP web framework
- Live Reload: Built-in air integration for development
- Relationships: belongsTo, hasOne, hasMany and manyToMany
- Pagination & Search: Built-in pagination, search, filtering and sorting
- RESTful APIs: Automatic CRUD handler and route generation
- Migration System: Database schema management
- JWT authentication with rotating refresh tokens
- Two-factor authentication (TOTP) with recovery codes
- Permissions System: Built-in role-based access control (RBAC) to manage user and group permissions
- Integrated caching layer powered by AnarCache (memory, Redis or Memcached)
- Background jobs on a queue powered by AnarQueue (memory or Redis)
- SMTP email support
- S3-compatible storage support
- OAuth / OpenID Connect login, including Google, Apple, GitHub and Facebook
- Modular, extensible, and configurable architecture
Architecture#
The anar CLI is made of:
- CLI Handler: Command processing and routing
- Generators: Code generation for models, handlers, migrations, routes and jobs
- Model Parser: Automatic model detection and parsing
- Project Management: Build, test and run utilities
- Configuration: Centralized configuration management
A generated project has this layout:
| Directory | Contents |
|---|---|
app/ |
The App container: config, logger, database, cache, queue, OAuth |
config/ |
Configuration loaded from environment variables |
db/ |
The Database wrapper around GORM, CRUD and permission helpers |
handlers/ |
HTTP handlers, including generated CRUD handlers |
jobs/ |
Background job handlers and their registry |
middleware/ |
JWT and permission middleware |
migrations/ |
Generated migrations |
models/ |
Your models (models.go) and the built-in ones |
routes/ |
Hand-written and generated routes |
Installation and Setup#
Prerequisites#
- Go 1.25 or higher
- Git (
anar newclones the project template) - A database: PostgreSQL, MySQL, SQLite or SQL Server
Installation#
- Install ANAR:
go install codeberg.org/anarproject/anar@latest
- Create a new project:
anar new github.com/yourusername/yourproject
- Navigate to your project directory. It is named after the last part of the module path:
cd yourproject
The First Admin Account#
When the application starts with an empty users table and ADMIN_PASSWORD is set, it creates an admin account named ADMIN_NAME (default admin) with that password. If ADMIN_PASSWORD is not set, no admin is created and a warning is logged. Once any user exists, these settings are ignored.
ADMIN_NAME=admin
ADMIN_PASSWORD=a-long-random-password
Projects created before v0.10.0 created admin / admin instead. If yours did, change that password.
Environment Variables Configuration#
Environment variables are a critical part of configuring ANAR projects. They allow you to customize the framework behavior, database connections, authentication mechanisms, caching, logging, and other runtime settings without changing the project code. Proper configuration ensures that your project is secure, scalable, and production-ready.
Environment Types#
ANAR loads configuration based on the environment type defined by the APP_ENV variable.
This allows you to maintain separate environment files such as .env.development, .env.production, or .env.test, so each environment (local development, production server, or CI pipeline) uses its own configuration.
| Variable | Default | Description |
|---|---|---|
APP_ENV |
development |
Defines which environment configuration to load (development, production, test). |
When the application starts, it checks this variable and loads the corresponding environment file:
APP_ENV Value |
Loaded File |
|---|---|
development |
.env.development |
production |
.env.production |
test |
.env.test |
If that file doesn't exist, ANAR falls back to the default .env file and the system environment variables.
Example setup:
# .env.development
DB_HOST=localhost
CACHE_ENABLED=true
LOGGER_CONSOLE=true
# .env.production
DB_HOST=db.internal
CACHE_ENABLED=true
LOGGER_CONSOLE=false
LOGGER_FILE=true
Production Safety Checks#
With APP_ENV=production the server refuses to start if any of these is true. It lists everything that must be fixed:
JWT_SECRETis a placeholder from the defaults or these docs, or shorter than 32 bytes.OTP_ENCRYPTION_KEYis a placeholder, or set but not exactly 32 bytes.ADMIN_PASSWORDis a placeholder, or shorter thanPASSWORD_MIN_LENGTH.CORS_ALLOWED_ORIGINScontains*.MAIL_DRIVER=log, which would write reset links to the log.
These only log a warning:
OTP_ENCRYPTION_KEYis empty, so two-factor authentication won't work.APP_URLis nothttps://.RATE_LIMIT_ENABLED=false.
In any other environment (development, test, staging, ...) every item is logged as a warning and the server starts. Generate secrets with:
openssl rand -hex 32 # JWT_SECRET
openssl rand -hex 16 # OTP_ENCRYPTION_KEY (32 characters)
The template's docker-compose.yml runs with APP_ENV=production and reads JWT_SECRET, ADMIN_PASSWORD and OTP_ENCRYPTION_KEY from your shell or a .env file next to it. docker compose up stops with a message naming any that are missing.
Server Settings#
| Variable | Default | Description |
|---|---|---|
SERVER_PORT |
8002 |
Port number on which the server listens. |
SERVER_READ_HEADER_TIMEOUT |
10s |
Time allowed to send request headers. Stops slow-header (Slowloris) attacks. |
SERVER_READ_TIMEOUT |
60s |
Time allowed to read the whole request, including uploads. |
SERVER_WRITE_TIMEOUT |
60s |
Time allowed to write the response. |
SERVER_IDLE_TIMEOUT |
120s |
How long an idle keep-alive connection stays open. |
MAX_BODY_SIZE_MB |
32 |
Largest request body accepted, uploads included. Larger requests get 413 Request Entity Too Large. 0 disables the limit. |
Raise SERVER_READ_TIMEOUT and MAX_BODY_SIZE_MB together if clients upload large files over slow connections.
Every response carries X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Content-Security-Policy: default-src 'none'; frame-ancestors 'none' and Referrer-Policy: no-referrer. When APP_URL is https://, Strict-Transport-Security is added too.
The template's Docker image pins its Go and Alpine versions and runs the server as an unprivileged app user. docker-compose.yml keeps uploads and logs in the named volumes app_uploads and app_logs. A host bind mount would need to be writable by that user.
JWT Settings#
ANAR provides JWT-based authentication by default.
| Variable | Default | Description |
|---|---|---|
JWT_SECRET |
your-256-bit-secret |
Secret key used to sign and verify JWT tokens. Always set your own. |
JWT_ISSUER |
your-api |
Token issuer (iss claim). |
JWT_EXPIRATION |
15m |
Access token lifetime, as a Go duration (15m, 1h). |
REFRESH_TOKEN_TTL |
168h |
Refresh token lifetime (7 days). |
Access tokens can't be revoked before they expire, so keep JWT_EXPIRATION short and have clients renew them with /api/v1/refresh. Logging out revokes refresh tokens. See Logout.
Database Settings#
ANAR integrates with GORM, supporting PostgreSQL, MySQL, SQLite, and SQL Server.
| Variable | Default | Description |
|---|---|---|
DB_DRIVER |
postgres |
Database driver (postgres, mysql, sqlite, sqlserver). |
DB_HOST |
localhost |
Database host address. |
DB_PORT |
5432 |
Database port. |
DB_USER |
postgres |
Database username. |
DB_PASSWORD |
(empty) | Database password. |
DB_NAME |
mydb |
Database name. For sqlite, the path of the database file. |
DB_SSLMODE |
disable |
SSL mode for PostgreSQL. |
MIGRATIONS_ENABLED |
true |
Run migrations automatically at startup. Set to false and run anar migrate (or ./server migrate) yourself. See Running Migrations. |
GORM_LOGGER_ENABLED |
false |
Enable or disable GORM SQL logging. |
CORS Settings#
CORS allows you to define which domains can access your API.
| Variable | Default | Description |
|---|---|---|
CORS_ALLOWED_ORIGINS |
http://localhost:3000 |
Comma-separated list of allowed origins. Set this to your own frontend's origins. |
Other CORS settings are defined in config/cors.go: methods GET, POST, PUT, PATCH, DELETE, OPTIONS, headers Origin, Content-Type, Accept, Authorization, credentials allowed, and a max age of 12 hours.
Cache Settings#
ANAR provides a generic caching system that can work with in-memory storage, Redis, or Memcached. It is used for permission checks, for the responses of routes that opt in (see Response Caching), and, with Redis, for in-progress OAuth logins.
| Variable | Default | Description |
|---|---|---|
CACHE_ENABLED |
true |
Enable or disable caching globally. |
CACHE_TYPE |
memory |
Cache backend: memory, redis or memcached. |
CACHE_DB |
0 |
Database index (Redis only). |
CACHE_TTL |
30 |
TTL in seconds for cached permissions, and for responseCache.Route(0). |
REDIS_ADDR |
localhost:6379 |
Redis address, used when CACHE_TYPE=redis or QUEUE_TYPE=redis. |
REDIS_PASSWORD |
(empty) | Redis password, if your Redis requires one. |
MEMCACHED_ADDR |
localhost:11211 |
Memcached address, used when CACHE_TYPE=memcached. |
Email Verification Settings#
New accounts confirm their email address either by clicking a link or by entering a code.
| Variable | Default | Description |
|---|---|---|
EMAIL_VERIFICATION_MODE |
link |
link: email a link to /api/v1/confirm/<token>. code: email a numeric code to send to /api/v1/confirm/code. |
EMAIL_VERIFICATION_CODE_LENGTH |
6 |
Number of digits in the code. |
EMAIL_VERIFICATION_CODE_TTL |
600 |
Seconds before the code expires. |
SMTP / Email Settings#
ANAR supports sending emails via SMTP (e.g., Gmail, Postfix, SendGrid).
| Variable | Default | Description |
|---|---|---|
MAIL_DRIVER |
smtp |
smtp sends through the server below. log only writes each email to the log, which is handy in development. The server refuses to start with log in production. |
SMTP_HOST |
smtp.example.com |
SMTP server hostname. |
SMTP_PORT |
587 |
SMTP server port. |
SMTP_USER |
your_email@example.com |
SMTP username. |
SMTP_PASSWORD |
your_password |
SMTP password. |
SMTP_FROM |
noreply@example.com |
Default sender address. |
How email is sent#
Emails are sent in the background, so a slow mail server never slows down a request:
- With the queue enabled (
QUEUE_ENABLED=true, the default), each email becomes asend_emailjob. A failed send is retried up toQUEUE_MAX_ATTEMPTStimes. UseQUEUE_TYPE=redisin production so queued emails survive a restart. - With the queue disabled, each email is sent from a background goroutine, and shutdown waits for it. Failures are logged.
Email templates#
Each email is rendered from mail/templates/. <name>.txt holds the plain-text body and defines the subject with {{define "subject"}}...{{end}}. <name>.html is optional and is sent as the HTML version. They use Go's text/template and html/template syntax and are compiled into the binary.
| Template | Sent when | Data |
|---|---|---|
confirm_link |
A user registers (EMAIL_VERIFICATION_MODE=link) |
Name, Link |
confirm_code |
A user registers (EMAIL_VERIFICATION_MODE=code) |
Name, Code, ExpiresIn |
password_reset |
A user asks for a password reset | Name, Link, ExpiresIn |
Edit these files to change the wording. To send your own email, add a template and call the mailer from a handler:
// mail/templates/welcome.txt:
// {{define "subject"}}Welcome to {{.App}}{{end}}Hello {{.Name}}, ...
err := h.App.Mailer.Send(c.Request.Context(), user.Email, "welcome", map[string]any{
"App": "My App", "Name": user.Name,
})
Send returns an error only if the template fails to render or the job can't be queued. To send through another provider (SES, Postmark, ...), implement mail.Sender (Send(ctx, mail.Message) error) and set it as App.Mailer.Sender. utils.SendMail still works but is deprecated: it sends synchronously and doesn't retry.
Application Settings#
| Variable | Default | Description |
|---|---|---|
APP_URL |
http://localhost:8002 |
Public URL of the API. Used in confirmation and password reset links, and for OAuth callback URLs. |
UPLOAD_DIR |
uploads |
Directory for uploaded files when S3 is disabled. |
OTP_ENCRYPTION_KEY |
(empty) | Key that encrypts OTP secrets and recovery codes. Must be exactly 32 bytes. Two-factor authentication does not work without it. |
PASSWORD_MIN_LENGTH |
8 |
Minimum password length, in characters, for register, change-password, reset-password and admin-created users. |
API_LEGACY_ROUTES |
true |
Also serve API version 1 at the unversioned /api/... paths, marked deprecated. See API Versioning. |
Rate Limiting Settings#
Anar limits brute-force attempts in two ways:
- Per client IP: each of
/api/v1/register,/api/v1/login(shared with/api/v1/auth/mfa/verify), the email confirmation routes and the password reset routes acceptsAUTH_RATE_LIMITrequests per IP in eachAUTH_RATE_WINDOW. - Per account: after
AUTH_MAX_FAILURESfailed attempts inAUTH_FAILURE_WINDOW, further attempts are refused until the window ends, even with the right credentials. The counters are: - wrong passwords for a user name;
- wrong OTP or recovery codes for a user (password login, OAuth's MFA step and OTP enrolment share one counter);
- wrong email confirmation codes for an email address.
A successful attempt resets its counter.
Refused requests get 429 Too Many Requests with a Retry-After header (seconds) and {"error": {"code": "too_many_requests", "message": "Too many attempts, try again later"}}.
| Variable | Default | Description |
|---|---|---|
RATE_LIMIT_ENABLED |
true |
Turn all of the above on or off. |
AUTH_RATE_LIMIT |
20 |
Requests per client IP to each auth route per window. |
AUTH_RATE_WINDOW |
1m |
Length of the per-IP window. |
AUTH_MAX_FAILURES |
5 |
Failed attempts per account before it is locked for the rest of the window. |
AUTH_FAILURE_WINDOW |
15m |
Length of the per-account window. |
TRUSTED_PROXIES |
(empty) | Comma-separated IPs or CIDRs of your reverse proxies or load balancer. Only their X-Forwarded-For header is used for the client IP. |
Behind a reverse proxy, set TRUSTED_PROXIES (for example 10.0.0.0/8). Otherwise every request appears to come from the proxy and all clients share one per-IP budget. Without it, X-Forwarded-For is ignored, so clients can't fake their IP.
Counters are kept in memory by each server process. With several replicas, a client can make up to that many times the limit, and a restart clears them. Anyone who knows a user name can lock that account for AUTH_FAILURE_WINDOW by failing on purpose. That is the trade-off for stopping password guessing.
Admin Account Settings#
| Variable | Default | Description |
|---|---|---|
ADMIN_NAME |
admin |
Name of the first admin account. |
ADMIN_EMAIL |
(empty) | Email of the first admin account. |
ADMIN_PASSWORD |
(empty) | Password of the first admin account. Without it, no admin is created. PASSWORD_MIN_LENGTH is not applied to it, so choose a long one. |
S3-Compatible Storage#
ANAR supports object storage services like AWS S3 and MinIO for file uploads.
| Variable | Default | Description |
|---|---|---|
S3_ENABLED |
false |
Enable or disable S3-compatible storage. |
S3_ENDPOINT |
http://localhost:9000 |
Endpoint of the S3-compatible service. |
S3_REGION |
us-east-1 |
S3 region name. |
S3_ACCESS_KEY |
minioadmin |
S3 access key. |
S3_SECRET_KEY |
minioadmin |
S3 secret key. |
S3_BUCKET |
aether |
Default S3 bucket name. |
S3_USE_SSL |
false |
Enable SSL for S3 connection. |
Queue Settings#
Background jobs (see Jobs and Queues) run on a queue.
| Variable | Default | Description |
|---|---|---|
QUEUE_ENABLED |
true |
Start the queue and its workers. |
QUEUE_TYPE |
memory |
memory or redis (uses REDIS_ADDR and REDIS_PASSWORD). Jobs in a memory queue are lost when the process stops. |
QUEUE_WORKER_COUNT |
5 |
Number of concurrent workers. |
QUEUE_LISTEN_QUEUES |
default |
Comma-separated queue names the workers take jobs from. |
QUEUE_MAX_ATTEMPTS |
3 |
Attempts before a job is given up. |
QUEUE_TIMEOUT |
60 |
Seconds a job may run. |
QUEUE_RETRY_AFTER |
30 |
Seconds to wait before retrying a failed job. |
OAuth Settings#
ANAR supports login with any OpenID Connect provider and with Google, Apple, GitHub and Facebook. Providers are enabled by name in OAUTH_PROVIDERS; each one is configured with OAUTH_<NAME>_* variables (the name upper-cased, - becomes _).
Providers#
| Variable | Default | Description |
|---|---|---|
OAUTH_PROVIDERS |
(empty) | Comma-separated provider names, e.g. google,github,keycloak. |
OAUTH_<NAME>_TYPE |
the name | oidc, google, apple, github, facebook or oauth2. |
OAUTH_<NAME>_CLIENT_ID |
(required) | Client ID (Apple: the Services ID). |
OAUTH_<NAME>_CLIENT_SECRET |
(empty) | Client secret (not used by Apple). |
OAUTH_<NAME>_REDIRECT_URL |
${APP_URL}/api/auth/oauth/<name>/callback |
Callback URL registered at the provider. The default is ${APP_URL}/api/v1/auth/oauth/<name>/callback when API_LEGACY_ROUTES=false. |
OAUTH_<NAME>_ISSUER |
(empty) | Issuer URL, required for oidc. |
OAUTH_<NAME>_SCOPES |
provider default | Space- or comma-separated scopes. |
OAUTH_<NAME>_DISPLAY_NAME |
provider default | Label for login buttons. |
OAUTH_<NAME>_PRIVATE_KEY_FILE |
(empty) | Apple: path to the .p8 key file. |
Type-specific options use the same prefix:
| Type | Options |
|---|---|
oidc |
SKIP_ISSUER_CHECK (multi-tenant issuers only), USERINFO_FALLBACK (default true), PARAM_<X> (extra authorization parameter x) |
google |
HOSTED_DOMAIN (restrict to one Workspace domain; enforced on the verified hd claim) |
apple |
TEAM_ID, KEY_ID, PRIVATE_KEY or PRIVATE_KEY_FILE, SECRET_TTL (default 1h) |
github |
BASE_URL, API_URL (GitHub Enterprise) |
facebook |
GRAPH_VERSION (default v21.0), EMAIL_VERIFIED (default true) |
oauth2 |
AUTH_URL, TOKEN_URL, USERINFO_URL, CLAIM_SUBJECT, CLAIM_EMAIL, CLAIM_EMAIL_VERIFIED, CLAIM_NAME, CLAIM_USERNAME, CLAIM_PICTURE, PARAM_<X> |
An unknown option is a startup error, so typos are caught immediately.
The pre-v0.2 variables (GOOGLE_CLIENT_ID, FACEBOOK_*, GITHUB_*, APPLE_*) still work when OAUTH_PROVIDERS is not set, and log a deprecation warning.
Accounts and security#
| Variable | Default | Description |
|---|---|---|
OAUTH_LINK_POLICY |
explicit |
What happens when a new provider login has the email of an existing account. explicit: refuse (account_exists); the user logs in and links the provider from settings. verified_email: link automatically if a trusted provider verified the email. |
OAUTH_TRUSTED_EMAIL_PROVIDERS |
google,apple |
Providers whose verified emails verified_email trusts. |
OAUTH_ALLOW_SIGNUP |
true |
Create accounts for new provider logins. |
OAUTH_REQUIRE_VERIFIED_EMAIL |
true |
Refuse sign-up when the provider does not assert the email is verified. |
OAUTH_FLOW_STORE |
auto |
Where in-progress logins are kept: redis (needs CACHE_TYPE=redis), db, or auto (Redis if available, else the database). |
OAUTH_FLOW_TTL |
10m |
Time allowed to finish logging in at the provider. |
OAUTH_LOGIN_CODE_TTL |
1m |
Lifetime of the one-time code handed to the frontend. |
OAUTH_MFA_TICKET_TTL |
5m |
Lifetime of the ticket for entering an OTP code. |
OAUTH_ALLOWED_RETURN_URLS |
(empty) | URL prefixes a login may return to via ?return_to=. |
Frontend Redirects#
| Variable | Default | Description |
|---|---|---|
FRONTEND_SUCCESS_URL |
http://localhost:3000/auth/success |
Where a successful login lands (with #code=). |
FRONTEND_ERROR_URL |
http://localhost:3000/auth/error |
Where a failed login lands (with #error=). |
Logger Settings#
ANAR provides flexible logging configuration. These variables allow enabling/disabling log outputs, file rotation, and asynchronous logging.
| Variable | Default | Description |
|---|---|---|
LOGGER_ENABLED |
true |
Enable or disable the logger. |
LOGGER_CONSOLE |
false |
Enable console logging. |
LOGGER_FILE |
true |
Enable file logging. |
LOGGER_SYSLOG |
false |
Enable syslog logging. |
LOGGER_SYSLOG_TAG |
anar-app |
Syslog tag name. |
LOGGER_FILE_PATH |
logs/app.log |
Path to log file. |
LOGGER_ASYNC |
true |
Enable asynchronous logging. |
LOGGER_FILE_MAX_MB |
5 |
Maximum file size in MB before rotation. |
LOGGER_FILE_DAILY |
true |
Rotate logs daily. |
LOGGER_FILE_BACKUPS |
3 |
Number of backup log files to keep. |
Example .env File#
SERVER_PORT=8002
APP_URL=http://localhost:8002
JWT_SECRET=change-me-to-a-long-random-string
JWT_ISSUER=my-api
OTP_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef
ADMIN_PASSWORD=change-me-admin-password
DB_DRIVER=postgres
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=password
DB_NAME=anar
DB_SSLMODE=disable
CORS_ALLOWED_ORIGINS=http://localhost:3000
CACHE_ENABLED=true
CACHE_TYPE=memory
CACHE_TTL=30
# For CACHE_TYPE=redis or QUEUE_TYPE=redis:
REDIS_ADDR=localhost:6379
REDIS_PASSWORD=
QUEUE_ENABLED=true
QUEUE_TYPE=memory
QUEUE_LISTEN_QUEUES=default
EMAIL_VERIFICATION_MODE=link
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=myemail@gmail.com
SMTP_PASSWORD=mypass
SMTP_FROM=noreply@example.com
UPLOAD_DIR=uploads
S3_ENABLED=false
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=anar
S3_USE_SSL=false
LOGGER_ENABLED=true
LOGGER_CONSOLE=true
LOGGER_FILE=true
LOGGER_FILE_PATH=logs/app.log
LOGGER_FILE_MAX_MB=10
LOGGER_FILE_DAILY=true
LOGGER_FILE_BACKUPS=5
OAUTH_PROVIDERS=google,github
OAUTH_GOOGLE_CLIENT_ID=
OAUTH_GOOGLE_CLIENT_SECRET=
OAUTH_GITHUB_CLIENT_ID=
OAUTH_GITHUB_CLIENT_SECRET=
FRONTEND_SUCCESS_URL=http://localhost:3000/auth/success
FRONTEND_ERROR_URL=http://localhost:3000/auth/error
The OTP_ENCRYPTION_KEY above is only an example; generate your own 32-character key (for example openssl rand -hex 16).
Notes#
- Use
true/falsefor all boolean variables. - Never commit real secrets. Keep
.envfiles out of version control.
Command Line Interface (CLI)#
ANAR provides a CLI for managing your application. All commands follow the pattern:
anar <command> [arguments]
Run anar with no arguments to print the full help.
Available Commands#
Project Management#
| Command | What it does |
|---|---|
anar new <module> |
Create a new project from the pinned anar-core template. |
anar build |
Compile every package (go build ./...) to check the project builds. It does not write a binary; use go build -o <name> . for that. |
anar test |
Run the project's tests (go test ./...). |
anar clean |
Clean build artifacts (go clean ./...). |
anar run |
Run with live reload, installing air if needed. |
anar migrate |
Apply database migrations (go run . migrate). See Running Migrations. |
anar migrate:status |
List versioned migrations and whether each is applied. |
anar migrate:rollback [N] |
Revert the last N versioned migrations (default 1). |
anar make:migration <name> |
Create a versioned migration. See Versioned Migrations. |
Code Generation#
| Command | What it does |
|---|---|
anar scaffold |
Generate handlers, migrations and routes for all models. |
anar handlers [--force] |
Generate handlers (and their routes). Existing handler files are kept unless you pass --force. |
anar migrations |
Generate migrations/auto_migrations.go. |
anar routes |
Regenerate routes/generated_routes.go from //anar:route directives. See Routes for its options. |
anar routes:list |
List all routes, generated and hand-written. |
anar openapi [--check] [--out PATH] |
Write docs/openapi.json. See API Documentation. |
anar job <Name> [queue] |
Create a background job handler. See Jobs and Queues. |
Model Management#
| Command | What it does |
|---|---|
anar model <Name> [field:type ...] [flags] |
Create a new model. |
anar add-field-to-model <Name> field:type [...] |
Add fields to an existing model. |
anar remove-field-from-model <Name> <field> |
Remove a field from a model. |
anar delete-model <Name> |
Delete a model. |
anar models:list |
List all models and their fields. |
Command Examples#
# Create a project
anar new github.com/mycompany/blogapi
cd blogapi
# Create models
anar model Author name:string email:string:unique age:int
anar model Post title:string content:text author:belongsTo
anar model Category name:string:unique
# Generate handlers, migrations and routes
anar scaffold
# Add a new field to an existing model
anar add-field-to-model Author phone:string:nullable
# Remove a field from a model
anar remove-field-from-model Author age
# Run the application
anar run
Model Generation and Management#
Creating Models#
Models are generated into models/models.go. A new project already contains the User and Product models, plus the built-in models used for permissions, OAuth and refresh tokens.
Basic Model Creation#
anar model Author name:string email:string age:int active:bool
This generates:
type Author struct {
gorm.Model
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
Active bool `json:"active"`
}
func (Author) TableName() string {
return "authors"
}
Supported Field Types#
| Type | Go Type | Column | Example |
|---|---|---|---|
string |
string |
VARCHAR | name:string |
text |
string |
TEXT (gorm:"type:text") |
content:text |
int |
int |
INTEGER | age:int |
int64 |
int64 |
BIGINT | views:int64 |
uint |
uint |
unsigned INTEGER | score:uint |
float |
float32 |
FLOAT | rating:float |
float64 |
float64 |
DOUBLE | price:float64 |
bool |
bool |
BOOLEAN | active:bool |
Any other type is rejected with unsupported field type. For dates, files, JSON and other custom types, add the field to the struct by hand using the types from anar-types such as types.FileField and types.TimeField. See Models for details.
Field Modifiers#
Append modifiers after the type, separated by :. You can combine them.
| Modifier | Effect | Example |
|---|---|---|
unique |
gorm:"uniqueIndex" |
email:string:unique |
index |
gorm:"index" |
slug:string:index |
required |
gorm:"not null" |
name:string:required |
nullable |
Pointer type (*string), stored as NULL when nil |
phone:string:nullable |
default=<value> |
gorm:"default:<value>" |
status:string:default=draft |
anar model Order total:float64:required status:string:default=pending
Model Flags#
| Flag | Effect |
|---|---|
--no-timestamps |
Don't embed gorm.Model; the model gets only an ID primary key. |
--soft-delete |
Add DeletedAt gorm.DeletedAt for soft deletes. Requires --no-timestamps, because gorm.Model already includes it. |
anar model Event name:string --no-timestamps --soft-delete
Generates:
type Event struct {
ID uint `gorm:"primaryKey" json:"id"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Name string `json:"name"`
}
Model Field Management#
Adding Fields to Existing Models#
# Add single field
anar add-field-to-model Author phone:string
# Add multiple fields
anar add-field-to-model Author phone:string:nullable verified:bool:default=false
Removing Fields from Models#
anar remove-field-from-model Author age
When you remove a belongsTo field, give the relation name (for example author); both the foreign key (AuthorID) and the association field (Author) are removed.
Deleting Models#
anar delete-model Event
This removes the model from models/models.go. Delete its handler file (handlers/event_handler.go) yourself, then run anar migrations and anar routes so nothing refers to it any more.
Listing Models#
anar models:list
Model Structure#
All generated models include:
gorm.Model: Embedded struct providingID,CreatedAt,UpdatedAt,DeletedAt(unless--no-timestampsis used)- Your fields with their Go types
- JSON tags: the field name in lower case
- GORM tags from the type and modifiers
- A
TableName()method returning the plural, snake_case table name
Database Relationships#
The related model must already exist (a model may also refer to itself). Otherwise the command fails with related model X does not exist. Create models in dependency order, or add relation fields later with add-field-to-model.
Relationship Types#
1. BelongsTo Relationship#
Adds a foreign key and the association to this model.
anar model Article title:string content:text author:belongsTo
Generates:
type Article struct {
gorm.Model
Title string `json:"title"`
Content string `json:"content" gorm:"type:text"`
AuthorID uint `json:"author_id"`
Author *Author `json:"author" gorm:"foreignKey:AuthorID"`
}
2. HasOne Relationship#
The foreign key lives on the child model. Anar adds the association to this model and the foreign key column to the child.
anar model Bio body:text
anar add-field-to-model Author bio:hasOne
Adds to Author:
Bio *Bio `json:"bio" gorm:"foreignKey:AuthorID"`
and to Bio:
AuthorID uint `json:"-" gorm:"index"`
3. HasMany Relationship#
The field name is plural; Anar singularizes it to find the child model and adds the foreign key column to the child.
anar model Comment body:text
anar add-field-to-model Article comments:hasMany
Adds to Article:
Comments []Comment `json:"comments" gorm:"foreignKey:ArticleID"`
and to Comment:
ArticleID uint `json:"-" gorm:"index"`
4. ManyToMany Relationship#
Creates a many-to-many relationship. GORM manages the join table.
anar model Tag name:string
anar add-field-to-model Article tags:manyToMany
Adds to Article:
Tags []Tag `json:"tags" gorm:"many2many:article_tags;"`
Handler Generation#
ANAR generates RESTful API handlers for all your models with full CRUD functionality. The built-in models (User, Group, Permission and the OAuth and token models) are skipped; they have their own handlers.
Generated Handler Methods#
For each model, anar handlers (or anar scaffold) writes handlers/<model>_handler.go, where <model> is the model name in lower case. Each routed handler carries an //anar:route directive, and the command then runs anar routes, so the endpoints work immediately (see Routes):
| Handler | Route | Permission | Notes |
|---|---|---|---|
GetAll<Plural> |
GET /api/v1/<plural> |
view |
Pagination (page/per_page, or after_id), search, filters, sort_by / sort_desc, fields, include. See Search, Filtering and Pagination. |
GetAll<Plural>Simple |
none | Every row, unpaginated; for use from code only (//anar:noroute). |
|
Get<Model>ByID |
GET /api/v1/<plural>/:id |
view |
404 if missing. Accepts include. |
Add<Model> |
POST /api/v1/<plural> |
create |
JSON or multipart (file fields). Returns 201. |
Update<Model> |
PUT and PATCH /api/v1/<plural>/:id |
update |
Applies exactly the fields sent, zero values included ("stock": 0, "active": false); fields left out keep their values. Returns 200. |
Delete<Model> |
DELETE /api/v1/<plural>/:id |
delete |
Also removes stored files. Returns 204. |
Handler Generation#
# Generate handlers (and their routes) for all models
anar handlers
# Or generate complete scaffold (handlers, migrations and routes)
anar scaffold
Existing handler files are kept unless you pass --force.
Generated Handler Example#
For an OrderItem model, ANAR generates handlers/orderitem_handler.go:
package handlers
import (
"github.com/gin-gonic/gin"
"yourproject/models"
)
// OrderItem Handlers
// GetAllOrderItems handles fetching order items with pagination, search, and filtering
//anar:route GET /order-items perm=view
func (h Handler) GetAllOrderItems(c *gin.Context) {
searchFields := []string{"title"}
h.getAllWithPagination(models.OrderItem{}, searchFields, c)
}
// GetAllOrderItemsSimple returns every order item without pagination. It has no route:
// an unpaginated list grows without bound.
//anar:noroute
func (h Handler) GetAllOrderItemsSimple(c *gin.Context) {
h.getAll(models.OrderItem{}, c)
}
// GetOrderItemByID handles fetching the OrderItem with the given ID
//anar:route GET /order-items/:id perm=view
func (h Handler) GetOrderItemByID(c *gin.Context) {
h.getByID(models.OrderItem{}, c, "OrderItem not found")
}
// AddOrderItem handles creating a new OrderItem
//anar:route POST /order-items perm=create
func (h Handler) AddOrderItem(c *gin.Context) {
h.add(models.OrderItem{}, c)
}
// UpdateOrderItem handles updating an existing OrderItem
//anar:route PUT /order-items/:id perm=update
func (h Handler) UpdateOrderItem(c *gin.Context) {
h.update(models.OrderItem{}, c, "OrderItem not found")
}
// DeleteOrderItem handles deleting the OrderItem with the given ID
//anar:route DELETE /order-items/:id perm=delete
func (h Handler) DeleteOrderItem(c *gin.Context) {
h.delete(models.OrderItem{}, c, "OrderItem not found")
}
Search, Filtering and Pagination#
List endpoints (GetAll<Plural>) accept:
| Parameter | Default | Meaning |
|---|---|---|
page |
1 |
Page number. |
per_page |
10 |
Items per page, at most 100. |
search |
Case-insensitive substring match across the handler's search fields. | |
filter_<column> |
Numbers match exactly; text matches case-insensitively. Repeat it to match any of several values exactly: filter_status=draft&filter_status=archived. |
|
filter_<column>_gte, _gt, _lte, _lt |
Range comparisons, for numbers and dates: filter_price_gte=10&filter_created_at_lt=2026-01-01. |
|
sort_by |
Column to sort by. | |
sort_desc |
false |
true for descending. |
fields |
all | Comma-separated fields to return: fields=name,price. Each record keeps only those keys and its ID. |
include |
Comma-separated associations to load, if the model allows it (below). | |
after_id |
Cursor pagination (below). |
Columns can be given as the database name (created_at), the JSON name or the Go field name (CreatedAt). Names that aren't columns of the model are ignored, so query parameters can never inject SQL.
GET /api/v1/order-items?page=2&per_page=20
GET /api/v1/order-items?search=lamp
GET /api/v1/order-items?filter_quantity=3&sort_by=created_at&sort_desc=true
GET /api/v1/order-items?filter_price_gte=10&filter_price_lt=50&fields=name,price
The response is a page object:
{"data": [...], "total": 42, "page": 2, "per_page": 20, "total_pages": 3, "has_next": true, "has_previous": true}
Including associations#
include loads related records, but only associations whose field is tagged anar:"include". Without the tag, include=owner on a post could expose the owner's email to anyone allowed to read posts, so each association has to opt in:
type Post struct {
gorm.Model
Title string `json:"title"`
AuthorID uint `json:"author_id"`
Author *Author `json:"author,omitempty" anar:"include"`
}
GET /api/v1/posts?include=author
GET /api/v1/posts/7?include=author
Cursor pagination#
For large tables, page with after_id instead of page. It returns the records after that ID (before it with sort_desc=true), ordered by ID, without counting the table:
GET /api/v1/order-items?after_id=0&per_page=50
{"data": [...], "per_page": 50, "has_next": true, "next_after_id": 50}
Pass next_after_id as after_id to get the next page. It is null on the last page. Filters, search, fields and include still apply, and sort_by is ignored. An after_id that isn't a valid ID gets 400 invalid_cursor.
Bulk Operations and Restore#
The base handler has bulk and restore helpers. They aren't routed by default, because they widen each model's API. Add the routes you want:
//anar:route POST /order-items/bulk perm=create
func (h Handler) AddOrderItems(c *gin.Context) { h.addMany(models.OrderItem{}, c) }
//anar:route PATCH /order-items/bulk perm=update
func (h Handler) UpdateOrderItems(c *gin.Context) { h.updateMany(models.OrderItem{}, c) }
//anar:route DELETE /order-items/bulk perm=delete
func (h Handler) DeleteOrderItems(c *gin.Context) { h.deleteMany(models.OrderItem{}, c) }
//anar:route POST /order-items/:id/restore perm=update
func (h Handler) RestoreOrderItem(c *gin.Context) { h.restore(models.OrderItem{}, c, "OrderItem not found") }
| Helper | Body | Result |
|---|---|---|
addMany |
A JSON array of records | 201 with the created records. |
updateMany |
A JSON array of partial records, each with its ID |
200 {"updated": n}. Only the fields sent change. 404 if an ID doesn't exist. |
deleteMany |
{"ids": [1, 2, 3]} |
200 {"deleted": n}, the number that existed. Stored files are removed too. |
restore |
none | 200 with the record. It undeletes a soft-deleted record (models embedding gorm.Model delete softly). 404 if no deleted record has that ID. |
A request can carry at most 100 records and is all or nothing: one invalid record, or one missing ID, changes nothing. Validation failures are keyed by position, for example "[1].price": "is required". The template's Product handler has these four routes as an example.
Custom Search Fields#
ANAR uses the model's string and text fields as search fields. Edit the generated handler to change them:
searchFields := []string{"title", "description"}
Error Responses#
Every error has the same shape:
{"error": {"code": "not_found", "message": "Product not found"}}
Validation failures (422) add the failing fields, keyed by JSON name:
{"error": {"code": "validation_failed", "message": "Validation failed", "fields": {"price": "is required"}}}
code is stable and meant for programs, so branch on it. message is for people and may change. Codes used across the API:
| Code | Status | Meaning |
|---|---|---|
bad_request |
400 | The body or a parameter is malformed. |
missing_token |
401 | No Authorization header on a protected route. |
invalid_token |
401 | The access token is invalid, expired or not a Bearer token. Also used for an invalid email confirmation or reset token (400). |
invalid_credentials |
400/401 | Wrong password. |
user_not_found |
404 | No user with that name or email. |
email_not_confirmed |
403 | Log-in before the email address was confirmed. |
otp_required, invalid_otp |
401 | Missing or wrong second factor. |
invalid_code, code_expired, token_expired |
400/401 | Email codes, login codes and reset links. |
invalid_refresh_token |
401 | The refresh token is invalid, expired or reused. |
admin_required, permission_denied, forbidden |
403 | Not allowed. |
not_found |
404 | No such record. |
validation_failed |
422 | See fields. |
invalid_cursor |
400 | after_id is not a valid ID. |
bulk_size |
400 | A bulk request has no records, or more than 100. |
payload_too_large |
413 | The body exceeds MAX_BODY_SIZE_MB. |
unsupported_media_type |
415 | Not JSON or multipart. |
too_many_requests |
429 | Rate limited; see Retry-After. |
internal_error |
500 | Something failed on the server; details are in the log. |
In your own handlers, write errors with the httperr package so they match:
httperr.Write(c, http.StatusNotFound, httperr.NotFound, "Invoice not found")
httperr.Abort(c, http.StatusForbidden, "plan_required", "Upgrade to export invoices") // in middleware
httperr.Validation(c, map[string]string{"due_date": "must be in the future"})
Response Caching#
Responses are cached only for routes that ask for it. Add cache= to a GET route's directive:
//anar:route GET /products perm=view cache=60s
func (h Handler) GetAllProducts(c *gin.Context) { ... }
It needs CACHE_ENABLED=true, which is the default, and works with every CACHE_TYPE. Without a cache backend the option does nothing.
- Keys: each response is cached per URL (query string included),
Acceptheader and caller. A logged-in user gets their own entries, keyed by user ID, and permission checks still run before the cache. Anonymous callers share entries. - What is stored: only
200responses, and not those the handler marksCache-Control: no-storeorprivate. The content type is kept. - Invalidation: a successful
POST,PUT,PATCHorDELETEunder/api/v1/products/...invalidates every cached response taggedproducts. A route is tagged with its own resource, the first path segment after/apiand the version, so/api/v1/productsand/api/v2/productsshare theproductstag. A response that also depends on other resources lists them all:cache=5m tags=posts,authorsis invalidated by writes to either. Invalidation bumps a version number per tag, so nothing is scanned or deleted; stale entries simply expire. - ETags: responses carry an
ETag. A request with a matchingIf-None-Matchgets304 Not Modifiedwith no body. TheX-Cacheheader saysHITorMISS. - Client control: a request with
Cache-Control: no-cacheskips the cached copy and refreshes it.no-storebypasses the cache entirely.
For hand-written routes, add the response cache built in SetupRoutes to the route. Pass responseCache to registerRoutes if the route lives there:
api.GET("/stats", jwtMiddleware.AuthRequired(), responseCache.Route(5*time.Minute, "orders"), handler.Stats)
When data changes outside a write route (a job, an import), call InvalidateTags:
responseCache.InvalidateTags("products")
The template's product list and get-by-ID routes use cache=30s as an example.
API Documentation#
anar openapi writes an OpenAPI 3.0 description of your API to docs/openapi.json. The server embeds it and, when API_DOCS_ENABLED is on, serves it at:
GET /docs: Swagger UI, with an Authorize button for trying protected routes with an access token;GET /docs/openapi.json: the spec itself, for client generators, Postman, and so on.
| Variable | Default | Description |
|---|---|---|
API_DOCS_ENABLED |
true, or false when APP_ENV=production |
Serve /docs. Turn it on in production only if your API is meant to be public. |
anar openapi # write docs/openapi.json
anar openapi --check # fail if it is out of date (for CI)
anar scaffold and anar handlers refresh the spec automatically. Run anar openapi yourself after changing models or hand-written routes. The spec is compiled into the binary, so restart or rebuild the server to serve the new version.
What the spec contains:
- Generated routes (
//anar:route): the method and path,auth=jwtas bearer authentication, and theperm=expression with its 401 and 403 responses. - Generic CRUD handlers also get full request and response schemas, based on the helper they call:
getAllWithPagination: the page, cursor, search, sort, field, include, filter and range parameters, and both page shapes;getByID: the record;add: the body with its required fields and a 422 response, plusmultipart/form-datawhen the model has file fields;update: a body where every field is optional;delete: a 204 response;addMany,updateMany,deleteManyandrestore: their array bodies and results.
- Cached routes (
cache=) note the cache and the 304 response. - Other generated handlers are listed without schemas.
- Model schemas: each field's JSON name and type, including relations and anar-types fields. Fields tagged
json:"-"are left out.validaterules become schema constraints:required,min,max,len,gt,gte,lt,lte,oneof,email,urlanduuid. - Hand-written routes in
routes/: the method, path, path parameters and the handler's doc comment. Their authentication and bodies are not described.
Summaries come from each handler's doc comment, for example // GetAllPosts handles fetching posts., or from its name when there is no comment.
Health Checks#
| Endpoint | Returns |
|---|---|
GET /healthz |
Liveness: always 200 {"status": "ok"} while the process is serving requests. |
GET /readyz |
Readiness: 200 when the database, and the cache and queue if enabled, respond within 2 seconds, otherwise 503. |
{"status": "ok", "checks": {"database": "ok", "cache": "ok", "queue": "ok"}}
A failed check shows as "error". The detail goes to the server log, not the response. Use /healthz for liveness probes, so a database outage doesn't restart the process, and /readyz for readiness probes and load balancer health checks. Neither endpoint is rate limited or cached. The template's docker-compose.yml uses /readyz as the container health check.
Migration System#
ANAR generates database migrations from your models.
Migration Generation#
# Generate migrations for all models
anar migrations
# Or generate complete scaffold (includes migrations)
anar scaffold
Generated Migration File#
ANAR writes a single migrations/auto_migrations.go file containing all model migrations:
package migrations
import (
"fmt"
"gorm.io/gorm"
"yourproject/models"
)
// AutoMigrate runs all model migrations
func AutoMigrate(db *gorm.DB) error {
fmt.Println("Running auto migrations...")
if err := db.AutoMigrate(&models.Author{}); err != nil {
return fmt.Errorf("failed to migrate Author: %w", err)
}
if err := db.AutoMigrate(&models.Post{}); err != nil {
return fmt.Errorf("failed to migrate Post: %w", err)
}
fmt.Println("All migrations completed successfully!")
return nil
}
// DropAllTables drops all model tables (use with caution)
func DropAllTables(db *gorm.DB) error {
fmt.Println("Dropping all tables...")
// Drop tables in reverse order to handle dependencies
if err := db.Migrator().DropTable(&models.Post{}); err != nil {
return fmt.Errorf("failed to drop Post table: %w", err)
}
if err := db.Migrator().DropTable(&models.Author{}); err != nil {
return fmt.Errorf("failed to drop Author table: %w", err)
}
fmt.Println("All tables dropped successfully!")
return nil
}
// GetModelList returns list of all models being migrated
func GetModelList() []string {
return []string{"Author", "Post"}
}
Migration Features#
- Auto Migration: Uses GORM's AutoMigrate for schema synchronization.
- Order: Models are migrated in the order they appear in
models/models.go, and dropped in reverse order. - Built-in models:
User,Group,Permissionand the OAuth and token models are migrated bydb.Migrate, not by this file. - Startup: When
MIGRATIONS_ENABLED=true(the default), migrations run every time the application starts.
AutoMigrate only adds things: it creates tables, columns and indexes, but never drops or renames columns, changes data, or undoes anything. Versioned migrations handle those changes.
Versioned Migrations#
A versioned migration is a Go file in migrations/ that runs once per database and is recorded in the schema_migrations table. Create one with:
anar make:migration rename_title_to_name
# Created migrations/20261025120000_rename_title_to_name.go
Fill in Up, and Down if the change can be undone:
package migrations
import "gorm.io/gorm"
func init() {
Register(Migration{
ID: "20261025120000_rename_title_to_name",
Up: func(tx *gorm.DB) error {
return tx.Migrator().RenameColumn(&post{}, "title", "name")
},
Down: func(tx *gorm.DB) error {
return tx.Migrator().RenameColumn(&post{}, "name", "title")
},
})
}
// A small local struct keeps the migration working after the model changes.
type post struct{ Name string }
func (post) TableName() string { return "posts" }
Then rename the field in the model and run anar migrate.
How migrations run (anar migrate, ./server migrate, or server startup):
- Existing database: pending migrations run in ID (timestamp) order, then AutoMigrate adds anything new from the models. Each migration runs in a transaction together with its
schema_migrationsrecord, so a failure stops the run and records nothing. On PostgreSQL, SQLite and SQL Server the failed migration's schema changes are rolled back too. MySQL commits schema changes immediately, so keep each MySQL migration to a single change. - New database (no
schema_migrationsand nouserstable): AutoMigrate creates the current schema from the models, and every migration is recorded as applied without running. Migrations describe how to change existing data, and a new database is already in the final shape. Put seed data in code that runs at startup, not in migrations.
Commands:
| Command | What it does |
|---|---|
anar migrate |
Apply pending migrations, then AutoMigrate. |
anar migrate:status |
List migrations as applied (with the time) or pending, and flag applied ones whose file was deleted. |
anar migrate:rollback [N] |
Run Down for the last N applied migrations (default 1), newest first. It stops at a migration without Down. |
anar make:migration <name> |
Create a migration file. |
With a built binary, use ./server migrate, ./server migrate:status and ./server migrate:rollback [N].
Guidelines:
- Don't import
modelsin a migration: the model will change later, and old migrations must keep compiling and meaning the same thing. Use plain SQL (tx.Exec) or a small local struct with aTableNamemethod, as above. - Use a struct, not a table-name string, with
tx.Migrator(). Some GORM drivers (SQLite) crash when given a table name string. - Rolling back doesn't change your models. Revert the code too, or the next
migrateadds the dropped columns back through AutoMigrate. - Several replicas starting at once with
MIGRATIONS_ENABLED=truemay race on the same migration. On PostgreSQL the loser's transaction fails and is rolled back. To be safe, especially on MySQL, setMIGRATIONS_ENABLED=falseand runmigrateonce per deploy.
Running Migrations#
By default the server runs all migrations every time it starts. To control when the schema changes (for example in production, or with several replicas starting at once), turn that off:
MIGRATIONS_ENABLED=false
The server then starts without touching the schema, and you apply migrations yourself:
# In the project directory
anar migrate
# Or with a built binary (e.g. in a container or a release step)
./server migrate
migrate reads the same configuration as the server (.env.<APP_ENV>, .env and the environment), applies pending versioned migrations, creates or updates the built-in and generated tables, creates the default permissions and groups, and exits. It exits with status 1 if anything fails. It always migrates, whatever MIGRATIONS_ENABLED is set to, and it is safe to run more than once.
If the server starts with migrations disabled and finds an unmigrated database, it logs a warning telling you to run migrate.
Scaffolding System#
The scaffolding system generates complete CRUD functionality for your entire application.
Scaffold Generation#
anar scaffold
This command:
1. Parses all models in the models/ directory
2. Generates handlers for each model (existing handler files are kept; pass --force to overwrite)
3. Generates the migration file
4. Regenerates routes/generated_routes.go and prints the route list
Custom Scaffold Generators#
If an executable exists at ./cmd/gen/gen, anar scaffold runs it instead of the built-in generator.
What Gets Generated#
- Handler files:
handlers/<model>_handler.gofor each model - Migration file:
migrations/auto_migrations.go - Routes:
routes/generated_routes.go
Example Scaffold Output#
Generating project scaffold using built-in generator...
Generated 2 handler files in handlers/ directory:
- author_handler.go
- post_handler.go
Generated single migration file: migrations/auto_migrations.go
Models included: Product, Author, Post
Updated routes/generated_routes.go (15 routes)
METHOD PATH HANDLER AUTH PERM SOURCE
GET /api/v1/authors GetAllAuthors jwt view handlers/author_handler.go:11
...
Routes#
Routes are registered in the routes package in two ways, which work side by side:
- Generated routes. A handler declares its route with an
//anar:routecomment, andanar routeswritesroutes/generated_routes.go. This is how CRUD handlers made byanar handlers/anar scaffoldget their routes. - Hand-written routes in
registerRoutesinroutes/roots.go, such as login, registration and OAuth.
Files#
| File | Who edits it | Purpose |
|---|---|---|
routes/roots.go |
You | SetupRoutes, which mounts one group per API version (and the unversioned alias), and registerRoutes, which holds the hand-written routes every version serves. |
routes/middleware.go |
You (rarely) | The middlewares set generated routes use (JWT, permissions and cache). |
routes/generated_routes.go |
anar routes only |
Generated from the directives. Never edit it; it is overwritten. |
Declaring a route#
Put one or more directives directly above a handler method in handlers/:
//anar:route GET /products perm=view
func (h Handler) GetAllProducts(c *gin.Context) { ... }
//anar:route POST /contact auth=public
func (h Handler) Contact(c *gin.Context) { ... }
//anar:route GET /reports/:id perm="group:admin OR view_reports"
func (h Handler) GetReport(c *gin.Context) { ... }
Format: //anar:route METHOD PATH [options], with no space after //.
METHOD:GET,POST,PUT,PATCHorDELETE.PATH: relative to the version's group (/api/v1,/api/v2, โฆ). Lower-case kebab-case segments,:paramparameters, and an optional final*wildcard.
| Option | Default | Meaning |
|---|---|---|
auth=jwt / auth=public |
jwt |
Require a valid access token, or allow anyone. |
perm=<expression> |
none | Also require a permission, using the same expressions as RequirePermissionsString: permission codes, group:<name>, AND, OR, XOR, NOT, parentheses. Quote it if it has spaces. Users with admin: true always pass. |
name=<identifier> |
handler name | Name shown in anar routes:list. |
cache=<duration> |
none | Cache successful responses for this long, from 1s to 24h. GET routes only. See Response Caching. |
tags=<a,b> |
the route's resource | Resources the cached response depends on, for invalidation. Needs cache=. |
version=<N> |
1 |
First API version that serves the route. See API Versioning. |
until=<M> |
none | Last API version that serves the route. |
Routes are protected by default: without auth=public, a route requires a token. A handler that should deliberately have no route gets //anar:noroute, which stops anar routes from warning about it.
Generated CRUD routes#
anar handlers and anar scaffold write these directives for each model, then run anar routes:
| Handler | Route | Permission |
|---|---|---|
GetAll<Plural> |
GET /api/v1/<plural> |
view |
Get<Model>ByID |
GET /api/v1/<plural>/:id |
view |
Add<Model> |
POST /api/v1/<plural> |
create |
Update<Model> |
PUT and PATCH /api/v1/<plural>/:id (applies the fields sent) |
update |
Delete<Model> |
DELETE /api/v1/<plural>/:id |
delete |
GetAll<Plural>Simple |
none (//anar:noroute; unpaginated) |
<plural> is the kebab-case plural of the model: OrderItem gives /api/v1/order-items, Category gives /api/v1/categories. The view, create, update and delete permissions and the read, write and read_write groups exist in every new project. Grant them to users through /api/v1/permissions, or edit perm= on a handler to use your own.
To make a route public, change its directive (for example //anar:route GET /products auth=public) and run anar routes.
API Versioning#
The API is versioned in the URL. Routes are served under /api/v1, and each new version you declare gets its own prefix (/api/v2, โฆ). A route's directive says which versions serve it:
| Option | Meaning |
|---|---|
version=N |
The first version that serves the route. The default is 1. |
until=M |
The last version that serves it. It is gone from version M+1 on. |
Each version serves every route active in it, so a new version inherits everything from the one before. To change an endpoint in v2, add a handler for the same method and path with version=2. It replaces the old one from v2 on, while v1 keeps the original:
//anar:route GET /products perm=view
func (h Handler) GetAllProducts(c *gin.Context) { ... } // v1
//anar:route GET /products perm=view version=2
func (h Handler) GetAllProductsV2(c *gin.Context) { ... } // v2 and later
To drop an endpoint from v2, mark it until=1. The versions are 1 through the highest version=, or one past the highest until=. anar routes writes this list into generated_routes.go as generatedVersions, and SetupRoutes mounts one group per version. Hand-written routes in registerRoutes are mounted on every version. anar routes:list, anar openapi and the conflict checks all work per version.
Unversioned paths. Projects created before versioning served everything at /api/.... While API_LEGACY_ROUTES is on (the default), those paths still serve version 1, with two extra headers telling clients where to go:
Deprecation: true
Link: </api/v1/products>; rel="successor-version"
Move your clients to /api/v1, then set API_LEGACY_ROUTES=false. Links the server sends (email confirmation, password reset, provider linking) already use /api/v1. While the setting is on, the default OAuth callback URL stays at /api/auth/oauth/<name>/callback, so URLs already registered at providers keep working. Check them before turning it off.
Older projects. A project whose SetupRoutes still calls registerGeneratedRoutes(api, handler, mw), with three arguments, keeps unversioned routes, and anar routes rejects version= and until= there. To adopt versioning, copy the apiPrefix constant, SetupRoutes and registerRoutes layout from routes/roots.go in anar-core v0.11.0, then run anar routes.
Commands#
| Command | What it does |
|---|---|
anar routes |
Check every directive and regenerate routes/generated_routes.go. Errors show file and line. |
anar routes --check |
Change nothing; fail if the generated file is out of date or there are errors. Add --strict to also fail on handlers that have no route. Use it in CI. |
anar routes:list |
List all routes, generated and hand-written. |
anar routes --annotate [--write] |
Add directives to existing CRUD handlers that match the generated names. Dry run without --write. |
anar routes --install [--write] |
Set up generated routes in a project created before they existed. Dry run without --write. |
anar routes refuses to generate when a route would clash: the same method and path in two places (generated or hand-written), or the same path with different parameter names, which Gin rejects at startup.
Adding a hand-written route#
For routes that need custom middleware or don't belong to a handler method, add them to registerRoutes in routes/roots.go. Every API version serves them:
api.GET("/health", handler.Health) // public
protected.POST("/reports/rebuild", handler.RebuildReports) // requires a token
anar routes reads these too, so a clash with a generated route is reported.
Projects created before generated routes#
anar routes --install --annotate # show what would change
anar routes --install --annotate --write # apply it and generate the routes
--install adds routes/middleware.go and the registerGeneratedRoutes(...) call. --annotate adds directives only to handlers whose names exactly match the generated CRUD handlers of your models. Any hand-written route you already added for those handlers is reported as a clash; delete either the hand-written line or the directive.
Handler System in Anar#
In Anar, the handler layer acts as the bridge between incoming HTTP requests and the database. Each handler reaches the Database struct (in the db package) through the Handler base structure, which also gives access to the logger, configuration, and file manager.
Base Handler Overview#
The base handler is defined in handlers/handlers.go:
type Handler struct {
App *app.App
FileManager *filemanager.FileManager
}
This struct provides shared access to:
App: the main application container, giving access to the database (App.Database), configuration (App.Config), logger (App.Logger), cache (App.Cache) and job queue (App.Dispatcher).FileManager: manages file uploads and deletions, using S3 whenS3_ENABLED=trueandUPLOAD_DIRotherwise.
The base handler is initialized using:
func Init(a *app.App) *Handler {
h := &Handler{
App: a,
FileManager: filemanager.New(a.Config),
}
if err := h.App.Database.AddAdmin(a.Config.Admin); errors.Is(err, db.ErrNoAdminPassword) {
h.App.Logger.Warn("There are no users and ADMIN_PASSWORD is not set, so no admin account was created")
} else if err != nil {
h.App.Logger.Errorf("Failed to create the admin account: %v", err)
}
return h
}
AddAdmin creates the first admin account from ADMIN_* settings when there are no users yet (see The First Admin Account).
Database Layer and Its Integration#
The Database struct in the db package (db/db.go, with CRUD helpers in db/crud.go) wraps GORM, providing a unified interface for database operations.
It supports PostgreSQL, MySQL, SQLite, and SQL Server.
type Database struct {
db *gorm.DB
}
Initialization#
The database is initialized with the configuration provided by config.Config:
func Init(log *anarlogger.Logger, cfg *config.Config) Database
This function:
- Reads configuration such as driver type, host, credentials, and SSL mode.
- Establishes a connection using the appropriate GORM driver.
- If
MIGRATIONS_ENABLEDis true, migrates the built-in models and runsmigrations.AutoMigrate. - Creates the default permissions and groups.
Core Database Methods#
The Database struct provides CRUD and query helper methods to simplify handler development.
a. FindAll#
func (self Database) FindAll(model any) error
Fetches all records into model (a pointer to a slice) without pagination or filtering.
b. FindAllWithPagination#
func (self Database) FindAllWithPagination(model any, params QueryParams, searchFields []string) (*PaginationResult, error)
This method supports:
- Search: Case-insensitive substring matching (
LOWER(column) LIKE ..., which works on every supported database) across the search fields. - Filters: Field-based filtering; numbers match exactly and text matches case-insensitively.
- Sorting: Ascending or descending.
- Pagination: Offset-based pagination with full metadata (total, page count, has next, has previous).
Only real columns of the model are used; other names in filters or sorting are ignored.
QueryParams is a structured representation of request parameters:
type QueryParams struct {
Search string
Page int
PerPage int
SortBy string
SortDesc bool
Filters map[string]string
}
The returned PaginationResult contains both the data and pagination information.
c. FindAllWithCondition#
func (self Database) FindAllWithCondition(
model any,
params QueryParams,
searchFields []string,
conditionField string,
conditionValue interface{},
) (*PaginationResult, error)
An extended version of FindAllWithPagination that adds a mandatory condition, commonly used to fetch records related to a specific parent entity (for example, all comments belonging to a post).
d. FindByID#
func (self Database) FindByID(model any, id any) error
Fetches a single record by its primary key. Returns an error if not found.
e. FindByField#
func (self Database) FindByField(model any, field string, value interface{}) error
Fetches the first record that matches a specific field-value pair.
Useful for unique fields like email, username, or token. field must be a column of the model, given as its column name (password_reset_token) or Go field name (PasswordResetToken). Anything else returns an error, so it can't be used to inject SQL.
f. Create#
func (self Database) Create(model any) error
Inserts a new record into the database.
g. Update#
func (self Database) Update(model any, id any) error
Updates the record with the given ID. If model was loaded from the database and edited (its ID equals id), every field is saved. If it was bound from a request body (no ID), only its non-zero fields are applied, so fields the client left out keep their stored values.
h. Delete#
func (self Database) Delete(model any, id any) error
Removes a record by its ID, after checking that it exists. Returns gorm.ErrRecordNotFound when there is none, or the error from the delete itself.
Working with the Base Handler#
The base handler provides generic methods that handle CRUD operations for any model. You pass a model value such as models.Product{} only to tell the method which type to use.
a. Fetching Data#
Get All (with Pagination)#
func (h Handler) getAllWithPagination(modelType interface{}, searchFields []string, c *gin.Context)
- Parses query parameters using
utils.ParseQueryParams. - Uses the database method
FindAllWithPagination. - Returns a paginated response.
Get All (Simple)#
func (h Handler) getAll(modelType interface{}, c *gin.Context)
Fetches all records without pagination.
Get By ID#
func (h Handler) getByID(modelType interface{}, c *gin.Context, notFoundMsg string)
Fetches a single record using its ID and returns 404 if not found.
b. Creating Records#
func (h Handler) add(modelType interface{}, c *gin.Context)
This function:
- Detects the request type (JSON or multipart).
- Binds the data.
- Processes file uploads via
FileManager.ProcessFileFields. - Persists the record using
Database.Create. - Returns HTTP 201 with the created record.
c. Updating Records#
func (h Handler) update(modelType interface{}, c *gin.Context, notFoundMsg string)
- Detects the request format.
- Binds the data and notes which fields were sent: the JSON keys, or the form fields.
- Processes file fields if any.
- Validates the fields that were sent, including any sent with a zero value.
- Calls
Database.UpdateFields, which writes exactly those columns, zero values included. - Returns HTTP 200 with no body.
d. Deleting Records#
func (h Handler) delete(modelType interface{}, c *gin.Context, notFoundMsg string)
- Fetches the item by ID.
- Removes any associated files using
FileManager.DeleteFileFields. - Deletes the record using
Database.Delete. - Returns HTTP 204 (No Content) if successful.
Creating a Manual Handler#
Besides generated handlers, you can write your own to have full control over logic, validation, and flow. A handler for a specific model can reuse the base functions. The template's handlers/product_handler.go does this:
//anar:route GET /products perm=view
func (h Handler) GetAllProducts(c *gin.Context) {
searchFields := []string{"name", "description", "category", "imageurl"}
h.getAllWithPagination(models.Product{}, searchFields, c)
}
//anar:route GET /products/:id perm=view
func (h Handler) GetProductByID(c *gin.Context) {
h.getByID(models.Product{}, c, "Product not found")
}
Add an //anar:route directive and run anar routes to expose a handler, or register it by hand in routes/roots.go (see Routes).
Summary#
| Layer | Responsibility | Key Methods / Structs |
|---|---|---|
db |
Database abstraction using GORM | FindAll, FindByID, Create, Update, Delete |
handlers |
HTTP interface for CRUD operations | getAllWithPagination, add, update, delete |
Handler base |
Shared context (App, FileManager) | Initialized with Init() |
filemanager |
File uploads & cleanup | ProcessFileFields, DeleteFileFields |
Jobs and Queues#
Anar runs background work, such as sending email or processing images, as jobs on a queue powered by anarqueue. The queue and its workers start with the application when QUEUE_ENABLED=true.
Creating a Job#
anar job SendWelcomeEmail emails
The second argument is the job's default queue (default if omitted). This creates jobs/send_welcome_email_handler.go and updates jobs/registry.go:
// SendWelcomeEmailHandler handles "send_welcome_email" jobs
// Default queue: "emails"
func SendWelcomeEmailHandler(ctx context.Context, job *queue.Job) error {
payload, ok := job.Payload.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid job payload")
}
log.Printf("Processing SendWelcomeEmail job: %+v", payload)
// TODO: Implement job logic here
time.Sleep(2 * time.Second)
log.Printf("SendWelcomeEmail job completed successfully")
return nil
}
Replace the body with your job's work. Return an error to have the job retried, up to QUEUE_MAX_ATTEMPTS times.
Dispatching a Job#
The generated file also contains two dispatch helpers:
// Run as soon as a worker is free
err := jobs.DispatchSendWelcomeEmail(ctx, h.App.Dispatcher, "", map[string]interface{}{
"user_id": user.ID,
})
// Run after a delay
err := jobs.DispatchSendWelcomeEmailAfter(ctx, h.App.Dispatcher, "", 10*time.Minute, map[string]interface{}{
"user_id": user.ID,
})
Pass "" as the queue name to use the job's default queue.
Listening on Queues#
Workers only take jobs from the queues listed in QUEUE_LISTEN_QUEUES, which defaults to default. A job dispatched to any other queue waits until a worker listens on it, so add every queue you use:
QUEUE_LISTEN_QUEUES=default,emails
Use QUEUE_TYPE=redis in production: jobs in the memory queue are lost when the process stops.
Authentication in Anar#
Anar provides an authentication system that combines email/password login, two-factor authentication, and OAuth / OpenID Connect login.
Overview#
Authentication in Anar consists of several layers:
- User Registration and Email Confirmation
- Login and Token Generation
- Access Token Refresh
- Password Reset and Change
- OAuth 2.0 / OpenID Connect Login
- Two-Factor Authentication (OTP)
- Protected Routes via JWT Middleware
User Registration and Email Confirmation#
When a new user registers using the /api/v1/register endpoint, the following happens:
- The server validates the request body (
name,emailandpassare required). - The user is created with
confirmedset to false. - A confirmation email is sent, depending on
EMAIL_VERIFICATION_MODE: *link(default): a link to/api/v1/confirm/<token>. *code: a numeric code that expires afterEMAIL_VERIFICATION_CODE_TTLseconds.
Example Request:
POST /api/v1/register
{
"name": "john_doe",
"email": "john@example.com",
"pass": "mypassword"
}
Example Response (201):
{
"message": "User created, please check your email to confirm"
}
A password shorter than PASSWORD_MIN_LENGTH (8 characters by default) is rejected with 422. The same check applies when a password is changed or reset:
{
"error": {
"code": "validation_failed",
"message": "Validation failed",
"fields": {"pass": "must be at least 8 characters long"}
}
}
In link mode, opening GET /api/v1/confirm/:token confirms the account. In code mode, the client sends the code:
POST /api/v1/confirm/code
{
"email": "john@example.com",
"code": "123456"
}
Both respond with {"message": "Email confirmed successfully"}.
Login and JWT Token Generation#
After confirmation, users can log in using /api/v1/login:
POST /api/v1/login
{
"name": "john_doe",
"pass": "mypassword"
}
The process is as follows:
- The handler looks up the user by name (404 if not found).
- Unconfirmed users are refused with 403.
- The password is checked (400 if wrong).
- If the user has OTP enabled, an
otpfield is also required (see OTP). - On success, two tokens are returned:
- Access token: used for API access. Expires after
JWT_EXPIRATION(15 minutes by default). - Refresh token: used to get a new token pair. Expires after
REFRESH_TOKEN_TTL(7 days by default).
Example Response:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6...",
"token_type": "Bearer",
"expires_in": 900
}
expires_in is the access token's lifetime in seconds. Refresh it before then.
The access token's claims include id (user ID), name, admin (true for admins), iss, exp and typ.
Token Refresh#
To obtain a new pair of tokens without logging in again, send a POST /api/v1/refresh request with the current refresh token:
POST /api/v1/refresh
{
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6..."
}
The backend validates the token, marks it used and issues a new access/refresh pair (jwt_helpers.RotateRefreshToken()). Each refresh token works once. Presenting one that was already used is treated as theft: every token in that login's family is revoked and the user must log in again.
Access and refresh tokens carry a typ claim. The JWT middleware accepts only access tokens and /api/v1/refresh accepts only refresh tokens.
Logout#
To log out, send the refresh token. No access token is needed, so this works even after the access token has expired:
POST /api/v1/logout
{
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6..."
}
The response is 204 No Content. That token and every token from the same login stop working, while the user's other devices stay logged in. Logging out again, or with an expired token, also returns 204. A malformed or forged token returns 401.
To log out of every device, an authenticated user calls:
POST /api/v1/logout/all
Authorization: Bearer <access_token>
This revokes all of the user's refresh tokens. Access tokens are stateless and stay valid until they expire, so the client should discard its own tokens on logout. A short JWT_EXPIRATION keeps that window small.
Password Reset and Change#
A user who forgot their password requests a reset link:
POST /api/v1/forgot-password
{"email": "john@example.com"}
The response is always {"message": "If the email exists, a reset link was sent"}, so it does not reveal which emails have accounts. The email contains a link to /api/v1/reset-password/<token>, valid for 15 minutes and usable once. Only a SHA-256 hash of the token is stored, so a copy of the database can't be used to reset passwords. The new password is sent to that URL:
POST /api/v1/reset-password/<token>
{"new_password": "my-new-password"}
A successful reset logs the user out everywhere: every refresh token they hold is revoked, and they log in again with the new password.
A logged-in user changes their password with:
POST /api/v1/change-password
Authorization: Bearer <access token>
{"old_password": "mypassword", "new_password": "my-new-password"}
Changing the password revokes every existing login of the user, including other devices. The response carries a fresh token pair so the device that made the change stays logged in:
{
"message": "Password changed successfully",
"access_token": "...",
"refresh_token": "...",
"token_type": "Bearer",
"expires_in": 900
}
Replace the client's stored tokens with these.
OAuth 2.0 / OpenID Connect Login#
Anar can log users in with any OpenID Connect provider (Keycloak, Authentik, GitLab, Entra ID, Okta, ...) and with Google, Apple, GitHub and Facebook. The protocol work is done by anar-oauth, a separate, framework-independent package. Anar core adds accounts, linking, tokens and routes.
What is enforced#
- Authorization code flow with PKCE (S256) for every provider.
- The
statevalue is single-use and bound to the browser that started the login (an HttpOnly cookie), which stops replay and login CSRF. - OpenID Connect ID tokens are fully validated: signature against the provider's keys, issuer, audience, expiry and nonce.
- Returning users are recognised by the provider's stable user ID, never by email.
- No tokens in URLs: the browser receives a one-time code and the frontend exchanges it.
- Users with OTP enabled must enter a code after OAuth login too.
Login flow#
- The login button navigates the browser to
/api/v1/auth/oauth/<provider>/authorize. Use a link orwindow.location, notfetch. An optional?return_to=must matchFRONTEND_SUCCESS_URLorOAUTH_ALLOWED_RETURN_URLS. - The user signs in at the provider, which redirects back to
/api/v1/auth/oauth/<provider>/callback(Apple uses a POST). - Anar verifies the callback, finds or creates the user, and redirects to
FRONTEND_SUCCESS_URL#code=<one-time code>. - The frontend immediately sends the code to the API:
POST /api/v1/auth/oauth/exchange
{"code": "<code from the URL fragment>"}
The response is the same token pair as password login:
{"access_token": "...", "refresh_token": "...", "token_type": "Bearer", "expires_in": 900}
If the user has OTP enabled, it is instead:
{"mfa_required": true, "mfa_ticket": "..."}
The frontend asks for the code and sends it:
POST /api/v1/auth/mfa/verify
{"mfa_ticket": "...", "otp": "123456"}
The ticket works once; after a wrong code the user starts the login again. Codes expire after OAUTH_LOGIN_CODE_TTL (1 minute) and tickets after OAUTH_MFA_TICKET_TTL (5 minutes).
On failure the browser lands on FRONTEND_ERROR_URL#error=<code>:
| Code | Meaning |
|---|---|
access_denied |
The user cancelled at the provider. |
invalid_state |
The login expired, was already used, or was started in another browser. |
account_exists |
An account with this email exists; log in and link the provider instead. |
email_required |
The provider gave no email address. |
email_unverified |
The provider did not verify the email, so sign-up was refused. |
signup_disabled |
New accounts cannot be created through OAuth. |
identity_taken |
That provider account is already linked to another user. |
login_not_allowed |
An application hook refused the login. |
unknown_provider, invalid_link_ticket, provider_error, server_error |
Configuration or provider problems; details are in the server log. |
Linked accounts#
A user can sign in with several providers. The protected endpoints are:
| Endpoint | Method | Description |
|---|---|---|
/api/v1/me/identities |
GET | List linked providers. |
/api/v1/me/identities/:provider |
POST | Returns {"authorize_url": ...}, a single-use URL valid for 2 minutes. Navigate the browser to it to link the provider. Success lands on FRONTEND_SUCCESS_URL#linked=<provider>. |
/api/v1/me/identities/:id |
DELETE | Unlink. Refused (409) if it is the user's only way to log in. |
Adding a provider#
Most providers need only configuration. Any OpenID Connect provider:
OAUTH_PROVIDERS=google,keycloak
OAUTH_KEYCLOAK_TYPE=oidc
OAUTH_KEYCLOAK_ISSUER=https://id.example.com/realms/main
OAUTH_KEYCLOAK_CLIENT_ID=anar
OAUTH_KEYCLOAK_CLIENT_SECRET=...
A plain OAuth 2.0 service: set TYPE=oauth2 with AUTH_URL, TOKEN_URL, USERINFO_URL and CLAIM_* mappings. For anything else, implement the oauth.Provider interface from anar-oauth and register its type with providers.Register before the app starts.
To customise sign-up or login (e.g. allow only one company domain, or assign groups to new users), set hooks on app.Identity.Hooks: MapIdentity, BeforeSignup, AllowLogin, AfterLogin.
Provider Setup#
Register https://<your API>/api/auth/oauth/<name>/callback as the redirect URL at each provider. This unversioned path is the default while API_LEGACY_ROUTES is on. If you turn it off, register https://<your API>/api/v1/auth/oauth/<name>/callback instead, or pin the URL with OAUTH_<NAME>_REDIRECT_URL.
Google#
- In Google Cloud Console, go to APIs & Services, then Credentials, then Create credentials, then OAuth client ID.
- Application type: Web application. Add the redirect URL.
- Set
OAUTH_GOOGLE_CLIENT_IDandOAUTH_GOOGLE_CLIENT_SECRET.
Apple#
- In the Apple Developer Portal, create an App ID with Sign in with Apple, then a Services ID for the web (its identifier is the client ID).
- Configure the Services ID with your domain and the redirect URL. Apple requires HTTPS.
- Create a Sign in with Apple key and download the
.p8file. - Set
OAUTH_APPLE_CLIENT_ID,OAUTH_APPLE_TEAM_ID,OAUTH_APPLE_KEY_IDandOAUTH_APPLE_PRIVATE_KEY_FILE.
Apple sends the user's name only on their first sign-in. Anar stores it on the linked identity then.
GitHub#
- GitHub, then Settings, then Developer settings, then OAuth Apps, then New OAuth App.
- Set the Authorization callback URL to the redirect URL.
- Set
OAUTH_GITHUB_CLIENT_IDandOAUTH_GITHUB_CLIENT_SECRET.
Only the user's primary, verified GitHub email is used.
Facebook#
- In Meta for Developers, create an app and add Facebook Login.
- Add the redirect URL under Valid OAuth Redirect URIs.
- Set
OAUTH_FACEBOOK_CLIENT_ID(App ID) andOAUTH_FACEBOOK_CLIENT_SECRET(App Secret).
Protected Routes and JWT Middleware#
Once authenticated, users can access protected routes guarded by the JWT middleware. Generated routes use it by default (auth=jwt). For hand-written routes, registerRoutes in routes/roots.go already sets up a protected group:
protected := api.Group("/")
protected.Use(authRequired) // jwt.NewJWTMiddleware(a.Config).AuthRequired()
When a request is made to a protected route:
- The
Authorization: Bearer <token>header is validated: signature, expiry, issuer, and that it is an access token. Otherwise the response is 401. - The token's claims are stored in the Gin context as
claims. - Handlers can read these claims for additional logic.
The jwt_helpers package has helpers for the common cases:
userID, ok := jwt_helpers.UserID(c) // the logged-in user's ID
if !jwt_helpers.IsUserAdmin(c) {
httperr.Write(c, http.StatusForbidden, "admin_required", "Admin access required")
return
}
You can also read the claims directly:
claims := c.MustGet("claims").(jwt.MapClaims)
name, _ := claims["name"].(string)
OTP (Two-Factor Authentication)#
Anar includes two-factor authentication with TOTP (Time-based One-Time Passwords), compatible with authenticator apps. It adds a second step on top of the password.
OTP_ENCRYPTION_KEY must be set to a 32-byte key; without it, enabling OTP fails.
Features#
- TOTP-based authentication using
anar-otp - Encrypted storage of OTP secrets
- QR code generation
- 10 single-use backup recovery codes
- Regeneration of recovery codes
- Disabling OTP with password and code
- OTP is also required after OAuth login
Security Model#
| Component | Security |
|---|---|
| User password | Hashed (irreversible) |
| OTP Secret | AES-256-GCM encrypted |
| Recovery codes | AES-256-GCM encrypted |
| Secrets in memory | Decrypted only when needed |
OTP secrets are never stored as plaintext in the database.
OTP Lifecycle#
1. Enabling OTP#
The logged-in user starts setup by calling:
POST /api/v1/otp/enable
The server will:
- Generate and encrypt a secret
- Generate a QR code
- Generate 10 backup recovery codes
- Return the QR code, secret and recovery codes
Response:
{
"qr_code": "BASE64_IMAGE",
"secret": "RAW_SECRET",
"recovery_codes": [
"A1B2C3D4E5",
"F6G7H8I9J0"
]
}
Recovery codes are shown only once. OTP is not active yet: the user must verify a code first.
2. Verifying OTP#
After scanning the QR code, the user confirms setup:
POST /api/v1/otp/verify
Request:
{
"code": "123456"
}
On success:
{
"message": "OTP enabled successfully"
}
3. Login With OTP#
If OTP is enabled, the login request needs an extra otp field:
POST /api/v1/login
{
"name": "john_doe",
"pass": "mypassword",
"otp": "123456"
}
The otp field also accepts a recovery code. Each recovery code works once.
4. Regenerate Recovery Codes#
POST /api/v1/otp/recovery/regenerate
This replaces the old recovery codes and returns the new ones once, as {"recovery_codes": [...]}.
5. Disable OTP#
POST /api/v1/otp/disable
Request:
{
"password": "mypassword",
"otp": "123456"
}
This verifies the password and the code, then clears the secret and recovery codes.
Error Handling#
Common error responses:
| Status | Message |
|---|---|
| 401 | OTP or recovery code required (login without otp) |
| 401 | Invalid OTP or recovery code (login) |
| 401 | Invalid OTP code (verify, disable) |
| 401 | Invalid password (disable) |
| 400 | OTP not enabled (regenerate) |
| 404 | User not found |
Flow Summary#
Enable OTP -> Scan QR code -> Verify OTP -> OTP required on login
(or use a recovery code)
Best Practices#
- Store
OTP_ENCRYPTION_KEYsecurely, and never change it once users have enabled OTP: existing secrets could no longer be decrypted. - Never expose decrypted secrets
- Never log OTP secrets
- Show recovery codes only once
Example cURL#
Enable OTP:
curl -X POST http://localhost:8002/api/otp/enable \
-H "Authorization: Bearer TOKEN"
Verify OTP:
curl -X POST http://localhost:8002/api/otp/verify \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"code":"123456"}'
Summary of Endpoints#
| Endpoint | Method | Description | Auth Required |
|---|---|---|---|
/api/v1/register |
POST | Register new user | No |
/api/v1/confirm/:token |
GET | Confirm email (link mode) | No |
/api/v1/confirm/code |
POST | Confirm email (code mode) | No |
/api/v1/login |
POST | Login with credentials | No |
/api/v1/refresh |
POST | Refresh JWT tokens | No |
/api/v1/logout |
POST | Revoke a refresh token's login | No |
/api/v1/forgot-password |
POST | Email a password reset link | No |
/api/v1/reset-password/:token |
POST | Set a new password from a reset link | No |
/api/v1/auth/oauth/providers |
GET | List enabled login providers | No |
/api/v1/auth/oauth/:provider/authorize |
GET | Start OAuth login (browser navigation) | No |
/api/v1/auth/oauth/:provider/callback |
GET/POST | OAuth callback | No |
/api/v1/auth/oauth/exchange |
POST | Trade one-time code for tokens | No |
/api/v1/auth/mfa/verify |
POST | Finish a login that needs OTP | No |
/api/v1/change-password |
POST | Change password | Yes |
/api/v1/logout/all |
POST | Log out of every device | Yes |
/api/v1/me/identities |
GET | List linked providers | Yes |
/api/v1/me/identities/:provider |
POST | Get a URL to link a provider | Yes |
/api/v1/me/identities/:id |
DELETE | Unlink a provider | Yes |
/api/v1/otp/enable |
POST | Start OTP setup (QR + recovery codes) | Yes |
/api/v1/otp/verify |
POST | Verify OTP to activate it | Yes |
/api/v1/otp/disable |
POST | Disable OTP (password + OTP) | Yes |
/api/v1/otp/recovery/regenerate |
POST | Generate new recovery codes | Yes |
/api/v1/users |
GET | List users | Yes, admin |
/api/v1/users/:id |
GET | Get a user | Yes, admin or that user |
/api/v1/users |
POST | Create a user | Yes, admin |
/api/v1/users/:id |
PUT | Update a user | Yes, admin |
/api/v1/users/:id |
DELETE | Delete a user | Yes, admin |
The permission management endpoints are listed under Permission System.
Summary#
The Anar authentication system is designed to be:
- Secure: Short-lived access tokens, single-use rotating refresh tokens, encrypted OTP secrets.
- Flexible: Supports local login and any OpenID Connect or OAuth 2.0 provider, with linked accounts.
- Extensible: Integrates with the permissions and groups middleware for role-based access control.
- Production-ready: Includes email confirmation, password reset, two-factor authentication, and fine-grained route protection.
Permission System#
Overview#
Anar implements a role-based access control (RBAC) system to manage user permissions. It supports: * Direct user permissions * Group-based permissions * Dynamic assignment and removal of permissions
Users whose is_admin field is true pass every permission check.
Data Models#
User#
The User model stores user data, including authentication and roles. Its permission-related fields are:
type User struct {
gorm.Model
Name string `json:"name" gorm:"unique;not null"`
Pass types.PasswordField `json:"pass"`
Email string `json:"email" gorm:"unique;not null"`
Is_admin bool `json:"is_admin"`
Confirmed bool `json:"confirmed" gorm:"default:false"`
// ... OTP, email confirmation and password reset fields ...
Groups []*Group `gorm:"many2many:user_groups;"`
Permissions []*Permission `gorm:"many2many:user_permissions;"`
}
Key points:
Groupsallows users to inherit permissions from groups.Permissionsallows direct assignment of permissions.
Permission#
A Permission defines a single action:
type Permission struct {
gorm.Model
Code string `json:"code" gorm:"unique;not null"`
Description string `json:"description"`
}
Group#
A Group aggregates permissions that its users inherit:
type Group struct {
gorm.Model
Name string `json:"name" gorm:"unique;not null"`
Permissions []*Permission `gorm:"many2many:group_permissions;"`
}
Default Setup#
When the database is initialized, these are created if they don't exist:
| Permission | Description |
|---|---|
view |
View records |
create |
Create records |
update |
Update records |
delete |
Delete records |
| Group | Permissions |
|---|---|
read |
view |
write |
create, update, delete |
read_write |
view, create, update, delete |
Generated CRUD routes require these permissions (see Routes).
API Routes#
All permission routes require a logged-in admin; other users get 403.
Permission Management#
| Method | Route | Description |
|---|---|---|
| GET | /api/v1/permissions/ |
List all permissions (supports pagination & search) |
| GET | /api/v1/permissions/:id |
Get a single permission by ID |
| POST | /api/v1/permissions/ |
Create a new permission |
| PUT | /api/v1/permissions/:id |
Update an existing permission |
| DELETE | /api/v1/permissions/:id |
Delete a permission |
Example: create a permission
POST /api/v1/permissions/
{
"code": "export_data",
"description": "Allow exporting data"
}
Assigning Permissions to Users#
| Method | Route | Description |
|---|---|---|
| POST | /api/v1/permissions/user/:user_id/:perm_id |
Assign a permission to a user |
| DELETE | /api/v1/permissions/user/:user_id/:perm_id |
Remove a permission from a user |
For example, POST /api/v1/permissions/user/5/2 gives permission 2 to user 5, and DELETE /api/v1/permissions/user/5/2 removes it.
Assigning Permissions to Groups#
| Method | Route | Description |
|---|---|---|
| POST | /api/v1/permissions/group/:group_id/:perm_id |
Assign a permission to a group |
| DELETE | /api/v1/permissions/group/:group_id/:perm_id |
Remove a permission from a group |
For example, POST /api/v1/permissions/group/3/1 gives permission 1 to group 3.
Adding Users to Groups#
There are no built-in routes for group membership yet. Use the database helpers from your own handler:
err := h.App.Database.AddUserToGroup(userID, groupID)
err := h.App.Database.RemoveUserFromGroup(userID, groupID)
Permission Caching#
When caching is enabled, each user's permissions and groups are cached for CACHE_TTL seconds. The permission routes above clear the cache, so their changes apply immediately. If you change permissions or group membership directly in the database (for example with AddUserToGroup), call utils.ClearAllPermissionCaches(h.App.Cache) afterwards, or the old permissions apply until the cache expires.
Using Permissions in Routes#
For generated routes, write the expression in the directive's perm= option:
//anar:route GET /reports perm="view AND export_data"
func (h Handler) GetReports(c *gin.Context) { ... }
For hand-written routes, attach the permission middleware. routes/roots.go already creates it:
permissionmiddleware := permission.NewPermissionMiddleware(a)
protected.GET("/reports", permissionmiddleware.RequirePermissionsString("view AND export_data"), handler.GetReports)
The route must also be behind the JWT middleware. A user without the required permissions gets 403 Forbidden.
Expression Syntax#
| Expression | Meaning |
|---|---|
view |
Has the view permission (directly or through a group). |
group:editor |
Belongs to the editor group. |
update OR delete |
Has at least one of them. |
view AND export_data |
Has both. |
role_a XOR role_b |
Has exactly one of them. |
NOT group:guest |
Is not in the guest group. |
(group:admin OR group:editor) AND (create_post OR edit_post) |
Parentheses group conditions. |
Operators and permission names are case-insensitive.
Operator Order#
Operators have no precedence: an expression is read from left to right, and everything to the right of an operator is evaluated first. So:
a AND b OR cmeansa AND (b OR c).NOT a AND bmeansNOT (a AND b), not(NOT a) AND b.
Use parentheses whenever you combine different operators:
// Admins or managers, with create or edit permission, who are not banned
permissionmiddleware.RequirePermissionsString("((group:admin OR group:manager) AND (create OR edit)) AND (NOT banned)")
Manually Checking Permissions in Handlers#
Sometimes you need to check a permission inside a handler rather than in the route. Use UserHasPermission from the db package:
func (d Database) UserHasPermission(userID uint, permCode string) (bool, error)
It returns true if the user has the permission directly or through one of their groups. It does not treat admins specially.
// ExportProducts lets only users with "export_data" download products
func (h Handler) ExportProducts(c *gin.Context) {
userID, ok := jwt_helpers.UserID(c)
if !ok {
httperr.Write(c, http.StatusUnauthorized, httperr.Unauthorized, "Unauthorized")
return
}
hasPerm, err := h.App.Database.UserHasPermission(userID, "export_data")
if err != nil {
h.App.Logger.Errorf("Failed to check permission: %v", err)
httperr.Write(c, http.StatusInternalServerError, httperr.Internal, "Failed to verify permission")
return
}
if !hasPerm && !jwt_helpers.IsUserAdmin(c) {
httperr.Write(c, http.StatusForbidden, "permission_denied", "Permission denied")
return
}
// ... export logic ...
}
Listing a User's Permissions and Groups#
func (d Database) ListUserPermissions(userID uint) ([]string, error)
func (d Database) ListUserGroups(userID uint) ([]string, error)
ListUserPermissions returns the codes of every permission the user has, directly or through groups, without duplicates. ListUserGroups returns the names of the user's groups.
func (h Handler) GetMyPermissions(c *gin.Context) {
userID, _ := jwt_helpers.UserID(c)
perms, err := h.App.Database.ListUserPermissions(userID)
if err != nil {
h.App.Logger.Errorf("Failed to get user permissions: %v", err)
httperr.Write(c, http.StatusInternalServerError, httperr.Internal, "Failed to retrieve permissions")
return
}
c.JSON(http.StatusOK, gin.H{"user_id": userID, "permissions": perms})
}
Example response:
{
"user_id": 5,
"permissions": ["view", "create", "export_data"]
}
Logger System#
The ANAR logger is built on the anar-logger package. It is shared across handlers, database operations and file management, so all logs are consistent and timestamped.
Initialization Process#
In app/app.go, the logger is initialized right after loading the configuration:
a.Config = config.LoadConfig()
a.Logger = utils.SetupLogger(a.Config)
Logger Configuration#
The logger is configured by the LoggerConfig section of the configuration:
type LoggerConfig struct {
Enabled bool // Enable or disable the logger completely
Console bool // Enable console logging
File bool // Enable file logging
Syslog bool // Enable syslog logging
SyslogTag string // Tag name for syslog entries
FilePath string // Path to log file
Async bool // Enable asynchronous logging
FileMaxMB int64 // Max file size in MB before rotation
FileDaily bool // Rotate log files daily
FileBackups int // Number of backup log files to keep
}
Set these values with the LOGGER_* environment variables (see Logger Settings).
Logging Behavior#
Depending on the configuration, ANAR can log to multiple destinations:
- Console: Displays logs directly in the terminal, useful during development.
- File: Saves logs to LOGGER_FILE_PATH (default logs/app.log), with rotation.
- Syslog: Sends logs to the system's syslog service, suitable for production environments.
Each line has a timestamp, the level (DEBUG, INFO, WARNING or ERROR) and the message:
2025-10-25 19:04:12 [INFO] Cache initialized successfully using redis
2025-10-25 19:05:43 [ERROR] Failed to fetch data: record not found
Example Log Usage#
Use the f methods to format a message, and the plain methods to log values as they are:
h.App.Logger.Infof("Order %d created", order.ID)
h.App.Logger.Warnf("Low stock for product %d", product.ID)
h.App.Logger.Errorf("Failed to process request: %v", err)
h.App.Logger.Debugf("Payload: %+v", payload)
h.App.Logger.Error(err)
Advanced Features#
Custom Field Types#
The CLI generates only basic Go types. For file uploads, dates, JSON, emails and other special values, add fields to your model by hand using the types from anar-types:
type Post struct {
gorm.Model
Title string `json:"title"`
Cover types.FileField `json:"cover"`
PublishedAt types.TimeField `json:"published_at"`
}
Generated handlers accept multipart requests and store types.FileField uploads with the file manager, locally or on S3. See Models for every type.
Live Reload Development#
ANAR includes integration with Air for live reload during development:
anar run
This: 1. Installs Air if it isn't already installed 2. Starts the application with live reload 3. Watches for file changes and rebuilds automatically
Best Practices#
Model Design#
- Use descriptive names: Choose clear, meaningful model names
- Plan relationships: Design your data model before generating, and create models in dependency order
- Consider search fields: Think about which fields users will search
- Use appropriate types: Choose the right field types and modifiers for your data
Development Workflow#
- Start with models: Design and create your models first
- Generate scaffold: Use
anar scaffoldto generate handlers, migrations and routes - Customize as needed: Modify generated code to fit your requirements
- Use live reload: Develop with
anar runfor faster iteration - Check routes in CI: Run
anar routes --checkso the generated routes never go stale
Code Organization#
- Keep models simple: Focus on data structure in models
- Business logic in services: Create service layers for complex logic
- Background work in jobs: Move slow tasks such as email out of the request
- Version control: Commit generated code to track changes
Database Management#
- Use migrations: Always use the migration system
- Plan relationships: Design foreign keys and constraints properly
- Index important fields: Use the
indexanduniquemodifiers - Backup before changes: Always backup before major migrations
Security#
- Set a strong
ADMIN_PASSWORD, and change the oldadmin/adminaccount if your project was created before v0.10.0 - Set a long random
JWT_SECRETand your ownOTP_ENCRYPTION_KEY - Set
CORS_ALLOWED_ORIGINSto your frontend's origins - Grant users the narrowest groups and permissions they need
Troubleshooting#
Common Issues#
1. Model Not Found Errors#
Problem: no models found in models directory
Solutions:
- Ensure models exist in models/models.go
- Check that models follow proper Go struct syntax
- Run commands from the project root
2. Unsupported Field Type#
Problem: unsupported field type: time (or file, json, ...)
Solution: The CLI supports string, text, int, int64, uint, float, float64 and bool. Add other types to the struct by hand (see Custom Field Types).
3. Import Path Issues#
Problem: Import path errors in generated files
Solutions:
- Run anar new with the correct module name
- Ensure go.mod has the right module path
4. Relationship Errors#
Problem: related model X does not exist
Solutions:
- Create the related model first
- For hasMany and manyToMany, use the plural of an existing model (comments for Comment)
5. Route Clashes#
Problem: anar routes refuses to generate
Solution: Two routes use the same method and path, or the same path with different parameter names. Fix or remove one of them; the error shows the file and line of each.
6. Jobs Never Run#
Problem: Dispatched jobs stay in the queue
Solution: Add the job's queue to QUEUE_LISTEN_QUEUES and check that QUEUE_ENABLED=true.
Debugging Tips#
- Verbose Output: Check console output for detailed error messages
- Logs: Enable
LOGGER_CONSOLE=trueduring development, andGORM_LOGGER_ENABLED=trueto see SQL - Routes: Run
anar routes:listto see every route with its auth and permission - Models: Run
anar models:listto see what the CLI parsed
Getting Help#
- Check Documentation: Review this documentation thoroughly
- Examine Generated Code: Look at generated files for patterns
- Issue Reporting: Report bugs with detailed reproduction steps at codeberg.org/anarproject/anar/issues
Examples and Use Cases#
Example 1: Blog API#
Blog API with authentication. User is already in every project.
# Initialize project
anar new github.com/mycompany/blog-api
cd blog-api
# Create models
anar model Category name:string:unique description:text
anar model Post title:string content:text published:bool user:belongsTo
anar model Comment content:text user:belongsTo post:belongsTo
# Create many-to-many relationship
anar add-field-to-model Post categories:manyToMany
# Generate handlers, migrations and routes
anar scaffold
# Run the application
anar run
Example 2: E-commerce API#
Product catalog with orders. Every project already has a Product model, so the example extends it.
# Initialize
anar new github.com/store/ecommerce-api
cd ecommerce-api
# Create core models
anar model Customer name:string email:string:unique phone:string
anar model Order total:float64 status:string:default=pending customer:belongsTo
anar model OrderItem quantity:int price:float64 order:belongsTo product:belongsTo
# Generate scaffold
anar scaffold
Example 3: Social Media API#
User profiles and content sharing:
# Initialize
anar new github.com/social/api
cd api
# Create profile and content models
anar model Profile bio:text avatar:string location:string user:belongsTo
anar model Post content:text image:string likes:int user:belongsTo
anar model Comment content:text user:belongsTo post:belongsTo
anar model Like user:belongsTo post:belongsTo
# Generate complete API
anar scaffold
To store uploaded avatars and images, change those fields to types.FileField by hand (see Custom Field Types). A model with two references to the same model, such as a Follow with a follower and a followed user, also needs its fields written by hand.
Conclusion#
ANAR lets you focus on business logic rather than boilerplate. With a few commands you get a RESTful API with CRUD handlers, routes, migrations, authentication and permissions, as ordinary Go code you can change.
For continued learning and development with ANAR:
- Practice with Examples: Work through the provided examples
- Customize Generated Code: Adapt the generated code to your specific needs
- Build Real Applications: Apply ANAR to actual projects
- Contribute Back: Share improvements and extensions with the community
Happy coding with ANAR!