This is an automated email from the ASF dual-hosted git repository.

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new fa5309b36 fix(rest): Encode identifier path segments (#2074)
fa5309b36 is described below

commit fa5309b3624d5798bde69722b279366bb7f39846
Author: Alessandro Nori <[email protected]>
AuthorDate: Fri Oct 2 22:12:37 2026 +0200

    fix(rest): Encode identifier path segments (#2074)
    
    * Encode table names in REST paths
    
    * Fix REST path segment encoding
    
    * Keep view payload names unencoded
---
 catalog/rest/metrics_reporter_test.go |  4 +--
 catalog/rest/rest.go                  | 51 ++++++++++++++++++++---------------
 catalog/rest/rest_internal_test.go    | 37 +++++++++++++++++++++++++
 catalog/rest/rest_test.go             | 45 +++++++++++++++++++++++++++++--
 4 files changed, 111 insertions(+), 26 deletions(-)

diff --git a/catalog/rest/metrics_reporter_test.go 
b/catalog/rest/metrics_reporter_test.go
index b901e7459..7485135b7 100644
--- a/catalog/rest/metrics_reporter_test.go
+++ b/catalog/rest/metrics_reporter_test.go
@@ -573,8 +573,8 @@ func TestRESTMetricsReporterBuildsPathThroughProduction(t 
*testing.T) {
 
        select {
        case req := <-received:
-               // The namespace levels are percent-encoded and joined by the 
encoded
-               // separator (%1F); the table segment is escaped by the URL 
builder.
+               // Namespace levels and the table name use path encoding; 
namespace
+               // levels are joined by the encoded separator (%1F).
                assert.Equal(t, 
"/v1/my-prefix/namespaces/a%20b%1Fd%20e/tables/t%20x/metrics", req.escapedPath)
        case <-time.After(3 * time.Second):
                t.Fatal("timed out waiting for the async metrics POST")
diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index 29ca144c8..fae679108 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -1395,13 +1395,20 @@ func (r *Catalog) nsSeparator() string {
        return r.namespaceSeparator
 }
 
+// encodePathSegment escapes a REST path segment per RFC 3986. PathEscape
+// leaves plus signs literal, so encode them explicitly to avoid form decoders
+// interpreting them as spaces.
+func encodePathSegment(value string) string {
+       return strings.ReplaceAll(url.PathEscape(value), "+", "%2B")
+}
+
 // encodeNamespace URL-encodes each namespace level and joins them with the
 // server-advertised, URL-encoded namespace separator for use as a REST path
-// segment. Mirrors RESTUtil.encodeNamespace in the Java implementation.
+// segment.
 func (r *Catalog) encodeNamespace(namespace table.Identifier) string {
        encoded := make([]string, len(namespace))
        for i, level := range namespace {
-               encoded[i] = url.PathEscape(level)
+               encoded[i] = encodePathSegment(level)
        }
 
        return strings.Join(encoded, r.nsSeparator())
@@ -1428,7 +1435,7 @@ func (r *Catalog) splitIdentForPath(ident 
table.Identifier) (string, string, err
                return "", "", err
        }
 
-       return r.encodeNamespace(catalog.NamespaceFromIdent(ident)), 
catalog.ObjectNameFromIdent(ident), nil
+       return r.encodeNamespace(catalog.NamespaceFromIdent(ident)), 
encodePathSegment(catalog.ObjectNameFromIdent(ident)), nil
 }
 
 func (r *Catalog) splitViewIdentForPath(ident table.Identifier) (string, 
string, error) {
@@ -1436,7 +1443,7 @@ func (r *Catalog) splitViewIdentForPath(ident 
table.Identifier) (string, string,
                return "", "", err
        }
 
-       return r.encodeNamespace(catalog.NamespaceFromIdent(ident)), 
catalog.ObjectNameFromIdent(ident), nil
+       return r.encodeNamespace(catalog.NamespaceFromIdent(ident)), 
encodePathSegment(catalog.ObjectNameFromIdent(ident)), nil
 }
 
 func (r *Catalog) splitFunctionIdentForPath(ident table.Identifier) (string, 
string, error) {
@@ -1444,7 +1451,7 @@ func (r *Catalog) splitFunctionIdentForPath(ident 
table.Identifier) (string, str
                return "", "", err
        }
 
-       return r.encodeNamespace(catalog.NamespaceFromIdent(ident)), 
catalog.ObjectNameFromIdent(ident), nil
+       return r.encodeNamespace(catalog.NamespaceFromIdent(ident)), 
encodePathSegment(catalog.ObjectNameFromIdent(ident)), nil
 }
 
 func (r *Catalog) CreateTable(ctx context.Context, identifier 
table.Identifier, schema *iceberg.Schema, opts ...catalog.CreateTableOpt) 
(*table.Table, error) {
@@ -1452,7 +1459,7 @@ func (r *Catalog) CreateTable(ctx context.Context, 
identifier table.Identifier,
                return nil, err
        }
 
-       ns, tbl, err := r.splitIdentForPath(identifier)
+       ns, _, err := r.splitIdentForPath(identifier)
        if err != nil {
                return nil, err
        }
@@ -1479,7 +1486,7 @@ func (r *Catalog) CreateTable(ctx context.Context, 
identifier table.Identifier,
        stagedCreate := len(cfg.StagedUpdates) > 0
 
        payload := createTableRequest{
-               Name:          tbl,
+               Name:          catalog.ObjectNameFromIdent(identifier),
                Schema:        schema,
                Location:      cfg.Location,
                PartitionSpec: cfg.PartitionSpec,
@@ -1574,14 +1581,14 @@ func (r *Catalog) CommitTable(ctx context.Context, 
ident table.Identifier, requi
                return nil, "", err
        }
 
-       ns, tblName, err := r.splitIdentForPath(ident)
+       ns, encodedTbl, err := r.splitIdentForPath(ident)
        if err != nil {
                return nil, "", err
        }
 
        restIdentifier := identifier{
                Namespace: catalog.NamespaceFromIdent(ident),
-               Name:      tblName,
+               Name:      catalog.ObjectNameFromIdent(ident),
        }
 
        type payload struct {
@@ -1590,7 +1597,7 @@ func (r *Catalog) CommitTable(ctx context.Context, ident 
table.Identifier, requi
                Updates      []table.Update      `json:"updates"`
        }
 
-       path, err := endpointUpdateTable.reqPath(ns, tblName)
+       path, err := endpointUpdateTable.reqPath(ns, encodedTbl)
        if err != nil {
                return nil, "", err
        }
@@ -1704,7 +1711,7 @@ func (r *Catalog) RegisterTable(ctx context.Context, 
identifier table.Identifier
                return nil, err
        }
 
-       ns, tbl, err := r.splitIdentForPath(identifier)
+       ns, _, err := r.splitIdentForPath(identifier)
        if err != nil {
                return nil, err
        }
@@ -1729,7 +1736,7 @@ func (r *Catalog) RegisterTable(ctx context.Context, 
identifier table.Identifier
        }
 
        ret, err := doPost[payload, loadTableResponse](ctx, r.baseURI, path,
-               payload{Name: tbl, MetadataLoc: metadataLoc}, r.cl, 
map[int]error{
+               payload{Name: catalog.ObjectNameFromIdent(identifier), 
MetadataLoc: metadataLoc}, r.cl, map[int]error{
                        http.StatusNotFound: catalog.ErrNoSuchNamespace, 
http.StatusConflict: catalog.ErrTableAlreadyExists,
                })
        if err != nil {
@@ -1796,21 +1803,21 @@ func (r *Catalog) UpdateTable(ctx context.Context, 
ident table.Identifier, requi
                return nil, err
        }
 
-       ns, tbl, err := r.splitIdentForPath(ident)
+       ns, encodedTbl, err := r.splitIdentForPath(ident)
        if err != nil {
                return nil, err
        }
 
        restIdentifier := identifier{
                Namespace: catalog.NamespaceFromIdent(ident),
-               Name:      tbl,
+               Name:      catalog.ObjectNameFromIdent(ident),
        }
        type payload struct {
                Identifier   identifier          `json:"identifier"`
                Requirements []table.Requirement `json:"requirements"`
                Updates      []table.Update      `json:"updates"`
        }
-       path, err := endpointUpdateTable.reqPath(ns, tbl)
+       path, err := endpointUpdateTable.reqPath(ns, encodedTbl)
        if err != nil {
                return nil, err
        }
@@ -2338,7 +2345,7 @@ func (r *Catalog) CreateView(ctx context.Context, 
identifier table.Identifier, v
                return nil, fmt.Errorf("%w: view version cannot be nil", 
iceberg.ErrInvalidArgument)
        }
 
-       ns, viewName, err := r.splitViewIdentForPath(identifier)
+       ns, _, err := r.splitViewIdentForPath(identifier)
        if err != nil {
                return nil, err
        }
@@ -2369,7 +2376,7 @@ func (r *Catalog) CreateView(ctx context.Context, 
identifier table.Identifier, v
        }
 
        payload := createViewRequest{
-               Name:        viewName,
+               Name:        catalog.ObjectNameFromIdent(identifier),
                Location:    cfg.Location,
                Schema:      freshSchema,
                Props:       cfg.Properties,
@@ -2399,21 +2406,21 @@ func (r *Catalog) UpdateView(ctx context.Context, ident 
table.Identifier, requir
                return nil, err
        }
 
-       ns, viewName, err := r.splitViewIdentForPath(ident)
+       ns, encodedView, err := r.splitViewIdentForPath(ident)
        if err != nil {
                return nil, err
        }
 
        restIdentifier := identifier{
                Namespace: catalog.NamespaceFromIdent(ident),
-               Name:      viewName,
+               Name:      catalog.ObjectNameFromIdent(ident),
        }
        type payload struct {
                Identifier   identifier         `json:"identifier"`
                Requirements []view.Requirement `json:"requirements"`
                Updates      []view.Update      `json:"updates"`
        }
-       path, err := endpointUpdateView.reqPath(ns, viewName)
+       path, err := endpointUpdateView.reqPath(ns, encodedView)
        if err != nil {
                return nil, err
        }
@@ -2445,7 +2452,7 @@ func (r *Catalog) RegisterView(ctx context.Context, 
identifier table.Identifier,
                return nil, err
        }
 
-       ns, v, err := r.splitViewIdentForPath(identifier)
+       ns, _, err := r.splitViewIdentForPath(identifier)
        if err != nil {
                return nil, err
        }
@@ -2461,7 +2468,7 @@ func (r *Catalog) RegisterView(ctx context.Context, 
identifier table.Identifier,
        }
 
        rsp, err := doPost[payload, loadViewResponse](ctx, r.baseURI, path,
-               payload{Name: v, MetadataLoc: metadataLoc}, r.cl, map[int]error{
+               payload{Name: catalog.ObjectNameFromIdent(identifier), 
MetadataLoc: metadataLoc}, r.cl, map[int]error{
                        http.StatusNotFound: catalog.ErrNoSuchNamespace, 
http.StatusConflict: catalog.ErrViewAlreadyExists,
                })
        if err != nil {
diff --git a/catalog/rest/rest_internal_test.go 
b/catalog/rest/rest_internal_test.go
index 92da26362..c16ad2db5 100644
--- a/catalog/rest/rest_internal_test.go
+++ b/catalog/rest/rest_internal_test.go
@@ -131,6 +131,43 @@ func TestSplitIdentForPathRequiresNamespaceAndName(t 
*testing.T) {
        require.NoError(t, err)
        assert.Equal(t, "parent%1Fnamespace", ns)
        assert.Equal(t, "table", tbl)
+
+       ns, tbl, err = cat.splitIdentForPath(table.Identifier{"namespace+name", 
"table+name"})
+       require.NoError(t, err)
+       assert.Equal(t, "namespace%2Bname", ns)
+       assert.Equal(t, "table%2Bname", tbl)
+
+       for name, split := range map[string]func(table.Identifier) (string, 
string, error){
+               "view":     cat.splitViewIdentForPath,
+               "function": cat.splitFunctionIdentForPath,
+       } {
+               t.Run(name, func(t *testing.T) {
+                       ns, object, err := 
split(table.Identifier{"namespace+name", name + "+name"})
+                       require.NoError(t, err)
+                       assert.Equal(t, "namespace%2Bname", ns)
+                       assert.Equal(t, name+"%2Bname", object)
+               })
+       }
+}
+
+func TestEncodePathSegment(t *testing.T) {
+       tests := []struct {
+               name  string
+               value string
+               want  string
+       }{
+               {name: "space", value: " ", want: "%20"},
+               {name: "plus", value: "+", want: "%2B"},
+               {name: "percent", value: "%", want: "%25"},
+               {name: "slash", value: "/", want: "%2F"},
+               {name: "unicode", value: "£€", want: "%C2%A3%E2%82%AC"},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       assert.Equal(t, tt.want, encodePathSegment(tt.value))
+               })
+       }
 }
 
 func TestLoadRegisteredCatalogRejectsInvalidAuthURL(t *testing.T) {
diff --git a/catalog/rest/rest_test.go b/catalog/rest/rest_test.go
index dc4f6653e..75f86c13a 100644
--- a/catalog/rest/rest_test.go
+++ b/catalog/rest/rest_test.go
@@ -2954,9 +2954,50 @@ var (
 }`, exampleViewMetadataJSON)
 )
 
+func (r *RestCatalogSuite) TestUpdatePathsEncodeNamesAndBodiesRemainRaw() {
+       const objectName = "a b+c"
+
+       type updatePayload struct {
+               Identifier struct {
+                       Name string `json:"name"`
+               } `json:"identifier"`
+       }
+
+       r.mux.HandleFunc("/v1/namespaces/table-ns/tables/", func(w 
http.ResponseWriter, req *http.Request) {
+               r.Equal("/v1/namespaces/table-ns/tables/a%20b%2Bc", 
req.URL.EscapedPath())
+
+               var payload updatePayload
+               r.Require().NoError(json.NewDecoder(req.Body).Decode(&payload))
+               r.Equal(objectName, payload.Identifier.Name)
+
+               _, err := w.Write([]byte(createTableRestExample))
+               r.Require().NoError(err)
+       })
+
+       r.mux.HandleFunc("/v1/namespaces/view-ns/views/", func(w 
http.ResponseWriter, req *http.Request) {
+               r.Equal("/v1/namespaces/view-ns/views/a%20b%2Bc", 
req.URL.EscapedPath())
+
+               var payload updatePayload
+               r.Require().NoError(json.NewDecoder(req.Body).Decode(&payload))
+               r.Equal(objectName, payload.Identifier.Name)
+
+               _, err := w.Write([]byte(createViewRestExample))
+               r.Require().NoError(err)
+       })
+
+       cat, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL)
+       r.Require().NoError(err)
+
+       _, err = cat.UpdateTable(context.Background(), 
table.Identifier{"table-ns", objectName}, nil, nil)
+       r.Require().NoError(err)
+
+       _, err = cat.UpdateView(context.Background(), 
table.Identifier{"view-ns", objectName}, nil, nil)
+       r.Require().NoError(err)
+}
+
 func (r *RestCatalogSuite) TestCreateView200() {
        ns := "ns"
-       viewName := "view"
+       viewName := "a b+c"
        identifier := table.Identifier{ns, viewName}
        schema := iceberg.NewSchemaWithIdentifiers(0, []int{1}, 
iceberg.NestedField{
                ID:       1,
@@ -3095,7 +3136,7 @@ func (r *RestCatalogSuite) TestCreateView404() {
 func (r *RestCatalogSuite) TestRegisterView200() {
        const (
                ns          = "fokko"
-               viewName    = "myview"
+               viewName    = "a b+c"
                metadataLoc = 
"s3://bucket/warehouse/fokko.db/myview/metadata/00001.metadata.json"
        )
        identifier := table.Identifier{ns, viewName}

Reply via email to