Merge remote-tracking branch 'origin/main' into feature/openapi-docs

# Conflicts:
#	apps/api/internal/httpapi/handlers.go
This commit is contained in:
2026-05-15 09:42:44 +08:00
50 changed files with 10910 additions and 2190 deletions
@@ -0,0 +1,58 @@
package httpapi
import (
"io"
"net/http"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
)
const maxGatewayUploadBytes = 256 << 20
func (s *Server) uploadFile(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes)
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart upload")
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "file is required")
return
}
defer file.Close()
payload, err := io.ReadAll(file)
if err != nil {
writeError(w, http.StatusBadRequest, "read upload file failed")
return
}
contentType := strings.TrimSpace(header.Header.Get("Content-Type"))
if contentType == "" && len(payload) > 0 {
contentType = http.DetectContentType(payload)
}
upload, err := s.runner.UploadFile(r.Context(), runner.FileUploadPayload{
Bytes: payload,
ContentType: contentType,
FileName: header.Filename,
Source: firstNonEmptyFormValue(r, "source", "ai-gateway-openapi"),
})
if err != nil {
s.logger.Error("upload file failed", "error", err)
status := http.StatusBadGateway
if clients.ErrorCode(err) == "upload_no_channel" {
status = http.StatusServiceUnavailable
}
writeError(w, status, err.Error())
return
}
writeJSON(w, http.StatusOK, upload)
}
func firstNonEmptyFormValue(r *http.Request, key string, fallback string) string {
if value := strings.TrimSpace(r.FormValue(key)); value != "" {
return value
}
return fallback
}
+137 -2
View File
@@ -953,7 +953,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
status := statusFromRunError(runErr)
errorPayload := map[string]any{
"code": runErrorCode(runErr),
"message": runErr.Error(),
"message": runErrorMessage(runErr),
"status": status,
}
if result.Task.ID != "" {
@@ -962,6 +962,9 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
if result.Task.RequestID != "" {
errorPayload["requestId"] = result.Task.RequestID
}
for key, value := range runErrorDetails(runErr) {
errorPayload[key] = value
}
sendSSE(w, "error", map[string]any{"error": errorPayload})
if flusher != nil {
flusher.Flush()
@@ -982,7 +985,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
if !requestStillConnected(r) {
return
}
writeError(w, statusFromRunError(runErr), runErr.Error(), runErrorCode(runErr))
writeErrorWithDetails(w, statusFromRunError(runErr), runErrorMessage(runErr), runErrorDetails(runErr), runErrorCode(runErr))
return
}
if !requestStillConnected(r) {
@@ -1098,6 +1101,138 @@ func runErrorCode(err error) string {
return clients.ErrorCode(err)
}
func runErrorMessage(err error) string {
if err == nil {
return ""
}
if summary := rateLimitErrorSummary(err); summary != "" {
return err.Error() + "" + summary
}
return err.Error()
}
func runErrorDetails(err error) map[string]any {
if detail := rateLimitErrorDetail(err); len(detail) > 0 {
return map[string]any{"rateLimit": detail}
}
return nil
}
func rateLimitErrorSummary(err error) string {
var limitErr *store.RateLimitExceededError
if !errors.As(err, &limitErr) {
return ""
}
scopeLabel := "限流对象"
switch limitErr.ScopeType {
case "user_group":
scopeLabel = "用户组"
case "platform_model":
scopeLabel = "平台模型"
}
scopeName := strings.TrimSpace(limitErr.ScopeName)
if scopeName == "" {
scopeName = strings.TrimSpace(limitErr.ScopeKey)
}
if groupKey := stringValue(limitErr.ScopeMetadata["groupKey"]); limitErr.ScopeType == "user_group" && groupKey != "" && groupKey != scopeName {
scopeName = fmt.Sprintf("%s(%s)", scopeName, groupKey)
}
projected := limitErr.Projected
if projected <= 0 {
projected = limitErr.Current + limitErr.Amount
}
parts := []string{
fmt.Sprintf("限流摘要:%s %s 的 %s 超限", scopeLabel, scopeName, limitErr.Metric),
fmt.Sprintf("当前 %s,本次 %s,预计 %s,限制 %s", formatRateLimitValue(limitErr.Current), formatRateLimitValue(limitErr.Amount), formatRateLimitValue(projected), formatRateLimitValue(limitErr.Limit)),
}
if limitErr.WindowSeconds > 0 {
parts = append(parts, fmt.Sprintf("窗口 %d 秒", limitErr.WindowSeconds))
}
if limitErr.RetryAfter > 0 {
parts = append(parts, fmt.Sprintf("约%s后可重试", formatRateLimitDuration(limitErr.RetryAfter)))
} else if !limitErr.Retryable {
parts = append(parts, "该请求超过单次限额,不能排队重试")
}
return strings.Join(parts, "")
}
func rateLimitErrorDetail(err error) map[string]any {
var limitErr *store.RateLimitExceededError
if !errors.As(err, &limitErr) {
return nil
}
detail := map[string]any{
"scopeType": limitErr.ScopeType,
"scopeKey": limitErr.ScopeKey,
"scopeName": limitErr.ScopeName,
"metric": limitErr.Metric,
"limit": limitErr.Limit,
"amount": limitErr.Amount,
"current": limitErr.Current,
"used": limitErr.Used,
"reserved": limitErr.Reserved,
"projected": limitErr.Projected,
"windowSeconds": limitErr.WindowSeconds,
"retryable": limitErr.Retryable,
"exceeded": map[string]any{
"metric": limitErr.Metric,
"current": limitErr.Current,
"amount": limitErr.Amount,
"projected": limitErr.Projected,
"limit": limitErr.Limit,
},
}
if limitErr.RetryAfter > 0 {
detail["retryAfterMs"] = limitErr.RetryAfter.Milliseconds()
}
if !limitErr.ResetAt.IsZero() {
detail["resetAt"] = limitErr.ResetAt.UTC().Format(time.RFC3339Nano)
}
if len(limitErr.Policy) > 0 {
detail["rateLimitPolicy"] = limitErr.Policy
if matchedRule := matchedRateLimitRule(limitErr.Policy, limitErr.Metric); len(matchedRule) > 0 {
detail["matchedRule"] = matchedRule
}
}
if len(limitErr.ScopeMetadata) > 0 {
detail["scopeMetadata"] = limitErr.ScopeMetadata
}
if limitErr.ScopeType == "user_group" {
userGroup := map[string]any{
"id": limitErr.ScopeKey,
"name": limitErr.ScopeName,
}
if groupKey := stringValue(limitErr.ScopeMetadata["groupKey"]); groupKey != "" {
userGroup["groupKey"] = groupKey
}
detail["userGroup"] = userGroup
}
return detail
}
func formatRateLimitValue(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64)
}
func formatRateLimitDuration(duration time.Duration) string {
if duration < time.Second {
return strconv.FormatInt(duration.Milliseconds(), 10) + "毫秒"
}
seconds := duration.Seconds()
return strconv.FormatFloat(seconds, 'f', -1, 64) + "秒"
}
func matchedRateLimitRule(policy map[string]any, metric string) map[string]any {
rules, _ := policy["rules"].([]any)
for _, rawRule := range rules {
rule, _ := rawRule.(map[string]any)
if stringValue(rule["metric"]) == metric {
return rule
}
}
return nil
}
// listTasks godoc
// @Summary 列出任务
// @Description 按当前用户列出任务,支持关键字、模型类型、时间范围和分页过滤。
@@ -0,0 +1,72 @@
package httpapi
import (
"strings"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestRateLimitErrorDetailIncludesUserGroupAndExceededMetric(t *testing.T) {
resetAt := time.Date(2026, 5, 15, 10, 30, 0, 0, time.UTC)
detail := rateLimitErrorDetail(&store.RateLimitExceededError{
ScopeType: "user_group",
ScopeKey: "group-1",
ScopeName: "VIP 用户组",
ScopeMetadata: map[string]any{"groupKey": "vip"},
Metric: "rpm",
Limit: 2,
Amount: 1,
Current: 2,
Used: 1,
Reserved: 1,
Projected: 3,
WindowSeconds: 60,
ResetAt: resetAt,
RetryAfter: 5 * time.Second,
Retryable: true,
Policy: map[string]any{
"rules": []any{
map[string]any{"metric": "rpm", "limit": float64(2), "windowSeconds": float64(60)},
},
},
})
if detail["metric"] != "rpm" || detail["projected"] != float64(3) || detail["limit"] != float64(2) {
t.Fatalf("unexpected exceeded detail: %+v", detail)
}
userGroup, _ := detail["userGroup"].(map[string]any)
if userGroup["id"] != "group-1" || userGroup["groupKey"] != "vip" || userGroup["name"] != "VIP 用户组" {
t.Fatalf("missing user group detail: %+v", detail)
}
matchedRule, _ := detail["matchedRule"].(map[string]any)
if matchedRule["metric"] != "rpm" {
t.Fatalf("missing matched rule: %+v", detail)
}
if detail["retryAfterMs"] != int64(5000) || detail["resetAt"] != resetAt.Format(time.RFC3339Nano) {
t.Fatalf("missing retry/reset detail: %+v", detail)
}
}
func TestRunErrorMessageIncludesRateLimitSummary(t *testing.T) {
message := runErrorMessage(&store.RateLimitExceededError{
ScopeType: "user_group",
ScopeKey: "group-1",
ScopeName: "VIP 用户组",
ScopeMetadata: map[string]any{"groupKey": "vip"},
Metric: "rpm",
Limit: 2,
Amount: 1,
Current: 2,
Projected: 3,
WindowSeconds: 60,
RetryAfter: 5 * time.Second,
Retryable: true,
Message: "rate limit exceeded: rpm window has no remaining capacity",
})
for _, expected := range []string{"限流摘要", "用户组 VIP 用户组(vip)", "rpm 超限", "当前 2", "本次 1", "预计 3", "限制 2", "窗口 60 秒", "约5秒后可重试"} {
if !strings.Contains(message, expected) {
t.Fatalf("message %q should contain %q", message, expected)
}
}
}
+7
View File
@@ -14,6 +14,10 @@ func writeJSON(w http.ResponseWriter, status int, value any) {
}
func writeError(w http.ResponseWriter, status int, message string, codes ...string) {
writeErrorWithDetails(w, status, message, nil, codes...)
}
func writeErrorWithDetails(w http.ResponseWriter, status int, message string, details map[string]any, codes ...string) {
errorPayload := map[string]any{
"message": message,
"status": status,
@@ -23,6 +27,9 @@ func writeError(w http.ResponseWriter, status int, message string, codes ...stri
errorPayload["code"] = code
}
}
for key, value := range details {
errorPayload[key] = value
}
writeJSON(w, status, map[string]any{"error": errorPayload})
}
+10
View File
@@ -41,6 +41,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.HandleFunc("GET /healthz", server.health)
mux.HandleFunc("GET /readyz", server.ready)
mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset)
mux.HandleFunc("GET /static/generated/{asset}", server.serveGeneratedStaticAsset)
mux.HandleFunc("GET /static/uploaded/{asset}", server.serveUploadedStaticAsset)
mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register)))
mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login)))
@@ -102,6 +104,12 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("GET /api/admin/runtime/runner-policy", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getRunnerPolicy)))
mux.Handle("PATCH /api/admin/runtime/runner-policy", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateRunnerPolicy)))
mux.Handle("GET /api/admin/config/network-proxy", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getNetworkProxyConfig)))
mux.Handle("GET /api/admin/system/file-storage/settings", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getFileStorageSettings)))
mux.Handle("PATCH /api/admin/system/file-storage/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageSettings)))
mux.Handle("GET /api/admin/system/file-storage/channels", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listFileStorageChannels)))
mux.Handle("POST /api/admin/system/file-storage/channels", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createFileStorageChannel)))
mux.Handle("PATCH /api/admin/system/file-storage/channels/{channelID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageChannel)))
mux.Handle("DELETE /api/admin/system/file-storage/channels/{channelID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteFileStorageChannel)))
mux.Handle("GET /api/admin/platforms", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPlatforms)))
mux.Handle("POST /api/admin/platforms", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPlatform)))
mux.Handle("PATCH /api/admin/platforms/{platformID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updatePlatform)))
@@ -123,6 +131,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("POST /api/v1/images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", false)))
mux.Handle("POST /api/v1/images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", false)))
mux.Handle("POST /api/v1/videos/generations", server.auth.Require(auth.PermissionBasic, server.createTask("videos.generations", false)))
mux.Handle("POST /api/v1/files/upload", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.uploadFile)))
mux.Handle("GET /api/v1/tasks", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
mux.Handle("GET /api/v1/tasks/{taskID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
mux.Handle("GET /api/v1/tasks/{taskID}/param-preprocessing", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskParamPreprocessing)))
@@ -135,6 +144,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("POST /v1/images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", true)))
mux.Handle("POST /images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", true)))
mux.Handle("POST /v1/images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", true)))
mux.Handle("POST /v1/files/upload", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.uploadFile)))
return server.recover(server.cors(mux))
}
@@ -0,0 +1,37 @@
package httpapi
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
)
func (s *Server) serveGeneratedStaticAsset(w http.ResponseWriter, r *http.Request) {
s.serveLocalStaticAsset(w, r, s.cfg.LocalGeneratedStorageDir, config.DefaultLocalGeneratedStorageDir)
}
func (s *Server) serveUploadedStaticAsset(w http.ResponseWriter, r *http.Request) {
s.serveLocalStaticAsset(w, r, s.cfg.LocalUploadedStorageDir, config.DefaultLocalUploadedStorageDir)
}
func (s *Server) serveLocalStaticAsset(w http.ResponseWriter, r *http.Request, storageDir string, fallbackStorageDir string) {
fileName := filepath.Base(strings.TrimSpace(r.PathValue("asset")))
if fileName == "" || fileName == "." || fileName == ".." || fileName == string(filepath.Separator) {
http.NotFound(w, r)
return
}
storageDir = strings.TrimSpace(storageDir)
if storageDir == "" {
storageDir = fallbackStorageDir
}
filePath := filepath.Join(storageDir, fileName)
info, err := os.Stat(filePath)
if err != nil || info.IsDir() {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, filePath)
}
@@ -0,0 +1,65 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
)
func TestServeGeneratedStaticAsset(t *testing.T) {
storageDir := t.TempDir()
if err := os.WriteFile(filepath.Join(storageDir, "result.png"), []byte("png"), 0o644); err != nil {
t.Fatalf("failed to write generated asset fixture: %v", err)
}
server := &Server{cfg: config.Config{LocalGeneratedStorageDir: storageDir}}
request := httptest.NewRequest(http.MethodGet, "/static/generated/result.png", nil)
request.SetPathValue("asset", "result.png")
response := httptest.NewRecorder()
server.serveGeneratedStaticAsset(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected generated asset to be served, got status %d", response.Code)
}
if response.Body.String() != "png" {
t.Fatalf("unexpected generated asset payload: %q", response.Body.String())
}
}
func TestServeUploadedStaticAsset(t *testing.T) {
storageDir := t.TempDir()
if err := os.WriteFile(filepath.Join(storageDir, "upload.pdf"), []byte("pdf"), 0o644); err != nil {
t.Fatalf("failed to write uploaded asset fixture: %v", err)
}
server := &Server{cfg: config.Config{LocalUploadedStorageDir: storageDir}}
request := httptest.NewRequest(http.MethodGet, "/static/uploaded/upload.pdf", nil)
request.SetPathValue("asset", "upload.pdf")
response := httptest.NewRecorder()
server.serveUploadedStaticAsset(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected uploaded asset to be served, got status %d", response.Code)
}
if response.Body.String() != "pdf" {
t.Fatalf("unexpected uploaded asset payload: %q", response.Body.String())
}
}
func TestServeLocalStaticAssetRejectsTraversal(t *testing.T) {
storageDir := t.TempDir()
server := &Server{cfg: config.Config{LocalGeneratedStorageDir: storageDir}}
request := httptest.NewRequest(http.MethodGet, "/static/generated/..", nil)
request.SetPathValue("asset", "..")
response := httptest.NewRecorder()
server.serveGeneratedStaticAsset(response, request)
if response.Code != http.StatusNotFound {
t.Fatalf("expected traversal-like generated asset name to 404, got status %d", response.Code)
}
}
@@ -0,0 +1,150 @@
package httpapi
import (
"encoding/json"
"net/http"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func (s *Server) listFileStorageChannels(w http.ResponseWriter, r *http.Request) {
items, err := s.store.ListFileStorageChannels(r.Context())
if err != nil {
s.logger.Error("list file storage channels failed", "error", err)
writeError(w, http.StatusInternalServerError, "list file storage channels failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) getFileStorageSettings(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetFileStorageSettings(r.Context())
if err != nil {
if store.IsUndefinedDatabaseObject(err) {
writeJSON(w, http.StatusOK, store.DefaultFileStorageSettings())
return
}
s.logger.Error("get file storage settings failed", "error", err)
writeError(w, http.StatusInternalServerError, "get file storage settings failed")
return
}
writeJSON(w, http.StatusOK, settings)
}
func (s *Server) updateFileStorageSettings(w http.ResponseWriter, r *http.Request) {
var input store.FileStorageSettingsInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
settings, err := s.store.UpdateFileStorageSettings(r.Context(), input)
if err != nil {
s.logger.Error("update file storage settings failed", "error", err)
writeError(w, http.StatusInternalServerError, "update file storage settings failed")
return
}
writeJSON(w, http.StatusOK, settings)
}
func (s *Server) createFileStorageChannel(w http.ResponseWriter, r *http.Request) {
var input store.FileStorageChannelInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
if message := validateFileStorageChannelInput(input, nil); message != "" {
writeError(w, http.StatusBadRequest, message)
return
}
item, err := s.store.CreateFileStorageChannel(r.Context(), input)
if err != nil {
if store.IsUniqueViolation(err) {
writeError(w, http.StatusConflict, "file storage channel key already exists")
return
}
s.logger.Error("create file storage channel failed", "error", err)
writeError(w, http.StatusInternalServerError, "create file storage channel failed")
return
}
writeJSON(w, http.StatusCreated, item)
}
func (s *Server) updateFileStorageChannel(w http.ResponseWriter, r *http.Request) {
var input store.FileStorageChannelInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
existing, err := s.store.GetFileStorageChannel(r.Context(), r.PathValue("channelID"))
if err != nil {
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "file storage channel not found")
return
}
s.logger.Error("get file storage channel failed", "error", err)
writeError(w, http.StatusInternalServerError, "get file storage channel failed")
return
}
if message := validateFileStorageChannelInput(input, &existing); message != "" {
writeError(w, http.StatusBadRequest, message)
return
}
item, err := s.store.UpdateFileStorageChannel(r.Context(), r.PathValue("channelID"), input)
if err != nil {
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "file storage channel not found")
return
}
if store.IsUniqueViolation(err) {
writeError(w, http.StatusConflict, "file storage channel key already exists")
return
}
s.logger.Error("update file storage channel failed", "error", err)
writeError(w, http.StatusInternalServerError, "update file storage channel failed")
return
}
writeJSON(w, http.StatusOK, item)
}
func (s *Server) deleteFileStorageChannel(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeleteFileStorageChannel(r.Context(), r.PathValue("channelID")); err != nil {
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "file storage channel not found")
return
}
s.logger.Error("delete file storage channel failed", "error", err)
writeError(w, http.StatusInternalServerError, "delete file storage channel failed")
return
}
w.WriteHeader(http.StatusNoContent)
}
func validateFileStorageChannelInput(input store.FileStorageChannelInput, existing *store.FileStorageChannel) string {
provider := strings.ToLower(strings.TrimSpace(input.Provider))
if provider == "" {
provider = "server_main_openapi"
}
status := strings.ToLower(strings.TrimSpace(input.Status))
if status == "" {
status = "disabled"
}
if strings.TrimSpace(input.ChannelKey) == "" || strings.TrimSpace(input.Name) == "" {
return "channelKey and name are required"
}
if status != "enabled" && status != "disabled" {
return "status must be enabled or disabled"
}
if provider == "server_main_openapi" {
hasAPIKey := false
if input.APIKey != nil {
hasAPIKey = strings.TrimSpace(*input.APIKey) != ""
} else if existing != nil {
hasAPIKey = strings.TrimSpace(existing.APIKey) != ""
}
if status == "enabled" && !hasAPIKey {
return "server-main OpenAPI channel requires API key before enabling"
}
}
return ""
}