Skip to content

Commit 1a546fc

Browse files
authored
feat(rest): add RegisterView to REST catalog (#753)
## Description: Adds RegisterView(ctx, identifier, metadataLocation) to rest.Catalog, the view equivalent of the existing RegisterTable. The method POSTs to POST /v1/{prefix}/namespaces/{namespace}/register-view as defined in the Iceberg REST catalog OpenAPI specification. ## Motivation Fixes: #752 The only way to migrate a view between REST catalogs without this method is LoadView + CreateView. That approach is lossy: CreateView resets version-id to 1, reassigns default-catalog to the target catalog name, and creates a new UUID — producing a new view rather than a registration of the existing one. RegisterView avoids all of this by pointing the target catalog at the existing metadata file, exactly as RegisterTable does for tables. ## Changes - catalog/rest/rest.go: RegisterView on *Catalog — POSTs {name, metadata-location} to /namespaces/{ns}/register-view, parses the response through the existing loadViewResponse path, maps 404 → ErrNoSuchNamespace and 409 → ErrViewAlreadyExists. - catalog/rest/rest_test.go: three test cases covering the 200, 404, and 409 responses, following the same structure as the existing TestRegisterTable{200,404,409} tests. ## Related - Java implementation: apache/iceberg#14868 - REST OpenAPI spec: open-api/rest-catalog-open-api.yaml — operationId registerView - Mailing list discussion: https://lists.apache.org/thread/mxqz0fp6vz3nt7nl70wgrz6d2gr41rzj Signed-off-by: Shubhendu Ram Tripathi <[email protected]>
1 parent f192c8e commit 1a546fc

File tree

2 files changed

+135
-0
lines changed

2 files changed

+135
-0
lines changed

catalog/rest/rest.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1339,6 +1339,37 @@ type loadViewResponse struct {
13391339
Config iceberg.Properties `json:"config"`
13401340
}
13411341

1342+
// RegisterView registers an existing view in the catalog using its metadata file location.
1343+
// The metadata file must already be accessible to the catalog. This is the view equivalent
1344+
// of RegisterTable, using the REST endpoint POST /namespaces/{ns}/register-view defined in
1345+
// the Iceberg REST catalog specification.
1346+
func (r *Catalog) RegisterView(ctx context.Context, identifier table.Identifier, metadataLoc string) (*view.View, error) {
1347+
ns, v, err := splitIdentForPath(identifier)
1348+
if err != nil {
1349+
return nil, err
1350+
}
1351+
1352+
type payload struct {
1353+
Name string `json:"name"`
1354+
MetadataLoc string `json:"metadata-location"`
1355+
}
1356+
1357+
rsp, err := doPost[payload, loadViewResponse](ctx, r.baseURI, []string{"namespaces", ns, "register-view"},
1358+
payload{Name: v, MetadataLoc: metadataLoc}, r.cl, map[int]error{
1359+
http.StatusNotFound: catalog.ErrNoSuchNamespace, http.StatusConflict: catalog.ErrViewAlreadyExists,
1360+
})
1361+
if err != nil {
1362+
return nil, err
1363+
}
1364+
1365+
metadata, err := view.ParseMetadataBytes(rsp.RawMetadata)
1366+
if err != nil {
1367+
return nil, fmt.Errorf("failed to parse view metadata: %w", err)
1368+
}
1369+
1370+
return view.New(identifier, metadata, rsp.MetadataLoc), nil
1371+
}
1372+
13421373
// LoadView loads a view from the catalog.
13431374
func (r *Catalog) LoadView(ctx context.Context, identifier table.Identifier) (*view.View, error) {
13441375
ns, v, err := splitIdentForPath(identifier)

catalog/rest/rest_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2478,6 +2478,110 @@ func (r *RestCatalogSuite) TestCreateView404() {
24782478
r.ErrorIs(err, catalog.ErrNoSuchNamespace)
24792479
}
24802480

2481+
func (r *RestCatalogSuite) TestRegisterView200() {
2482+
const (
2483+
ns = "fokko"
2484+
viewName = "myview"
2485+
metadataLoc = "s3://bucket/warehouse/fokko.db/myview/metadata/00001.metadata.json"
2486+
)
2487+
identifier := table.Identifier{ns, viewName}
2488+
2489+
r.mux.HandleFunc("/v1/namespaces/"+ns+"/register-view", func(w http.ResponseWriter, req *http.Request) {
2490+
r.Require().Equal(http.MethodPost, req.Method)
2491+
2492+
for k, v := range TestHeaders {
2493+
r.Equal(v, req.Header.Values(k))
2494+
}
2495+
2496+
var payload struct {
2497+
Name string `json:"name"`
2498+
MetadataLoc string `json:"metadata-location"`
2499+
}
2500+
r.NoError(json.NewDecoder(req.Body).Decode(&payload))
2501+
r.Equal(viewName, payload.Name)
2502+
r.Equal(metadataLoc, payload.MetadataLoc)
2503+
2504+
w.Header().Set("Content-Type", "application/json")
2505+
w.WriteHeader(http.StatusOK)
2506+
fmt.Fprintf(w, `{"metadata-location": %q, "metadata": %s, "config": {}}`,
2507+
metadataLoc, exampleViewMetadataJSON)
2508+
})
2509+
2510+
cat, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL, rest.WithOAuthToken(TestToken))
2511+
r.Require().NoError(err)
2512+
2513+
v, err := cat.RegisterView(context.Background(), identifier, metadataLoc)
2514+
r.Require().NoError(err)
2515+
2516+
r.Equal(identifier, v.Identifier())
2517+
r.Equal(metadataLoc, v.MetadataLocation())
2518+
r.Equal(uuid.MustParse("a1b2c3d4-e5f6-7890-1234-567890abcdef"), v.Metadata().ViewUUID())
2519+
r.EqualValues(1, v.Metadata().CurrentVersionID())
2520+
r.Equal(exampleViewSQL, v.Metadata().CurrentVersion().Representations[0].Sql)
2521+
}
2522+
2523+
func (r *RestCatalogSuite) TestRegisterView404() {
2524+
const (
2525+
ns = "nonexistent"
2526+
viewName = "myview"
2527+
metadataLoc = "s3://bucket/warehouse/nonexistent.db/myview/metadata/00001.metadata.json"
2528+
)
2529+
2530+
r.mux.HandleFunc("/v1/namespaces/"+ns+"/register-view", func(w http.ResponseWriter, req *http.Request) {
2531+
r.Require().Equal(http.MethodPost, req.Method)
2532+
2533+
for k, v := range TestHeaders {
2534+
r.Equal(v, req.Header.Values(k))
2535+
}
2536+
2537+
w.Header().Set("Content-Type", "application/json")
2538+
w.WriteHeader(http.StatusNotFound)
2539+
json.NewEncoder(w).Encode(map[string]any{"error": errorResponse{
2540+
Message: "The given namespace does not exist",
2541+
Type: "NoSuchNamespaceException",
2542+
Code: 404,
2543+
}})
2544+
})
2545+
2546+
cat, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL, rest.WithOAuthToken(TestToken))
2547+
r.Require().NoError(err)
2548+
2549+
_, err = cat.RegisterView(context.Background(), catalog.ToIdentifier(ns, viewName), metadataLoc)
2550+
r.ErrorIs(err, catalog.ErrNoSuchNamespace)
2551+
r.ErrorContains(err, "The given namespace does not exist")
2552+
}
2553+
2554+
func (r *RestCatalogSuite) TestRegisterView409() {
2555+
const (
2556+
ns = "fokko"
2557+
viewName = "alreadyexists"
2558+
metadataLoc = "s3://bucket/warehouse/fokko.db/alreadyexists/metadata/00001.metadata.json"
2559+
)
2560+
2561+
r.mux.HandleFunc("/v1/namespaces/"+ns+"/register-view", func(w http.ResponseWriter, req *http.Request) {
2562+
r.Require().Equal(http.MethodPost, req.Method)
2563+
2564+
for k, v := range TestHeaders {
2565+
r.Equal(v, req.Header.Values(k))
2566+
}
2567+
2568+
w.Header().Set("Content-Type", "application/json")
2569+
w.WriteHeader(http.StatusConflict)
2570+
json.NewEncoder(w).Encode(map[string]any{"error": errorResponse{
2571+
Message: "The given view already exists",
2572+
Type: "AlreadyExistsException",
2573+
Code: 409,
2574+
}})
2575+
})
2576+
2577+
cat, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL, rest.WithOAuthToken(TestToken))
2578+
r.Require().NoError(err)
2579+
2580+
_, err = cat.RegisterView(context.Background(), catalog.ToIdentifier(ns, viewName), metadataLoc)
2581+
r.ErrorIs(err, catalog.ErrViewAlreadyExists)
2582+
r.ErrorContains(err, "The given view already exists")
2583+
}
2584+
24812585
type mockTransport struct {
24822586
calls []struct {
24832587
method, path string

0 commit comments

Comments
 (0)