From 60dd19c42f314bcfad9c7184d25d9d37ef6bfda9 Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Wed, 18 Dec 2024 00:54:06 +0100
Subject: [PATCH 01/22] fix: simplify url system
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/url.go | 57 +++++++++++++--------------------
gno.land/pkg/gnoweb/url_test.go | 2 ++
2 files changed, 25 insertions(+), 34 deletions(-)
diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go
index bc03f2182d9..8097e808b2e 100644
--- a/gno.land/pkg/gnoweb/url.go
+++ b/gno.land/pkg/gnoweb/url.go
@@ -16,6 +16,9 @@ const (
KindPure PathKind = 'p'
)
+// reRealmPath match and validate a realm or package path
+var rePkgOrRealmPath = regexp.MustCompile(`^/[a-z]/[a-zA-Z0-9_/]*$`)
+
// GnoURL decomposes the parts of an URL to query a realm.
type GnoURL struct {
// Example full path:
@@ -75,55 +78,41 @@ func (url GnoURL) EncodeWebPath() string {
}
func (url GnoURL) Kind() PathKind {
- if len(url.Path) < 2 {
- return KindInvalid
- }
- pk := PathKind(url.Path[1])
- switch pk {
- case KindPure, KindRealm:
- return pk
+ // Check if the first and third character is '/' and extract the next character
+ if len(url.Path) > 2 && url.Path[0] == '/' && url.Path[2] == '/' {
+ switch k := PathKind(url.Path[1]); k {
+ case KindPure, KindRealm:
+ return k
+ }
}
+
return KindInvalid
}
var (
- ErrURLMalformedPath = errors.New("malformed URL path")
+ ErrURLMalformedPath = errors.New("malformed path")
ErrURLInvalidPathKind = errors.New("invalid path kind")
)
-// reRealName match a realm path
-// - matches[1]: path
-// - matches[2]: path args
-var reRealmPath = regexp.MustCompile(`^` +
- `(/(?:[a-zA-Z0-9_-]+)/` + // path kind
- `[a-zA-Z][a-zA-Z0-9_-]*` + // First path segment
- `(?:/[a-zA-Z][.a-zA-Z0-9_-]*)*/?)` + // Additional path segments
- `([:$](?:.*))?$`, // Remaining portions args, separate by `$` or `:`
-)
-
func ParseGnoURL(u *url.URL) (*GnoURL, error) {
- matches := reRealmPath.FindStringSubmatch(u.EscapedPath())
- if len(matches) != 3 {
- return nil, fmt.Errorf("%w: %s", ErrURLMalformedPath, u.Path)
+ var webargs string
+ path, args, found := strings.Cut(u.EscapedPath(), ":")
+ if found {
+ args, webargs, _ = strings.Cut(args, "$")
+ } else {
+ path, webargs, _ = strings.Cut(path, "$")
}
- path := matches[1]
- args := matches[2]
+ // XXX: should we lower case the path ?
- if len(args) > 0 {
- switch args[0] {
- case ':':
- args = args[1:]
- case '$':
- default:
- return nil, fmt.Errorf("%w: %s", ErrURLMalformedPath, u.Path)
- }
+ // Validate path format
+ if !rePkgOrRealmPath.MatchString(path) {
+ return nil, fmt.Errorf("%w: %q", ErrURLMalformedPath, path)
}
- var err error
webquery := url.Values{}
- args, webargs, found := strings.Cut(args, "$")
- if found {
+ if len(webargs) > 0 {
+ var err error
if webquery, err = url.ParseQuery(webargs); err != nil {
return nil, fmt.Errorf("unable to parse webquery %q: %w ", webquery, err)
}
diff --git a/gno.land/pkg/gnoweb/url_test.go b/gno.land/pkg/gnoweb/url_test.go
index 73cfdda69bd..3b8bf1eeb06 100644
--- a/gno.land/pkg/gnoweb/url_test.go
+++ b/gno.land/pkg/gnoweb/url_test.go
@@ -116,6 +116,8 @@ func TestParseGnoURL(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
+ t.Logf("testing: %s", tc.Input)
+
u, err := url.Parse(tc.Input)
require.NoError(t, err)
From 68b589623213185e30d829dcc4b92900ac981e8d Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Wed, 18 Dec 2024 13:34:14 +0100
Subject: [PATCH 02/22] wip: more tests
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/url_test.go | 78 +++++++++++++++++++++++++++++----
1 file changed, 69 insertions(+), 9 deletions(-)
diff --git a/gno.land/pkg/gnoweb/url_test.go b/gno.land/pkg/gnoweb/url_test.go
index 3b8bf1eeb06..f6e81f9cbe1 100644
--- a/gno.land/pkg/gnoweb/url_test.go
+++ b/gno.land/pkg/gnoweb/url_test.go
@@ -48,7 +48,6 @@ func TestParseGnoURL(t *testing.T) {
},
Err: nil,
},
-
{
Name: "path args + webquery",
Input: "https://gno.land/r/demo/foo:example$tz=Europe/Paris",
@@ -63,7 +62,6 @@ func TestParseGnoURL(t *testing.T) {
},
Err: nil,
},
-
{
Name: "path args + webquery + query",
Input: "https://gno.land/r/demo/foo:example$tz=Europe/Paris?hello=42",
@@ -80,7 +78,6 @@ func TestParseGnoURL(t *testing.T) {
},
Err: nil,
},
-
{
Name: "webquery inside query",
Input: "https://gno.land/r/demo/foo:example?value=42$tz=Europe/Paris",
@@ -95,7 +92,6 @@ func TestParseGnoURL(t *testing.T) {
},
Err: nil,
},
-
{
Name: "webquery escaped $",
Input: "https://gno.land/r/demo/foo:example%24hello=43$hello=42",
@@ -110,13 +106,77 @@ func TestParseGnoURL(t *testing.T) {
},
Err: nil,
},
-
- // XXX: more tests
+ {
+ Name: "invalid path kind",
+ Input: "https://gno.land/x/demo/foo",
+ Expected: nil,
+ Err: ErrURLMalformedPath,
+ },
+ {
+ Name: "empty path",
+ Input: "https://gno.land/r/",
+ Expected: &GnoURL{
+ Path: "/r/",
+ Args: "",
+ WebQuery: url.Values{},
+ Query: url.Values{},
+ Domain: "gno.land",
+ },
+ Err: nil,
+ },
+ {
+ Name: "complex query",
+ Input: "https://gno.land/r/demo/foo$help?func=Bar&name=Baz&age=30",
+ Expected: &GnoURL{
+ Path: "/r/demo/foo",
+ Args: "",
+ WebQuery: url.Values{
+ "help": []string{""},
+ },
+ Query: url.Values{
+ "func": []string{"Bar"},
+ "name": []string{"Baz"},
+ "age": []string{"30"},
+ },
+ Domain: "gno.land",
+ },
+ Err: nil,
+ },
+ {
+ Name: "multiple web queries",
+ Input: "https://gno.land/r/demo/foo$help&func=Bar$test=123",
+ Expected: &GnoURL{
+ Path: "/r/demo/foo",
+ Args: "",
+ WebQuery: url.Values{
+ "help": []string{""},
+ "func": []string{"Bar"},
+ "test": []string{"123"},
+ },
+ Query: url.Values{},
+ Domain: "gno.land",
+ },
+ Err: nil,
+ },
+ {
+ Name: "escaped characters in args",
+ Input: "https://gno.land/r/demo/foo:example%20with%20spaces$tz=Europe/Paris",
+ Expected: &GnoURL{
+ Path: "/r/demo/foo",
+ Args: "example with spaces",
+ WebQuery: url.Values{
+ "tz": []string{"Europe/Paris"},
+ },
+ Query: url.Values{},
+ Domain: "gno.land",
+ },
+ Err: nil,
+ },
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
- t.Logf("testing: %s", tc.Input)
+ t.Logf("testing input: %q", tc.Input)
u, err := url.Parse(tc.Input)
require.NoError(t, err)
@@ -124,8 +184,8 @@ func TestParseGnoURL(t *testing.T) {
result, err := ParseGnoURL(u)
if tc.Err == nil {
require.NoError(t, err)
- t.Logf("parsed: %s", result.EncodePath())
- t.Logf("parsed web: %s", result.EncodeWebPath())
+ t.Logf("encoded path: %q", result.EncodePath())
+ t.Logf("encoded web path: %q", result.EncodeWebPath())
} else {
require.Error(t, err)
require.ErrorIs(t, err, tc.Err)
From 0d9b8a2f716be58bb3effd3f3f441d3ae8e7a973 Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Wed, 18 Dec 2024 14:48:48 +0100
Subject: [PATCH 03/22] fix: improve url
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/handler.go | 7 ++++---
gno.land/pkg/gnoweb/url.go | 12 ++++++++++++
2 files changed, 16 insertions(+), 3 deletions(-)
diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go
index b3a9fcd143c..7235a0687ae 100644
--- a/gno.land/pkg/gnoweb/handler.go
+++ b/gno.land/pkg/gnoweb/handler.go
@@ -140,9 +140,7 @@ func (h *WebHandler) renderPackage(w io.Writer, gnourl *GnoURL) (status int, err
switch {
case gnourl.WebQuery.Has("source"):
return h.renderRealmSource(w, gnourl)
- case kind == KindPure,
- strings.HasSuffix(gnourl.Path, "/"),
- isFile(gnourl.Path):
+ case kind == KindPure, gnourl.IsFile(), gnourl.IsDir():
i := strings.LastIndexByte(gnourl.Path, '/')
if i < 0 {
return http.StatusInternalServerError, fmt.Errorf("unable to get ending slash for %q", gnourl.Path)
@@ -152,10 +150,13 @@ func (h *WebHandler) renderPackage(w io.Writer, gnourl *GnoURL) (status int, err
gnourl.WebQuery.Set("source", "") // set source
file := gnourl.Path[i+1:]
+ // If there nothing after the last slash that mean its a
+ // directory ...
if file == "" {
return h.renderRealmDirectory(w, gnourl)
}
+ // ... else, remaining part is a file
gnourl.WebQuery.Set("file", file)
gnourl.Path = gnourl.Path[:i]
diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go
index 8097e808b2e..30cadf1e21a 100644
--- a/gno.land/pkg/gnoweb/url.go
+++ b/gno.land/pkg/gnoweb/url.go
@@ -89,6 +89,18 @@ func (url GnoURL) Kind() PathKind {
return KindInvalid
}
+func (url GnoURL) IsDir() bool {
+ if pathlen := len(url.Path); pathlen > 0 {
+ return url.Path[pathlen-1] == '/'
+ }
+
+ return false
+}
+
+func (url GnoURL) IsFile() bool {
+ return filepath.Ext(url.Path) != ""
+}
+
var (
ErrURLMalformedPath = errors.New("malformed path")
ErrURLInvalidPathKind = errors.New("invalid path kind")
From 845f2d944031c43c8c78a5d00e50f7dc4608b0c1 Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Wed, 18 Dec 2024 14:50:32 +0100
Subject: [PATCH 04/22] feat: improve url tests
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/app_test.go | 1 +
gno.land/pkg/gnoweb/url.go | 15 ++++++---
gno.land/pkg/gnoweb/url_test.go | 58 ++++++++++++++++++++++++---------
3 files changed, 53 insertions(+), 21 deletions(-)
diff --git a/gno.land/pkg/gnoweb/app_test.go b/gno.land/pkg/gnoweb/app_test.go
index 78fe197a134..5459d6215c6 100644
--- a/gno.land/pkg/gnoweb/app_test.go
+++ b/gno.land/pkg/gnoweb/app_test.go
@@ -73,6 +73,7 @@ func TestRoutes(t *testing.T) {
for _, r := range routes {
t.Run(fmt.Sprintf("test route %s", r.route), func(t *testing.T) {
+ t.Logf("input: %q", r.route)
request := httptest.NewRequest(http.MethodGet, r.route, nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go
index 30cadf1e21a..12b5a3220ac 100644
--- a/gno.land/pkg/gnoweb/url.go
+++ b/gno.land/pkg/gnoweb/url.go
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/url"
+ "path/filepath"
"regexp"
"strings"
)
@@ -17,7 +18,7 @@ const (
)
// reRealmPath match and validate a realm or package path
-var rePkgOrRealmPath = regexp.MustCompile(`^/[a-z]/[a-zA-Z0-9_/]*$`)
+var rePkgOrRealmPath = regexp.MustCompile(`^/[a-z]/[a-zA-Z0-9_/.]*$`)
// GnoURL decomposes the parts of an URL to query a realm.
type GnoURL struct {
@@ -116,10 +117,14 @@ func ParseGnoURL(u *url.URL) (*GnoURL, error) {
}
// XXX: should we lower case the path ?
+ upath, err := url.PathUnescape(path)
+ if err != nil {
+ return nil, fmt.Errorf("unable to unescape path %q: %w", args, err)
+ }
// Validate path format
- if !rePkgOrRealmPath.MatchString(path) {
- return nil, fmt.Errorf("%w: %q", ErrURLMalformedPath, path)
+ if !rePkgOrRealmPath.MatchString(upath) {
+ return nil, fmt.Errorf("%w: %q", ErrURLMalformedPath, upath)
}
webquery := url.Values{}
@@ -132,11 +137,11 @@ func ParseGnoURL(u *url.URL) (*GnoURL, error) {
uargs, err := url.PathUnescape(args)
if err != nil {
- return nil, fmt.Errorf("unable to unescape path %q: %w", args, err)
+ return nil, fmt.Errorf("unable to unescape args %q: %w", args, err)
}
return &GnoURL{
- Path: path,
+ Path: upath,
Args: uargs,
WebQuery: webquery,
Query: u.Query(),
diff --git a/gno.land/pkg/gnoweb/url_test.go b/gno.land/pkg/gnoweb/url_test.go
index f6e81f9cbe1..b15f578b69e 100644
--- a/gno.land/pkg/gnoweb/url_test.go
+++ b/gno.land/pkg/gnoweb/url_test.go
@@ -21,6 +21,7 @@ func TestParseGnoURL(t *testing.T) {
Expected: nil,
Err: ErrURLMalformedPath,
},
+
{
Name: "simple",
Input: "https://gno.land/r/simple/test",
@@ -30,8 +31,8 @@ func TestParseGnoURL(t *testing.T) {
WebQuery: url.Values{},
Query: url.Values{},
},
- Err: nil,
},
+
{
Name: "webquery + query",
Input: "https://gno.land/r/demo/foo$help&func=Bar&name=Baz",
@@ -46,8 +47,8 @@ func TestParseGnoURL(t *testing.T) {
Query: url.Values{},
Domain: "gno.land",
},
- Err: nil,
},
+
{
Name: "path args + webquery",
Input: "https://gno.land/r/demo/foo:example$tz=Europe/Paris",
@@ -60,8 +61,8 @@ func TestParseGnoURL(t *testing.T) {
Query: url.Values{},
Domain: "gno.land",
},
- Err: nil,
},
+
{
Name: "path args + webquery + query",
Input: "https://gno.land/r/demo/foo:example$tz=Europe/Paris?hello=42",
@@ -76,8 +77,8 @@ func TestParseGnoURL(t *testing.T) {
},
Domain: "gno.land",
},
- Err: nil,
},
+
{
Name: "webquery inside query",
Input: "https://gno.land/r/demo/foo:example?value=42$tz=Europe/Paris",
@@ -90,8 +91,8 @@ func TestParseGnoURL(t *testing.T) {
},
Domain: "gno.land",
},
- Err: nil,
},
+
{
Name: "webquery escaped $",
Input: "https://gno.land/r/demo/foo:example%24hello=43$hello=42",
@@ -104,14 +105,20 @@ func TestParseGnoURL(t *testing.T) {
Query: url.Values{},
Domain: "gno.land",
},
- Err: nil,
},
+
{
- Name: "invalid path kind",
- Input: "https://gno.land/x/demo/foo",
- Expected: nil,
- Err: ErrURLMalformedPath,
+ Name: "unknown path kind",
+ Input: "https://gno.land/x/demo/foo",
+ Expected: &GnoURL{
+ Path: "/x/demo/foo",
+ Args: "",
+ WebQuery: url.Values{},
+ Query: url.Values{},
+ Domain: "gno.land",
+ },
},
+
{
Name: "empty path",
Input: "https://gno.land/r/",
@@ -122,8 +129,8 @@ func TestParseGnoURL(t *testing.T) {
Query: url.Values{},
Domain: "gno.land",
},
- Err: nil,
},
+
{
Name: "complex query",
Input: "https://gno.land/r/demo/foo$help?func=Bar&name=Baz&age=30",
@@ -140,8 +147,8 @@ func TestParseGnoURL(t *testing.T) {
},
Domain: "gno.land",
},
- Err: nil,
},
+
{
Name: "multiple web queries",
Input: "https://gno.land/r/demo/foo$help&func=Bar$test=123",
@@ -150,14 +157,34 @@ func TestParseGnoURL(t *testing.T) {
Args: "",
WebQuery: url.Values{
"help": []string{""},
- "func": []string{"Bar"},
- "test": []string{"123"},
+ "func": []string{"Bar$test=123"},
},
Query: url.Values{},
Domain: "gno.land",
},
- Err: nil,
},
+
+ {
+ Name: "webquery-args-webquery",
+ Input: "https://gno.land/r/demo/AAA$BBB:CCC&DDD$EEE",
+ Err: ErrURLMalformedPath, // `/r/demo/AAA$BBB` is an invalid path
+ },
+
+ {
+ Name: "args-webquery-args",
+ Input: "https://gno.land/r/demo/AAA:BBB$CCC&DDD:EEE",
+ Expected: &GnoURL{
+ Domain: "gno.land",
+ Path: "/r/demo/AAA",
+ Args: "BBB",
+ WebQuery: url.Values{
+ "CCC": []string{""},
+ "DDD:EEE": []string{""},
+ },
+ Query: url.Values{},
+ },
+ },
+
{
Name: "escaped characters in args",
Input: "https://gno.land/r/demo/foo:example%20with%20spaces$tz=Europe/Paris",
@@ -170,7 +197,6 @@ func TestParseGnoURL(t *testing.T) {
Query: url.Values{},
Domain: "gno.land",
},
- Err: nil,
},
}
From 10bed00e5c41581c6461943f65259738a285f09d Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Wed, 18 Dec 2024 15:15:31 +0100
Subject: [PATCH 05/22] fix: simplify more and add comments
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/url.go | 89 ++++++++++++++++++--------------------
1 file changed, 41 insertions(+), 48 deletions(-)
diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go
index 12b5a3220ac..2130d7332bd 100644
--- a/gno.land/pkg/gnoweb/url.go
+++ b/gno.land/pkg/gnoweb/url.go
@@ -12,12 +12,12 @@ import (
type PathKind byte
const (
- KindInvalid PathKind = 0
+ KindUnknown PathKind = 0
KindRealm PathKind = 'r'
KindPure PathKind = 'p'
)
-// reRealmPath match and validate a realm or package path
+// rePkgOrRealmPath matches and validates a realm or package path.
var rePkgOrRealmPath = regexp.MustCompile(`^/[a-z]/[a-zA-Z0-9_/.]*$`)
// GnoURL decomposes the parts of an URL to query a realm.
@@ -32,74 +32,67 @@ type GnoURL struct {
Query url.Values // c=d
}
-func (url GnoURL) EncodeArgs() string {
+// EncodeArgs encodes the arguments and query parameters into a string.
+func (gnoURL GnoURL) EncodeArgs() string {
var urlstr strings.Builder
- if url.Args != "" {
- urlstr.WriteString(url.Args)
+ if gnoURL.Args != "" {
+ urlstr.WriteString(gnoURL.Args)
}
-
- if len(url.Query) > 0 {
- urlstr.WriteString("?" + url.Query.Encode())
+ if len(gnoURL.Query) > 0 {
+ urlstr.WriteString("?" + gnoURL.Query.Encode())
}
-
return urlstr.String()
}
-func (url GnoURL) EncodePath() string {
+// EncodePath encodes the path, arguments, and query parameters into a string.
+func (gnoURL GnoURL) EncodePath() string {
var urlstr strings.Builder
- urlstr.WriteString(url.Path)
- if url.Args != "" {
- urlstr.WriteString(":" + url.Args)
+ urlstr.WriteString(gnoURL.Path)
+ if gnoURL.Args != "" {
+ urlstr.WriteString(":" + gnoURL.Args)
}
-
- if len(url.Query) > 0 {
- urlstr.WriteString("?" + url.Query.Encode())
+ if len(gnoURL.Query) > 0 {
+ urlstr.WriteString("?" + gnoURL.Query.Encode())
}
-
return urlstr.String()
}
-func (url GnoURL) EncodeWebPath() string {
+// EncodeWebPath encodes the path, arguments, and both web and query parameters into a string.
+func (gnoURL GnoURL) EncodeWebPath() string {
var urlstr strings.Builder
- urlstr.WriteString(url.Path)
- if url.Args != "" {
- pathEscape := escapeDollarSign(url.Args)
+ urlstr.WriteString(gnoURL.Path)
+ if gnoURL.Args != "" {
+ pathEscape := escapeDollarSign(gnoURL.Args)
urlstr.WriteString(":" + pathEscape)
}
-
- if len(url.WebQuery) > 0 {
- urlstr.WriteString("$" + url.WebQuery.Encode())
+ if len(gnoURL.WebQuery) > 0 {
+ urlstr.WriteString("$" + gnoURL.WebQuery.Encode())
}
-
- if len(url.Query) > 0 {
- urlstr.WriteString("?" + url.Query.Encode())
+ if len(gnoURL.Query) > 0 {
+ urlstr.WriteString("?" + gnoURL.Query.Encode())
}
-
return urlstr.String()
}
-func (url GnoURL) Kind() PathKind {
- // Check if the first and third character is '/' and extract the next character
- if len(url.Path) > 2 && url.Path[0] == '/' && url.Path[2] == '/' {
- switch k := PathKind(url.Path[1]); k {
+// Kind determines the kind of path (invalid, realm, or pure) based on the path structure.
+func (gnoURL GnoURL) Kind() PathKind {
+ if len(gnoURL.Path) > 2 && gnoURL.Path[0] == '/' && gnoURL.Path[2] == '/' {
+ switch k := PathKind(gnoURL.Path[1]); k {
case KindPure, KindRealm:
return k
}
}
-
- return KindInvalid
+ return KindUnknown
}
-func (url GnoURL) IsDir() bool {
- if pathlen := len(url.Path); pathlen > 0 {
- return url.Path[pathlen-1] == '/'
- }
-
- return false
+// IsDir checks if the URL path represents a directory.
+func (gnoURL GnoURL) IsDir() bool {
+ return len(gnoURL.Path) > 0 && gnoURL.Path[len(gnoURL.Path)-1] == '/'
}
-func (url GnoURL) IsFile() bool {
- return filepath.Ext(url.Path) != ""
+// IsFile checks if the URL path represents a file.
+func (gnoURL GnoURL) IsFile() bool {
+ return filepath.Ext(gnoURL.Path) != ""
}
var (
@@ -107,6 +100,7 @@ var (
ErrURLInvalidPathKind = errors.New("invalid path kind")
)
+// ParseGnoURL parses a URL into a GnoURL structure, extracting and validating its components.
func ParseGnoURL(u *url.URL) (*GnoURL, error) {
var webargs string
path, args, found := strings.Cut(u.EscapedPath(), ":")
@@ -116,22 +110,20 @@ func ParseGnoURL(u *url.URL) (*GnoURL, error) {
path, webargs, _ = strings.Cut(path, "$")
}
- // XXX: should we lower case the path ?
upath, err := url.PathUnescape(path)
if err != nil {
- return nil, fmt.Errorf("unable to unescape path %q: %w", args, err)
+ return nil, fmt.Errorf("unable to unescape path %q: %w", path, err)
}
- // Validate path format
if !rePkgOrRealmPath.MatchString(upath) {
return nil, fmt.Errorf("%w: %q", ErrURLMalformedPath, upath)
}
webquery := url.Values{}
if len(webargs) > 0 {
- var err error
- if webquery, err = url.ParseQuery(webargs); err != nil {
- return nil, fmt.Errorf("unable to parse webquery %q: %w ", webquery, err)
+ var parseErr error
+ if webquery, parseErr = url.ParseQuery(webargs); parseErr != nil {
+ return nil, fmt.Errorf("unable to parse webquery %q: %w", webargs, parseErr)
}
}
@@ -149,6 +141,7 @@ func ParseGnoURL(u *url.URL) (*GnoURL, error) {
}, nil
}
+// escapeDollarSign replaces dollar signs with their URL-encoded equivalent.
func escapeDollarSign(s string) string {
return strings.ReplaceAll(s, "$", "%24")
}
From beda4a2dc531412209f2916a1c3fd98317915feb Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Wed, 18 Dec 2024 17:14:17 +0100
Subject: [PATCH 06/22] chore: lint
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/handler.go | 7 -------
1 file changed, 7 deletions(-)
diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go
index 7235a0687ae..4c6826defa4 100644
--- a/gno.land/pkg/gnoweb/handler.go
+++ b/gno.land/pkg/gnoweb/handler.go
@@ -373,10 +373,3 @@ func generateBreadcrumbPaths(path string) []components.BreadcrumbPart {
return parts
}
-
-// IsFile checks if the last element of the path is a file (has an extension)
-func isFile(path string) bool {
- base := filepath.Base(path)
- ext := filepath.Ext(base)
- return ext != ""
-}
From 689e3557da4f1443d2cbe4fd40b89d64e5cdbd02 Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Tue, 17 Dec 2024 11:34:21 +0100
Subject: [PATCH 07/22] feat: add public static tests
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/app_test.go | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/gno.land/pkg/gnoweb/app_test.go b/gno.land/pkg/gnoweb/app_test.go
index 5459d6215c6..4005b43efcd 100644
--- a/gno.land/pkg/gnoweb/app_test.go
+++ b/gno.land/pkg/gnoweb/app_test.go
@@ -51,8 +51,13 @@ func TestRoutes(t *testing.T) {
{"/아스키문자가아닌경로", notFound, ""},
{"/%ED%85%8C%EC%8A%A4%ED%8A%B8", notFound, ""},
{"/グノー", notFound, ""},
- {"/⚛️", notFound, ""},
+ {"/\u269B\uFE0F", notFound, ""}, // unicode
{"/p/demo/flow/LICENSE", ok, "BSD 3-Clause"},
+ // Test assets
+ {"/public/styles.css", ok, ""},
+ {"/public/js/index.js", ok, ""},
+ {"/public/_chroma/style.css", ok, ""},
+ {"/public/imgs/gnoland.svg", ok, ""},
}
rootdir := gnoenv.RootDir()
From 7c8f30903c1925a205a596f5366ab305872a4aa8 Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Wed, 18 Dec 2024 00:54:59 +0100
Subject: [PATCH 08/22] chore: remove println
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/app_test.go | 1 -
1 file changed, 1 deletion(-)
diff --git a/gno.land/pkg/gnoweb/app_test.go b/gno.land/pkg/gnoweb/app_test.go
index 4005b43efcd..0a0db8702ff 100644
--- a/gno.land/pkg/gnoweb/app_test.go
+++ b/gno.land/pkg/gnoweb/app_test.go
@@ -131,7 +131,6 @@ func TestAnalytics(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, route, nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
- fmt.Println("HELLO:", response.Body.String())
assert.Contains(t, response.Body.String(), "sa.gno.services")
})
}
From c09975ddbbb83dd830262e91fe4a6f89ae995b1b Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Wed, 18 Dec 2024 00:57:18 +0100
Subject: [PATCH 09/22] feat: cleanup gnoweb
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/app.go | 36 ++++--
gno.land/pkg/gnoweb/formatter.go | 25 ----
gno.land/pkg/gnoweb/handler.go | 184 ++++++++++++++++-------------
gno.land/pkg/gnoweb/highlighter.go | 62 ++++++++++
gno.land/pkg/gnoweb/webclient.go | 108 +++++++++++++----
5 files changed, 269 insertions(+), 146 deletions(-)
delete mode 100644 gno.land/pkg/gnoweb/formatter.go
create mode 100644 gno.land/pkg/gnoweb/highlighter.go
diff --git a/gno.land/pkg/gnoweb/app.go b/gno.land/pkg/gnoweb/app.go
index dc13253468e..c370ba9d3e5 100644
--- a/gno.land/pkg/gnoweb/app.go
+++ b/gno.land/pkg/gnoweb/app.go
@@ -31,10 +31,12 @@ type AppConfig struct {
ChainID string
// AssetsPath is the base path to the gnoweb assets.
AssetsPath string
- // AssetDir, if set, will be used for assets instead of the embedded public directory
+ // AssetDir, if set, will be used for assets instead of the embedded public directory.
AssetsDir string
// FaucetURL, if specified, will be the URL to which `/faucet` redirects.
FaucetURL string
+ // Domain is the domain used by the node.
+ Domain string
}
// NewDefaultAppConfig returns a new default [AppConfig]. The default sets
@@ -49,6 +51,7 @@ func NewDefaultAppConfig() *AppConfig {
RemoteHelp: defaultRemote,
ChainID: "dev",
AssetsPath: "/public/",
+ Domain: "gno.land",
}
}
@@ -64,12 +67,18 @@ func mustGetStyle(name string) *chroma.Style {
// NewRouter initializes the gnoweb router, with the given logger and config.
func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) {
+ client, err := client.NewHTTPClient(cfg.NodeRemote)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create http client: %w", err)
+ }
+
chromaOptions := []chromahtml.Option{
chromahtml.WithLineNumbers(true),
chromahtml.WithLinkableLineNumbers(true, "L"),
chromahtml.WithClasses(true),
chromahtml.ClassPrefix("chroma-"),
}
+ chroma := chromahtml.New(chromaOptions...)
mdopts := []goldmark.Option{
goldmark.WithExtensions(
@@ -84,21 +93,23 @@ func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) {
md := goldmark.New(mdopts...)
- client, err := client.NewHTTPClient(cfg.NodeRemote)
- if err != nil {
- return nil, fmt.Errorf("unable to create http client: %w", err)
+ webcfg := WebClientConfig{
+ Markdown: md,
+ Highlighter: NewChromaHighlighter(chroma, chromaStyle),
+ Domain: cfg.Domain,
+ UnsafeHTML: cfg.UnsafeHTML,
+ RPCClient: client,
}
- webcli := NewWebClient(logger, client, md)
- formatter := chromahtml.New(chromaOptions...)
+ webcli := NewWebClient(logger, &webcfg)
chromaStylePath := path.Join(cfg.AssetsPath, "_chroma", "style.css")
var webConfig WebHandlerConfig
- webConfig.RenderClient = webcli
- webConfig.Formatter = newFormatterWithStyle(formatter, chromaStyle)
+ webConfig.WebClient = webcli
// Static meta
+ webConfig.Meta.Domain = cfg.Domain
webConfig.Meta.AssetsPath = cfg.AssetsPath
webConfig.Meta.ChromaPath = chromaStylePath
webConfig.Meta.RemoteHelp = cfg.RemoteHelp
@@ -106,7 +117,10 @@ func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) {
webConfig.Meta.Analytics = cfg.Analytics
// Setup main handler
- webhandler := NewWebHandler(logger, webConfig)
+ webhandler, err := NewWebHandler(logger, webConfig)
+ if err != nil {
+ return nil, fmt.Errorf("unable create web handler: %w", err)
+ }
mux := http.NewServeMux()
@@ -124,11 +138,11 @@ func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) {
}))
}
- // setup assets
+ // Setup assets
mux.Handle(chromaStylePath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Setup Formatter
w.Header().Set("Content-Type", "text/css")
- if err := formatter.WriteCSS(w, chromaStyle); err != nil {
+ if err := chroma.WriteCSS(w, chromaStyle); err != nil {
logger.Error("unable to write css", "err", err)
http.NotFound(w, r)
}
diff --git a/gno.land/pkg/gnoweb/formatter.go b/gno.land/pkg/gnoweb/formatter.go
deleted file mode 100644
index e172afe9e21..00000000000
--- a/gno.land/pkg/gnoweb/formatter.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package gnoweb
-
-import (
- "io"
-
- "github.com/alecthomas/chroma/v2"
- "github.com/alecthomas/chroma/v2/formatters/html"
-)
-
-type Formatter interface {
- Format(w io.Writer, iterator chroma.Iterator) error
-}
-
-type formatterWithStyle struct {
- *html.Formatter
- style *chroma.Style
-}
-
-func newFormatterWithStyle(formater *html.Formatter, style *chroma.Style) Formatter {
- return &formatterWithStyle{Formatter: formater, style: style}
-}
-
-func (f *formatterWithStyle) Format(w io.Writer, iterator chroma.Iterator) error {
- return f.Formatter.Format(w, f.style, iterator)
-}
diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go
index 4c6826defa4..c239ad09535 100644
--- a/gno.land/pkg/gnoweb/handler.go
+++ b/gno.land/pkg/gnoweb/handler.go
@@ -13,15 +13,12 @@ import (
"strings"
"time"
- "github.com/alecthomas/chroma/v2"
- "github.com/alecthomas/chroma/v2/lexers"
"github.com/gnolang/gno/gno.land/pkg/gnoweb/components"
"github.com/gnolang/gno/gno.land/pkg/sdk/vm" // for error types
)
-const DefaultChainDomain = "gno.land"
-
type StaticMetadata struct {
+ Domain string
AssetsPath string
ChromaPath string
RemoteHelp string
@@ -30,30 +27,42 @@ type StaticMetadata struct {
}
type WebHandlerConfig struct {
- Meta StaticMetadata
- RenderClient *WebClient
- Formatter Formatter
+ Meta StaticMetadata
+
+ WebClient Client
+ SourceFormatter Highlighter
}
-type WebHandler struct {
- formatter Formatter
+func (cfg WebHandlerConfig) validate() error {
+ if cfg.WebClient == nil {
+ return fmt.Errorf("no `Webclient` configured")
+ }
+ if cfg.SourceFormatter == nil {
+ return fmt.Errorf("no `SourceFormatter` configured")
+ }
+
+ return nil
+}
+
+type WebHandler struct {
logger *slog.Logger
- static StaticMetadata
- webcli *WebClient
+
+ Static StaticMetadata
+ Client Client
}
-func NewWebHandler(logger *slog.Logger, cfg WebHandlerConfig) *WebHandler {
- if cfg.RenderClient == nil {
- logger.Error("no renderer has been defined")
+func NewWebHandler(logger *slog.Logger, cfg WebHandlerConfig) (*WebHandler, error) {
+ if err := cfg.validate(); err != nil {
+ return nil, fmt.Errorf("config validate error: %w", err)
}
return &WebHandler{
- formatter: cfg.Formatter,
- webcli: cfg.RenderClient,
- logger: logger,
- static: cfg.Meta,
- }
+ Client: cfg.WebClient,
+ Static: cfg.Meta,
+
+ logger: logger,
+ }, nil
}
func (h *WebHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -78,10 +87,10 @@ func (h *WebHandler) Get(w http.ResponseWriter, r *http.Request) {
}()
var indexData components.IndexData
- indexData.HeadData.AssetsPath = h.static.AssetsPath
- indexData.HeadData.ChromaPath = h.static.ChromaPath
- indexData.FooterData.Analytics = h.static.Analytics
- indexData.FooterData.AssetsPath = h.static.AssetsPath
+ indexData.HeadData.AssetsPath = h.Static.AssetsPath
+ indexData.HeadData.ChromaPath = h.Static.ChromaPath
+ indexData.FooterData.Analytics = h.Static.Analytics
+ indexData.FooterData.AssetsPath = h.Static.AssetsPath
// Render the page body into the buffer
var status int
@@ -101,7 +110,7 @@ func (h *WebHandler) Get(w http.ResponseWriter, r *http.Request) {
// Render
switch gnourl.Kind() {
case KindRealm, KindPure:
- status, err = h.renderPackage(&body, gnourl)
+ status, err = h.GetPackagePage(&body, gnourl)
default:
h.logger.Debug("invalid page kind", "kind", gnourl.Kind)
status, err = http.StatusNotFound, components.RenderStatusComponent(&body, "page not found")
@@ -126,20 +135,20 @@ func (h *WebHandler) Get(w http.ResponseWriter, r *http.Request) {
return
}
-func (h *WebHandler) renderPackage(w io.Writer, gnourl *GnoURL) (status int, err error) {
+func (h *WebHandler) GetPackagePage(w io.Writer, gnourl *GnoURL) (status int, err error) {
h.logger.Info("component render", "path", gnourl.Path, "args", gnourl.Args)
kind := gnourl.Kind()
// Display realm help page?
if kind == KindRealm && gnourl.WebQuery.Has("help") {
- return h.renderRealmHelp(w, gnourl)
+ return h.GetHelpPage(w, gnourl)
}
// Display package source page?
switch {
case gnourl.WebQuery.Has("source"):
- return h.renderRealmSource(w, gnourl)
+ return h.GetSource(w, gnourl)
case kind == KindPure, gnourl.IsFile(), gnourl.IsDir():
i := strings.LastIndexByte(gnourl.Path, '/')
if i < 0 {
@@ -153,19 +162,19 @@ func (h *WebHandler) renderPackage(w io.Writer, gnourl *GnoURL) (status int, err
// If there nothing after the last slash that mean its a
// directory ...
if file == "" {
- return h.renderRealmDirectory(w, gnourl)
+ return h.GetDirectoryPage(w, gnourl)
}
// ... else, remaining part is a file
gnourl.WebQuery.Set("file", file)
gnourl.Path = gnourl.Path[:i]
- return h.renderRealmSource(w, gnourl)
+ return h.GetSource(w, gnourl)
}
// Render content into the content buffer
var content bytes.Buffer
- meta, err := h.webcli.Render(&content, gnourl.Path, gnourl.EncodeArgs())
+ meta, err := h.Client.Render(&content, gnourl.Path, gnourl.EncodeArgs())
if err != nil {
if errors.Is(err, vm.InvalidPkgPathError{}) {
return http.StatusNotFound, components.RenderStatusComponent(w, "not found")
@@ -191,8 +200,55 @@ func (h *WebHandler) renderPackage(w io.Writer, gnourl *GnoURL) (status int, err
return http.StatusOK, nil
}
-func (h *WebHandler) renderRealmHelp(w io.Writer, gnourl *GnoURL) (status int, err error) {
- fsigs, err := h.webcli.Functions(gnourl.Path)
+func (h *WebHandler) GetHelpPage(w io.Writer, gnourl *GnoURL) (status int, err error) {
+ fsigs, err := h.Client.Functions(gnourl.Path)
+ if err != nil {
+ h.logger.Error("unable to fetch path functions", "err", err)
+ return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
+ }
+
+ var selArgs map[string]string
+ var selFn string
+ if selFn = gnourl.WebQuery.Get("func"); selFn != "" {
+ for _, fn := range fsigs {
+ if selFn != fn.FuncName {
+ continue
+ }
+
+ selArgs = make(map[string]string)
+ for _, param := range fn.Params {
+ selArgs[param.Name] = gnourl.WebQuery.Get(param.Name)
+ }
+
+ fsigs = []vm.FunctionSignature{fn}
+ break
+ }
+ }
+
+ // Catch last name of the path
+ // XXX: we should probably add a helper within the template
+ realmName := filepath.Base(gnourl.Path)
+ err = components.RenderHelpComponent(w, components.HelpData{
+ SelectedFunc: selFn,
+ SelectedArgs: selArgs,
+ RealmName: realmName,
+ ChainId: h.Static.ChainId,
+ // TODO: get chain domain and use that.
+ PkgPath: filepath.Join(h.Static.Domain, gnourl.Path),
+ Remote: h.Static.RemoteHelp,
+ Functions: fsigs,
+ })
+ if err != nil {
+ h.logger.Error("unable to render helper", "err", err)
+ return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
+ }
+
+ return http.StatusOK, nil
+
+}
+
+func (h *WebHandler) GetRealmPage(w io.Writer, gnourl *GnoURL) (status int, err error) {
+ fsigs, err := h.Client.Functions(gnourl.Path)
if err != nil {
h.logger.Error("unable to fetch path functions", "err", err)
return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
@@ -223,10 +279,10 @@ func (h *WebHandler) renderRealmHelp(w io.Writer, gnourl *GnoURL) (status int, e
SelectedFunc: selFn,
SelectedArgs: selArgs,
RealmName: realmName,
- ChainId: h.static.ChainId,
+ ChainId: h.Static.ChainId,
// TODO: get chain domain and use that.
- PkgPath: filepath.Join(DefaultChainDomain, gnourl.Path),
- Remote: h.static.RemoteHelp,
+ PkgPath: filepath.Join(h.Static.Domain, gnourl.Path),
+ Remote: h.Static.RemoteHelp,
Functions: fsigs,
})
if err != nil {
@@ -237,10 +293,10 @@ func (h *WebHandler) renderRealmHelp(w io.Writer, gnourl *GnoURL) (status int, e
return http.StatusOK, nil
}
-func (h *WebHandler) renderRealmSource(w io.Writer, gnourl *GnoURL) (status int, err error) {
+func (h *WebHandler) GetSource(w io.Writer, gnourl *GnoURL) (status int, err error) {
pkgPath := gnourl.Path
- files, err := h.webcli.Sources(pkgPath)
+ files, err := h.Client.Sources(pkgPath)
if err != nil {
h.logger.Error("unable to list sources file", "path", gnourl.Path, "err", err)
return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
@@ -262,32 +318,23 @@ func (h *WebHandler) renderRealmSource(w io.Writer, gnourl *GnoURL) (status int,
return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
}
- source, err := h.webcli.SourceFile(pkgPath, fileName)
+ var source bytes.Buffer
+ meta, err := h.Client.SourceFile(&source, pkgPath, fileName)
if err != nil {
h.logger.Error("unable to get source file", "file", fileName, "err", err)
return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
}
// XXX: we should either do this on the front or in the markdown parsing side
- fileLines := strings.Count(string(source), "\n")
- fileSizeKb := float64(len(source)) / 1024.0
- fileSizeStr := fmt.Sprintf("%.2f Kb", fileSizeKb)
-
- // Highlight code source
- hsource, err := h.highlightSource(fileName, source)
- if err != nil {
- h.logger.Error("unable to highlight source file", "file", fileName, "err", err)
- return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
- }
-
+ fileSizeStr := fmt.Sprintf("%.2f Kb", meta.SizeKb)
err = components.RenderSourceComponent(w, components.SourceData{
PkgPath: gnourl.Path,
Files: files,
FileName: fileName,
FileCounter: len(files),
- FileLines: fileLines,
+ FileLines: meta.Lines,
FileSize: fileSizeStr,
- FileSource: template.HTML(hsource), //nolint:gosec
+ FileSource: template.HTML(source.String()), //nolint:gosec
})
if err != nil {
h.logger.Error("unable to render helper", "err", err)
@@ -297,10 +344,10 @@ func (h *WebHandler) renderRealmSource(w io.Writer, gnourl *GnoURL) (status int,
return http.StatusOK, nil
}
-func (h *WebHandler) renderRealmDirectory(w io.Writer, gnourl *GnoURL) (status int, err error) {
+func (h *WebHandler) GetDirectoryPage(w io.Writer, gnourl *GnoURL) (status int, err error) {
pkgPath := gnourl.Path
- files, err := h.webcli.Sources(pkgPath)
+ files, err := h.Client.Sources(pkgPath)
if err != nil {
h.logger.Error("unable to list sources file", "path", gnourl.Path, "err", err)
return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
@@ -324,37 +371,6 @@ func (h *WebHandler) renderRealmDirectory(w io.Writer, gnourl *GnoURL) (status i
return http.StatusOK, nil
}
-func (h *WebHandler) highlightSource(fileName string, src []byte) ([]byte, error) {
- var lexer chroma.Lexer
-
- switch strings.ToLower(filepath.Ext(fileName)) {
- case ".gno":
- lexer = lexers.Get("go")
- case ".md":
- lexer = lexers.Get("markdown")
- case ".mod":
- lexer = lexers.Get("gomod")
- default:
- lexer = lexers.Get("txt") // file kind not supported, fallback on `.txt`
- }
-
- if lexer == nil {
- return nil, fmt.Errorf("unsupported lexer for file %q", fileName)
- }
-
- iterator, err := lexer.Tokenise(nil, string(src))
- if err != nil {
- h.logger.Error("unable to ", "fileName", fileName, "err", err)
- }
-
- var buff bytes.Buffer
- if err := h.formatter.Format(&buff, iterator); err != nil {
- return nil, fmt.Errorf("unable to format source file %q: %w", fileName, err)
- }
-
- return buff.Bytes(), nil
-}
-
func generateBreadcrumbPaths(path string) []components.BreadcrumbPart {
split := strings.Split(path, "/")
parts := []components.BreadcrumbPart{}
diff --git a/gno.land/pkg/gnoweb/highlighter.go b/gno.land/pkg/gnoweb/highlighter.go
new file mode 100644
index 00000000000..8174283c717
--- /dev/null
+++ b/gno.land/pkg/gnoweb/highlighter.go
@@ -0,0 +1,62 @@
+package gnoweb
+
+import (
+ "fmt"
+ "io"
+ "path/filepath"
+ "strings"
+
+ "github.com/alecthomas/chroma/v2"
+ "github.com/alecthomas/chroma/v2/formatters/html"
+ "github.com/alecthomas/chroma/v2/lexers"
+)
+
+type Highlighter interface {
+ Format(w io.Writer, fileName string, file []byte) error
+}
+
+type ChromaHighlighter struct {
+ *html.Formatter
+ style *chroma.Style
+}
+
+func NewChromaHighlighter(formater *html.Formatter, style *chroma.Style) Highlighter {
+ return &ChromaHighlighter{Formatter: formater, style: style}
+}
+
+func (f *ChromaHighlighter) Format(w io.Writer, fileName string, src []byte) error {
+ var lexer chroma.Lexer
+
+ switch strings.ToLower(filepath.Ext(fileName)) {
+ case ".gno":
+ lexer = lexers.Get("go")
+ case ".md":
+ lexer = lexers.Get("markdown")
+ case ".mod":
+ lexer = lexers.Get("gomod")
+ default:
+ lexer = lexers.Get("txt") // file kind not supported, fallback on `.txt`
+ }
+
+ if lexer == nil {
+ return fmt.Errorf("unsupported lexer for file %q", fileName)
+ }
+
+ iterator, err := lexer.Tokenise(nil, string(src))
+ if err != nil {
+ return fmt.Errorf("unable to tokenise %q: %w ", fileName, err)
+ }
+
+ if err := f.Formatter.Format(w, f.style, iterator); err != nil {
+ return fmt.Errorf("unable to format source file %q: %w", fileName, err)
+ }
+
+ return nil
+}
+
+type noopHighlighter struct{}
+
+func (f *noopHighlighter) Format(w io.Writer, fileName string, src []byte) error {
+ _, err := w.Write(src)
+ return err
+}
diff --git a/gno.land/pkg/gnoweb/webclient.go b/gno.land/pkg/gnoweb/webclient.go
index a1005baa0a5..faff942dc41 100644
--- a/gno.land/pkg/gnoweb/webclient.go
+++ b/gno.land/pkg/gnoweb/webclient.go
@@ -17,25 +17,73 @@ import (
"github.com/yuin/goldmark/text"
)
+type FileMeta struct {
+ Lines int
+ SizeKb float64
+}
+
+type RealmMeta struct {
+ *md.Toc
+}
+
+// WebClient is an interface for interacting with web resources.
+type Client interface {
+ Render(w io.Writer, path string, args string) (*RealmMeta, error)
+ SourceFile(w io.Writer, pkgPath, fileName string) (*FileMeta, error)
+ Functions(path string) ([]vm.FunctionSignature, error)
+ Sources(path string) ([]string, error)
+}
+
+type WebClientConfig struct {
+ Domain string
+ UnsafeHTML bool
+ RPCClient *client.RPCClient
+ Highlighter Highlighter
+ Markdown goldmark.Markdown
+}
+
+func NewDefaultWebClientConfig(client *client.RPCClient) *WebClientConfig {
+ // Configure goldmark markdown options
+ mdopts := []goldmark.Option{goldmark.WithParserOptions(parser.WithAutoHeadingID())}
+ return &WebClientConfig{
+ Domain: "gno.land",
+ Highlighter: &noopHighlighter{},
+ Markdown: goldmark.New(mdopts...),
+ RPCClient: client,
+ }
+}
+
+// Validate checks if all elements of WebClientConfig are not nil.
+func (cfg *WebClientConfig) Validate() error {
+ if cfg.RPCClient == nil {
+ return errors.New("RPCClient must not be nil")
+ }
+
+ return nil
+}
+
type WebClient struct {
- logger *slog.Logger
- client *client.RPCClient
- md goldmark.Markdown
+ domain string
+ logger *slog.Logger
+ client *client.RPCClient
+ md goldmark.Markdown
+ highlighter Highlighter
}
-func NewWebClient(log *slog.Logger, cl *client.RPCClient, m goldmark.Markdown) *WebClient {
- m.Parser().AddOptions(parser.WithAutoHeadingID())
+func NewWebClient(log *slog.Logger, cfg *WebClientConfig) *WebClient {
return &WebClient{
- logger: log,
- client: cl,
- md: m,
+ logger: log,
+ domain: cfg.Domain,
+ client: cfg.RPCClient,
+ md: cfg.Markdown,
+ highlighter: cfg.Highlighter,
}
}
func (s *WebClient) Functions(pkgPath string) ([]vm.FunctionSignature, error) {
const qpath = "vm/qfuncs"
- args := fmt.Sprintf("gno.land/%s", strings.Trim(pkgPath, "/"))
+ args := fmt.Sprintf("%s/%s", s.domain, strings.Trim(pkgPath, "/"))
res, err := s.query(qpath, []byte(args))
if err != nil {
return nil, fmt.Errorf("unable query funcs list: %w", err)
@@ -50,7 +98,7 @@ func (s *WebClient) Functions(pkgPath string) ([]vm.FunctionSignature, error) {
return fsigs, nil
}
-func (s *WebClient) SourceFile(path, fileName string) ([]byte, error) {
+func (s *WebClient) SourceFile(w io.Writer, path, fileName string) (*FileMeta, error) {
const qpath = "vm/qfile"
fileName = strings.TrimSpace(fileName) // sanitize filename
@@ -59,16 +107,32 @@ func (s *WebClient) SourceFile(path, fileName string) ([]byte, error) {
}
// XXX: move this into gnoclient ?
- path = fmt.Sprintf("gno.land/%s", strings.Trim(path, "/"))
+ path = fmt.Sprintf("%s/%s", s.domain, strings.Trim(path, "/"))
path = filepath.Join(path, fileName)
- return s.query(qpath, []byte(path))
+
+ source, err := s.query(qpath, []byte(path))
+ if err != nil {
+ return nil, err
+ }
+
+ // XXX: we should either do this on the front or in the markdown parsing side
+ fileMeta := FileMeta{
+ Lines: strings.Count(string(source), "\n"),
+ SizeKb: float64(len(source)) / 1024.0,
+ }
+
+ if err := s.highlighter.Format(w, fileName, source); err != nil {
+ return nil, err
+ }
+
+ return &fileMeta, nil
}
func (s *WebClient) Sources(path string) ([]string, error) {
const qpath = "vm/qfile"
// XXX: move this into gnoclient
- path = fmt.Sprintf("gno.land/%s", strings.Trim(path, "/"))
+ path = fmt.Sprintf("%s/%s", s.domain, strings.Trim(path, "/"))
res, err := s.query(qpath, []byte(path))
if err != nil {
return nil, err
@@ -78,15 +142,12 @@ func (s *WebClient) Sources(path string) ([]string, error) {
return files, nil
}
-type Metadata struct {
- *md.Toc
-}
-
-func (s *WebClient) Render(w io.Writer, pkgPath string, args string) (*Metadata, error) {
+func (s *WebClient) Render(w io.Writer, pkgPath string, args string) (*RealmMeta, error) {
const qpath = "vm/qrender"
- data := []byte(gnoPath(pkgPath, args))
- rawres, err := s.query(qpath, data)
+ 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
}
@@ -96,7 +157,7 @@ func (s *WebClient) Render(w io.Writer, pkgPath string, args string) (*Metadata,
return nil, fmt.Errorf("unable render real %q: %w", data, err)
}
- var meta Metadata
+ var meta RealmMeta
meta.Toc, err = md.TocInspect(doc, rawres, md.TocOptions{MaxDepth: 6, MinDepth: 2})
if err != nil {
s.logger.Warn("unable to inspect for toc elements", "err", err)
@@ -120,8 +181,3 @@ func (s *WebClient) query(qpath string, data []byte) ([]byte, error) {
return qres.Response.Data, nil
}
-
-func gnoPath(pkgPath, args string) string {
- pkgPath = strings.Trim(pkgPath, "/")
- return fmt.Sprintf("gno.land/%s:%s", pkgPath, args)
-}
From c1433c0607296728cd1b6522a00c3751202f309e Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Thu, 19 Dec 2024 13:01:11 +0100
Subject: [PATCH 10/22] wip: cleanup weblient
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/webclient_html.go | 177 +++++++++++++++++++++
gno.land/pkg/gnoweb/webclient_mock.go | 83 ++++++++++
gno.land/pkg/gnoweb/webclient_mock_test.go | 84 ++++++++++
3 files changed, 344 insertions(+)
create mode 100644 gno.land/pkg/gnoweb/webclient_html.go
create mode 100644 gno.land/pkg/gnoweb/webclient_mock.go
create mode 100644 gno.land/pkg/gnoweb/webclient_mock_test.go
diff --git a/gno.land/pkg/gnoweb/webclient_html.go b/gno.land/pkg/gnoweb/webclient_html.go
new file mode 100644
index 00000000000..a08eef70508
--- /dev/null
+++ b/gno.land/pkg/gnoweb/webclient_html.go
@@ -0,0 +1,177 @@
+package gnoweb
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "path/filepath"
+ "strings"
+
+ md "github.com/gnolang/gno/gno.land/pkg/gnoweb/markdown"
+ "github.com/gnolang/gno/gno.land/pkg/sdk/vm" // for error types
+ "github.com/gnolang/gno/tm2/pkg/amino"
+ "github.com/gnolang/gno/tm2/pkg/bft/rpc/client"
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/text"
+)
+
+type HTMLWebClientConfig struct {
+ Domain string
+ UnsafeHTML bool
+ RPCClient *client.RPCClient
+ Highlighter Highlighter
+ Markdown goldmark.Markdown
+}
+
+// NewDefaultHTMLWebClientConfig initializes a WebClientConfig with default settings.
+// It sets up goldmark Markdown parsing options and default domain and highlighter.
+func NewDefaultHTMLWebClientConfig(client *client.RPCClient) *HTMLWebClientConfig {
+ mdopts := []goldmark.Option{goldmark.WithParserOptions(parser.WithAutoHeadingID())}
+ return &HTMLWebClientConfig{
+ Domain: "gno.land",
+ Highlighter: &noopHighlighter{},
+ Markdown: goldmark.New(mdopts...),
+ RPCClient: client,
+ }
+}
+
+type HTMLWebClient struct {
+ domain string
+ logger *slog.Logger
+ client *client.RPCClient
+ md goldmark.Markdown
+ highlighter Highlighter
+}
+
+// NewHTMLClient creates a new instance of WebClient.
+// It requires a configured logger and WebClientConfig.
+func NewHTMLClient(log *slog.Logger, cfg *HTMLWebClientConfig) *HTMLWebClient {
+ return &HTMLWebClient{
+ logger: log,
+ domain: cfg.Domain,
+ client: cfg.RPCClient,
+ md: cfg.Markdown,
+ highlighter: cfg.Highlighter,
+ }
+}
+
+// Functions retrieves a list of function signatures from a
+// specified package path.
+func (s *HTMLWebClient) Functions(pkgPath string) ([]vm.FunctionSignature, error) {
+ const qpath = "vm/qfuncs"
+
+ args := fmt.Sprintf("%s/%s", s.domain, strings.Trim(pkgPath, "/"))
+ res, err := s.query(qpath, []byte(args))
+ if err != nil {
+ return nil, fmt.Errorf("unable to query func list: %w", err)
+ }
+
+ var fsigs vm.FunctionSignatures
+ if err := amino.UnmarshalJSON(res, &fsigs); err != nil {
+ s.logger.Warn("unable to unmarshal function signatures, client is probably outdated")
+ return nil, fmt.Errorf("unable to unmarshal function signatures: %w", err)
+ }
+
+ return fsigs, nil
+}
+
+// SourceFile fetches and writes the source file from a given
+// package path and file name to the provided writer. It uses
+// Chroma for syntax highlighting source.
+func (s *HTMLWebClient) SourceFile(w io.Writer, path, fileName string) (*FileMeta, error) {
+ const qpath = "vm/qfile"
+
+ fileName = strings.TrimSpace(fileName)
+ if fileName == "" {
+ return nil, errors.New("empty filename given") // XXX: Consider creating a specific error variable
+ }
+
+ // XXX: Consider moving this into gnoclient
+ fullPath := filepath.Join(s.domain, strings.Trim(path, "/"), fileName)
+
+ source, err := s.query(qpath, []byte(fullPath))
+ if err != nil {
+ return nil, err
+ }
+
+ fileMeta := FileMeta{
+ Lines: strings.Count(string(source), "\n"),
+ SizeKb: float64(len(source)) / 1024.0,
+ }
+
+ // Use Chroma for syntax highlighting
+ if err := s.highlighter.Format(w, fileName, source); err != nil {
+ return nil, err
+ }
+
+ return &fileMeta, nil
+}
+
+// Sources lists all source files available in a specified
+// package path by querying the RPC client.
+func (s *HTMLWebClient) Sources(path string) ([]string, error) {
+ const qpath = "vm/qfile"
+
+ // XXX: Consider moving this into gnoclient
+ fullPath := fmt.Sprintf("%s/%s", s.domain, strings.Trim(path, "/"))
+ res, err := s.query(qpath, []byte(fullPath))
+ if err != nil {
+ return nil, err
+ }
+
+ files := strings.Split(strings.TrimSpace(string(res)), "\n")
+ return files, nil
+}
+
+// RenderRealm renders the content of a realm from a given path
+// and arguments into the provided writer. It uses Goldmark for
+// Markdown processing to generate HTML content.
+func (s *HTMLWebClient) RenderRealm(w io.Writer, pkgPath string, args string) (*RealmMeta, error) {
+ const qpath = "vm/qrender"
+
+ 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
+ }
+
+ // Use Goldmark for Markdown parsing
+ doc := s.md.Parser().Parse(text.NewReader(rawres))
+ if err := s.md.Renderer().Render(w, rawres, doc); err != nil {
+ return nil, fmt.Errorf("unable to render realm %q: %w", data, err)
+ }
+
+ var meta RealmMeta
+ meta.Toc, err = md.TocInspect(doc, rawres, md.TocOptions{MaxDepth: 6, MinDepth: 2})
+ if err != nil {
+ s.logger.Warn("unable to inspect for TOC elements", "error", err)
+ }
+
+ return &meta, nil
+}
+
+// query sends a query to the RPC client and returns the response
+// data.
+func (s *HTMLWebClient) query(qpath string, data []byte) ([]byte, error) {
+ s.logger.Info("query", "path", qpath, "data", string(data))
+
+ qres, err := s.client.ABCIQuery(qpath, data)
+ if err != nil {
+ s.logger.Debug("request error", "path", qpath, "data", string(data), "error", err)
+ return nil, fmt.Errorf("%w: %s", ErrClientBadRequest, err.Error())
+ }
+
+ if err = qres.Response.Error; err != nil {
+ if errors.Is(err, vm.InvalidPkgPathError{}) {
+ return nil, ErrClientPathNotFound
+ }
+
+ s.logger.Error("response error", "path", qpath, "log", qres.Response.Log)
+ return nil, fmt.Errorf("%w: %s", ErrClientResponse, err.Error())
+ }
+
+ return qres.Response.Data, nil
+}
diff --git a/gno.land/pkg/gnoweb/webclient_mock.go b/gno.land/pkg/gnoweb/webclient_mock.go
new file mode 100644
index 00000000000..0596aedbd20
--- /dev/null
+++ b/gno.land/pkg/gnoweb/webclient_mock.go
@@ -0,0 +1,83 @@
+package gnoweb
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "sort"
+
+ "github.com/gnolang/gno/gno.land/pkg/sdk/vm"
+)
+
+// MockPackage represents a mock package with files and function signatures.
+type MockPackage struct {
+ Domain string
+ Path string
+ Files map[string] /* filename */ string /* body */
+ Functions []vm.FunctionSignature
+}
+
+// MockWebClient is a mock implementation of the Client interface.
+type MockWebClient struct {
+ Packages map[string] /* path */ *MockPackage /* package */
+}
+
+// Render 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, errors.New("package not found")
+ }
+
+ fmt.Fprintf(w, "[%s]%s:%s
", pkg.Domain, pkg.Path)
+
+ // Return a dummy RealmMeta for simplicity
+ return &RealmMeta{}, nil
+}
+
+// SourceFile simulates retrieving a source file's metadata.
+func (m *MockWebClient) SourceFile(w io.Writer, pkgPath, fileName string) (*FileMeta, error) {
+ pkg, exists := m.Packages[pkgPath]
+ if !exists {
+ return nil, errors.New("package not found")
+ }
+
+ if body, ok := pkg.Files[fileName]; ok {
+ w.Write([]byte(body))
+ return &FileMeta{
+ Lines: len(bytes.Split([]byte(body), []byte("\n"))),
+ SizeKb: float64(len(body)) / 1024.0,
+ }, nil
+ }
+
+ return nil, errors.New("file not found")
+}
+
+// Functions simulates retrieving function signatures from a package.
+func (m *MockWebClient) Functions(path string) ([]vm.FunctionSignature, error) {
+ pkg, exists := m.Packages[path]
+ if !exists {
+ return nil, errors.New("package not found")
+ }
+
+ return pkg.Functions, nil
+}
+
+// Sources simulates listing all source files in a package.
+func (m *MockWebClient) Sources(path string) ([]string, error) {
+ pkg, exists := m.Packages[path]
+ if !exists {
+ return nil, errors.New("package not found")
+ }
+
+ fileNames := make([]string, 0, len(pkg.Files))
+ for file, _ := range pkg.Files {
+ fileNames = append(fileNames, file)
+ }
+
+ // Sort for consistency
+ sort.Strings(fileNames)
+
+ return fileNames, nil
+}
diff --git a/gno.land/pkg/gnoweb/webclient_mock_test.go b/gno.land/pkg/gnoweb/webclient_mock_test.go
new file mode 100644
index 00000000000..e9a11bd87e6
--- /dev/null
+++ b/gno.land/pkg/gnoweb/webclient_mock_test.go
@@ -0,0 +1,84 @@
+package gnoweb_test
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "sort"
+
+ "github.com/gnolang/gno/gno.land/pkg/gnoweb"
+ "github.com/gnolang/gno/gno.land/pkg/sdk/vm"
+)
+
+// MockPackage represents a mock package with files and function signatures.
+type MockPackage struct {
+ Domain string
+ Path string
+ Files map[string] /* filename */ string /* body */
+ Functions []vm.FunctionSignature
+}
+
+// MockClient is a mock implementation of the gnoweb.Client interface.
+type MockClient struct {
+ Packages map[string] /* path */ *MockPackage /* package */
+}
+
+// Render simulates rendering a package by writing its content to the writer.
+func (m *MockClient) RenderRealm((w io.Writer, path string, args string) (*gnoweb.RealmMeta, error) {
+ pkg, exists := m.Packages[path]
+ if !exists {
+ return nil, errors.New("package not found")
+ }
+
+ fmt.Fprintf(w, "[%s]%s:%s
", pkg.Domain, pkg.Path)
+
+ // Return a dummy RealmMeta for simplicity
+ return &gnoweb.RealmMeta{}, nil
+}
+
+// SourceFile simulates retrieving a source file's metadata.
+func (m *MockClient) SourceFile(w io.Writer, pkgPath, fileName string) (*gnoweb.FileMeta, error) {
+ pkg, exists := m.Packages[pkgPath]
+ if !exists {
+ return nil, errors.New("package not found")
+ }
+
+ if body, ok := pkg.Files[fileName]; ok {
+ w.Write([]byte(body))
+ return &gnoweb.FileMeta{
+ Lines: len(bytes.Split([]byte(body), []byte("\n"))),
+ SizeKb: float64(len(body)) / 1024.0,
+ }, nil
+ }
+
+ return nil, errors.New("file not found")
+}
+
+// Functions simulates retrieving function signatures from a package.
+func (m *MockClient) Functions(path string) ([]vm.FunctionSignature, error) {
+ pkg, exists := m.Packages[path]
+ if !exists {
+ return nil, errors.New("package not found")
+ }
+
+ return pkg.Functions, nil
+}
+
+// Sources simulates listing all source files in a package.
+func (m *MockClient) Sources(path string) ([]string, error) {
+ pkg, exists := m.Packages[path]
+ if !exists {
+ return nil, errors.New("package not found")
+ }
+
+ fileNames := make([]string, 0, len(pkg.Files))
+ for file, _ := range pkg.Files {
+ fileNames = append(fileNames, file)
+ }
+
+ // Sort for consistency
+ sort.Strings(fileNames)
+
+ return fileNames, nil
+}
From 9aa62cbd881487e7a636bc95d732b4f433ae8238 Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Thu, 19 Dec 2024 13:02:10 +0100
Subject: [PATCH 11/22] wip: rework handler
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/handler.go | 316 +++++++++++++++------------------
gno.land/pkg/gnoweb/url.go | 10 +-
2 files changed, 145 insertions(+), 181 deletions(-)
diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go
index c239ad09535..e4238d70e2e 100644
--- a/gno.land/pkg/gnoweb/handler.go
+++ b/gno.land/pkg/gnoweb/handler.go
@@ -14,9 +14,10 @@ import (
"time"
"github.com/gnolang/gno/gno.land/pkg/gnoweb/components"
- "github.com/gnolang/gno/gno.land/pkg/sdk/vm" // for error types
+ "github.com/gnolang/gno/gno.land/pkg/sdk/vm" // For error types
)
+// StaticMetadata holds static configuration for a web handler.
type StaticMetadata struct {
Domain string
AssetsPath string
@@ -26,32 +27,28 @@ type StaticMetadata struct {
Analytics bool
}
+// WebHandlerConfig configures a WebHandler.
type WebHandlerConfig struct {
- Meta StaticMetadata
-
- WebClient Client
- SourceFormatter Highlighter
+ Meta StaticMetadata
+ WebClient WebClient
}
+// validate checks if the WebHandlerConfig is valid.
func (cfg WebHandlerConfig) validate() error {
if cfg.WebClient == nil {
- return fmt.Errorf("no `Webclient` configured")
- }
-
- if cfg.SourceFormatter == nil {
- return fmt.Errorf("no `SourceFormatter` configured")
+ return errors.New("no `WebClient` configured")
}
-
return nil
}
+// WebHandler processes HTTP requests.
type WebHandler struct {
- logger *slog.Logger
-
+ Logger *slog.Logger
Static StaticMetadata
- Client Client
+ Client WebClient
}
+// NewWebHandler creates a new WebHandler.
func NewWebHandler(logger *slog.Logger, cfg WebHandlerConfig) (*WebHandler, error) {
if err := cfg.validate(); err != nil {
return nil, fmt.Errorf("config validate error: %w", err)
@@ -60,13 +57,13 @@ func NewWebHandler(logger *slog.Logger, cfg WebHandlerConfig) (*WebHandler, erro
return &WebHandler{
Client: cfg.WebClient,
Static: cfg.Meta,
-
- logger: logger,
+ Logger: logger,
}, nil
}
+// ServeHTTP handles HTTP requests.
func (h *WebHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- h.logger.Debug("receiving request", "method", r.Method, "path", r.URL.Path)
+ h.Logger.Debug("receiving request", "method", r.Method, "path", r.URL.Path)
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -76,47 +73,29 @@ func (h *WebHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.Get(w, r)
}
+// Get processes a GET HTTP request.
func (h *WebHandler) Get(w http.ResponseWriter, r *http.Request) {
var body bytes.Buffer
start := time.Now()
defer func() {
- h.logger.Debug("request completed",
+ h.Logger.Debug("request completed",
"url", r.URL.String(),
"elapsed", time.Since(start).String())
}()
- var indexData components.IndexData
- indexData.HeadData.AssetsPath = h.Static.AssetsPath
- indexData.HeadData.ChromaPath = h.Static.ChromaPath
- indexData.FooterData.Analytics = h.Static.Analytics
- indexData.FooterData.AssetsPath = h.Static.AssetsPath
-
- // Render the page body into the buffer
- var status int
- gnourl, err := ParseGnoURL(r.URL)
- if err != nil {
- h.logger.Warn("page not found", "path", r.URL.Path, "err", err)
- status, err = http.StatusNotFound, components.RenderStatusComponent(&body, "page not found")
- } else {
- // TODO: real data (title & description)
- indexData.HeadData.Title = "gno.land - " + gnourl.Path
-
- // Header
- indexData.HeaderData.RealmPath = gnourl.Path
- indexData.HeaderData.Breadcrumb.Parts = generateBreadcrumbPaths(gnourl.Path)
- indexData.HeaderData.WebQuery = gnourl.WebQuery
-
- // Render
- switch gnourl.Kind() {
- case KindRealm, KindPure:
- status, err = h.GetPackagePage(&body, gnourl)
- default:
- h.logger.Debug("invalid page kind", "kind", gnourl.Kind)
- status, err = http.StatusNotFound, components.RenderStatusComponent(&body, "page not found")
- }
+ indexData := components.IndexData{
+ HeadData: components.HeadData{
+ AssetsPath: h.Static.AssetsPath,
+ ChromaPath: h.Static.ChromaPath,
+ },
+ FooterData: components.FooterData{
+ Analytics: h.Static.Analytics,
+ AssetsPath: h.Static.AssetsPath,
+ },
}
+ status, err := h.renderPage(&body, r, &indexData)
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
@@ -129,151 +108,123 @@ func (h *WebHandler) Get(w http.ResponseWriter, r *http.Request) {
// Render the final page with the rendered body
if err = components.RenderIndexComponent(w, indexData); err != nil {
- h.logger.Error("failed to render index component", "err", err)
+ h.Logger.Error("failed to render index component", "err", err)
}
-
- return
}
-func (h *WebHandler) GetPackagePage(w io.Writer, gnourl *GnoURL) (status int, err error) {
- h.logger.Info("component render", "path", gnourl.Path, "args", gnourl.Args)
+// renderPage renders the page into the given buffer and prepares the index data.
+func (h *WebHandler) renderPage(body *bytes.Buffer, r *http.Request, indexData *components.IndexData) (int, error) {
+ gnourl, err := ParseGnoURL(r.URL)
+ if err != nil {
+ h.Logger.Warn("page not found", "path", r.URL.Path, "err", err)
+ return http.StatusNotFound, components.RenderStatusComponent(body, "page not found")
+ }
+
+ breadcrumb := components.BreadcrumbData{Parts: generateBreadcrumbPaths(gnourl.Path)}
+ indexData.HeadData.Title = h.Static.Domain + " - " + gnourl.Path
+ indexData.HeaderData = components.HeaderData{
+ RealmPath: gnourl.Path,
+ Breadcrumb: breadcrumb,
+ WebQuery: gnourl.WebQuery,
+ }
+
+ switch gnourl.Kind() {
+ case KindRealm, KindPure:
+ return h.GetPackagePage(body, gnourl)
+ default:
+ h.Logger.Debug("invalid page kind", "kind", gnourl.Kind())
+ return http.StatusNotFound, components.RenderStatusComponent(body, "page not found")
+ }
+}
- kind := gnourl.Kind()
+// GetPackagePage handles package pages.
+func (h *WebHandler) GetPackagePage(w io.Writer, gnourl *GnoURL) (int, error) {
+ h.Logger.Info("component render", "path", gnourl.Path, "args", gnourl.Args)
- // Display realm help page?
- if kind == KindRealm && gnourl.WebQuery.Has("help") {
+ // Handle Help page
+ if gnourl.Kind() == KindRealm && gnourl.WebQuery.Has("help") {
return h.GetHelpPage(w, gnourl)
}
- // Display package source page?
+ // Handle Source page
switch {
case gnourl.WebQuery.Has("source"):
return h.GetSource(w, gnourl)
- case kind == KindPure, gnourl.IsFile(), gnourl.IsDir():
- i := strings.LastIndexByte(gnourl.Path, '/')
- if i < 0 {
- return http.StatusInternalServerError, fmt.Errorf("unable to get ending slash for %q", gnourl.Path)
- }
+ case gnourl.Kind() == KindPure, gnourl.IsFile(), gnourl.IsDir():
+ return h.handleFilePage(w, gnourl)
+ }
- // Fill webquery with file infos
- gnourl.WebQuery.Set("source", "") // set source
+ // Ultimately render realm content
+ return h.renderRealmContent(w, gnourl)
+}
- file := gnourl.Path[i+1:]
- // If there nothing after the last slash that mean its a
- // directory ...
- if file == "" {
- return h.GetDirectoryPage(w, gnourl)
- }
+// handleFilePage processes pages that involve file handling.
+func (h *WebHandler) handleFilePage(w io.Writer, gnourl *GnoURL) (int, error) {
+ i := strings.LastIndexByte(gnourl.Path, '/')
+ if i < 0 {
+ return http.StatusInternalServerError, fmt.Errorf("unable to get ending slash for %q", gnourl.Path)
+ }
- // ... else, remaining part is a file
- gnourl.WebQuery.Set("file", file)
- gnourl.Path = gnourl.Path[:i]
+ gnourl.WebQuery.Set("source", "")
- return h.GetSource(w, gnourl)
+ file := gnourl.Path[i+1:]
+ if file == "" {
+ return h.GetDirectoryPage(w, gnourl)
}
- // Render content into the content buffer
+ gnourl.WebQuery.Set("file", file)
+ gnourl.Path = gnourl.Path[:i]
+
+ return h.GetSource(w, gnourl)
+}
+
+// renderRealmContent renders the content of a realm.
+func (h *WebHandler) renderRealmContent(w io.Writer, gnourl *GnoURL) (int, error) {
var content bytes.Buffer
- meta, err := h.Client.Render(&content, gnourl.Path, gnourl.EncodeArgs())
+ meta, err := h.Client.RenderRealm(&content, gnourl.Path, gnourl.EncodeArgs())
if err != nil {
- if errors.Is(err, vm.InvalidPkgPathError{}) {
- return http.StatusNotFound, components.RenderStatusComponent(w, "not found")
- }
-
- h.logger.Error("unable to render markdown", "err", err)
- return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
+ h.Logger.Error("unable to render realm", "err", err, "path", gnourl.EncodePath())
+ return renderClientErrorStatusPage(w, gnourl, err)
}
err = components.RenderRealmComponent(w, components.RealmData{
TocItems: &components.RealmTOCData{
Items: meta.Items,
},
- // NOTE: `content` should have already been escaped by
+ // NOTE: `RenderRealm` should ensure that HTML content is
+ // sanitized before rendering
Content: template.HTML(content.String()), //nolint:gosec
})
if err != nil {
- h.logger.Error("unable to render template", "err", err)
- return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
- }
-
- // Write the rendered content to the response writer
- return http.StatusOK, nil
-}
-
-func (h *WebHandler) GetHelpPage(w io.Writer, gnourl *GnoURL) (status int, err error) {
- fsigs, err := h.Client.Functions(gnourl.Path)
- if err != nil {
- h.logger.Error("unable to fetch path functions", "err", err)
- return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
- }
-
- var selArgs map[string]string
- var selFn string
- if selFn = gnourl.WebQuery.Get("func"); selFn != "" {
- for _, fn := range fsigs {
- if selFn != fn.FuncName {
- continue
- }
-
- selArgs = make(map[string]string)
- for _, param := range fn.Params {
- selArgs[param.Name] = gnourl.WebQuery.Get(param.Name)
- }
-
- fsigs = []vm.FunctionSignature{fn}
- break
- }
- }
-
- // Catch last name of the path
- // XXX: we should probably add a helper within the template
- realmName := filepath.Base(gnourl.Path)
- err = components.RenderHelpComponent(w, components.HelpData{
- SelectedFunc: selFn,
- SelectedArgs: selArgs,
- RealmName: realmName,
- ChainId: h.Static.ChainId,
- // TODO: get chain domain and use that.
- PkgPath: filepath.Join(h.Static.Domain, gnourl.Path),
- Remote: h.Static.RemoteHelp,
- Functions: fsigs,
- })
- if err != nil {
- h.logger.Error("unable to render helper", "err", err)
+ h.Logger.Error("unable to render template", "err", err)
return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
}
return http.StatusOK, nil
-
}
-func (h *WebHandler) GetRealmPage(w io.Writer, gnourl *GnoURL) (status int, err error) {
+// GetHelpPage renders the help page.
+func (h *WebHandler) GetHelpPage(w io.Writer, gnourl *GnoURL) (int, error) {
fsigs, err := h.Client.Functions(gnourl.Path)
if err != nil {
- h.logger.Error("unable to fetch path functions", "err", err)
- return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
+ h.Logger.Error("unable to fetch path functions", "err", err)
+ return renderClientErrorStatusPage(w, gnourl, err)
}
- var selArgs map[string]string
- var selFn string
- if selFn = gnourl.WebQuery.Get("func"); selFn != "" {
+ selArgs := make(map[string]string)
+ selFn := gnourl.WebQuery.Get("func")
+ if selFn != "" {
for _, fn := range fsigs {
- if selFn != fn.FuncName {
- continue
- }
-
- selArgs = make(map[string]string)
- for _, param := range fn.Params {
- selArgs[param.Name] = gnourl.WebQuery.Get(param.Name)
+ if selFn == fn.FuncName {
+ for _, param := range fn.Params {
+ selArgs[param.Name] = gnourl.WebQuery.Get(param.Name)
+ }
+ fsigs = []vm.FunctionSignature{fn}
+ break
}
-
- fsigs = []vm.FunctionSignature{fn}
- break
}
}
- // Catch last name of the path
- // XXX: we should probably add a helper within the template
realmName := filepath.Base(gnourl.Path)
err = components.RenderHelpComponent(w, components.HelpData{
SelectedFunc: selFn,
@@ -286,46 +237,40 @@ func (h *WebHandler) GetRealmPage(w io.Writer, gnourl *GnoURL) (status int, err
Functions: fsigs,
})
if err != nil {
- h.logger.Error("unable to render helper", "err", err)
+ h.Logger.Error("unable to render helper", "err", err)
return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
}
return http.StatusOK, nil
}
-func (h *WebHandler) GetSource(w io.Writer, gnourl *GnoURL) (status int, err error) {
+// GetSource renders the source page.
+func (h *WebHandler) GetSource(w io.Writer, gnourl *GnoURL) (int, error) {
pkgPath := gnourl.Path
files, err := h.Client.Sources(pkgPath)
if err != nil {
- h.logger.Error("unable to list sources file", "path", gnourl.Path, "err", err)
- return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
+ h.Logger.Error("unable to list sources file", "path", gnourl.Path, "err", err)
+ return renderClientErrorStatusPage(w, gnourl, err)
}
if len(files) == 0 {
- h.logger.Debug("no files available", "path", gnourl.Path)
+ h.Logger.Debug("no files available", "path", gnourl.Path)
return http.StatusOK, components.RenderStatusComponent(w, "no files available")
}
- var fileName string
- file := gnourl.WebQuery.Get("file")
- if file == "" {
+ fileName := gnourl.WebQuery.Get("file")
+ if fileName == "" || !slices.Contains(files, fileName) {
fileName = files[0]
- } else if slices.Contains(files, file) {
- fileName = file
- } else {
- h.logger.Error("unable to render source", "file", file, "err", "file does not exist")
- return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
}
var source bytes.Buffer
meta, err := h.Client.SourceFile(&source, pkgPath, fileName)
if err != nil {
- h.logger.Error("unable to get source file", "file", fileName, "err", err)
- return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
+ h.Logger.Error("unable to get source file", "file", fileName, "err", err)
+ return renderClientErrorStatusPage(w, gnourl, err)
}
- // XXX: we should either do this on the front or in the markdown parsing side
fileSizeStr := fmt.Sprintf("%.2f Kb", meta.SizeKb)
err = components.RenderSourceComponent(w, components.SourceData{
PkgPath: gnourl.Path,
@@ -337,24 +282,25 @@ func (h *WebHandler) GetSource(w io.Writer, gnourl *GnoURL) (status int, err err
FileSource: template.HTML(source.String()), //nolint:gosec
})
if err != nil {
- h.logger.Error("unable to render helper", "err", err)
+ h.Logger.Error("unable to render helper", "err", err)
return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
}
return http.StatusOK, nil
}
-func (h *WebHandler) GetDirectoryPage(w io.Writer, gnourl *GnoURL) (status int, err error) {
+// GetDirectoryPage renders the directory page.
+func (h *WebHandler) GetDirectoryPage(w io.Writer, gnourl *GnoURL) (int, error) {
pkgPath := gnourl.Path
files, err := h.Client.Sources(pkgPath)
if err != nil {
- h.logger.Error("unable to list sources file", "path", gnourl.Path, "err", err)
- return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
+ h.Logger.Error("unable to list sources file", "path", gnourl.Path, "err", err)
+ return renderClientErrorStatusPage(w, gnourl, err)
}
if len(files) == 0 {
- h.logger.Debug("no files available", "path", gnourl.Path)
+ h.Logger.Debug("no files available", "path", gnourl.Path)
return http.StatusOK, components.RenderStatusComponent(w, "no files available")
}
@@ -364,20 +310,38 @@ func (h *WebHandler) GetDirectoryPage(w io.Writer, gnourl *GnoURL) (status int,
FileCounter: len(files),
})
if err != nil {
- h.logger.Error("unable to render directory", "err", err)
- return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
+ h.Logger.Error("unable to render directory", "err", err)
+ return http.StatusInternalServerError, components.RenderStatusComponent(w, "not found")
}
return http.StatusOK, nil
}
+func renderClientErrorStatusPage(w io.Writer, u *GnoURL, err error) (int, error) {
+ if err == nil {
+ return http.StatusOK, nil
+ }
+
+ if errors.Is(err, ErrClientPathNotFound) {
+ msg := fmt.Sprintf(`not found: "%s"`, u.Path)
+ return http.StatusNotFound, components.RenderStatusComponent(w, msg)
+ }
+
+ if errors.Is(err, ErrClientBadRequest) || errors.Is(err, ErrClientResponse) {
+ return http.StatusNotFound, components.RenderStatusComponent(w, err.Error())
+ }
+
+ return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
+}
+
+// generateBreadcrumbPaths creates breadcrumb paths from a given path.
+// XXX: This should probably be a template helper function.
func generateBreadcrumbPaths(path string) []components.BreadcrumbPart {
split := strings.Split(path, "/")
- parts := []components.BreadcrumbPart{}
+ parts := make([]components.BreadcrumbPart, 0, len(split))
- var name string
- for i := range split {
- if name = split[i]; name == "" {
+ for i, name := range split {
+ if name == "" {
continue
}
diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go
index 2130d7332bd..37368fed8c9 100644
--- a/gno.land/pkg/gnoweb/url.go
+++ b/gno.land/pkg/gnoweb/url.go
@@ -9,6 +9,11 @@ import (
"strings"
)
+var (
+ ErrURLMalformedPath = errors.New("malformed path")
+ ErrURLInvalidPathKind = errors.New("invalid path kind")
+)
+
type PathKind byte
const (
@@ -95,11 +100,6 @@ func (gnoURL GnoURL) IsFile() bool {
return filepath.Ext(gnoURL.Path) != ""
}
-var (
- ErrURLMalformedPath = errors.New("malformed path")
- ErrURLInvalidPathKind = errors.New("invalid path kind")
-)
-
// ParseGnoURL parses a URL into a GnoURL structure, extracting and validating its components.
func ParseGnoURL(u *url.URL) (*GnoURL, error) {
var webargs string
From 7ba7de49480e3c2cc062cc1bc60edc0a3e09dbbe Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Thu, 19 Dec 2024 13:02:17 +0100
Subject: [PATCH 12/22] wip: rework webclient 2
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/webclient.go | 182 +++------------------
gno.land/pkg/gnoweb/webclient_mock_test.go | 84 ----------
2 files changed, 22 insertions(+), 244 deletions(-)
delete mode 100644 gno.land/pkg/gnoweb/webclient_mock_test.go
diff --git a/gno.land/pkg/gnoweb/webclient.go b/gno.land/pkg/gnoweb/webclient.go
index faff942dc41..3077d1b99a5 100644
--- a/gno.land/pkg/gnoweb/webclient.go
+++ b/gno.land/pkg/gnoweb/webclient.go
@@ -2,19 +2,16 @@ package gnoweb
import (
"errors"
- "fmt"
"io"
- "log/slog"
- "path/filepath"
- "strings"
md "github.com/gnolang/gno/gno.land/pkg/gnoweb/markdown"
"github.com/gnolang/gno/gno.land/pkg/sdk/vm" // for error types
- "github.com/gnolang/gno/tm2/pkg/amino"
- "github.com/gnolang/gno/tm2/pkg/bft/rpc/client"
- "github.com/yuin/goldmark"
- "github.com/yuin/goldmark/parser"
- "github.com/yuin/goldmark/text"
+)
+
+var (
+ ErrClientPathNotFound = errors.New("package path not found")
+ ErrClientBadRequest = errors.New("bad request")
+ ErrClientResponse = errors.New("node response error") // Corrected typo in "response"
)
type FileMeta struct {
@@ -26,158 +23,23 @@ type RealmMeta struct {
*md.Toc
}
-// WebClient is an interface for interacting with web resources.
-type Client interface {
- Render(w io.Writer, path string, args string) (*RealmMeta, error)
- SourceFile(w io.Writer, pkgPath, fileName string) (*FileMeta, error)
- Functions(path string) ([]vm.FunctionSignature, error)
- Sources(path string) ([]string, error)
-}
-
-type WebClientConfig struct {
- Domain string
- UnsafeHTML bool
- RPCClient *client.RPCClient
- Highlighter Highlighter
- Markdown goldmark.Markdown
-}
-
-func NewDefaultWebClientConfig(client *client.RPCClient) *WebClientConfig {
- // Configure goldmark markdown options
- mdopts := []goldmark.Option{goldmark.WithParserOptions(parser.WithAutoHeadingID())}
- return &WebClientConfig{
- Domain: "gno.land",
- Highlighter: &noopHighlighter{},
- Markdown: goldmark.New(mdopts...),
- RPCClient: client,
- }
-}
-
-// Validate checks if all elements of WebClientConfig are not nil.
-func (cfg *WebClientConfig) Validate() error {
- if cfg.RPCClient == nil {
- return errors.New("RPCClient must not be nil")
- }
-
- return nil
-}
-
-type WebClient struct {
- domain string
- logger *slog.Logger
- client *client.RPCClient
- md goldmark.Markdown
- highlighter Highlighter
-}
-
-func NewWebClient(log *slog.Logger, cfg *WebClientConfig) *WebClient {
- return &WebClient{
- logger: log,
- domain: cfg.Domain,
- client: cfg.RPCClient,
- md: cfg.Markdown,
- highlighter: cfg.Highlighter,
- }
-}
-
-func (s *WebClient) Functions(pkgPath string) ([]vm.FunctionSignature, error) {
- const qpath = "vm/qfuncs"
-
- args := fmt.Sprintf("%s/%s", s.domain, strings.Trim(pkgPath, "/"))
- res, err := s.query(qpath, []byte(args))
- if err != nil {
- return nil, fmt.Errorf("unable query funcs list: %w", err)
- }
-
- var fsigs vm.FunctionSignatures
- if err := amino.UnmarshalJSON(res, &fsigs); err != nil {
- s.logger.Warn("unable to unmarshal fsigs, client is probably outdated ?")
- return nil, fmt.Errorf("unable to unamarshal fsigs: %w", err)
- }
-
- return fsigs, nil
-}
+// WebClient is an interface for interacting with package ressources.
+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
+ // content is safely handled and formatted.
+ RenderRealm(w io.Writer, path string, args string) (*RealmMeta, error)
-func (s *WebClient) SourceFile(w io.Writer, path, fileName string) (*FileMeta, error) {
- const qpath = "vm/qfile"
-
- fileName = strings.TrimSpace(fileName) // sanitize filename
- if fileName == "" {
- return nil, errors.New("empty filename given") // XXX -> ErrXXX
- }
-
- // XXX: move this into gnoclient ?
- path = fmt.Sprintf("%s/%s", s.domain, strings.Trim(path, "/"))
- path = filepath.Join(path, fileName)
-
- source, err := s.query(qpath, []byte(path))
- if err != nil {
- return nil, err
- }
-
- // XXX: we should either do this on the front or in the markdown parsing side
- fileMeta := FileMeta{
- Lines: strings.Count(string(source), "\n"),
- SizeKb: float64(len(source)) / 1024.0,
- }
-
- if err := s.highlighter.Format(w, fileName, source); err != nil {
- return nil, err
- }
-
- return &fileMeta, nil
-}
-
-func (s *WebClient) Sources(path string) ([]string, error) {
- const qpath = "vm/qfile"
-
- // XXX: move this into gnoclient
- path = fmt.Sprintf("%s/%s", s.domain, strings.Trim(path, "/"))
- res, err := s.query(qpath, []byte(path))
- if err != nil {
- return nil, err
- }
-
- files := strings.Split(string(res), "\n")
- return files, nil
-}
-
-func (s *WebClient) Render(w io.Writer, pkgPath string, args string) (*RealmMeta, error) {
- const qpath = "vm/qrender"
-
- 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
- }
-
- doc := s.md.Parser().Parse(text.NewReader(rawres))
- if err := s.md.Renderer().Render(w, rawres, doc); err != nil {
- return nil, fmt.Errorf("unable render real %q: %w", data, err)
- }
-
- var meta RealmMeta
- meta.Toc, err = md.TocInspect(doc, rawres, md.TocOptions{MaxDepth: 6, MinDepth: 2})
- if err != nil {
- s.logger.Warn("unable to inspect for toc elements", "err", err)
- }
-
- return &meta, nil
-}
-
-func (s *WebClient) query(qpath string, data []byte) ([]byte, error) {
- s.logger.Info("query", "qpath", qpath, "data", string(data))
+ // SourceFile fetches and writes the source file from a given
+ // package path and file name. The method should ensures the source
+ // file's content is safely handled and formatted.
+ SourceFile(w io.Writer, pkgPath, fileName string) (*FileMeta, error)
- qres, err := s.client.ABCIQuery(qpath, data)
- if err != nil {
- s.logger.Error("request error", "path", qpath, "data", string(data), "error", err)
- return nil, fmt.Errorf("unable to query path %q: %w", qpath, err)
- }
- if qres.Response.Error != nil {
- s.logger.Error("response error", "path", qpath, "log", qres.Response.Log)
- return nil, qres.Response.Error
- }
+ // Functions retrieves a list of function signatures from a
+ // specified package path.
+ Functions(path string) ([]vm.FunctionSignature, error)
- return qres.Response.Data, nil
+ // Sources lists all source files available in a specified
+ // package path.
+ Sources(path string) ([]string, error)
}
diff --git a/gno.land/pkg/gnoweb/webclient_mock_test.go b/gno.land/pkg/gnoweb/webclient_mock_test.go
deleted file mode 100644
index e9a11bd87e6..00000000000
--- a/gno.land/pkg/gnoweb/webclient_mock_test.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package gnoweb_test
-
-import (
- "bytes"
- "errors"
- "fmt"
- "io"
- "sort"
-
- "github.com/gnolang/gno/gno.land/pkg/gnoweb"
- "github.com/gnolang/gno/gno.land/pkg/sdk/vm"
-)
-
-// MockPackage represents a mock package with files and function signatures.
-type MockPackage struct {
- Domain string
- Path string
- Files map[string] /* filename */ string /* body */
- Functions []vm.FunctionSignature
-}
-
-// MockClient is a mock implementation of the gnoweb.Client interface.
-type MockClient struct {
- Packages map[string] /* path */ *MockPackage /* package */
-}
-
-// Render simulates rendering a package by writing its content to the writer.
-func (m *MockClient) RenderRealm((w io.Writer, path string, args string) (*gnoweb.RealmMeta, error) {
- pkg, exists := m.Packages[path]
- if !exists {
- return nil, errors.New("package not found")
- }
-
- fmt.Fprintf(w, "[%s]%s:%s
", pkg.Domain, pkg.Path)
-
- // Return a dummy RealmMeta for simplicity
- return &gnoweb.RealmMeta{}, nil
-}
-
-// SourceFile simulates retrieving a source file's metadata.
-func (m *MockClient) SourceFile(w io.Writer, pkgPath, fileName string) (*gnoweb.FileMeta, error) {
- pkg, exists := m.Packages[pkgPath]
- if !exists {
- return nil, errors.New("package not found")
- }
-
- if body, ok := pkg.Files[fileName]; ok {
- w.Write([]byte(body))
- return &gnoweb.FileMeta{
- Lines: len(bytes.Split([]byte(body), []byte("\n"))),
- SizeKb: float64(len(body)) / 1024.0,
- }, nil
- }
-
- return nil, errors.New("file not found")
-}
-
-// Functions simulates retrieving function signatures from a package.
-func (m *MockClient) Functions(path string) ([]vm.FunctionSignature, error) {
- pkg, exists := m.Packages[path]
- if !exists {
- return nil, errors.New("package not found")
- }
-
- return pkg.Functions, nil
-}
-
-// Sources simulates listing all source files in a package.
-func (m *MockClient) Sources(path string) ([]string, error) {
- pkg, exists := m.Packages[path]
- if !exists {
- return nil, errors.New("package not found")
- }
-
- fileNames := make([]string, 0, len(pkg.Files))
- for file, _ := range pkg.Files {
- fileNames = append(fileNames, file)
- }
-
- // Sort for consistency
- sort.Strings(fileNames)
-
- return fileNames, nil
-}
From 0ad68930c60f0e2092e49701e9b17449ea5fa14a Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Thu, 19 Dec 2024 13:02:24 +0100
Subject: [PATCH 13/22] wip: rework app
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/app.go | 61 +++++++++++++++++++-------------------
1 file changed, 30 insertions(+), 31 deletions(-)
diff --git a/gno.land/pkg/gnoweb/app.go b/gno.land/pkg/gnoweb/app.go
index c370ba9d3e5..6e25544b369 100644
--- a/gno.land/pkg/gnoweb/app.go
+++ b/gno.land/pkg/gnoweb/app.go
@@ -44,9 +44,7 @@ type AppConfig struct {
// to be served on /public/.
func NewDefaultAppConfig() *AppConfig {
const defaultRemote = "127.0.0.1:26657"
-
return &AppConfig{
- // same as Remote by default
NodeRemote: defaultRemote,
RemoteHelp: defaultRemote,
ChainID: "dev",
@@ -55,7 +53,7 @@ func NewDefaultAppConfig() *AppConfig {
}
}
-var chromaStyle = mustGetStyle("friendly")
+var chromaDefaultStyle = mustGetStyle("friendly")
func mustGetStyle(name string) *chroma.Style {
s := styles.Get(name)
@@ -65,13 +63,15 @@ func mustGetStyle(name string) *chroma.Style {
return s
}
-// NewRouter initializes the gnoweb router, with the given logger and config.
+// NewRouter initializes the gnoweb router with the specified logger and configuration.
func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) {
+ // Initialize RPC Client
client, err := client.NewHTTPClient(cfg.NodeRemote)
if err != nil {
- return nil, fmt.Errorf("unable to create http client: %w", err)
+ return nil, fmt.Errorf("unable to create HTTP client: %w", err)
}
+ // Configure Chroma highlighter
chromaOptions := []chromahtml.Option{
chromahtml.WithLineNumbers(true),
chromahtml.WithLinkableLineNumbers(true, "L"),
@@ -80,6 +80,7 @@ func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) {
}
chroma := chromahtml.New(chromaOptions...)
+ // Configure Goldmark markdown parser
mdopts := []goldmark.Option{
goldmark.WithExtensions(
markdown.NewHighlighting(
@@ -90,41 +91,41 @@ func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) {
if cfg.UnsafeHTML {
mdopts = append(mdopts, goldmark.WithRendererOptions(mdhtml.WithXHTML(), mdhtml.WithUnsafe()))
}
-
md := goldmark.New(mdopts...)
- webcfg := WebClientConfig{
+ // Configure WebClient
+ webcfg := HTMLWebClientConfig{
Markdown: md,
- Highlighter: NewChromaHighlighter(chroma, chromaStyle),
+ Highlighter: NewChromaHighlighter(chroma, chromaDefaultStyle),
Domain: cfg.Domain,
UnsafeHTML: cfg.UnsafeHTML,
RPCClient: client,
}
- webcli := NewWebClient(logger, &webcfg)
+ webcli := NewHTMLClient(logger, &webcfg)
chromaStylePath := path.Join(cfg.AssetsPath, "_chroma", "style.css")
- var webConfig WebHandlerConfig
-
- webConfig.WebClient = webcli
-
- // Static meta
- webConfig.Meta.Domain = cfg.Domain
- webConfig.Meta.AssetsPath = cfg.AssetsPath
- webConfig.Meta.ChromaPath = chromaStylePath
- webConfig.Meta.RemoteHelp = cfg.RemoteHelp
- webConfig.Meta.ChainId = cfg.ChainID
- webConfig.Meta.Analytics = cfg.Analytics
+ // Setup StaticMetadata
+ staticMeta := StaticMetadata{
+ Domain: cfg.Domain,
+ AssetsPath: cfg.AssetsPath,
+ ChromaPath: chromaStylePath,
+ RemoteHelp: cfg.RemoteHelp,
+ ChainId: cfg.ChainID,
+ Analytics: cfg.Analytics,
+ }
- // Setup main handler
+ // Configure WebHandler
+ webConfig := WebHandlerConfig{WebClient: webcli, Meta: staticMeta}
webhandler, err := NewWebHandler(logger, webConfig)
if err != nil {
- return nil, fmt.Errorf("unable create web handler: %w", err)
+ return nil, fmt.Errorf("unable to create web handler: %w", err)
}
+ // Setup HTTP muxer
mux := http.NewServeMux()
- // Setup Webahndler along Alias Middleware
+ // Handle web handler with alias middleware
mux.Handle("/", AliasAndRedirectMiddleware(webhandler, cfg.Analytics))
// Register faucet URL to `/faucet` if specified
@@ -138,22 +139,20 @@ func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) {
}))
}
- // Setup assets
+ // Handle Chroma CSS requests
+ // XXX: probably move this
mux.Handle(chromaStylePath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // Setup Formatter
w.Header().Set("Content-Type", "text/css")
- if err := chroma.WriteCSS(w, chromaStyle); err != nil {
- logger.Error("unable to write css", "err", err)
+ if err := chroma.WriteCSS(w, chromaDefaultStyle); err != nil {
+ logger.Error("unable to write CSS", "err", err)
http.NotFound(w, r)
}
}))
- // Normalize assets path
- assetsBase := "/" + strings.Trim(cfg.AssetsPath, "/") + "/"
-
// Handle assets path
+ assetsBase := "/" + strings.Trim(cfg.AssetsPath, "/") + "/"
if cfg.AssetsDir != "" {
- logger.Debug("using assets dir instead of embed assets", "dir", cfg.AssetsDir)
+ logger.Debug("using assets dir instead of embedded assets", "dir", cfg.AssetsDir)
mux.Handle(assetsBase, DevAssetHandler(assetsBase, cfg.AssetsDir))
} else {
mux.Handle(assetsBase, AssetHandler())
From c135697c637b12e7fbcb3f202de094d1d4fb3c37 Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Thu, 19 Dec 2024 13:30:34 +0100
Subject: [PATCH 14/22] wip: handler mock
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/cmd/gnoweb/main.go | 14 +++++++-------
gno.land/pkg/gnoweb/webclient.go | 4 ++--
gno.land/pkg/gnoweb/webclient_mock.go | 2 +-
3 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/gno.land/cmd/gnoweb/main.go b/gno.land/cmd/gnoweb/main.go
index 6500e44fcc4..8c0df00aa35 100644
--- a/gno.land/cmd/gnoweb/main.go
+++ b/gno.land/cmd/gnoweb/main.go
@@ -144,7 +144,6 @@ func setupWeb(cfg *webCfg, _ []string, io commands.IO) (func() error, error) {
if cfg.verbose {
level = zapcore.DebugLevel
}
-
var zapLogger *zap.Logger
if cfg.json {
zapLogger = log.NewZapJSONLogger(io.Out(), level)
@@ -155,23 +154,24 @@ func setupWeb(cfg *webCfg, _ []string, io commands.IO) (func() error, error) {
logger := log.ZapLoggerToSlog(zapLogger)
+ // Setup app
appcfg := gnoweb.NewDefaultAppConfig()
appcfg.ChainID = cfg.chainid
appcfg.NodeRemote = cfg.remote
appcfg.RemoteHelp = cfg.remoteHelp
+ if appcfg.RemoteHelp == "" {
+ appcfg.RemoteHelp = appcfg.NodeRemote
+ }
appcfg.Analytics = cfg.analytics
appcfg.UnsafeHTML = cfg.html
appcfg.FaucetURL = cfg.faucetURL
appcfg.AssetsDir = cfg.assetsDir
- if appcfg.RemoteHelp == "" {
- appcfg.RemoteHelp = appcfg.NodeRemote
- }
-
app, err := gnoweb.NewRouter(logger, appcfg)
if err != nil {
return nil, fmt.Errorf("unable to start gnoweb app: %w", err)
}
+ // Resolve binding address
bindaddr, err := net.ResolveTCPAddr("tcp", cfg.bind)
if err != nil {
return nil, fmt.Errorf("unable to resolve listener %q: %w", cfg.bind, err)
@@ -179,6 +179,7 @@ func setupWeb(cfg *webCfg, _ []string, io commands.IO) (func() error, error) {
logger.Info("Running", "listener", bindaddr.String())
+ // Setup server
server := &http.Server{
Handler: app,
Addr: bindaddr.String(),
@@ -187,10 +188,9 @@ func setupWeb(cfg *webCfg, _ []string, io commands.IO) (func() error, error) {
return func() error {
if err := server.ListenAndServe(); err != nil {
- logger.Error("HTTP server stopped", " error:", err)
+ logger.Error("HTTP server stopped", "error", err)
return commands.ExitCodeError(1)
}
-
return nil
}, nil
}
diff --git a/gno.land/pkg/gnoweb/webclient.go b/gno.land/pkg/gnoweb/webclient.go
index 3077d1b99a5..1a67050fdd4 100644
--- a/gno.land/pkg/gnoweb/webclient.go
+++ b/gno.land/pkg/gnoweb/webclient.go
@@ -5,13 +5,13 @@ import (
"io"
md "github.com/gnolang/gno/gno.land/pkg/gnoweb/markdown"
- "github.com/gnolang/gno/gno.land/pkg/sdk/vm" // for error types
+ "github.com/gnolang/gno/gno.land/pkg/sdk/vm"
)
var (
ErrClientPathNotFound = errors.New("package path not found")
ErrClientBadRequest = errors.New("bad request")
- ErrClientResponse = errors.New("node response error") // Corrected typo in "response"
+ ErrClientResponse = errors.New("node response error")
)
type FileMeta struct {
diff --git a/gno.land/pkg/gnoweb/webclient_mock.go b/gno.land/pkg/gnoweb/webclient_mock.go
index 0596aedbd20..af865ecb194 100644
--- a/gno.land/pkg/gnoweb/webclient_mock.go
+++ b/gno.land/pkg/gnoweb/webclient_mock.go
@@ -30,7 +30,7 @@ func (m *MockWebClient) RenderRealm((w io.Writer, path string, args string) (*Re
return nil, errors.New("package not found")
}
- fmt.Fprintf(w, "[%s]%s:%s
", pkg.Domain, pkg.Path)
+ fmt.Fprintf(w, "[%s]%s:%s: lorem ipsum", pkg.Domain, pkg.Path)
// Return a dummy RealmMeta for simplicity
return &RealmMeta{}, nil
From 0490da16f03d07020c29e0cd0ee5cff4c9ad2735 Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Thu, 19 Dec 2024 15:55:47 +0100
Subject: [PATCH 15/22] feat: add handler test
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/handler.go | 34 +++++++++++++--------------
gno.land/pkg/gnoweb/markdown/toc.go | 4 ++--
gno.land/pkg/gnoweb/webclient.go | 4 ++--
gno.land/pkg/gnoweb/webclient_html.go | 3 ++-
gno.land/pkg/gnoweb/webclient_mock.go | 17 ++++++++++----
5 files changed, 36 insertions(+), 26 deletions(-)
diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go
index e4238d70e2e..422ffa6a7c3 100644
--- a/gno.land/pkg/gnoweb/handler.go
+++ b/gno.land/pkg/gnoweb/handler.go
@@ -116,8 +116,8 @@ func (h *WebHandler) Get(w http.ResponseWriter, r *http.Request) {
func (h *WebHandler) renderPage(body *bytes.Buffer, r *http.Request, indexData *components.IndexData) (int, error) {
gnourl, err := ParseGnoURL(r.URL)
if err != nil {
- h.Logger.Warn("page not found", "path", r.URL.Path, "err", err)
- return http.StatusNotFound, components.RenderStatusComponent(body, "page not found")
+ h.Logger.Warn("unable to parse url path", "path", r.URL.Path, "err", err)
+ return http.StatusBadRequest, components.RenderStatusComponent(body, "invalid path")
}
breadcrumb := components.BreadcrumbData{Parts: generateBreadcrumbPaths(gnourl.Path)}
@@ -128,12 +128,12 @@ func (h *WebHandler) renderPage(body *bytes.Buffer, r *http.Request, indexData *
WebQuery: gnourl.WebQuery,
}
- switch gnourl.Kind() {
+ switch k := gnourl.Kind(); k {
case KindRealm, KindPure:
return h.GetPackagePage(body, gnourl)
default:
- h.Logger.Debug("invalid page kind", "kind", gnourl.Kind())
- return http.StatusNotFound, components.RenderStatusComponent(body, "page not found")
+ h.Logger.Debug("invalid path kind", "kind", k)
+ return http.StatusBadRequest, components.RenderStatusComponent(body, "invalid path")
}
}
@@ -189,7 +189,7 @@ func (h *WebHandler) renderRealmContent(w io.Writer, gnourl *GnoURL) (int, error
err = components.RenderRealmComponent(w, components.RealmData{
TocItems: &components.RealmTOCData{
- Items: meta.Items,
+ Items: meta.Toc.Items,
},
// NOTE: `RenderRealm` should ensure that HTML content is
// sanitized before rendering
@@ -246,7 +246,7 @@ func (h *WebHandler) GetHelpPage(w io.Writer, gnourl *GnoURL) (int, error) {
// GetSource renders the source page.
func (h *WebHandler) GetSource(w io.Writer, gnourl *GnoURL) (int, error) {
- pkgPath := gnourl.Path
+ pkgPath := strings.TrimSuffix(gnourl.Path, "/")
files, err := h.Client.Sources(pkgPath)
if err != nil {
@@ -291,7 +291,7 @@ func (h *WebHandler) GetSource(w io.Writer, gnourl *GnoURL) (int, error) {
// GetDirectoryPage renders the directory page.
func (h *WebHandler) GetDirectoryPage(w io.Writer, gnourl *GnoURL) (int, error) {
- pkgPath := gnourl.Path
+ pkgPath := strings.TrimSuffix(gnourl.Path, "/")
files, err := h.Client.Sources(pkgPath)
if err != nil {
@@ -317,21 +317,21 @@ func (h *WebHandler) GetDirectoryPage(w io.Writer, gnourl *GnoURL) (int, error)
return http.StatusOK, nil
}
-func renderClientErrorStatusPage(w io.Writer, u *GnoURL, err error) (int, error) {
+func renderClientErrorStatusPage(w io.Writer, _ *GnoURL, err error) (int, error) {
if err == nil {
return http.StatusOK, nil
}
- if errors.Is(err, ErrClientPathNotFound) {
- msg := fmt.Sprintf(`not found: "%s"`, u.Path)
- return http.StatusNotFound, components.RenderStatusComponent(w, msg)
- }
-
- if errors.Is(err, ErrClientBadRequest) || errors.Is(err, ErrClientResponse) {
+ switch {
+ case errors.Is(err, ErrClientPathNotFound):
return http.StatusNotFound, components.RenderStatusComponent(w, err.Error())
+ case errors.Is(err, ErrClientBadRequest):
+ return http.StatusInternalServerError, components.RenderStatusComponent(w, "bad request")
+ case errors.Is(err, ErrClientResponse):
+ fallthrough // XXX: for now fallback as internal error
+ default:
+ return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
}
-
- return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error")
}
// generateBreadcrumbPaths creates breadcrumb paths from a given path.
diff --git a/gno.land/pkg/gnoweb/markdown/toc.go b/gno.land/pkg/gnoweb/markdown/toc.go
index 59d4941fabf..ceafbd7cc96 100644
--- a/gno.land/pkg/gnoweb/markdown/toc.go
+++ b/gno.land/pkg/gnoweb/markdown/toc.go
@@ -45,7 +45,7 @@ type TocOptions struct {
MinDepth, MaxDepth int
}
-func TocInspect(n ast.Node, src []byte, opts TocOptions) (*Toc, error) {
+func TocInspect(n ast.Node, src []byte, opts TocOptions) (Toc, error) {
// Appends an empty subitem to the given node
// and returns a reference to it.
appendChild := func(n *TocItem) *TocItem {
@@ -114,7 +114,7 @@ func TocInspect(n ast.Node, src []byte, opts TocOptions) (*Toc, error) {
root.Items = compactItems(root.Items)
- return &Toc{Items: root.Items}, err
+ return Toc{Items: root.Items}, err
}
// compactItems removes items with no titles
diff --git a/gno.land/pkg/gnoweb/webclient.go b/gno.land/pkg/gnoweb/webclient.go
index 1a67050fdd4..3dcda69d670 100644
--- a/gno.land/pkg/gnoweb/webclient.go
+++ b/gno.land/pkg/gnoweb/webclient.go
@@ -20,10 +20,10 @@ type FileMeta struct {
}
type RealmMeta struct {
- *md.Toc
+ Toc md.Toc
}
-// WebClient is an interface for interacting with package ressources.
+// WebClient is an interface for interacting with package and node ressources.
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
diff --git a/gno.land/pkg/gnoweb/webclient_html.go b/gno.land/pkg/gnoweb/webclient_html.go
index a08eef70508..71ec2b53a22 100644
--- a/gno.land/pkg/gnoweb/webclient_html.go
+++ b/gno.land/pkg/gnoweb/webclient_html.go
@@ -115,7 +115,8 @@ func (s *HTMLWebClient) Sources(path string) ([]string, error) {
const qpath = "vm/qfile"
// XXX: Consider moving this into gnoclient
- fullPath := fmt.Sprintf("%s/%s", s.domain, strings.Trim(path, "/"))
+ pkgPath := strings.Trim(path, "/")
+ fullPath := fmt.Sprintf("%s/%s", s.domain, pkgPath)
res, err := s.query(qpath, []byte(fullPath))
if err != nil {
return nil, err
diff --git a/gno.land/pkg/gnoweb/webclient_mock.go b/gno.land/pkg/gnoweb/webclient_mock.go
index af865ecb194..d4a3a45229f 100644
--- a/gno.land/pkg/gnoweb/webclient_mock.go
+++ b/gno.land/pkg/gnoweb/webclient_mock.go
@@ -12,8 +12,8 @@ import (
// MockPackage represents a mock package with files and function signatures.
type MockPackage struct {
- Domain string
Path string
+ Domain string
Files map[string] /* filename */ string /* body */
Functions []vm.FunctionSignature
}
@@ -23,14 +23,23 @@ type MockWebClient struct {
Packages map[string] /* path */ *MockPackage /* package */
}
+func NewMockWebClient(pkgs ...*MockPackage) *MockWebClient {
+ mpkgs := make(map[string]*MockPackage)
+ for _, pkg := range pkgs {
+ mpkgs[pkg.Path] = pkg
+ }
+
+ return &MockWebClient{Packages: mpkgs}
+}
+
// Render simulates rendering a package by writing its content to the writer.
-func (m *MockWebClient) RenderRealm((w io.Writer, path string, args string) (*RealmMeta, error) {
+func (m *MockWebClient) RenderRealm(w io.Writer, path string, args string) (*RealmMeta, error) {
pkg, exists := m.Packages[path]
if !exists {
- return nil, errors.New("package not found")
+ return nil, ErrClientPathNotFound
}
- fmt.Fprintf(w, "[%s]%s:%s: lorem ipsum", pkg.Domain, pkg.Path)
+ fmt.Fprintf(w, "[%s]%s:", pkg.Domain, pkg.Path)
// Return a dummy RealmMeta for simplicity
return &RealmMeta{}, nil
From a13c2f9e0ceaabd7cf6a7247bb4a676261db765d Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Thu, 19 Dec 2024 17:16:15 +0100
Subject: [PATCH 16/22] chore: cleanup tests
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/Makefile | 3 +++
gno.land/pkg/gnoweb/app_test.go | 24 +++++++++++++-----------
gno.land/pkg/gnoweb/handler.go | 2 +-
gno.land/pkg/gnoweb/webclient_html.go | 12 ++++++++++++
4 files changed, 29 insertions(+), 12 deletions(-)
diff --git a/gno.land/pkg/gnoweb/Makefile b/gno.land/pkg/gnoweb/Makefile
index 61397fef54f..b1ec8f6f20b 100644
--- a/gno.land/pkg/gnoweb/Makefile
+++ b/gno.land/pkg/gnoweb/Makefile
@@ -38,6 +38,9 @@ cache_dir := .cache
# Install dependencies
all: generate
+test:
+ go test -v ./...
+
# Generate process
generate: css ts static
diff --git a/gno.land/pkg/gnoweb/app_test.go b/gno.land/pkg/gnoweb/app_test.go
index 0a0db8702ff..2730e8b41ca 100644
--- a/gno.land/pkg/gnoweb/app_test.go
+++ b/gno.land/pkg/gnoweb/app_test.go
@@ -24,9 +24,9 @@ func TestRoutes(t *testing.T) {
status int
substring string
}{
- {"/", ok, "Welcome"}, // assert / gives 200 (OK). assert / contains "Welcome".
+ {"/", ok, "Welcome"}, // Check if / returns 200 (OK) and contains "Welcome".
{"/about", ok, "blockchain"},
- {"/r/gnoland/blog", ok, ""}, // whatever content
+ {"/r/gnoland/blog", ok, ""}, // Any content
{"/r/gnoland/blog$help", ok, "AdminSetAdminAddr"},
{"/r/gnoland/blog/", ok, "admin.gno"},
{"/r/gnoland/blog/admin.gno", ok, ">func<"},
@@ -47,11 +47,12 @@ func TestRoutes(t *testing.T) {
{"/game-of-realms", found, "/contribute"},
{"/gor", found, "/contribute"},
{"/blog", found, "/r/gnoland/blog"},
- {"/404/not/found/", notFound, ""},
+ {"/r/not/found/", notFound, ""},
+ {"/404/not/found", notFound, ""},
{"/아스키문자가아닌경로", notFound, ""},
{"/%ED%85%8C%EC%8A%A4%ED%8A%B8", notFound, ""},
{"/グノー", notFound, ""},
- {"/\u269B\uFE0F", notFound, ""}, // unicode
+ {"/\u269B\uFE0F", notFound, ""}, // Unicode
{"/p/demo/flow/LICENSE", ok, "BSD 3-Clause"},
// Test assets
{"/public/styles.css", ok, ""},
@@ -71,8 +72,7 @@ func TestRoutes(t *testing.T) {
logger := log.NewTestingLogger(t)
- // set the `remoteAddr` of the client to the listening address of the
- // node, which is randomly assigned.
+ // Initialize the router with the current node's remote address
router, err := NewRouter(logger, cfg)
require.NoError(t, err)
@@ -90,24 +90,24 @@ func TestRoutes(t *testing.T) {
func TestAnalytics(t *testing.T) {
routes := []string{
- // special realms
- "/", // home
+ // Special realms
+ "/", // Home
"/about",
"/start",
- // redirects
+ // Redirects
"/game-of-realms",
"/getting-started",
"/blog",
"/boards",
- // realm, source, help page
+ // Realm, source, help page
"/r/gnoland/blog",
"/r/gnoland/blog/admin.gno",
"/r/demo/users:administrator",
"/r/gnoland/blog$help",
- // special pages
+ // Special pages
"/404-not-found",
}
@@ -130,6 +130,7 @@ func TestAnalytics(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, route, nil)
response := httptest.NewRecorder()
+
router.ServeHTTP(response, request)
assert.Contains(t, response.Body.String(), "sa.gno.services")
})
@@ -147,6 +148,7 @@ func TestAnalytics(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, route, nil)
response := httptest.NewRecorder()
+
router.ServeHTTP(response, request)
assert.NotContains(t, response.Body.String(), "sa.gno.services")
})
diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go
index 422ffa6a7c3..7baa8f6364a 100644
--- a/gno.land/pkg/gnoweb/handler.go
+++ b/gno.land/pkg/gnoweb/handler.go
@@ -117,7 +117,7 @@ func (h *WebHandler) renderPage(body *bytes.Buffer, r *http.Request, indexData *
gnourl, err := ParseGnoURL(r.URL)
if err != nil {
h.Logger.Warn("unable to parse url path", "path", r.URL.Path, "err", err)
- return http.StatusBadRequest, components.RenderStatusComponent(body, "invalid path")
+ return http.StatusNotFound, components.RenderStatusComponent(body, "invalid path")
}
breadcrumb := components.BreadcrumbData{Parts: generateBreadcrumbPaths(gnourl.Path)}
diff --git a/gno.land/pkg/gnoweb/webclient_html.go b/gno.land/pkg/gnoweb/webclient_html.go
index 71ec2b53a22..5b578c03af1 100644
--- a/gno.land/pkg/gnoweb/webclient_html.go
+++ b/gno.land/pkg/gnoweb/webclient_html.go
@@ -93,6 +93,12 @@ func (s *HTMLWebClient) SourceFile(w io.Writer, path, fileName string) (*FileMet
source, err := s.query(qpath, []byte(fullPath))
if err != nil {
+ // XXX: this is a bit ugly, we should make the keeper return an
+ // assertable error.
+ if strings.Contains(err.Error(), "not available") {
+ return nil, ErrClientPathNotFound
+ }
+
return nil, err
}
@@ -119,6 +125,12 @@ func (s *HTMLWebClient) Sources(path string) ([]string, error) {
fullPath := fmt.Sprintf("%s/%s", s.domain, pkgPath)
res, err := s.query(qpath, []byte(fullPath))
if err != nil {
+ // XXX: this is a bit ugly, we should make the keeper return an
+ // assertable error.
+ if strings.Contains(err.Error(), "not available") {
+ return nil, ErrClientPathNotFound
+ }
+
return nil, err
}
From d5df3977668c4c9f8ffc21e501d90d886e3ece58 Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Thu, 19 Dec 2024 17:16:50 +0100
Subject: [PATCH 17/22] chore: lint
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/webclient_mock.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gno.land/pkg/gnoweb/webclient_mock.go b/gno.land/pkg/gnoweb/webclient_mock.go
index d4a3a45229f..6ff381abb83 100644
--- a/gno.land/pkg/gnoweb/webclient_mock.go
+++ b/gno.land/pkg/gnoweb/webclient_mock.go
@@ -81,7 +81,7 @@ func (m *MockWebClient) Sources(path string) ([]string, error) {
}
fileNames := make([]string, 0, len(pkg.Files))
- for file, _ := range pkg.Files {
+ for file := range pkg.Files {
fileNames = append(fileNames, file)
}
From cbeafb917a943cb7eba95f6d55641c00a5c8a534 Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Thu, 19 Dec 2024 17:32:38 +0100
Subject: [PATCH 18/22] chore: rename highlighter to format
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/app.go | 2 +-
gno.land/pkg/gnoweb/format.go | 69 +++++++++++++++++++++++++++
gno.land/pkg/gnoweb/highlighter.go | 62 ------------------------
gno.land/pkg/gnoweb/webclient_html.go | 6 +--
4 files changed, 73 insertions(+), 66 deletions(-)
create mode 100644 gno.land/pkg/gnoweb/format.go
delete mode 100644 gno.land/pkg/gnoweb/highlighter.go
diff --git a/gno.land/pkg/gnoweb/app.go b/gno.land/pkg/gnoweb/app.go
index 6e25544b369..285c17d5605 100644
--- a/gno.land/pkg/gnoweb/app.go
+++ b/gno.land/pkg/gnoweb/app.go
@@ -96,7 +96,7 @@ func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) {
// Configure WebClient
webcfg := HTMLWebClientConfig{
Markdown: md,
- Highlighter: NewChromaHighlighter(chroma, chromaDefaultStyle),
+ Highlighter: NewChromaHighlighterFormat(chroma, chromaDefaultStyle),
Domain: cfg.Domain,
UnsafeHTML: cfg.UnsafeHTML,
RPCClient: client,
diff --git a/gno.land/pkg/gnoweb/format.go b/gno.land/pkg/gnoweb/format.go
new file mode 100644
index 00000000000..67911bfa985
--- /dev/null
+++ b/gno.land/pkg/gnoweb/format.go
@@ -0,0 +1,69 @@
+package gnoweb
+
+import (
+ "fmt"
+ "io"
+ "path/filepath"
+ "strings"
+
+ "github.com/alecthomas/chroma/v2"
+ "github.com/alecthomas/chroma/v2/formatters/html"
+ "github.com/alecthomas/chroma/v2/lexers"
+)
+
+// FormatSource defines the interface for formatting source code.
+type FormatSource interface {
+ Format(w io.Writer, fileName string, file []byte) error
+}
+
+// ChromaSourceHighlighter implements the Highlighter interface using the Chroma library.
+type ChromaSourceHighlighter struct {
+ *html.Formatter
+ style *chroma.Style
+}
+
+// NewChromaSourceHighlighter constructs a new ChromaHighlighter with the given formatter and style.
+func NewChromaSourceHighlighter(formatter *html.Formatter, style *chroma.Style) FormatSource {
+ return &ChromaSourceHighlighter{Formatter: formatter, style: style}
+}
+
+// Format applies syntax highlighting to the source code using Chroma.
+func (f *ChromaSourceHighlighter) Format(w io.Writer, fileName string, src []byte) error {
+ var lexer chroma.Lexer
+
+ // Determine the lexer to be used based on the file extension.
+ switch strings.ToLower(filepath.Ext(fileName)) {
+ case ".gno":
+ lexer = lexers.Get("go")
+ case ".md":
+ lexer = lexers.Get("markdown")
+ case ".mod":
+ lexer = lexers.Get("gomod")
+ default:
+ lexer = lexers.Get("txt") // Unsupported file type, default to plain text.
+ }
+
+ if lexer == nil {
+ return fmt.Errorf("unsupported lexer for file %q", fileName)
+ }
+
+ iterator, err := lexer.Tokenise(nil, string(src))
+ if err != nil {
+ return fmt.Errorf("unable to tokenise %q: %w", fileName, err)
+ }
+
+ if err := f.Formatter.Format(w, f.style, iterator); err != nil {
+ return fmt.Errorf("unable to format source file %q: %w", fileName, err)
+ }
+
+ return nil
+}
+
+// noopFormat is a no-operation highlighter that writes the source code as-is.
+type noopFormat struct{}
+
+// Format writes the source code to the writer without any formatting.
+func (f *noopFormat) Format(w io.Writer, fileName string, src []byte) error {
+ _, err := w.Write(src)
+ return err
+}
diff --git a/gno.land/pkg/gnoweb/highlighter.go b/gno.land/pkg/gnoweb/highlighter.go
deleted file mode 100644
index 8174283c717..00000000000
--- a/gno.land/pkg/gnoweb/highlighter.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package gnoweb
-
-import (
- "fmt"
- "io"
- "path/filepath"
- "strings"
-
- "github.com/alecthomas/chroma/v2"
- "github.com/alecthomas/chroma/v2/formatters/html"
- "github.com/alecthomas/chroma/v2/lexers"
-)
-
-type Highlighter interface {
- Format(w io.Writer, fileName string, file []byte) error
-}
-
-type ChromaHighlighter struct {
- *html.Formatter
- style *chroma.Style
-}
-
-func NewChromaHighlighter(formater *html.Formatter, style *chroma.Style) Highlighter {
- return &ChromaHighlighter{Formatter: formater, style: style}
-}
-
-func (f *ChromaHighlighter) Format(w io.Writer, fileName string, src []byte) error {
- var lexer chroma.Lexer
-
- switch strings.ToLower(filepath.Ext(fileName)) {
- case ".gno":
- lexer = lexers.Get("go")
- case ".md":
- lexer = lexers.Get("markdown")
- case ".mod":
- lexer = lexers.Get("gomod")
- default:
- lexer = lexers.Get("txt") // file kind not supported, fallback on `.txt`
- }
-
- if lexer == nil {
- return fmt.Errorf("unsupported lexer for file %q", fileName)
- }
-
- iterator, err := lexer.Tokenise(nil, string(src))
- if err != nil {
- return fmt.Errorf("unable to tokenise %q: %w ", fileName, err)
- }
-
- if err := f.Formatter.Format(w, f.style, iterator); err != nil {
- return fmt.Errorf("unable to format source file %q: %w", fileName, err)
- }
-
- return nil
-}
-
-type noopHighlighter struct{}
-
-func (f *noopHighlighter) Format(w io.Writer, fileName string, src []byte) error {
- _, err := w.Write(src)
- return err
-}
diff --git a/gno.land/pkg/gnoweb/webclient_html.go b/gno.land/pkg/gnoweb/webclient_html.go
index 5b578c03af1..ffe2238df98 100644
--- a/gno.land/pkg/gnoweb/webclient_html.go
+++ b/gno.land/pkg/gnoweb/webclient_html.go
@@ -21,7 +21,7 @@ type HTMLWebClientConfig struct {
Domain string
UnsafeHTML bool
RPCClient *client.RPCClient
- Highlighter Highlighter
+ Highlighter FormatSource
Markdown goldmark.Markdown
}
@@ -31,7 +31,7 @@ func NewDefaultHTMLWebClientConfig(client *client.RPCClient) *HTMLWebClientConfi
mdopts := []goldmark.Option{goldmark.WithParserOptions(parser.WithAutoHeadingID())}
return &HTMLWebClientConfig{
Domain: "gno.land",
- Highlighter: &noopHighlighter{},
+ Highlighter: &noopFormat{},
Markdown: goldmark.New(mdopts...),
RPCClient: client,
}
@@ -42,7 +42,7 @@ type HTMLWebClient struct {
logger *slog.Logger
client *client.RPCClient
md goldmark.Markdown
- highlighter Highlighter
+ highlighter FormatSource
}
// NewHTMLClient creates a new instance of WebClient.
From 792dc4ef6df3e9d639169cb773f9a0b7a05d059e Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Thu, 19 Dec 2024 17:32:55 +0100
Subject: [PATCH 19/22] feat: add handler tests
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/handler_test.go | 98 +++++++++++++++++++++++++++++
1 file changed, 98 insertions(+)
create mode 100644 gno.land/pkg/gnoweb/handler_test.go
diff --git a/gno.land/pkg/gnoweb/handler_test.go b/gno.land/pkg/gnoweb/handler_test.go
new file mode 100644
index 00000000000..2b86d41e2a9
--- /dev/null
+++ b/gno.land/pkg/gnoweb/handler_test.go
@@ -0,0 +1,98 @@
+package gnoweb_test
+
+import (
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gnolang/gno/gno.land/pkg/gnoweb"
+ "github.com/gnolang/gno/gno.land/pkg/sdk/vm"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type testingLogger struct {
+ *testing.T
+}
+
+func (t *testingLogger) Write(b []byte) (n int, err error) {
+ t.T.Log(strings.TrimSpace(string(b)))
+ return len(b), nil
+}
+
+// TestWebHandler_Get tests the Get method of WebHandler using table-driven tests.
+func TestWebHandler_Get(t *testing.T) {
+ // 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" }`,
+ },
+ Functions: []vm.FunctionSignature{
+ {FuncName: "SuperRenderFunction", Params: []vm.NamedType{
+ {Name: "my_super_arg", Type: "string"},
+ }},
+ },
+ }
+
+ // Create a mock web client with the mock package
+ webclient := gnoweb.NewMockWebClient(mockPackage)
+
+ // Create a WebHandlerConfig with the mock web client and static metadata
+ config := gnoweb.WebHandlerConfig{
+ WebClient: webclient,
+ }
+
+ // Define test cases
+ cases := []struct {
+ Path string
+ Status int
+ Body string
+ }{
+ // Found
+ {Path: "/r/mock/path", Status: http.StatusOK, Body: "[example.com]/r/mock/path"},
+ {Path: "/r/mock/path/", Status: http.StatusOK, Body: "render.gno"},
+ {Path: "/r/mock/path/render.gno", Status: http.StatusOK, Body: "one more time"},
+ {Path: "/r/mock/path$source&file=render.gno", Status: http.StatusOK, Body: "one more time"},
+ {Path: "/r/mock/path/$source", Status: http.StatusOK, Body: "one more time"}, // `render.gno` by default
+ {Path: "/r/mock/path$help", Status: http.StatusOK, Body: "SuperRenderFunction"},
+ {Path: "/r/mock/path$help", Status: http.StatusOK, Body: "my_super_arg"},
+
+ // Package not found
+ {Path: "/r/invalid/path", Status: http.StatusNotFound, Body: "not found"},
+
+ // Invalid path
+ {Path: "/r", Status: http.StatusNotFound, Body: "invalid path"},
+ {Path: "/r/~!1337", Status: http.StatusNotFound, Body: "invalid path"},
+ }
+
+ for _, tc := range cases {
+ t.Run(strings.TrimPrefix(tc.Path, "/"), func(t *testing.T) {
+ t.Logf("input: %+v", tc)
+
+ // Initialize testing logger
+ logger := slog.New(slog.NewTextHandler(&testingLogger{t}, &slog.HandlerOptions{}))
+
+ // Create a new WebHandler
+ handler, err := gnoweb.NewWebHandler(logger, config)
+ require.NoError(t, err)
+
+ // Create a new HTTP request for each test case
+ req, err := http.NewRequest(http.MethodGet, tc.Path, nil)
+ require.NoError(t, err)
+
+ // Create a ResponseRecorder to capture the response
+ rr := httptest.NewRecorder()
+
+ // Invoke serve method
+ handler.ServeHTTP(rr, req)
+
+ // Assert result
+ assert.Equal(t, tc.Status, rr.Code)
+ assert.Containsf(t, rr.Body.String(), tc.Body, "rendered body should contain: %q", tc.Body)
+ })
+ }
+}
From fd8035dfc9986988b0e8c759a444de0cb0855535 Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Thu, 19 Dec 2024 17:55:40 +0100
Subject: [PATCH 20/22] chore: lint
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/app.go | 2 +-
gno.land/pkg/gnoweb/webclient_mock.go | 9 ++++-----
2 files changed, 5 insertions(+), 6 deletions(-)
diff --git a/gno.land/pkg/gnoweb/app.go b/gno.land/pkg/gnoweb/app.go
index 285c17d5605..502311784e8 100644
--- a/gno.land/pkg/gnoweb/app.go
+++ b/gno.land/pkg/gnoweb/app.go
@@ -96,7 +96,7 @@ func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) {
// Configure WebClient
webcfg := HTMLWebClientConfig{
Markdown: md,
- Highlighter: NewChromaHighlighterFormat(chroma, chromaDefaultStyle),
+ Highlighter: NewChromaSourceHighlighter(chroma, chromaDefaultStyle),
Domain: cfg.Domain,
UnsafeHTML: cfg.UnsafeHTML,
RPCClient: client,
diff --git a/gno.land/pkg/gnoweb/webclient_mock.go b/gno.land/pkg/gnoweb/webclient_mock.go
index 6ff381abb83..261b0b02814 100644
--- a/gno.land/pkg/gnoweb/webclient_mock.go
+++ b/gno.land/pkg/gnoweb/webclient_mock.go
@@ -2,7 +2,6 @@ package gnoweb
import (
"bytes"
- "errors"
"fmt"
"io"
"sort"
@@ -49,7 +48,7 @@ func (m *MockWebClient) RenderRealm(w io.Writer, path string, args string) (*Rea
func (m *MockWebClient) SourceFile(w io.Writer, pkgPath, fileName string) (*FileMeta, error) {
pkg, exists := m.Packages[pkgPath]
if !exists {
- return nil, errors.New("package not found")
+ return nil, ErrClientPathNotFound
}
if body, ok := pkg.Files[fileName]; ok {
@@ -60,14 +59,14 @@ func (m *MockWebClient) SourceFile(w io.Writer, pkgPath, fileName string) (*File
}, nil
}
- return nil, errors.New("file not found")
+ return nil, ErrClientPathNotFound
}
// Functions simulates retrieving function signatures from a package.
func (m *MockWebClient) Functions(path string) ([]vm.FunctionSignature, error) {
pkg, exists := m.Packages[path]
if !exists {
- return nil, errors.New("package not found")
+ return nil, ErrClientPathNotFound
}
return pkg.Functions, nil
@@ -77,7 +76,7 @@ func (m *MockWebClient) Functions(path string) ([]vm.FunctionSignature, error) {
func (m *MockWebClient) Sources(path string) ([]string, error) {
pkg, exists := m.Packages[path]
if !exists {
- return nil, errors.New("package not found")
+ return nil, ErrClientPathNotFound
}
fileNames := make([]string, 0, len(pkg.Files))
From aea015b6316a1211f61d776e627f405a7deb2fa0 Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Thu, 9 Jan 2025 14:44:17 +0100
Subject: [PATCH 21/22] chore: cleanup merge
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/app.go | 3 +-
gno.land/pkg/gnoweb/handler.go | 31 +++++++++++--------
gno.land/pkg/gnoweb/handler_test.go | 43 ++++++++++++++++++---------
gno.land/pkg/gnoweb/webclient.go | 2 +-
gno.land/pkg/gnoweb/webclient_mock.go | 4 +--
5 files changed, 53 insertions(+), 30 deletions(-)
diff --git a/gno.land/pkg/gnoweb/app.go b/gno.land/pkg/gnoweb/app.go
index 5cd41f15fff..5a68d96ede7 100644
--- a/gno.land/pkg/gnoweb/app.go
+++ b/gno.land/pkg/gnoweb/app.go
@@ -141,7 +141,7 @@ func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) {
}
// Handle Chroma CSS requests
- // XXX: probably move this
+ // XXX: probably move this elsewhere
mux.Handle(chromaStylePath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
if err := chroma.WriteCSS(w, chromaDefaultStyle); err != nil {
@@ -151,6 +151,7 @@ func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) {
}))
// Handle assets path
+ // XXX: add caching
assetsBase := "/" + strings.Trim(cfg.AssetsPath, "/") + "/"
if cfg.AssetsDir != "" {
logger.Debug("using assets dir instead of embedded assets", "dir", cfg.AssetsDir)
diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go
index 50d999a6abf..91f233a416d 100644
--- a/gno.land/pkg/gnoweb/handler.go
+++ b/gno.land/pkg/gnoweb/handler.go
@@ -9,7 +9,6 @@ import (
"log/slog"
"net/http"
"path/filepath"
- "slices"
"strings"
"time"
@@ -198,13 +197,16 @@ func (h *WebHandler) GetHelpPage(w io.Writer, gnourl *GnoURL) (int, error) {
selFn := gnourl.WebQuery.Get("func")
if selFn != "" {
for _, fn := range fsigs {
- if selFn == fn.FuncName {
- for _, param := range fn.Params {
- selArgs[param.Name] = gnourl.WebQuery.Get(param.Name)
- }
- fsigs = []vm.FunctionSignature{fn}
- break
+ if selFn != fn.FuncName {
+ continue
}
+
+ for _, param := range fn.Params {
+ selArgs[param.Name] = gnourl.WebQuery.Get(param.Name)
+ }
+
+ fsigs = []vm.FunctionSignature{fn}
+ break
}
}
@@ -229,8 +231,7 @@ func (h *WebHandler) GetHelpPage(w io.Writer, gnourl *GnoURL) (int, error) {
// GetSource renders the source page.
func (h *WebHandler) GetSourcePage(w io.Writer, gnourl *GnoURL) (int, error) {
- pkgPath := strings.TrimSuffix(gnourl.Path, "/")
-
+ pkgPath := gnourl.Path
files, err := h.Client.Sources(pkgPath)
if err != nil {
h.Logger.Error("unable to list sources file", "path", gnourl.Path, "err", err)
@@ -242,9 +243,15 @@ func (h *WebHandler) GetSourcePage(w io.Writer, gnourl *GnoURL) (int, error) {
return http.StatusOK, components.RenderStatusComponent(w, "no files available")
}
- fileName := gnourl.WebQuery.Get("file")
- if fileName == "" || !slices.Contains(files, fileName) {
- fileName = files[0]
+ var fileName string
+ if gnourl.IsFile() { // check path file from path first
+ fileName = gnourl.File
+ } else if file := gnourl.WebQuery.Get("file"); file != "" {
+ fileName = file
+ }
+
+ if fileName == "" {
+ fileName = files[0] // fallback on the first file if
}
var source bytes.Buffer
diff --git a/gno.land/pkg/gnoweb/handler_test.go b/gno.land/pkg/gnoweb/handler_test.go
index 2b86d41e2a9..49bb5248dfc 100644
--- a/gno.land/pkg/gnoweb/handler_test.go
+++ b/gno.land/pkg/gnoweb/handler_test.go
@@ -30,6 +30,8 @@ func TestWebHandler_Get(t *testing.T) {
Path: "/r/mock/path",
Files: map[string]string{
"render.gno": `package main; func Render(path 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{
@@ -48,25 +50,34 @@ func TestWebHandler_Get(t *testing.T) {
// Define test cases
cases := []struct {
- Path string
- Status int
- Body string
+ Path string
+ Status int
+ Contain string // optional
+ Contains []string // optional
}{
// Found
- {Path: "/r/mock/path", Status: http.StatusOK, Body: "[example.com]/r/mock/path"},
- {Path: "/r/mock/path/", Status: http.StatusOK, Body: "render.gno"},
- {Path: "/r/mock/path/render.gno", Status: http.StatusOK, Body: "one more time"},
- {Path: "/r/mock/path$source&file=render.gno", Status: http.StatusOK, Body: "one more time"},
- {Path: "/r/mock/path/$source", Status: http.StatusOK, Body: "one more time"}, // `render.gno` by default
- {Path: "/r/mock/path$help", Status: http.StatusOK, Body: "SuperRenderFunction"},
- {Path: "/r/mock/path$help", Status: http.StatusOK, Body: "my_super_arg"},
+ {Path: "/r/mock/path", Status: http.StatusOK, Contain: "[example.com]/r/mock/path"},
+
+ // Source page
+ {Path: "/r/mock/path/", Status: http.StatusOK, Contain: "Directory"},
+ {Path: "/r/mock/path/render.gno", Status: http.StatusOK, Contain: "one more time"},
+ {Path: "/r/mock/path/LicEnse", Status: http.StatusOK, Contain: "my super license"},
+ {Path: "/r/mock/path$source&file=render.gno", Status: http.StatusOK, Contain: "one more time"},
+ {Path: "/r/mock/path$source", Status: http.StatusOK, Contain: "module"}, // `gno.mod` by default
+ {Path: "/r/mock/path/license", Status: http.StatusNotFound},
+
+ // Help page
+ {Path: "/r/mock/path$help", Status: http.StatusOK, Contains: []string{
+ "my_super_arg",
+ "SuperRenderFunction",
+ }},
// Package not found
- {Path: "/r/invalid/path", Status: http.StatusNotFound, Body: "not found"},
+ {Path: "/r/invalid/path", Status: http.StatusNotFound, Contain: "not found"},
// Invalid path
- {Path: "/r", Status: http.StatusNotFound, Body: "invalid path"},
- {Path: "/r/~!1337", Status: http.StatusNotFound, Body: "invalid path"},
+ {Path: "/r", Status: http.StatusBadRequest, Contain: "invalid path"},
+ {Path: "/r/~!1337", Status: http.StatusNotFound, Contain: "invalid path"},
}
for _, tc := range cases {
@@ -92,7 +103,11 @@ func TestWebHandler_Get(t *testing.T) {
// Assert result
assert.Equal(t, tc.Status, rr.Code)
- assert.Containsf(t, rr.Body.String(), tc.Body, "rendered body should contain: %q", tc.Body)
+ assert.Containsf(t, rr.Body.String(), tc.Contain, "rendered body should contain: %q", tc.Contain)
+ for _, contain := range tc.Contains {
+ assert.Containsf(t, rr.Body.String(), contain, "rendered body should contain: %q", contain)
+ }
+
})
}
}
diff --git a/gno.land/pkg/gnoweb/webclient.go b/gno.land/pkg/gnoweb/webclient.go
index 3dcda69d670..de44303f352 100644
--- a/gno.land/pkg/gnoweb/webclient.go
+++ b/gno.land/pkg/gnoweb/webclient.go
@@ -9,7 +9,7 @@ import (
)
var (
- ErrClientPathNotFound = errors.New("package path not found")
+ ErrClientPathNotFound = errors.New("package not found")
ErrClientBadRequest = errors.New("bad request")
ErrClientResponse = errors.New("node response error")
)
diff --git a/gno.land/pkg/gnoweb/webclient_mock.go b/gno.land/pkg/gnoweb/webclient_mock.go
index 261b0b02814..451f5e237c3 100644
--- a/gno.land/pkg/gnoweb/webclient_mock.go
+++ b/gno.land/pkg/gnoweb/webclient_mock.go
@@ -13,13 +13,13 @@ import (
type MockPackage struct {
Path string
Domain string
- Files map[string] /* filename */ string /* body */
+ Files map[string]string // filename -> body
Functions []vm.FunctionSignature
}
// MockWebClient is a mock implementation of the Client interface.
type MockWebClient struct {
- Packages map[string] /* path */ *MockPackage /* package */
+ Packages map[string]*MockPackage // path -> package
}
func NewMockWebClient(pkgs ...*MockPackage) *MockWebClient {
From bb38fa469be7ff9e64ca63511ea1d8f4cebff95a Mon Sep 17 00:00:00 2001
From: gfanton <8671905+gfanton@users.noreply.github.com>
Date: Thu, 9 Jan 2025 14:49:51 +0100
Subject: [PATCH 22/22] chore: lint
Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com>
---
gno.land/pkg/gnoweb/handler.go | 1 -
gno.land/pkg/gnoweb/handler_test.go | 1 -
2 files changed, 2 deletions(-)
diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go
index 91f233a416d..2dc51d64029 100644
--- a/gno.land/pkg/gnoweb/handler.go
+++ b/gno.land/pkg/gnoweb/handler.go
@@ -134,7 +134,6 @@ func (h *WebHandler) renderPage(body *bytes.Buffer, r *http.Request, indexData *
h.Logger.Debug("invalid path: path is neither a pure package or a realm")
return http.StatusBadRequest, components.RenderStatusComponent(body, "invalid path")
}
-
}
// GetPackagePage handles package pages.
diff --git a/gno.land/pkg/gnoweb/handler_test.go b/gno.land/pkg/gnoweb/handler_test.go
index 49bb5248dfc..624e3390a97 100644
--- a/gno.land/pkg/gnoweb/handler_test.go
+++ b/gno.land/pkg/gnoweb/handler_test.go
@@ -107,7 +107,6 @@ func TestWebHandler_Get(t *testing.T) {
for _, contain := range tc.Contains {
assert.Containsf(t, rr.Body.String(), contain, "rendered body should contain: %q", contain)
}
-
})
}
}