diff --git a/.cursorrules b/.cursorrules index 9a6a2208..1372e2e5 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,4 +1,4 @@ -- Only include essential comments where the code is not self-explanatory. Avoid redundant or obvious comments. +- All comments must be in English and only included where the code is not self-explanatory—avoid redundant or obvious comments. - You can read docs/.ai/SOP.server.zh-CN.md for server when the task starts. - You can read docs/.ai/SOP.client.zh-CN.md for web when the task starts. - Please check out docs/.ai/release.md for the release process. \ No newline at end of file diff --git a/.env.example b/.env.example index 36d8c4ea..2cd846ae 100644 --- a/.env.example +++ b/.env.example @@ -103,3 +103,5 @@ TZ=UTC SUPER_ADMIN_USERNAME=admin SUPER_ADMIN_PASSWORD=297df52fbc321ebf7198d497fe1c9206PlsChangeMe + +APISERVER_I18N_PATH=./configs/i18n diff --git a/cmd/apiserver/main.go b/cmd/apiserver/main.go index 0e011fbc..f362498f 100644 --- a/cmd/apiserver/main.go +++ b/cmd/apiserver/main.go @@ -7,6 +7,8 @@ import ( "os" "time" + "github.com/mcp-ecosystem/mcp-gateway/internal/i18n" + "github.com/mcp-ecosystem/mcp-gateway/pkg/logger" "github.com/mcp-ecosystem/mcp-gateway/internal/apiserver/database" @@ -230,6 +232,18 @@ func startServer(logger *zap.Logger, router *gin.Engine) { } } +// initI18n initializes the i18n translator +func initI18n(cfg *config.I18nConfig) { + translationsPath := cfg.Path + if translationsPath == "" { + translationsPath = "configs/i18n" + } + + if err := i18n.InitTranslator(translationsPath); err != nil { + log.Printf("Warning: Failed to load translations: %v\n", err) + } +} + func run() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -249,6 +263,9 @@ func run() { db := initDatabase(logger, &cfg.Database) defer db.Close() + // Initialize i18n translator + initI18n(&cfg.I18n) + // Initialize super admin if err := initSuperAdmin(ctx, db, cfg); err != nil { logger.Fatal("Failed to initialize super admin", zap.Error(err)) diff --git a/configs/apiserver.yaml b/configs/apiserver.yaml index 0e8472c3..bdf3602d 100644 --- a/configs/apiserver.yaml +++ b/configs/apiserver.yaml @@ -11,6 +11,10 @@ logger: color: ${APISERVER_LOGGER_COLOR:true} # whether to use color in console output stacktrace: ${APISERVER_LOGGER_STACKTRACE:true} # whether to include stacktrace in error logs +# i18n configuration +i18n: + path: "${APISERVER_I18N_PATH:./configs/i18n}" # path to i18n translation files + # Super admin configuration super_admin: username: "${SUPER_ADMIN_USERNAME:admin}" diff --git a/configs/i18n/en.toml b/configs/i18n/en.toml new file mode 100644 index 00000000..c9cbe786 --- /dev/null +++ b/configs/i18n/en.toml @@ -0,0 +1,358 @@ +# Common errors +[ErrorTenantRequired] +other = "Tenant name is required" + +[ErrorTenantNotFound] +other = "Tenant with name '{{.Name}}' not found" + +[ErrorRouterPrefixError] +other = "Router prefix must start with tenant prefix followed by a path separator" + +[ErrorTenantPermissionError] +other = "You do not have permission to access this tenant" + +# Tenant specific errors +[ErrorTenantRequiredFields] +other = "Tenant name and prefix are required" + +[ErrorTenantNameExists] +other = "Tenant name already exists" + +[ErrorTenantPrefixExists] +other = "Tenant prefix already exists" + +[ErrorTenantNameRequired] +other = "Tenant name is required" + +# Tenant success messages +[SuccessTenantCreated] +other = "Tenant created successfully" + +[SuccessTenantUpdated] +other = "Tenant updated successfully" + +[SuccessTenantDeleted] +other = "Tenant deleted successfully" + +# Authentication errors +[ErrorUnauthorized] +other = "Unauthorized access" + +[ErrorInvalidToken] +other = "Invalid or expired token" + +# Resource errors +[ErrorResourceNotFound] +other = "Resource not found" + +[ErrorPrefixConflict] +other = "Prefix conflicts with existing prefix" + +[ErrorNamespacePermissionError] +other = "User does not have permission to configure namespace" + +# Validation errors +[ErrorValidationFailed] +other = "Validation failed: {{.Reason}}" + +[ErrorInvalidYaml] +other = "Invalid YAML format" + +[ErrorInvalidJson] +other = "Invalid JSON format" + +# Success messages +[SuccessResourceCreated] +other = "Resource created successfully" + +[SuccessResourceUpdated] +other = "Resource updated successfully" + +[SuccessResourceDeleted] +other = "Resource deleted successfully" + +# Info messages +[InfoOperationInProgress] +other = "Operation in progress, please wait" + +[InfoNoChangesDetected] +other = "No changes detected" + +# Common errors +[ErrorNotFound] +other = "Resource not found" + +[ErrorForbidden] +other = "Access forbidden" + +[ErrorBadRequest] +other = "Bad request" + +[ErrorInternalServer] +other = "Internal server error" + +[ErrorMethodNotAllowed] +other = "Method not allowed" + +[ErrorConflict] +other = "Resource conflict" + +[ErrorTooManyRequests] +other = "Too many requests" + +[ErrorServiceUnavailable] +other = "Service unavailable" + +[ErrorGatewayTimeout] +other = "Gateway timeout" + +[ErrorUnsupportedMedia] +other = "Unsupported media type" + +# User related errors +[ErrorUserNotFound] +other = "User not found" + +[ErrorInvalidCredentials] +other = "Invalid username or password" + +[ErrorUserDisabled] +other = "User account is disabled" + +[ErrorUserNamePasswordRequired] +other = "Username and password are required" + +[ErrorInvalidOldPassword] +other = "Old password is incorrect" + +[ErrorUsernameExists] +other = "Username already exists" + +[ErrorInvalidUsername] +other = "Invalid username format" + +[ErrorEmailExists] +other = "Email already exists" + +[ErrorInvalidEmail] +other = "Invalid email format" + +# MCP related errors +[ErrorMCPServerNotFound] +other = "MCP server not found" + +[ErrorMCPServerExists] +other = "MCP server already exists" + +[ErrorMCPServerValidation] +other = "MCP server validation failed" + +[ErrorMCPServerNameRequired] +other = "MCP server name is required" + +[ErrorMCPConfigInvalid] +other = "MCP configuration is invalid" + +[ErrorMCPRequestFailed] +other = "MCP request failed" + +# API related errors +[ErrorAPINotFound] +other = "API not found" + +[ErrorAPIMethodNotAllowed] +other = "API method not allowed" + +[ErrorAPIRateLimitExceeded] +other = "API rate limit exceeded" + +[ErrorAPIUnavailable] +other = "API unavailable" + +[ErrorAPITimeout] +other = "API request timeout" + +[ErrorAPIValidationFailed] +other = "API validation failed" + +[ErrorAPIMalformedRequest] +other = "Malformed API request" + +[ErrorAPIResponseInvalid] +other = "Invalid API response" + +[ErrorAPIInvalidCredentials] +other = "Invalid API credentials" + +[ErrorAPIPermissionDenied] +other = "API permission denied" + +[ErrorAPIUnsupportedMediaType] +other = "Unsupported media type for API" + +# General validation errors +[ErrorRequiredField] +other = "Required field is missing" + +[ErrorInvalidFormat] +other = "Invalid format" + +[ErrorInvalidValue] +other = "Invalid value" + +[ErrorDuplicateEntity] +other = "Duplicate entity" + +[ErrorDataIntegrityViolation] +other = "Data integrity violation" + +# Tenant related success messages +[SuccessTenantInfo] +other = "Tenant information retrieved successfully" + +[SuccessTenantList] +other = "Tenant list retrieved successfully" + +[SuccessTenantStatus] +other = "Tenant status updated successfully" + +# User related success messages +[SuccessLogin] +other = "Login successful" + +[SuccessLogout] +other = "Logout successful" + +[SuccessPasswordChanged] +other = "Password changed successfully" + +[SuccessUserCreated] +other = "User created successfully" + +[SuccessUserUpdated] +other = "User updated successfully" + +[SuccessUserDeleted] +other = "User deleted successfully" + +[SuccessUserInfo] +other = "User information retrieved successfully" + +[SuccessUserList] +other = "User list retrieved successfully" + +[SuccessUserWithTenants] +other = "User with tenants retrieved successfully" + +[SuccessUserTenantsUpdated] +other = "User tenants updated successfully" + +# MCP related success messages +[SuccessMCPServerCreated] +other = "MCP server created successfully" + +[SuccessMCPServerUpdated] +other = "MCP server updated successfully" + +[SuccessMCPServerDeleted] +other = "MCP server deleted successfully" + +[SuccessMCPServerSynced] +other = "MCP server synced successfully" + +[SuccessMCPServerList] +other = "MCP server list retrieved successfully" + +[SuccessMCPServerInfo] +other = "MCP server information retrieved successfully" + +[SuccessMCPServerStatus] +other = "MCP server status updated successfully" + +# OpenAPI related success messages +[SuccessOpenAPIImported] +other = "OpenAPI specification imported successfully" + +[SuccessOpenAPIExported] +other = "OpenAPI specification exported successfully" + +[SuccessOpenAPIValidated] +other = "OpenAPI specification validated successfully" + +# API related success messages +[SuccessAPICreated] +other = "API created successfully" + +[SuccessAPIUpdated] +other = "API updated successfully" + +[SuccessAPIDeleted] +other = "API deleted successfully" + +[SuccessAPIList] +other = "API list retrieved successfully" + +[SuccessAPIInfo] +other = "API information retrieved successfully" + +[SuccessAPIKeyCreated] +other = "API key created successfully" + +[SuccessAPIKeyRevoked] +other = "API key revoked successfully" + +[SuccessAPIKeyList] +other = "API key list retrieved successfully" + +[SuccessAPIRouteCreated] +other = "API route created successfully" + +[SuccessAPIRouteUpdated] +other = "API route updated successfully" + +[SuccessAPIRouteDeleted] +other = "API route deleted successfully" + +[SuccessAPIRouteList] +other = "API route list retrieved successfully" + +# Chat related success messages +[SuccessChatSessions] +other = "Chat sessions retrieved successfully" + +[SuccessChatMessages] +other = "Chat messages retrieved successfully" + +[SuccessChatCreated] +other = "Chat created successfully" + +[SuccessChatUpdated] +other = "Chat updated successfully" + +[SuccessChatDeleted] +other = "Chat deleted successfully" + +[SuccessChatHistory] +other = "Chat history retrieved successfully" + +# General success messages +[SuccessOperationCompleted] +other = "Operation completed successfully" + +[SuccessItemCreated] +other = "Item created successfully" + +[SuccessItemUpdated] +other = "Item updated successfully" + +[SuccessItemDeleted] +other = "Item deleted successfully" + +[SuccessDataExported] +other = "Data exported successfully" + +[SuccessDataImported] +other = "Data imported successfully" + +[SuccessDataSaved] +other = "Data saved successfully" \ No newline at end of file diff --git a/configs/i18n/zh.toml b/configs/i18n/zh.toml new file mode 100644 index 00000000..25138f4e --- /dev/null +++ b/configs/i18n/zh.toml @@ -0,0 +1,358 @@ +# Common errors +[ErrorTenantRequired] +other = "租户名称不能为空" + +[ErrorTenantNotFound] +other = "找不到名为'{{.Name}}'的租户" + +[ErrorRouterPrefixError] +other = "路由前缀必须以租户前缀开头,后跟路径分隔符" + +[ErrorTenantPermissionError] +other = "您没有权限访问此租户" + +# Tenant specific errors +[ErrorTenantRequiredFields] +other = "租户名称和前缀不能为空" + +[ErrorTenantNameExists] +other = "租户名称已存在" + +[ErrorTenantPrefixExists] +other = "租户前缀已存在" + +[ErrorTenantNameRequired] +other = "租户名称不能为空" + +# Tenant success messages +[SuccessTenantCreated] +other = "租户创建成功" + +[SuccessTenantUpdated] +other = "租户更新成功" + +[SuccessTenantDeleted] +other = "租户删除成功" + +# Authentication errors +[ErrorUnauthorized] +other = "未授权访问,请先登录" + +[ErrorInvalidToken] +other = "无效或已过期的令牌" + +# Resource errors +[ErrorResourceNotFound] +other = "资源未找到" + +[ErrorPrefixConflict] +other = "前缀与已存在的前缀冲突" + +[ErrorNamespacePermissionError] +other = "用户没有权限配置此命名空间" + +# Validation errors +[ErrorValidationFailed] +other = "验证失败:{{.Reason}}" + +[ErrorInvalidYaml] +other = "无效的YAML格式" + +[ErrorInvalidJson] +other = "无效的JSON格式" + +# Success messages +[SuccessResourceCreated] +other = "资源创建成功" + +[SuccessResourceUpdated] +other = "资源更新成功" + +[SuccessResourceDeleted] +other = "资源删除成功" + +# Info messages +[InfoOperationInProgress] +other = "操作正在进行中,请稍候" + +[InfoNoChangesDetected] +other = "未检测到变更" + +# Common errors +[ErrorNotFound] +other = "资源未找到" + +[ErrorForbidden] +other = "禁止访问" + +[ErrorBadRequest] +other = "请求无效" + +[ErrorInternalServer] +other = "服务器内部错误" + +[ErrorMethodNotAllowed] +other = "方法不允许" + +[ErrorConflict] +other = "资源冲突" + +[ErrorTooManyRequests] +other = "请求过于频繁" + +[ErrorServiceUnavailable] +other = "服务不可用" + +[ErrorGatewayTimeout] +other = "网关超时" + +[ErrorUnsupportedMedia] +other = "不支持的媒体类型" + +# User related errors +[ErrorUserNotFound] +other = "用户不存在" + +[ErrorInvalidCredentials] +other = "用户名或密码错误" + +[ErrorUserDisabled] +other = "用户账号已禁用" + +[ErrorUserNamePasswordRequired] +other = "用户名和密码不能为空" + +[ErrorInvalidOldPassword] +other = "旧密码不正确" + +[ErrorUsernameExists] +other = "用户名已存在" + +[ErrorInvalidUsername] +other = "用户名格式无效" + +[ErrorEmailExists] +other = "邮箱已存在" + +[ErrorInvalidEmail] +other = "邮箱格式无效" + +# MCP related errors +[ErrorMCPServerNotFound] +other = "MCP服务器不存在" + +[ErrorMCPServerExists] +other = "MCP服务器已存在" + +[ErrorMCPServerValidation] +other = "MCP服务器验证失败" + +[ErrorMCPServerNameRequired] +other = "MCP服务器名称不能为空" + +[ErrorMCPConfigInvalid] +other = "MCP配置无效" + +[ErrorMCPRequestFailed] +other = "MCP请求失败" + +# API related errors +[ErrorAPINotFound] +other = "API不存在" + +[ErrorAPIMethodNotAllowed] +other = "API方法不允许" + +[ErrorAPIRateLimitExceeded] +other = "API请求频率超限" + +[ErrorAPIUnavailable] +other = "API不可用" + +[ErrorAPITimeout] +other = "API请求超时" + +[ErrorAPIValidationFailed] +other = "API验证失败" + +[ErrorAPIMalformedRequest] +other = "API请求格式错误" + +[ErrorAPIResponseInvalid] +other = "API响应无效" + +[ErrorAPIInvalidCredentials] +other = "API凭证无效" + +[ErrorAPIPermissionDenied] +other = "API权限被拒绝" + +[ErrorAPIUnsupportedMediaType] +other = "API不支持的媒体类型" + +# General validation errors +[ErrorRequiredField] +other = "必填字段缺失" + +[ErrorInvalidFormat] +other = "格式无效" + +[ErrorInvalidValue] +other = "值无效" + +[ErrorDuplicateEntity] +other = "实体重复" + +[ErrorDataIntegrityViolation] +other = "数据完整性违规" + +# Tenant related success messages +[SuccessTenantInfo] +other = "租户信息获取成功" + +[SuccessTenantList] +other = "租户列表获取成功" + +[SuccessTenantStatus] +other = "租户状态更新成功" + +# User related success messages +[SuccessLogin] +other = "登录成功" + +[SuccessLogout] +other = "退出登录成功" + +[SuccessPasswordChanged] +other = "密码修改成功" + +[SuccessUserCreated] +other = "用户创建成功" + +[SuccessUserUpdated] +other = "用户更新成功" + +[SuccessUserDeleted] +other = "用户删除成功" + +[SuccessUserInfo] +other = "用户信息获取成功" + +[SuccessUserList] +other = "用户列表获取成功" + +[SuccessUserWithTenants] +other = "用户及其租户获取成功" + +[SuccessUserTenantsUpdated] +other = "用户租户更新成功" + +# MCP related success messages +[SuccessMCPServerCreated] +other = "MCP服务器创建成功" + +[SuccessMCPServerUpdated] +other = "MCP服务器更新成功" + +[SuccessMCPServerDeleted] +other = "MCP服务器删除成功" + +[SuccessMCPServerSynced] +other = "MCP服务器同步成功" + +[SuccessMCPServerList] +other = "MCP服务器列表获取成功" + +[SuccessMCPServerInfo] +other = "MCP服务器信息获取成功" + +[SuccessMCPServerStatus] +other = "MCP服务器状态更新成功" + +# OpenAPI related success messages +[SuccessOpenAPIImported] +other = "OpenAPI规范导入成功" + +[SuccessOpenAPIExported] +other = "OpenAPI规范导出成功" + +[SuccessOpenAPIValidated] +other = "OpenAPI规范验证成功" + +# API related success messages +[SuccessAPICreated] +other = "API创建成功" + +[SuccessAPIUpdated] +other = "API更新成功" + +[SuccessAPIDeleted] +other = "API删除成功" + +[SuccessAPIList] +other = "API列表获取成功" + +[SuccessAPIInfo] +other = "API信息获取成功" + +[SuccessAPIKeyCreated] +other = "API密钥创建成功" + +[SuccessAPIKeyRevoked] +other = "API密钥撤销成功" + +[SuccessAPIKeyList] +other = "API密钥列表获取成功" + +[SuccessAPIRouteCreated] +other = "API路由创建成功" + +[SuccessAPIRouteUpdated] +other = "API路由更新成功" + +[SuccessAPIRouteDeleted] +other = "API路由删除成功" + +[SuccessAPIRouteList] +other = "API路由列表获取成功" + +# Chat related success messages +[SuccessChatSessions] +other = "聊天会话获取成功" + +[SuccessChatMessages] +other = "聊天消息获取成功" + +[SuccessChatCreated] +other = "聊天创建成功" + +[SuccessChatUpdated] +other = "聊天更新成功" + +[SuccessChatDeleted] +other = "聊天删除成功" + +[SuccessChatHistory] +other = "聊天历史获取成功" + +# General success messages +[SuccessOperationCompleted] +other = "操作完成成功" + +[SuccessItemCreated] +other = "项目创建成功" + +[SuccessItemUpdated] +other = "项目更新成功" + +[SuccessItemDeleted] +other = "项目删除成功" + +[SuccessDataExported] +other = "数据导出成功" + +[SuccessDataImported] +other = "数据导入成功" + +[SuccessDataSaved] +other = "数据保存成功" \ No newline at end of file diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 00000000..269b7c2d --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,172 @@ +# API Server i18n 国际化支持 + +## 概述 + +MCP-Gateway API Server 支持国际化消息,可以根据请求的语言返回不同语言的消息。这使得前端不需要再为每个消息维护翻译映射,简化了错误处理和用户交互流程。 + +## 工作原理 + +1. API Server 通过一个中间件来处理响应中的消息 +2. 消息使用大写驼峰命名格式,如 `ErrorTenantNotFound`、`SuccessResourceCreated` 等 +3. 中间件根据请求头中的 `X-Lang` 或 `Accept-Language` 来确定返回语言 +4. 中间件将消息ID翻译成对应语言的消息,并返回给客户端 + +## 新的错误处理机制 + +我们引入了一种新的国际化错误类型 `I18nError`,它提供了更高效、更直接的方式来处理国际化错误消息: + +- 不再需要在响应中解析和修改JSON +- 在错误处理的早期阶段就进行翻译 +- 支持错误代码和模板参数 +- 提供了预定义的常见错误 + +详细的用法示例请参考 [I18n国际化错误处理示例](./i18n_example.md)。 + +## 语言优先级 + +1. `X-Lang` 请求头(如果存在) +2. `Accept-Language` 请求头(如果存在) +3. 默认语言(中文 `zh`) + +## 支持的语言 + +目前支持以下语言: + +- 中文 (`zh`) +- 英文 (`en`) + +## 消息命名规则 + +消息ID采用大写驼峰式命名,根据消息类型有不同的前缀: + +- `Error` - 错误消息,例如 `ErrorTenantNotFound` +- `Success` - 成功消息,例如 `SuccessResourceCreated` +- `Info` - 提示或信息性消息,例如 `InfoOperationInProgress` + +## 在后端使用 + +### 旧方式:直接返回错误消息 + +```go +// 例如,当需要返回租户不存在的错误时 +if tenant == nil { + return nil, errors.New("ErrorTenantNotFound") +} +``` + +### 旧方式:返回成功或信息性消息 + +```go +// 返回带有成功消息的响应 +c.JSON(http.StatusOK, gin.H{ + "data": result, + "message": "SuccessResourceCreated", +}) + +// 返回带有信息性消息的响应 +c.JSON(http.StatusOK, gin.H{ + "message": "InfoOperationInProgress", +}) +``` + +### 新方式:使用I18nError + +```go +import ( + "github.com/mcp-ecosystem/mcp-gateway/internal/apiserver/middleware" + "github.com/mcp-ecosystem/mcp-gateway/pkg/i18n" +) + +// 返回简单错误 +if tenant == nil { + return c.Error(middleware.GetI18nError("ErrorTenantNotFound")) +} + +// 返回带状态码的错误 +if !hasPermission(c) { + return c.Error(middleware.GetI18nErrorWithCode("ErrorForbidden", i18n.ErrorForbidden)) +} + +// 返回带参数的错误 +if validationErr != nil { + return c.Error(middleware.GetI18nErrorWithData("ErrorValidationFailed", map[string]interface{}{ + "Reason": validationErr.Error(), + })) +} + +// 使用预定义错误 +if isNotFound(err) { + return c.Error(i18n.ErrNotFound.WithParam("ID", id)) +} +``` + +## 在前端使用 + +前端已经自动通过请求拦截器为所有请求添加了 `X-Lang` 请求头,值为当前语言设置。 + +错误处理已经更新为直接使用后端返回的错误消息,不再需要额外的翻译步骤: + +```typescript +// 使用提供的错误处理工具函数 +import { handleApiError } from '../utils/error-handler'; + +try { + // API 调用 + const result = await api.someEndpoint(); + // 处理成功响应 +} catch (error) { + // 错误已经被翻译成当前语言 + handleApiError(error); +} +``` + +显示普通消息: + +```typescript +// API 响应包含 { message: "SuccessResourceCreated" } +// 消息已经被 API 中间件翻译成当前语言 +toast.success(response.data.message); +``` + +## 添加新的消息和翻译 + +1. 在 `translations/en/messages.toml` 和 `translations/zh/messages.toml` 文件中添加新的消息定义 +2. 遵循消息类型前缀规则(Error, Success, Info) + +```toml +# 在 translations/en/messages.toml 中添加 +[ErrorCustomValidationFailed] +other = "Custom validation failed: {{.Reason}}" + +# 在 translations/zh/messages.toml 中添加 +[ErrorCustomValidationFailed] +other = "自定义验证失败:{{.Reason}}" +``` + +## 带参数的消息 + +使用 `{{.ParamName}}` 语法可以在消息中嵌入参数,例如: + +```go +// 在消息定义中使用参数 +// translations/en/messages.toml +[ErrorValidationFailed] +other = "Validation failed: {{.Reason}}" + +// 在代码中使用 +return middleware.GetI18nErrorWithData("ErrorValidationFailed", map[string]interface{}{ + "Reason": "Field 'name' cannot be empty", +}) +``` + +## 最佳实践 + +1. 对于业务逻辑相关的消息,始终使用大写驼峰式命名格式 +2. 保持消息ID简洁明了,遵循前缀规则(Error, Success, Info) +3. 同时更新英文和中文映射,确保所有支持的语言都有对应的翻译 +4. 在文档中记录所有使用的消息ID,便于其他开发者查阅 +5. 优先使用新的 `I18nError` 类型来处理错误,它提供了更好的类型安全和功能 + +## 向后兼容性 + +为了保持向后兼容性,系统仍然支持旧的 `errors.` 前缀格式和响应体翻译,但不推荐在新代码中使用。旧格式将在未来版本中逐步淘汰。 \ No newline at end of file diff --git a/docs/i18n_direct_errors.md b/docs/i18n_direct_errors.md new file mode 100644 index 00000000..df5ac3d7 --- /dev/null +++ b/docs/i18n_direct_errors.md @@ -0,0 +1,173 @@ +# 直接使用国际化错误 + +本文档展示了如何直接使用国际化错误处理机制,而不需要通过中间件。 + +## 基本原理 + +我们可以直接在错误发生点使用带有国际化支持的错误类型,这样就可以直接返回给客户端一个易于翻译的错误消息。当客户端指定了语言首选项(通过HTTP头`X-Lang`或`Accept-Language`),后端会自动根据这些首选项将错误消息翻译为适当的语言。 + +## 初始化全局翻译器 + +在应用程序启动时,应该初始化全局翻译器: + +```go +func main() { + // 初始化翻译器 + if err := i18n.InitTranslator("configs/i18n"); err != nil { + log.Printf("Warning: Failed to load translations: %v\n", err) + } + + // 设置默认语言(可选,默认为zh) + i18n.SetDefaultLanguage("zh") + + // ...其他初始化代码 +} +``` + +## 用法示例 + +### 引入必要的包 + +```go +import ( + "github.com/mcp-ecosystem/mcp-gateway/pkg/i18n" +) +``` + +### 使用预定义错误 + +```go +// 返回一个预定义的错误 +if user == nil { + return nil, i18n.ErrNotFound +} + +// 返回带参数的预定义错误 +if !hasPermission() { + return nil, i18n.ErrForbidden.WithParam("Resource", resourceName) +} +``` + +### 创建自定义错误 + +```go +// 创建一个新的国际化错误 +if tenant == nil { + return nil, i18n.NewErrorWithCode("ErrorTenantNotFound", i18n.ErrorNotFound).WithParam("Name", tenantName) +} + +// 带参数的错误 +if err := validate(input); err != nil { + return nil, i18n.NewErrorWithCode("ErrorValidationFailed", i18n.ErrorBadRequest).WithParam("Reason", err.Error()) +} +``` + +### 在HTTP处理程序中使用辅助函数 + +我们提供了一些辅助函数,使得在HTTP处理程序中更容易使用国际化错误: + +```go +func GetUser(c *gin.Context) { + userID := c.Param("id") + user, err := userService.GetByID(userID) + if err != nil { + // 使用辅助函数处理错误 + i18n.RespondWithError(c, err) + return + } + + c.JSON(http.StatusOK, user) +} +``` + +### 翻译成功消息 + +```go +// 翻译成功消息 +c.JSON(http.StatusOK, gin.H{ + "message": i18n.TranslateMessageGin("SuccessResourceCreated", c, nil), + "data": result, +}) + +// 带参数的成功消息 +c.JSON(http.StatusOK, gin.H{ + "message": i18n.TranslateMessageGin("SuccessResourceCreated", c, map[string]interface{}{ + "ResourceType": "User", + "ResourceName": user.Name, + }), + "data": user, +}) +``` + +## 完整示例 + +下面是一个完整的HTTP处理程序示例: + +```go +func CreateTenant(c *gin.Context) { + var req dto.CreateTenantRequest + if err := c.ShouldBindJSON(&req); err != nil { + i18n.RespondWithError(c, i18n.ErrBadRequest.WithParam("Reason", err.Error())) + return + } + + // Validate request + if req.Name == "" || req.Prefix == "" { + i18n.RespondWithError(c, i18n.NewErrorWithCode("ErrorTenantRequiredFields", i18n.ErrorBadRequest)) + return + } + + // Create tenant + tenant, err := tenantService.Create(c.Request.Context(), req) + if err != nil { + if isDuplicateName(err) { + i18n.RespondWithError(c, i18n.NewErrorWithCode("ErrorTenantNameExists", i18n.ErrorConflict)) + } else if isDuplicatePrefix(err) { + i18n.RespondWithError(c, i18n.NewErrorWithCode("ErrorTenantPrefixExists", i18n.ErrorConflict)) + } else { + i18n.RespondWithError(c, i18n.ErrInternalServer) + } + return + } + + c.JSON(http.StatusOK, gin.H{ + "id": tenant.ID, + "message": i18n.TranslateMessageGin("SuccessTenantCreated", c, nil), + }) +} +``` + +## 如何工作 + +该机制的工作原理如下: + +1. 全局翻译器在应用程序启动时加载所有翻译文件 +2. `I18nError`和`ErrorWithCode`类型包含消息ID和可选的数据参数 +3. 当需要翻译错误时,系统从HTTP请求中提取语言首选项 +4. 使用翻译器将消息ID翻译为适当的语言 +5. 辅助函数`RespondWithError`和`TranslateMessageGin`简化了与Gin的集成 + +## 主要功能和辅助函数 + +包`pkg/i18n`提供以下主要功能: + +- `InitTranslator(path string)` - 初始化全局翻译器 +- `SetDefaultLanguage(lang string)` - 设置默认语言 +- `New(messageID string)` - 创建新的I18nError +- `NewErrorWithCode(messageID string, code ErrorCode)` - 创建带状态码的错误 +- `RespondWithError(c *gin.Context, err error)` - 发送错误响应 +- `TranslateMessageGin(msgID string, c *gin.Context, data map[string]interface{})` - 翻译消息 + +## 预定义错误 + +`pkg/i18n`包预定义了一些常用错误: + +```go +var ( + ErrNotFound = NewErrorWithCode("ErrorResourceNotFound", ErrorNotFound) + ErrUnauthorized = NewErrorWithCode("ErrorUnauthorized", ErrorUnauthorized) + ErrForbidden = NewErrorWithCode("ErrorForbidden", ErrorForbidden) + ErrBadRequest = NewErrorWithCode("ErrorBadRequest", ErrorBadRequest) + ErrInternalServer = NewErrorWithCode("ErrorInternalServer", ErrorInternalServer) +) +``` \ No newline at end of file diff --git a/docs/i18n_example.md b/docs/i18n_example.md new file mode 100644 index 00000000..a6dd3d87 --- /dev/null +++ b/docs/i18n_example.md @@ -0,0 +1,179 @@ +# I18n 国际化错误处理示例 + +本文档展示了如何在MCP Gateway中使用新的国际化错误处理机制。 + +## 基本用法 + +新的国际化错误处理机制使用了自定义错误类型`I18nError`,它可以包含一个消息ID和可选的数据参数。中间件会自动检测这些错误并将其翻译为适当的语言。 + +### 在路由处理器中返回错误 + +```go +// 导入必要的包 +import ( + "github.com/gin-gonic/gin" + "github.com/mcp-ecosystem/mcp-gateway/internal/apiserver/middleware" + "github.com/mcp-ecosystem/mcp-gateway/pkg/i18n" +) + +// 简单的错误 +func GetUser(c *gin.Context) { + userID := c.Param("id") + user, err := userService.GetByID(userID) + if err != nil { + // 返回一个国际化错误 + c.Error(middleware.GetI18nError("ErrorUserNotFound")) + return + } + c.JSON(200, user) +} + +// 带参数的错误 +func CreateUser(c *gin.Context) { + var user User + if err := c.ShouldBindJSON(&user); err != nil { + // 返回带参数的国际化错误 + c.Error(middleware.GetI18nErrorWithData("ErrorInvalidInput", map[string]interface{}{ + "Field": "name", + "Reason": "Cannot be empty", + })) + return + } + + // 继续处理... +} + +// 带状态码的错误 +func DeleteUser(c *gin.Context) { + userID := c.Param("id") + if !hasPermission(c, userID) { + // 返回带状态码的国际化错误 + c.Error(middleware.GetI18nErrorWithCode("ErrorForbidden", i18n.ErrorForbidden)) + return + } + + err := userService.Delete(userID) + if err != nil { + if isNotFound(err) { + c.Error(middleware.GetI18nErrorWithCode("ErrorUserNotFound", i18n.ErrorNotFound)) + } else { + c.Error(middleware.GetI18nErrorWithCode("ErrorInternalServer", i18n.ErrorInternalServer)) + } + return + } + + c.Status(204) +} + +// 带状态码和参数的错误 +func UpdateUser(c *gin.Context) { + userID := c.Param("id") + var updates UserUpdates + + if err := c.ShouldBindJSON(&updates); err != nil { + c.Error(middleware.GetI18nErrorWithCodeAndData( + "ErrorValidationFailed", + i18n.ErrorBadRequest, + map[string]interface{}{"Reason": err.Error()}, + )) + return + } + + // 继续处理... +} +``` + +## 错误类型 + +新的国际化错误处理机制支持以下几种错误类型: + +1. **基本错误** - 使用`GetI18nError("ErrorMessageID")` +2. **带参数的错误** - 使用`GetI18nErrorWithData("ErrorMessageID", dataMap)` +3. **带状态码的错误** - 使用`GetI18nErrorWithCode("ErrorMessageID", code)` +4. **带状态码和参数的错误** - 使用`GetI18nErrorWithCodeAndData("ErrorMessageID", code, dataMap)` + +## 预定义错误 + +`pkg/i18n`包中预定义了一些常用的错误: + +```go +// 预定义错误 +var ( + ErrNotFound = NewErrorWithCode("ErrorResourceNotFound", ErrorNotFound) + ErrUnauthorized = NewErrorWithCode("ErrorUnauthorized", ErrorUnauthorized) + ErrForbidden = NewErrorWithCode("ErrorForbidden", ErrorForbidden) + ErrBadRequest = NewErrorWithCode("ErrorBadRequest", ErrorBadRequest) + ErrInternalServer = NewErrorWithCode("ErrorInternalServer", ErrorInternalServer) +) +``` + +你可以直接使用这些预定义错误: + +```go +func GetResource(c *gin.Context) { + id := c.Param("id") + resource, err := resourceService.GetByID(id) + if err != nil { + if isNotFound(err) { + // 使用预定义错误 + c.Error(i18n.ErrNotFound.WithParam("ID", id)) + return + } + c.Error(i18n.ErrInternalServer) + return + } + + c.JSON(200, resource) +} +``` + +## 翻译文件配置 + +确保在翻译文件中定义了相应的消息ID: + +```toml +# translations/en/messages.toml +[ErrorUserNotFound] +other = "User not found" + +[ErrorInvalidInput] +other = "Invalid input: field {{.Field}} {{.Reason}}" + +[ErrorForbidden] +other = "You do not have permission to perform this action" + +[ErrorValidationFailed] +other = "Validation failed: {{.Reason}}" + +[ErrorResourceNotFound] +other = "Resource with ID {{.ID}} not found" +``` + +```toml +# translations/zh/messages.toml +[ErrorUserNotFound] +other = "找不到用户" + +[ErrorInvalidInput] +other = "无效输入:字段 {{.Field}} {{.Reason}}" + +[ErrorForbidden] +other = "您没有权限执行此操作" + +[ErrorValidationFailed] +other = "验证失败:{{.Reason}}" + +[ErrorResourceNotFound] +other = "找不到ID为 {{.ID}} 的资源" +``` + +## 工作原理 + +新的国际化错误处理机制的工作原理如下: + +1. 处理程序返回一个`I18nError`或`ErrorWithCode`类型的错误 +2. 中间件检测到这些错误,获取请求中的语言偏好 +3. 中间件使用翻译器将错误消息ID翻译为适当的语言 +4. 中间件将翻译后的错误消息发送给客户端 + +这种方法比以前的方法更高效,因为它不需要解析和修改整个响应体,而是在错误处理的早期阶段就进行翻译。 \ No newline at end of file diff --git a/docs/i18n_summary.md b/docs/i18n_summary.md new file mode 100644 index 00000000..6a919d68 --- /dev/null +++ b/docs/i18n_summary.md @@ -0,0 +1,84 @@ +# 国际化错误处理总结 + +我们已经实现了一个新的国际化错误处理系统,它允许直接在错误发生点创建和翻译错误消息,而不需要通过中间件解析和修改响应体。 + +## 主要组件 + +1. **I18nError类型** - 定义在`pkg/i18n/error.go`中,包含消息ID、默认消息和模板数据 +2. **ErrorWithCode类型** - 扩展I18nError,添加了HTTP状态码支持 +3. **全局翻译器** - 在应用程序启动时初始化,用于所有国际化消息的翻译 +4. **辅助函数** - 简化错误创建和处理的函数,如`RespondWithError`和`TranslateMessageGin` + +## 用法示例 + +### 创建国际化错误 + +```go +// 使用预定义错误 +return i18n.ErrNotFound.WithParam("ID", id) + +// 创建自定义错误 +return i18n.NewErrorWithCode("ErrorTenantNotFound", i18n.ErrorNotFound).WithParam("Name", tenantName) +``` + +### 在HTTP处理程序中使用 + +```go +func GetResource(c *gin.Context) { + id := c.Param("id") + resource, err := resourceService.GetByID(id) + if err != nil { + // 简单方式:使用辅助函数发送错误响应 + i18n.RespondWithError(c, i18n.ErrNotFound.WithParam("ID", id)) + return + } + + // 翻译成功消息 + c.JSON(http.StatusOK, gin.H{ + "message": i18n.TranslateMessageGin("SuccessResourceFound", c, nil), + "data": resource, + }) +} +``` + +## 工作原理 + +1. 当一个请求到达服务器时,`I18nMiddleware`中间件会提取并存储用户的语言首选项 +2. 当需要返回一个错误时,直接创建一个`I18nError`或使用预定义错误 +3. 使用`RespondWithError`发送带有适当状态码和翻译错误消息的HTTP响应 +4. 使用`TranslateMessageGin`翻译成功消息或其他消息字符串 + +## 优势 + +1. **简单直接** - 不需要复杂的中间件逻辑来解析和修改响应 +2. **更好的类型安全** - 使用专用错误类型而不是字符串 +3. **更好的语义** - 错误定义在它们发生的地方 +4. **一致的接口** - 辅助函数提供一致的错误处理接口 + +## 预定义错误 + +```go +var ( + ErrNotFound = NewErrorWithCode("ErrorResourceNotFound", ErrorNotFound) + ErrUnauthorized = NewErrorWithCode("ErrorUnauthorized", ErrorUnauthorized) + ErrForbidden = NewErrorWithCode("ErrorForbidden", ErrorForbidden) + ErrBadRequest = NewErrorWithCode("ErrorBadRequest", ErrorBadRequest) + ErrInternalServer = NewErrorWithCode("ErrorInternalServer", ErrorInternalServer) +) +``` + +## 翻译文件 + +翻译文件位于`configs/i18n/{lang}/messages.toml`,使用以下格式: + +```toml +[ErrorTenantNotFound] +other = "Tenant with name '{{.Name}}' not found" +``` + +## 进一步改进 + +1. 添加更多预定义错误 +2. 为特定模块创建专用错误 +3. 添加日志记录和错误追踪功能 +4. 考虑添加错误分类和分组支持 \ No newline at end of file diff --git a/go.mod b/go.mod index 9ba2f7f9..f0694965 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/mcp-ecosystem/mcp-gateway go 1.24.1 require ( + github.com/BurntSushi/toml v1.5.0 github.com/getkin/kin-openapi v0.131.0 github.com/gin-gonic/gin v1.10.0 github.com/glebarez/sqlite v1.11.0 @@ -12,12 +13,15 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/ifuryst/lol v1.3.0 github.com/joho/godotenv v1.5.1 + github.com/nicksnyder/go-i18n/v2 v2.6.0 github.com/openai/openai-go v0.1.0-beta.10 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 + github.com/tidwall/gjson v1.18.0 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.38.0 + golang.org/x/text v0.25.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.5.7 @@ -75,7 +79,6 @@ require ( github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect @@ -87,7 +90,6 @@ require ( golang.org/x/net v0.39.0 // indirect golang.org/x/sync v0.14.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.25.0 // indirect google.golang.org/protobuf v1.36.6 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect diff --git a/go.sum b/go.sum index 54765056..c7ec15cd 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= @@ -108,6 +110,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ= +github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= @@ -161,8 +165,6 @@ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= -github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= diff --git a/internal/apiserver/handler/auth.go b/internal/apiserver/handler/auth.go index b44103dd..964c081f 100644 --- a/internal/apiserver/handler/auth.go +++ b/internal/apiserver/handler/auth.go @@ -11,6 +11,7 @@ import ( "github.com/mcp-ecosystem/mcp-gateway/internal/auth/jwt" "github.com/mcp-ecosystem/mcp-gateway/internal/common/config" "github.com/mcp-ecosystem/mcp-gateway/internal/common/dto" + "github.com/mcp-ecosystem/mcp-gateway/internal/i18n" "golang.org/x/crypto/bcrypt" ) @@ -34,33 +35,33 @@ func NewHandler(db database.Database, jwtService *jwt.Service, cfg *config.MCPGa func (h *Handler) Login(c *gin.Context) { var req dto.LoginRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + i18n.RespondWithError(c, i18n.ErrBadRequest.WithParam("Reason", err.Error())) return } // Get user from database user, err := h.db.GetUserByUsername(c.Request.Context(), req.Username) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"}) + i18n.RespondWithError(c, i18n.ErrorInvalidCredentials) return } // Check if user is active if !user.IsActive { - c.JSON(http.StatusForbidden, gin.H{"error": "User is disabled"}) + i18n.RespondWithError(c, i18n.ErrorUserDisabled) return } // Verify password if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"}) + i18n.RespondWithError(c, i18n.ErrorInvalidCredentials) return } // Generate JWT token token, err := h.jwtService.GenerateToken(user.ID, user.Username, string(user.Role)) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) + i18n.RespondWithError(c, i18n.ErrInternalServer) return } @@ -72,24 +73,24 @@ func (h *Handler) Login(c *gin.Context) { } c.Set("user", userInfo) - c.JSON(http.StatusOK, gin.H{ - "token": token, - "user": userInfo, - }) + i18n.Success(i18n.SuccessLogin). + With("token", token). + With("user", userInfo). + Send(c) } // ChangePassword handles password change requests func (h *Handler) ChangePassword(c *gin.Context) { var req dto.ChangePasswordRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + i18n.RespondWithError(c, i18n.ErrBadRequest.WithParam("Reason", err.Error())) return } // Get the user from the context claims, exists := c.Get("claims") if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + i18n.RespondWithError(c, i18n.ErrUnauthorized) return } jwtClaims := claims.(*jwt.Claims) @@ -97,20 +98,20 @@ func (h *Handler) ChangePassword(c *gin.Context) { // Get the user from the database user, err := h.db.GetUserByUsername(c.Request.Context(), jwtClaims.Username) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + i18n.RespondWithError(c, i18n.ErrInternalServer) return } // Compare the old password if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil { - c.JSON(http.StatusForbidden, gin.H{"error": "invalid old password"}) + i18n.RespondWithError(c, i18n.ErrorInvalidOldPassword) return } // Hash the new password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + i18n.RespondWithError(c, i18n.ErrInternalServer) return } @@ -118,11 +119,11 @@ func (h *Handler) ChangePassword(c *gin.Context) { user.Password = string(hashedPassword) user.UpdatedAt = time.Now() if err := h.db.UpdateUser(c.Request.Context(), user); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + i18n.RespondWithError(c, i18n.ErrInternalServer) return } - c.JSON(http.StatusOK, dto.ChangePasswordResponse{Success: true}) + i18n.Success(i18n.SuccessPasswordChanged).With("success", true).Send(c) } // AdminAuthMiddleware creates a middleware that checks if the user has admin role @@ -130,14 +131,14 @@ func AdminAuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { claims, exists := c.Get("claims") if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + i18n.RespondWithError(c, i18n.ErrUnauthorized) c.Abort() return } jwtClaims, ok := claims.(*jwt.Claims) if !ok || jwtClaims.Role != "admin" { - c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden: Only administrators can access this resource"}) + i18n.RespondWithError(c, i18n.ErrForbidden.WithParam("Reason", "Only administrators can access this resource")) c.Abort() return } @@ -149,7 +150,7 @@ func AdminAuthMiddleware() gin.HandlerFunc { func (h *Handler) ListUsers(c *gin.Context) { users, err := h.db.ListUsers(c.Request.Context()) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + i18n.RespondWithError(c, i18n.ErrInternalServer) return } @@ -160,20 +161,20 @@ func (h *Handler) ListUsers(c *gin.Context) { func (h *Handler) CreateUser(c *gin.Context) { var req dto.CreateUserRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + i18n.RespondWithError(c, i18n.ErrBadRequest.WithParam("Reason", err.Error())) return } // Validate request if req.Username == "" || req.Password == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Username and password are required"}) + i18n.RespondWithError(c, i18n.ErrorUserNamePasswordRequired) return } // Hash the password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + i18n.RespondWithError(c, i18n.ErrInternalServer) return } @@ -209,25 +210,25 @@ func (h *Handler) CreateUser(c *gin.Context) { }) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + i18n.RespondWithError(c, i18n.ErrInternalServer) return } - c.JSON(http.StatusOK, gin.H{"id": userID}) + i18n.Created(i18n.SuccessUserCreated).With("id", userID).Send(c) } // UpdateUser handles user updates func (h *Handler) UpdateUser(c *gin.Context) { var req dto.UpdateUserRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + i18n.RespondWithError(c, i18n.ErrBadRequest.WithParam("Reason", err.Error())) return } // Get the user from the database existingUser, err := h.db.GetUserByUsername(c.Request.Context(), req.Username) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + i18n.RespondWithError(c, i18n.ErrorUserNotFound.WithParam("Username", req.Username)) return } @@ -296,25 +297,25 @@ func (h *Handler) UpdateUser(c *gin.Context) { }) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", err.Error())) return } - c.JSON(http.StatusOK, gin.H{"message": "User updated successfully"}) + i18n.Success(i18n.SuccessUserUpdated).Send(c) } // DeleteUser handles user deletion func (h *Handler) DeleteUser(c *gin.Context) { username := c.Param("username") if username == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"}) + i18n.RespondWithError(c, i18n.ErrBadRequest.WithParam("Reason", "Username is required")) return } // Get the user from the database existingUser, err := h.db.GetUserByUsername(c.Request.Context(), username) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + i18n.RespondWithError(c, i18n.ErrorUserNotFound.WithParam("Username", username)) return } @@ -334,11 +335,11 @@ func (h *Handler) DeleteUser(c *gin.Context) { }) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", err.Error())) return } - c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"}) + i18n.Success(i18n.SuccessUserDeleted).Send(c) } // GetUserInfo handles getting current user info @@ -346,7 +347,7 @@ func (h *Handler) GetUserInfo(c *gin.Context) { // Get the user from the context claims, exists := c.Get("claims") if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + i18n.RespondWithError(c, i18n.ErrUnauthorized) return } jwtClaims := claims.(*jwt.Claims) @@ -354,7 +355,7 @@ func (h *Handler) GetUserInfo(c *gin.Context) { // Get the user from the database user, err := h.db.GetUserByUsername(c.Request.Context(), jwtClaims.Username) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + i18n.RespondWithError(c, i18n.ErrInternalServer) return } @@ -370,7 +371,7 @@ func (h *Handler) GetUserInfo(c *gin.Context) { } if err2 != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get tenants"}) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to get tenants")) return } @@ -386,13 +387,13 @@ func (h *Handler) GetUserInfo(c *gin.Context) { } } - c.JSON(http.StatusOK, gin.H{ - "id": user.ID, - "username": user.Username, - "role": user.Role, - "isActive": user.IsActive, - "tenants": tenantResponses, - }) + i18n.Success(i18n.SuccessUserInfo). + With("id", user.ID). + With("username", user.Username). + With("role", user.Role). + With("isActive", user.IsActive). + With("tenants", tenantResponses). + Send(c) } // GetUserWithTenants gets a user with their associated tenants @@ -400,7 +401,7 @@ func (h *Handler) GetUserWithTenants(c *gin.Context) { // Get current logged-in user information for permission checking claims, exists := c.Get("claims") if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + i18n.RespondWithError(c, i18n.ErrUnauthorized) return } currentUserClaims := claims.(*jwt.Claims) @@ -415,7 +416,7 @@ func (h *Handler) GetUserWithTenants(c *gin.Context) { } else { // Only administrators can view information of other users if currentUserClaims.Role != "admin" && username != currentUserClaims.Username { - c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden: Only administrators can access other users' information"}) + i18n.RespondWithError(c, i18n.ErrForbidden.WithParam("Reason", "Only administrators can access other users' information")) return } } @@ -423,7 +424,7 @@ func (h *Handler) GetUserWithTenants(c *gin.Context) { // Get user from database user, err := h.db.GetUserByUsername(c.Request.Context(), username) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + i18n.RespondWithError(c, i18n.ErrorUserNotFound.WithParam("Username", username)) return } @@ -439,7 +440,7 @@ func (h *Handler) GetUserWithTenants(c *gin.Context) { } if err2 != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user tenants"}) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to get user tenants")) return } @@ -464,14 +465,14 @@ func (h *Handler) GetUserWithTenants(c *gin.Context) { Tenants: tenantResponses, } - c.JSON(http.StatusOK, userResponse) + i18n.Success(i18n.SuccessUserWithTenants).WithPayload(userResponse).Send(c) } // UpdateUserTenants updates the tenant associations for a user func (h *Handler) UpdateUserTenants(c *gin.Context) { var req dto.UserTenantRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + i18n.RespondWithError(c, i18n.ErrBadRequest.WithParam("Reason", err.Error())) return } @@ -517,9 +518,9 @@ func (h *Handler) UpdateUserTenants(c *gin.Context) { }) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", err.Error())) return } - c.JSON(http.StatusOK, gin.H{"message": "User tenant associations updated successfully"}) + i18n.Success(i18n.SuccessUserTenantsUpdated).Send(c) } diff --git a/internal/apiserver/handler/chat.go b/internal/apiserver/handler/chat.go index ed7f6b6c..f02a20f8 100644 --- a/internal/apiserver/handler/chat.go +++ b/internal/apiserver/handler/chat.go @@ -1,10 +1,11 @@ package handler import ( - "github.com/mcp-ecosystem/mcp-gateway/internal/apiserver/database" - "net/http" "strconv" + "github.com/mcp-ecosystem/mcp-gateway/internal/apiserver/database" + "github.com/mcp-ecosystem/mcp-gateway/internal/i18n" + "github.com/gin-gonic/gin" ) @@ -19,16 +20,16 @@ func NewChat(db database.Database) *Chat { func (h *Chat) HandleGetChatSessions(c *gin.Context) { sessions, err := h.db.GetSessions(c.Request.Context()) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get chat sessions"}) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to get chat sessions")) return } - c.JSON(http.StatusOK, sessions) + i18n.Success(i18n.SuccessChatSessions).WithPayload(sessions).Send(c) } func (h *Chat) HandleGetChatMessages(c *gin.Context) { sessionId := c.Param("sessionId") if sessionId == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "sessionId is required"}) + i18n.RespondWithError(c, i18n.ErrBadRequest.WithParam("Reason", "SessionId is required")) return } @@ -50,9 +51,9 @@ func (h *Chat) HandleGetChatMessages(c *gin.Context) { // Get messages with pagination messages, err := h.db.GetMessagesWithPagination(c.Request.Context(), sessionId, page, pageSize) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get messages"}) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to get messages")) return } - c.JSON(http.StatusOK, messages) + i18n.Success(i18n.SuccessChatMessages).WithPayload(messages).Send(c) } diff --git a/internal/apiserver/handler/mcp.go b/internal/apiserver/handler/mcp.go index e75b4055..dfad291c 100644 --- a/internal/apiserver/handler/mcp.go +++ b/internal/apiserver/handler/mcp.go @@ -11,6 +11,7 @@ import ( "github.com/mcp-ecosystem/mcp-gateway/internal/auth/jwt" "github.com/mcp-ecosystem/mcp-gateway/internal/common/config" "github.com/mcp-ecosystem/mcp-gateway/internal/common/dto" + "github.com/mcp-ecosystem/mcp-gateway/internal/i18n" "github.com/mcp-ecosystem/mcp-gateway/internal/mcp/storage" "github.com/mcp-ecosystem/mcp-gateway/internal/mcp/storage/notifier" "gopkg.in/yaml.v3" @@ -35,26 +36,26 @@ func NewMCP(db database.Database, store storage.Store, ntf notifier.Notifier) *M func (h *MCP) checkTenantPermission(c *gin.Context, tenantName string, cfg *config.MCPConfig) (*database.Tenant, error) { // Check if tenant name is empty if tenantName == "" { - return nil, errors.New("errors.tenant_required") + return nil, i18n.ErrorTenantNameRequired } // Get user authentication information claims, exists := c.Get("claims") if !exists { - return nil, errors.New("unauthorized") + return nil, i18n.ErrUnauthorized } jwtClaims := claims.(*jwt.Claims) // Get user information user, err := h.db.GetUserByUsername(c.Request.Context(), jwtClaims.Username) if err != nil { - return nil, errors.New("Failed to get user info: " + err.Error()) + return nil, i18n.ErrInternalServer.WithParam("Reason", "Failed to get user info: "+err.Error()) } // Get tenant information tenant, err := h.db.GetTenantByName(c.Request.Context(), tenantName) if err != nil { - return nil, errors.New("errors.tenant_not_found") + return nil, i18n.ErrorTenantNotFound.WithParam("Name", tenantName) } // Normalize tenant prefix @@ -80,7 +81,7 @@ func (h *MCP) checkTenantPermission(c *gin.Context, tenantName string, cfg *conf // Must start with tenant prefix followed by a path separator if !strings.HasPrefix(routerPrefix, tenantPrefix+"/") { - return nil, errors.New("errors.router_prefix_error") + return nil, i18n.ErrorRouterPrefixError } } @@ -88,7 +89,7 @@ func (h *MCP) checkTenantPermission(c *gin.Context, tenantName string, cfg *conf if user.Role != database.RoleAdmin { userTenants, err := h.db.GetUserTenants(c.Request.Context(), user.ID) if err != nil { - return nil, errors.New("Failed to get user tenants: " + err.Error()) + return nil, i18n.ErrInternalServer.WithParam("Reason", "Failed to get user tenants: "+err.Error()) } allowed := false @@ -100,7 +101,7 @@ func (h *MCP) checkTenantPermission(c *gin.Context, tenantName string, cfg *conf } if !allowed { - return nil, errors.New("errors.tenant_permission_error") + return nil, i18n.ErrorTenantPermissionError } } @@ -111,60 +112,52 @@ func (h *MCP) HandleMCPServerUpdate(c *gin.Context) { // Get the server name from path parameter instead of query parameter name := c.Param("name") if name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "name parameter is required"}) + i18n.RespondWithError(c, i18n.ErrorMCPServerNameRequired) return } // Read the raw YAML content from request body content, err := c.GetRawData() if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body: " + err.Error()}) + i18n.RespondWithError(c, i18n.ErrBadRequest.WithParam("Reason", "Failed to read request body: "+err.Error())) return } // Validate the YAML content var cfg config.MCPConfig if err := yaml.Unmarshal(content, &cfg); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid YAML content: " + err.Error()}) + i18n.RespondWithError(c, i18n.ErrorMCPServerValidation.WithParam("Reason", "Invalid YAML content: "+err.Error())) return } // Check if the server name in config matches the name parameter if len(cfg.Servers) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "server name in configuration must match name parameter"}) + i18n.RespondWithError(c, i18n.ErrorMCPServerValidation.WithParam("Reason", "Server name in configuration must match name parameter")) return } // Get existing server oldCfg, err := h.store.Get(c.Request.Context(), name) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "MCP server not found"}) + i18n.RespondWithError(c, i18n.ErrorMCPServerNotFound.WithParam("Name", name)) return } if oldCfg.Name != cfg.Name { - c.JSON(http.StatusBadRequest, gin.H{"error": "server name in configuration must match name parameter"}) + i18n.RespondWithError(c, i18n.ErrorMCPServerValidation.WithParam("Reason", "Server name in configuration must match name parameter")) return } _, err = h.checkTenantPermission(c, cfg.Tenant, &cfg) if err != nil { - status := http.StatusBadRequest - if err.Error() == "unauthorized" { - status = http.StatusUnauthorized - } else if err.Error() == "errors.tenant_permission_error" { - status = http.StatusForbidden - } - c.JSON(status, gin.H{"error": err.Error()}) + i18n.RespondWithError(c, err) return } // Get all existing configurations configs, err := h.store.List(c.Request.Context()) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to get existing configurations: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to get existing configurations: "+err.Error())) return } @@ -180,35 +173,25 @@ func (h *MCP) HandleMCPServerUpdate(c *gin.Context) { if err := config.ValidateMCPConfigs(configs); err != nil { var validationErr *config.ValidationError if errors.As(err, &validationErr) { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "configuration validation failed: " + validationErr.Error(), - }) + i18n.RespondWithError(c, i18n.ErrorMCPServerValidation.WithParam("Reason", "Configuration validation failed: "+validationErr.Error())) } else { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "failed to validate configurations: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrorMCPServerValidation.WithParam("Reason", "Failed to validate configurations: "+err.Error())) } return } if err := h.store.Update(c.Request.Context(), &cfg); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to update MCP server: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to update MCP server: "+err.Error())) return } // Send reload signal to gateway using notifier if err := h.notifier.NotifyUpdate(c.Request.Context(), &cfg); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to reload gateway: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to reload gateway: "+err.Error())) return } - c.JSON(http.StatusOK, gin.H{ - "status": "success", - }) + i18n.Success(i18n.SuccessMCPServerUpdated).With("status", "success").Send(c) } func (h *MCP) HandleListMCPServers(c *gin.Context) { @@ -217,9 +200,7 @@ func (h *MCP) HandleListMCPServers(c *gin.Context) { if tenantIDStr != "" { tid, err := strconv.ParseUint(tenantIDStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Invalid tenantId parameter", - }) + i18n.RespondWithError(c, i18n.ErrBadRequest.WithParam("Reason", "Invalid tenantId parameter")) return } tenantID = uint(tid) @@ -227,33 +208,27 @@ func (h *MCP) HandleListMCPServers(c *gin.Context) { claims, exists := c.Get("claims") if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + i18n.RespondWithError(c, i18n.ErrUnauthorized) return } jwtClaims := claims.(*jwt.Claims) user, err := h.db.GetUserByUsername(c.Request.Context(), jwtClaims.Username) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to get user info: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to get user info: "+err.Error())) return } servers, err := h.store.List(c.Request.Context()) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to get MCP servers: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to get MCP servers: "+err.Error())) return } if user.Role != database.RoleAdmin && tenantID > 0 { userTenants, err := h.db.GetUserTenants(c.Request.Context(), user.ID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to get user tenants: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to get user tenants: "+err.Error())) return } @@ -266,9 +241,7 @@ func (h *MCP) HandleListMCPServers(c *gin.Context) { } if !hasPermission { - c.JSON(http.StatusForbidden, gin.H{ - "error": "User does not have permission to access this tenant", - }) + i18n.RespondWithError(c, i18n.ErrorTenantPermissionError) return } } @@ -277,9 +250,7 @@ func (h *MCP) HandleListMCPServers(c *gin.Context) { if tenantID > 0 { tenant, err := h.db.GetTenantByID(c.Request.Context(), tenantID) if err != nil { - c.JSON(http.StatusNotFound, gin.H{ - "error": "Tenant not found", - }) + i18n.RespondWithError(c, i18n.ErrorTenantNotFound) return } @@ -292,9 +263,7 @@ func (h *MCP) HandleListMCPServers(c *gin.Context) { } else if user.Role != database.RoleAdmin { userTenants, err := h.db.GetUserTenants(c.Request.Context(), user.ID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to get user tenants: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to get user tenants: "+err.Error())) return } @@ -332,53 +301,51 @@ func (h *MCP) HandleMCPServerCreate(c *gin.Context) { // Read the raw YAML content from request body content, err := c.GetRawData() if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body: " + err.Error()}) + i18n.RespondWithError(c, i18n.ErrBadRequest.WithParam("Reason", "Failed to read request body: "+err.Error())) return } // Validate the YAML content and get the server name var cfg config.MCPConfig if err := yaml.Unmarshal(content, &cfg); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid YAML content: " + err.Error()}) + i18n.RespondWithError(c, i18n.ErrorMCPServerValidation.WithParam("Reason", "Invalid YAML content: "+err.Error())) return } // Check if there is at least one server in the config if len(cfg.Servers) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "no server configuration found in YAML"}) + i18n.RespondWithError(c, i18n.ErrorMCPServerValidation.WithParam("Reason", "No server configuration found in YAML")) return } if cfg.Name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "server name is required in configuration"}) + i18n.RespondWithError(c, i18n.ErrorMCPServerValidation.WithParam("Reason", "Server name is required in configuration")) return } _, err = h.checkTenantPermission(c, cfg.Tenant, &cfg) if err != nil { - status := http.StatusBadRequest - if err.Error() == "unauthorized" { - status = http.StatusUnauthorized - } else if err.Error() == "errors.tenant_permission_error" { - status = http.StatusForbidden + if err == i18n.ErrUnauthorized { + i18n.RespondWithError(c, i18n.ErrUnauthorized) + } else if err == i18n.ErrorTenantPermissionError { + i18n.RespondWithError(c, i18n.ErrorTenantPermissionError) + } else { + i18n.RespondWithError(c, err) } - c.JSON(status, gin.H{"error": err.Error()}) return } // Check if server already exists _, err = h.store.Get(c.Request.Context(), cfg.Name) if err == nil { - c.JSON(http.StatusConflict, gin.H{"error": "MCP server already exists"}) + i18n.RespondWithError(c, i18n.ErrorMCPServerExists.WithParam("Name", cfg.Name)) return } // Get all existing configurations configs, err := h.store.List(c.Request.Context()) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to get existing configurations: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to get existing configurations: "+err.Error())) return } @@ -389,75 +356,57 @@ func (h *MCP) HandleMCPServerCreate(c *gin.Context) { if err := config.ValidateMCPConfigs(configs); err != nil { var validationErr *config.ValidationError if errors.As(err, &validationErr) { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "configuration validation failed: " + validationErr.Error(), - }) + i18n.RespondWithError(c, i18n.ErrorMCPServerValidation.WithParam("Reason", "Configuration validation failed: "+validationErr.Error())) } else { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "failed to validate configurations: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrorMCPServerValidation.WithParam("Reason", "Failed to validate configurations: "+err.Error())) } return } if err := h.store.Create(c.Request.Context(), &cfg); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to create MCP server: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to create MCP server: "+err.Error())) return } // Send reload signal to gateway using notifier if err := h.notifier.NotifyUpdate(c.Request.Context(), &cfg); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to reload gateway: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to reload gateway: "+err.Error())) return } - c.JSON(http.StatusCreated, gin.H{ - "status": "success", - }) + i18n.Created(i18n.SuccessMCPServerCreated).With("status", "success").Send(c) } func (h *MCP) HandleMCPServerDelete(c *gin.Context) { // Get the server name from path parameter name := c.Param("name") if name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "name parameter is required"}) + i18n.RespondWithError(c, i18n.ErrorMCPServerNameRequired) return } // Check if server exists _, err := h.store.Get(c.Request.Context(), name) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "MCP server not found"}) + i18n.RespondWithError(c, i18n.ErrorMCPServerNotFound.WithParam("Name", name)) return } // Delete server if err := h.store.Delete(c.Request.Context(), name); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to delete MCP server: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to delete MCP server: "+err.Error())) return } - c.JSON(http.StatusOK, gin.H{ - "status": "success", - }) + i18n.Success(i18n.SuccessMCPServerDeleted).With("status", "success").Send(c) } func (h *MCP) HandleMCPServerSync(c *gin.Context) { // Send reload signal to gateway using notifier if err := h.notifier.NotifyUpdate(c.Request.Context(), nil); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to reload gateway: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to reload gateway: "+err.Error())) return } - c.JSON(http.StatusOK, gin.H{ - "status": "success", - }) + i18n.Success(i18n.SuccessMCPServerSynced).With("status", "success").Send(c) } diff --git a/internal/apiserver/handler/openapi.go b/internal/apiserver/handler/openapi.go index b2544d95..d8f2d251 100644 --- a/internal/apiserver/handler/openapi.go +++ b/internal/apiserver/handler/openapi.go @@ -1,10 +1,9 @@ package handler import ( - "net/http" - "github.com/gin-gonic/gin" "github.com/mcp-ecosystem/mcp-gateway/internal/apiserver/database" + "github.com/mcp-ecosystem/mcp-gateway/internal/i18n" "github.com/mcp-ecosystem/mcp-gateway/internal/mcp/storage" "github.com/mcp-ecosystem/mcp-gateway/internal/mcp/storage/notifier" "github.com/mcp-ecosystem/mcp-gateway/pkg/openapi" @@ -31,18 +30,14 @@ func (h *OpenAPI) HandleImport(c *gin.Context) { // Get the file from the request file, err := c.FormFile("file") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "failed to get file: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrBadRequest.WithParam("Reason", "Failed to get file: "+err.Error())) return } // Open the file f, err := file.Open() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to open file: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to open file: "+err.Error())) return } defer f.Close() @@ -50,9 +45,7 @@ func (h *OpenAPI) HandleImport(c *gin.Context) { // Read the file content content := make([]byte, file.Size) if _, err := f.Read(content); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to read file: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to read file: "+err.Error())) return } @@ -62,30 +55,24 @@ func (h *OpenAPI) HandleImport(c *gin.Context) { // Convert the OpenAPI specification config, err := converter.Convert(content) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "failed to convert OpenAPI specification: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrBadRequest.WithParam("Reason", "Failed to convert OpenAPI specification: "+err.Error())) return } // Create the MCP server configuration if err := h.store.Create(c.Request.Context(), config); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to create MCP server: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to create MCP server: "+err.Error())) return } // Notify the gateway about the update if err := h.notifier.NotifyUpdate(c.Request.Context(), config); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to notify gateway: " + err.Error(), - }) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to notify gateway: "+err.Error())) return } - c.JSON(http.StatusCreated, gin.H{ - "status": "success", - "config": config, - }) + i18n.Created(i18n.SuccessOpenAPIImported). + With("status", "success"). + With("config", config). + Send(c) } diff --git a/internal/apiserver/handler/tenant.go b/internal/apiserver/handler/tenant.go index 4888d8ed..084516e1 100644 --- a/internal/apiserver/handler/tenant.go +++ b/internal/apiserver/handler/tenant.go @@ -1,9 +1,10 @@ package handler import ( - "net/http" "time" + "github.com/mcp-ecosystem/mcp-gateway/internal/i18n" + "github.com/gin-gonic/gin" "github.com/mcp-ecosystem/mcp-gateway/internal/apiserver/database" "github.com/mcp-ecosystem/mcp-gateway/internal/common/dto" @@ -13,41 +14,41 @@ import ( func (h *Handler) ListTenants(c *gin.Context) { tenants, err := h.db.ListTenants(c.Request.Context()) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + i18n.From(i18n.ErrInternalServer).Send(c) return } - c.JSON(http.StatusOK, tenants) + i18n.Success(i18n.SuccessTenantInfo).WithPayload(tenants).Send(c) } // CreateTenant handles tenant creation func (h *Handler) CreateTenant(c *gin.Context) { var req dto.CreateTenantRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + i18n.Error(i18n.ErrBadRequest.WithParam("Reason", err.Error())).Send(c) return } // Validate request if req.Name == "" || req.Prefix == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Tenant name and prefix are required"}) + i18n.From(i18n.ErrorTenantRequiredFields).Send(c) return } // Check if name or prefix already exists existingTenants, err := h.db.ListTenants(c.Request.Context()) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + i18n.From(i18n.ErrInternalServer).Send(c) return } for _, tenant := range existingTenants { if tenant.Name == req.Name { - c.JSON(http.StatusConflict, gin.H{"error": "Tenant name already exists"}) + i18n.From(i18n.ErrorTenantNameExists).Send(c) return } if tenant.Prefix == req.Prefix { - c.JSON(http.StatusConflict, gin.H{"error": "Prefix already exists"}) + i18n.From(i18n.ErrorTenantPrefixExists).Send(c) return } } @@ -63,32 +64,32 @@ func (h *Handler) CreateTenant(c *gin.Context) { } if err := h.db.CreateTenant(c.Request.Context(), newTenant); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + i18n.From(i18n.ErrInternalServer).Send(c) return } - c.JSON(http.StatusOK, gin.H{"id": newTenant.ID}) + i18n.Created(i18n.SuccessTenantCreated).With("id", newTenant.ID).Send(c) } // UpdateTenant handles tenant updates func (h *Handler) UpdateTenant(c *gin.Context) { var req dto.UpdateTenantRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + i18n.BadRequest("ErrorInvalidRequest").WithParam("Reason", err.Error()).Send(c) return } // Get the tenant from the database existingTenant, err := h.db.GetTenantByName(c.Request.Context(), req.Name) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Tenant not found"}) + i18n.NotFoundFromErr(i18n.ErrorTenantNotFound.WithParam("Name", req.Name)).Send(c) return } // Get all tenants for validation allTenants, err := h.db.ListTenants(c.Request.Context()) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + i18n.From(i18n.ErrInternalServer).Send(c) return } @@ -96,7 +97,7 @@ func (h *Handler) UpdateTenant(c *gin.Context) { if req.Prefix != "" && req.Prefix != existingTenant.Prefix { for _, tenant := range allTenants { if tenant.ID != existingTenant.ID && tenant.Prefix == req.Prefix { - c.JSON(http.StatusConflict, gin.H{"error": "Prefix already exists"}) + i18n.From(i18n.ErrorTenantPrefixExists).Send(c) return } } @@ -114,51 +115,51 @@ func (h *Handler) UpdateTenant(c *gin.Context) { existingTenant.UpdatedAt = time.Now() if err := h.db.UpdateTenant(c.Request.Context(), existingTenant); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + i18n.From(i18n.ErrInternalServer).Send(c) return } - c.JSON(http.StatusOK, gin.H{"message": "Tenant updated successfully"}) + i18n.Success(i18n.SuccessTenantUpdated).Send(c) } // DeleteTenant handles tenant deletion func (h *Handler) DeleteTenant(c *gin.Context) { name := c.Param("name") if name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Tenant name is required"}) + i18n.Error(i18n.ErrorTenantNameRequired).Send(c) return } // Get the tenant from the database existingTenant, err := h.db.GetTenantByName(c.Request.Context(), name) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Tenant not found"}) + i18n.NotFoundFromErr(i18n.ErrorTenantNotFound.WithParam("Name", name)).Send(c) return } // Delete the tenant if err := h.db.DeleteTenant(c.Request.Context(), existingTenant.ID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + i18n.From(i18n.ErrInternalServer).Send(c) return } - c.JSON(http.StatusOK, gin.H{"message": "Tenant deleted successfully"}) + i18n.Success(i18n.SuccessTenantDeleted).Send(c) } // GetTenantInfo handles getting tenant info by name func (h *Handler) GetTenantInfo(c *gin.Context) { name := c.Param("name") if name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Tenant name is required"}) + i18n.Error(i18n.ErrorTenantNameRequired).Send(c) return } // Get the tenant from the database tenant, err := h.db.GetTenantByName(c.Request.Context(), name) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Tenant not found"}) + i18n.NotFoundFromErr(i18n.ErrorTenantNotFound.WithParam("Name", name)).Send(c) return } - c.JSON(http.StatusOK, tenant) + i18n.Success(i18n.SuccessTenantInfo).WithPayload(tenant).Send(c) } diff --git a/internal/apiserver/handler/websocket.go b/internal/apiserver/handler/websocket.go index 49b29db4..d8d6d2aa 100644 --- a/internal/apiserver/handler/websocket.go +++ b/internal/apiserver/handler/websocket.go @@ -6,6 +6,9 @@ import ( "net/http" "time" + "github.com/mcp-ecosystem/mcp-gateway/internal/common/cnst" + "github.com/mcp-ecosystem/mcp-gateway/internal/i18n" + "github.com/google/uuid" "github.com/mcp-ecosystem/mcp-gateway/internal/apiserver/database" "github.com/mcp-ecosystem/mcp-gateway/internal/common/dto" @@ -41,32 +44,38 @@ func (h *WebSocket) HandleWebSocket(c *gin.Context) { // Token auth from query token := c.Query("token") if token == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "token is required"}) + i18n.RespondWithError(c, i18n.ErrUnauthorized.WithParam("Reason", "Token is required")) return } _, err := h.jwtService.ValidateToken(token) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + i18n.RespondWithError(c, i18n.ErrUnauthorized.WithParam("Reason", "Invalid token")) return } // Get sessionId from query parameter sessionId := c.Query("sessionId") if sessionId == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "sessionId is required"}) + i18n.RespondWithError(c, i18n.ErrBadRequest.WithParam("Reason", "SessionId is required")) return } + lang := c.Query("lang") + if lang == "" { + lang = cnst.LangDefault + } + c.Set(cnst.XLang, lang) + // Check if session exists, if not create it exists, err := h.db.SessionExists(c.Request.Context(), sessionId) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check session"}) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to check session")) return } if !exists { // Create new session with the provided sessionId if err := h.db.CreateSession(c.Request.Context(), sessionId); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"}) + i18n.RespondWithError(c, i18n.ErrInternalServer.WithParam("Reason", "Failed to create session")) return } } diff --git a/internal/common/cnst/i18n.go b/internal/common/cnst/i18n.go new file mode 100644 index 00000000..3c6b7fe6 --- /dev/null +++ b/internal/common/cnst/i18n.go @@ -0,0 +1,10 @@ +package cnst + +const ( + LangDefault = LangEN + LangEN = "en" + LangZH = "zh" + + XLang = "X-Lang" + CtxKeyTranslator = "translator" +) diff --git a/internal/common/config/apiserver.go b/internal/common/config/apiserver.go index 5870c3bf..e13124f3 100644 --- a/internal/common/config/apiserver.go +++ b/internal/common/config/apiserver.go @@ -14,6 +14,12 @@ type ( Logger LoggerConfig `yaml:"logger"` JWT JWTConfig `yaml:"jwt"` SuperAdmin SuperAdminConfig `yaml:"super_admin"` + I18n I18nConfig `yaml:"i18n"` + } + + // I18nConfig represents the internationalization configuration + I18nConfig struct { + Path string `yaml:"path"` // Path to i18n translation files } DatabaseConfig struct { diff --git a/internal/i18n/const.go b/internal/i18n/const.go new file mode 100644 index 00000000..e7072241 --- /dev/null +++ b/internal/i18n/const.go @@ -0,0 +1,148 @@ +package i18n + +// Common errors +var ( + ErrNotFound = NewErrorWithCode("ErrorResourceNotFound", ErrorNotFound) + ErrUnauthorized = NewErrorWithCode("ErrorUnauthorized", ErrorUnauthorized) + ErrForbidden = NewErrorWithCode("ErrorForbidden", ErrorForbidden) + ErrBadRequest = NewErrorWithCode("ErrorBadRequest", ErrorBadRequest) + ErrInternalServer = NewErrorWithCode("ErrorInternalServer", ErrorInternalServer) +) + +// Tenant related errors +var ( + ErrorTenantNotFound = NewErrorWithCode("ErrorTenantNotFound", ErrorNotFound) + ErrorTenantNameRequired = NewErrorWithCode("ErrorTenantNameRequired", ErrorBadRequest) + ErrorTenantPrefixExists = NewErrorWithCode("ErrorTenantPrefixExists", ErrorConflict) + ErrorTenantNameExists = NewErrorWithCode("ErrorTenantNameExists", ErrorConflict) + ErrorTenantRequiredFields = NewErrorWithCode("ErrorTenantRequiredFields", ErrorBadRequest) + ErrorTenantPermissionError = NewErrorWithCode("ErrorTenantPermissionError", ErrorForbidden) + ErrorTenantDisabled = NewErrorWithCode("ErrorTenantDisabled", ErrorForbidden) +) + +// User related errors +var ( + ErrorUserNotFound = NewErrorWithCode("ErrorUserNotFound", ErrorNotFound) + ErrorInvalidCredentials = NewErrorWithCode("ErrorInvalidCredentials", ErrorUnauthorized) + ErrorUserDisabled = NewErrorWithCode("ErrorUserDisabled", ErrorForbidden) + ErrorUserNamePasswordRequired = NewErrorWithCode("ErrorUserNamePasswordRequired", ErrorBadRequest) + ErrorInvalidOldPassword = NewErrorWithCode("ErrorInvalidOldPassword", ErrorForbidden) + ErrorUsernameExists = NewErrorWithCode("ErrorUsernameExists", ErrorConflict) + ErrorInvalidUsername = NewErrorWithCode("ErrorInvalidUsername", ErrorBadRequest) + ErrorEmailExists = NewErrorWithCode("ErrorEmailExists", ErrorConflict) + ErrorInvalidEmail = NewErrorWithCode("ErrorInvalidEmail", ErrorBadRequest) +) + +// MCP related errors +var ( + ErrorMCPServerNotFound = NewErrorWithCode("ErrorMCPServerNotFound", ErrorNotFound) + ErrorMCPServerExists = NewErrorWithCode("ErrorMCPServerExists", ErrorConflict) + ErrorMCPServerValidation = NewErrorWithCode("ErrorMCPServerValidation", ErrorBadRequest) + ErrorMCPServerNameRequired = NewErrorWithCode("ErrorMCPServerNameRequired", ErrorBadRequest) + ErrorRouterPrefixError = NewErrorWithCode("ErrorRouterPrefixError", ErrorBadRequest) + ErrorMCPConfigInvalid = NewErrorWithCode("ErrorMCPConfigInvalid", ErrorBadRequest) + ErrorMCPRequestFailed = NewErrorWithCode("ErrorMCPRequestFailed", ErrorInternalServer) +) + +// API related errors +var ( + ErrorAPINotFound = NewErrorWithCode("ErrorAPINotFound", ErrorNotFound) + ErrorAPIMethodNotAllowed = NewErrorWithCode("ErrorAPIMethodNotAllowed", ErrorMethodNotAllowed) + ErrorAPIRateLimitExceeded = NewErrorWithCode("ErrorAPIRateLimitExceeded", ErrorTooManyRequests) + ErrorAPIUnavailable = NewErrorWithCode("ErrorAPIUnavailable", ErrorServiceUnavailable) + ErrorAPITimeout = NewErrorWithCode("ErrorAPITimeout", ErrorGatewayTimeout) + ErrorAPIValidationFailed = NewErrorWithCode("ErrorAPIValidationFailed", ErrorBadRequest) + ErrorAPIMalformedRequest = NewErrorWithCode("ErrorAPIMalformedRequest", ErrorBadRequest) + ErrorAPIResponseInvalid = NewErrorWithCode("ErrorAPIResponseInvalid", ErrorInternalServer) + ErrorAPIInvalidCredentials = NewErrorWithCode("ErrorAPIInvalidCredentials", ErrorUnauthorized) + ErrorAPIPermissionDenied = NewErrorWithCode("ErrorAPIPermissionDenied", ErrorForbidden) + ErrorAPIUnsupportedMediaType = NewErrorWithCode("ErrorAPIUnsupportedMediaType", ErrorUnsupportedMedia) +) + +// General validation errors +var ( + ErrorRequiredField = NewErrorWithCode("ErrorRequiredField", ErrorBadRequest) + ErrorInvalidFormat = NewErrorWithCode("ErrorInvalidFormat", ErrorBadRequest) + ErrorInvalidValue = NewErrorWithCode("ErrorInvalidValue", ErrorBadRequest) + ErrorDuplicateEntity = NewErrorWithCode("ErrorDuplicateEntity", ErrorConflict) + ErrorDataIntegrityViolation = NewErrorWithCode("ErrorDataIntegrityViolation", ErrorBadRequest) +) + +// Tenant related success messages +const ( + SuccessTenantCreated = "SuccessTenantCreated" + SuccessTenantUpdated = "SuccessTenantUpdated" + SuccessTenantDeleted = "SuccessTenantDeleted" + SuccessTenantInfo = "SuccessTenantInfo" + SuccessTenantList = "SuccessTenantList" + SuccessTenantStatus = "SuccessTenantStatus" +) + +// User related success messages +const ( + SuccessLogin = "SuccessLogin" + SuccessLogout = "SuccessLogout" + SuccessPasswordChanged = "SuccessPasswordChanged" + SuccessUserCreated = "SuccessUserCreated" + SuccessUserUpdated = "SuccessUserUpdated" + SuccessUserDeleted = "SuccessUserDeleted" + SuccessUserInfo = "SuccessUserInfo" + SuccessUserList = "SuccessUserList" + SuccessUserWithTenants = "SuccessUserWithTenants" + SuccessUserTenantsUpdated = "SuccessUserTenantsUpdated" +) + +// MCP related success messages +const ( + SuccessMCPServerCreated = "SuccessMCPServerCreated" + SuccessMCPServerUpdated = "SuccessMCPServerUpdated" + SuccessMCPServerDeleted = "SuccessMCPServerDeleted" + SuccessMCPServerSynced = "SuccessMCPServerSynced" + SuccessMCPServerList = "SuccessMCPServerList" + SuccessMCPServerInfo = "SuccessMCPServerInfo" + SuccessMCPServerStatus = "SuccessMCPServerStatus" +) + +// OpenAPI related success messages +const ( + SuccessOpenAPIImported = "SuccessOpenAPIImported" + SuccessOpenAPIExported = "SuccessOpenAPIExported" + SuccessOpenAPIValidated = "SuccessOpenAPIValidated" +) + +// API related success messages +const ( + SuccessAPICreated = "SuccessAPICreated" + SuccessAPIUpdated = "SuccessAPIUpdated" + SuccessAPIDeleted = "SuccessAPIDeleted" + SuccessAPIList = "SuccessAPIList" + SuccessAPIInfo = "SuccessAPIInfo" + SuccessAPIKeyCreated = "SuccessAPIKeyCreated" + SuccessAPIKeyRevoked = "SuccessAPIKeyRevoked" + SuccessAPIKeyList = "SuccessAPIKeyList" + SuccessAPIRouteCreated = "SuccessAPIRouteCreated" + SuccessAPIRouteUpdated = "SuccessAPIRouteUpdated" + SuccessAPIRouteDeleted = "SuccessAPIRouteDeleted" + SuccessAPIRouteList = "SuccessAPIRouteList" +) + +// Chat related success messages +const ( + SuccessChatSessions = "SuccessChatSessions" + SuccessChatMessages = "SuccessChatMessages" + SuccessChatCreated = "SuccessChatCreated" + SuccessChatUpdated = "SuccessChatUpdated" + SuccessChatDeleted = "SuccessChatDeleted" + SuccessChatHistory = "SuccessChatHistory" +) + +// General success messages +const ( + SuccessOperationCompleted = "SuccessOperationCompleted" + SuccessItemCreated = "SuccessItemCreated" + SuccessItemUpdated = "SuccessItemUpdated" + SuccessItemDeleted = "SuccessItemDeleted" + SuccessDataExported = "SuccessDataExported" + SuccessDataImported = "SuccessDataImported" + SuccessDataSaved = "SuccessDataSaved" +) diff --git a/internal/i18n/core.go b/internal/i18n/core.go new file mode 100644 index 00000000..10828eaf --- /dev/null +++ b/internal/i18n/core.go @@ -0,0 +1,189 @@ +package i18n + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/BurntSushi/toml" + "github.com/gin-gonic/gin" + "github.com/mcp-ecosystem/mcp-gateway/internal/common/cnst" + "github.com/nicksnyder/go-i18n/v2/i18n" + "golang.org/x/text/language" +) + +var ( + translatorOnce sync.Once + translator *I18n + defaultLang = cnst.LangEN +) + +// SetDefaultLanguage sets the default language for error messages +func SetDefaultLanguage(lang string) { + defaultLang = lang +} + +// InitTranslator initializes the global translator +func InitTranslator(translationsPath string) error { + var initErr error + translatorOnce.Do(func() { + translator = NewI18n(language.Chinese) + initErr = translator.LoadTranslations(translationsPath) + }) + return initErr +} + +// GetTranslator returns the global translator +func GetTranslator() *I18n { + if translator == nil { + // Initialize with default path if not already initialized + _ = InitTranslator("configs/i18n") + } + return translator +} + +// I18n manages internationalization and translations +type I18n struct { + bundle *i18n.Bundle + defaultLang language.Tag +} + +// NewI18n creates a new I18n instance with the specified default language +func NewI18n(defaultLang language.Tag) *I18n { + bundle := i18n.NewBundle(defaultLang) + bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + + return &I18n{ + bundle: bundle, + defaultLang: defaultLang, + } +} + +// LoadTranslations loads translation files from the specified directory +func (i *I18n) LoadTranslations(translationsDir string) error { + files, err := os.ReadDir(translationsDir) + if err != nil { + return fmt.Errorf("failed to read translations directory: %w", err) + } + + for _, file := range files { + if file.IsDir() { + continue + } + + if !strings.HasSuffix(file.Name(), ".toml") { + continue + } + + filePath := filepath.Join(translationsDir, file.Name()) + i.bundle.MustLoadMessageFile(filePath) + } + + return nil +} + +// Translate returns a localized string for the given message ID and language +func (i *I18n) Translate(msgID string, lang string, templateData map[string]interface{}) string { + tag := language.Make(lang) + localizer := i18n.NewLocalizer(i.bundle, tag.String(), i.defaultLang.String()) + + lc := &i18n.LocalizeConfig{ + MessageID: msgID, + } + + if len(templateData) > 0 { + lc.TemplateData = templateData + } + + msg, err := localizer.Localize(lc) + if err != nil { + return msgID // Return original message ID if translation fails + } + + return msg +} + +// TranslateContext returns a localized string using the Gin context's language preference +func (i *I18n) TranslateContext(c *gin.Context, msgID string, templateData map[string]interface{}) string { + defaultLanguage := "zh" + + lang, exists := c.Get(cnst.XLang) + if !exists || lang == "" { + lang = defaultLanguage + } + + langStr, ok := lang.(string) + if !ok { + langStr = defaultLanguage + } + + return i.Translate(msgID, langStr, templateData) +} + +// getLanguageFromRequest extracts language preference from HTTP headers +func getLanguageFromRequest(r *http.Request) string { + // Try X-Lang header first + lang := r.Header.Get(cnst.XLang) + if lang != "" { + return normalizeLang(lang) + } + + // Then try Accept-Language + acceptLang := r.Header.Get("Accept-Language") + if acceptLang != "" { + langs := strings.Split(acceptLang, ",") + if len(langs) > 0 { + firstLang := strings.TrimSpace(strings.Split(langs[0], ";")[0]) + return normalizeLang(firstLang) + } + } + + return defaultLang +} + +// normalizeLang standardizes language codes +func normalizeLang(lang string) string { + langCode := strings.Split(lang, "-")[0] + langCode = strings.ToLower(langCode) + + supportedLangs := []string{"en", "zh"} + for _, supported := range supportedLangs { + if langCode == supported { + return langCode + } + } + + return defaultLang +} + +// TranslateMessage translates a message ID using the context's language preference +func TranslateMessage(c *gin.Context, msgID string, data map[string]interface{}) string { + lang, exists := c.Get(cnst.XLang) + if !exists || lang == "" { + lang = defaultLang + } + + langStr, ok := lang.(string) + if !ok { + langStr = defaultLang + } + + t := GetTranslator() + if t != nil { + return t.Translate(msgID, langStr, data) + } + return msgID +} + +// TranslateMessageGin is an alias for TranslateMessage with the same parameter order +func TranslateMessageGin(c *gin.Context, msgID string, data map[string]interface{}) string { + return TranslateMessage(c, msgID, data) +} + +// DebugLoadedMessages prints out debugging information about loaded messages +func (i *I18n) DebugLoadedMessages() { + // Debug function kept but implementation removed +} diff --git a/internal/i18n/error.go b/internal/i18n/error.go new file mode 100644 index 00000000..2787f35b --- /dev/null +++ b/internal/i18n/error.go @@ -0,0 +1,244 @@ +package i18n + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/mcp-ecosystem/mcp-gateway/internal/common/cnst" +) + +// ErrorCode represents an HTTP status code +type ErrorCode int + +// Standard HTTP status codes +const ( + ErrorBadRequest ErrorCode = http.StatusBadRequest + ErrorUnauthorized ErrorCode = http.StatusUnauthorized + ErrorForbidden ErrorCode = http.StatusForbidden + ErrorNotFound ErrorCode = http.StatusNotFound + ErrorMethodNotAllowed ErrorCode = http.StatusMethodNotAllowed + ErrorConflict ErrorCode = http.StatusConflict + ErrorInternalServer ErrorCode = http.StatusInternalServerError + ErrorNotImplemented ErrorCode = http.StatusNotImplemented + ErrorServiceUnavailable ErrorCode = http.StatusServiceUnavailable + ErrorTooManyRequests ErrorCode = http.StatusTooManyRequests + ErrorGatewayTimeout ErrorCode = http.StatusGatewayTimeout + ErrorUnsupportedMedia ErrorCode = http.StatusUnsupportedMediaType +) + +// I18nError represents an internationalized error +type I18nError struct { + // MessageID is the key used for translation lookup + MessageID string + // DefaultMessage is used when translation is not available + DefaultMessage string + // Data holds template parameters for the message + Data map[string]interface{} +} + +// New creates a new I18nError with the given message ID +func New(messageID string) *I18nError { + return &I18nError{ + MessageID: messageID, + DefaultMessage: messageID, + Data: make(map[string]interface{}), + } +} + +// NewWithMessage creates a new I18nError with a message ID and default message +func NewWithMessage(messageID, defaultMessage string) *I18nError { + return &I18nError{ + MessageID: messageID, + DefaultMessage: defaultMessage, + Data: make(map[string]interface{}), + } +} + +// WithData adds template data to the error +func (e *I18nError) WithData(data map[string]interface{}) *I18nError { + if data != nil { + for k, v := range data { + e.Data[k] = v + } + } + return e +} + +// WithParam adds a single template parameter to the error +func (e *I18nError) WithParam(key string, value interface{}) *I18nError { + e.Data[key] = value + return e +} + +// Error implements the error interface +func (e *I18nError) Error() string { + // 使用默认语言翻译消息 + t := GetTranslator() + if t != nil { + translated := t.Translate(e.MessageID, defaultLang, e.Data) + if translated != e.MessageID { + return translated + } + } + + // 如果没有翻译,返回默认消息 + if len(e.Data) == 0 { + return e.DefaultMessage + } + + // 尝试格式化消息 + msg := e.DefaultMessage + for k, v := range e.Data { + placeholder := fmt.Sprintf("{{.%s}}", k) + msg = strings.Replace(msg, placeholder, fmt.Sprintf("%v", v), -1) + } + return msg +} + +// GetMessageID returns the message ID for translation +func (e *I18nError) GetMessageID() string { + return e.MessageID +} + +// GetData returns the template data +func (e *I18nError) GetData() map[string]interface{} { + return e.Data +} + +// TranslateByContext translates the error based on the context's language preference +func (e *I18nError) TranslateByContext(c *gin.Context) string { + lang, exists := c.Get(cnst.XLang) + if !exists || lang == "" { + lang = defaultLang + } + + langStr, ok := lang.(string) + if !ok { + langStr = defaultLang + } + + t := GetTranslator() + if t != nil { + translated := t.Translate(e.MessageID, langStr, e.Data) + if translated != e.MessageID { + return translated + } + } + return e.Error() +} + +// TranslateByRequest translates the error based on the HTTP request's language preference +func (e *I18nError) TranslateByRequest(r *http.Request) string { + lang := getLanguageFromRequest(r) + t := GetTranslator() + if t != nil { + translated := t.Translate(e.MessageID, lang, e.Data) + if translated != e.MessageID { + return translated + } + } + return e.Error() +} + +// ErrorWithCode is an error with a code that can be used in API responses +type ErrorWithCode struct { + *I18nError + Code ErrorCode +} + +// NewErrorWithCode creates a new error with a code +func NewErrorWithCode(messageID string, code ErrorCode) *ErrorWithCode { + return &ErrorWithCode{ + I18nError: New(messageID), + Code: code, + } +} + +// WithData adds template data to the error +func (e *ErrorWithCode) WithData(data map[string]interface{}) *ErrorWithCode { + e.I18nError.WithData(data) + return e +} + +// WithParam adds a single template parameter to the error +func (e *ErrorWithCode) WithParam(key string, value interface{}) *ErrorWithCode { + e.I18nError.WithParam(key, value) + return e +} + +// WithHttpCode allows changing the HTTP status code dynamically +func (e *ErrorWithCode) WithHttpCode(code ErrorCode) *ErrorWithCode { + newErr := &ErrorWithCode{ + I18nError: e.I18nError, + Code: code, + } + return newErr +} + +// GetCode returns the error code +func (e *ErrorWithCode) GetCode() ErrorCode { + return e.Code +} + +// IsI18nError checks if an error is an I18nError +func IsI18nError(err error) bool { + if err == nil { + return false + } + + var i18nErr *I18nError + return errors.As(err, &i18nErr) +} + +// AsI18nError converts an error to an I18nError if possible, or returns nil +func AsI18nError(err error) *I18nError { + var i18nErr *I18nError + if errors.As(err, &i18nErr) { + return i18nErr + } + return nil +} + +// TranslateError translates an error using the context's language preference +func TranslateError(c *gin.Context, err error) string { + if err == nil { + return "" + } + + var i18nErr *I18nError + if errors.As(err, &i18nErr) { + return i18nErr.TranslateByContext(c) + } + + var errWithCode *ErrorWithCode + if errors.As(err, &errWithCode) { + return errWithCode.TranslateByContext(c) + } + + // Try to see if it's a message ID + errMsg := err.Error() + if IsI18nError(err) { + lang, exists := c.Get(cnst.XLang) + if !exists || lang == "" { + lang = defaultLang + } + + langStr, ok := lang.(string) + if !ok { + langStr = defaultLang + } + + t := GetTranslator() + if t != nil { + translated := t.Translate(errMsg, langStr, nil) + if translated != errMsg { + return translated + } + } + } + + return errMsg +} diff --git a/internal/i18n/errorresponse.go b/internal/i18n/errorresponse.go new file mode 100644 index 00000000..1ba7fba6 --- /dev/null +++ b/internal/i18n/errorresponse.go @@ -0,0 +1,237 @@ +package i18n + +import ( + "errors" + + "github.com/gin-gonic/gin" +) + +// ErrorResponse represents an error response +type ErrorResponse struct { + StatusCode ErrorCode + Err error +} + +// WithHttpCode sets the HTTP status code for the error +func (r *ErrorResponse) WithHttpCode(code ErrorCode) *ErrorResponse { + r.StatusCode = code + return r +} + +// WithParam adds a parameter to the error +func (r *ErrorResponse) WithParam(key string, value interface{}) *ErrorResponse { + var i18nErr *ErrorWithCode + if errors.As(r.Err, &i18nErr) { + r.Err = i18nErr.WithParam(key, value) + } + return r +} + +// WithHeader adds a header to the response +func (r *ErrorResponse) WithHeader(key, value string) *ErrorResponse { + // Note: Headers will be implemented in the Send method + return r +} + +// Send sends the error response to the client +func (r *ErrorResponse) Send(c *gin.Context) { + RespondWithError(c, r.Err) +} + +// BadRequest creates a new error response with status code 400 +func BadRequest(msgID string) *ErrorResponse { + return &ErrorResponse{ + StatusCode: ErrorBadRequest, + Err: NewErrorWithCode(msgID, ErrorBadRequest), + } +} + +// Unauthorized creates a new error response with status code 401 +func Unauthorized(msgID string) *ErrorResponse { + return &ErrorResponse{ + StatusCode: ErrorUnauthorized, + Err: NewErrorWithCode(msgID, ErrorUnauthorized), + } +} + +// Forbidden creates a new error response with status code 403 +func Forbidden(msgID string) *ErrorResponse { + return &ErrorResponse{ + StatusCode: ErrorForbidden, + Err: NewErrorWithCode(msgID, ErrorForbidden), + } +} + +// NotFound creates a new error response with status code 404 +func NotFound(msgID string) *ErrorResponse { + return &ErrorResponse{ + StatusCode: ErrorNotFound, + Err: NewErrorWithCode(msgID, ErrorNotFound), + } +} + +// Conflict creates a new error response with status code 409 +func Conflict(msgID string) *ErrorResponse { + return &ErrorResponse{ + StatusCode: ErrorConflict, + Err: NewErrorWithCode(msgID, ErrorConflict), + } +} + +// InternalError creates a new error response with status code 500 +func InternalError(msgID string) *ErrorResponse { + return &ErrorResponse{ + StatusCode: ErrorInternalServer, + Err: NewErrorWithCode(msgID, ErrorInternalServer), + } +} + +// Error creates an error response from a predefined error constant +func Error(predefinedErr error) *ErrorResponse { + statusCode := ErrorInternalServer + var errWithCode *ErrorWithCode + if errors.As(predefinedErr, &errWithCode) { + statusCode = errWithCode.GetCode() + } + return &ErrorResponse{ + StatusCode: statusCode, + Err: predefinedErr, + } +} + +// From is an alias for Error for backward compatibility +func From(err error) *ErrorResponse { + return Error(err) +} + +// NotFoundFromErr creates a not found error from a predefined error +func NotFoundFromErr(predefinedErr error) *ErrorResponse { + var errWithCode *ErrorWithCode + if errors.As(predefinedErr, &errWithCode) { + return &ErrorResponse{ + StatusCode: ErrorNotFound, + Err: errWithCode.WithHttpCode(ErrorNotFound), + } + } + return &ErrorResponse{ + StatusCode: ErrorNotFound, + Err: NewErrorWithCode(predefinedErr.Error(), ErrorNotFound), + } +} + +// BadRequestFromErr creates a bad request error from a predefined error +func BadRequestFromErr(predefinedErr error) *ErrorResponse { + var errWithCode *ErrorWithCode + if errors.As(predefinedErr, &errWithCode) { + return &ErrorResponse{ + StatusCode: ErrorBadRequest, + Err: errWithCode.WithHttpCode(ErrorBadRequest), + } + } + return &ErrorResponse{ + StatusCode: ErrorBadRequest, + Err: NewErrorWithCode(predefinedErr.Error(), ErrorBadRequest), + } +} + +// UnauthorizedFromErr creates an unauthorized error from a predefined error +func UnauthorizedFromErr(predefinedErr error) *ErrorResponse { + var errWithCode *ErrorWithCode + if errors.As(predefinedErr, &errWithCode) { + return &ErrorResponse{ + StatusCode: ErrorUnauthorized, + Err: errWithCode.WithHttpCode(ErrorUnauthorized), + } + } + return &ErrorResponse{ + StatusCode: ErrorUnauthorized, + Err: NewErrorWithCode(predefinedErr.Error(), ErrorUnauthorized), + } +} + +// ForbiddenFromErr creates a forbidden error from a predefined error +func ForbiddenFromErr(predefinedErr error) *ErrorResponse { + var errWithCode *ErrorWithCode + if errors.As(predefinedErr, &errWithCode) { + return &ErrorResponse{ + StatusCode: ErrorForbidden, + Err: errWithCode.WithHttpCode(ErrorForbidden), + } + } + return &ErrorResponse{ + StatusCode: ErrorForbidden, + Err: NewErrorWithCode(predefinedErr.Error(), ErrorForbidden), + } +} + +// ConflictFromErr creates a conflict error from a predefined error +func ConflictFromErr(predefinedErr error) *ErrorResponse { + var errWithCode *ErrorWithCode + if errors.As(predefinedErr, &errWithCode) { + return &ErrorResponse{ + StatusCode: ErrorConflict, + Err: errWithCode.WithHttpCode(ErrorConflict), + } + } + return &ErrorResponse{ + StatusCode: ErrorConflict, + Err: NewErrorWithCode(predefinedErr.Error(), ErrorConflict), + } +} + +// InternalServerFromErr creates an internal server error from a predefined error +func InternalServerFromErr(predefinedErr error) *ErrorResponse { + var errWithCode *ErrorWithCode + if errors.As(predefinedErr, &errWithCode) { + return &ErrorResponse{ + StatusCode: ErrorInternalServer, + Err: errWithCode.WithHttpCode(ErrorInternalServer), + } + } + return &ErrorResponse{ + StatusCode: ErrorInternalServer, + Err: NewErrorWithCode(predefinedErr.Error(), ErrorInternalServer), + } +} + +// ErrorWithParam creates an error response with parameters +func ErrorWithParam(predefinedErr error, key string, value interface{}) *ErrorResponse { + var errWithCode *ErrorWithCode + if errors.As(predefinedErr, &errWithCode) { + return &ErrorResponse{ + StatusCode: errWithCode.GetCode(), + Err: errWithCode.WithParam(key, value), + } + } + + // For other error types, create an internal server error + return &ErrorResponse{ + StatusCode: ErrorInternalServer, + Err: NewErrorWithCode(predefinedErr.Error(), ErrorInternalServer).WithParam(key, value), + } +} + +// ErrorWithParams creates an error response with multiple parameters +func ErrorWithParams(predefinedErr error, params map[string]interface{}) *ErrorResponse { + var errWithCode *ErrorWithCode + if errors.As(predefinedErr, &errWithCode) { + paramErr := errWithCode + for k, v := range params { + paramErr = paramErr.WithParam(k, v) + } + return &ErrorResponse{ + StatusCode: errWithCode.GetCode(), + Err: paramErr, + } + } + + // For other error types, create an internal server error + internalErr := NewErrorWithCode(predefinedErr.Error(), ErrorInternalServer) + for k, v := range params { + internalErr = internalErr.WithParam(k, v) + } + return &ErrorResponse{ + StatusCode: ErrorInternalServer, + Err: internalErr, + } +} diff --git a/internal/i18n/response.go b/internal/i18n/response.go new file mode 100644 index 00000000..fb9fa5c1 --- /dev/null +++ b/internal/i18n/response.go @@ -0,0 +1,126 @@ +package i18n + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" +) + +// RespondWithError sends an appropriate HTTP error response for the given error +func RespondWithError(c *gin.Context, err error) { + if err == nil { + return + } + + // Default status for any kind of error + statusCode := http.StatusInternalServerError + errorMsg := TranslateError(c, err) + + // Try to extract status code from error + var errWithCode *ErrorWithCode + if errors.As(err, &errWithCode) { + statusCode = int(errWithCode.GetCode()) + } + + c.JSON(statusCode, gin.H{"error": errorMsg}) +} + +// RespondWithSuccess sends a success HTTP response with an internationalized message +func RespondWithSuccess(c *gin.Context, statusCode int, msgID string, data map[string]any, payload interface{}) { + message := TranslateMessage(c, msgID, data) + + response := gin.H{ + "message": message, + } + + // Add data to the top level of the response + if data != nil { + for k, v := range data { + response[k] = v + } + } + + // Add additional payload if provided + if payload != nil { + switch p := payload.(type) { + case map[string]any: + for k, v := range p { + response[k] = v + } + case gin.H: + for k, v := range p { + response[k] = v + } + default: + response["data"] = payload + } + } + + c.JSON(statusCode, response) +} + +// SuccessResponse represents a response with success message +type SuccessResponse struct { + StatusCode int + MsgID string + Data map[string]interface{} + Payload interface{} +} + +// With adds a key-value pair to the response data +func (r *SuccessResponse) With(key string, value interface{}) *SuccessResponse { + if r.Data == nil { + r.Data = make(map[string]interface{}) + } + r.Data[key] = value + return r +} + +// WithData adds multiple key-value pairs to the response data +func (r *SuccessResponse) WithData(data map[string]interface{}) *SuccessResponse { + if r.Data == nil { + r.Data = make(map[string]interface{}) + } + for k, v := range data { + r.Data[k] = v + } + return r +} + +// WithPayload sets the payload for the response +func (r *SuccessResponse) WithPayload(payload interface{}) *SuccessResponse { + r.Payload = payload + return r +} + +// Send sends the response to the client +func (r *SuccessResponse) Send(c *gin.Context) { + RespondWithSuccess(c, r.StatusCode, r.MsgID, r.Data, r.Payload) +} + +// Success creates a new success response with status code 200 +func Success(msgID string) *SuccessResponse { + return &SuccessResponse{ + StatusCode: http.StatusOK, + MsgID: msgID, + } +} + +// Created creates a new success response with status code 201 +func Created(msgID string) *SuccessResponse { + return &SuccessResponse{ + StatusCode: http.StatusCreated, + MsgID: msgID, + } +} + +// RespondOK sends a success HTTP response with status code 200 +func RespondOK(c *gin.Context, msgID string, data map[string]interface{}, payload interface{}) { + RespondWithSuccess(c, http.StatusOK, msgID, data, payload) +} + +// RespondCreated sends a success HTTP response with status code 201 +func RespondCreated(c *gin.Context, msgID string, data map[string]interface{}, payload interface{}) { + RespondWithSuccess(c, http.StatusCreated, msgID, data, payload) +} diff --git a/web/src/components/LanguageSwitcher.tsx b/web/src/components/LanguageSwitcher.tsx index 2a9d97fe..642263df 100644 --- a/web/src/components/LanguageSwitcher.tsx +++ b/web/src/components/LanguageSwitcher.tsx @@ -7,9 +7,19 @@ const languages = [ { code: 'zh', name: '中文' } ]; +/** + * Language switching component that allows users to change the application language. + * When language is changed, it automatically updates i18n context and all + * subsequent API requests will include the selected language in X-Lang header. + */ export function LanguageSwitcher() { const { i18n, t } = useTranslation(); + /** + * Change the application language + * This automatically affects API requests through the axios interceptor + * which adds the X-Lang header to all requests + */ const handleLanguageChange = (languageCode: string) => { i18n.changeLanguage(languageCode); }; diff --git a/web/src/services/api.ts b/web/src/services/api.ts index e64dd90c..05356b94 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1,7 +1,9 @@ import axios from 'axios'; import { t } from 'i18next'; +import i18n from '../i18n'; import { toast } from '../utils/toast'; +import { handleApiError } from '../utils/error-handler'; // Create an axios instance with default config const api = axios.create({ @@ -12,6 +14,24 @@ const api = axios.create({ }, }); +// Request interceptor: add language and auth headers +api.interceptors.request.use( + (config) => { + // Add current language from i18n to X-Lang header + config.headers['X-Lang'] = i18n.language || 'zh'; + + // Add authorization token if available + const token = window.localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + // Add response interceptor api.interceptors.response.use( (response) => response, @@ -29,36 +49,15 @@ api.interceptors.response.use( } ); -// Add request interceptor to add token to headers -api.interceptors.request.use( - (config) => { - const token = window.localStorage.getItem('token'); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; - }, - (error) => { - return Promise.reject(error); - } -); - // API endpoints export const getMCPServers = async (tenantId?: number) => { try { const params = tenantId ? { tenantId } : {}; const response = await api.get('/mcp-servers', { params }); - return response.data; + // 处理数据,兼容直接返回和嵌套在 data 中的情况 + return response.data.data || response.data; } catch (error) { - if (axios.isAxiosError(error) && error.response?.data?.error) { - toast.error(t('errors.fetch_mcp_servers'), { - duration: 3000, - }); - } else { - toast.error(t('errors.fetch_mcp_servers'), { - duration: 3000, - }); - } + handleApiError(error, 'errors.fetch_mcp_servers'); throw error; } }; @@ -66,50 +65,14 @@ export const getMCPServers = async (tenantId?: number) => { export const getMCPServer = async (name: string) => { try { const response = await api.get(`/mcp-servers/${name}`); - return response.data; + // 处理数据,兼容直接返回和嵌套在 data 中的情况 + return response.data.data || response.data; } catch (error) { - if (axios.isAxiosError(error) && error.response?.data?.error) { - toast.error(t('errors.fetch_mcp_server'), { - duration: 3000, - }); - } else { - toast.error(t('errors.fetch_mcp_server'), { - duration: 3000, - }); - } + handleApiError(error, 'errors.fetch_mcp_server'); throw error; } }; -// Helper function to map backend error messages to i18n keys -const mapErrorToI18nKey = (errorMessage: string): string => { - // Direct error codes returned from backend - if (errorMessage.startsWith('errors.')) { - return errorMessage; - } - - // Legacy error message mapping - if (errorMessage.includes('Tenant field is required')) { - return 'errors.tenant_required'; - } - if (errorMessage.includes('Tenant with prefix') && errorMessage.includes('does not exist')) { - return 'errors.tenant_not_found'; - } - if (errorMessage.includes('User does not have permission to configure')) { - return 'errors.tenant_permission_error'; - } - if (errorMessage.includes('router prefix') && errorMessage.includes('must start with tenant prefix')) { - return 'errors.router_prefix_error'; - } - if (errorMessage === 'errors.namespace_permission_error') { - return 'errors.namespace_permission_error'; - } - if (errorMessage === 'errors.tenant_permission_error') { - return 'errors.tenant_permission_error'; - } - return ''; -}; - export const createMCPServer = async (config: string) => { try { const response = await api.post('/mcp-servers', config, { @@ -119,18 +82,7 @@ export const createMCPServer = async (config: string) => { }); return response.data; } catch (error) { - if (axios.isAxiosError(error) && error.response?.data?.error) { - const errorMessage = error.response.data.error; - const i18nKey = mapErrorToI18nKey(errorMessage); - - if (i18nKey) { - toast.error(t(i18nKey), { duration: 3000 }); - } else { - toast.error(t('errors.create_mcp_server'), { duration: 3000 }); - } - } else { - toast.error(t('errors.create_mcp_server'), { duration: 3000 }); - } + handleApiError(error, 'errors.create_mcp_server'); throw error; } }; @@ -144,18 +96,7 @@ export const updateMCPServer = async (name: string, config: string) => { }); return response.data; } catch (error) { - if (axios.isAxiosError(error) && error.response?.data?.error) { - const errorMessage = error.response.data.error; - const i18nKey = mapErrorToI18nKey(errorMessage); - - if (i18nKey) { - toast.error(t(i18nKey), { duration: 3000 }); - } else { - toast.error(t('errors.update_mcp_server'), { duration: 3000 }); - } - } else { - toast.error(t('errors.update_mcp_server'), { duration: 3000 }); - } + handleApiError(error, 'errors.update_mcp_server'); throw error; } }; @@ -165,15 +106,7 @@ export const deleteMCPServer = async (name: string) => { const response = await api.delete(`/mcp-servers/${name}`); return response.data; } catch (error) { - if (axios.isAxiosError(error) && error.response?.data?.error) { - toast.error(t('errors.delete_mcp_server'), { - duration: 3000, - }); - } else { - toast.error(t('errors.delete_mcp_server'), { - duration: 3000, - }); - } + handleApiError(error, 'errors.delete_mcp_server'); throw error; } }; @@ -183,15 +116,7 @@ export const syncMCPServers = async () => { const response = await api.post('/mcp-servers/sync'); return response.data; } catch (error) { - if (axios.isAxiosError(error) && error.response?.data?.error) { - toast.error(t('errors.sync_mcp_server'), { - duration: 3000, - }); - } else { - toast.error(t('errors.sync_mcp_server'), { - duration: 3000, - }); - } + handleApiError(error, 'errors.sync_mcp_server'); throw error; } }; @@ -204,17 +129,10 @@ export const getChatMessages = async (sessionId: string, page: number = 1, pageS pageSize, }, }); - return response.data; + // 处理数据,兼容直接返回和嵌套在 data 中的情况 + return response.data.data || response.data; } catch (error) { - if (axios.isAxiosError(error) && error.response?.data?.error) { - toast.error(t('errors.fetch_chat_messages'), { - duration: 3000, - }); - } else { - toast.error(t('errors.fetch_chat_messages'), { - duration: 3000, - }); - } + handleApiError(error, 'errors.fetch_chat_messages'); throw error; } }; @@ -222,17 +140,10 @@ export const getChatMessages = async (sessionId: string, page: number = 1, pageS export const getChatSessions = async () => { try { const response = await api.get('/chat/sessions'); - return response.data; + // 处理数据,兼容直接返回和嵌套在 data 中的情况 + return response.data.data || response.data; } catch (error) { - if (axios.isAxiosError(error) && error.response?.data?.error) { - toast.error(t('errors.fetch_chat_sessions'), { - duration: 3000, - }); - } else { - toast.error(t('errors.fetch_chat_sessions'), { - duration: 3000, - }); - } + handleApiError(error, 'errors.fetch_chat_sessions'); throw error; } }; @@ -249,15 +160,7 @@ export const importOpenAPI = async (file: File) => { }); return response.data; } catch (error) { - if (axios.isAxiosError(error) && error.response?.data?.error) { - toast.error(t('errors.import_openapi'), { - duration: 3000, - }); - } else { - toast.error(t('errors.import_openapi'), { - duration: 3000, - }); - } + handleApiError(error, 'errors.import_openapi'); throw error; } }; @@ -266,17 +169,10 @@ export const importOpenAPI = async (file: File) => { export const getTenants = async () => { try { const response = await api.get('/auth/tenants'); - return response.data; + // 处理数据,兼容直接返回和嵌套在 data 中的情况 + return response.data.data || response.data; } catch (error) { - if (axios.isAxiosError(error) && error.response?.data?.error) { - toast.error(t('errors.fetch_tenants'), { - duration: 3000, - }); - } else { - toast.error(t('errors.fetch_tenants'), { - duration: 3000, - }); - } + handleApiError(error, 'errors.fetch_tenants'); throw error; } }; @@ -284,17 +180,10 @@ export const getTenants = async () => { export const getTenant = async (name: string) => { try { const response = await api.get(`/auth/tenants/${name}`); - return response.data; + // 处理数据,兼容直接返回和嵌套在 data 中的情况 + return response.data.data || response.data; } catch (error) { - if (axios.isAxiosError(error) && error.response?.data?.error) { - toast.error(t('errors.fetch_tenant'), { - duration: 3000, - }); - } else { - toast.error(t('errors.fetch_tenant'), { - duration: 3000, - }); - } + handleApiError(error, 'errors.fetch_tenant'); throw error; } }; @@ -460,7 +349,8 @@ export const deleteTenant = async (name: string) => { export const getUsers = async () => { try { const response = await api.get('/auth/users'); - return response.data; + // 处理数据,兼容直接返回和嵌套在 data 中的情况 + return response.data.data || response.data; } catch (error) { toast.error(t('errors.fetch_users'), { duration: 3000, @@ -472,7 +362,8 @@ export const getUsers = async () => { export const getUser = async (username: string) => { try { const response = await api.get(`/auth/users/${username}`); - return response.data; + // 处理数据,兼容直接返回和嵌套在 data 中的情况 + return response.data.data || response.data; } catch (error) { toast.error(t('errors.fetch_user'), { duration: 3000, @@ -559,7 +450,8 @@ export const toggleUserStatus = async (username: string, isActive: boolean) => { export const getUserWithTenants = async (username: string) => { try { const response = await api.get(`/auth/users/${username}`); - return response.data; + // 处理数据,兼容直接返回和嵌套在 data 中的情况 + return response.data.data || response.data; } catch (error) { toast.error(t('errors.fetch_user'), { duration: 3000, @@ -572,7 +464,9 @@ export const getUserWithTenants = async (username: string) => { export const getUserAuthorizedTenants = async () => { try { const response = await api.get('/auth/user'); - return response.data.tenants || []; + // 处理数据,兼容直接返回和嵌套在 data 中的情况 + const data = response.data.data || response.data; + return data.tenants || []; } catch (error) { toast.error(t('errors.fetch_authorized_tenants'), { duration: 3000, diff --git a/web/src/services/websocket.ts b/web/src/services/websocket.ts index 053a8247..1bef8a7d 100644 --- a/web/src/services/websocket.ts +++ b/web/src/services/websocket.ts @@ -1,8 +1,8 @@ -import { t } from 'i18next'; -import { v4 as uuidv4 } from 'uuid'; +import {t} from 'i18next'; +import {v4 as uuidv4} from 'uuid'; -import { ToolCall } from '../types/message'; -import { toast } from '../utils/toast'; +import {ToolCall} from '../types/message'; +import {toast} from '../utils/toast'; export interface WebSocketMessage { type: 'system' | 'message' | 'stream' | 'tool_call' | 'tool_result'; @@ -68,7 +68,9 @@ export class WebSocketService { return new Promise((resolve) => { const token = window.localStorage.getItem('token'); - this.ws = new WebSocket(`${import.meta.env.VITE_WS_BASE_URL}/chat?sessionId=${this.sessionId}&token=${token}`); + // Include language parameter in WebSocket URL + const lang = localStorage.getItem('i18nextLng') || 'zh'; + this.ws = new WebSocket(`${import.meta.env.VITE_WS_BASE_URL}/chat?sessionId=${this.sessionId}&token=${token}&lang=${lang}`); this.ws.onopen = () => { resolve(); diff --git a/web/src/utils/error-handler.ts b/web/src/utils/error-handler.ts new file mode 100644 index 00000000..a601e81c --- /dev/null +++ b/web/src/utils/error-handler.ts @@ -0,0 +1,28 @@ +import axios, { AxiosError } from 'axios'; +import { toast } from './toast'; +import { t } from 'i18next'; + +/** + * Handle API errors and display user-friendly error messages + * + * @param error - The error object + * @param fallbackMessage - Default message to display if specific error information is not available + * @returns The error message that was displayed + */ +export const handleApiError = (error: unknown, fallbackMessage: string): string => { + // Handle standard error responses + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + const serverError = axiosError.response?.data as { error?: string }; + + if (serverError?.error) { + // Server error message is already i18n translated + toast.error(serverError.error, { duration: 3000 }); + return serverError.error; + } + } + + // Handle general or network errors + toast.error(t(fallbackMessage), { duration: 3000 }); + return t(fallbackMessage); +}; \ No newline at end of file