mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
feat: open API v1 and postman collection
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
service := apikeys.NewService(db)
|
||||
ctx := context.Background()
|
||||
|
||||
switch os.Args[1] {
|
||||
case "create":
|
||||
fs := flag.NewFlagSet("create", flag.ExitOnError)
|
||||
name := fs.String("name", "dashboard-read-api", "integration client name")
|
||||
environment := fs.String("env", config.AppEnv, "environment label")
|
||||
permissions := fs.String("permissions", "", "comma separated permission codes")
|
||||
allArea := fs.Bool("all-area", true, "grant all areas")
|
||||
areaIDs := fs.String("area-ids", "", "comma separated area ids")
|
||||
allLocation := fs.Bool("all-location", true, "grant all locations")
|
||||
locationIDs := fs.String("location-ids", "", "comma separated location ids")
|
||||
fs.Parse(os.Args[2:])
|
||||
|
||||
permissionCodes := apikeys.DefaultDashboardPermissions()
|
||||
if strings.TrimSpace(*permissions) != "" {
|
||||
permissionCodes = splitCSV(*permissions)
|
||||
}
|
||||
|
||||
issued, err := service.Create(ctx, apikeys.CreateInput{
|
||||
Name: *name,
|
||||
Environment: *environment,
|
||||
PermissionCodes: permissionCodes,
|
||||
AllArea: *allArea,
|
||||
AreaIDs: parseUintCSV(*areaIDs),
|
||||
AllLocation: *allLocation,
|
||||
LocationIDs: parseUintCSV(*locationIDs),
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("name: %s\n", issued.Record.Name)
|
||||
fmt.Printf("environment: %s\n", issued.Record.Environment)
|
||||
fmt.Printf("prefix: %s\n", issued.Record.KeyPrefix)
|
||||
fmt.Printf("status: %s\n", issued.Record.Status)
|
||||
fmt.Printf("api_key: %s\n", issued.Key)
|
||||
case "list":
|
||||
fs := flag.NewFlagSet("list", flag.ExitOnError)
|
||||
environment := fs.String("env", "", "filter by environment")
|
||||
fs.Parse(os.Args[2:])
|
||||
|
||||
records, err := service.List(ctx, *environment)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
fmt.Printf("%s\t%s\t%s\t%s\tareas=%t\tlocations=%t\n",
|
||||
record.Environment,
|
||||
record.KeyPrefix,
|
||||
record.Status,
|
||||
record.Name,
|
||||
record.AllArea,
|
||||
record.AllLocation,
|
||||
)
|
||||
}
|
||||
case "revoke":
|
||||
fs := flag.NewFlagSet("revoke", flag.ExitOnError)
|
||||
environment := fs.String("env", config.AppEnv, "environment label")
|
||||
prefix := fs.String("prefix", "", "key prefix to revoke")
|
||||
fs.Parse(os.Args[2:])
|
||||
|
||||
if err := service.Revoke(ctx, *environment, *prefix); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("revoked %s/%s\n", *environment, *prefix)
|
||||
default:
|
||||
usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Println("usage:")
|
||||
fmt.Println(" go run ./cmd/api-key create [flags]")
|
||||
fmt.Println(" go run ./cmd/api-key list [flags]")
|
||||
fmt.Println(" go run ./cmd/api-key revoke -env <environment> -prefix <prefix>")
|
||||
}
|
||||
|
||||
func splitCSV(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseUintCSV(raw string) []uint {
|
||||
parts := splitCSV(raw)
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
values := make([]uint, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
var value uint
|
||||
if _, err := fmt.Sscanf(part, "%d", &value); err == nil && value > 0 {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -9,12 +9,14 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/cache"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/middleware"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
|
||||
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/readapi"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/route"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
|
||||
@@ -131,6 +133,7 @@ func setupDatabase() *gorm.DB {
|
||||
}
|
||||
|
||||
func setupRoutes(app *fiber.App, db *gorm.DB, rdb *redis.Client) {
|
||||
middleware.SetAPIKeyAuthenticator(apikeys.NewService(db))
|
||||
|
||||
// route.Routes(app, db)
|
||||
// app.Use(utils.NotFoundHandler)
|
||||
@@ -169,6 +172,8 @@ func setupRoutes(app *fiber.App, db *gorm.DB, rdb *redis.Client) {
|
||||
return c.Status(status).JSON(body)
|
||||
})
|
||||
|
||||
readAPIRoutes := app.Group("/api")
|
||||
readapi.RegisterRoutes(readAPIRoutes)
|
||||
route.Routes(app, db)
|
||||
app.Use(utils.NotFoundHandler)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/cache"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/readapi"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/route"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func main() {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
readapi.PrimeBuildConfig()
|
||||
cache.SetRedis(redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"}))
|
||||
app := fiber.New(config.FiberConfig())
|
||||
app.Get("/healthz", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"status": "ok", "service": "api", "version": config.Version})
|
||||
})
|
||||
app.Get("/readyz", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"status": "ok", "db": "up", "redis": "up"})
|
||||
})
|
||||
route.Routes(app, nil)
|
||||
|
||||
artifacts, err := readapi.BuildArtifactsFromApp(app)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
files := map[string][]byte{
|
||||
filepath.Join(root, "docs", "openapi", "read-api.json"): artifacts.OpenAPIJSON,
|
||||
filepath.Join(root, "docs", "openapi", "read-api.yaml"): artifacts.OpenAPIYAML,
|
||||
filepath.Join(root, "docs", "postman", "read-api.collection.json"): artifacts.PostmanCollectionJSON,
|
||||
filepath.Join(root, "docs", "postman", "read-api.environment.json"): artifacts.PostmanEnvironmentJSON,
|
||||
}
|
||||
|
||||
for path, body := range files {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.WriteFile(path, body, 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("wrote %s\n", path)
|
||||
}
|
||||
}
|
||||
|
||||
func findRepoRoot() (string, error) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
current := wd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(current, "go.mod")); err == nil {
|
||||
return current, nil
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return "", fmt.Errorf("go.mod not found from %s", wd)
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"_postman_exported_at": "2026-04-14T00:00:00Z",
|
||||
"_postman_exported_using": "Codex",
|
||||
"_postman_variable_scope": "environment",
|
||||
"id": "lti-read-api-local",
|
||||
"name": "LTI ERP Read API.local",
|
||||
"values": [
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "adjustment_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "api_key",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "area_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "bank_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "base_url",
|
||||
"value": "http://localhost:8081"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "bearer_token",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "chickin_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "customer_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "employee_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "expense_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "flock_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "idDailyChecklist",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "idProjectFlockKandang",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "initial_balance_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "injection_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "location_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "nonstock_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "payment_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "product_category_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "product_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "projectFlockId",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "project_flock_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "project_flock_kandang_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "purchase_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "recording_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "supplier_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "transaction_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "transfer_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "uniformity_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "uom_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "user_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "warehouse_id",
|
||||
"value": "1"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package apikeys
|
||||
|
||||
func DefaultDashboardPermissions() []string {
|
||||
return []string{
|
||||
"lti.approval.list",
|
||||
"lti.closing.list",
|
||||
"lti.closing.detail",
|
||||
"lti.daily_checklist.create",
|
||||
"lti.daily_checklist.dashboard.list",
|
||||
"lti.daily_checklist.detail",
|
||||
"lti.daily_checklist.list",
|
||||
"lti.daily_checklist.master_data.activity",
|
||||
"lti.daily_checklist.master_data.configuration",
|
||||
"lti.daily_checklist.master_data.employee",
|
||||
"lti.daily_checklist.reports",
|
||||
"lti.dashboard.list",
|
||||
"lti.expense.detail",
|
||||
"lti.expense.list",
|
||||
"lti.finance.initial_balances.detail",
|
||||
"lti.finance.injections.detail",
|
||||
"lti.finance.payments.detail",
|
||||
"lti.finance.transactions.detail",
|
||||
"lti.finance.transactions.list",
|
||||
"lti.inventory.detail",
|
||||
"lti.inventory.list",
|
||||
"lti.inventory.product_stock.detail",
|
||||
"lti.inventory.product_stock.list",
|
||||
"lti.inventory.product_warehouses.detail",
|
||||
"lti.inventory.product_warehouses.list",
|
||||
"lti.inventory.transfer.detail",
|
||||
"lti.inventory.transfer.list",
|
||||
"lti.marketing.delivery_order.detail",
|
||||
"lti.marketing.delivery_order.list",
|
||||
"lti.master.area.detail",
|
||||
"lti.master.area.list",
|
||||
"lti.master.banks.detail",
|
||||
"lti.master.banks.list",
|
||||
"lti.master.customer.detail",
|
||||
"lti.master.customer.list",
|
||||
"lti.master.fcr.detail",
|
||||
"lti.master.fcr.list",
|
||||
"lti.master.flocks.detail",
|
||||
"lti.master.flocks.list",
|
||||
"lti.master.kandangs.detail",
|
||||
"lti.master.kandangs.list",
|
||||
"lti.master.locations.detail",
|
||||
"lti.master.locations.list",
|
||||
"lti.master.nonstocks.detail",
|
||||
"lti.master.nonstocks.list",
|
||||
"lti.master.product_categories.detail",
|
||||
"lti.master.product_categories.list",
|
||||
"lti.master.products.detail",
|
||||
"lti.master.products.list",
|
||||
"lti.master.production_standards.detail",
|
||||
"lti.master.production_standards.list",
|
||||
"lti.master.suppliers.detail",
|
||||
"lti.master.suppliers.list",
|
||||
"lti.master.uoms.detail",
|
||||
"lti.master.uoms.list",
|
||||
"lti.master.warehouses.detail",
|
||||
"lti.master.warehouses.list",
|
||||
"lti.production.chickins.detail",
|
||||
"lti.production.project_flock_kandangs.closing.detail",
|
||||
"lti.production.project_flock_kandangs.detail",
|
||||
"lti.production.project_flock_kandangs.list",
|
||||
"lti.production.project_flocks.detail",
|
||||
"lti.production.project_flocks.list",
|
||||
"lti.production.project_flocks.lookup",
|
||||
"lti.production.project_flocks.next_period",
|
||||
"lti.production.recording.detail",
|
||||
"lti.production.recording.list",
|
||||
"lti.production.recording.next_day",
|
||||
"lti.production.transfer_to_laying.create",
|
||||
"lti.production.transfer_to_laying.detail",
|
||||
"lti.production.transfer_to_laying.getavailableqty",
|
||||
"lti.production.transfer_to_laying.list",
|
||||
"lti.production.uniformity.detail",
|
||||
"lti.production.uniformity.list",
|
||||
"lti.purchase.detail",
|
||||
"lti.purchase.list",
|
||||
"lti.repport.customerpayment.list",
|
||||
"lti.repport.debtsupplier.list",
|
||||
"lti.repport.delivery.list",
|
||||
"lti.repport.expense.list",
|
||||
"lti.repport.gethppperkandang.list",
|
||||
"lti.repport.production_result.list",
|
||||
"lti.repport.purchasesupplier.list",
|
||||
"lti.users.detail",
|
||||
"lti.users.list",
|
||||
"lti.daily_checklist.master_data.kandang",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package apikeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, record *entity.IntegrationAPIKey) error
|
||||
GetByEnvironmentAndPrefix(ctx context.Context, environment, prefix string) (*entity.IntegrationAPIKey, error)
|
||||
List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error)
|
||||
Revoke(ctx context.Context, environment, prefix string, revokedAt time.Time) error
|
||||
TouchLastUsed(ctx context.Context, id uint, usedAt time.Time, usedFrom string) error
|
||||
}
|
||||
|
||||
type repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &repository{db: db}
|
||||
}
|
||||
|
||||
func (r *repository) Create(ctx context.Context, record *entity.IntegrationAPIKey) error {
|
||||
if r.db == nil {
|
||||
return errors.New("database not configured")
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(record).Error
|
||||
}
|
||||
|
||||
func (r *repository) GetByEnvironmentAndPrefix(ctx context.Context, environment, prefix string) (*entity.IntegrationAPIKey, error) {
|
||||
if r.db == nil {
|
||||
return nil, errors.New("database not configured")
|
||||
}
|
||||
|
||||
var record entity.IntegrationAPIKey
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("environment = ?", environment).
|
||||
Where("key_prefix = ?", prefix).
|
||||
First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (r *repository) List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error) {
|
||||
if r.db == nil {
|
||||
return nil, errors.New("database not configured")
|
||||
}
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&entity.IntegrationAPIKey{})
|
||||
if environment != "" {
|
||||
query = query.Where("environment = ?", environment)
|
||||
}
|
||||
|
||||
var records []entity.IntegrationAPIKey
|
||||
if err := query.Order("environment ASC").Order("name ASC").Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (r *repository) Revoke(ctx context.Context, environment, prefix string, revokedAt time.Time) error {
|
||||
if r.db == nil {
|
||||
return errors.New("database not configured")
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"status": entity.IntegrationAPIKeyStatusRevoked,
|
||||
"revoked_at": revokedAt,
|
||||
"updated_at": revokedAt,
|
||||
}
|
||||
|
||||
result := r.db.WithContext(ctx).
|
||||
Model(&entity.IntegrationAPIKey{}).
|
||||
Where("environment = ?", environment).
|
||||
Where("key_prefix = ?", prefix).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repository) TouchLastUsed(ctx context.Context, id uint, usedAt time.Time, usedFrom string) error {
|
||||
if r.db == nil {
|
||||
return errors.New("database not configured")
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&entity.IntegrationAPIKey{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"last_used_at": usedAt,
|
||||
"last_used_from": usedFrom,
|
||||
"updated_at": usedAt,
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package apikeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/secure"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidAPIKey = errors.New("invalid api key")
|
||||
ErrInactiveKey = errors.New("inactive api key")
|
||||
)
|
||||
|
||||
type Principal struct {
|
||||
ID uint
|
||||
Name string
|
||||
Environment string
|
||||
Permissions []string
|
||||
AllArea bool
|
||||
AreaIDs []uint
|
||||
AllLocation bool
|
||||
LocationIDs []uint
|
||||
}
|
||||
|
||||
type Authenticator interface {
|
||||
Authenticate(ctx context.Context, rawKey, source string) (*Principal, error)
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
Authenticator
|
||||
Create(ctx context.Context, input CreateInput) (*IssuedKey, error)
|
||||
List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error)
|
||||
Revoke(ctx context.Context, environment, prefix string) error
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
Name string
|
||||
Environment string
|
||||
PermissionCodes []string
|
||||
AllArea bool
|
||||
AreaIDs []uint
|
||||
AllLocation bool
|
||||
LocationIDs []uint
|
||||
}
|
||||
|
||||
type IssuedKey struct {
|
||||
Key string
|
||||
Record *entity.IntegrationAPIKey
|
||||
}
|
||||
|
||||
type service struct {
|
||||
repo Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) Service {
|
||||
return &service{
|
||||
repo: NewRepository(db),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) Authenticate(ctx context.Context, rawKey, source string) (*Principal, error) {
|
||||
environment, prefix, secret, err := parseRawKey(rawKey)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
record, err := s.repo.GetByEnvironmentAndPrefix(ctx, environment, prefix)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrInvalidAPIKey
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !strings.EqualFold(record.Status, entity.IntegrationAPIKeyStatusActive) || record.RevokedAt != nil {
|
||||
return nil, ErrInactiveKey
|
||||
}
|
||||
if !secure.Verify(record.KeyHash, secret) {
|
||||
return nil, ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
usedAt := s.now().UTC()
|
||||
if err := s.repo.TouchLastUsed(ctx, record.ID, usedAt, strings.TrimSpace(source)); err != nil {
|
||||
utils.Log.WithError(err).Warn("api key: failed to update last_used fields")
|
||||
}
|
||||
|
||||
return &Principal{
|
||||
ID: record.ID,
|
||||
Name: record.Name,
|
||||
Environment: record.Environment,
|
||||
Permissions: canonicalPermissions(record.PermissionCodes),
|
||||
AllArea: record.AllArea,
|
||||
AreaIDs: uniqueUint(record.AreaIDs),
|
||||
AllLocation: record.AllLocation,
|
||||
LocationIDs: uniqueUint(record.LocationIDs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) Create(ctx context.Context, input CreateInput) (*IssuedKey, error) {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
environment := strings.ToLower(strings.TrimSpace(input.Environment))
|
||||
if name == "" || environment == "" {
|
||||
return nil, fmt.Errorf("name and environment are required")
|
||||
}
|
||||
|
||||
prefix, err := randomToken(10)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, err := randomToken(24)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hash, err := secure.Hash(secret, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record := &entity.IntegrationAPIKey{
|
||||
Name: name,
|
||||
Environment: environment,
|
||||
Status: entity.IntegrationAPIKeyStatusActive,
|
||||
KeyPrefix: prefix,
|
||||
KeyHash: hash,
|
||||
PermissionCodes: canonicalPermissions(input.PermissionCodes),
|
||||
AllArea: input.AllArea,
|
||||
AreaIDs: uniqueUint(input.AreaIDs),
|
||||
AllLocation: input.AllLocation,
|
||||
LocationIDs: uniqueUint(input.LocationIDs),
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &IssuedKey{
|
||||
Key: fmt.Sprintf("lti_%s_%s_%s", environment, prefix, secret),
|
||||
Record: record,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error) {
|
||||
return s.repo.List(ctx, strings.ToLower(strings.TrimSpace(environment)))
|
||||
}
|
||||
|
||||
func (s *service) Revoke(ctx context.Context, environment, prefix string) error {
|
||||
environment = strings.ToLower(strings.TrimSpace(environment))
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if environment == "" || prefix == "" {
|
||||
return fmt.Errorf("environment and prefix are required")
|
||||
}
|
||||
return s.repo.Revoke(ctx, environment, prefix, s.now().UTC())
|
||||
}
|
||||
|
||||
func parseRawKey(rawKey string) (environment string, prefix string, secret string, err error) {
|
||||
rawKey = strings.TrimSpace(rawKey)
|
||||
parts := strings.Split(rawKey, "_")
|
||||
if len(parts) != 4 || parts[0] != "lti" {
|
||||
return "", "", "", ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
environment = strings.ToLower(strings.TrimSpace(parts[1]))
|
||||
prefix = strings.TrimSpace(parts[2])
|
||||
secret = strings.TrimSpace(parts[3])
|
||||
if environment == "" || prefix == "" || secret == "" {
|
||||
return "", "", "", ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
return environment, prefix, secret, nil
|
||||
}
|
||||
|
||||
func randomToken(size int) (string, error) {
|
||||
buf := make([]byte, size)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
encoder := base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
return strings.ToLower(encoder.EncodeToString(buf)), nil
|
||||
}
|
||||
|
||||
func canonicalPermissions(perms []string) []string {
|
||||
if len(perms) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(perms))
|
||||
result := make([]string, 0, len(perms))
|
||||
for _, perm := range perms {
|
||||
perm = strings.ToLower(strings.TrimSpace(perm))
|
||||
if perm == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[perm]; ok {
|
||||
continue
|
||||
}
|
||||
seen[perm] = struct{}{}
|
||||
result = append(result, perm)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func uniqueUint(values []uint) []uint {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[uint]struct{}, len(values))
|
||||
result := make([]uint, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -23,6 +23,7 @@ type SSOClientConfig struct {
|
||||
|
||||
var (
|
||||
IsProd bool
|
||||
AppEnv string
|
||||
AppHost string
|
||||
Version string
|
||||
LogLevel string
|
||||
@@ -84,7 +85,8 @@ func init() {
|
||||
loadConfig()
|
||||
|
||||
// server configuration
|
||||
IsProd = viper.GetString("APP_ENV") == "prod"
|
||||
AppEnv = defaultString(strings.TrimSpace(viper.GetString("APP_ENV")), "development")
|
||||
IsProd = AppEnv == "prod"
|
||||
AppHost = viper.GetString("APP_HOST")
|
||||
AppPort = viper.GetInt("APP_PORT")
|
||||
Version = viper.GetString("VERSION")
|
||||
@@ -111,7 +113,7 @@ func init() {
|
||||
// Cors
|
||||
CORSAllowOrigins = parseList("CORS_ALLOW_ORIGINS")
|
||||
CORSAllowMethods = parseListWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
||||
CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-Requested-With")
|
||||
CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-API-Key,X-Requested-With")
|
||||
CORSExposeHeaders = parseList("CORS_EXPOSE_HEADERS")
|
||||
CORSAllowCredentials = viper.GetBool("CORS_ALLOW_CREDENTIALS")
|
||||
CORSMaxAge = viper.GetInt("CORS_MAX_AGE")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS integration_api_keys;
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE IF NOT EXISTS integration_api_keys (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
environment VARCHAR(50) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
key_prefix VARCHAR(64) NOT NULL,
|
||||
key_hash TEXT NOT NULL,
|
||||
permission_codes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
all_area BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
area_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
all_location BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
location_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
last_used_at TIMESTAMPTZ NULL,
|
||||
last_used_from VARCHAR(128) NULL,
|
||||
revoked_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_integration_api_keys_environment_prefix UNIQUE (environment, key_prefix)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_integration_api_keys_status ON integration_api_keys (status);
|
||||
CREATE INDEX idx_integration_api_keys_deleted_at ON integration_api_keys (deleted_at);
|
||||
@@ -0,0 +1,36 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
IntegrationAPIKeyStatusActive = "active"
|
||||
IntegrationAPIKeyStatusRevoked = "revoked"
|
||||
)
|
||||
|
||||
type IntegrationAPIKey struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
Environment string `gorm:"type:varchar(50);not null;uniqueIndex:idx_integration_api_keys_env_prefix,priority:1"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:active;index"`
|
||||
KeyPrefix string `gorm:"type:varchar(64);not null;uniqueIndex:idx_integration_api_keys_env_prefix,priority:2"`
|
||||
KeyHash string `gorm:"type:text;not null"`
|
||||
PermissionCodes []string `gorm:"type:jsonb;serializer:json;not null"`
|
||||
AllArea bool `gorm:"not null;default:false"`
|
||||
AreaIDs []uint `gorm:"type:jsonb;serializer:json;not null"`
|
||||
AllLocation bool `gorm:"not null;default:false"`
|
||||
LocationIDs []uint `gorm:"type:jsonb;serializer:json;not null"`
|
||||
LastUsedAt *time.Time
|
||||
LastUsedFrom string `gorm:"type:varchar(128)"`
|
||||
RevokedAt *time.Time
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
func (IntegrationAPIKey) TableName() string {
|
||||
return "integration_api_keys"
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
|
||||
@@ -17,11 +21,21 @@ const (
|
||||
authUserLocalsKey = "auth.user"
|
||||
)
|
||||
|
||||
var (
|
||||
verifyAccessTokenFunc = sso.VerifyAccessToken
|
||||
fetchProfileFunc = sso.FetchProfile
|
||||
|
||||
apiKeyAuthMu sync.RWMutex
|
||||
apiKeyAuthenticator apikeys.Authenticator
|
||||
)
|
||||
|
||||
// AuthContext keeps authentication details captured by the middleware.
|
||||
type AuthContext struct {
|
||||
Token string
|
||||
Verification *sso.VerificationResult
|
||||
User *entity.User
|
||||
PrincipalType string
|
||||
PrincipalName string
|
||||
Roles []sso.Role
|
||||
Permissions map[string]struct{}
|
||||
UserAreaIDs []uint
|
||||
@@ -30,6 +44,13 @@ type AuthContext struct {
|
||||
UserAllLocation bool
|
||||
}
|
||||
|
||||
func SetAPIKeyAuthenticator(authenticator apikeys.Authenticator) {
|
||||
apiKeyAuthMu.Lock()
|
||||
defer apiKeyAuthMu.Unlock()
|
||||
|
||||
apiKeyAuthenticator = authenticator
|
||||
}
|
||||
|
||||
// Auth validates the incoming request against the central SSO access token and
|
||||
// loads the corresponding local user. Optional scopes can be provided to enforce
|
||||
// fine-grained authorization using the SSO access token scopes.
|
||||
@@ -62,10 +83,20 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
if c.Method() == fiber.MethodGet {
|
||||
if err := authenticateAPIKey(c); err == nil {
|
||||
if len(requiredScopes) > 0 {
|
||||
return fiber.NewError(fiber.StatusForbidden, "Insufficient scope")
|
||||
}
|
||||
return c.Next()
|
||||
} else if err != nil && !errors.Is(err, apikeys.ErrInvalidAPIKey) && !errors.Is(err, apikeys.ErrInactiveKey) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
|
||||
}
|
||||
|
||||
verification, err := sso.VerifyAccessToken(token)
|
||||
verification, err := verifyAccessTokenFunc(token)
|
||||
if err != nil {
|
||||
if sso.IsSignatureError(err) {
|
||||
logSignatureError("auth", tokenSource, token, err)
|
||||
@@ -99,7 +130,7 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
|
||||
permissions := make(map[string]struct{})
|
||||
var profile *sso.UserProfile
|
||||
if verification.UserID != 0 {
|
||||
if p, err := sso.FetchProfile(c.Context(), token, verification); err != nil {
|
||||
if p, err := fetchProfileFunc(c.Context(), token, verification); err != nil {
|
||||
utils.Log.WithError(err).Warn("auth: failed to fetch sso profile")
|
||||
} else {
|
||||
profile = p
|
||||
@@ -118,6 +149,8 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
|
||||
Token: token,
|
||||
Verification: verification,
|
||||
User: user,
|
||||
PrincipalType: "user",
|
||||
PrincipalName: user.Name,
|
||||
Roles: roles,
|
||||
Permissions: permissions,
|
||||
UserAreaIDs: nil,
|
||||
@@ -219,6 +252,57 @@ func bearerToken(c *fiber.Ctx) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func authenticateAPIKey(c *fiber.Ctx) error {
|
||||
rawKey := strings.TrimSpace(c.Get("X-API-Key"))
|
||||
if rawKey == "" {
|
||||
return apikeys.ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
authenticator := currentAPIKeyAuthenticator()
|
||||
if authenticator == nil {
|
||||
return apikeys.ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
principal, err := authenticator.Authenticate(context.Background(), rawKey, c.IP())
|
||||
if err != nil {
|
||||
if errors.Is(err, apikeys.ErrInvalidAPIKey) || errors.Is(err, apikeys.ErrInactiveKey) {
|
||||
return apikeys.ErrInvalidAPIKey
|
||||
}
|
||||
utils.Log.WithError(err).Warn("auth: api key authentication failed")
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to authenticate request")
|
||||
}
|
||||
|
||||
permissions := make(map[string]struct{}, len(principal.Permissions))
|
||||
for _, perm := range principal.Permissions {
|
||||
if canonical := canonicalPermission(perm); canonical != "" {
|
||||
permissions[canonical] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
c.Locals(authContextLocalsKey, &AuthContext{
|
||||
Token: "",
|
||||
Verification: nil,
|
||||
User: nil,
|
||||
PrincipalType: "api_key",
|
||||
PrincipalName: principal.Name,
|
||||
Roles: nil,
|
||||
Permissions: permissions,
|
||||
UserAreaIDs: principal.AreaIDs,
|
||||
UserLocationIDs: principal.LocationIDs,
|
||||
UserAllArea: principal.AllArea,
|
||||
UserAllLocation: principal.AllLocation,
|
||||
})
|
||||
c.Locals(authUserLocalsKey, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentAPIKeyAuthenticator() apikeys.Authenticator {
|
||||
apiKeyAuthMu.RLock()
|
||||
defer apiKeyAuthMu.RUnlock()
|
||||
|
||||
return apiKeyAuthenticator
|
||||
}
|
||||
|
||||
func hasAllScopes(have, required []string) bool {
|
||||
if len(required) == 0 {
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
|
||||
userValidation "gitlab.com/mbugroup/lti-api.git/internal/modules/users/validations"
|
||||
)
|
||||
|
||||
type stubUserService struct {
|
||||
user *entity.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubUserService) GetAll(_ *fiber.Ctx, _ *userValidation.Query) ([]entity.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (s *stubUserService) GetOne(_ *fiber.Ctx, _ uint) (*entity.User, error) {
|
||||
return s.user, s.err
|
||||
}
|
||||
|
||||
func (s *stubUserService) CreateOne(_ *fiber.Ctx, _ *userValidation.Create) (*entity.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubUserService) UpdateOne(_ *fiber.Ctx, _ *userValidation.Update, _ uint) (*entity.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubUserService) DeleteOne(_ *fiber.Ctx, _ uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubUserService) GetBySSOUserID(_ *fiber.Ctx, _ uint) (*entity.User, error) {
|
||||
return s.user, s.err
|
||||
}
|
||||
|
||||
type stubAPIKeyAuthenticator struct {
|
||||
principal *apikeys.Principal
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubAPIKeyAuthenticator) Authenticate(_ context.Context, _ string, _ string) (*apikeys.Principal, error) {
|
||||
return s.principal, s.err
|
||||
}
|
||||
|
||||
func TestAuthAllowsAPIKeyOnGet(t *testing.T) {
|
||||
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{
|
||||
principal: &apikeys.Principal{
|
||||
Name: "dashboard",
|
||||
Permissions: []string{"perm.read"},
|
||||
LocationIDs: []uint{3, 5},
|
||||
},
|
||||
})
|
||||
defer SetAPIKeyAuthenticator(nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/reports", Auth(&stubUserService{}), RequirePermissions("perm.read"), func(c *fiber.Ctx) error {
|
||||
scope, err := ResolveLocationScope(c, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"principal": c.Locals(authContextLocalsKey).(*AuthContext).PrincipalType,
|
||||
"restrict": scope.Restrict,
|
||||
"ids": scope.IDs,
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
|
||||
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRejectsAPIKeyOnPost(t *testing.T) {
|
||||
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{
|
||||
principal: &apikeys.Principal{
|
||||
Name: "dashboard",
|
||||
Permissions: []string{"perm.write"},
|
||||
},
|
||||
})
|
||||
defer SetAPIKeyAuthenticator(nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Post("/reports", Auth(&stubUserService{}), RequirePermissions("perm.write"), func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(fiber.MethodPost, "/reports", nil)
|
||||
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRejectsInvalidAPIKey(t *testing.T) {
|
||||
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{err: apikeys.ErrInvalidAPIKey})
|
||||
defer SetAPIKeyAuthenticator(nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/reports", Auth(&stubUserService{}), func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
|
||||
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRejectsInactiveAPIKey(t *testing.T) {
|
||||
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{err: apikeys.ErrInactiveKey})
|
||||
defer SetAPIKeyAuthenticator(nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/reports", Auth(&stubUserService{}), func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
|
||||
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRejectsMissingPermission(t *testing.T) {
|
||||
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{
|
||||
principal: &apikeys.Principal{
|
||||
Name: "dashboard",
|
||||
Permissions: []string{"perm.other"},
|
||||
},
|
||||
})
|
||||
defer SetAPIKeyAuthenticator(nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/reports", Auth(&stubUserService{}), RequirePermissions("perm.read"), func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
|
||||
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthAllowsBearerOnGet(t *testing.T) {
|
||||
previousVerify := verifyAccessTokenFunc
|
||||
previousProfile := fetchProfileFunc
|
||||
defer func() {
|
||||
verifyAccessTokenFunc = previousVerify
|
||||
fetchProfileFunc = previousProfile
|
||||
}()
|
||||
|
||||
verifyAccessTokenFunc = func(_ string) (*sso.VerificationResult, error) {
|
||||
return &sso.VerificationResult{
|
||||
UserID: 1,
|
||||
Claims: &sso.AccessTokenClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now().UTC()),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
fetchProfileFunc = func(_ context.Context, _ string, _ *sso.VerificationResult) (*sso.UserProfile, error) {
|
||||
return &sso.UserProfile{
|
||||
Permissions: []sso.Permission{{Name: "perm.read"}},
|
||||
LocationIDs: []uint{7},
|
||||
}, nil
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/reports", Auth(&stubUserService{user: &entity.User{Id: 9, Name: "API User"}}), RequirePermissions("perm.read"), func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"principal": c.Locals(authContextLocalsKey).(*AuthContext).PrincipalType})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthReturnsServerErrorWhenAPIKeyVerifierFailsUnexpectedly(t *testing.T) {
|
||||
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{err: errors.New("boom")})
|
||||
defer SetAPIKeyAuthenticator(nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/reports", Auth(&stubUserService{}), func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
|
||||
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
package readapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Artifacts struct {
|
||||
OpenAPIJSON []byte
|
||||
OpenAPIYAML []byte
|
||||
PostmanCollectionJSON []byte
|
||||
PostmanEnvironmentJSON []byte
|
||||
}
|
||||
|
||||
type normalizedRoute struct {
|
||||
Method string
|
||||
Path string
|
||||
Params []string
|
||||
}
|
||||
|
||||
type securityMode string
|
||||
|
||||
const (
|
||||
securityNone securityMode = "none"
|
||||
securityBearer securityMode = "bearer"
|
||||
securityAPIOrBearer securityMode = "api_or_bearer"
|
||||
)
|
||||
|
||||
type parameterMeta struct {
|
||||
Name string
|
||||
In string
|
||||
Description string
|
||||
Required bool
|
||||
Example any
|
||||
PostmanValue string
|
||||
IncludePostman bool
|
||||
}
|
||||
|
||||
type routeMeta struct {
|
||||
Group string
|
||||
Tag string
|
||||
Summary string
|
||||
Description string
|
||||
Security securityMode
|
||||
ListStyle bool
|
||||
QueryParams []parameterMeta
|
||||
Exclude bool
|
||||
}
|
||||
|
||||
func RegisterRoutes(router fiber.Router) {
|
||||
router.Get("/openapi/read.json", serveOpenAPIJSON)
|
||||
router.Get("/openapi/read.yaml", serveOpenAPIYAML)
|
||||
}
|
||||
|
||||
func PrimeBuildConfig() {
|
||||
if strings.TrimSpace(config.S3Bucket) == "" {
|
||||
config.S3Bucket = "read-api-artifacts"
|
||||
}
|
||||
if strings.TrimSpace(config.S3Region) == "" {
|
||||
config.S3Region = "us-east-1"
|
||||
}
|
||||
if strings.TrimSpace(config.S3AccessKey) == "" {
|
||||
config.S3AccessKey = "local-access-key"
|
||||
}
|
||||
if strings.TrimSpace(config.S3SecretKey) == "" {
|
||||
config.S3SecretKey = "local-secret-key"
|
||||
}
|
||||
if strings.TrimSpace(config.S3Endpoint) == "" {
|
||||
config.S3Endpoint = "http://localhost:9000"
|
||||
}
|
||||
if strings.TrimSpace(config.S3PublicBaseURL) == "" {
|
||||
config.S3PublicBaseURL = "http://localhost:9000/read-api-artifacts"
|
||||
}
|
||||
}
|
||||
|
||||
func BuildArtifactsFromApp(app *fiber.App) (Artifacts, error) {
|
||||
if app == nil {
|
||||
return Artifacts{}, fmt.Errorf("app is required")
|
||||
}
|
||||
|
||||
routes := normalizeRoutes(app.GetRoutes(true))
|
||||
return buildArtifactsFromNormalized(routes)
|
||||
}
|
||||
|
||||
func BuildArtifacts(routes []fiber.Route) (Artifacts, error) {
|
||||
normalized := normalizeRoutes(routes)
|
||||
return buildArtifactsFromNormalized(normalized)
|
||||
}
|
||||
|
||||
func buildArtifactsFromNormalized(normalized []normalizedRoute) (Artifacts, error) {
|
||||
specDoc := buildOpenAPIDocument(normalized)
|
||||
collectionDoc := buildPostmanCollection(normalized)
|
||||
environmentDoc := buildPostmanEnvironment(normalized)
|
||||
|
||||
openAPIJSON, err := json.MarshalIndent(specDoc, "", " ")
|
||||
if err != nil {
|
||||
return Artifacts{}, err
|
||||
}
|
||||
|
||||
openAPIYAML, err := yaml.Marshal(specDoc)
|
||||
if err != nil {
|
||||
return Artifacts{}, err
|
||||
}
|
||||
|
||||
postmanCollectionJSON, err := json.MarshalIndent(collectionDoc, "", " ")
|
||||
if err != nil {
|
||||
return Artifacts{}, err
|
||||
}
|
||||
|
||||
postmanEnvironmentJSON, err := json.MarshalIndent(environmentDoc, "", " ")
|
||||
if err != nil {
|
||||
return Artifacts{}, err
|
||||
}
|
||||
|
||||
return Artifacts{
|
||||
OpenAPIJSON: openAPIJSON,
|
||||
OpenAPIYAML: openAPIYAML,
|
||||
PostmanCollectionJSON: postmanCollectionJSON,
|
||||
PostmanEnvironmentJSON: postmanEnvironmentJSON,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func serveOpenAPIJSON(c *fiber.Ctx) error {
|
||||
artifacts, err := BuildArtifactsFromApp(c.App())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status(fiber.StatusOK).Type("json").Send(artifacts.OpenAPIJSON)
|
||||
}
|
||||
|
||||
func serveOpenAPIYAML(c *fiber.Ctx) error {
|
||||
artifacts, err := BuildArtifactsFromApp(c.App())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Set(fiber.HeaderContentType, "application/yaml")
|
||||
return c.Status(fiber.StatusOK).Send(artifacts.OpenAPIYAML)
|
||||
}
|
||||
|
||||
func normalizeRoutes(routes []fiber.Route) []normalizedRoute {
|
||||
seen := make(map[string]struct{}, len(routes))
|
||||
normalized := make([]normalizedRoute, 0, len(routes))
|
||||
for _, route := range routes {
|
||||
if route.Method != http.MethodGet {
|
||||
continue
|
||||
}
|
||||
if route.Path == "" || strings.HasPrefix(route.Path, "/api/openapi/") {
|
||||
continue
|
||||
}
|
||||
|
||||
key := route.Method + " " + route.Path
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
|
||||
normalized = append(normalized, normalizedRoute{
|
||||
Method: route.Method,
|
||||
Path: route.Path,
|
||||
Params: append([]string(nil), route.Params...),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(normalized, func(i, j int) bool {
|
||||
if normalized[i].Path == normalized[j].Path {
|
||||
return normalized[i].Method < normalized[j].Method
|
||||
}
|
||||
return normalized[i].Path < normalized[j].Path
|
||||
})
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func buildOpenAPIDocument(routes []normalizedRoute) map[string]any {
|
||||
paths := make(map[string]any, len(routes))
|
||||
for _, route := range routes {
|
||||
meta := describeRoute(route)
|
||||
if meta.Exclude {
|
||||
continue
|
||||
}
|
||||
|
||||
openAPIPath := toOpenAPIPath(route.Path)
|
||||
operation := map[string]any{
|
||||
"summary": meta.Summary,
|
||||
"description": meta.Description,
|
||||
"tags": []string{meta.Tag},
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{
|
||||
"description": "Successful response",
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": successSchema(meta),
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": map[string]any{
|
||||
"description": "Unauthorized",
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": map[string]any{"$ref": "#/components/schemas/ErrorEnvelope"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": map[string]any{
|
||||
"description": "Forbidden",
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": map[string]any{"$ref": "#/components/schemas/ErrorEnvelope"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
params := buildParameters(route, meta)
|
||||
if len(params) > 0 {
|
||||
operation["parameters"] = params
|
||||
}
|
||||
|
||||
switch meta.Security {
|
||||
case securityBearer:
|
||||
operation["security"] = []map[string][]string{{"BearerAuth": {}}}
|
||||
case securityAPIOrBearer:
|
||||
operation["security"] = []map[string][]string{
|
||||
{"ApiKeyAuth": {}},
|
||||
{"BearerAuth": {}},
|
||||
}
|
||||
}
|
||||
|
||||
paths[openAPIPath] = map[string]any{
|
||||
"get": operation,
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"openapi": "3.1.0",
|
||||
"info": map[string]any{
|
||||
"title": "LTI ERP Read API",
|
||||
"version": "v1",
|
||||
"description": "Read-only OpenAPI surface for dashboard integrations and GET endpoint exploration.",
|
||||
},
|
||||
"servers": []map[string]any{
|
||||
{"url": "http://localhost:8081"},
|
||||
},
|
||||
"paths": paths,
|
||||
"components": map[string]any{
|
||||
"securitySchemes": map[string]any{
|
||||
"ApiKeyAuth": map[string]any{
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
},
|
||||
"BearerAuth": map[string]any{
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
},
|
||||
},
|
||||
"schemas": map[string]any{
|
||||
"SuccessEnvelope": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"code": map[string]any{"type": "integer", "example": 200},
|
||||
"status": map[string]any{"type": "string", "example": "success"},
|
||||
"message": map[string]any{"type": "string", "example": "Request completed successfully"},
|
||||
"data": map[string]any{"type": "object", "additionalProperties": true},
|
||||
},
|
||||
},
|
||||
"PaginatedEnvelope": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"code": map[string]any{"type": "integer", "example": 200},
|
||||
"status": map[string]any{"type": "string", "example": "success"},
|
||||
"message": map[string]any{"type": "string", "example": "Request completed successfully"},
|
||||
"meta": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"page": map[string]any{"type": "integer", "example": 1},
|
||||
"limit": map[string]any{"type": "integer", "example": 10},
|
||||
"total_pages": map[string]any{"type": "integer", "example": 1},
|
||||
"total_results": map[string]any{"type": "integer", "example": 0},
|
||||
},
|
||||
},
|
||||
"data": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{"type": "object", "additionalProperties": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
"ErrorEnvelope": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"code": map[string]any{"type": "integer", "example": 401},
|
||||
"status": map[string]any{"type": "string", "example": "error"},
|
||||
"message": map[string]any{"type": "string", "example": "Please authenticate"},
|
||||
"errors": map[string]any{"type": "object", "additionalProperties": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildParameters(route normalizedRoute, meta routeMeta) []map[string]any {
|
||||
params := make([]map[string]any, 0, len(route.Params)+len(meta.QueryParams))
|
||||
for _, param := range route.Params {
|
||||
params = append(params, map[string]any{
|
||||
"name": param,
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": fmt.Sprintf("Path parameter `%s`.", param),
|
||||
"schema": map[string]any{
|
||||
"type": "string",
|
||||
"example": defaultExampleForVariable(inferPostmanPathVariable(route.Path, param)),
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, query := range meta.QueryParams {
|
||||
parameter := map[string]any{
|
||||
"name": query.Name,
|
||||
"in": query.In,
|
||||
"required": query.Required,
|
||||
"description": query.Description,
|
||||
"schema": inferSchema(query.Example),
|
||||
}
|
||||
if query.Example != nil {
|
||||
parameter["example"] = query.Example
|
||||
}
|
||||
params = append(params, parameter)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func successSchema(meta routeMeta) map[string]any {
|
||||
if meta.ListStyle {
|
||||
return map[string]any{"$ref": "#/components/schemas/PaginatedEnvelope"}
|
||||
}
|
||||
return map[string]any{"$ref": "#/components/schemas/SuccessEnvelope"}
|
||||
}
|
||||
|
||||
func buildPostmanCollection(routes []normalizedRoute) map[string]any {
|
||||
type folder struct {
|
||||
name string
|
||||
items []any
|
||||
}
|
||||
|
||||
folders := map[string]map[string]*folder{
|
||||
"Public": {},
|
||||
"Dashboard API Key": {},
|
||||
"Internal/OAuth Reference": {},
|
||||
}
|
||||
|
||||
for _, route := range routes {
|
||||
meta := describeRoute(route)
|
||||
if meta.Exclude {
|
||||
continue
|
||||
}
|
||||
|
||||
tagFolder := folders[meta.Group]
|
||||
group, ok := tagFolder[meta.Tag]
|
||||
if !ok {
|
||||
group = &folder{name: meta.Tag}
|
||||
tagFolder[meta.Tag] = group
|
||||
}
|
||||
group.items = append(group.items, buildPostmanRequest(route, meta))
|
||||
}
|
||||
|
||||
rootItems := make([]any, 0, len(folders))
|
||||
for _, groupName := range []string{"Public", "Dashboard API Key", "Internal/OAuth Reference"} {
|
||||
tagFolder := folders[groupName]
|
||||
tagNames := make([]string, 0, len(tagFolder))
|
||||
for tagName := range tagFolder {
|
||||
tagNames = append(tagNames, tagName)
|
||||
}
|
||||
sort.Strings(tagNames)
|
||||
|
||||
groupItems := make([]any, 0, len(tagNames))
|
||||
for _, tagName := range tagNames {
|
||||
groupItems = append(groupItems, map[string]any{
|
||||
"name": tagName,
|
||||
"item": tagFolder[tagName].items,
|
||||
})
|
||||
}
|
||||
|
||||
folder := map[string]any{
|
||||
"name": groupName,
|
||||
"item": groupItems,
|
||||
}
|
||||
if events := postmanFolderEvents(groupName); len(events) > 0 {
|
||||
folder["event"] = events
|
||||
}
|
||||
rootItems = append(rootItems, folder)
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"info": map[string]any{
|
||||
"name": "LTI ERP Read API",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
},
|
||||
"item": rootItems,
|
||||
}
|
||||
}
|
||||
|
||||
func postmanFolderEvents(groupName string) []map[string]any {
|
||||
switch groupName {
|
||||
case "Dashboard API Key":
|
||||
return []map[string]any{
|
||||
{
|
||||
"listen": "prerequest",
|
||||
"script": map[string]any{
|
||||
"type": "text/javascript",
|
||||
"exec": []string{
|
||||
"const apiKey = pm.environment.get('api_key');",
|
||||
"const bearerToken = pm.environment.get('bearer_token');",
|
||||
"if (apiKey) {",
|
||||
" pm.request.headers.upsert({ key: 'X-API-Key', value: apiKey });",
|
||||
" pm.request.headers.remove('Authorization');",
|
||||
"} else if (bearerToken) {",
|
||||
" pm.request.headers.upsert({ key: 'Authorization', value: 'Bearer ' + bearerToken });",
|
||||
" pm.request.headers.remove('X-API-Key');",
|
||||
"} else {",
|
||||
" pm.request.headers.remove('Authorization');",
|
||||
" pm.request.headers.remove('X-API-Key');",
|
||||
"}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
case "Internal/OAuth Reference":
|
||||
return []map[string]any{
|
||||
{
|
||||
"listen": "prerequest",
|
||||
"script": map[string]any{
|
||||
"type": "text/javascript",
|
||||
"exec": []string{
|
||||
"const bearerToken = pm.environment.get('bearer_token');",
|
||||
"pm.request.headers.remove('X-API-Key');",
|
||||
"if (bearerToken) {",
|
||||
" pm.request.headers.upsert({ key: 'Authorization', value: 'Bearer ' + bearerToken });",
|
||||
"} else {",
|
||||
" pm.request.headers.remove('Authorization');",
|
||||
"}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildPostmanRequest(route normalizedRoute, meta routeMeta) map[string]any {
|
||||
return map[string]any{
|
||||
"name": meta.Summary,
|
||||
"request": map[string]any{
|
||||
"method": http.MethodGet,
|
||||
"header": []map[string]any{
|
||||
{"key": "Accept", "value": "application/json"},
|
||||
},
|
||||
"url": buildPostmanURL(route, meta),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildPostmanURL(route normalizedRoute, meta routeMeta) string {
|
||||
path := route.Path
|
||||
for _, param := range route.Params {
|
||||
path = strings.ReplaceAll(path, ":"+param, "{{"+inferPostmanPathVariable(route.Path, param)+"}}")
|
||||
}
|
||||
|
||||
query := make([]string, 0, len(meta.QueryParams))
|
||||
for _, param := range meta.QueryParams {
|
||||
if !param.IncludePostman {
|
||||
continue
|
||||
}
|
||||
query = append(query, fmt.Sprintf("%s=%v", param.Name, param.PostmanValue))
|
||||
}
|
||||
|
||||
if len(query) == 0 {
|
||||
return "{{base_url}}" + path
|
||||
}
|
||||
|
||||
return "{{base_url}}" + path + "?" + strings.Join(query, "&")
|
||||
}
|
||||
|
||||
func buildPostmanEnvironment(routes []normalizedRoute) map[string]any {
|
||||
values := map[string]string{
|
||||
"base_url": "http://localhost:8081",
|
||||
"api_key": "",
|
||||
"bearer_token": "",
|
||||
"id": "1",
|
||||
"bank_id": "1",
|
||||
"customer_id": "1",
|
||||
"expense_id": "1",
|
||||
"location_id": "1",
|
||||
"project_flock_id": "1",
|
||||
"project_flock_kandang_id": "1",
|
||||
"supplier_id": "1",
|
||||
}
|
||||
|
||||
for _, route := range routes {
|
||||
meta := describeRoute(route)
|
||||
if meta.Exclude {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, param := range route.Params {
|
||||
name := inferPostmanPathVariable(route.Path, param)
|
||||
if _, ok := values[name]; !ok {
|
||||
values[name] = defaultExampleForVariable(name)
|
||||
}
|
||||
}
|
||||
for _, query := range meta.QueryParams {
|
||||
if query.IncludePostman && strings.HasPrefix(query.PostmanValue, "{{") && strings.HasSuffix(query.PostmanValue, "}}") {
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(query.PostmanValue, "{{"), "}}")
|
||||
if _, ok := values[name]; !ok {
|
||||
values[name] = defaultExampleForVariable(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
items := make([]map[string]any, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
items = append(items, map[string]any{
|
||||
"key": key,
|
||||
"value": values[key],
|
||||
"enabled": true,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"id": "lti-read-api-local",
|
||||
"name": "LTI ERP Read API.local",
|
||||
"values": items,
|
||||
"_postman_variable_scope": "environment",
|
||||
"_postman_exported_at": "2026-04-14T00:00:00Z",
|
||||
"_postman_exported_using": "Codex",
|
||||
}
|
||||
}
|
||||
|
||||
func describeRoute(route normalizedRoute) routeMeta {
|
||||
meta := routeMeta{
|
||||
Group: "Dashboard API Key",
|
||||
Tag: inferTag(route.Path),
|
||||
Summary: defaultSummary(route.Path),
|
||||
Description: fmt.Sprintf("Read access to `%s`.", route.Path),
|
||||
Security: securityAPIOrBearer,
|
||||
ListStyle: !strings.Contains(route.Path, ":"),
|
||||
}
|
||||
|
||||
switch {
|
||||
case route.Path == "/healthz" || route.Path == "/readyz" || route.Path == "/api/constants":
|
||||
meta.Group = "Public"
|
||||
meta.Security = securityNone
|
||||
meta.ListStyle = false
|
||||
case strings.HasPrefix(route.Path, "/api/sso/"):
|
||||
meta.Group = "Internal/OAuth Reference"
|
||||
meta.ListStyle = false
|
||||
if route.Path == "/api/sso/userinfo" {
|
||||
meta.Security = securityBearer
|
||||
} else {
|
||||
meta.Security = securityNone
|
||||
}
|
||||
}
|
||||
|
||||
switch route.Path {
|
||||
case "/api/dashboards":
|
||||
meta.QueryParams = []parameterMeta{
|
||||
{Name: "page", In: "query", Description: "Page number.", Example: 1},
|
||||
{Name: "limit", In: "query", Description: "Page size.", Example: 10},
|
||||
{Name: "search", In: "query", Description: "Search keyword.", Example: "farm"},
|
||||
{Name: "start_date", In: "query", Description: "Period start date (YYYY-MM-DD).", Example: "2026-01-01"},
|
||||
{Name: "end_date", In: "query", Description: "Period end date (YYYY-MM-DD).", Example: "2026-01-31"},
|
||||
{Name: "analysis_mode", In: "query", Description: "Dashboard analysis mode.", Example: "OVERVIEW"},
|
||||
{Name: "comparison_type", In: "query", Description: "Required when analysis_mode is COMPARISON.", Example: "PREVIOUS_PERIOD"},
|
||||
{Name: "metric", In: "query", Description: "Metric to compare.", Example: "egg_mass"},
|
||||
{Name: "location_ids", In: "query", Description: "Comma separated location ids.", Example: "1,2"},
|
||||
{Name: "flock_ids", In: "query", Description: "Comma separated flock ids.", Example: "1,2"},
|
||||
{Name: "kandang_ids", In: "query", Description: "Comma separated kandang ids.", Example: "1,2"},
|
||||
{Name: "include", In: "query", Description: "Comma separated dashboard sections to include.", Example: "performance,summary"},
|
||||
}
|
||||
case "/api/closings/:projectFlockId/sapronak", "/api/closings/:projectFlockId/sapronak/summary":
|
||||
meta.ListStyle = route.Path == "/api/closings/:projectFlockId/sapronak"
|
||||
meta.QueryParams = []parameterMeta{
|
||||
{Name: "type", In: "query", Description: "Required sapronak direction.", Required: true, Example: "incoming", PostmanValue: "incoming", IncludePostman: true},
|
||||
{Name: "search", In: "query", Description: "Search keyword.", Example: "pakan"},
|
||||
{Name: "kandang_id", In: "query", Description: "Optional kandang id filter.", Example: 1},
|
||||
}
|
||||
case "/api/closings/:projectFlockId/production-data", "/api/closings/:projectFlockId/keuangan":
|
||||
meta.ListStyle = false
|
||||
meta.QueryParams = []parameterMeta{
|
||||
{Name: "kandang_id", In: "query", Description: "Optional kandang id filter.", Example: 1},
|
||||
}
|
||||
case "/api/closings/:project_flock_id/expedition-hpp":
|
||||
meta.ListStyle = false
|
||||
meta.QueryParams = []parameterMeta{
|
||||
{Name: "project_flock_kandang_id", In: "query", Description: "Optional project flock kandang id filter.", Example: 1},
|
||||
}
|
||||
case "/api/reports/expense":
|
||||
meta.QueryParams = []parameterMeta{
|
||||
{Name: "page", In: "query", Description: "Page number.", Example: 1},
|
||||
{Name: "limit", In: "query", Description: "Page size.", Example: 10},
|
||||
{Name: "search", In: "query", Description: "Search keyword.", Example: "operasional"},
|
||||
{Name: "category", In: "query", Description: "Expense category filter.", Example: "BOP"},
|
||||
{Name: "supplier_id", In: "query", Description: "Supplier id filter.", Example: 1},
|
||||
{Name: "location_id", In: "query", Description: "Location id filter.", Example: 1},
|
||||
{Name: "area_id", In: "query", Description: "Area id filter.", Example: 1},
|
||||
{Name: "realization_date", In: "query", Description: "Realization date filter (YYYY-MM-DD).", Example: "2026-01-15"},
|
||||
}
|
||||
case "/api/reports/marketing":
|
||||
meta.QueryParams = []parameterMeta{
|
||||
{Name: "page", In: "query", Description: "Page number.", Example: 1},
|
||||
{Name: "limit", In: "query", Description: "Page size.", Example: 10},
|
||||
{Name: "start_date", In: "query", Description: "Period start date (YYYY-MM-DD).", Example: "2026-01-01"},
|
||||
{Name: "end_date", In: "query", Description: "Period end date (YYYY-MM-DD).", Example: "2026-01-31"},
|
||||
{Name: "customer_id", In: "query", Description: "Customer id filter.", Example: 1},
|
||||
{Name: "location_id", In: "query", Description: "Location id filter.", Example: 1},
|
||||
}
|
||||
case "/api/reports/purchase-supplier":
|
||||
meta.QueryParams = []parameterMeta{
|
||||
{Name: "page", In: "query", Description: "Page number.", Example: 1},
|
||||
{Name: "limit", In: "query", Description: "Page size.", Example: 10},
|
||||
{Name: "start_date", In: "query", Description: "Period start date (YYYY-MM-DD).", Example: "2026-01-01"},
|
||||
{Name: "end_date", In: "query", Description: "Period end date (YYYY-MM-DD).", Example: "2026-01-31"},
|
||||
{Name: "supplier_id", In: "query", Description: "Comma separated supplier ids.", Example: "1,2"},
|
||||
{Name: "area_id", In: "query", Description: "Comma separated area ids.", Example: "1,2"},
|
||||
}
|
||||
case "/api/reports/debt-supplier":
|
||||
meta.QueryParams = []parameterMeta{
|
||||
{Name: "page", In: "query", Description: "Page number.", Example: 1},
|
||||
{Name: "limit", In: "query", Description: "Page size.", Example: 10},
|
||||
{Name: "start_date", In: "query", Description: "Period start date (YYYY-MM-DD).", Example: "2026-01-01"},
|
||||
{Name: "end_date", In: "query", Description: "Period end date (YYYY-MM-DD).", Example: "2026-01-31"},
|
||||
{Name: "supplier_ids", In: "query", Description: "Comma separated supplier ids.", Example: "1,2", PostmanValue: "{{supplier_id}}", IncludePostman: true},
|
||||
}
|
||||
case "/api/reports/customer-payment":
|
||||
meta.QueryParams = []parameterMeta{
|
||||
{Name: "page", In: "query", Description: "Page number.", Example: 1},
|
||||
{Name: "limit", In: "query", Description: "Page size.", Example: 10},
|
||||
{Name: "start_date", In: "query", Description: "Period start date (YYYY-MM-DD).", Example: "2026-01-01"},
|
||||
{Name: "end_date", In: "query", Description: "Period end date (YYYY-MM-DD).", Example: "2026-01-31"},
|
||||
{Name: "customer_ids", In: "query", Description: "Comma separated customer ids.", Example: "1,2", PostmanValue: "{{customer_id}}", IncludePostman: true},
|
||||
}
|
||||
case "/api/reports/hpp-per-kandang":
|
||||
meta.QueryParams = []parameterMeta{
|
||||
{Name: "page", In: "query", Description: "Page number.", Example: 1},
|
||||
{Name: "limit", In: "query", Description: "Page size.", Example: 10},
|
||||
{Name: "period", In: "query", Description: "Daily period filter (YYYY-MM).", Example: "2026-01"},
|
||||
{Name: "location_id", In: "query", Description: "Location id filter.", Example: 1},
|
||||
{Name: "kandang_id", In: "query", Description: "Kandang id filter.", Example: 1},
|
||||
}
|
||||
case "/api/finance/transactions":
|
||||
meta.QueryParams = []parameterMeta{
|
||||
{Name: "page", In: "query", Description: "Page number.", Example: 1},
|
||||
{Name: "limit", In: "query", Description: "Page size.", Example: 10},
|
||||
{Name: "search", In: "query", Description: "Search keyword.", Example: "invoice"},
|
||||
{Name: "bank_ids", In: "query", Description: "Comma separated bank ids.", Example: "1,2", PostmanValue: "{{bank_id}}", IncludePostman: true},
|
||||
{Name: "customer_ids", In: "query", Description: "Comma separated customer ids.", Example: "1,2"},
|
||||
{Name: "supplier_ids", In: "query", Description: "Comma separated supplier ids.", Example: "1,2"},
|
||||
{Name: "transaction_types", In: "query", Description: "Comma separated transaction types.", Example: "payment,initial_balance"},
|
||||
{Name: "start_date", In: "query", Description: "Start date (YYYY-MM-DD).", Example: "2026-01-01"},
|
||||
{Name: "end_date", In: "query", Description: "End date (YYYY-MM-DD).", Example: "2026-01-31"},
|
||||
}
|
||||
}
|
||||
|
||||
if route.Path == "/healthz" {
|
||||
meta.Summary = "Health check"
|
||||
meta.Description = "Simple liveness probe."
|
||||
} else if route.Path == "/readyz" {
|
||||
meta.Summary = "Readiness check"
|
||||
meta.Description = "Readiness probe for database and Redis."
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
func inferTag(path string) string {
|
||||
switch {
|
||||
case path == "/healthz" || path == "/readyz":
|
||||
return "System"
|
||||
case strings.HasPrefix(path, "/api/master-data/"):
|
||||
return "Master Data"
|
||||
case strings.HasPrefix(path, "/api/finance/"):
|
||||
return "Finance"
|
||||
case strings.HasPrefix(path, "/api/inventory/"):
|
||||
return "Inventory"
|
||||
case strings.HasPrefix(path, "/api/production/"):
|
||||
return "Production"
|
||||
case strings.HasPrefix(path, "/api/reports/"):
|
||||
return "Reports"
|
||||
case strings.HasPrefix(path, "/api/closings/"):
|
||||
return "Closings"
|
||||
case strings.HasPrefix(path, "/api/expenses"):
|
||||
return "Expenses"
|
||||
case strings.HasPrefix(path, "/api/dashboards"):
|
||||
return "Dashboards"
|
||||
case strings.HasPrefix(path, "/api/purchases"):
|
||||
return "Purchases"
|
||||
case strings.HasPrefix(path, "/api/marketing"):
|
||||
return "Marketing"
|
||||
case strings.HasPrefix(path, "/api/users"):
|
||||
return "Users"
|
||||
case strings.HasPrefix(path, "/api/daily-checklists"):
|
||||
return "Daily Checklists"
|
||||
case strings.HasPrefix(path, "/api/sso"):
|
||||
return "SSO"
|
||||
default:
|
||||
return "API"
|
||||
}
|
||||
}
|
||||
|
||||
func defaultSummary(path string) string {
|
||||
switch path {
|
||||
case "/healthz":
|
||||
return "Health check"
|
||||
case "/readyz":
|
||||
return "Readiness check"
|
||||
}
|
||||
|
||||
trimmed := strings.Trim(path, "/")
|
||||
if trimmed == "" {
|
||||
return "List root"
|
||||
}
|
||||
parts := strings.Split(trimmed, "/")
|
||||
for i, part := range parts {
|
||||
parts[i] = strings.ReplaceAll(part, "-", " ")
|
||||
}
|
||||
return "GET " + strings.Join(parts, " / ")
|
||||
}
|
||||
|
||||
func toOpenAPIPath(path string) string {
|
||||
segments := strings.Split(path, "/")
|
||||
for i, segment := range segments {
|
||||
if strings.HasPrefix(segment, ":") {
|
||||
segments[i] = "{" + strings.TrimPrefix(segment, ":") + "}"
|
||||
}
|
||||
}
|
||||
return strings.Join(segments, "/")
|
||||
}
|
||||
|
||||
func inferPostmanPathVariable(path, param string) string {
|
||||
if param != "id" {
|
||||
return param
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/expenses/"):
|
||||
return "expense_id"
|
||||
case strings.HasPrefix(path, "/api/finance/payments/"):
|
||||
return "payment_id"
|
||||
case strings.HasPrefix(path, "/api/finance/transactions/"):
|
||||
return "transaction_id"
|
||||
case strings.HasPrefix(path, "/api/finance/initial-balances/"):
|
||||
return "initial_balance_id"
|
||||
case strings.HasPrefix(path, "/api/finance/injections/"):
|
||||
return "injection_id"
|
||||
case strings.HasPrefix(path, "/api/purchases/"):
|
||||
return "purchase_id"
|
||||
case strings.HasPrefix(path, "/api/inventory/adjustments/"):
|
||||
return "adjustment_id"
|
||||
case strings.HasPrefix(path, "/api/inventory/transfers/"):
|
||||
return "transfer_id"
|
||||
case strings.HasPrefix(path, "/api/master-data/banks/"):
|
||||
return "bank_id"
|
||||
case strings.HasPrefix(path, "/api/master-data/customers/"):
|
||||
return "customer_id"
|
||||
case strings.HasPrefix(path, "/api/master-data/suppliers/"):
|
||||
return "supplier_id"
|
||||
case strings.HasPrefix(path, "/api/master-data/locations/"):
|
||||
return "location_id"
|
||||
case strings.HasPrefix(path, "/api/master-data/areas/"):
|
||||
return "area_id"
|
||||
case strings.HasPrefix(path, "/api/master-data/products/"):
|
||||
return "product_id"
|
||||
case strings.HasPrefix(path, "/api/master-data/product-categories/"):
|
||||
return "product_category_id"
|
||||
case strings.HasPrefix(path, "/api/master-data/nonstocks/"):
|
||||
return "nonstock_id"
|
||||
case strings.HasPrefix(path, "/api/master-data/employees/"):
|
||||
return "employee_id"
|
||||
case strings.HasPrefix(path, "/api/master-data/flocks/"):
|
||||
return "flock_id"
|
||||
case strings.HasPrefix(path, "/api/master-data/warehouses/"):
|
||||
return "warehouse_id"
|
||||
case strings.HasPrefix(path, "/api/master-data/uoms/"):
|
||||
return "uom_id"
|
||||
case strings.HasPrefix(path, "/api/users/"):
|
||||
return "user_id"
|
||||
case strings.HasPrefix(path, "/api/production/recordings/"):
|
||||
return "recording_id"
|
||||
case strings.HasPrefix(path, "/api/production/uniformities/"):
|
||||
return "uniformity_id"
|
||||
case strings.HasPrefix(path, "/api/production/chickins/"):
|
||||
return "chickin_id"
|
||||
default:
|
||||
return "id"
|
||||
}
|
||||
}
|
||||
|
||||
func defaultExampleForVariable(name string) string {
|
||||
if strings.Contains(name, "date") {
|
||||
return "2026-01-01"
|
||||
}
|
||||
if strings.Contains(name, "token") || strings.Contains(name, "key") {
|
||||
return ""
|
||||
}
|
||||
return "1"
|
||||
}
|
||||
|
||||
func inferSchema(example any) map[string]any {
|
||||
switch example.(type) {
|
||||
case int, int32, int64, uint, uint32, uint64, float64:
|
||||
return map[string]any{"type": "integer"}
|
||||
default:
|
||||
return map[string]any{"type": "string"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package readapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/cache"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/route"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestGeneratedArtifactsAreCurrent(t *testing.T) {
|
||||
PrimeBuildConfig()
|
||||
cache.SetRedis(redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"}))
|
||||
app := fiber.New(config.FiberConfig())
|
||||
app.Get("/healthz", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"status": "ok"})
|
||||
})
|
||||
app.Get("/readyz", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"status": "ok"})
|
||||
})
|
||||
route.Routes(app, nil)
|
||||
|
||||
artifacts, err := BuildArtifactsFromApp(app)
|
||||
if err != nil {
|
||||
t.Fatalf("build artifacts: %v", err)
|
||||
}
|
||||
|
||||
root := repoRoot(t)
|
||||
assertJSONMatchesFile(t, artifacts.OpenAPIJSON, filepath.Join(root, "docs", "openapi", "read-api.json"))
|
||||
assertYAMLMatchesFile(t, artifacts.OpenAPIYAML, filepath.Join(root, "docs", "openapi", "read-api.yaml"))
|
||||
assertJSONMatchesFile(t, artifacts.PostmanCollectionJSON, filepath.Join(root, "docs", "postman", "read-api.collection.json"))
|
||||
assertJSONMatchesFile(t, artifacts.PostmanEnvironmentJSON, filepath.Join(root, "docs", "postman", "read-api.environment.json"))
|
||||
}
|
||||
|
||||
func assertJSONMatchesFile(t *testing.T, got []byte, path string) {
|
||||
t.Helper()
|
||||
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
|
||||
var gotValue any
|
||||
if err := json.Unmarshal(got, &gotValue); err != nil {
|
||||
t.Fatalf("unmarshal generated json: %v", err)
|
||||
}
|
||||
var wantValue any
|
||||
if err := json.Unmarshal(want, &wantValue); err != nil {
|
||||
t.Fatalf("unmarshal fixture json: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(gotValue, wantValue) {
|
||||
t.Fatalf("json artifact mismatch for %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
func assertYAMLMatchesFile(t *testing.T, got []byte, path string) {
|
||||
t.Helper()
|
||||
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
|
||||
var gotValue any
|
||||
if err := yaml.Unmarshal(got, &gotValue); err != nil {
|
||||
t.Fatalf("unmarshal generated yaml: %v", err)
|
||||
}
|
||||
var wantValue any
|
||||
if err := yaml.Unmarshal(want, &wantValue); err != nil {
|
||||
t.Fatalf("unmarshal fixture yaml: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(gotValue, wantValue) {
|
||||
t.Fatalf("yaml artifact mismatch for %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
|
||||
return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
|
||||
}
|
||||
Reference in New Issue
Block a user