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 \` +