diff --git a/server/.air.toml b/server/.air.toml index 6b01b7010d..4964556f14 100644 --- a/server/.air.toml +++ b/server/.air.toml @@ -13,7 +13,7 @@ send_interrupt = false kill_delay = 1000 # ms # include file extensions -include_ext = ["go"] +include_ext = ["go", "json"] # exclude directories exclude_dir = ["e2e", "tmp"] diff --git a/server/e2e/gql_storytelling_test.go b/server/e2e/gql_storytelling_test.go index 6fb679ecf9..59ffd8d126 100644 --- a/server/e2e/gql_storytelling_test.go +++ b/server/e2e/gql_storytelling_test.go @@ -710,7 +710,7 @@ func TestStoryPageCRUD(t *testing.T) { res.Object(). Value("errors").Array(). Element(0).Object(). - ValueEqual("message", "input: updateStoryPage page not found") + ValueEqual("message", "page not found") _, _, pageID2 := createPage(e, sID, storyID, "test 2", true) _, _, pageID3 := createPage(e, sID, storyID, "test 3", false) diff --git a/server/e2e/gql_workspace_test.go b/server/e2e/gql_workspace_test.go index 4e860fd55c..58c2e58b81 100644 --- a/server/e2e/gql_workspace_test.go +++ b/server/e2e/gql_workspace_test.go @@ -41,7 +41,7 @@ func TestDeleteTeam(t *testing.T) { } o = Request(e, uId1.String(), request).Object() - o.Value("errors").Array().First().Object().Value("message").Equal("input: deleteTeam operation denied") + o.Value("errors").Array().First().Object().Value("message").Equal("operation denied") } func TestUpdateTeam(t *testing.T) { @@ -67,7 +67,7 @@ func TestUpdateTeam(t *testing.T) { Query: query, } o = Request(e, uId1.String(), request).Object() - o.Value("errors").Array().First().Object().Value("message").Equal("input: updateTeam not found") + o.Value("errors").Array().First().Object().Value("message").Equal("not found") } func TestAddMemberToTeam(t *testing.T) { @@ -93,7 +93,7 @@ func TestAddMemberToTeam(t *testing.T) { Query: query, } Request(e, uId1.String(), request).Object(). - Value("errors").Array().First().Object().Value("message").Equal("input: addMemberToTeam user already joined") + Value("errors").Array().First().Object().Value("message").Equal("user already joined") } func TestRemoveMemberFromTeam(t *testing.T) { @@ -114,7 +114,7 @@ func TestRemoveMemberFromTeam(t *testing.T) { assert.False(t, w.Members().HasUser(uId3)) o := Request(e, uId1.String(), request).Object() - o.Value("errors").Array().First().Object().Value("message").Equal("input: removeMemberFromTeam target user does not exist in the workspace") + o.Value("errors").Array().First().Object().Value("message").Equal("target user does not exist in the workspace") } func TestUpdateMemberOfTeam(t *testing.T) { @@ -128,7 +128,6 @@ func TestUpdateMemberOfTeam(t *testing.T) { Query: query, } Request(e, uId1.String(), request) - w, err = r.Workspace.FindByID(context.Background(), wId2) assert.Nil(t, err) assert.Equal(t, w.Members().User(uId3).Role, workspace.RoleWriter) @@ -138,5 +137,5 @@ func TestUpdateMemberOfTeam(t *testing.T) { Query: query, } o := Request(e, uId1.String(), request).Object() - o.Value("errors").Array().First().Object().Value("message").Equal("input: updateMemberOfTeam operation denied") + o.Value("errors").Array().First().Object().Value("message").Equal("operation denied") } diff --git a/server/internal/adapter/context.go b/server/internal/adapter/context.go index 9436e31a18..cf0851cae5 100644 --- a/server/internal/adapter/context.go +++ b/server/internal/adapter/context.go @@ -20,6 +20,7 @@ const ( contextUsecases ContextKey = "usecases" contextMockAuth ContextKey = "mockauth" contextCurrentHost ContextKey = "currenthost" + contextLang ContextKey = "lang" ) var defaultLang = language.English @@ -33,6 +34,10 @@ type AuthInfo struct { EmailVerified *bool } +func AttachLang(ctx context.Context, lang language.Tag) context.Context { + return context.WithValue(ctx, contextLang, lang) +} + func AttachUser(ctx context.Context, u *user.User) context.Context { return context.WithValue(ctx, contextUser, u) } @@ -68,17 +73,16 @@ func Lang(ctx context.Context, lang *language.Tag) string { return lang.String() } - u := User(ctx) - if u == nil { - return defaultLang.String() - } - - l := u.Lang() - if l.IsRoot() { - return defaultLang.String() + if v := ctx.Value(contextLang); v != nil { + if lang, ok := v.(language.Tag); ok { + if lang.IsRoot() { + return defaultLang.String() + } + return lang.String() + } } - return l.String() + return defaultLang.String() } func Operator(ctx context.Context) *usecase.Operator { diff --git a/server/internal/adapter/context_test.go b/server/internal/adapter/context_test.go new file mode 100644 index 0000000000..48fc70c1fb --- /dev/null +++ b/server/internal/adapter/context_test.go @@ -0,0 +1,96 @@ +package adapter + +import ( + "context" + "testing" + + "github.com/reearth/reearthx/log" + "github.com/stretchr/testify/assert" + "golang.org/x/text/language" +) + +func TestAttachLang(t *testing.T) { + t.Run("Valid language tag", func(t *testing.T) { + ctx := context.Background() + + lang := language.Japanese + newCtx := AttachLang(ctx, lang) + + storedLang := newCtx.Value(contextLang) + + assert.NotNil(t, storedLang, "Language should be stored in context") + assert.Equal(t, lang, storedLang, "Stored language should match the input") + }) + + t.Run("Default language (Und)", func(t *testing.T) { + ctx := context.Background() + + lang := language.Und + newCtx := AttachLang(ctx, lang) + + storedLang := newCtx.Value(contextLang) + + assert.NotNil(t, storedLang, "Language should be stored in context") + assert.Equal(t, lang, storedLang, "Stored language should match the input") + }) + + t.Run("Context chaining", func(t *testing.T) { + ctx := context.Background() + + lang1 := language.English + ctx1 := AttachLang(ctx, lang1) + + lang2 := language.French + ctx2 := AttachLang(ctx1, lang2) + + // confirm that the latest language is stored in the context + assert.Equal(t, lang2, ctx2.Value(contextLang), "Latest language should be stored in context") + + // old context is not affected + assert.Equal(t, lang1, ctx1.Value(contextLang), "Old context should retain its value") + }) +} + +func TestLang(t *testing.T) { + + // Default language for testing + defaultLang := language.English // or set it to whatever your defaultLang is + + t.Run("Lang is provided and valid", func(t *testing.T) { + lang := language.Japanese + result := Lang(context.Background(), &lang) + assert.Equal(t, "ja", result) + }) + + t.Run("Lang is nil, context has valid lang", func(t *testing.T) { + lang := language.French + ctx := context.WithValue(context.Background(), contextLang, lang) + result := Lang(ctx, nil) + log.Infofc(ctx, "result: %s", result) + assert.Equal(t, "fr", result) + }) + + t.Run("Lang is nil, context lang is empty", func(t *testing.T) { + ctx := context.WithValue(context.Background(), contextLang, language.Make("")) + result := Lang(ctx, nil) + assert.Equal(t, defaultLang.String(), result) + }) + + t.Run("Lang is nil, context has no lang", func(t *testing.T) { + result := Lang(context.Background(), nil) + assert.Equal(t, defaultLang.String(), result) + }) + + t.Run("Lang is root, context has no lang", func(t *testing.T) { + rootLang := language.Und + result := Lang(context.Background(), &rootLang) + assert.Equal(t, defaultLang.String(), result) + }) + + t.Run("Lang is french, context has no lang", func(t *testing.T) { + lang := language.French + result := Lang(context.Background(), &lang) + assert.Equal(t, lang.String(), result) + }) + +} diff --git a/server/internal/app/app.go b/server/internal/app/app.go index 613cb716c0..cb0c484ed4 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -13,6 +13,7 @@ import ( "github.com/labstack/echo/v4/middleware" "github.com/reearth/reearth/server/internal/adapter" http2 "github.com/reearth/reearth/server/internal/adapter/http" + "github.com/reearth/reearth/server/internal/usecase/interactor" "github.com/reearth/reearthx/appx" "github.com/reearth/reearthx/log" @@ -65,6 +66,7 @@ func initEcho(ctx context.Context, cfg *ServerConfig) *echo.Echo { log.Infof("Using mock auth for local development") wrapHandler = func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() ctx = adapter.AttachMockAuth(ctx, true) next.ServeHTTP(w, r.WithContext(ctx)) @@ -115,6 +117,8 @@ func initEcho(ctx context.Context, cfg *ServerConfig) *echo.Echo { AuthSrvUIDomain: cfg.Config.Host_Web, })) + e.Use(AttachLanguageMiddleware) + // auth srv authServer(ctx, e, &cfg.Config.AuthSrv, cfg.Repos) diff --git a/server/internal/app/graphql.go b/server/internal/app/graphql.go index 4ee7e03a21..dcc7693ae9 100644 --- a/server/internal/app/graphql.go +++ b/server/internal/app/graphql.go @@ -2,6 +2,7 @@ package app import ( "context" + "errors" "time" "github.com/99designs/gqlgen/graphql" @@ -14,7 +15,12 @@ import ( "github.com/reearth/reearth/server/internal/adapter" "github.com/reearth/reearth/server/internal/adapter/gql" "github.com/reearth/reearth/server/internal/app/config" + "github.com/reearth/reearth/server/pkg/i18n/message" + "github.com/reearth/reearth/server/pkg/verror" + "github.com/reearth/reearthx/log" + "github.com/vektah/gqlparser/v2/ast" "github.com/vektah/gqlparser/v2/gqlerror" + "golang.org/x/text/language" ) const ( @@ -24,6 +30,7 @@ const ( ) func GraphqlAPI(conf config.GraphQLConfig, dev bool) echo.HandlerFunc { + schema := gql.NewExecutableSchema(gql.Config{ Resolvers: gql.NewResolver(), }) @@ -55,15 +62,15 @@ func GraphqlAPI(conf config.GraphQLConfig, dev bool) echo.HandlerFunc { // tracing srv.Use(otelgqlgen.Middleware()) - srv.SetErrorPresenter( - // show more detailed error messgage in debug mode - func(ctx context.Context, e error) *gqlerror.Error { - if dev { - return gqlerror.ErrorPathf(graphql.GetFieldContext(ctx).Path(), "%s", e.Error()) + srv.SetErrorPresenter(func(ctx context.Context, e error) *gqlerror.Error { + defer func() { + if r := recover(); r != nil { + log.Errorfc(ctx, "panic recovered in error presenter: %v", r) + return } - return graphql.DefaultErrorPresenter(ctx, e) - }, - ) + }() + return customErrorPresenter(ctx, e, dev) + }) // only enable middlewares in dev mode if dev { @@ -82,3 +89,57 @@ func GraphqlAPI(conf config.GraphQLConfig, dev bool) echo.HandlerFunc { return nil } } + +// customErrorPresenter handles custom GraphQL error presentation by converting various error types +// into localized GraphQL errors. +func customErrorPresenter(ctx context.Context, e error, devMode bool) *gqlerror.Error { + var graphqlErr *gqlerror.Error + var vError *verror.VError + lang := adapter.Lang(ctx, nil) + + systemError := "" + if errors.As(e, &vError) { + if errMsg, ok := vError.ErrMsg[language.Make(lang)]; ok { + messageText := message.ApplyTemplate(ctx, errMsg.Message, vError.TemplateData, language.Make(lang)) + graphqlErr = &gqlerror.Error{ + Err: vError, + Message: messageText, + Extensions: map[string]interface{}{ + "code": vError.GetErrCode(), + "message": messageText, + "description": message.ApplyTemplate(ctx, errMsg.Description, vError.TemplateData, language.Make(lang)), + }, + } + } + if vError.Err != nil { + systemError = vError.Err.Error() + } + } + + if graphqlErr == nil { + graphqlErr = graphql.DefaultErrorPresenter(ctx, e) + systemError = e.Error() + } + + if graphqlErr.Extensions == nil { + graphqlErr.Extensions = make(map[string]interface{}) + } + + if devMode { + if fieldCtx := graphql.GetFieldContext(ctx); fieldCtx != nil { + graphqlErr.Path = fieldCtx.Path() + } else { + graphqlErr.Path = ast.Path{} + } + + graphqlErr.Extensions["system_error"] = systemError + } + + if systemError != "" { + log.Errorfc(ctx, "system error: %+v", e) + } + + log.Warnfc(ctx, "graphqlErr: %+v", graphqlErr) + + return graphqlErr +} diff --git a/server/internal/app/graphql_test.go b/server/internal/app/graphql_test.go new file mode 100644 index 0000000000..8799d0eb1e --- /dev/null +++ b/server/internal/app/graphql_test.go @@ -0,0 +1,88 @@ +package app + +import ( + "context" + "errors" + "testing" + + "github.com/reearth/reearth/server/internal/adapter" + "github.com/reearth/reearth/server/internal/app/i18n/message/errmsg" + "github.com/reearth/reearth/server/pkg/verror" + "github.com/stretchr/testify/assert" + "github.com/vektah/gqlparser/v2/ast" + "golang.org/x/text/language" +) + +func TestCustomErrorPresenter(t *testing.T) { + ctx := context.Background() + ctx = adapter.AttachLang(ctx, language.English) + + vErr := verror.NewVError(errmsg.ErrKeyUnknown, errmsg.ErrorMessages[errmsg.ErrKeyUnknown], nil, nil) + vErrHaveWrapped := verror.NewVError(errmsg.ErrKeyUnknown, errmsg.ErrorMessages[errmsg.ErrKeyUnknown], nil, errors.New("wrapped error")) + + t.Run("vErr with English language", func(t *testing.T) { + graphqlErr := customErrorPresenter(ctx, vErr, false) + + assert.NotNil(t, graphqlErr) + assert.Equal(t, "An unknown error occurred.", graphqlErr.Message) + assert.Equal(t, string(errmsg.ErrKeyUnknown), graphqlErr.Extensions["code"]) + assert.Equal(t, nil, graphqlErr.Extensions["system_error"]) + }) + + t.Run("vErr with Japanese language", func(t *testing.T) { + jaCtx := adapter.AttachLang(context.Background(), language.Japanese) + graphqlErr := customErrorPresenter(jaCtx, vErr, false) + + assert.NotNil(t, graphqlErr) + assert.Equal(t, "不明なエラーが発生しました。", graphqlErr.Message) + assert.Equal(t, string(errmsg.ErrKeyUnknown), graphqlErr.Extensions["code"]) + }) + + t.Run("Wrapped vErr with English language", func(t *testing.T) { + graphqlErr := customErrorPresenter(ctx, vErrHaveWrapped, false) + + assert.NotNil(t, graphqlErr) + assert.Equal(t, "An unknown error occurred.", graphqlErr.Message) + assert.Equal(t, string(errmsg.ErrKeyUnknown), graphqlErr.Extensions["code"]) + assert.Equal(t, nil, graphqlErr.Extensions["system_error"]) + }) + + t.Run("Fallback to default GraphQL error", func(t *testing.T) { + defaultErr := errors.New("default error") + graphqlErr := customErrorPresenter(ctx, defaultErr, false) + + assert.NotNil(t, graphqlErr) + assert.Equal(t, "default error", graphqlErr.Message) + assert.Equal(t, nil, graphqlErr.Extensions["system_error"]) + }) + + t.Run("Development mode with AppError", func(t *testing.T) { + graphqlErr := customErrorPresenter(ctx, vErr, true) + + assert.NotNil(t, graphqlErr) + assert.Equal(t, ast.Path{}, graphqlErr.Path) + assert.Equal(t, "An unknown error occurred.", graphqlErr.Message) + assert.Equal(t, string(errmsg.ErrKeyUnknown), graphqlErr.Extensions["code"]) + assert.Equal(t, "", graphqlErr.Extensions["system_error"]) + + }) + + t.Run("Development mode with default error", func(t *testing.T) { + defaultErr := errors.New("default error") + graphqlErr := customErrorPresenter(ctx, defaultErr, true) + + assert.NotNil(t, graphqlErr) + assert.Equal(t, "default error", graphqlErr.Message) + assert.Equal(t, defaultErr.Error(), graphqlErr.Extensions["system_error"]) + }) + + t.Run("Development mode with Wrapped vErr ", func(t *testing.T) { + graphqlErr := customErrorPresenter(ctx, vErrHaveWrapped, true) + + assert.NotNil(t, graphqlErr) + assert.Equal(t, "An unknown error occurred.", graphqlErr.Message) + assert.Equal(t, string(errmsg.ErrKeyUnknown), graphqlErr.Extensions["code"]) + assert.Equal(t, "wrapped error", graphqlErr.Extensions["system_error"]) + }) + +} diff --git a/server/internal/app/i18n/locales/errmsg/en.json b/server/internal/app/i18n/locales/errmsg/en.json new file mode 100644 index 0000000000..6fa89ac2fa --- /dev/null +++ b/server/internal/app/i18n/locales/errmsg/en.json @@ -0,0 +1,13 @@ +{ + "unknown": { + "message": "An unknown error occurred.", + "description": "The cause of the error cannot be determined." + }, + "repo": { + "resource_not_found": { + "message": "Resource not found.", + "description": "The resource does not exist or you do not have access." + } + } +} + diff --git a/server/internal/app/i18n/locales/errmsg/ja.json b/server/internal/app/i18n/locales/errmsg/ja.json new file mode 100644 index 0000000000..6caf2ae27d --- /dev/null +++ b/server/internal/app/i18n/locales/errmsg/ja.json @@ -0,0 +1,13 @@ +{ + "unknown": { + "message": "不明なエラーが発生しました。", + "description": "エラーが発生した原因を特定できません。" + }, + "repo": { + "resource_not_found": { + "message": "リソースが見つかりません。", + "description": "リソースが存在しないか、アクセス権限がありません。" + } + } +} + diff --git a/server/internal/app/i18n/message/errmsg/errmsg_generated.go b/server/internal/app/i18n/message/errmsg/errmsg_generated.go new file mode 100644 index 0000000000..92251cedb8 --- /dev/null +++ b/server/internal/app/i18n/message/errmsg/errmsg_generated.go @@ -0,0 +1,35 @@ +// Code generated by go generate; DO NOT EDIT. +package errmsg + +import ( + "golang.org/x/text/language" + "github.com/reearth/reearth/server/pkg/i18n/message" +) + +const ( + ErrKeyUnknown message.ErrKey = "unknown" + ErrKeyRepoResourceNotFound message.ErrKey = "repo.resource_not_found" +) + +var ErrorMessages = map[message.ErrKey]map[language.Tag]message.ErrorMessage{ + ErrKeyRepoResourceNotFound: { + language.English: { + Message: "Resource not found.", + Description: "The resource does not exist or you do not have access.", + }, + language.Japanese: { + Message: "リソースが見つかりません。", + Description: "リソースが存在しないか、アクセス権限がありません。", + }, + }, + ErrKeyUnknown: { + language.English: { + Message: "An unknown error occurred.", + Description: "The cause of the error cannot be determined.", + }, + language.Japanese: { + Message: "不明なエラーが発生しました。", + Description: "エラーが発生した原因を特定できません。", + }, + }, +} diff --git a/server/internal/app/i18n/message/messageg.go b/server/internal/app/i18n/message/messageg.go new file mode 100644 index 0000000000..17c8ddf726 --- /dev/null +++ b/server/internal/app/i18n/message/messageg.go @@ -0,0 +1,4 @@ +// for generated errmsg/errmsg_generated.go +//go:generate go run ../../../../pkg/i18n/gen/errmsg/gen.go + +package message diff --git a/server/internal/app/lang.go b/server/internal/app/lang.go new file mode 100644 index 0000000000..e8076983d4 --- /dev/null +++ b/server/internal/app/lang.go @@ -0,0 +1,34 @@ +package app + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/reearth/reearth/server/internal/adapter" + "golang.org/x/text/language" +) + +func LanguageExtractor(req *http.Request) language.Tag { + lang := req.Header.Get("lang") + + u := adapter.User(req.Context()) + if u != nil && !u.Lang().IsRoot() { + lang = u.Lang().String() + } + + tag, err := language.Parse(lang) + if err != nil { + return language.English + } + + return tag +} + +func AttachLanguageMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + lang := LanguageExtractor(c.Request()) + ctx := adapter.AttachLang(c.Request().Context(), lang) + c.SetRequest(c.Request().WithContext(ctx)) + return next(c) + } +} diff --git a/server/internal/app/lang_test.go b/server/internal/app/lang_test.go new file mode 100644 index 0000000000..fe4b0d8b66 --- /dev/null +++ b/server/internal/app/lang_test.go @@ -0,0 +1,83 @@ +package app_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + "github.com/reearth/reearth/server/internal/adapter" + "github.com/reearth/reearth/server/internal/app" + "github.com/reearth/reearthx/account/accountdomain/user" + "golang.org/x/text/language" + + "github.com/stretchr/testify/assert" +) + +func TestLanguageExtractor(t *testing.T) { + // Mock user with a language + tests := []struct { + name string + headerLang string + userLang language.Tag + expected language.Tag + }{ + { + name: "User language overrides browser language", + headerLang: "fr", + userLang: language.English, + expected: language.English, + }, + { + name: "No user language, use browser language", + headerLang: "fr", + userLang: language.Und, + expected: language.French, + }, + { + name: "No browser language or user language is und, fallback to default", + headerLang: "", + userLang: language.Und, + expected: language.English, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Mock request and context + req, _ := http.NewRequest("GET", "/", nil) + req.Header.Set("lang", tt.headerLang) + + u := &user.User{} + u.UpdateLang(tt.userLang) + ctx := adapter.AttachUser(context.Background(), u) + req = req.WithContext(ctx) + + lang := app.LanguageExtractor(req) + assert.Equal(t, tt.expected, lang) + }) + } +} + +func TestAttachLanguageMiddleware(t *testing.T) { + e := echo.New() + e.Use(app.AttachLanguageMiddleware) + + e.GET("/", func(c echo.Context) error { + // get lang from context + lang := adapter.Lang(c.Request().Context(), nil) + // include lang in response + return c.String(http.StatusOK, lang) + }) + + t.Run("Middleware attaches correct language", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("lang", "fr") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + // check lang in response + assert.Equal(t, "fr", rec.Body.String()) + }) +} diff --git a/server/internal/infrastructure/mongo/project.go b/server/internal/infrastructure/mongo/project.go index aea7186ddf..d80793fbc5 100644 --- a/server/internal/infrastructure/mongo/project.go +++ b/server/internal/infrastructure/mongo/project.go @@ -15,6 +15,7 @@ import ( "github.com/reearth/reearthx/usecasex" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" ) @@ -47,9 +48,18 @@ func (r *Project) Filtered(f repo.WorkspaceFilter) repo.Project { } func (r *Project) FindByID(ctx context.Context, id id.ProjectID) (*project.Project, error) { - return r.findOne(ctx, bson.M{ + prj, err := r.findOne(ctx, bson.M{ "id": id.String(), }, true) + + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, repo.ErrResourceNotFound + } + return nil, err + } + + return prj, nil } func (r *Project) FindByScene(ctx context.Context, id id.SceneID) (*project.Project, error) { diff --git a/server/internal/usecase/interactor/project.go b/server/internal/usecase/interactor/project.go index dad27e0752..51836ac657 100644 --- a/server/internal/usecase/interactor/project.go +++ b/server/internal/usecase/interactor/project.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/99designs/gqlgen/graphql" "github.com/reearth/reearth/server/internal/adapter" "github.com/reearth/reearth/server/internal/adapter/gql/gqlmodel" jsonmodel "github.com/reearth/reearth/server/internal/adapter/gql/gqlmodel" @@ -203,7 +204,7 @@ func (i *Project) Update(ctx context.Context, p interfaces.UpdateProjectParam, o if p.Alias != nil { if err := prj.UpdateAlias(*p.Alias); err != nil { - return nil, err + graphql.AddError(ctx, err) } } @@ -280,6 +281,10 @@ func (i *Project) Update(ctx context.Context, p interfaces.UpdateProjectParam, o } } + if len(graphql.GetErrors(ctx)) > 0 { + return prj, nil + } + currentTime := time.Now().UTC() prj.SetUpdatedAt(currentTime) diff --git a/server/internal/usecase/repo/container.go b/server/internal/usecase/repo/container.go index 53c68c01da..71b8ec1194 100644 --- a/server/internal/usecase/repo/container.go +++ b/server/internal/usecase/repo/container.go @@ -3,9 +3,11 @@ package repo import ( "errors" + "github.com/reearth/reearth/server/internal/app/i18n/message/errmsg" "github.com/reearth/reearth/server/internal/usecase" "github.com/reearth/reearth/server/pkg/plugin" "github.com/reearth/reearth/server/pkg/scene" + "github.com/reearth/reearth/server/pkg/verror" "github.com/reearth/reearthx/account/accountdomain" "github.com/reearth/reearthx/account/accountusecase/accountrepo" "github.com/reearth/reearthx/authserver" @@ -13,7 +15,8 @@ import ( ) var ( - ErrOperationDenied = errors.New("operation denied") + ErrOperationDenied = errors.New("operation denied") + ErrResourceNotFound = verror.NewVError(errmsg.ErrKeyRepoResourceNotFound, errmsg.ErrorMessages[errmsg.ErrKeyRepoResourceNotFound], nil, nil) ) type Container struct { diff --git a/server/internal/usecase/repo/container_errkey_test.go b/server/internal/usecase/repo/container_errkey_test.go new file mode 100644 index 0000000000..d68c27a53a --- /dev/null +++ b/server/internal/usecase/repo/container_errkey_test.go @@ -0,0 +1,19 @@ +package repo + +import ( + "context" + "testing" + + "github.com/reearth/reearth/server/pkg/i18n" + "github.com/reearth/reearth/server/pkg/i18n/message" + "github.com/stretchr/testify/assert" +) + +func TestRepoErrResourceNotFound(t *testing.T) { + ctx := context.Background() + vErr := ErrResourceNotFound + for _, locale := range i18n.LocaleTypes() { + assert.NotEqual(t, "", message.ApplyTemplate(ctx, vErr.ErrMsg[locale].Message, vErr.TemplateData, locale)) + assert.NotEqual(t, "", message.ApplyTemplate(ctx, vErr.ErrMsg[locale].Description, vErr.TemplateData, locale)) + } +} diff --git a/server/pkg/i18n/gen/entitymsg/gen.go b/server/pkg/i18n/gen/entitymsg/gen.go new file mode 100644 index 0000000000..cfa4913280 --- /dev/null +++ b/server/pkg/i18n/gen/entitymsg/gen.go @@ -0,0 +1,192 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "text/template" + + "golang.org/x/text/language" + "golang.org/x/text/language/display" +) + +func main() { + inputDir := "../locales/entitymsg" + outputFile := "./entitymsg/entitymsg_generated.go" + + files, err := collectJSONFiles(inputDir) + if err != nil { + panic(fmt.Errorf("failed to collect JSON files: %w", err)) + } + + translations, err := loadEntityMessages(files) + if err != nil { + panic(fmt.Errorf("failed to load entity messages: %w", err)) + } + + code := generateEntityCode(translations) + + if err := os.WriteFile(outputFile, []byte(code), 0644); err != nil { + panic(fmt.Errorf("failed to write output file: %w", err)) + } + + fmt.Println("Code generated:", outputFile) +} + +// collectJSONFiles collects all JSON files from the given directory. +func collectJSONFiles(dir string) ([]string, error) { + var files []string + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && filepath.Ext(d.Name()) == ".json" { + files = append(files, path) + } + return nil + }) + return files, err +} + +// loadEntityMessages loads and flattens nested entity message JSON files into a flat map. +func loadEntityMessages(files []string) (map[string]map[language.Tag]string, error) { + messages := make(map[string]map[language.Tag]string) + for _, file := range files { + lang := language.Make(extractLanguageCode(file)) + + data, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", file, err) + } + + var nested map[string]interface{} + if err := json.Unmarshal(data, &nested); err != nil { + return nil, fmt.Errorf("failed to parse file %s: %w", file, err) + } + + flatMessages := flattenJSON(nested, "") + + for key, value := range flatMessages { + if _, exists := messages[key]; !exists { + messages[key] = make(map[language.Tag]string) + } + messages[key][lang] = value + } + } + return messages, nil +} + +// extractLanguageCode extracts the language code from a file name (e.g., en.json -> en). +func extractLanguageCode(fileName string) string { + base := filepath.Base(fileName) + return strings.TrimSuffix(base, filepath.Ext(base)) +} + +// flattenJSON flattens a nested JSON object into a flat map with dot-delimited keys. +func flattenJSON(data map[string]interface{}, parentKey string) map[string]string { + flat := make(map[string]string) + + for key, value := range data { + fullKey := key + if parentKey != "" { + fullKey = parentKey + "." + key + } + + switch v := value.(type) { + case string: + flat[fullKey] = v + case map[string]interface{}: + nestedFlat := flattenJSON(v, fullKey) + for k, val := range nestedFlat { + flat[k] = val + } + } + } + + return flat +} + +// generateEntityCode generates Go code for the entity messages and their constants. +func generateEntityCode(messages map[string]map[language.Tag]string) string { + var buf bytes.Buffer + + funcMap := template.FuncMap{ + "toCamelCase": toCamelCase, + "tagToString": func(tag language.Tag) string { + return fmt.Sprintf("language.%s", display.English.Languages().Name(language.Make(tag.String()))) + }, + } + + tmpl := template.Must(template.New("entitymsg").Funcs(funcMap).Parse(`// Code generated by go generate; DO NOT EDIT. +package entitymsg + +import ( + "golang.org/x/text/language" + "github.com/reearth/reearth/server/pkg/i18n/message" +) + +const ( +{{- range $key := .Keys }} + EntityKey{{ toCamelCase $key }} message.EntityKey = "{{$key}}" +{{- end }} +) + +var EntityMessages = map[message.EntityKey]map[language.Tag]string{ +{{- range $key, $langs := .Messages }} + EntityKey{{ toCamelCase $key }}: { + {{- range $lang, $msg := $langs }} + {{ tagToString $lang }}: "{{$msg}}", + {{- end }} + }, +{{- end }} +} + +func GetLocalizedEntityMessage(key message.EntityKey, locale language.Tag) string { + if msg, ok := EntityMessages[key][locale]; ok { + return msg + } + return "" +} +`)) + + keys := extractSortedKeys(messages) + data := map[string]interface{}{ + "Messages": messages, + "Keys": keys, + } + if err := tmpl.Execute(&buf, data); err != nil { + panic(fmt.Errorf("failed to execute template: %w", err)) + } + + return buf.String() +} + +// extractSortedKeys extracts and sorts the keys from the messages map. +func extractSortedKeys(messages map[string]map[language.Tag]string) []string { + keys := make([]string, 0, len(messages)) + for key := range messages { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// toCamelCase converts dot-separated keys (and handles snake_case) to CamelCase. +func toCamelCase(input string) string { + parts := strings.Split(input, ".") + for i, part := range parts { + subParts := strings.Split(part, "_") + for j, subPart := range subParts { + subParts[j] = strings.Title(subPart) + } + parts[i] = strings.Join(subParts, "") + } + return strings.Join(parts, "") +} diff --git a/server/pkg/i18n/gen/errmsg/gen.go b/server/pkg/i18n/gen/errmsg/gen.go new file mode 100644 index 0000000000..8355087b06 --- /dev/null +++ b/server/pkg/i18n/gen/errmsg/gen.go @@ -0,0 +1,215 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/reearth/reearth/server/pkg/i18n/message" + "golang.org/x/text/language" + "golang.org/x/text/language/display" +) + +// Translations represents the structure for the output map. +type Translations map[string]map[language.Tag]message.ErrorMessage + +func main() { + inputDir := "../locales/errmsg" + outputFile := "./errmsg/errmsg_generated.go" + + files, err := collectJSONFiles(inputDir) + if err != nil { + panic(fmt.Errorf("failed to collect JSON files: %w", err)) + } + + translations, keys, err := loadMessages(files) + if err != nil { + panic(fmt.Errorf("failed to load messages: %w", err)) + } + + code := generateCode(translations, keys) + + if err := os.WriteFile(outputFile, []byte(code), 0644); err != nil { + panic(fmt.Errorf("failed to write output file: %w", err)) + } + + fmt.Println("Code generated:", outputFile) +} + +// collectJSONFiles collects all JSON files from the given directory. +func collectJSONFiles(dir string) ([]string, error) { + var files []string + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && filepath.Ext(d.Name()) == ".json" { + files = append(files, path) + } + return nil + }) + return files, err +} + +// loadMessages loads and flattens nested JSON files into the desired structure. +func loadMessages(files []string) (Translations, []string, error) { + messages := make(Translations) + var keys []string + for _, file := range files { + lang := extractLanguageCode(file) + tag := language.Make(lang) + + data, err := os.ReadFile(file) + if err != nil { + return nil, nil, fmt.Errorf("failed to read file %s: %w", file, err) + } + + var nested map[string]interface{} + if err := json.Unmarshal(data, &nested); err != nil { + return nil, nil, fmt.Errorf("failed to parse file %s: %w", file, err) + } + + flattenAndGroupMessages(messages, nested, tag, "") + if lang == "en" { + for key := range collectMessageKeys(nested, "") { + keys = append(keys, key) + } + } + } + return messages, keys, nil +} + +// extractLanguageCode extracts the language code from a file name (e.g., en.json -> en). +func extractLanguageCode(fileName string) string { + base := filepath.Base(fileName) + return strings.TrimSuffix(base, filepath.Ext(base)) +} + +// flattenAndGroupMessages flattens nested JSON and groups them by key and language. +func flattenAndGroupMessages(messages Translations, data map[string]interface{}, tag language.Tag, parentKey string) { + for key, value := range data { + fullKey := key + if parentKey != "" { + fullKey = parentKey + "." + key + } + + switch v := value.(type) { + case map[string]interface{}: + // Check if this map is a message + if isMessage(v) { + if _, exists := messages[fullKey]; !exists { + messages[fullKey] = make(map[language.Tag]message.ErrorMessage) + } + messages[fullKey][tag] = message.ErrorMessage{ + Message: fmt.Sprintf("%v", v["message"]), + Description: fmt.Sprintf("%v", v["description"]), + } + } else { + // Recurse into nested maps + flattenAndGroupMessages(messages, v, tag, fullKey) + } + } + } +} + +// collectMessageKeys collects keys for error messages from nested JSON. +func collectMessageKeys(data map[string]interface{}, parentKey string) map[string]struct{} { + keys := make(map[string]struct{}) + for key, value := range data { + fullKey := key + if parentKey != "" { + fullKey = parentKey + "." + key + } + + switch v := value.(type) { + case map[string]interface{}: + // Check if this map is a message + if isMessage(v) { + keys[fullKey] = struct{}{} + } else { + // Recurse into nested maps + for k := range collectMessageKeys(v, fullKey) { + keys[k] = struct{}{} + } + } + } + } + return keys +} + +// isMessage checks if the map contains keys required for a Message. +func isMessage(data map[string]interface{}) bool { + _, hasMessage := data["message"] + _, hasDescription := data["description"] + return hasMessage && hasDescription +} + +// generateCode generates Go code for the messages and keys. +func generateCode(messages Translations, keys []string) string { + var buf bytes.Buffer + + funcMap := template.FuncMap{ + "toCamelCase": toCamelCase, + "tagToString": func(tag language.Tag) string { + return fmt.Sprintf("language.%s", display.English.Languages().Name(language.Make(tag.String()))) + }, + } + + tmpl := template.Must(template.New("messages").Funcs(funcMap).Parse(`// Code generated by go generate; DO NOT EDIT. +package errmsg + +import ( + "golang.org/x/text/language" + "github.com/reearth/reearth/server/pkg/i18n/message" +) + +const ( +{{- range $key := .Keys }} + ErrKey{{ toCamelCase $key }} message.ErrKey = "{{$key}}" +{{- end }} +) + +var ErrorMessages = map[message.ErrKey]map[language.Tag]message.ErrorMessage{ +{{- range $key, $langs := .Messages }} + ErrKey{{ toCamelCase $key }}: { + {{- range $tag, $msg := $langs }} + {{ tagToString $tag }}: { + Message: "{{$msg.Message}}", + Description: "{{$msg.Description}}", + }, + {{- end }} + }, +{{- end }} +} +`)) + + data := map[string]interface{}{ + "Messages": messages, + "Keys": keys, + } + if err := tmpl.Execute(&buf, data); err != nil { + panic(fmt.Errorf("failed to execute template: %w", err)) + } + + return buf.String() +} + +// toCamelCase converts dot-separated keys (and handles snake_case) to CamelCase. +func toCamelCase(input string) string { + parts := strings.Split(input, ".") + for i, part := range parts { + subParts := strings.Split(part, "_") + for j, subPart := range subParts { + subParts[j] = strings.Title(subPart) + } + parts[i] = strings.Join(subParts, "") + } + return strings.Join(parts, "") +} diff --git a/server/pkg/i18n/locales.go b/server/pkg/i18n/locales.go new file mode 100644 index 0000000000..82f36a67c0 --- /dev/null +++ b/server/pkg/i18n/locales.go @@ -0,0 +1,9 @@ +package i18n + +import ( + "golang.org/x/text/language" +) + +func LocaleTypes() []language.Tag { + return []language.Tag{language.English, language.Japanese} +} diff --git a/server/pkg/i18n/locales/entitymsg/en.json b/server/pkg/i18n/locales/entitymsg/en.json new file mode 100644 index 0000000000..27fb2f6797 --- /dev/null +++ b/server/pkg/i18n/locales/entitymsg/en.json @@ -0,0 +1,9 @@ +{ + "pkg": { + "project": { + "alias": { + "allowed_chars": "alphanumeric, underscore, hyphen" + } + } + } +} \ No newline at end of file diff --git a/server/pkg/i18n/locales/entitymsg/ja.json b/server/pkg/i18n/locales/entitymsg/ja.json new file mode 100644 index 0000000000..b4fef4de34 --- /dev/null +++ b/server/pkg/i18n/locales/entitymsg/ja.json @@ -0,0 +1,9 @@ +{ + "pkg": { + "project": { + "alias": { + "allowed_chars": "英数字、アンダースコア、ハイフン" + } + } + } +} \ No newline at end of file diff --git a/server/pkg/i18n/locales/errmsg/en.json b/server/pkg/i18n/locales/errmsg/en.json new file mode 100644 index 0000000000..aa679477f4 --- /dev/null +++ b/server/pkg/i18n/locales/errmsg/en.json @@ -0,0 +1,10 @@ +{ + "pkg": { + "project": { + "invalid_alias": { + "message": "Invalid alias name: {{.aliasName}}", + "description": "The alias '{{.aliasName}}' must be {{.minLength}}-{{.maxLength}} characters long and can only contain {{.allowedChars}}." + } + } + } +} \ No newline at end of file diff --git a/server/pkg/i18n/locales/errmsg/ja.json b/server/pkg/i18n/locales/errmsg/ja.json new file mode 100644 index 0000000000..0002d7591f --- /dev/null +++ b/server/pkg/i18n/locales/errmsg/ja.json @@ -0,0 +1,10 @@ +{ + "pkg": { + "project": { + "invalid_alias": { + "message": "不正なエイリアス名です: {{.aliasName}}", + "description": "エイリアス名は{{.minLength}}-{{.maxLength}}文字で、{{.allowedChars}}のみ使用できます。" + } + } + } + } \ No newline at end of file diff --git a/server/pkg/i18n/message/entitymsg/entitymsg_generated.go b/server/pkg/i18n/message/entitymsg/entitymsg_generated.go new file mode 100644 index 0000000000..814c3b85e7 --- /dev/null +++ b/server/pkg/i18n/message/entitymsg/entitymsg_generated.go @@ -0,0 +1,25 @@ +// Code generated by go generate; DO NOT EDIT. +package entitymsg + +import ( + "golang.org/x/text/language" + "github.com/reearth/reearth/server/pkg/i18n/message" +) + +const ( + EntityKeyPkgProjectAliasAllowedChars message.EntityKey = "pkg.project.alias.allowed_chars" +) + +var EntityMessages = map[message.EntityKey]map[language.Tag]string{ + EntityKeyPkgProjectAliasAllowedChars: { + language.English: "alphanumeric, underscore, hyphen", + language.Japanese: "英数字、アンダースコア、ハイフン", + }, +} + +func GetLocalizedEntityMessage(key message.EntityKey, locale language.Tag) string { + if msg, ok := EntityMessages[key][locale]; ok { + return msg + } + return "" +} diff --git a/server/pkg/i18n/message/errmsg/errmsg_generated.go b/server/pkg/i18n/message/errmsg/errmsg_generated.go new file mode 100644 index 0000000000..40df762bb7 --- /dev/null +++ b/server/pkg/i18n/message/errmsg/errmsg_generated.go @@ -0,0 +1,24 @@ +// Code generated by go generate; DO NOT EDIT. +package errmsg + +import ( + "golang.org/x/text/language" + "github.com/reearth/reearth/server/pkg/i18n/message" +) + +const ( + ErrKeyPkgProjectInvalidAlias message.ErrKey = "pkg.project.invalid_alias" +) + +var ErrorMessages = map[message.ErrKey]map[language.Tag]message.ErrorMessage{ + ErrKeyPkgProjectInvalidAlias: { + language.English: { + Message: "Invalid alias name: {{.aliasName}}", + Description: "The alias '{{.aliasName}}' must be {{.minLength}}-{{.maxLength}} characters long and can only contain {{.allowedChars}}.", + }, + language.Japanese: { + Message: "不正なエイリアス名です: {{.aliasName}}", + Description: "エイリアス名は{{.minLength}}-{{.maxLength}}文字で、{{.allowedChars}}のみ使用できます。", + }, + }, +} diff --git a/server/pkg/i18n/message/message.go b/server/pkg/i18n/message/message.go new file mode 100644 index 0000000000..07b3668206 --- /dev/null +++ b/server/pkg/i18n/message/message.go @@ -0,0 +1,57 @@ +//go:generate go run ../gen/entitymsg/gen.go +//go:generate go run ../gen/errmsg/gen.go +package message + +import ( + "bytes" + "context" + "html/template" + + "github.com/reearth/reearth/server/pkg/i18n" + "github.com/reearth/reearthx/log" + "github.com/samber/lo" + "golang.org/x/text/language" +) + +type EntityKey string +type EntityMessage map[language.Tag]string + +type ErrKey string +type ErrorMessage struct { + Message string + Description string +} + +// ApplyTemplate applies template data to the given template string. +func ApplyTemplate(ctx context.Context, tmpl string, data map[language.Tag]map[string]interface{}, locale language.Tag) string { + processedData := make(map[string]interface{}) + + for key, value := range data[locale] { + if fn, ok := value.(func(language.Tag) string); ok { + processedData[key] = fn(locale) + } else { + processedData[key] = value + } + } + + t, err := template.New("message").Option("missingkey=error").Parse(tmpl) + if err != nil { + log.Warnfc(ctx, "failed to parse template: %s", err) + return "" + } + + var result bytes.Buffer + if err := t.Execute(&result, processedData); err != nil { + log.Warnfc(ctx, "failed to execute template: %s", err) + return "" + } + + return result.String() +} + +// MultiLocaleTemplateData creates a map of template data for multiple locales. +func MultiLocaleTemplateData(data map[string]interface{}) map[language.Tag]map[string]interface{} { + return lo.SliceToMap(i18n.LocaleTypes(), func(locale language.Tag) (language.Tag, map[string]interface{}) { + return locale, data + }) +} diff --git a/server/pkg/project/project.go b/server/pkg/project/project.go index 40ad047f47..e4395949e4 100644 --- a/server/pkg/project/project.go +++ b/server/pkg/project/project.go @@ -1,17 +1,30 @@ package project import ( - "errors" "net/url" "regexp" "time" + "github.com/reearth/reearth/server/pkg/i18n/message" + "github.com/reearth/reearth/server/pkg/i18n/message/entitymsg" + "github.com/reearth/reearth/server/pkg/i18n/message/errmsg" + "github.com/reearth/reearth/server/pkg/verror" "github.com/reearth/reearth/server/pkg/visualizer" + "golang.org/x/text/language" ) var ( - ErrInvalidAlias error = errors.New("invalid alias") - aliasRegexp = regexp.MustCompile("^[a-zA-Z0-9_-]{5,32}$") + ErrInvalidAlias = verror.NewVError( + errmsg.ErrKeyPkgProjectInvalidAlias, + errmsg.ErrorMessages[errmsg.ErrKeyPkgProjectInvalidAlias], + message.MultiLocaleTemplateData(map[string]interface{}{ + "minLength": 5, + "maxLength": 32, + "allowedChars": func(locale language.Tag) string { + return entitymsg.GetLocalizedEntityMessage(entitymsg.EntityKeyPkgProjectAliasAllowedChars, locale) + }, + }), nil) + aliasRegexp = regexp.MustCompile("^[a-zA-Z0-9_-]{5,32}$") ) type Project struct { @@ -200,7 +213,7 @@ func (p *Project) UpdateAlias(alias string) error { if CheckAliasPattern(alias) { p.alias = alias } else { - return ErrInvalidAlias + return ErrInvalidAlias.AddTemplateData("aliasName", alias) } return nil } diff --git a/server/pkg/project/project_errkey_test.go b/server/pkg/project/project_errkey_test.go new file mode 100644 index 0000000000..c43ac6f039 --- /dev/null +++ b/server/pkg/project/project_errkey_test.go @@ -0,0 +1,19 @@ +package project + +import ( + "context" + "testing" + + "github.com/reearth/reearth/server/pkg/i18n" + "github.com/reearth/reearth/server/pkg/i18n/message" + "github.com/stretchr/testify/assert" +) + +func TestErrInvalidAlias(t *testing.T) { + ctx := context.Background() + vErr := ErrInvalidAlias.AddTemplateData("aliasName", "test") + for _, locale := range i18n.LocaleTypes() { + assert.NotEqual(t, "", message.ApplyTemplate(ctx, vErr.ErrMsg[locale].Message, vErr.TemplateData, locale)) + assert.NotEqual(t, "", message.ApplyTemplate(ctx, vErr.ErrMsg[locale].Description, vErr.TemplateData, locale)) + } +} diff --git a/server/pkg/verror/verror.go b/server/pkg/verror/verror.go new file mode 100644 index 0000000000..5cddfc83e1 --- /dev/null +++ b/server/pkg/verror/verror.go @@ -0,0 +1,70 @@ +package verror + +import ( + "strings" + + "github.com/reearth/reearth/server/pkg/i18n" + "github.com/reearth/reearth/server/pkg/i18n/message" + "golang.org/x/text/language" +) + +type VError struct { + Key message.ErrKey + ErrMsg map[language.Tag]message.ErrorMessage + TemplateData map[language.Tag]map[string]interface{} + Err error +} + +func (e *VError) GetErrCode() string { + parts := strings.Split(string(e.Key), ".") + if len(parts) == 0 { + return string(e.Key) + } + return parts[len(parts)-1] +} + +// NewVError creates a new VError with the given key and error. +func NewVError( + key message.ErrKey, + errMsg map[language.Tag]message.ErrorMessage, + templateData map[language.Tag]map[string]interface{}, + err error, +) *VError { + return &VError{ + Key: key, + ErrMsg: errMsg, + TemplateData: templateData, + Err: err, + } +} + +func (e *VError) AddTemplateData(key string, value interface{}) *VError { + clone := &VError{ + Key: e.Key, + ErrMsg: e.ErrMsg, + TemplateData: e.TemplateData, + Err: e.Err, + } + if clone.TemplateData == nil { + clone.TemplateData = make(map[language.Tag]map[string]interface{}) + } + for _, locale := range i18n.LocaleTypes() { + if _, ok := e.TemplateData[locale]; !ok { + clone.TemplateData[locale] = make(map[string]interface{}) + } + clone.TemplateData[locale][key] = value + } + + return clone +} + +func (e *VError) Error() string { + if e.Err != nil { + return e.Err.Error() + } + return "" +} + +func (e *VError) Unwrap() error { + return e.Err +} diff --git a/server/pkg/verror/verror_test.go b/server/pkg/verror/verror_test.go new file mode 100644 index 0000000000..6909d60cce --- /dev/null +++ b/server/pkg/verror/verror_test.go @@ -0,0 +1,108 @@ +package verror_test + +import ( + "context" + "errors" + "testing" + + "github.com/reearth/reearth/server/pkg/i18n/message" + "github.com/reearth/reearth/server/pkg/i18n/message/errmsg" + "github.com/reearth/reearth/server/pkg/verror" + "github.com/stretchr/testify/assert" + "golang.org/x/text/language" +) + +func TestNewVError(t *testing.T) { + errmsg.ErrorMessages = map[message.ErrKey]map[language.Tag]message.ErrorMessage{ + errmsg.ErrKeyPkgProjectInvalidAlias: { + language.English: { + Message: "Invalid alias name: {{.aliasName}}", + Description: "The alias '{{.aliasName}}' must be {{.minLength}}-{{.maxLength}} characters long.", + }, + }, + } + + templateData := map[language.Tag]map[string]interface{}{ + language.English: { + "aliasName": "test_alias", + "minLength": 5, + "maxLength": 32, + }, + } + + err := errors.New("underlying error") + ve := verror.NewVError(errmsg.ErrKeyPkgProjectInvalidAlias, errmsg.ErrorMessages[errmsg.ErrKeyPkgProjectInvalidAlias], templateData, err) + + assert.Equal(t, "invalid_alias", ve.GetErrCode()) + assert.Equal(t, errmsg.ErrorMessages["pkg.project.invalid_alias"], ve.ErrMsg) + assert.Equal(t, templateData, ve.TemplateData) + assert.Equal(t, err, ve.Err) +} + +func TestAddTemplateData(t *testing.T) { + ve := verror.NewVError(errmsg.ErrKeyPkgProjectInvalidAlias, errmsg.ErrorMessages[errmsg.ErrKeyPkgProjectInvalidAlias], nil, nil) + + ve = ve.AddTemplateData("key1", "value1") + ve = ve.AddTemplateData("key2", 123) + + expectedData := map[language.Tag]map[string]interface{}{ + language.English: { + "key1": "value1", + "key2": 123, + }, + language.Japanese: { + "key1": "value1", + "key2": 123, + }, + } + assert.Equal(t, expectedData, ve.TemplateData) +} + +func TestApplyTemplate(t *testing.T) { + templateString := "Invalid alias name: {{.aliasName}}, must be between {{.minLength}} and {{.maxLength}} characters." + data := map[language.Tag]map[string]interface{}{ + language.English: { + "aliasName": "test_alias", + "minLength": 5, + "maxLength": 32, + }, + } + + ctx := context.Background() + result := message.ApplyTemplate(ctx, templateString, data, language.English) + expected := "Invalid alias name: test_alias, must be between 5 and 32 characters." + assert.Equal(t, expected, result) +} + +func TestError(t *testing.T) { + en := language.English + errmsg.ErrorMessages = map[message.ErrKey]map[language.Tag]message.ErrorMessage{ + errmsg.ErrKeyPkgProjectInvalidAlias: { + en: { + Message: "Invalid alias name: {{.aliasName}}", + Description: "The alias '{{.aliasName}}' must be {{.minLength}}-{{.maxLength}} characters long.", + }, + }, + } + + templateData := map[language.Tag]map[string]interface{}{ + en: { + "aliasName": "test_alias", + "minLength": 5, + "maxLength": 32, + }, + } + + ve := verror.NewVError(errmsg.ErrKeyPkgProjectInvalidAlias, errmsg.ErrorMessages[errmsg.ErrKeyPkgProjectInvalidAlias], templateData, nil) + + ctx := context.Background() + msg := message.ApplyTemplate(ctx, ve.ErrMsg[en].Message, templateData, en) + assert.Equal(t, "Invalid alias name: test_alias", msg) +} + +func TestUnwrap(t *testing.T) { + underlyingErr := errors.New("underlying error") + ve := verror.NewVError(errmsg.ErrKeyPkgProjectInvalidAlias, errmsg.ErrorMessages[errmsg.ErrKeyPkgProjectInvalidAlias], nil, underlyingErr) + + assert.Equal(t, underlyingErr, ve.Unwrap()) +} diff --git a/web/src/beta/features/Editor/Visualizer/hooks.ts b/web/src/beta/features/Editor/Visualizer/hooks.ts index 83bde4c8e6..f5fc02c238 100644 --- a/web/src/beta/features/Editor/Visualizer/hooks.ts +++ b/web/src/beta/features/Editor/Visualizer/hooks.ts @@ -125,12 +125,14 @@ export default ({ setInitialCamera(initialCamera); } - if (initialCamera?.fov && initialCamera.fov !== prevFOV.current) { - prevFOV.current = initialCamera.fov; - setCurrentCamera((c) => - !c ? undefined : { ...c, fov: initialCamera.fov } - ); - } + setTimeout(() => { + if (initialCamera?.fov && initialCamera.fov !== prevFOV.current) { + prevFOV.current = initialCamera.fov; + setCurrentCamera((c) => + !c ? undefined : { ...c, fov: initialCamera.fov } + ); + } + }, 0); const viewerProperty = sceneProperty ? (convertData( diff --git a/web/src/beta/features/Notification/hooks.ts b/web/src/beta/features/Notification/hooks.ts index fab6de4de6..12a1ebf000 100644 --- a/web/src/beta/features/Notification/hooks.ts +++ b/web/src/beta/features/Notification/hooks.ts @@ -1,9 +1,6 @@ import { useT, useLang } from "@reearth/services/i18n"; -import { - useError, - useNotification, - Notification -} from "@reearth/services/state"; +import { useNotification, Notification } from "@reearth/services/state"; +import { useErrors } from "@reearth/services/state/gqlErrorHandling"; import { useState, useEffect, useCallback, useMemo } from "react"; export type PolicyItems = @@ -26,7 +23,7 @@ const policyItems: PolicyItems[] = [ export default () => { const t = useT(); const currentLanguage = useLang(); - const [error, setError] = useError(); + const [errors, setErrors] = useErrors(); const [notification, setNotification] = useNotification(); const [visible, changeVisibility] = useState(false); @@ -54,42 +51,48 @@ export default () => { }, []); useEffect(() => { - if (!error) return; - if (error.message?.includes("policy violation") && error.message) { - const limitedItem = policyItems.find((i) => error.message?.includes(i)); - const policyItem = - limitedItem && policyLimitNotifications - ? policyLimitNotifications[limitedItem] - : undefined; - const message = policyItem - ? typeof policyItem === "string" - ? policyItem - : policyItem[currentLanguage] - : t( - "You have reached a policy limit. Please contact an administrator of your Re:Earth system." - ); + if (errors.length === 0) return; + const defaultErrorMessage = t( + "You have reached a policy limit. Please contact an administrator of your Re:Earth system." + ); + errors.forEach((error) => { + const isPolicyViolation = error.message + ?.toLowerCase() + .includes("policy violation"); + if (isPolicyViolation && error.message) { + const limitedItem = policyItems.find((i) => error.message?.includes(i)); + const policyItem = + limitedItem && policyLimitNotifications + ? policyLimitNotifications[limitedItem] + : undefined; + const message = policyItem + ? typeof policyItem === "string" + ? policyItem + : policyItem[currentLanguage] + : defaultErrorMessage; - setNotification({ - type: "info", - heading: noticeHeading, - text: message, - duration: "persistent" - }); - } else { - setNotification({ - type: "error", - heading: errorHeading, - text: t("Something went wrong. Please try again later.") - }); - } - setError(undefined); + setNotification({ + type: "info", + heading: noticeHeading, + text: message, + duration: "persistent" + }); + } else { + setNotification({ + type: "error", + heading: errorHeading, + text: error.description || error.message || "" + }); + } + }); + setErrors([]); }, [ - error, + errors, currentLanguage, policyLimitNotifications, errorHeading, noticeHeading, - setError, + setErrors, setNotification, t ]); diff --git a/web/src/beta/features/PluginPlayground/Plugins/presets/index.ts b/web/src/beta/features/PluginPlayground/Plugins/presets/index.ts index 9ea4ec7a57..7675c5c7ea 100644 --- a/web/src/beta/features/PluginPlayground/Plugins/presets/index.ts +++ b/web/src/beta/features/PluginPlayground/Plugins/presets/index.ts @@ -9,6 +9,7 @@ import { addKml } from "./layers/add-kml"; import { addOsm3dTiles } from "./layers/add-OSM-3DTiles"; import { addGooglePhotorealistic3dTiles } from "./layers/add-google-photorealistic-3d-tiles"; import { addWms } from "./layers/add-wms"; +import { hideFlyToDeleteLayer } from "./layers/hideFlyToDeleteLayer"; import { header } from "./ui/header"; import { responsivePanel } from "./ui/responsivePanel"; import { sidebar } from "./ui/sidebar"; @@ -55,6 +56,7 @@ export const presetPlugins: PresetPlugins = [ addOsm3dTiles, addWms, addGooglePhotorealistic3dTiles + hideFlyToDeleteLayer ] }, { diff --git a/web/src/beta/features/PluginPlayground/Plugins/presets/layers/hideFlyToDeleteLayer.ts b/web/src/beta/features/PluginPlayground/Plugins/presets/layers/hideFlyToDeleteLayer.ts new file mode 100644 index 0000000000..015c8153e2 --- /dev/null +++ b/web/src/beta/features/PluginPlayground/Plugins/presets/layers/hideFlyToDeleteLayer.ts @@ -0,0 +1,222 @@ +import { FileType, PluginType } from "../../constants"; +import { PRESET_PLUGIN_COMMON_STYLE } from "../common"; + +const yamlFile: FileType = { + id: "ui-hide-flyto-delete-layer-reearth-yml", + title: "reearth.yml", + sourceCode: `id: hide-flyto-delete-layer-plugin +name: Hide Flyto Delete Layer +version: 1.0.0 +extensions: + - id: hide-flyto-delete-layer + type: widget + name: Hide Flyto Delete Layer Widget + description: Hide Flyto Delete Layer Widget + widgetLayout: + defaultLocation: + zone: outer + section: center + area: top + `, + disableEdit: true, + disableDelete: true +}; + +const widgetFile: FileType = { + id: "hide-flyto-delete-layer-widget", + title: "hide-flyto-delete-layer.js", + sourceCode: `const layerGeojson = { + type: "simple", + title: "sample", + visible: true, + data: { + type: "geojson", + url: "https://reearth.github.io/visualizer-plugin-sample-data/public/geojson/sample_polygon_polyline_marker.geojson" + }, + polygon: {}, + polyline: {}, + marker: {} +}; + +const layer3dTiles = { + type: "simple", + title: "3dtiles", + visible: true, + data: { + type: "3dtiles", + url: "https://assets.cms.plateau.reearth.io/assets/8b/cce097-2d4a-46eb-a98b-a78e7178dc30/13103_minato-ku_pref_2023_citygml_1_op_bldg_3dtiles_13103_minato-ku_lod2_no_texture/tileset.json" + }, + "3dtiles": { + pbr: false, + selectedFeatureColor: "red" + } +}; + +const pluginGeojsonLayerId = reearth.layers.add(layerGeojson); +const plugin3dTilesLayerId = reearth.layers.add(layer3dTiles); +const layers = reearth.layers.layers + +// filter layers +const pluginLayerIds = [plugin3dTilesLayerId, pluginGeojsonLayerId]; +const presetLayers = layers.filter(layer => !pluginLayerIds.includes(layer.id)); +const pluginLayers = layers.filter(layer => pluginLayerIds.includes(layer.id)); + +const generateLayerItem = (layer, isPreset) => { + return \` +
  • + \${layer.title} +
    + + + $\{!isPreset + ? \`\` + : "" } +
    +
  • + \`; +}; + +const presetLayerItems = presetLayers.map(layer => generateLayerItem(layer, true)).join(''); +const pluginLayerItems = pluginLayers.map(layer => generateLayerItem(layer, false)).join(''); + +reearth.ui.show(\` + ${PRESET_PLUGIN_COMMON_STYLE} + + +
    +

    Layers

    + +

    Preset Layers

    + + +

    Plugin Layers

    + +
    + + +\`); + +reearth.extension.on("message", (msg) => { + switch (msg.type) { + case "delete": + reearth.layers.delete(msg.layerId); + break; + case "flyTo": + reearth.camera.flyTo(msg.layerId, { duration: 2 }); + break; + case "hide": + reearth.layers.hide(msg.layerId); + break; + case "show": + reearth.layers.show(msg.layerId); + break; + default: + } +}); +` +}; + +export const hideFlyToDeleteLayer: PluginType = { + id: "hide-flyto-delete-layer", + title: "Hide/Flyto/Delete Layer", + files: [widgetFile, yamlFile] +}; diff --git a/web/src/beta/ui/fields/CameraField/EditorPanel.tsx b/web/src/beta/ui/fields/CameraField/EditorPanel.tsx index 3fe11f8c57..455a962e6a 100644 --- a/web/src/beta/ui/fields/CameraField/EditorPanel.tsx +++ b/web/src/beta/ui/fields/CameraField/EditorPanel.tsx @@ -58,7 +58,7 @@ const EditPanel: FC = ({ camera, onSave, onFlyTo, onClose }) => { > = ({ camera, onSave, onFlyTo, onClose }) => { {t("Height")} handleFieldChange("height", value)} onBlur={(value) => handleFieldBlur("height", value)} /> ({ fov: camera?.fov }); +const defaultCamera: Camera = { + lat: 0, + lng: 0, + height: 0, + heading: 0, + pitch: 0, + roll: 0, + fov: 1 +}; + export default ({ camera, onFlyTo, @@ -38,8 +48,8 @@ export default ({ onSave: (value?: Camera) => void; onClose?: () => void; }) => { - const [newCamera, setNewCamera] = useState( - camera ? handleCameraeRadianToDegree(camera) : undefined + const [newCamera, setNewCamera] = useState( + camera ? handleCameraeRadianToDegree(camera) : defaultCamera ); useEffect(() => { @@ -61,15 +71,19 @@ export default ({ ); const handleFieldChange = useCallback((key: keyof Camera, value?: number) => { - setNewCamera((prev) => (prev ? { ...prev, [key]: value } : undefined)); + setNewCamera((prev) => (prev ? { ...prev, [key]: value } : defaultCamera)); }, []); + const handleSave = useCallback(() => { if (!newCamera) return; const data = handleCameraDegreeToRadian(newCamera); + if (JSON.stringify(newCamera) === JSON.stringify(defaultCamera)) { + onFlyTo?.(data); + } onSave?.(data); onClose?.(); - }, [newCamera, onClose, onSave]); + }, [newCamera, onClose, onFlyTo, onSave]); const handleTwinFieldChange = (values: [number, number]) => { handleFieldChange("lat", values[0]); diff --git a/web/src/sentry.ts b/web/src/sentry.ts index e303896863..95b4d9ed5d 100644 --- a/web/src/sentry.ts +++ b/web/src/sentry.ts @@ -12,14 +12,16 @@ export const initialize = () => { } }; -export const reportError = (error: ReportError) => { - if (error instanceof Error) { - Sentry.captureException(error); - } else { - Sentry.captureException( - new Error( - `${error.type || "Unknown"}: ${error.message || "No message provided"}` - ) - ); - } +export const reportError = (errors: ReportError[]) => { + errors.forEach((error) => { + if (error instanceof Error) { + Sentry.captureException(error); + } else { + Sentry.captureException( + new Error( + `${error.type || "Unknown"}: ${error.message || "No message provided"}` + ) + ); + } + }); }; diff --git a/web/src/services/gql/provider/index.tsx b/web/src/services/gql/provider/index.tsx index 4e865afda7..9f9b1a4e5d 100644 --- a/web/src/services/gql/provider/index.tsx +++ b/web/src/services/gql/provider/index.tsx @@ -14,6 +14,7 @@ import { useCallback, type ReactNode } from "react"; import fragmentMatcher from "../__gen__/fragmentMatcher.json"; import { authLink, sentryLink, errorLink, uploadLink, taskLink } from "./links"; +import langLink from "./links/langLink"; import { paginationMerge } from "./pagination"; const Provider: React.FC<{ children?: ReactNode }> = ({ children }) => { @@ -90,6 +91,7 @@ const Provider: React.FC<{ children?: ReactNode }> = ({ children }) => { errorLink(), sentryLink(endpoint), authLink(), + langLink(), // https://github.com/apollographql/apollo-client/issues/6011#issuecomment-619468320 uploadLink(endpoint) as unknown as ApolloLink ]), diff --git a/web/src/services/gql/provider/links/errorLink.ts b/web/src/services/gql/provider/links/errorLink.ts index 6ce70b57e2..8a2b935f79 100644 --- a/web/src/services/gql/provider/links/errorLink.ts +++ b/web/src/services/gql/provider/links/errorLink.ts @@ -1,25 +1,33 @@ import { onError } from "@apollo/client/link/error"; import { reportError } from "@reearth/sentry"; import { useSetError } from "@reearth/services/state"; +import { GQLError } from "@reearth/services/state/gqlErrorHandling"; export default () => { - const { setError } = useSetError(); + const { setErrors } = useSetError(); return onError(({ graphQLErrors, networkError }) => { if (!networkError && !graphQLErrors) return; - let error: { type?: string; message?: string } | undefined; + let errors: GQLError[] = []; if (networkError?.message) { - error = { message: networkError?.message }; + errors = [ + { message: networkError?.message, description: networkError.message } + ]; } else { - error = { - type: graphQLErrors?.[0].path?.[0].toString(), - message: graphQLErrors?.[0].message - }; + errors = + graphQLErrors?.map((gqlError) => { + return { + type: gqlError.path?.[0].toString(), + message: gqlError.message, + code: gqlError.extensions?.code as string, + description: gqlError.extensions?.description as string + }; + }) ?? []; } - if (error) { - setError(error); - reportError(error); + if (errors.length > 0) { + setErrors(errors); + reportError(errors); } }); }; diff --git a/web/src/services/gql/provider/links/langLink.ts b/web/src/services/gql/provider/links/langLink.ts new file mode 100644 index 0000000000..c514194bb4 --- /dev/null +++ b/web/src/services/gql/provider/links/langLink.ts @@ -0,0 +1,25 @@ +import { setContext } from "@apollo/client/link/context"; +import i18n from "@reearth/services/i18n/i18n"; + +export default () => { + return setContext(async (_, { headers }) => { + const defaultLang = "en"; + const locale = i18n.language; + + const splitLocale = locale.split("-"); + const lang = splitLocale.length > 1 ? splitLocale[0] : defaultLang; + if (!lang.match(/^[a-zA-Z]{2,3}$/)) { + console.warn( + `Invalid language code: ${locale}, falling back to "${defaultLang}"` + ); + return { headers: { ...headers, lang: defaultLang } }; + } + + return { + headers: { + ...headers, + lang + } + }; + }); +}; diff --git a/web/src/services/i18n/translations/en.yml b/web/src/services/i18n/translations/en.yml index b8429699e1..983f9f3c19 100644 --- a/web/src/services/i18n/translations/en.yml +++ b/web/src/services/i18n/translations/en.yml @@ -233,7 +233,6 @@ Error: '' Warning: '' Notice: '' You have reached a policy limit. Please contact an administrator of your Re:Earth system.: '' -Something went wrong. Please try again later.: '' Public: '' Most project settings are hidden when the project is archived. Please unarchive the project to view and edit these settings.: '' Project Info: '' diff --git a/web/src/services/i18n/translations/ja.yml b/web/src/services/i18n/translations/ja.yml index 6bfe4f1365..3782288b77 100644 --- a/web/src/services/i18n/translations/ja.yml +++ b/web/src/services/i18n/translations/ja.yml @@ -236,7 +236,6 @@ Error: エラー Warning: 注意 Notice: 通知 You have reached a policy limit. Please contact an administrator of your Re:Earth system.: 現在のポリシーの上限に達しました。システム管理者にお問い合わせください。 -Something went wrong. Please try again later.: 何らかの問題が発生しました。しばらく経ってからお試しください。 Public: 公開 Most project settings are hidden when the project is archived. Please unarchive the project to view and edit these settings.: プロジェクトをアーカイブ化すると、削除とアーカイブ化解除以外の編集は行えません。再度編集可能な状態にするには、プロジェクトのアーカイブ化を解除してください。 Project Info: プロジェクト情報 diff --git a/web/src/services/state/gqlErrorHandling.ts b/web/src/services/state/gqlErrorHandling.ts index 27b4a6d996..b4d8e4f744 100644 --- a/web/src/services/state/gqlErrorHandling.ts +++ b/web/src/services/state/gqlErrorHandling.ts @@ -1,12 +1,16 @@ import { atom, useAtom, useSetAtom } from "jotai"; // useError is needed for Apollo provider error only. Handle other errors with useNotification directly. -type GQLError = { type?: string; message?: string }; -const error = atom(undefined); - -export const useError = () => useAtom(error); +export type GQLError = { + type?: string; + message?: string; + code?: string; + description?: string; +}; +const errors = atom([]); +export const useErrors = () => useAtom(errors); export default () => { - const setError = useSetAtom(error); - return { setError }; + const setErrors = useSetAtom(errors); + return { setErrors }; }; diff --git a/web/src/services/state/index.ts b/web/src/services/state/index.ts index 39725f1af9..d45e863bda 100644 --- a/web/src/services/state/index.ts +++ b/web/src/services/state/index.ts @@ -5,7 +5,7 @@ import { TeamMember } from "../gql"; export * from "./devPlugins"; -export { default as useSetError, useError } from "./gqlErrorHandling"; +export { default as useSetError } from "./gqlErrorHandling"; export type WidgetAreaState = { zone: "inner" | "outer";