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:

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#

Architecture#

The anar CLI is made of:

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#

Installation#

  1. Install ANAR:
go install codeberg.org/anarproject/anar@latest
  1. Create a new project:
anar new github.com/yourusername/yourproject
  1. 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:

These only log a warning:

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:

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:

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.

See The First Admin Account.

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#

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:

  1. gorm.Model: Embedded struct providing ID, CreatedAt, UpdatedAt, DeletedAt (unless --no-timestamps is used)
  2. Your fields with their Go types
  3. JSON tags: the field name in lower case
  4. GORM tags from the type and modifiers
  5. 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.

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:

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:

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#

  1. Auto Migration: Uses GORM's AutoMigrate for schema synchronization.
  2. Order: Models are migrated in the order they appear in models/models.go, and dropped in reverse order.
  3. Built-in models: User, Group, Permission and the OAuth and token models are migrated by db.Migrate, not by this file.
  4. 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):

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:

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#

  1. Handler files: handlers/<model>_handler.go for each model
  2. Migration file: migrations/auto_migrations.go
  3. 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:

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 //.

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:

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:

  1. Reads configuration such as driver type, host, credentials, and SSL mode.
  2. Establishes a connection using the appropriate GORM driver.
  3. If MIGRATIONS_ENABLED is true, migrates the built-in models and runs migrations.AutoMigrate.
  4. 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:

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)
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:

  1. Detects the request type (JSON or multipart).
  2. Binds the data.
  3. Processes file uploads via FileManager.ProcessFileFields.
  4. Persists the record using Database.Create.
  5. Returns HTTP 201 with the created record.

c. Updating Records#

func (h Handler) update(modelType interface{}, c *gin.Context, notFoundMsg string)
  1. Detects the request format.
  2. Binds the data and notes which fields were sent: the JSON keys, or the form fields.
  3. Processes file fields if any.
  4. Validates the fields that were sent, including any sent with a zero value.
  5. Calls Database.UpdateFields, which writes exactly those columns, zero values included.
  6. Returns HTTP 200 with no body.

d. Deleting Records#

func (h Handler) delete(modelType interface{}, c *gin.Context, notFoundMsg string)
  1. Fetches the item by ID.
  2. Removes any associated files using FileManager.DeleteFileFields.
  3. Deletes the record using Database.Delete.
  4. 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:

  1. User Registration and Email Confirmation
  2. Login and Token Generation
  3. Access Token Refresh
  4. Password Reset and Change
  5. OAuth 2.0 / OpenID Connect Login
  6. Two-Factor Authentication (OTP)
  7. Protected Routes via JWT Middleware

User Registration and Email Confirmation#

When a new user registers using the /api/v1/register endpoint, the following happens:

  1. The server validates the request body (name, email and pass are required).
  2. The user is created with confirmed set to false.
  3. 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 after EMAIL_VERIFICATION_CODE_TTL seconds.

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:

  1. The handler looks up the user by name (404 if not found).
  2. Unconfirmed users are refused with 403.
  3. The password is checked (400 if wrong).
  4. If the user has OTP enabled, an otp field is also required (see OTP).
  5. On success, two tokens are returned:

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#

Login flow#

  1. The login button navigates the browser to /api/v1/auth/oauth/<provider>/authorize. Use a link or window.location, not fetch. An optional ?return_to= must match FRONTEND_SUCCESS_URL or OAUTH_ALLOWED_RETURN_URLS.
  2. The user signs in at the provider, which redirects back to /api/v1/auth/oauth/<provider>/callback (Apple uses a POST).
  3. Anar verifies the callback, finds or creates the user, and redirects to FRONTEND_SUCCESS_URL#code=<one-time code>.
  4. 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#
  1. In Google Cloud Console, go to APIs & Services, then Credentials, then Create credentials, then OAuth client ID.
  2. Application type: Web application. Add the redirect URL.
  3. Set OAUTH_GOOGLE_CLIENT_ID and OAUTH_GOOGLE_CLIENT_SECRET.
Apple#
  1. 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).
  2. Configure the Services ID with your domain and the redirect URL. Apple requires HTTPS.
  3. Create a Sign in with Apple key and download the .p8 file.
  4. Set OAUTH_APPLE_CLIENT_ID, OAUTH_APPLE_TEAM_ID, OAUTH_APPLE_KEY_ID and OAUTH_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#
  1. GitHub, then Settings, then Developer settings, then OAuth Apps, then New OAuth App.
  2. Set the Authorization callback URL to the redirect URL.
  3. Set OAUTH_GITHUB_CLIENT_ID and OAUTH_GITHUB_CLIENT_SECRET.

Only the user's primary, verified GitHub email is used.

Facebook#
  1. In Meta for Developers, create an app and add Facebook Login.
  2. Add the redirect URL under Valid OAuth Redirect URIs.
  3. Set OAUTH_FACEBOOK_CLIENT_ID (App ID) and OAUTH_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:

  1. The Authorization: Bearer <token> header is validated: signature, expiry, issuer, and that it is an access token. Otherwise the response is 401.
  2. The token's claims are stored in the Gin context as claims.
  3. 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#

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:

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#

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:

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:

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:

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#

  1. Use descriptive names: Choose clear, meaningful model names
  2. Plan relationships: Design your data model before generating, and create models in dependency order
  3. Consider search fields: Think about which fields users will search
  4. Use appropriate types: Choose the right field types and modifiers for your data

Development Workflow#

  1. Start with models: Design and create your models first
  2. Generate scaffold: Use anar scaffold to generate handlers, migrations and routes
  3. Customize as needed: Modify generated code to fit your requirements
  4. Use live reload: Develop with anar run for faster iteration
  5. Check routes in CI: Run anar routes --check so the generated routes never go stale

Code Organization#

  1. Keep models simple: Focus on data structure in models
  2. Business logic in services: Create service layers for complex logic
  3. Background work in jobs: Move slow tasks such as email out of the request
  4. Version control: Commit generated code to track changes

Database Management#

  1. Use migrations: Always use the migration system
  2. Plan relationships: Design foreign keys and constraints properly
  3. Index important fields: Use the index and unique modifiers
  4. Backup before changes: Always backup before major migrations

Security#

  1. Set a strong ADMIN_PASSWORD, and change the old admin / admin account if your project was created before v0.10.0
  2. Set a long random JWT_SECRET and your own OTP_ENCRYPTION_KEY
  3. Set CORS_ALLOWED_ORIGINS to your frontend's origins
  4. 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#

  1. Verbose Output: Check console output for detailed error messages
  2. Logs: Enable LOGGER_CONSOLE=true during development, and GORM_LOGGER_ENABLED=true to see SQL
  3. Routes: Run anar routes:list to see every route with its auth and permission
  4. Models: Run anar models:list to see what the CLI parsed

Getting Help#

  1. Check Documentation: Review this documentation thoroughly
  2. Examine Generated Code: Look at generated files for patterns
  3. 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:

  1. Practice with Examples: Work through the provided examples
  2. Customize Generated Code: Adapt the generated code to your specific needs
  3. Build Real Applications: Apply ANAR to actual projects
  4. Contribute Back: Share improvements and extensions with the community

Happy coding with ANAR!