feat(openapi): add unified /setting/* endpoints for OpenAPI
- Introduced handlers for the new /setting/* endpoints in the OpenAPI module. - Updated import statements to include the new setting package for better organization. - Enhanced routing capabilities to support the new settings functionality.
This commit is contained in:
parent
934424f9ea
commit
3bc15b1039
7 changed files with 505 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -83,3 +83,4 @@ agent/robot/ROBOT-CACHE-IMPROVEMENT.md
|
|||
sandbox/v2/PID-KILL-UPGRADE.md
|
||||
sandbox/v2/*.md
|
||||
POSTGRESQL_COMPAT.md
|
||||
openapi/setting/*.md
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import (
|
|||
"github.com/yaoapp/yao/openapi/otp"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
"github.com/yaoapp/yao/openapi/sandbox"
|
||||
openAPISetting "github.com/yaoapp/yao/openapi/setting"
|
||||
openapiTai "github.com/yaoapp/yao/openapi/tai"
|
||||
"github.com/yaoapp/yao/openapi/team"
|
||||
openapiTrace "github.com/yaoapp/yao/openapi/trace"
|
||||
|
|
@ -199,6 +200,9 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) {
|
|||
group.POST("/tai-nodes/heartbeat", taiapi.HandleHeartbeat)
|
||||
group.DELETE("/tai-nodes/register/:tai_id", taiapi.HandleUnregister)
|
||||
|
||||
// Setting handlers (unified /setting/* endpoints)
|
||||
openAPISetting.Attach(group.Group("/setting"), openapi.OAuth)
|
||||
|
||||
// Custom handlers (Defined by developer)
|
||||
|
||||
}
|
||||
|
|
|
|||
42
openapi/setting/promotions.yml
Normal file
42
openapi/setting/promotions.yml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Promotions & localized labels for the System Info page.
|
||||
# Embedded at compile time via go:embed.
|
||||
|
||||
# --- Localized UI labels ---
|
||||
labels:
|
||||
deployment:
|
||||
community:
|
||||
zh: "社区版"
|
||||
en: "Community"
|
||||
starter:
|
||||
zh: "入门版"
|
||||
en: "Starter"
|
||||
pro:
|
||||
zh: "专业版"
|
||||
en: "Pro"
|
||||
enterprise:
|
||||
zh: "企业版"
|
||||
en: "Enterprise"
|
||||
cloud:
|
||||
zh: "Cloud"
|
||||
en: "Cloud"
|
||||
environment:
|
||||
development:
|
||||
zh: "测试环境"
|
||||
en: "Development"
|
||||
production:
|
||||
zh: "正式环境"
|
||||
en: "Production"
|
||||
|
||||
# --- Promotions by deployment type ---
|
||||
community:
|
||||
- id: upgrade-enterprise
|
||||
link: "https://yaoagents.com/enterprise?source=yao-setting"
|
||||
i18n:
|
||||
zh:
|
||||
title: "升级到企业版"
|
||||
desc: "专属支持、私有部署、完全可控,行业 Agents 方案"
|
||||
label: "了解更多 →"
|
||||
en:
|
||||
title: "Upgrade to Enterprise"
|
||||
desc: "Dedicated support, private deployment, full control, industry-specific Agents solutions"
|
||||
label: "Learn more →"
|
||||
40
openapi/setting/setting.go
Normal file
40
openapi/setting/setting.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package setting
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
oauthTypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
"github.com/yaoapp/yao/setting"
|
||||
)
|
||||
|
||||
// Attach registers all /setting/* routes under the given group.
|
||||
// Currently only System Info routes are wired; other groups will be
|
||||
// added incrementally.
|
||||
func Attach(group *gin.RouterGroup, oauth oauthTypes.OAuth) {
|
||||
group.Use(oauth.Guard)
|
||||
|
||||
sys := group.Group("/system")
|
||||
sys.GET("", handleSystemInfo)
|
||||
sys.POST("/check-update", handleSystemCheckUpdate)
|
||||
}
|
||||
|
||||
// resolveOwner extracts the authenticated user/team from the Gin context
|
||||
// and returns a setting.ScopeID suitable for registry operations.
|
||||
func resolveOwner(c *gin.Context) setting.ScopeID {
|
||||
info := authorized.GetInfo(c)
|
||||
return setting.ScopeID{
|
||||
Scope: setting.ScopeUser,
|
||||
TeamID: info.TeamID,
|
||||
UserID: info.UserID,
|
||||
}
|
||||
}
|
||||
|
||||
// respondError is a thin helper that writes a JSON error via the shared
|
||||
// response package.
|
||||
func respondError(c *gin.Context, status int, msg string) {
|
||||
response.RespondWithError(c, status, &response.ErrorResponse{
|
||||
Code: "server_error",
|
||||
ErrorDescription: msg,
|
||||
})
|
||||
}
|
||||
255
openapi/setting/system.go
Normal file
255
openapi/setting/system.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
package setting
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/yao/commercial"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
"github.com/yaoapp/yao/share"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const cdnBase = "https://get.yaoapps.com/yao"
|
||||
|
||||
// update check cache (package-level, protected by mutex)
|
||||
var (
|
||||
updateCache *CheckUpdateResult
|
||||
updateCacheTime time.Time
|
||||
updateMu sync.Mutex
|
||||
cacheTTL = 10 * time.Minute
|
||||
)
|
||||
|
||||
// handleSystemInfo returns aggregated system information.
|
||||
// GET /setting/system?locale=zh-cn
|
||||
func handleSystemInfo(c *gin.Context) {
|
||||
locale := strings.ToLower(c.DefaultQuery("locale", "en-us"))
|
||||
|
||||
env := share.App.Option["env"]
|
||||
environment, _ := env.(string)
|
||||
if environment == "" {
|
||||
environment = config.Conf.Mode
|
||||
}
|
||||
if environment == "" {
|
||||
environment = "production"
|
||||
}
|
||||
|
||||
listen := fmt.Sprintf("%s:%d", config.Conf.Host, config.Conf.Port)
|
||||
sessionStore := config.Conf.Session.Store
|
||||
if sessionStore == "" {
|
||||
sessionStore = "file"
|
||||
}
|
||||
|
||||
lang := langFromLocale(locale)
|
||||
lic := commercial.License
|
||||
deployment := lic.Edition
|
||||
if deployment == "" {
|
||||
deployment = "community"
|
||||
}
|
||||
|
||||
var licenseKey string
|
||||
if lic.Valid && lic.SerialNumber != "" {
|
||||
licenseKey = lic.SerialNumber
|
||||
}
|
||||
|
||||
data := SystemInfoData{
|
||||
App: AppInfo{
|
||||
Name: share.App.Name,
|
||||
Short: share.App.Short,
|
||||
Description: share.App.Description,
|
||||
Logo: "/api/__yao/app/icons/app.png",
|
||||
Version: share.App.Version,
|
||||
},
|
||||
Deployment: deployment,
|
||||
DeploymentLabel: resolveLabel(promFile.Labels.Deployment, deployment, lang, deployment),
|
||||
LicenseKey: licenseKey,
|
||||
Environment: environment,
|
||||
EnvironmentLabel: resolveLabel(promFile.Labels.Environment, environment, lang, environment),
|
||||
Server: VersionInfo{
|
||||
Version: share.VERSION,
|
||||
BuildDate: share.PRVERSION,
|
||||
CommitSHA: share.PRVERSION,
|
||||
},
|
||||
Client: VersionInfo{
|
||||
Version: share.CUI,
|
||||
BuildDate: share.PRCUI,
|
||||
CommitSHA: share.PRCUI,
|
||||
},
|
||||
Technical: TechnicalInfo{
|
||||
Listen: listen,
|
||||
DBDriver: config.Conf.DB.Driver,
|
||||
SessionStore: sessionStore,
|
||||
},
|
||||
Promotions: buildPromotions(deployment, locale),
|
||||
}
|
||||
|
||||
response.RespondWithSuccess(c, http.StatusOK, data)
|
||||
}
|
||||
|
||||
//go:embed promotions.yml
|
||||
var promotionsYML []byte
|
||||
|
||||
type promotionEntry struct {
|
||||
ID string `yaml:"id"`
|
||||
Link string `yaml:"link"`
|
||||
I18n map[string]promotionLocale `yaml:"i18n"`
|
||||
}
|
||||
|
||||
type promotionLocale struct {
|
||||
Title string `yaml:"title"`
|
||||
Desc string `yaml:"desc"`
|
||||
Label string `yaml:"label"`
|
||||
}
|
||||
|
||||
type promotionsFile struct {
|
||||
Labels struct {
|
||||
Deployment map[string]map[string]string `yaml:"deployment"`
|
||||
Environment map[string]map[string]string `yaml:"environment"`
|
||||
} `yaml:"labels"`
|
||||
Community []promotionEntry `yaml:"community"`
|
||||
Enterprise []promotionEntry `yaml:"enterprise"`
|
||||
Cloud []promotionEntry `yaml:"cloud"`
|
||||
}
|
||||
|
||||
var promFile promotionsFile
|
||||
|
||||
func init() {
|
||||
yaml.Unmarshal(promotionsYML, &promFile)
|
||||
}
|
||||
|
||||
func resolveLabel(m map[string]map[string]string, key, lang, fallback string) string {
|
||||
if langs, ok := m[key]; ok {
|
||||
if v, ok := langs[lang]; ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := langs["en"]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func langFromLocale(locale string) string {
|
||||
if strings.HasPrefix(locale, "zh") {
|
||||
return "zh"
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
func buildPromotions(deployment, locale string) []Promotion {
|
||||
lang := langFromLocale(locale)
|
||||
|
||||
var entries []promotionEntry
|
||||
switch deployment {
|
||||
case "community":
|
||||
entries = promFile.Community
|
||||
case "enterprise":
|
||||
entries = promFile.Enterprise
|
||||
case "cloud":
|
||||
entries = promFile.Cloud
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
promos := make([]Promotion, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
loc, ok := e.I18n[lang]
|
||||
if !ok {
|
||||
loc = e.I18n["en"]
|
||||
}
|
||||
promos = append(promos, Promotion{
|
||||
ID: e.ID,
|
||||
Title: loc.Title,
|
||||
Desc: loc.Desc,
|
||||
Link: e.Link,
|
||||
Label: loc.Label,
|
||||
})
|
||||
}
|
||||
return promos
|
||||
}
|
||||
|
||||
// handleSystemCheckUpdate checks for a newer engine release.
|
||||
// Uses the same CDN source as `yao upgrade` and yao-desktop:
|
||||
//
|
||||
// GET https://get.yaoapps.com/yao/latest.json
|
||||
//
|
||||
// POST /setting/system/check-update
|
||||
func handleSystemCheckUpdate(c *gin.Context) {
|
||||
updateMu.Lock()
|
||||
if updateCache != nil && time.Since(updateCacheTime) < cacheTTL {
|
||||
result := *updateCache
|
||||
updateMu.Unlock()
|
||||
response.RespondWithSuccess(c, http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
updateMu.Unlock()
|
||||
|
||||
result := fetchLatestVersion()
|
||||
|
||||
updateMu.Lock()
|
||||
updateCache = &result
|
||||
updateCacheTime = time.Now()
|
||||
updateMu.Unlock()
|
||||
|
||||
response.RespondWithSuccess(c, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// cdnLatest mirrors the JSON structure of get.yaoapps.com/yao/latest.json
|
||||
// (same format used by cmd/upgrade.go and yao-desktop updater.rs).
|
||||
type cdnLatest struct {
|
||||
Version string `json:"version"`
|
||||
ReleasedAt string `json:"released_at"`
|
||||
Assets map[string]string `json:"assets"`
|
||||
}
|
||||
|
||||
func fetchLatestVersion() CheckUpdateResult {
|
||||
current := strings.TrimPrefix(share.VERSION, "v")
|
||||
base := CheckUpdateResult{HasUpdate: false, CurrentVersion: current}
|
||||
|
||||
url := cdnBase + "/latest.json"
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return base
|
||||
}
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("yao/%s", share.VERSION))
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return base
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return base
|
||||
}
|
||||
|
||||
var data cdnLatest
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return base
|
||||
}
|
||||
|
||||
latest := strings.TrimPrefix(data.Version, "v")
|
||||
if latest == "" {
|
||||
return base
|
||||
}
|
||||
|
||||
platformKey := fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH)
|
||||
downloadURL := data.Assets[platformKey]
|
||||
|
||||
return CheckUpdateResult{
|
||||
HasUpdate: latest != current,
|
||||
CurrentVersion: current,
|
||||
LatestVersion: latest,
|
||||
DownloadURL: downloadURL,
|
||||
}
|
||||
}
|
||||
55
openapi/setting/types.go
Normal file
55
openapi/setting/types.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package setting
|
||||
|
||||
// SystemInfoData is the top-level response for GET /setting/system.
|
||||
type SystemInfoData struct {
|
||||
App AppInfo `json:"app"`
|
||||
Deployment string `json:"deployment"`
|
||||
DeploymentLabel string `json:"deployment_label"`
|
||||
LicenseKey string `json:"license_key,omitempty"`
|
||||
Server VersionInfo `json:"server"`
|
||||
Client VersionInfo `json:"client"`
|
||||
Environment string `json:"environment"`
|
||||
EnvironmentLabel string `json:"environment_label"`
|
||||
Technical TechnicalInfo `json:"technical"`
|
||||
Promotions []Promotion `json:"promotions,omitempty"`
|
||||
}
|
||||
|
||||
// Promotion is a localized CTA banner returned by the API.
|
||||
type Promotion struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Desc string `json:"desc"`
|
||||
Link string `json:"link"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// AppInfo describes the running application.
|
||||
type AppInfo struct {
|
||||
Name string `json:"name"`
|
||||
Short string `json:"short"`
|
||||
Description string `json:"description"`
|
||||
Logo string `json:"logo"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// VersionInfo carries build metadata for a component (engine / CUI).
|
||||
type VersionInfo struct {
|
||||
Version string `json:"version"`
|
||||
BuildDate string `json:"build_date"`
|
||||
CommitSHA string `json:"commit"`
|
||||
}
|
||||
|
||||
// TechnicalInfo contains runtime / infrastructure details.
|
||||
type TechnicalInfo struct {
|
||||
Listen string `json:"listen"`
|
||||
DBDriver string `json:"db_driver"`
|
||||
SessionStore string `json:"session_store"`
|
||||
}
|
||||
|
||||
// CheckUpdateResult is the response for POST /setting/system/check-update.
|
||||
type CheckUpdateResult struct {
|
||||
HasUpdate bool `json:"has_update"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
LatestVersion string `json:"latest_version,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
}
|
||||
108
openapi/tests/setting/system_test.go
Normal file
108
openapi/tests/setting/system_test.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package setting_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/openapi"
|
||||
"github.com/yaoapp/yao/openapi/tests/testutils"
|
||||
)
|
||||
|
||||
func baseURL() string {
|
||||
if openapi.Server != nil && openapi.Server.Config != nil {
|
||||
return openapi.Server.Config.BaseURL
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestSystemInfo verifies GET /setting/system returns the expected structure.
|
||||
func TestSystemInfo(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
client := testutils.RegisterTestClient(t, "Setting System Test", []string{"https://localhost/callback"})
|
||||
defer testutils.CleanupTestClient(t, client.ClientID)
|
||||
token := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
|
||||
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL()+"/setting/system", nil)
|
||||
assert.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Top-level keys
|
||||
assert.Contains(t, body, "app")
|
||||
assert.Contains(t, body, "deployment")
|
||||
assert.Contains(t, body, "server")
|
||||
assert.Contains(t, body, "client")
|
||||
assert.Contains(t, body, "environment")
|
||||
assert.Contains(t, body, "technical")
|
||||
|
||||
// app sub-fields
|
||||
app, ok := body["app"].(map[string]interface{})
|
||||
assert.True(t, ok)
|
||||
assert.NotEmpty(t, app["name"])
|
||||
assert.NotEmpty(t, app["version"])
|
||||
|
||||
// server sub-fields
|
||||
server, ok := body["server"].(map[string]interface{})
|
||||
assert.True(t, ok)
|
||||
assert.NotEmpty(t, server["version"])
|
||||
|
||||
// technical sub-fields
|
||||
tech, ok := body["technical"].(map[string]interface{})
|
||||
assert.True(t, ok)
|
||||
assert.NotEmpty(t, tech["listen"])
|
||||
assert.NotEmpty(t, tech["db_driver"])
|
||||
assert.NotEmpty(t, tech["session_store"])
|
||||
}
|
||||
|
||||
// TestSystemInfoUnauthenticated verifies 401 when no token is provided.
|
||||
func TestSystemInfoUnauthenticated(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL()+"/setting/system", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestSystemCheckUpdate verifies POST /setting/system/check-update returns has_update.
|
||||
func TestSystemCheckUpdate(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
client := testutils.RegisterTestClient(t, "Setting CheckUpdate Test", []string{"https://localhost/callback"})
|
||||
defer testutils.CleanupTestClient(t, client.ClientID)
|
||||
token := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
|
||||
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL()+"/setting/system/check-update", nil)
|
||||
assert.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, exists := body["has_update"]
|
||||
assert.True(t, exists, "response must contain has_update field")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue