Skip to content

Commit

Permalink
feat(gnoweb): "No render" page/component (#3611)
Browse files Browse the repository at this point in the history
## Description

Defining a `Render()` function in realms is optional. Currently gnoweb
presents an error if a realm that doesn't have a render func is
requested. This should not be the case.

This PR also adds a VM error, `RenderNotDeclared`, which is to be
returned when `vm/qrender` is called on a realm which does not have a
`Render()` function declared.

I updated the status component to return the following in the
aforementioned case:
<img width="1557" alt="Screenshot 2025-01-25 at 16 30 55"
src="https://github.com/user-attachments/assets/dc82c1d6-d815-4d92-a1a9-7639cbf7bca2"
/>


Also adds another `r/docs` realm mentioning that a render function is
optional in `r/`.
  • Loading branch information
leohhhn authored Jan 29, 2025
1 parent 21fe656 commit 4d0000e
Show file tree
Hide file tree
Showing 14 changed files with 139 additions and 16 deletions.
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 {
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
25 changes: 24 additions & 1 deletion gno.land/pkg/gnoweb/webclient_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,18 @@ 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
}

if !pkgHasRender(pkg) {
return nil, ErrRenderNotDeclared
}

// Write to the realm render
fmt.Fprintf(w, "[%s]%s:", pkg.Domain, pkg.Path)

// Return a dummy RealmMeta for simplicity
Expand Down Expand Up @@ -89,3 +94,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

0 comments on commit 4d0000e

Please sign in to comment.