A step-by-step tutorial for building a complete REST API with Cetus. This demo implements user registration, JWT authentication, and user CRUD operations.
- Go 1.21+
- PostgreSQL (or MySQL)
- Redis
- OpenSSL
git clone https://github.com/JackDPro/cetus-demo.git
cd cetus-demo
go mod tidyThis guide walks you through building the project from scratch. If you just want to run the demo, skip to Quick Run.
mkdir cetus-demo && cd cetus-demo
go mod init cetus-demo
go get github.com/JackDPro/cetus
go get github.com/gin-contrib/corsCreate the project directory structure:
cetus-demo/
├── controller/
├── model/
├── middleware/
├── provider/
├── request/
├── db/
└── storage/
Create .env file in the project root:
APP_NAME=cetus-demo
APP_ENV=dev
APP_DATA_ROOT=storage
LOG_CONSOLE_OUT=true
LOG_FILE_OUT=false
LOG_LEVEL=debug
LOG_FORMAT=json
DB_TYPE=postgres
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=cetus
DB_USERNAME=postgres
DB_PASSWORD=password
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_DATABASE=0
REDIS_PASSWORD=password
JWT_CERT_PATH=storage/jwt.pub
JWT_KEY_PATH=storage/jwt8-der.key
JWT_EXPIRES_IN=72
JWT_REDIS_PREFIX=auth
JWT_ISSUE=https://cetus.com
OPTIMUS_PRIME=11
OPTIMUS_INVERSE=22
OPTIMUS_RANDOM=33
SERVER_HTTP_PORT=9001The
OPTIMUS_*andJWT_*values are placeholders — we'll generate real ones in the next two steps.
Cetus JWT requires a PKCS#8 DER private key and a PEM public key.
Create storage/jwt_key.sh:
#!/bin/sh
# Generate RSA private key (PKCS#1 PEM)
openssl genrsa -out jwt1.pem 2048
# Convert to PKCS#8 DER format (required by cetus)
openssl pkcs8 -topk8 -inform PEM -outform DER \
-in jwt1.pem -out jwt8-der.key -nocrypt
# Extract public key (PEM)
openssl rsa -in jwt1.pem -pubout -out jwt.pubRun it:
cd storage && sh jwt_key.sh && cd ..Generated files:
| File | Format | Purpose |
|---|---|---|
storage/jwt8-der.key |
PKCS#8 DER | Token signing (private key) |
storage/jwt.pub |
PEM | Token verification (public key) |
Cetus uses Optimus to encode sequential database IDs into non-guessable integers in API responses (e.g. 1 -> 1580030173). You need 3 values: OPTIMUS_PRIME, OPTIMUS_INVERSE, OPTIMUS_RANDOM.
Create storage/optimus_gen.go:
package main
import (
"crypto/rand"
"fmt"
"math/big"
"os"
"strconv"
)
const maxInt = uint64(1<<31 - 1) // 2,147,483,647
func modInverse(prime uint64) uint64 {
p := big.NewInt(int64(prime))
max := big.NewInt(int64(maxInt + 1))
var i big.Int
return i.ModInverse(p, max).Uint64()
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "Usage: go run storage/optimus_gen.go <prime>")
fmt.Fprintln(os.Stderr, "Prime numbers: http://primes.utm.edu/lists/small/millions/")
os.Exit(1)
}
prime, err := strconv.ParseUint(os.Args[1], 10, 64)
if err != nil {
fmt.Fprintf(os.Stderr, "Invalid prime: %s\n", os.Args[1])
os.Exit(1)
}
upper := big.NewInt(int64(maxInt - 1))
r, _ := rand.Int(rand.Reader, upper)
random := r.Uint64() + 1
fmt.Printf("OPTIMUS_PRIME=%d\n", prime)
fmt.Printf("OPTIMUS_INVERSE=%d\n", modInverse(prime))
fmt.Printf("OPTIMUS_RANDOM=%d\n", random)
}How to use:
- Visit http://primes.utm.edu/lists/small/millions/, download any
.txtfile, open it and randomly pick a prime number less than2,147,483,647. - Pass the chosen prime to the generator:
go run storage/optimus_gen.go 104393867Output:
OPTIMUS_PRIME=104393867
OPTIMUS_INVERSE=1990279033
OPTIMUS_RANDOM=1333095938
- Copy these values into your
.envfile, replacing the placeholders.
Important: Once deployed to production, never change these values — all existing encoded IDs in your API will become invalid.
Create model/user.go:
package model
import (
"time"
"github.com/JackDPro/cetus/model"
"github.com/JackDPro/cetus/provider"
"gorm.io/gorm"
)
type User struct {
model.BaseModel
Id uint64 `json:"id" gorm:"primaryKey"`
Nickname string `json:"nickname"`
Username string `json:"username" gorm:"unique"`
Password string `binding:"required"`
Avatar string `json:"avatar"`
Status int `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt
}
// ToMap implements IModel interface.
// Encodes the database ID before returning to the API response.
func (m *User) ToMap() (map[string]interface{}, error) {
data, err := m.BaseModel.ToMap(m)
if err != nil {
return nil, err
}
data["id"] = provider.Hash().Encode(m.Id)
return data, nil
}
// BeforeSave is a GORM hook that automatically hashes the password before saving.
func (m *User) BeforeSave(_ *gorm.DB) (err error) {
if m.Password != "" {
m.Password, err = provider.HashMake(m.Password)
}
return
}Key points:
- Embed
model.BaseModelfor serialization helpers ToMap()implements theIModelinterface, which is required bycontroller.ResponseItem()provider.Hash().Encode()encodes the ID so the raw database ID is never exposedBeforeSave()GORM hook automatically bcrypt-hashes the password
These structs use Gin's binding tags for input validation.
Create request/user_store_request.go:
package request
type UserStoreRequest struct {
Username string `binding:"required" form:"username" json:"username"`
Nickname string `binding:"required" form:"nickname" json:"nickname"`
Password string `binding:"required,min=8,max=24" form:"password" json:"password"`
}Create request/user_update_request.go:
package request
type UserUpdateRequest struct {
Nickname string `form:"nickname" json:"nickname"`
Password string `binding:"omitempty,min=8,max=24" form:"password" json:"password"`
Avatar string `form:"avatar" json:"avatar"`
}Create request/auth_password_request.go:
package request
type AuthPasswordRequest struct {
Username string `binding:"required" form:"username" json:"username"`
Password string `binding:"required" form:"password" json:"password"`
}Providers encapsulate business logic. The auth provider wraps cetus JWT functionality.
Create provider/auth_provider.go:
package provider
import (
"cetus-demo/model"
"fmt"
"github.com/JackDPro/cetus/jwt"
CetusProvider "github.com/JackDPro/cetus/provider"
)
type AuthProvider struct{}
func NewAuthProvider() *AuthProvider {
return &AuthProvider{}
}
// CreateToken creates a JWT access token + refresh token for the given user.
func (p *AuthProvider) CreateToken(userId uint64) (*jwt.AccessToken, error) {
guard, err := jwt.GetJwtGuard()
if err != nil {
return nil, err
}
return guard.CreateToken(CetusProvider.Hash().Encode(userId), false)
}
// GetTokenByPassword authenticates a user by username/password and returns tokens.
func (p *AuthProvider) GetTokenByPassword(username, password string) (*jwt.AccessToken, error) {
var item = &model.User{}
CetusProvider.GetOrm().Db.Where("username=?", username).First(&item)
if item.Id == 0 {
return nil, fmt.Errorf("not found user")
}
if err := CetusProvider.HashCheck(password, item.Password); err != nil {
return nil, fmt.Errorf("invalid password")
}
return p.CreateToken(item.Id)
}
// AttemptAccessToken validates a JWT token and returns the decoded user ID.
func (p *AuthProvider) AttemptAccessToken(accessToken string) (uint64, error) {
guard, err := jwt.GetJwtGuard()
if err != nil {
return 0, err
}
token, err := guard.Attempt(accessToken)
if err != nil {
return 0, err
}
return CetusProvider.Hash().Decode(token.UserId), nil
}
// DeleteAccessToken revokes a token (logout).
func (p *AuthProvider) DeleteAccessToken(accessToken string) error {
guard, err := jwt.GetJwtGuard()
if err != nil {
return err
}
return guard.DeleteCredential(accessToken)
}Create provider/gin_toolkit.go (optional utility for extracting route parameters):
package provider
import (
"fmt"
"strconv"
"github.com/gin-gonic/gin"
)
func GetIdFromGin[T any](c *gin.Context, convertFunc func(string) (T, error)) (T, error) {
idStr := c.Param("id")
result, err := convertFunc(idStr)
if err != nil {
var zero T
return zero, fmt.Errorf("failed to convert id: %w", err)
}
return result, nil
}
func ConvertToUInt64(s string) (uint64, error) {
return strconv.ParseUint(s, 10, 64)
}Create middleware/auth.go — JWT authentication middleware:
package middleware
import (
"cetus-demo/provider"
"strings"
"github.com/JackDPro/cetus/controller"
"github.com/gin-gonic/gin"
)
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
tokenStr := c.GetHeader("Authorization")
tokenArr := strings.Split(tokenStr, "Bearer ")
if len(tokenArr) != 2 {
controller.ResponseUnauthorized(c)
return
}
p := provider.NewAuthProvider()
userId, err := p.AttemptAccessToken(tokenArr[1])
if err != nil {
controller.ResponseUnauthorized(c)
return
}
c.Set("user_id", userId)
c.Next()
}
}How it works:
- Extract JWT from the
Authorization: Bearer <token>header - Validate the token using
AuthProvider - Store the decoded
user_idin Gin context for downstream handlers - Return 401 if the token is missing or invalid
Create controller/user_controller.go:
package controller
import (
"cetus-demo/model"
AppProvider "cetus-demo/provider"
"cetus-demo/request"
"github.com/JackDPro/cetus/controller"
"github.com/JackDPro/cetus/provider"
"github.com/gin-gonic/gin"
)
type UserController struct{}
func NewUserController() *UserController {
return &UserController{}
}
// Store registers a new user.
func (ctr *UserController) Store(c *gin.Context) {
var payload = &request.UserStoreRequest{}
if err := c.ShouldBind(payload); err != nil {
controller.ResponseUnprocessable(c, 1, "params is invalid", err)
return
}
user := model.User{
Username: payload.Username,
Nickname: payload.Nickname,
Password: payload.Password,
Status: 1,
}
if err := provider.GetOrm().Db.Create(&user).Error; err != nil {
controller.ResponseInternalError(c, 1000, "create user failed", err)
return
}
controller.ResponseCreated(c, provider.Hash().Encode(user.Id))
}
// Me returns the authenticated user's profile.
func (ctr *UserController) Me(c *gin.Context) {
var user model.User
userId, ok := c.Get("user_id")
if !ok {
controller.ResponseUnauthorized(c)
return
}
provider.GetOrm().Db.Where("id", userId).First(&user)
if user.Id == 0 {
controller.ResponseNotFound(c, "user not found")
return
}
controller.ResponseItem(c, &user)
}
// Show returns a user by encoded ID.
func (ctr *UserController) Show(c *gin.Context) {
userId, err := AppProvider.GetIdFromGin[uint64](c, AppProvider.ConvertToUInt64)
if err != nil {
controller.ResponseUnprocessable(c, 1, "invalid id", err)
return
}
var user model.User
decodeId := provider.Hash().Decode(userId)
provider.GetOrm().Db.Where("id", decodeId).First(&user)
if user.Id == 0 {
controller.ResponseNotFound(c, "user not found")
return
}
controller.ResponseItem(c, &user)
}
// Update modifies the authenticated user's profile.
func (ctr *UserController) Update(c *gin.Context) {
var user model.User
userId, ok := c.Get("user_id")
if !ok {
controller.ResponseUnauthorized(c)
return
}
provider.GetOrm().Db.Where("id", userId).First(&user)
if user.Id == 0 {
controller.ResponseNotFound(c, "user not found")
return
}
var payload = &request.UserUpdateRequest{}
if err := c.ShouldBind(payload); err != nil {
controller.ResponseUnprocessable(c, 1, "params is invalid", err)
return
}
if payload.Nickname != "" {
user.Nickname = payload.Nickname
}
if payload.Password != "" {
user.Password = payload.Password
}
if payload.Avatar != "" {
user.Avatar = payload.Avatar
}
if err := provider.GetOrm().Db.Save(&user).Error; err != nil {
controller.ResponseInternalError(c, 1001, "update user failed", err)
return
}
controller.ResponseSuccess(c)
}Create controller/auth_controller.go:
package controller
import (
AppProvider "cetus-demo/provider"
"cetus-demo/request"
"strings"
"github.com/JackDPro/cetus/controller"
"github.com/gin-gonic/gin"
)
type AuthController struct{}
func NewAuthController() *AuthController {
return &AuthController{}
}
// AuthByPassword authenticates a user with username/password and returns JWT tokens.
func (ctr *AuthController) AuthByPassword(c *gin.Context) {
var payload = &request.AuthPasswordRequest{}
if err := c.ShouldBind(payload); err != nil {
controller.ResponseUnprocessable(c, 1, "params is invalid", err)
return
}
authProvider := AppProvider.NewAuthProvider()
accessToken, err := authProvider.GetTokenByPassword(payload.Username, payload.Password)
if err != nil {
controller.ResponseUnprocessable(c, 1000, "auth failed", err)
return
}
controller.ResponseItem(c, accessToken)
}
// Logout revokes the current access token.
func (ctr *AuthController) Logout(c *gin.Context) {
tokenStr := c.GetHeader("Authorization")
tokenArr := strings.Split(tokenStr, "Bearer ")
if len(tokenArr) != 2 {
controller.ResponseUnauthorized(c)
return
}
p := AppProvider.NewAuthProvider()
if err := p.DeleteAccessToken(tokenArr[1]); err != nil {
controller.ResponseInternalError(c, 1000, "logout failed", err)
return
}
controller.ResponseSuccess(c)
}Create db/migrate.go:
package main
import (
"cetus-demo/model"
"log"
"github.com/JackDPro/cetus/provider"
)
func main() {
mysql := provider.GetOrm()
tables := []interface{}{
&model.User{},
}
for _, table := range tables {
err := mysql.Db.AutoMigrate(table)
if err != nil {
log.Fatalf("create %T table failed: %v\n", table, err)
}
}
}Create the database and run the migration:
# PostgreSQL
createdb cetus
# MySQL
# mysql -u root -p -e "CREATE DATABASE cetus"
go run db/migrate.goCreate main.go:
package main
import (
"cetus-demo/controller"
AppMiddleware "cetus-demo/middleware"
"errors"
"fmt"
"github.com/JackDPro/cetus/config"
CetusController "github.com/JackDPro/cetus/controller"
"github.com/JackDPro/cetus/middleware"
"github.com/JackDPro/cetus/provider"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
var probe *CetusController.ProbeController
var userCtr *controller.UserController
var authCtr *controller.AuthController
func init() {
provider.GetLogger().Infow("init success")
probe = CetusController.NewProbeController()
authCtr = controller.NewAuthController()
userCtr = controller.NewUserController()
}
func StartServer() error {
appConf := config.GetAppConfig()
apiConf := config.GetApiConfig()
if appConf.Env == "prod" {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
router.Use(gin.Recovery())
// CORS
corsConf := cors.DefaultConfig()
corsConf.AllowAllOrigins = true
corsConf.AllowHeaders = []string{"Authorization", "Content-Type", "X-Requested-With"}
corsConf.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
router.Use(cors.New(corsConf))
// Request ID middleware (from cetus)
router.Use(middleware.RequestId())
// --- Public routes (no auth) ---
router.GET("/probe", probe.Show)
router.POST("/users", userCtr.Store)
router.POST("/auth/password", authCtr.AuthByPassword)
// --- Protected routes (require JWT) ---
authorized := router.Use(AppMiddleware.AuthMiddleware())
authorized.POST("/auth/logout", authCtr.Logout)
authorized.GET("/users/me", userCtr.Me)
authorized.GET("/users/:id", userCtr.Show)
authorized.PUT("/users/:id", userCtr.Update)
address := fmt.Sprintf("0.0.0.0:%d", apiConf.HttpPort)
provider.GetLogger().Infow("start api server success", "address", address)
if err := router.Run(address); err != nil {
return errors.New("start http server failed error=" + err.Error())
}
return nil
}
func main() {
if err := StartServer(); err != nil {
panic(err)
}
}go run main.goThe server starts at http://localhost:9001.
If you just want to run the demo without building from scratch:
git clone https://github.com/JackDPro/cetus-demo.git
cd cetus-demo
go mod tidy
cp .env.example .env # Edit .env with your DB/Redis credentials
cd storage && sh jwt_key.sh && cd .. # Generate JWT keys
go run storage/optimus_gen.go 104393867 # Generate ID hashing params, copy to .env
createdb cetus # Create database
go run db/migrate.go # Run migration
go run main.go # Start servercurl http://localhost:9001/probecurl -X POST http://localhost:9001/users \
-H "Content-Type: application/json" \
-d '{"username": "jack", "nickname": "Jack", "password": "12345678"}'Response 201 Created:
{"id": 1580030173}curl -X POST http://localhost:9001/auth/password \
-H "Content-Type: application/json" \
-d '{"username": "jack", "password": "12345678"}'Response:
{
"data": {
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "bearer",
"expires_in": 259200
}
}curl http://localhost:9001/users/me \
-H "Authorization: Bearer <access_token>"curl http://localhost:9001/users/1580030173 \
-H "Authorization: Bearer <access_token>"curl -X PUT http://localhost:9001/users/1580030173 \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{"nickname": "Jack Pro"}'curl -X POST http://localhost:9001/auth/logout \
-H "Authorization: Bearer <access_token>"cetus-demo/
├── main.go # Entry point & router setup
├── controller/
│ ├── user_controller.go # User CRUD handlers
│ └── auth_controller.go # Login / logout handlers
├── model/
│ └── user.go # User model (BaseModel + GORM hooks)
├── middleware/
│ ├── auth.go # JWT authentication middleware
│ └── localization.go # Accept-Language middleware
├── provider/
│ ├── auth_provider.go # JWT token operations
│ └── gin_toolkit.go # Request parameter helpers
├── request/
│ ├── user_store_request.go
│ ├── user_update_request.go
│ └── auth_password_request.go
├── db/
│ └── migrate.go # Database auto-migration
└── storage/
├── jwt_key.sh # JWT key generation script
├── optimus_gen.go # ID hashing parameter generator
├── jwt8-der.key # RSA private key (generated)
└── jwt.pub # RSA public key (generated)
| Feature | Where | What it does |
|---|---|---|
| Config | main.go |
config.GetAppConfig(), config.GetApiConfig() |
| Database | controller/user_controller.go |
provider.GetOrm().Db for GORM queries |
| JWT | provider/auth_provider.go |
jwt.GetJwtGuard() for token create/validate/revoke |
| Password hash | model/user.go |
provider.HashMake() in BeforeSave() hook |
| ID obfuscation | model/user.go |
provider.Hash().Encode/Decode() |
| Request ID | main.go |
middleware.RequestId() adds trace ID to every request |
| Response helpers | All controllers | ResponseItem(), ResponseCreated(), ResponseSuccess(), etc. |
| Logging | main.go |
provider.GetLogger() structured logging |
| BaseModel | model/user.go |
ToMap() serialization with json tags |
MIT