Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(gnoweb): "No render" page/component #3611

Merged
merged 18 commits into from
Jan 29, 2025
1 change: 1 addition & 0 deletions examples/gno.land/r/docs/docs.gno
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Explore various examples to learn more about Gno functionality and usage.
- [AVL Pager](/r/docs/avl_pager) - Paginate through AVL tree items.
- [AVL Pager + Render paths](/r/docs/avl_pager_params) - Handle render arguments with pagination.
- [Img Embed](/r/docs/img_embed) - Demonstrates how to embed an image.
- [Optional Render](/r/docs/optional_render) - Render() is optional in realms.
- ...
<!-- meta issue with suggestions: https://github.com/gnolang/gno/issues/3292 -->

Expand Down
1 change: 1 addition & 0 deletions examples/gno.land/r/docs/optional_render/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module gno.land/r/docs/optional_render
7 changes: 7 additions & 0 deletions examples/gno.land/r/docs/optional_render/optional_render.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package optional_render

func Info() string {
leohhhn marked this conversation as resolved.
Show resolved Hide resolved
return `Having a Render() function in your realm is optional!
If you do decide to have a Render() function, it must have the following signature:
func Render(path string) string { ... }`
}
1 change: 1 addition & 0 deletions gno.land/pkg/gnoweb/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ func TestRoutes(t *testing.T) {
{"/game-of-realms", found, "/contribute"},
{"/gor", found, "/contribute"},
{"/blog", found, "/r/gnoland/blog"},
{"/r/docs/optional_render", http.StatusNoContent, "No Render"},
{"/r/not/found/", notFound, ""},
{"/404/not/found", notFound, ""},
{"/아스키문자가아닌경로", notFound, ""},
Expand Down
34 changes: 31 additions & 3 deletions gno.land/pkg/gnoweb/components/view_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,38 @@ package components

const StatusViewType ViewType = "status-view"

// StatusData holds the dynamic fields for the "status" template
type StatusData struct {
Message string
Title string
Body string
ButtonURL string
ButtonText string
}

func StatusComponent(message string) *View {
return NewTemplateView(StatusViewType, "status", StatusData{message})
// StatusErrorComponent returns a view for error scenarios
func StatusErrorComponent(message string) *View {
return NewTemplateView(
StatusViewType,
"status",
StatusData{
Title: "Error: " + message,
Body: "Something went wrong.",
ButtonURL: "/",
ButtonText: "Go Back Home",
},
)
}

// StatusNoRenderComponent returns a view for non-error notifications
func StatusNoRenderComponent(pkgPath string) *View {
return NewTemplateView(
StatusViewType,
"status",
StatusData{
Title: "No Render",
Body: "This realm does not implement a Render() function.",
ButtonURL: pkgPath + "$source",
ButtonText: "View Realm Source",
},
)
}
10 changes: 7 additions & 3 deletions gno.land/pkg/gnoweb/components/views/status.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
{{ define "status" }}
<div class="col-span-10 flex flex-col h-full w-full mt-10 pb-24 justify-center items-center">
<img src="/public/imgs/gnoland.svg" alt="gno land" width="70px" height="70px" />
<h1 class="text-600 font-bold text-gray-600 pb-4 capitalize"><span>Error:</span> <span>{{ .Message }}</span></h1>
<p class="pb-3">Something went wrong. Let’s find our way back!</p>
<a href="/" class="rounded border py-1 px-2 hover:bg-gray-100">Go Back Home</a>
<h1 class="text-600 font-bold text-gray-600 pb-4 capitalize">
{{ .Title }}
</h1>
<p class="pb-3">{{ .Body }}</p>
<a href="{{ .ButtonURL }}" class="rounded border py-1 px-2 hover:bg-gray-100">
{{ .ButtonText }}
</a>
</div>
{{ end }}
18 changes: 11 additions & 7 deletions gno.land/pkg/gnoweb/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ func (h *WebHandler) prepareIndexBodyView(r *http.Request, indexData *components
gnourl, err := ParseGnoURL(r.URL)
if err != nil {
h.Logger.Warn("unable to parse url path", "path", r.URL.Path, "error", err)
return http.StatusNotFound, components.StatusComponent("invalid path")
return http.StatusNotFound, components.StatusErrorComponent("invalid path")
}

breadcrumb := generateBreadcrumbPaths(gnourl)
Expand All @@ -130,7 +130,7 @@ func (h *WebHandler) prepareIndexBodyView(r *http.Request, indexData *components
return h.GetPackageView(gnourl)
default:
h.Logger.Debug("invalid path: path is neither a pure package or a realm")
return http.StatusBadRequest, components.StatusComponent("invalid path")
return http.StatusBadRequest, components.StatusErrorComponent("invalid path")
}
}

Expand Down Expand Up @@ -160,6 +160,10 @@ func (h *WebHandler) GetRealmView(gnourl *GnoURL) (int, *components.View) {

meta, err := h.Client.RenderRealm(&content, gnourl.Path, gnourl.EncodeArgs())
if err != nil {
if errors.Is(err, ErrRenderNotDeclared) {
return http.StatusNoContent, components.StatusNoRenderComponent(gnourl.Path)
}

h.Logger.Error("unable to render realm", "error", err, "path", gnourl.EncodeURL())
return GetClientErrorStatusPage(gnourl, err)
}
Expand Down Expand Up @@ -223,7 +227,7 @@ func (h *WebHandler) GetSourceView(gnourl *GnoURL) (int, *components.View) {

if len(files) == 0 {
h.Logger.Debug("no files available", "path", gnourl.Path)
return http.StatusOK, components.StatusComponent("no files available")
return http.StatusOK, components.StatusErrorComponent("no files available")
}

var fileName string
Expand Down Expand Up @@ -266,7 +270,7 @@ func (h *WebHandler) GetDirectoryView(gnourl *GnoURL) (int, *components.View) {

if len(files) == 0 {
h.Logger.Debug("no files available", "path", gnourl.Path)
return http.StatusOK, components.StatusComponent("no files available")
return http.StatusOK, components.StatusErrorComponent("no files available")
}

return http.StatusOK, components.DirectoryView(components.DirData{
Expand All @@ -283,13 +287,13 @@ func GetClientErrorStatusPage(_ *GnoURL, err error) (int, *components.View) {

switch {
case errors.Is(err, ErrClientPathNotFound):
return http.StatusNotFound, components.StatusComponent(err.Error())
return http.StatusNotFound, components.StatusErrorComponent(err.Error())
case errors.Is(err, ErrClientBadRequest):
return http.StatusInternalServerError, components.StatusComponent("bad request")
return http.StatusInternalServerError, components.StatusErrorComponent("bad request")
case errors.Is(err, ErrClientResponse):
fallthrough // XXX: for now fallback as internal error
default:
return http.StatusInternalServerError, components.StatusComponent("internal error")
return http.StatusInternalServerError, components.StatusErrorComponent("internal error")
}
}

Expand Down
43 changes: 42 additions & 1 deletion gno.land/pkg/gnoweb/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,24 @@ func (t *testingLogger) Write(b []byte) (n int, err error) {

// TestWebHandler_Get tests the Get method of WebHandler using table-driven tests.
func TestWebHandler_Get(t *testing.T) {
t.Parallel()
// Set up a mock package with some files and functions
mockPackage := &gnoweb.MockPackage{
Domain: "example.com",
Path: "/r/mock/path",
Files: map[string]string{
"render.gno": `package main; func Render(path string) { return "one more time" }`,
"render.gno": `package main; func Render(path string) string { return "one more time" }`,
"gno.mod": `module example.com/r/mock/path`,
"LicEnse": `my super license`,
},
Functions: []vm.FunctionSignature{
{FuncName: "SuperRenderFunction", Params: []vm.NamedType{
{Name: "my_super_arg", Type: "string"},
}},
{
FuncName: "Render", Params: []vm.NamedType{{Name: "path", Type: "string"}},
Results: []vm.NamedType{{Name: "", Type: "string"}},
},
},
}

Expand Down Expand Up @@ -82,6 +87,7 @@ func TestWebHandler_Get(t *testing.T) {

for _, tc := range cases {
t.Run(strings.TrimPrefix(tc.Path, "/"), func(t *testing.T) {
t.Parallel()
t.Logf("input: %+v", tc)

// Initialize testing logger
Expand Down Expand Up @@ -110,3 +116,38 @@ func TestWebHandler_Get(t *testing.T) {
})
}
}

// TestWebHandler_NoRender checks if gnoweb displays the `No Render` page properly.
// This happens when the render being queried does not have a Render function declared.
func TestWebHandler_NoRender(t *testing.T) {
t.Parallel()

mockPath := "/r/mock/path"
mockPackage := &gnoweb.MockPackage{
Domain: "gno.land",
Path: "/r/mock/path",
Files: map[string]string{
"render.gno": `package main; func init() {}`,
"gno.mod": `module gno.land/r/mock/path`,
},
}

webclient := gnoweb.NewMockWebClient(mockPackage)
config := gnoweb.WebHandlerConfig{
WebClient: webclient,
}

logger := slog.New(slog.NewTextHandler(&testingLogger{t}, &slog.HandlerOptions{}))
handler, err := gnoweb.NewWebHandler(logger, config)
require.NoError(t, err, "failed to create WebHandler")

req, err := http.NewRequest(http.MethodGet, mockPath, nil)
require.NoError(t, err, "failed to create HTTP request")

rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

assert.Equal(t, http.StatusNoContent, rr.Code, "unexpected status code")
assert.Containsf(t, rr.Body.String(), "", "rendered body should contain: %q", "No Render")
assert.Containsf(t, rr.Body.String(), "", "rendered body should contain: %q", "This realm does not implement a Render() function.")
}
3 changes: 2 additions & 1 deletion gno.land/pkg/gnoweb/webclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

var (
ErrClientPathNotFound = errors.New("package not found")
ErrRenderNotDeclared = errors.New("render function not declared")
ErrClientBadRequest = errors.New("bad request")
ErrClientResponse = errors.New("node response error")
)
Expand All @@ -23,7 +24,7 @@ type RealmMeta struct {
Toc md.Toc
}

// WebClient is an interface for interacting with package and node ressources.
// WebClient is an interface for interacting with package and node resources.
type WebClient interface {
// RenderRealm renders the content of a realm from a given path and
// arguments into the giver `writer`. The method should ensures the rendered
Expand Down
5 changes: 5 additions & 0 deletions gno.land/pkg/gnoweb/webclient_html.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ func (s *HTMLWebClient) RenderRealm(w io.Writer, pkgPath string, args string) (*

pkgPath = strings.Trim(pkgPath, "/")
data := fmt.Sprintf("%s/%s:%s", s.domain, pkgPath, args)

rawres, err := s.query(qpath, []byte(data))
if err != nil {
return nil, err
Expand Down Expand Up @@ -213,6 +214,10 @@ func (s *HTMLWebClient) query(qpath string, data []byte) ([]byte, error) {
return nil, ErrClientPathNotFound
}

if errors.Is(err, vm.NoRenderDeclError{}) {
return nil, ErrRenderNotDeclared
}

s.logger.Error("response error", "path", qpath, "log", qres.Response.Log)
return nil, fmt.Errorf("%w: %s", ErrClientResponse, err.Error())
}
Expand Down
24 changes: 22 additions & 2 deletions gno.land/pkg/gnoweb/webclient_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,16 @@ func NewMockWebClient(pkgs ...*MockPackage) *MockWebClient {
return &MockWebClient{Packages: mpkgs}
}

// Render simulates rendering a package by writing its content to the writer.
// RenderRealm simulates rendering a package by writing its content to the writer.
func (m *MockWebClient) RenderRealm(w io.Writer, path string, args string) (*RealmMeta, error) {
pkg, exists := m.Packages[path]
if !exists {
return nil, ErrClientPathNotFound
}

fmt.Fprintf(w, "[%s]%s:", pkg.Domain, pkg.Path)
if !pkgHasRender(pkg) {
return nil, ErrRenderNotDeclared
}

// Return a dummy RealmMeta for simplicity
return &RealmMeta{}, nil
Expand Down Expand Up @@ -89,3 +91,21 @@ func (m *MockWebClient) Sources(path string) ([]string, error) {

return fileNames, nil
}

func pkgHasRender(pkg *MockPackage) bool {
if len(pkg.Functions) == 0 {
return false
}

for _, fn := range pkg.Functions {
if fn.FuncName == "Render" &&
len(fn.Params) == 1 &&
len(fn.Results) == 1 &&
fn.Params[0].Type == "string" &&
fn.Results[0].Type == "string" {
return true
}
}

return false
}
2 changes: 2 additions & 0 deletions gno.land/pkg/sdk/vm/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func (abciError) AssertABCIError() {}
// NOTE: these are meant to be used in conjunction with pkgs/errors.
type (
InvalidPkgPathError struct{ abciError }
NoRenderDeclError struct{ abciError }
PkgExistError struct{ abciError }
InvalidStmtError struct{ abciError }
InvalidExprError struct{ abciError }
Expand All @@ -27,6 +28,7 @@ type (
)

func (e InvalidPkgPathError) Error() string { return "invalid package path" }
func (e NoRenderDeclError) Error() string { return "render function not declared" }
func (e PkgExistError) Error() string { return "package already exists" }
func (e InvalidStmtError) Error() string { return "invalid statement" }
func (e InvalidExprError) Error() string { return "invalid expression" }
Expand Down
4 changes: 4 additions & 0 deletions gno.land/pkg/sdk/vm/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,13 @@ func (vh vmHandler) queryRender(ctx sdk.Context, req abci.RequestQuery) (res abc
expr := fmt.Sprintf("Render(%q)", path)
result, err := vh.vm.QueryEvalString(ctx, pkgPath, expr)
if err != nil {
if strings.Contains(err.Error(), "Render not declared") {
err = NoRenderDeclError{}
}
res = sdk.ABCIResponseQueryFromError(err)
return
}

res.Data = []byte(result)
return
}
Expand Down
1 change: 1 addition & 0 deletions gno.land/pkg/sdk/vm/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ var Package = amino.RegisterPackage(amino.NewPackage(

// errors
InvalidPkgPathError{}, "InvalidPkgPathError",
NoRenderDeclError{}, "NoRenderDeclError",
PkgExistError{}, "PkgExistError",
InvalidStmtError{}, "InvalidStmtError",
InvalidExprError{}, "InvalidExprError",
Expand Down
Loading