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

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


The following commit(s) were added to refs/heads/main by this push:
     new f19643a93f Core: Add View support for REST catalog (#7913)
f19643a93f is described below

commit f19643a93f5dac99bbdbc9881ef19c89d7bcd3eb
Author: Eduard Tudenhoefner <[email protected]>
AuthorDate: Tue Dec 5 17:03:47 2023 +0100

    Core: Add View support for REST catalog (#7913)
---
 .../org/apache/iceberg/catalog/ViewCatalog.java    |   1 +
 .../{ViewCatalog.java => ViewSessionCatalog.java}  |  23 +-
 .../java/org/apache/iceberg/UpdateRequirement.java |  15 +
 .../iceberg/catalog/BaseViewSessionCatalog.java    |  92 ++++
 .../org/apache/iceberg/rest/CatalogHandlers.java   | 137 +++++
 .../org/apache/iceberg/rest/ErrorHandlers.java     |  52 ++
 .../java/org/apache/iceberg/rest/RESTCatalog.java  |  43 +-
 .../org/apache/iceberg/rest/RESTSerializers.java   |  51 +-
 .../apache/iceberg/rest/RESTSessionCatalog.java    | 260 ++++++++-
 .../apache/iceberg/rest/RESTViewOperations.java    |  83 +++
 .../org/apache/iceberg/rest/ResourcePaths.java     |  18 +
 .../iceberg/rest/requests/CreateViewRequest.java   |  45 ++
 .../rest/requests/CreateViewRequestParser.java     |  99 ++++
 .../iceberg/rest/requests/RenameTableRequest.java  |   2 +-
 .../iceberg/rest/responses/LoadViewResponse.java   |  38 ++
 .../rest/responses/LoadViewResponseParser.java     |  85 +++
 .../java/org/apache/iceberg/view/ViewMetadata.java |  38 +-
 .../apache/iceberg/view/ViewMetadataParser.java    |   2 +-
 .../org/apache/iceberg/view/ViewVersionParser.java |   2 +-
 .../apache/iceberg/view/ViewVersionReplace.java    |   2 +-
 .../apache/iceberg/rest/RESTCatalogAdapter.java    |  95 +++-
 .../apache/iceberg/rest/TestRESTViewCatalog.java   | 166 ++++++
 .../org/apache/iceberg/rest/TestResourcePaths.java |  47 ++
 .../rest/requests/TestCreateViewRequestParser.java | 130 +++++
 .../rest/responses/TestLoadViewResponseParser.java | 260 +++++++++
 .../org/apache/iceberg/view/TestViewMetadata.java  |  23 +-
 .../org/apache/iceberg/view/ViewCatalogTests.java  | 144 ++++-
 open-api/rest-catalog-open-api.py                  | 111 ++++
 open-api/rest-catalog-open-api.yaml                | 595 ++++++++++++++++++++-
 29 files changed, 2614 insertions(+), 45 deletions(-)

diff --git a/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java 
b/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java
index df118e2596..ca470eec71 100644
--- a/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java
+++ b/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java
@@ -92,6 +92,7 @@ public interface ViewCatalog {
    * @param to new view identifier
    * @throws NoSuchViewException if the "from" view does not exist
    * @throws AlreadyExistsException if the "to" view already exists
+   * @throws NoSuchNamespaceException if the "to" namespace doesn't exist
    */
   void renameView(TableIdentifier from, TableIdentifier to);
 
diff --git a/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java 
b/api/src/main/java/org/apache/iceberg/catalog/ViewSessionCatalog.java
similarity index 77%
copy from api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java
copy to api/src/main/java/org/apache/iceberg/catalog/ViewSessionCatalog.java
index df118e2596..106e20d3bc 100644
--- a/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java
+++ b/api/src/main/java/org/apache/iceberg/catalog/ViewSessionCatalog.java
@@ -26,8 +26,8 @@ import org.apache.iceberg.exceptions.NoSuchViewException;
 import org.apache.iceberg.view.View;
 import org.apache.iceberg.view.ViewBuilder;
 
-/** A Catalog API for view create, drop, and load operations. */
-public interface ViewCatalog {
+/** A session Catalog API for view create, drop, and load operations. */
+public interface ViewSessionCatalog {
 
   /**
    * Return the name for this catalog.
@@ -43,7 +43,7 @@ public interface ViewCatalog {
    * @return a list of identifiers for views
    * @throws NoSuchNamespaceException if the namespace is not found
    */
-  List<TableIdentifier> listViews(Namespace namespace);
+  List<TableIdentifier> listViews(SessionCatalog.SessionContext context, 
Namespace namespace);
 
   /**
    * Load a view.
@@ -52,7 +52,7 @@ public interface ViewCatalog {
    * @return instance of {@link View} implementation referred by the identifier
    * @throws NoSuchViewException if the view does not exist
    */
-  View loadView(TableIdentifier identifier);
+  View loadView(SessionCatalog.SessionContext context, TableIdentifier 
identifier);
 
   /**
    * Check whether view exists.
@@ -60,9 +60,9 @@ public interface ViewCatalog {
    * @param identifier a view identifier
    * @return true if the view exists, false otherwise
    */
-  default boolean viewExists(TableIdentifier identifier) {
+  default boolean viewExists(SessionCatalog.SessionContext context, 
TableIdentifier identifier) {
     try {
-      loadView(identifier);
+      loadView(context, identifier);
       return true;
     } catch (NoSuchViewException e) {
       return false;
@@ -75,7 +75,7 @@ public interface ViewCatalog {
    * @param identifier a view identifier
    * @return a view builder
    */
-  ViewBuilder buildView(TableIdentifier identifier);
+  ViewBuilder buildView(SessionCatalog.SessionContext context, TableIdentifier 
identifier);
 
   /**
    * Drop a view.
@@ -83,7 +83,7 @@ public interface ViewCatalog {
    * @param identifier a view identifier
    * @return true if the view was dropped, false if the view did not exist
    */
-  boolean dropView(TableIdentifier identifier);
+  boolean dropView(SessionCatalog.SessionContext context, TableIdentifier 
identifier);
 
   /**
    * Rename a view.
@@ -92,8 +92,9 @@ public interface ViewCatalog {
    * @param to new view identifier
    * @throws NoSuchViewException if the "from" view does not exist
    * @throws AlreadyExistsException if the "to" view already exists
+   * @throws NoSuchNamespaceException if the "to" namespace doesn't exist
    */
-  void renameView(TableIdentifier from, TableIdentifier to);
+  void renameView(SessionCatalog.SessionContext context, TableIdentifier from, 
TableIdentifier to);
 
   /**
    * Invalidate cached view metadata from current catalog.
@@ -103,7 +104,7 @@ public interface ViewCatalog {
    *
    * @param identifier a view identifier
    */
-  default void invalidateView(TableIdentifier identifier) {}
+  default void invalidateView(SessionCatalog.SessionContext context, 
TableIdentifier identifier) {}
 
   /**
    * Initialize a view catalog given a custom name and a map of catalog 
properties.
@@ -115,5 +116,5 @@ public interface ViewCatalog {
    * @param name a custom name for the catalog
    * @param properties catalog properties
    */
-  default void initialize(String name, Map<String, String> properties) {}
+  void initialize(String name, Map<String, String> properties);
 }
diff --git a/core/src/main/java/org/apache/iceberg/UpdateRequirement.java 
b/core/src/main/java/org/apache/iceberg/UpdateRequirement.java
index 2645e79fa4..80ecf84efa 100644
--- a/core/src/main/java/org/apache/iceberg/UpdateRequirement.java
+++ b/core/src/main/java/org/apache/iceberg/UpdateRequirement.java
@@ -19,12 +19,19 @@
 package org.apache.iceberg;
 
 import org.apache.iceberg.exceptions.CommitFailedException;
+import org.apache.iceberg.exceptions.ValidationException;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.view.ViewMetadata;
 
 /** Represents a requirement for a {@link MetadataUpdate} */
 public interface UpdateRequirement {
   void validate(TableMetadata base);
 
+  default void validate(ViewMetadata base) {
+    throw new ValidationException(
+        "Cannot validate %s against a view", this.getClass().getSimpleName());
+  }
+
   class AssertTableDoesNotExist implements UpdateRequirement {
     public AssertTableDoesNotExist() {}
 
@@ -55,6 +62,14 @@ public interface UpdateRequirement {
             "Requirement failed: UUID does not match: expected %s != %s", 
base.uuid(), uuid);
       }
     }
+
+    @Override
+    public void validate(ViewMetadata base) {
+      if (!uuid.equalsIgnoreCase(base.uuid())) {
+        throw new CommitFailedException(
+            "Requirement failed: UUID does not match: expected %s != %s", 
base.uuid(), uuid);
+      }
+    }
   }
 
   class AssertRefSnapshotID implements UpdateRequirement {
diff --git 
a/core/src/main/java/org/apache/iceberg/catalog/BaseViewSessionCatalog.java 
b/core/src/main/java/org/apache/iceberg/catalog/BaseViewSessionCatalog.java
new file mode 100644
index 0000000000..10895e1de9
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/catalog/BaseViewSessionCatalog.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.catalog;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.apache.iceberg.view.View;
+import org.apache.iceberg.view.ViewBuilder;
+
+public abstract class BaseViewSessionCatalog extends BaseSessionCatalog
+    implements ViewSessionCatalog {
+
+  private final Cache<String, ViewCatalog> catalogs =
+      Caffeine.newBuilder().expireAfterAccess(10, TimeUnit.MINUTES).build();
+
+  public ViewCatalog asViewCatalog(SessionContext context) {
+    return catalogs.get(context.sessionId(), id -> new AsViewCatalog(context));
+  }
+
+  public class AsViewCatalog implements ViewCatalog {
+    private final SessionContext context;
+
+    private AsViewCatalog(SessionContext context) {
+      this.context = context;
+    }
+
+    @Override
+    public String name() {
+      return BaseViewSessionCatalog.this.name();
+    }
+
+    @Override
+    public List<TableIdentifier> listViews(Namespace namespace) {
+      return BaseViewSessionCatalog.this.listViews(context, namespace);
+    }
+
+    @Override
+    public View loadView(TableIdentifier identifier) {
+      return BaseViewSessionCatalog.this.loadView(context, identifier);
+    }
+
+    @Override
+    public boolean viewExists(TableIdentifier identifier) {
+      return BaseViewSessionCatalog.this.viewExists(context, identifier);
+    }
+
+    @Override
+    public ViewBuilder buildView(TableIdentifier identifier) {
+      return BaseViewSessionCatalog.this.buildView(context, identifier);
+    }
+
+    @Override
+    public boolean dropView(TableIdentifier identifier) {
+      return BaseViewSessionCatalog.this.dropView(context, identifier);
+    }
+
+    @Override
+    public void renameView(TableIdentifier from, TableIdentifier to) {
+      BaseViewSessionCatalog.this.renameView(context, from, to);
+    }
+
+    @Override
+    public void invalidateView(TableIdentifier identifier) {
+      BaseViewSessionCatalog.this.invalidateView(context, identifier);
+    }
+
+    @Override
+    public void initialize(String name, Map<String, String> properties) {
+      throw new UnsupportedOperationException(
+          this.getClass().getSimpleName() + " doesn't support initialization");
+    }
+  }
+}
diff --git a/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java 
b/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java
index 1e0ef66027..e4e3c065fb 100644
--- a/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java
+++ b/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java
@@ -45,26 +45,38 @@ import org.apache.iceberg.catalog.Catalog;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.SupportsNamespaces;
 import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.catalog.ViewCatalog;
 import org.apache.iceberg.exceptions.AlreadyExistsException;
 import org.apache.iceberg.exceptions.CommitFailedException;
 import org.apache.iceberg.exceptions.NoSuchNamespaceException;
 import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.apache.iceberg.exceptions.NoSuchViewException;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 import org.apache.iceberg.relocated.com.google.common.collect.Maps;
 import org.apache.iceberg.relocated.com.google.common.collect.Sets;
 import org.apache.iceberg.rest.requests.CreateNamespaceRequest;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.CreateViewRequest;
 import org.apache.iceberg.rest.requests.RegisterTableRequest;
 import org.apache.iceberg.rest.requests.RenameTableRequest;
 import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest;
 import org.apache.iceberg.rest.requests.UpdateTableRequest;
 import org.apache.iceberg.rest.responses.CreateNamespaceResponse;
 import org.apache.iceberg.rest.responses.GetNamespaceResponse;
+import org.apache.iceberg.rest.responses.ImmutableLoadViewResponse;
 import org.apache.iceberg.rest.responses.ListNamespacesResponse;
 import org.apache.iceberg.rest.responses.ListTablesResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
+import org.apache.iceberg.rest.responses.LoadViewResponse;
 import org.apache.iceberg.rest.responses.UpdateNamespacePropertiesResponse;
 import org.apache.iceberg.util.Tasks;
+import org.apache.iceberg.view.BaseView;
+import org.apache.iceberg.view.SQLViewRepresentation;
+import org.apache.iceberg.view.View;
+import org.apache.iceberg.view.ViewBuilder;
+import org.apache.iceberg.view.ViewMetadata;
+import org.apache.iceberg.view.ViewOperations;
+import org.apache.iceberg.view.ViewRepresentation;
 
 public class CatalogHandlers {
   private static final Schema EMPTY_SCHEMA = new Schema();
@@ -374,4 +386,129 @@ public class CatalogHandlers {
 
     return ops.current();
   }
+
+  private static BaseView asBaseView(View view) {
+    Preconditions.checkState(
+        view instanceof BaseView, "Cannot wrap catalog that does not produce 
BaseView");
+    return (BaseView) view;
+  }
+
+  public static ListTablesResponse listViews(ViewCatalog catalog, Namespace 
namespace) {
+    return 
ListTablesResponse.builder().addAll(catalog.listViews(namespace)).build();
+  }
+
+  public static LoadViewResponse createView(
+      ViewCatalog catalog, Namespace namespace, CreateViewRequest request) {
+    request.validate();
+
+    ViewBuilder viewBuilder =
+        catalog
+            .buildView(TableIdentifier.of(namespace, request.name()))
+            .withSchema(request.schema())
+            .withProperties(request.properties())
+            .withDefaultNamespace(request.viewVersion().defaultNamespace())
+            .withDefaultCatalog(request.viewVersion().defaultCatalog())
+            .withLocation(request.location());
+
+    Set<String> unsupportedRepresentations =
+        request.viewVersion().representations().stream()
+            .filter(r -> !(r instanceof SQLViewRepresentation))
+            .map(ViewRepresentation::type)
+            .collect(Collectors.toSet());
+
+    if (!unsupportedRepresentations.isEmpty()) {
+      throw new IllegalStateException(
+          String.format("Found unsupported view representations: %s", 
unsupportedRepresentations));
+    }
+
+    request.viewVersion().representations().stream()
+        .filter(SQLViewRepresentation.class::isInstance)
+        .map(SQLViewRepresentation.class::cast)
+        .forEach(r -> viewBuilder.withQuery(r.dialect(), r.sql()));
+
+    View view = viewBuilder.create();
+
+    return viewResponse(view);
+  }
+
+  private static LoadViewResponse viewResponse(View view) {
+    ViewMetadata metadata = asBaseView(view).operations().current();
+    return ImmutableLoadViewResponse.builder()
+        .metadata(metadata)
+        .metadataLocation(metadata.metadataFileLocation())
+        .build();
+  }
+
+  public static LoadViewResponse loadView(ViewCatalog catalog, TableIdentifier 
viewIdentifier) {
+    View view = catalog.loadView(viewIdentifier);
+    return viewResponse(view);
+  }
+
+  public static LoadViewResponse updateView(
+      ViewCatalog catalog, TableIdentifier ident, UpdateTableRequest request) {
+    View view = catalog.loadView(ident);
+    ViewMetadata metadata = commit(asBaseView(view).operations(), request);
+
+    return ImmutableLoadViewResponse.builder()
+        .metadata(metadata)
+        .metadataLocation(metadata.metadataFileLocation())
+        .build();
+  }
+
+  public static void renameView(ViewCatalog catalog, RenameTableRequest 
request) {
+    catalog.renameView(request.source(), request.destination());
+  }
+
+  public static void dropView(ViewCatalog catalog, TableIdentifier 
viewIdentifier) {
+    boolean dropped = catalog.dropView(viewIdentifier);
+    if (!dropped) {
+      throw new NoSuchViewException("View does not exist: %s", viewIdentifier);
+    }
+  }
+
+  static ViewMetadata commit(ViewOperations ops, UpdateTableRequest request) {
+    AtomicBoolean isRetry = new AtomicBoolean(false);
+    try {
+      Tasks.foreach(ops)
+          .retry(COMMIT_NUM_RETRIES_DEFAULT)
+          .exponentialBackoff(
+              COMMIT_MIN_RETRY_WAIT_MS_DEFAULT,
+              COMMIT_MAX_RETRY_WAIT_MS_DEFAULT,
+              COMMIT_TOTAL_RETRY_TIME_MS_DEFAULT,
+              2.0 /* exponential */)
+          .onlyRetryOn(CommitFailedException.class)
+          .run(
+              taskOps -> {
+                ViewMetadata base = isRetry.get() ? taskOps.refresh() : 
taskOps.current();
+                isRetry.set(true);
+
+                // validate requirements
+                try {
+                  request.requirements().forEach(requirement -> 
requirement.validate(base));
+                } catch (CommitFailedException e) {
+                  // wrap and rethrow outside of tasks to avoid unnecessary 
retry
+                  throw new ValidationFailureException(e);
+                }
+
+                // apply changes
+                ViewMetadata.Builder metadataBuilder = 
ViewMetadata.buildFrom(base);
+                request.updates().forEach(update -> 
update.applyTo(metadataBuilder));
+
+                ViewMetadata updated = metadataBuilder.build();
+
+                if (updated.changes().isEmpty()) {
+                  // do not commit if the metadata has not changed
+                  return;
+                }
+
+                // commit
+                taskOps.commit(base, updated);
+              });
+
+    } catch (ValidationFailureException e) {
+      throw e.wrapped();
+    }
+
+    return ops.current();
+  }
 }
diff --git a/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java 
b/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java
index 5a7f9b59f2..846820a99d 100644
--- a/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java
+++ b/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java
@@ -26,6 +26,7 @@ import 
org.apache.iceberg.exceptions.CommitStateUnknownException;
 import org.apache.iceberg.exceptions.ForbiddenException;
 import org.apache.iceberg.exceptions.NoSuchNamespaceException;
 import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.apache.iceberg.exceptions.NoSuchViewException;
 import org.apache.iceberg.exceptions.NotAuthorizedException;
 import org.apache.iceberg.exceptions.RESTException;
 import org.apache.iceberg.exceptions.ServiceFailureException;
@@ -55,6 +56,14 @@ public class ErrorHandlers {
     return TableErrorHandler.INSTANCE;
   }
 
+  public static Consumer<ErrorResponse> viewErrorHandler() {
+    return ViewErrorHandler.INSTANCE;
+  }
+
+  public static Consumer<ErrorResponse> viewCommitHandler() {
+    return ViewCommitErrorHandler.INSTANCE;
+  }
+
   public static Consumer<ErrorResponse> tableCommitHandler() {
     return CommitErrorHandler.INSTANCE;
   }
@@ -110,6 +119,49 @@ public class ErrorHandlers {
     }
   }
 
+  /** View commit error handler. */
+  private static class ViewCommitErrorHandler extends DefaultErrorHandler {
+    private static final ErrorHandler INSTANCE = new ViewCommitErrorHandler();
+
+    @Override
+    public void accept(ErrorResponse error) {
+      switch (error.code()) {
+        case 404:
+          throw new NoSuchViewException("%s", error.message());
+        case 409:
+          throw new CommitFailedException("Commit failed: %s", 
error.message());
+        case 500:
+        case 502:
+        case 504:
+          throw new CommitStateUnknownException(
+              new ServiceFailureException("Service failed: %s: %s", 
error.code(), error.message()));
+      }
+
+      super.accept(error);
+    }
+  }
+
+  /** View level error handler. */
+  private static class ViewErrorHandler extends DefaultErrorHandler {
+    private static final ErrorHandler INSTANCE = new ViewErrorHandler();
+
+    @Override
+    public void accept(ErrorResponse error) {
+      switch (error.code()) {
+        case 404:
+          if 
(NoSuchNamespaceException.class.getSimpleName().equals(error.type())) {
+            throw new NoSuchNamespaceException("%s", error.message());
+          } else {
+            throw new NoSuchViewException("%s", error.message());
+          }
+        case 409:
+          throw new AlreadyExistsException("%s", error.message());
+      }
+
+      super.accept(error);
+    }
+  }
+
   /** Request error handler specifically for CRUD ops on namespaces. */
   private static class NamespaceErrorHandler extends DefaultErrorHandler {
     private static final ErrorHandler INSTANCE = new NamespaceErrorHandler();
diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTCatalog.java 
b/core/src/main/java/org/apache/iceberg/rest/RESTCatalog.java
index 63b660c46a..61a7eca272 100644
--- a/core/src/main/java/org/apache/iceberg/rest/RESTCatalog.java
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTCatalog.java
@@ -35,17 +35,22 @@ import org.apache.iceberg.catalog.SessionCatalog;
 import org.apache.iceberg.catalog.SupportsNamespaces;
 import org.apache.iceberg.catalog.TableCommit;
 import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.catalog.ViewCatalog;
 import org.apache.iceberg.exceptions.NamespaceNotEmptyException;
 import org.apache.iceberg.exceptions.NoSuchNamespaceException;
 import org.apache.iceberg.hadoop.Configurable;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.view.View;
+import org.apache.iceberg.view.ViewBuilder;
 
-public class RESTCatalog implements Catalog, SupportsNamespaces, 
Configurable<Object>, Closeable {
+public class RESTCatalog
+    implements Catalog, ViewCatalog, SupportsNamespaces, Configurable<Object>, 
Closeable {
   private final RESTSessionCatalog sessionCatalog;
   private final Catalog delegate;
   private final SupportsNamespaces nsDelegate;
   private final SessionCatalog.SessionContext context;
+  private final ViewCatalog viewSessionCatalog;
 
   public RESTCatalog() {
     this(
@@ -64,6 +69,7 @@ public class RESTCatalog implements Catalog, 
SupportsNamespaces, Configurable<Ob
     this.delegate = sessionCatalog.asCatalog(context);
     this.nsDelegate = (SupportsNamespaces) delegate;
     this.context = context;
+    this.viewSessionCatalog = sessionCatalog.asViewCatalog(context);
   }
 
   @Override
@@ -261,4 +267,39 @@ public class RESTCatalog implements Catalog, 
SupportsNamespaces, Configurable<Ob
     sessionCatalog.commitTransaction(
         context, ImmutableList.<TableCommit>builder().add(commits).build());
   }
+
+  @Override
+  public List<TableIdentifier> listViews(Namespace namespace) {
+    return viewSessionCatalog.listViews(namespace);
+  }
+
+  @Override
+  public View loadView(TableIdentifier identifier) {
+    return viewSessionCatalog.loadView(identifier);
+  }
+
+  @Override
+  public ViewBuilder buildView(TableIdentifier identifier) {
+    return viewSessionCatalog.buildView(identifier);
+  }
+
+  @Override
+  public boolean dropView(TableIdentifier identifier) {
+    return viewSessionCatalog.dropView(identifier);
+  }
+
+  @Override
+  public void renameView(TableIdentifier from, TableIdentifier to) {
+    viewSessionCatalog.renameView(from, to);
+  }
+
+  @Override
+  public boolean viewExists(TableIdentifier identifier) {
+    return viewSessionCatalog.viewExists(identifier);
+  }
+
+  @Override
+  public void invalidateView(TableIdentifier identifier) {
+    viewSessionCatalog.invalidateView(identifier);
+  }
 }
diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java 
b/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java
index 06f2de32df..7d4f327b67 100644
--- a/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java
@@ -44,6 +44,9 @@ import org.apache.iceberg.catalog.TableIdentifierParser;
 import org.apache.iceberg.rest.auth.OAuth2Util;
 import org.apache.iceberg.rest.requests.CommitTransactionRequest;
 import org.apache.iceberg.rest.requests.CommitTransactionRequestParser;
+import org.apache.iceberg.rest.requests.CreateViewRequest;
+import org.apache.iceberg.rest.requests.CreateViewRequestParser;
+import org.apache.iceberg.rest.requests.ImmutableCreateViewRequest;
 import org.apache.iceberg.rest.requests.ImmutableRegisterTableRequest;
 import org.apache.iceberg.rest.requests.ImmutableReportMetricsRequest;
 import org.apache.iceberg.rest.requests.RegisterTableRequest;
@@ -56,6 +59,9 @@ import 
org.apache.iceberg.rest.requests.UpdateTableRequest.UpdateRequirement;
 import org.apache.iceberg.rest.requests.UpdateTableRequestParser;
 import org.apache.iceberg.rest.responses.ErrorResponse;
 import org.apache.iceberg.rest.responses.ErrorResponseParser;
+import org.apache.iceberg.rest.responses.ImmutableLoadViewResponse;
+import org.apache.iceberg.rest.responses.LoadViewResponse;
+import org.apache.iceberg.rest.responses.LoadViewResponseParser;
 import org.apache.iceberg.rest.responses.OAuthTokenResponse;
 import org.apache.iceberg.util.JsonUtil;
 
@@ -101,7 +107,16 @@ public class RESTSerializers {
         .addDeserializer(RegisterTableRequest.class, new 
RegisterTableRequestDeserializer<>())
         .addSerializer(ImmutableRegisterTableRequest.class, new 
RegisterTableRequestSerializer<>())
         .addDeserializer(
-            ImmutableRegisterTableRequest.class, new 
RegisterTableRequestDeserializer<>());
+            ImmutableRegisterTableRequest.class, new 
RegisterTableRequestDeserializer<>())
+        .addSerializer(CreateViewRequest.class, new 
CreateViewRequestSerializer<>())
+        .addSerializer(ImmutableCreateViewRequest.class, new 
CreateViewRequestSerializer<>())
+        .addDeserializer(CreateViewRequest.class, new 
CreateViewRequestDeserializer<>())
+        .addDeserializer(ImmutableCreateViewRequest.class, new 
CreateViewRequestDeserializer<>())
+        .addSerializer(LoadViewResponse.class, new 
LoadViewResponseSerializer<>())
+        .addSerializer(ImmutableLoadViewResponse.class, new 
LoadViewResponseSerializer<>())
+        .addDeserializer(LoadViewResponse.class, new 
LoadViewResponseDeserializer<>())
+        .addDeserializer(ImmutableLoadViewResponse.class, new 
LoadViewResponseDeserializer<>());
+
     mapper.registerModule(module);
   }
 
@@ -379,4 +394,38 @@ public class RESTSerializers {
       return (T) RegisterTableRequestParser.fromJson(jsonNode);
     }
   }
+
+  static class CreateViewRequestSerializer<T extends CreateViewRequest> 
extends JsonSerializer<T> {
+    @Override
+    public void serialize(T request, JsonGenerator gen, SerializerProvider 
serializers)
+        throws IOException {
+      CreateViewRequestParser.toJson(request, gen);
+    }
+  }
+
+  static class CreateViewRequestDeserializer<T extends CreateViewRequest>
+      extends JsonDeserializer<T> {
+    @Override
+    public T deserialize(JsonParser p, DeserializationContext context) throws 
IOException {
+      JsonNode jsonNode = p.getCodec().readTree(p);
+      return (T) CreateViewRequestParser.fromJson(jsonNode);
+    }
+  }
+
+  static class LoadViewResponseSerializer<T extends LoadViewResponse> extends 
JsonSerializer<T> {
+    @Override
+    public void serialize(T request, JsonGenerator gen, SerializerProvider 
serializers)
+        throws IOException {
+      LoadViewResponseParser.toJson(request, gen);
+    }
+  }
+
+  static class LoadViewResponseDeserializer<T extends LoadViewResponse>
+      extends JsonDeserializer<T> {
+    @Override
+    public T deserialize(JsonParser p, DeserializationContext context) throws 
IOException {
+      JsonNode jsonNode = p.getCodec().readTree(p);
+      return (T) LoadViewResponseParser.fromJson(jsonNode);
+    }
+  }
 }
diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java 
b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
index 72547eec34..5a55afbfce 100644
--- a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
@@ -38,6 +38,7 @@ import java.util.function.Supplier;
 import org.apache.iceberg.BaseTable;
 import org.apache.iceberg.CatalogProperties;
 import org.apache.iceberg.CatalogUtil;
+import org.apache.iceberg.EnvironmentContext;
 import org.apache.iceberg.MetadataTableType;
 import org.apache.iceberg.MetadataTableUtils;
 import org.apache.iceberg.MetadataUpdate;
@@ -49,13 +50,14 @@ import org.apache.iceberg.TableMetadata;
 import org.apache.iceberg.TableOperations;
 import org.apache.iceberg.Transaction;
 import org.apache.iceberg.Transactions;
-import org.apache.iceberg.catalog.BaseSessionCatalog;
+import org.apache.iceberg.catalog.BaseViewSessionCatalog;
 import org.apache.iceberg.catalog.Catalog;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableCommit;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.exceptions.NoSuchNamespaceException;
 import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.apache.iceberg.exceptions.NoSuchViewException;
 import org.apache.iceberg.hadoop.Configurable;
 import org.apache.iceberg.io.CloseableGroup;
 import org.apache.iceberg.io.FileIO;
@@ -65,12 +67,15 @@ import 
org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
 import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.relocated.com.google.common.collect.Maps;
 import org.apache.iceberg.rest.auth.OAuth2Properties;
 import org.apache.iceberg.rest.auth.OAuth2Util;
 import org.apache.iceberg.rest.auth.OAuth2Util.AuthSession;
 import org.apache.iceberg.rest.requests.CommitTransactionRequest;
 import org.apache.iceberg.rest.requests.CreateNamespaceRequest;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.CreateViewRequest;
+import org.apache.iceberg.rest.requests.ImmutableCreateViewRequest;
 import org.apache.iceberg.rest.requests.ImmutableRegisterTableRequest;
 import org.apache.iceberg.rest.requests.RegisterTableRequest;
 import org.apache.iceberg.rest.requests.RenameTableRequest;
@@ -82,15 +87,25 @@ import 
org.apache.iceberg.rest.responses.GetNamespaceResponse;
 import org.apache.iceberg.rest.responses.ListNamespacesResponse;
 import org.apache.iceberg.rest.responses.ListTablesResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
+import org.apache.iceberg.rest.responses.LoadViewResponse;
 import org.apache.iceberg.rest.responses.OAuthTokenResponse;
 import org.apache.iceberg.rest.responses.UpdateNamespacePropertiesResponse;
 import org.apache.iceberg.util.EnvironmentUtil;
 import org.apache.iceberg.util.PropertyUtil;
 import org.apache.iceberg.util.ThreadPools;
+import org.apache.iceberg.view.BaseView;
+import org.apache.iceberg.view.ImmutableSQLViewRepresentation;
+import org.apache.iceberg.view.ImmutableViewVersion;
+import org.apache.iceberg.view.View;
+import org.apache.iceberg.view.ViewBuilder;
+import org.apache.iceberg.view.ViewMetadata;
+import org.apache.iceberg.view.ViewRepresentation;
+import org.apache.iceberg.view.ViewUtil;
+import org.apache.iceberg.view.ViewVersion;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-public class RESTSessionCatalog extends BaseSessionCatalog
+public class RESTSessionCatalog extends BaseViewSessionCatalog
     implements Configurable<Object>, Closeable {
   private static final Logger LOG = 
LoggerFactory.getLogger(RESTSessionCatalog.class);
   private static final String DEFAULT_FILE_IO_IMPL = 
"org.apache.iceberg.io.ResolvingFileIO";
@@ -919,6 +934,12 @@ public class RESTSessionCatalog extends BaseSessionCatalog
     }
   }
 
+  private void checkViewIdentifierIsValid(TableIdentifier identifier) {
+    if (identifier.namespace().isEmpty()) {
+      throw new NoSuchViewException("Invalid view identifier: %s", identifier);
+    }
+  }
+
   private void checkNamespaceIsValid(Namespace namespace) {
     if (namespace.isEmpty()) {
       throw new NoSuchNamespaceException("Invalid namespace: %s", namespace);
@@ -971,4 +992,239 @@ public class RESTSessionCatalog extends BaseSessionCatalog
         headers(context),
         ErrorHandlers.tableCommitHandler());
   }
+
+  @Override
+  public List<TableIdentifier> listViews(SessionContext context, Namespace 
namespace) {
+    checkNamespaceIsValid(namespace);
+
+    ListTablesResponse response =
+        client.get(
+            paths.views(namespace),
+            ListTablesResponse.class,
+            headers(context),
+            ErrorHandlers.namespaceErrorHandler());
+    return response.identifiers();
+  }
+
+  @Override
+  public View loadView(SessionContext context, TableIdentifier identifier) {
+    checkViewIdentifierIsValid(identifier);
+
+    LoadViewResponse response =
+        client.get(
+            paths.view(identifier),
+            LoadViewResponse.class,
+            headers(context),
+            ErrorHandlers.viewErrorHandler());
+
+    AuthSession session = tableSession(response.config(), session(context));
+    ViewMetadata metadata = response.metadata();
+
+    RESTViewOperations ops =
+        new RESTViewOperations(client, paths.view(identifier), 
session::headers, metadata);
+
+    return new BaseView(ops, ViewUtil.fullViewName(name(), identifier));
+  }
+
+  @Override
+  public RESTViewBuilder buildView(SessionContext context, TableIdentifier 
identifier) {
+    return new RESTViewBuilder(context, identifier);
+  }
+
+  @Override
+  public boolean dropView(SessionContext context, TableIdentifier identifier) {
+    checkViewIdentifierIsValid(identifier);
+
+    try {
+      client.delete(
+          paths.view(identifier), null, headers(context), 
ErrorHandlers.viewErrorHandler());
+      return true;
+    } catch (NoSuchViewException e) {
+      return false;
+    }
+  }
+
+  @Override
+  public void renameView(SessionContext context, TableIdentifier from, 
TableIdentifier to) {
+    checkViewIdentifierIsValid(from);
+    checkViewIdentifierIsValid(to);
+
+    RenameTableRequest request =
+        
RenameTableRequest.builder().withSource(from).withDestination(to).build();
+
+    client.post(
+        paths.renameView(), request, null, headers(context), 
ErrorHandlers.viewErrorHandler());
+  }
+
+  private class RESTViewBuilder implements ViewBuilder {
+    private final SessionContext context;
+    private final TableIdentifier identifier;
+    private final Map<String, String> properties = Maps.newHashMap();
+    private final List<ViewRepresentation> representations = 
Lists.newArrayList();
+    private Namespace defaultNamespace = null;
+    private String defaultCatalog = null;
+    private Schema schema = null;
+    private String location = null;
+
+    private RESTViewBuilder(SessionContext context, TableIdentifier 
identifier) {
+      checkViewIdentifierIsValid(identifier);
+      this.identifier = identifier;
+      this.context = context;
+    }
+
+    @Override
+    public ViewBuilder withSchema(Schema newSchema) {
+      this.schema = newSchema;
+      return this;
+    }
+
+    @Override
+    public ViewBuilder withQuery(String dialect, String sql) {
+      representations.add(
+          
ImmutableSQLViewRepresentation.builder().dialect(dialect).sql(sql).build());
+      return this;
+    }
+
+    @Override
+    public ViewBuilder withDefaultCatalog(String catalog) {
+      this.defaultCatalog = catalog;
+      return this;
+    }
+
+    @Override
+    public ViewBuilder withDefaultNamespace(Namespace namespace) {
+      this.defaultNamespace = namespace;
+      return this;
+    }
+
+    @Override
+    public ViewBuilder withProperties(Map<String, String> newProperties) {
+      this.properties.putAll(newProperties);
+      return this;
+    }
+
+    @Override
+    public ViewBuilder withProperty(String key, String value) {
+      this.properties.put(key, value);
+      return this;
+    }
+
+    @Override
+    public ViewBuilder withLocation(String newLocation) {
+      this.location = newLocation;
+      return this;
+    }
+
+    @Override
+    public View create() {
+      Preconditions.checkState(
+          !representations.isEmpty(), "Cannot create view without specifying a 
query");
+      Preconditions.checkState(null != schema, "Cannot create view without 
specifying schema");
+      Preconditions.checkState(
+          null != defaultNamespace, "Cannot create view without specifying a 
default namespace");
+
+      ViewVersion viewVersion =
+          ImmutableViewVersion.builder()
+              .versionId(1)
+              .schemaId(schema.schemaId())
+              .addAllRepresentations(representations)
+              .defaultNamespace(defaultNamespace)
+              .defaultCatalog(defaultCatalog)
+              .timestampMillis(System.currentTimeMillis())
+              .putAllSummary(EnvironmentContext.get())
+              .build();
+
+      CreateViewRequest request =
+          ImmutableCreateViewRequest.builder()
+              .name(identifier.name())
+              .location(location)
+              .schema(schema)
+              .viewVersion(viewVersion)
+              .properties(properties)
+              .build();
+
+      LoadViewResponse response =
+          client.post(
+              paths.views(identifier.namespace()),
+              request,
+              LoadViewResponse.class,
+              headers(context),
+              ErrorHandlers.viewErrorHandler());
+
+      AuthSession session = tableSession(response.config(), session(context));
+      RESTViewOperations ops =
+          new RESTViewOperations(
+              client, paths.view(identifier), session::headers, 
response.metadata());
+
+      return new BaseView(ops, ViewUtil.fullViewName(name(), identifier));
+    }
+
+    @Override
+    public View createOrReplace() {
+      try {
+        return replace(loadView());
+      } catch (NoSuchViewException e) {
+        return create();
+      }
+    }
+
+    @Override
+    public View replace() {
+      return replace(loadView());
+    }
+
+    private LoadViewResponse loadView() {
+      return client.get(
+          paths.view(identifier),
+          LoadViewResponse.class,
+          headers(context),
+          ErrorHandlers.viewErrorHandler());
+    }
+
+    private View replace(LoadViewResponse response) {
+      Preconditions.checkState(
+          !representations.isEmpty(), "Cannot replace view without specifying 
a query");
+      Preconditions.checkState(null != schema, "Cannot replace view without 
specifying schema");
+      Preconditions.checkState(
+          null != defaultNamespace, "Cannot replace view without specifying a 
default namespace");
+
+      ViewMetadata metadata = response.metadata();
+
+      int maxVersionId =
+          metadata.versions().stream()
+              .map(ViewVersion::versionId)
+              .max(Integer::compareTo)
+              .orElseGet(metadata::currentVersionId);
+
+      ViewVersion viewVersion =
+          ImmutableViewVersion.builder()
+              .versionId(maxVersionId + 1)
+              .schemaId(schema.schemaId())
+              .addAllRepresentations(representations)
+              .defaultNamespace(defaultNamespace)
+              .defaultCatalog(defaultCatalog)
+              .timestampMillis(System.currentTimeMillis())
+              .putAllSummary(EnvironmentContext.get())
+              .build();
+
+      ViewMetadata.Builder builder =
+          ViewMetadata.buildFrom(metadata)
+              .setProperties(properties)
+              .setCurrentVersion(viewVersion, schema);
+
+      if (null != location) {
+        builder.setLocation(location);
+      }
+
+      ViewMetadata replacement = builder.build();
+
+      AuthSession session = tableSession(response.config(), session(context));
+      RESTViewOperations ops =
+          new RESTViewOperations(client, paths.view(identifier), 
session::headers, metadata);
+
+      ops.commit(metadata, replacement);
+
+      return new BaseView(ops, ViewUtil.fullViewName(name(), identifier));
+    }
+  }
 }
diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTViewOperations.java 
b/core/src/main/java/org/apache/iceberg/rest/RESTViewOperations.java
new file mode 100644
index 0000000000..48dc075b13
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTViewOperations.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.rest;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Supplier;
+import org.apache.iceberg.UpdateRequirement;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.apache.iceberg.rest.responses.LoadViewResponse;
+import org.apache.iceberg.view.ViewMetadata;
+import org.apache.iceberg.view.ViewOperations;
+
+class RESTViewOperations implements ViewOperations {
+  private final RESTClient client;
+  private final String path;
+  private final Supplier<Map<String, String>> headers;
+  private ViewMetadata current;
+
+  RESTViewOperations(
+      RESTClient client, String path, Supplier<Map<String, String>> headers, 
ViewMetadata current) {
+    Preconditions.checkArgument(null != current, "Invalid view metadata: 
null");
+    this.client = client;
+    this.path = path;
+    this.headers = headers;
+    this.current = current;
+  }
+
+  @Override
+  public ViewMetadata current() {
+    return current;
+  }
+
+  @Override
+  public ViewMetadata refresh() {
+    return updateCurrentMetadata(
+        client.get(path, LoadViewResponse.class, headers, 
ErrorHandlers.viewErrorHandler()));
+  }
+
+  @Override
+  public void commit(ViewMetadata base, ViewMetadata metadata) {
+    // this is only used for replacing view metadata
+    Preconditions.checkState(base != null, "Invalid base metadata: null");
+
+    UpdateTableRequest request =
+        UpdateTableRequest.create(
+            null,
+            ImmutableList.of(new 
UpdateRequirement.AssertTableUUID(base.uuid())),
+            metadata.changes());
+
+    LoadViewResponse response =
+        client.post(
+            path, request, LoadViewResponse.class, headers, 
ErrorHandlers.viewCommitHandler());
+
+    updateCurrentMetadata(response);
+  }
+
+  private ViewMetadata updateCurrentMetadata(LoadViewResponse response) {
+    if (!Objects.equals(current.metadataFileLocation(), 
response.metadataLocation())) {
+      this.current = response.metadata();
+    }
+
+    return current;
+  }
+}
diff --git a/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java 
b/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java
index b5b974f14c..c68a4f4508 100644
--- a/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java
+++ b/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java
@@ -93,4 +93,22 @@ public class ResourcePaths {
   public String commitTransaction() {
     return SLASH.join("v1", prefix, "transactions", "commit");
   }
+
+  public String views(Namespace ns) {
+    return SLASH.join("v1", prefix, "namespaces", 
RESTUtil.encodeNamespace(ns), "views");
+  }
+
+  public String view(TableIdentifier ident) {
+    return SLASH.join(
+        "v1",
+        prefix,
+        "namespaces",
+        RESTUtil.encodeNamespace(ident.namespace()),
+        "views",
+        RESTUtil.encodeString(ident.name()));
+  }
+
+  public String renameView() {
+    return SLASH.join("v1", prefix, "views", "rename");
+  }
 }
diff --git 
a/core/src/main/java/org/apache/iceberg/rest/requests/CreateViewRequest.java 
b/core/src/main/java/org/apache/iceberg/rest/requests/CreateViewRequest.java
new file mode 100644
index 0000000000..1c355273e0
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/rest/requests/CreateViewRequest.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.rest.requests;
+
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.rest.RESTRequest;
+import org.apache.iceberg.view.ViewVersion;
+import org.immutables.value.Value;
+
[email protected]
+public interface CreateViewRequest extends RESTRequest {
+  String name();
+
+  @Nullable
+  String location();
+
+  Schema schema();
+
+  ViewVersion viewVersion();
+
+  Map<String, String> properties();
+
+  @Override
+  default void validate() {
+    // nothing to validate as it's not possible to create an invalid instance
+  }
+}
diff --git 
a/core/src/main/java/org/apache/iceberg/rest/requests/CreateViewRequestParser.java
 
b/core/src/main/java/org/apache/iceberg/rest/requests/CreateViewRequestParser.java
new file mode 100644
index 0000000000..7a66bc4a1e
--- /dev/null
+++ 
b/core/src/main/java/org/apache/iceberg/rest/requests/CreateViewRequestParser.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.rest.requests;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.io.IOException;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.SchemaParser;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.util.JsonUtil;
+import org.apache.iceberg.view.ViewVersion;
+import org.apache.iceberg.view.ViewVersionParser;
+
+public class CreateViewRequestParser {
+
+  private static final String NAME = "name";
+  private static final String LOCATION = "location";
+  private static final String SCHEMA = "schema";
+  private static final String VIEW_VERSION = "view-version";
+  private static final String PROPERTIES = "properties";
+
+  private CreateViewRequestParser() {}
+
+  public static String toJson(CreateViewRequest request) {
+    return toJson(request, false);
+  }
+
+  public static String toJson(CreateViewRequest request, boolean pretty) {
+    return JsonUtil.generate(gen -> toJson(request, gen), pretty);
+  }
+
+  public static void toJson(CreateViewRequest request, JsonGenerator gen) 
throws IOException {
+    Preconditions.checkArgument(null != request, "Invalid create view request: 
null");
+
+    gen.writeStartObject();
+
+    gen.writeStringField(NAME, request.name());
+
+    if (null != request.location()) {
+      gen.writeStringField(LOCATION, request.location());
+    }
+
+    gen.writeFieldName(VIEW_VERSION);
+    ViewVersionParser.toJson(request.viewVersion(), gen);
+
+    gen.writeFieldName(SCHEMA);
+    SchemaParser.toJson(request.schema(), gen);
+
+    if (!request.properties().isEmpty()) {
+      JsonUtil.writeStringMap(PROPERTIES, request.properties(), gen);
+    }
+
+    gen.writeEndObject();
+  }
+
+  public static CreateViewRequest fromJson(String json) {
+    return JsonUtil.parse(json, CreateViewRequestParser::fromJson);
+  }
+
+  public static CreateViewRequest fromJson(JsonNode json) {
+    Preconditions.checkArgument(null != json, "Cannot parse create view 
request from null object");
+
+    String name = JsonUtil.getString(NAME, json);
+    String location = JsonUtil.getStringOrNull(LOCATION, json);
+
+    ViewVersion viewVersion = 
ViewVersionParser.fromJson(JsonUtil.get(VIEW_VERSION, json));
+    Schema schema = SchemaParser.fromJson(JsonUtil.get(SCHEMA, json));
+
+    ImmutableCreateViewRequest.Builder builder =
+        ImmutableCreateViewRequest.builder()
+            .name(name)
+            .location(location)
+            .viewVersion(viewVersion)
+            .schema(schema);
+
+    if (json.has(PROPERTIES)) {
+      builder.properties(JsonUtil.getStringMap(PROPERTIES, json));
+    }
+
+    return builder.build();
+  }
+}
diff --git 
a/core/src/main/java/org/apache/iceberg/rest/requests/RenameTableRequest.java 
b/core/src/main/java/org/apache/iceberg/rest/requests/RenameTableRequest.java
index bb44410f2b..fad0ea3cd2 100644
--- 
a/core/src/main/java/org/apache/iceberg/rest/requests/RenameTableRequest.java
+++ 
b/core/src/main/java/org/apache/iceberg/rest/requests/RenameTableRequest.java
@@ -23,7 +23,7 @@ import 
org.apache.iceberg.relocated.com.google.common.base.MoreObjects;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 import org.apache.iceberg.rest.RESTRequest;
 
-/** A REST request to rename a table. */
+/** A REST request to rename a table or a view. */
 public class RenameTableRequest implements RESTRequest {
 
   private TableIdentifier source;
diff --git 
a/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java 
b/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java
new file mode 100644
index 0000000000..d07ba872fd
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.rest.responses;
+
+import java.util.Map;
+import org.apache.iceberg.rest.RESTResponse;
+import org.apache.iceberg.view.ViewMetadata;
+import org.immutables.value.Value;
+
[email protected]
+public interface LoadViewResponse extends RESTResponse {
+  String metadataLocation();
+
+  ViewMetadata metadata();
+
+  Map<String, String> config();
+
+  @Override
+  default void validate() {
+    // nothing to validate as it's not possible to create an invalid instance
+  }
+}
diff --git 
a/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponseParser.java
 
b/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponseParser.java
new file mode 100644
index 0000000000..a8aaf17e5d
--- /dev/null
+++ 
b/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponseParser.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.rest.responses;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.io.IOException;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.util.JsonUtil;
+import org.apache.iceberg.view.ViewMetadata;
+import org.apache.iceberg.view.ViewMetadataParser;
+
+public class LoadViewResponseParser {
+
+  private static final String METADATA_LOCATION = "metadata-location";
+  private static final String METADATA = "metadata";
+  private static final String CONFIG = "config";
+
+  private LoadViewResponseParser() {}
+
+  public static String toJson(LoadViewResponse response) {
+    return toJson(response, false);
+  }
+
+  public static String toJson(LoadViewResponse response, boolean pretty) {
+    return JsonUtil.generate(gen -> toJson(response, gen), pretty);
+  }
+
+  public static void toJson(LoadViewResponse response, JsonGenerator gen) 
throws IOException {
+    Preconditions.checkArgument(null != response, "Invalid load view response: 
null");
+
+    gen.writeStartObject();
+
+    gen.writeStringField(METADATA_LOCATION, response.metadataLocation());
+
+    gen.writeFieldName(METADATA);
+    ViewMetadataParser.toJson(response.metadata(), gen);
+
+    if (!response.config().isEmpty()) {
+      JsonUtil.writeStringMap(CONFIG, response.config(), gen);
+    }
+
+    gen.writeEndObject();
+  }
+
+  public static LoadViewResponse fromJson(String json) {
+    return JsonUtil.parse(json, LoadViewResponseParser::fromJson);
+  }
+
+  public static LoadViewResponse fromJson(JsonNode json) {
+    Preconditions.checkArgument(null != json, "Cannot parse load view response 
from null object");
+
+    String metadataLocation = JsonUtil.getString(METADATA_LOCATION, json);
+    ViewMetadata metadata = ViewMetadataParser.fromJson(JsonUtil.get(METADATA, 
json));
+
+    if (null == metadata.metadataFileLocation()) {
+      metadata = 
ViewMetadata.buildFrom(metadata).setMetadataLocation(metadataLocation).build();
+    }
+
+    ImmutableLoadViewResponse.Builder builder =
+        
ImmutableLoadViewResponse.builder().metadataLocation(metadataLocation).metadata(metadata);
+
+    if (json.has(CONFIG)) {
+      builder.config(JsonUtil.getStringMap(CONFIG, json));
+    }
+
+    return builder.build();
+  }
+}
diff --git a/core/src/main/java/org/apache/iceberg/view/ViewMetadata.java 
b/core/src/main/java/org/apache/iceberg/view/ViewMetadata.java
index 51921d476c..fa75c352f1 100644
--- a/core/src/main/java/org/apache/iceberg/view/ViewMetadata.java
+++ b/core/src/main/java/org/apache/iceberg/view/ViewMetadata.java
@@ -155,6 +155,7 @@ public interface ViewMetadata extends Serializable {
 
     // internal change tracking
     private Integer lastAddedVersionId = null;
+    private Integer lastAddedSchemaId = null;
 
     // indexes
     private final Map<Integer, ViewVersion> versionsById;
@@ -257,8 +258,13 @@ public interface ViewMetadata extends Serializable {
       return this;
     }
 
-    private int addVersionInternal(ViewVersion version) {
-      int newVersionId = reuseOrCreateNewViewVersionId(version);
+    private int addVersionInternal(ViewVersion newVersion) {
+      int newVersionId = reuseOrCreateNewViewVersionId(newVersion);
+      ViewVersion version = newVersion;
+      if (newVersionId != version.versionId()) {
+        version = 
ImmutableViewVersion.builder().from(version).versionId(newVersionId).build();
+      }
+
       if (versionsById.containsKey(newVersionId)) {
         boolean addedInBuilder =
             changes(MetadataUpdate.AddViewVersion.class)
@@ -267,6 +273,13 @@ public interface ViewMetadata extends Serializable {
         return newVersionId;
       }
 
+      if (newVersion.schemaId() == LAST_ADDED) {
+        ValidationException.check(
+            lastAddedSchemaId != null, "Cannot set last added schema: no 
schema has been added");
+        version =
+            
ImmutableViewVersion.builder().from(newVersion).schemaId(lastAddedSchemaId).build();
+      }
+
       Preconditions.checkArgument(
           schemasById.containsKey(version.schemaId()),
           "Cannot add version with unknown schema: %s",
@@ -283,20 +296,21 @@ public interface ViewMetadata extends Serializable {
         }
       }
 
-      ViewVersion newVersion;
-      if (newVersionId != version.versionId()) {
-        newVersion = 
ImmutableViewVersion.builder().from(version).versionId(newVersionId).build();
+      versions.add(version);
+      versionsById.put(version.versionId(), version);
+
+      if (null != lastAddedSchemaId && version.schemaId() == 
lastAddedSchemaId) {
+        changes.add(
+            new MetadataUpdate.AddViewVersion(
+                
ImmutableViewVersion.builder().from(version).schemaId(LAST_ADDED).build()));
       } else {
-        newVersion = version;
+        changes.add(new MetadataUpdate.AddViewVersion(version));
       }
 
-      versions.add(newVersion);
-      versionsById.put(newVersion.versionId(), newVersion);
-      changes.add(new MetadataUpdate.AddViewVersion(newVersion));
       history.add(
           ImmutableViewHistoryEntry.builder()
-              .timestampMillis(newVersion.timestampMillis())
-              .versionId(newVersion.versionId())
+              .timestampMillis(version.timestampMillis())
+              .versionId(version.versionId())
               .build());
 
       this.lastAddedVersionId = newVersionId;
@@ -358,6 +372,8 @@ public interface ViewMetadata extends Serializable {
       schemasById.put(newSchema.schemaId(), newSchema);
       changes.add(new MetadataUpdate.AddSchema(newSchema, highestFieldId));
 
+      this.lastAddedSchemaId = newSchemaId;
+
       return newSchemaId;
     }
 
diff --git a/core/src/main/java/org/apache/iceberg/view/ViewMetadataParser.java 
b/core/src/main/java/org/apache/iceberg/view/ViewMetadataParser.java
index f50a6c4a85..5e7f2d9d1d 100644
--- a/core/src/main/java/org/apache/iceberg/view/ViewMetadataParser.java
+++ b/core/src/main/java/org/apache/iceberg/view/ViewMetadataParser.java
@@ -62,7 +62,7 @@ public class ViewMetadataParser {
     return JsonUtil.generate(gen -> toJson(metadata, gen), pretty);
   }
 
-  static void toJson(ViewMetadata metadata, JsonGenerator gen) throws 
IOException {
+  public static void toJson(ViewMetadata metadata, JsonGenerator gen) throws 
IOException {
     Preconditions.checkArgument(null != metadata, "Invalid view metadata: 
null");
 
     gen.writeStartObject();
diff --git a/core/src/main/java/org/apache/iceberg/view/ViewVersionParser.java 
b/core/src/main/java/org/apache/iceberg/view/ViewVersionParser.java
index 8bdbdc431c..2645e40d94 100644
--- a/core/src/main/java/org/apache/iceberg/view/ViewVersionParser.java
+++ b/core/src/main/java/org/apache/iceberg/view/ViewVersionParser.java
@@ -65,7 +65,7 @@ public class ViewVersionParser {
     generator.writeEndObject();
   }
 
-  static String toJson(ViewVersion version) {
+  public static String toJson(ViewVersion version) {
     return JsonUtil.generate(gen -> toJson(version, gen), false);
   }
 
diff --git a/core/src/main/java/org/apache/iceberg/view/ViewVersionReplace.java 
b/core/src/main/java/org/apache/iceberg/view/ViewVersionReplace.java
index c1ac43e829..8b3d087940 100644
--- a/core/src/main/java/org/apache/iceberg/view/ViewVersionReplace.java
+++ b/core/src/main/java/org/apache/iceberg/view/ViewVersionReplace.java
@@ -55,7 +55,7 @@ class ViewVersionReplace implements ReplaceViewVersion {
     return internalApply().currentVersion();
   }
 
-  private ViewMetadata internalApply() {
+  ViewMetadata internalApply() {
     Preconditions.checkState(
         !representations.isEmpty(), "Cannot replace view without specifying a 
query");
     Preconditions.checkState(null != schema, "Cannot replace view without 
specifying schema");
diff --git a/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java 
b/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java
index 0772601b77..1974838ede 100644
--- a/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java
+++ b/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java
@@ -31,6 +31,7 @@ import org.apache.iceberg.catalog.Catalog;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.SupportsNamespaces;
 import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.catalog.ViewCatalog;
 import org.apache.iceberg.exceptions.AlreadyExistsException;
 import org.apache.iceberg.exceptions.CommitFailedException;
 import org.apache.iceberg.exceptions.CommitStateUnknownException;
@@ -39,6 +40,7 @@ import 
org.apache.iceberg.exceptions.NamespaceNotEmptyException;
 import org.apache.iceberg.exceptions.NoSuchIcebergTableException;
 import org.apache.iceberg.exceptions.NoSuchNamespaceException;
 import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.apache.iceberg.exceptions.NoSuchViewException;
 import org.apache.iceberg.exceptions.NotAuthorizedException;
 import org.apache.iceberg.exceptions.RESTException;
 import org.apache.iceberg.exceptions.UnprocessableEntityException;
@@ -49,6 +51,7 @@ import 
org.apache.iceberg.relocated.com.google.common.collect.Lists;
 import org.apache.iceberg.rest.requests.CommitTransactionRequest;
 import org.apache.iceberg.rest.requests.CreateNamespaceRequest;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.CreateViewRequest;
 import org.apache.iceberg.rest.requests.RegisterTableRequest;
 import org.apache.iceberg.rest.requests.RenameTableRequest;
 import org.apache.iceberg.rest.requests.ReportMetricsRequest;
@@ -61,6 +64,7 @@ import org.apache.iceberg.rest.responses.GetNamespaceResponse;
 import org.apache.iceberg.rest.responses.ListNamespacesResponse;
 import org.apache.iceberg.rest.responses.ListTablesResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
+import org.apache.iceberg.rest.responses.LoadViewResponse;
 import org.apache.iceberg.rest.responses.OAuthTokenResponse;
 import org.apache.iceberg.rest.responses.UpdateNamespacePropertiesResponse;
 import org.apache.iceberg.util.Pair;
@@ -79,6 +83,7 @@ public class RESTCatalogAdapter implements RESTClient {
           .put(ForbiddenException.class, 403)
           .put(NoSuchNamespaceException.class, 404)
           .put(NoSuchTableException.class, 404)
+          .put(NoSuchViewException.class, 404)
           .put(NoSuchIcebergTableException.class, 404)
           .put(UnsupportedOperationException.class, 406)
           .put(AlreadyExistsException.class, 409)
@@ -89,11 +94,13 @@ public class RESTCatalogAdapter implements RESTClient {
 
   private final Catalog catalog;
   private final SupportsNamespaces asNamespaceCatalog;
+  private final ViewCatalog asViewCatalog;
 
   public RESTCatalogAdapter(Catalog catalog) {
     this.catalog = catalog;
     this.asNamespaceCatalog =
         catalog instanceof SupportsNamespaces ? (SupportsNamespaces) catalog : 
null;
+    this.asViewCatalog = catalog instanceof ViewCatalog ? (ViewCatalog) 
catalog : null;
   }
 
   enum HTTPMethod {
@@ -126,7 +133,7 @@ public class RESTCatalogAdapter implements RESTClient {
         CreateTableRequest.class,
         LoadTableResponse.class),
     LOAD_TABLE(
-        HTTPMethod.GET, "v1/namespaces/{namespace}/tables/{table}", null, 
LoadTableResponse.class),
+        HTTPMethod.GET, "v1/namespaces/{namespace}/tables/{name}", null, 
LoadTableResponse.class),
     REGISTER_TABLE(
         HTTPMethod.POST,
         "v1/namespaces/{namespace}/register",
@@ -134,18 +141,33 @@ public class RESTCatalogAdapter implements RESTClient {
         LoadTableResponse.class),
     UPDATE_TABLE(
         HTTPMethod.POST,
-        "v1/namespaces/{namespace}/tables/{table}",
+        "v1/namespaces/{namespace}/tables/{name}",
         UpdateTableRequest.class,
         LoadTableResponse.class),
-    DROP_TABLE(HTTPMethod.DELETE, "v1/namespaces/{namespace}/tables/{table}"),
+    DROP_TABLE(HTTPMethod.DELETE, "v1/namespaces/{namespace}/tables/{name}"),
     RENAME_TABLE(HTTPMethod.POST, "v1/tables/rename", 
RenameTableRequest.class, null),
     REPORT_METRICS(
         HTTPMethod.POST,
-        "v1/namespaces/{namespace}/tables/{table}/metrics",
+        "v1/namespaces/{namespace}/tables/{name}/metrics",
         ReportMetricsRequest.class,
         null),
     COMMIT_TRANSACTION(
-        HTTPMethod.POST, "v1/transactions/commit", 
CommitTransactionRequest.class, null);
+        HTTPMethod.POST, "v1/transactions/commit", 
CommitTransactionRequest.class, null),
+    LIST_VIEWS(HTTPMethod.GET, "v1/namespaces/{namespace}/views", null, 
ListTablesResponse.class),
+    LOAD_VIEW(
+        HTTPMethod.GET, "v1/namespaces/{namespace}/views/{name}", null, 
LoadViewResponse.class),
+    CREATE_VIEW(
+        HTTPMethod.POST,
+        "v1/namespaces/{namespace}/views",
+        CreateViewRequest.class,
+        LoadViewResponse.class),
+    UPDATE_VIEW(
+        HTTPMethod.POST,
+        "v1/namespaces/{namespace}/views/{name}",
+        UpdateTableRequest.class,
+        LoadViewResponse.class),
+    RENAME_VIEW(HTTPMethod.POST, "v1/views/rename", RenameTableRequest.class, 
null),
+    DROP_VIEW(HTTPMethod.DELETE, "v1/namespaces/{namespace}/views/{name}");
 
     private final HTTPMethod method;
     private final int requiredLength;
@@ -223,7 +245,7 @@ public class RESTCatalogAdapter implements RESTClient {
     }
   }
 
-  @SuppressWarnings("MethodLength")
+  @SuppressWarnings({"MethodLength", "checkstyle:CyclomaticComplexity"})
   public <T extends RESTResponse> T handleRequest(
       Route route, Map<String, String> vars, Object body, Class<T> 
responseType) {
     switch (route) {
@@ -387,6 +409,65 @@ public class RESTCatalogAdapter implements RESTClient {
           return null;
         }
 
+      case LIST_VIEWS:
+        {
+          if (null != asViewCatalog) {
+            Namespace namespace = namespaceFromPathVars(vars);
+            return castResponse(responseType, 
CatalogHandlers.listViews(asViewCatalog, namespace));
+          }
+          break;
+        }
+
+      case CREATE_VIEW:
+        {
+          if (null != asViewCatalog) {
+            Namespace namespace = namespaceFromPathVars(vars);
+            CreateViewRequest request = castRequest(CreateViewRequest.class, 
body);
+            return castResponse(
+                responseType, CatalogHandlers.createView(asViewCatalog, 
namespace, request));
+          }
+          break;
+        }
+
+      case LOAD_VIEW:
+        {
+          if (null != asViewCatalog) {
+            TableIdentifier ident = identFromPathVars(vars);
+            return castResponse(responseType, 
CatalogHandlers.loadView(asViewCatalog, ident));
+          }
+          break;
+        }
+
+      case UPDATE_VIEW:
+        {
+          if (null != asViewCatalog) {
+            TableIdentifier ident = identFromPathVars(vars);
+            UpdateTableRequest request = castRequest(UpdateTableRequest.class, 
body);
+            return castResponse(
+                responseType, CatalogHandlers.updateView(asViewCatalog, ident, 
request));
+          }
+          break;
+        }
+
+      case RENAME_VIEW:
+        {
+          if (null != asViewCatalog) {
+            RenameTableRequest request = castRequest(RenameTableRequest.class, 
body);
+            CatalogHandlers.renameView(asViewCatalog, request);
+            return null;
+          }
+          break;
+        }
+
+      case DROP_VIEW:
+        {
+          if (null != asViewCatalog) {
+            CatalogHandlers.dropView(asViewCatalog, identFromPathVars(vars));
+            return null;
+          }
+          break;
+        }
+
       default:
     }
 
@@ -566,6 +647,6 @@ public class RESTCatalogAdapter implements RESTClient {
 
   private static TableIdentifier identFromPathVars(Map<String, String> 
pathVars) {
     return TableIdentifier.of(
-        namespaceFromPathVars(pathVars), 
RESTUtil.decodeString(pathVars.get("table")));
+        namespaceFromPathVars(pathVars), 
RESTUtil.decodeString(pathVars.get("name")));
   }
 }
diff --git 
a/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalog.java 
b/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalog.java
new file mode 100644
index 0000000000..0b29da7042
--- /dev/null
+++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalog.java
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.rest;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.File;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Consumer;
+import org.apache.iceberg.CatalogProperties;
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.SessionCatalog;
+import org.apache.iceberg.inmemory.InMemoryCatalog;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.rest.RESTCatalogAdapter.HTTPMethod;
+import org.apache.iceberg.rest.responses.ErrorResponse;
+import org.apache.iceberg.view.ViewCatalogTests;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.handler.gzip.GzipHandler;
+import org.eclipse.jetty.servlet.ServletContextHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.io.TempDir;
+
+public class TestRESTViewCatalog extends ViewCatalogTests<RESTCatalog> {
+  private static final ObjectMapper MAPPER = RESTObjectMapper.mapper();
+
+  @TempDir private Path temp;
+
+  private RESTCatalog restCatalog;
+  private InMemoryCatalog backendCatalog;
+  private Server httpServer;
+
+  @BeforeEach
+  public void createCatalog() throws Exception {
+    File warehouse = temp.toFile();
+
+    this.backendCatalog = new InMemoryCatalog();
+    this.backendCatalog.initialize(
+        "in-memory",
+        ImmutableMap.of(CatalogProperties.WAREHOUSE_LOCATION, 
warehouse.getAbsolutePath()));
+
+    RESTCatalogAdapter adaptor =
+        new RESTCatalogAdapter(backendCatalog) {
+          @Override
+          public <T extends RESTResponse> T execute(
+              HTTPMethod method,
+              String path,
+              Map<String, String> queryParams,
+              Object body,
+              Class<T> responseType,
+              Map<String, String> headers,
+              Consumer<ErrorResponse> errorHandler) {
+            Object request = roundTripSerialize(body, "request");
+            T response =
+                super.execute(
+                    method, path, queryParams, request, responseType, headers, 
errorHandler);
+            T responseAfterSerialization = roundTripSerialize(response, 
"response");
+            return responseAfterSerialization;
+          }
+        };
+
+    RESTCatalogServlet servlet = new RESTCatalogServlet(adaptor);
+    ServletContextHandler servletContext =
+        new ServletContextHandler(ServletContextHandler.NO_SESSIONS);
+    servletContext.setContextPath("/");
+    ServletHolder servletHolder = new ServletHolder(servlet);
+    servletHolder.setInitParameter("javax.ws.rs.Application", 
"ServiceListPublic");
+    servletContext.addServlet(servletHolder, "/*");
+    servletContext.setVirtualHosts(null);
+    servletContext.setGzipHandler(new GzipHandler());
+
+    this.httpServer = new Server(0);
+    httpServer.setHandler(servletContext);
+    httpServer.start();
+
+    SessionCatalog.SessionContext context =
+        new SessionCatalog.SessionContext(
+            UUID.randomUUID().toString(),
+            "user",
+            ImmutableMap.of("credential", "user:12345"),
+            ImmutableMap.of());
+
+    this.restCatalog =
+        new RESTCatalog(
+            context,
+            (config) -> 
HTTPClient.builder(config).uri(config.get(CatalogProperties.URI)).build());
+    restCatalog.initialize(
+        "prod",
+        ImmutableMap.of(
+            CatalogProperties.URI, httpServer.getURI().toString(), 
"credential", "catalog:12345"));
+  }
+
+  @SuppressWarnings("unchecked")
+  public static <T> T roundTripSerialize(T payload, String description) {
+    if (payload != null) {
+      try {
+        if (payload instanceof RESTMessage) {
+          return (T) MAPPER.readValue(MAPPER.writeValueAsString(payload), 
payload.getClass());
+        } else {
+          // use Map so that Jackson doesn't try to instantiate ImmutableMap 
from payload.getClass()
+          return (T) MAPPER.readValue(MAPPER.writeValueAsString(payload), 
Map.class);
+        }
+      } catch (JsonProcessingException e) {
+        throw new RuntimeException(
+            String.format("Failed to serialize and deserialize %s: %s", 
description, payload), e);
+      }
+    }
+    return null;
+  }
+
+  @AfterEach
+  public void closeCatalog() throws Exception {
+    if (restCatalog != null) {
+      restCatalog.close();
+    }
+
+    if (backendCatalog != null) {
+      backendCatalog.close();
+    }
+
+    if (httpServer != null) {
+      httpServer.stop();
+      httpServer.join();
+    }
+  }
+
+  @Override
+  protected RESTCatalog catalog() {
+    return restCatalog;
+  }
+
+  @Override
+  protected Catalog tableCatalog() {
+    return restCatalog;
+  }
+
+  @Override
+  protected boolean requiresNamespaceCreate() {
+    return true;
+  }
+
+  @Override
+  protected boolean supportsServerSideRetry() {
+    return true;
+  }
+}
diff --git a/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java 
b/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java
index e0e61a594e..4b91fbbad3 100644
--- a/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java
+++ b/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java
@@ -143,4 +143,51 @@ public class TestResourcePaths {
         .isEqualTo("v1/ws/catalog/namespaces/ns/register");
     
Assertions.assertThat(withoutPrefix.register(ns)).isEqualTo("v1/namespaces/ns/register");
   }
+
+  @Test
+  public void views() {
+    Namespace ns = Namespace.of("ns");
+    
Assertions.assertThat(withPrefix.views(ns)).isEqualTo("v1/ws/catalog/namespaces/ns/views");
+    
Assertions.assertThat(withoutPrefix.views(ns)).isEqualTo("v1/namespaces/ns/views");
+  }
+
+  @Test
+  public void viewsWithSlash() {
+    Namespace ns = Namespace.of("n/s");
+    
Assertions.assertThat(withPrefix.views(ns)).isEqualTo("v1/ws/catalog/namespaces/n%2Fs/views");
+    
Assertions.assertThat(withoutPrefix.views(ns)).isEqualTo("v1/namespaces/n%2Fs/views");
+  }
+
+  @Test
+  public void viewsWithMultipartNamespace() {
+    Namespace ns = Namespace.of("n", "s");
+    
Assertions.assertThat(withPrefix.views(ns)).isEqualTo("v1/ws/catalog/namespaces/n%1Fs/views");
+    
Assertions.assertThat(withoutPrefix.views(ns)).isEqualTo("v1/namespaces/n%1Fs/views");
+  }
+
+  @Test
+  public void view() {
+    TableIdentifier ident = TableIdentifier.of("ns", "view-name");
+    Assertions.assertThat(withPrefix.view(ident))
+        .isEqualTo("v1/ws/catalog/namespaces/ns/views/view-name");
+    
Assertions.assertThat(withoutPrefix.view(ident)).isEqualTo("v1/namespaces/ns/views/view-name");
+  }
+
+  @Test
+  public void viewWithSlash() {
+    TableIdentifier ident = TableIdentifier.of("n/s", "vi/ew-name");
+    Assertions.assertThat(withPrefix.view(ident))
+        .isEqualTo("v1/ws/catalog/namespaces/n%2Fs/views/vi%2Few-name");
+    Assertions.assertThat(withoutPrefix.view(ident))
+        .isEqualTo("v1/namespaces/n%2Fs/views/vi%2Few-name");
+  }
+
+  @Test
+  public void viewWithMultipartNamespace() {
+    TableIdentifier ident = TableIdentifier.of("n", "s", "view-name");
+    Assertions.assertThat(withPrefix.view(ident))
+        .isEqualTo("v1/ws/catalog/namespaces/n%1Fs/views/view-name");
+    Assertions.assertThat(withoutPrefix.view(ident))
+        .isEqualTo("v1/namespaces/n%1Fs/views/view-name");
+  }
 }
diff --git 
a/core/src/test/java/org/apache/iceberg/rest/requests/TestCreateViewRequestParser.java
 
b/core/src/test/java/org/apache/iceberg/rest/requests/TestCreateViewRequestParser.java
new file mode 100644
index 0000000000..a228c94a08
--- /dev/null
+++ 
b/core/src/test/java/org/apache/iceberg/rest/requests/TestCreateViewRequestParser.java
@@ -0,0 +1,130 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.rest.requests;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.types.Types;
+import org.apache.iceberg.view.ImmutableViewVersion;
+import org.apache.iceberg.view.ViewVersionParser;
+import org.junit.jupiter.api.Test;
+
+public class TestCreateViewRequestParser {
+
+  @Test
+  public void nullAndEmptyCheck() {
+    assertThatThrownBy(() -> CreateViewRequestParser.toJson(null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid create view request: null");
+
+    assertThatThrownBy(() -> CreateViewRequestParser.fromJson((JsonNode) null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Cannot parse create view request from null object");
+
+    assertThatThrownBy(() -> CreateViewRequestParser.fromJson("{}"))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Cannot parse missing string: name");
+  }
+
+  @Test
+  public void missingFields() {
+    assertThatThrownBy(() -> CreateViewRequestParser.fromJson("{\"x\": 
\"val\"}"))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Cannot parse missing string: name");
+
+    assertThatThrownBy(() -> CreateViewRequestParser.fromJson("{\"name\": 
\"view-name\"}"))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Cannot parse missing field: view-version");
+
+    String viewVersion =
+        ViewVersionParser.toJson(
+            ImmutableViewVersion.builder()
+                .schemaId(0)
+                .versionId(1)
+                .timestampMillis(23L)
+                .defaultNamespace(Namespace.of("ns1"))
+                .build());
+
+    assertThatThrownBy(
+            () ->
+                CreateViewRequestParser.fromJson(
+                    String.format(
+                        "{\"name\": \"view-name\", \"location\": \"loc\", 
\"view-version\": %s}",
+                        viewVersion)))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Cannot parse missing field: schema");
+  }
+
+  @Test
+  public void roundTripSerde() {
+    CreateViewRequest request =
+        ImmutableCreateViewRequest.builder()
+            .name("view-name")
+            .viewVersion(
+                ImmutableViewVersion.builder()
+                    .schemaId(0)
+                    .versionId(1)
+                    .timestampMillis(23L)
+                    .defaultNamespace(Namespace.of("ns1"))
+                    .build())
+            .location("location")
+            .schema(new Schema(Types.NestedField.required(1, "x", 
Types.LongType.get())))
+            .properties(ImmutableMap.of("key1", "val1"))
+            .build();
+
+    String expectedJson =
+        "{\n"
+            + "  \"name\" : \"view-name\",\n"
+            + "  \"location\" : \"location\",\n"
+            + "  \"view-version\" : {\n"
+            + "    \"version-id\" : 1,\n"
+            + "    \"timestamp-ms\" : 23,\n"
+            + "    \"schema-id\" : 0,\n"
+            + "    \"summary\" : { },\n"
+            + "    \"default-namespace\" : [ \"ns1\" ],\n"
+            + "    \"representations\" : [ ]\n"
+            + "  },\n"
+            + "  \"schema\" : {\n"
+            + "    \"type\" : \"struct\",\n"
+            + "    \"schema-id\" : 0,\n"
+            + "    \"fields\" : [ {\n"
+            + "      \"id\" : 1,\n"
+            + "      \"name\" : \"x\",\n"
+            + "      \"required\" : true,\n"
+            + "      \"type\" : \"long\"\n"
+            + "    } ]\n"
+            + "  },\n"
+            + "  \"properties\" : {\n"
+            + "    \"key1\" : \"val1\"\n"
+            + "  }\n"
+            + "}";
+
+    String json = CreateViewRequestParser.toJson(request, true);
+    assertThat(json).isEqualTo(expectedJson);
+
+    // can't do an equality comparison because Schema doesn't implement 
equals/hashCode
+    
assertThat(CreateViewRequestParser.toJson(CreateViewRequestParser.fromJson(json),
 true))
+        .isEqualTo(expectedJson);
+  }
+}
diff --git 
a/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadViewResponseParser.java
 
b/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadViewResponseParser.java
new file mode 100644
index 0000000000..d94d035596
--- /dev/null
+++ 
b/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadViewResponseParser.java
@@ -0,0 +1,260 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.rest.responses;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.types.Types;
+import org.apache.iceberg.view.ImmutableViewVersion;
+import org.apache.iceberg.view.ViewMetadata;
+import org.junit.jupiter.api.Test;
+
+public class TestLoadViewResponseParser {
+
+  @Test
+  public void nullAndEmptyCheck() {
+    assertThatThrownBy(() -> LoadViewResponseParser.toJson(null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid load view response: null");
+
+    assertThatThrownBy(() -> LoadViewResponseParser.fromJson((JsonNode) null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Cannot parse load view response from null object");
+
+    assertThatThrownBy(() -> LoadViewResponseParser.fromJson("{}"))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Cannot parse missing string: metadata-location");
+  }
+
+  @Test
+  public void missingFields() {
+    assertThatThrownBy(() -> LoadViewResponseParser.fromJson("{\"x\": 
\"val\"}"))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Cannot parse missing string: metadata-location");
+
+    assertThatThrownBy(
+            () -> LoadViewResponseParser.fromJson("{\"metadata-location\": 
\"custom-location\"}"))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Cannot parse missing field: metadata");
+  }
+
+  @Test
+  public void roundTripSerde() {
+    String uuid = "386b9f01-002b-4d8c-b77f-42c3fd3b7c9b";
+    ViewMetadata viewMetadata =
+        ViewMetadata.builder()
+            .assignUUID(uuid)
+            .setLocation("location")
+            .addSchema(new Schema(Types.NestedField.required(1, "x", 
Types.LongType.get())))
+            .addVersion(
+                ImmutableViewVersion.builder()
+                    .schemaId(0)
+                    .versionId(1)
+                    .timestampMillis(23L)
+                    .defaultNamespace(Namespace.of("ns1"))
+                    .build())
+            .addVersion(
+                ImmutableViewVersion.builder()
+                    .schemaId(0)
+                    .versionId(2)
+                    .timestampMillis(24L)
+                    .defaultNamespace(Namespace.of("ns2"))
+                    .build())
+            .addVersion(
+                ImmutableViewVersion.builder()
+                    .schemaId(0)
+                    .versionId(3)
+                    .timestampMillis(25L)
+                    .defaultNamespace(Namespace.of("ns3"))
+                    .build())
+            .setCurrentVersionId(3)
+            .build();
+
+    LoadViewResponse response =
+        ImmutableLoadViewResponse.builder()
+            .metadata(viewMetadata)
+            .metadataLocation("custom-location")
+            .build();
+    String expectedJson =
+        "{\n"
+            + "  \"metadata-location\" : \"custom-location\",\n"
+            + "  \"metadata\" : {\n"
+            + "    \"view-uuid\" : \"386b9f01-002b-4d8c-b77f-42c3fd3b7c9b\",\n"
+            + "    \"format-version\" : 1,\n"
+            + "    \"location\" : \"location\",\n"
+            + "    \"schemas\" : [ {\n"
+            + "      \"type\" : \"struct\",\n"
+            + "      \"schema-id\" : 0,\n"
+            + "      \"fields\" : [ {\n"
+            + "        \"id\" : 1,\n"
+            + "        \"name\" : \"x\",\n"
+            + "        \"required\" : true,\n"
+            + "        \"type\" : \"long\"\n"
+            + "      } ]\n"
+            + "    } ],\n"
+            + "    \"current-version-id\" : 3,\n"
+            + "    \"versions\" : [ {\n"
+            + "      \"version-id\" : 1,\n"
+            + "      \"timestamp-ms\" : 23,\n"
+            + "      \"schema-id\" : 0,\n"
+            + "      \"summary\" : { },\n"
+            + "      \"default-namespace\" : [ \"ns1\" ],\n"
+            + "      \"representations\" : [ ]\n"
+            + "    }, {\n"
+            + "      \"version-id\" : 2,\n"
+            + "      \"timestamp-ms\" : 24,\n"
+            + "      \"schema-id\" : 0,\n"
+            + "      \"summary\" : { },\n"
+            + "      \"default-namespace\" : [ \"ns2\" ],\n"
+            + "      \"representations\" : [ ]\n"
+            + "    }, {\n"
+            + "      \"version-id\" : 3,\n"
+            + "      \"timestamp-ms\" : 25,\n"
+            + "      \"schema-id\" : 0,\n"
+            + "      \"summary\" : { },\n"
+            + "      \"default-namespace\" : [ \"ns3\" ],\n"
+            + "      \"representations\" : [ ]\n"
+            + "    } ],\n"
+            + "    \"version-log\" : [ {\n"
+            + "      \"timestamp-ms\" : 23,\n"
+            + "      \"version-id\" : 1\n"
+            + "    }, {\n"
+            + "      \"timestamp-ms\" : 24,\n"
+            + "      \"version-id\" : 2\n"
+            + "    }, {\n"
+            + "      \"timestamp-ms\" : 25,\n"
+            + "      \"version-id\" : 3\n"
+            + "    } ]\n"
+            + "  }\n"
+            + "}";
+
+    String json = LoadViewResponseParser.toJson(response, true);
+    assertThat(json).isEqualTo(expectedJson);
+    // can't do an equality comparison because Schema doesn't implement 
equals/hashCode
+    
assertThat(LoadViewResponseParser.toJson(LoadViewResponseParser.fromJson(json), 
true))
+        .isEqualTo(expectedJson);
+  }
+
+  @Test
+  public void roundTripSerdeWithConfig() {
+    String uuid = "386b9f01-002b-4d8c-b77f-42c3fd3b7c9b";
+    ViewMetadata viewMetadata =
+        ViewMetadata.builder()
+            .assignUUID(uuid)
+            .setLocation("location")
+            .addSchema(new Schema(Types.NestedField.required(1, "x", 
Types.LongType.get())))
+            .addVersion(
+                ImmutableViewVersion.builder()
+                    .schemaId(0)
+                    .versionId(1)
+                    .timestampMillis(23L)
+                    .defaultNamespace(Namespace.of("ns1"))
+                    .build())
+            .addVersion(
+                ImmutableViewVersion.builder()
+                    .schemaId(0)
+                    .versionId(2)
+                    .timestampMillis(24L)
+                    .defaultNamespace(Namespace.of("ns2"))
+                    .build())
+            .addVersion(
+                ImmutableViewVersion.builder()
+                    .schemaId(0)
+                    .versionId(3)
+                    .timestampMillis(25L)
+                    .defaultNamespace(Namespace.of("ns3"))
+                    .build())
+            .setCurrentVersionId(3)
+            .build();
+
+    LoadViewResponse response =
+        ImmutableLoadViewResponse.builder()
+            .metadata(viewMetadata)
+            .metadataLocation("custom-location")
+            .config(ImmutableMap.of("key1", "val1", "key2", "val2"))
+            .build();
+    String expectedJson =
+        "{\n"
+            + "  \"metadata-location\" : \"custom-location\",\n"
+            + "  \"metadata\" : {\n"
+            + "    \"view-uuid\" : \"386b9f01-002b-4d8c-b77f-42c3fd3b7c9b\",\n"
+            + "    \"format-version\" : 1,\n"
+            + "    \"location\" : \"location\",\n"
+            + "    \"schemas\" : [ {\n"
+            + "      \"type\" : \"struct\",\n"
+            + "      \"schema-id\" : 0,\n"
+            + "      \"fields\" : [ {\n"
+            + "        \"id\" : 1,\n"
+            + "        \"name\" : \"x\",\n"
+            + "        \"required\" : true,\n"
+            + "        \"type\" : \"long\"\n"
+            + "      } ]\n"
+            + "    } ],\n"
+            + "    \"current-version-id\" : 3,\n"
+            + "    \"versions\" : [ {\n"
+            + "      \"version-id\" : 1,\n"
+            + "      \"timestamp-ms\" : 23,\n"
+            + "      \"schema-id\" : 0,\n"
+            + "      \"summary\" : { },\n"
+            + "      \"default-namespace\" : [ \"ns1\" ],\n"
+            + "      \"representations\" : [ ]\n"
+            + "    }, {\n"
+            + "      \"version-id\" : 2,\n"
+            + "      \"timestamp-ms\" : 24,\n"
+            + "      \"schema-id\" : 0,\n"
+            + "      \"summary\" : { },\n"
+            + "      \"default-namespace\" : [ \"ns2\" ],\n"
+            + "      \"representations\" : [ ]\n"
+            + "    }, {\n"
+            + "      \"version-id\" : 3,\n"
+            + "      \"timestamp-ms\" : 25,\n"
+            + "      \"schema-id\" : 0,\n"
+            + "      \"summary\" : { },\n"
+            + "      \"default-namespace\" : [ \"ns3\" ],\n"
+            + "      \"representations\" : [ ]\n"
+            + "    } ],\n"
+            + "    \"version-log\" : [ {\n"
+            + "      \"timestamp-ms\" : 23,\n"
+            + "      \"version-id\" : 1\n"
+            + "    }, {\n"
+            + "      \"timestamp-ms\" : 24,\n"
+            + "      \"version-id\" : 2\n"
+            + "    }, {\n"
+            + "      \"timestamp-ms\" : 25,\n"
+            + "      \"version-id\" : 3\n"
+            + "    } ]\n"
+            + "  },\n"
+            + "  \"config\" : {\n"
+            + "    \"key1\" : \"val1\",\n"
+            + "    \"key2\" : \"val2\"\n"
+            + "  }\n"
+            + "}";
+
+    String json = LoadViewResponseParser.toJson(response, true);
+    assertThat(json).isEqualTo(expectedJson);
+    // can't do an equality comparison because Schema doesn't implement 
equals/hashCode
+    
assertThat(LoadViewResponseParser.toJson(LoadViewResponseParser.fromJson(json), 
true))
+        .isEqualTo(expectedJson);
+  }
+}
diff --git a/core/src/test/java/org/apache/iceberg/view/TestViewMetadata.java 
b/core/src/test/java/org/apache/iceberg/view/TestViewMetadata.java
index 0f1758f85e..e60fe3b285 100644
--- a/core/src/test/java/org/apache/iceberg/view/TestViewMetadata.java
+++ b/core/src/test/java/org/apache/iceberg/view/TestViewMetadata.java
@@ -28,6 +28,7 @@ import java.util.UUID;
 import org.apache.iceberg.MetadataUpdate;
 import org.apache.iceberg.Schema;
 import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.exceptions.ValidationException;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet;
@@ -289,21 +290,21 @@ public class TestViewMetadata {
         .isInstanceOf(MetadataUpdate.AddViewVersion.class)
         
.asInstanceOf(InstanceOfAssertFactories.type(MetadataUpdate.AddViewVersion.class))
         .extracting(MetadataUpdate.AddViewVersion::viewVersion)
-        .isEqualTo(viewVersionOne);
+        
.isEqualTo(ImmutableViewVersion.builder().from(viewVersionOne).schemaId(-1).build());
 
     assertThat(changes)
         .element(4)
         .isInstanceOf(MetadataUpdate.AddViewVersion.class)
         
.asInstanceOf(InstanceOfAssertFactories.type(MetadataUpdate.AddViewVersion.class))
         .extracting(MetadataUpdate.AddViewVersion::viewVersion)
-        .isEqualTo(viewVersionTwo);
+        
.isEqualTo(ImmutableViewVersion.builder().from(viewVersionTwo).schemaId(-1).build());
 
     assertThat(changes)
         .element(5)
         .isInstanceOf(MetadataUpdate.AddViewVersion.class)
         
.asInstanceOf(InstanceOfAssertFactories.type(MetadataUpdate.AddViewVersion.class))
         .extracting(MetadataUpdate.AddViewVersion::viewVersion)
-        .isEqualTo(viewVersionThree);
+        
.isEqualTo(ImmutableViewVersion.builder().from(viewVersionThree).schemaId(-1).build());
 
     assertThat(changes)
         .element(6)
@@ -409,7 +410,7 @@ public class TestViewMetadata {
         .isInstanceOf(MetadataUpdate.AddViewVersion.class)
         
.asInstanceOf(InstanceOfAssertFactories.type(MetadataUpdate.AddViewVersion.class))
         .extracting(MetadataUpdate.AddViewVersion::viewVersion)
-        .isEqualTo(viewVersionThree);
+        
.isEqualTo(ImmutableViewVersion.builder().from(viewVersionThree).schemaId(-1).build());
 
     assertThat(changes)
         .element(8)
@@ -784,4 +785,18 @@ public class TestViewMetadata {
         .isInstanceOf(IllegalArgumentException.class)
         .hasMessage("Invalid view version: Cannot add multiple queries for 
dialect spark");
   }
+
+  @Test
+  public void lastAddedSchemaFailure() {
+    ViewVersion viewVersion = newViewVersion(1, -1, "select * from ns.tbl");
+    assertThatThrownBy(
+            () ->
+                ViewMetadata.builder()
+                    .setLocation("custom-location")
+                    .addVersion(viewVersion)
+                    .setCurrentVersionId(1)
+                    .build())
+        .isInstanceOf(ValidationException.class)
+        .hasMessage("Cannot set last added schema: no schema has been added");
+  }
 }
diff --git a/core/src/test/java/org/apache/iceberg/view/ViewCatalogTests.java 
b/core/src/test/java/org/apache/iceberg/view/ViewCatalogTests.java
index 682d7ade67..8cb77a7762 100644
--- a/core/src/test/java/org/apache/iceberg/view/ViewCatalogTests.java
+++ b/core/src/test/java/org/apache/iceberg/view/ViewCatalogTests.java
@@ -34,6 +34,7 @@ import org.apache.iceberg.catalog.SupportsNamespaces;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.catalog.ViewCatalog;
 import org.apache.iceberg.exceptions.AlreadyExistsException;
+import org.apache.iceberg.exceptions.CommitFailedException;
 import org.apache.iceberg.exceptions.NoSuchNamespaceException;
 import org.apache.iceberg.exceptions.NoSuchTableException;
 import org.apache.iceberg.exceptions.NoSuchViewException;
@@ -69,6 +70,10 @@ public abstract class ViewCatalogTests<C extends ViewCatalog 
& SupportsNamespace
     return false;
   }
 
+  protected boolean supportsServerSideRetry() {
+    return false;
+  }
+
   @Test
   public void basicCreateView() {
     TableIdentifier identifier = TableIdentifier.of("ns", "view");
@@ -247,8 +252,9 @@ public abstract class ViewCatalogTests<C extends 
ViewCatalog & SupportsNamespace
                     .withQuery(trino.dialect(), trino.sql())
                     .withQuery(trino.dialect(), trino.sql())
                     .create())
-        .isInstanceOf(IllegalArgumentException.class)
-        .hasMessage("Invalid view version: Cannot add multiple queries for 
dialect trino");
+        .isInstanceOf(Exception.class)
+        .hasMessageContaining(
+            "Invalid view version: Cannot add multiple queries for dialect 
trino");
   }
 
   @Test
@@ -1534,4 +1540,138 @@ public abstract class ViewCatalogTests<C extends 
ViewCatalog & SupportsNamespace
         .isInstanceOf(NoSuchViewException.class)
         .hasMessageContaining("View does not exist: ns.view");
   }
+
+  @Test
+  public void concurrentReplaceViewVersion() {
+    TableIdentifier identifier = TableIdentifier.of("ns", "view");
+
+    if (requiresNamespaceCreate()) {
+      catalog().createNamespace(identifier.namespace());
+    }
+
+    assertThat(catalog().viewExists(identifier)).as("View should not 
exist").isFalse();
+
+    View view =
+        catalog()
+            .buildView(identifier)
+            .withSchema(SCHEMA)
+            .withDefaultNamespace(identifier.namespace())
+            .withQuery("trino", "select * from ns.tbl")
+            .create();
+
+    assertThat(catalog().viewExists(identifier)).as("View should 
exist").isTrue();
+
+    ReplaceViewVersion replaceViewVersionOne =
+        view.replaceVersion()
+            .withQuery("trino", "select count(id) from ns.tbl")
+            .withSchema(SCHEMA)
+            .withDefaultNamespace(identifier.namespace());
+
+    ReplaceViewVersion replaceViewVersionTwo =
+        view.replaceVersion()
+            .withQuery("spark", "select count(some_id) from ns.tbl")
+            .withSchema(OTHER_SCHEMA)
+            .withDefaultNamespace(identifier.namespace());
+
+    // simulate a concurrent replace of the view version
+    ViewOperations viewOps = ((BaseView) view).operations();
+    ViewMetadata current = viewOps.current();
+
+    ViewMetadata trinoUpdate = ((ViewVersionReplace) 
replaceViewVersionTwo).internalApply();
+    ViewMetadata sparkUpdate = ((ViewVersionReplace) 
replaceViewVersionOne).internalApply();
+
+    viewOps.commit(current, trinoUpdate);
+
+    if (supportsServerSideRetry()) {
+      // retry should succeed and the changes should be applied
+      viewOps.commit(current, sparkUpdate);
+
+      View updatedView = catalog().loadView(identifier);
+      ViewVersion viewVersion = updatedView.currentVersion();
+      assertThat(viewVersion.versionId()).isEqualTo(3);
+      assertThat(updatedView.versions()).hasSize(3);
+      assertThat(updatedView.version(1))
+          .isEqualTo(
+              ImmutableViewVersion.builder()
+                  .timestampMillis(updatedView.version(1).timestampMillis())
+                  .versionId(1)
+                  .schemaId(0)
+                  .summary(updatedView.version(1).summary())
+                  .defaultNamespace(identifier.namespace())
+                  .addRepresentations(
+                      ImmutableSQLViewRepresentation.builder()
+                          .sql("select * from ns.tbl")
+                          .dialect("trino")
+                          .build())
+                  .build());
+
+      assertThat(updatedView.version(2))
+          .isEqualTo(
+              ImmutableViewVersion.builder()
+                  .timestampMillis(updatedView.version(2).timestampMillis())
+                  .versionId(2)
+                  .schemaId(1)
+                  .summary(updatedView.version(2).summary())
+                  .defaultNamespace(identifier.namespace())
+                  .addRepresentations(
+                      ImmutableSQLViewRepresentation.builder()
+                          .sql("select count(some_id) from ns.tbl")
+                          .dialect("spark")
+                          .build())
+                  .build());
+
+      assertThat(updatedView.version(3))
+          .isEqualTo(
+              ImmutableViewVersion.builder()
+                  .timestampMillis(updatedView.version(3).timestampMillis())
+                  .versionId(3)
+                  .schemaId(0)
+                  .summary(updatedView.version(3).summary())
+                  .defaultNamespace(identifier.namespace())
+                  .addRepresentations(
+                      ImmutableSQLViewRepresentation.builder()
+                          .sql("select count(id) from ns.tbl")
+                          .dialect("trino")
+                          .build())
+                  .build());
+    } else {
+      assertThatThrownBy(() -> viewOps.commit(current, sparkUpdate))
+          .isInstanceOf(CommitFailedException.class)
+          .hasMessageContaining("Cannot commit");
+
+      View updatedView = catalog().loadView(identifier);
+      ViewVersion viewVersion = updatedView.currentVersion();
+      assertThat(viewVersion.versionId()).isEqualTo(2);
+      assertThat(updatedView.versions()).hasSize(2);
+      assertThat(updatedView.version(1))
+          .isEqualTo(
+              ImmutableViewVersion.builder()
+                  .timestampMillis(updatedView.version(1).timestampMillis())
+                  .versionId(1)
+                  .schemaId(0)
+                  .summary(updatedView.version(1).summary())
+                  .defaultNamespace(identifier.namespace())
+                  .addRepresentations(
+                      ImmutableSQLViewRepresentation.builder()
+                          .sql("select * from ns.tbl")
+                          .dialect("trino")
+                          .build())
+                  .build());
+
+      assertThat(updatedView.version(2))
+          .isEqualTo(
+              ImmutableViewVersion.builder()
+                  .timestampMillis(updatedView.version(2).timestampMillis())
+                  .versionId(2)
+                  .schemaId(1)
+                  .summary(updatedView.version(2).summary())
+                  .defaultNamespace(identifier.namespace())
+                  .addRepresentations(
+                      ImmutableSQLViewRepresentation.builder()
+                          .sql("select count(some_id) from ns.tbl")
+                          .dialect("spark")
+                          .build())
+                  .build());
+    }
+  }
 }
diff --git a/open-api/rest-catalog-open-api.py 
b/open-api/rest-catalog-open-api.py
index 4db1fccdc2..5da91a16f9 100644
--- a/open-api/rest-catalog-open-api.py
+++ b/open-api/rest-catalog-open-api.py
@@ -209,6 +209,35 @@ class MetadataLog(BaseModel):
     __root__: List[MetadataLogItem]
 
 
+class SQLViewRepresentation(BaseModel):
+    type: str
+    sql: str
+    dialect: str
+
+
+class ViewRepresentation(BaseModel):
+    __root__: SQLViewRepresentation
+
+
+class ViewHistoryEntry(BaseModel):
+    version_id: int = Field(..., alias='version-id')
+    timestamp_ms: int = Field(..., alias='timestamp-ms')
+
+
+class ViewVersion(BaseModel):
+    version_id: int = Field(..., alias='version-id')
+    timestamp_ms: int = Field(..., alias='timestamp-ms')
+    schema_id: int = Field(
+        ...,
+        alias='schema-id',
+        description='Schema ID to set as current, or -1 to set last added 
schema',
+    )
+    summary: Dict[str, str]
+    representations: List[ViewRepresentation]
+    default_catalog: Optional[str] = Field(None, alias='default-catalog')
+    default_namespace: Namespace = Field(..., alias='default-namespace')
+
+
 class BaseUpdate(BaseModel):
     action: Literal[
         'assign-uuid',
@@ -226,6 +255,8 @@ class BaseUpdate(BaseModel):
         'set-location',
         'set-properties',
         'remove-properties',
+        'add-view-version',
+        'set-current-view-version',
     ]
 
 
@@ -301,6 +332,18 @@ class RemovePropertiesUpdate(BaseUpdate):
     removals: List[str]
 
 
+class AddViewVersionUpdate(BaseUpdate):
+    view_version: ViewVersion = Field(..., alias='view-version')
+
+
+class SetCurrentViewVersionUpdate(BaseUpdate):
+    view_version_id: int = Field(
+        ...,
+        alias='view-version-id',
+        description='The view version id to set as current, or -1 to set last 
added view version id',
+    )
+
+
 class TableRequirement(BaseModel):
     type: str
 
@@ -675,6 +718,17 @@ class TableMetadata(BaseModel):
     metadata_log: Optional[MetadataLog] = Field(None, alias='metadata-log')
 
 
+class ViewMetadata(BaseModel):
+    view_uuid: str = Field(..., alias='view-uuid')
+    format_version: int = Field(..., alias='format-version', ge=1, le=1)
+    location: str
+    current_version_id: int = Field(..., alias='current-version-id')
+    versions: List[ViewVersion]
+    version_log: List[ViewHistoryEntry] = Field(..., alias='version-log')
+    schemas: List[Schema]
+    properties: Optional[Dict[str, str]] = None
+
+
 class AddSchemaUpdate(BaseUpdate):
     schema_: Schema = Field(..., alias='schema')
     last_column_id: Optional[int] = Field(
@@ -704,6 +758,19 @@ class TableUpdate(BaseModel):
     ]
 
 
+class ViewUpdate(BaseModel):
+    __root__: Union[
+        AssignUUIDUpdate,
+        UpgradeFormatVersionUpdate,
+        AddSchemaUpdate,
+        SetLocationUpdate,
+        SetPropertiesUpdate,
+        RemovePropertiesUpdate,
+        AddViewVersionUpdate,
+        SetCurrentViewVersionUpdate,
+    ]
+
+
 class LoadTableResult(BaseModel):
     """
     Result used when a table is successfully loaded.
@@ -751,6 +818,13 @@ class CommitTableRequest(BaseModel):
     updates: List[TableUpdate]
 
 
+class CommitViewRequest(BaseModel):
+    identifier: Optional[TableIdentifier] = Field(
+        None, description='View identifier to update'
+    )
+    updates: List[ViewUpdate]
+
+
 class CommitTransactionRequest(BaseModel):
     table_changes: List[CommitTableRequest] = Field(..., alias='table-changes')
 
@@ -765,6 +839,41 @@ class CreateTableRequest(BaseModel):
     properties: Optional[Dict[str, str]] = None
 
 
+class CreateViewRequest(BaseModel):
+    name: str
+    location: Optional[str] = None
+    schema_: Schema = Field(..., alias='schema')
+    view_version: ViewVersion = Field(
+        ...,
+        alias='view-version',
+        description='The view version to create, will replace the schema-id 
sent within the view-version with the id assigned to the provided schema',
+    )
+    properties: Dict[str, str]
+
+
+class LoadViewResult(BaseModel):
+    """
+    Result used when a view is successfully loaded.
+
+
+    The view metadata JSON is returned in the `metadata` field. The 
corresponding file location of view metadata is returned in the 
`metadata-location` field.
+    Clients can check whether metadata has changed by comparing metadata 
locations after the view has been created.
+
+    The `config` map returns view-specific configuration for the view's 
resources.
+
+    The following configurations should be respected by clients:
+
+    ## General Configurations
+
+    - `token`: Authorization bearer token to use for view requests if OAuth2 
security is enabled
+
+    """
+
+    metadata_location: str = Field(..., alias='metadata-location')
+    metadata: ViewMetadata
+    config: Optional[Dict[str, str]] = None
+
+
 class ReportMetricsRequest2(BaseModel):
     __root__: Union[ReportMetricsRequest, ReportMetricsRequest1]
 
@@ -801,6 +910,8 @@ ListType.update_forward_refs()
 MapType.update_forward_refs()
 Expression.update_forward_refs()
 TableMetadata.update_forward_refs()
+ViewMetadata.update_forward_refs()
 AddSchemaUpdate.update_forward_refs()
 CreateTableRequest.update_forward_refs()
+CreateViewRequest.update_forward_refs()
 ReportMetricsRequest2.update_forward_refs()
diff --git a/open-api/rest-catalog-open-api.yaml 
b/open-api/rest-catalog-open-api.yaml
index 9370ef8f2d..c965793cb2 100644
--- a/open-api/rest-catalog-open-api.yaml
+++ b/open-api/rest-catalog-open-api.yaml
@@ -884,7 +884,7 @@ paths:
         406:
           $ref: '#/components/responses/UnsupportedOperationResponse'
         409:
-          description: Conflict - The target table identifier to rename to 
already exists
+          description: Conflict - The target identifier to rename to already 
exists as a table or view
           content:
             application/json:
               schema:
@@ -1056,6 +1056,356 @@ paths:
                 }
               }
 
+  /v1/{prefix}/namespaces/{namespace}/views:
+    parameters:
+      - $ref: '#/components/parameters/prefix'
+      - $ref: '#/components/parameters/namespace'
+
+    get:
+      tags:
+        - Catalog API
+      summary: List all view identifiers underneath a given namespace
+      description: Return all view identifiers under this namespace
+      operationId: listViews
+      responses:
+        200:
+          $ref: '#/components/responses/ListTablesResponse'
+        400:
+          $ref: '#/components/responses/BadRequestErrorResponse'
+        401:
+          $ref: '#/components/responses/UnauthorizedResponse'
+        403:
+          $ref: '#/components/responses/ForbiddenResponse'
+        404:
+          description: Not Found - The namespace specified does not exist
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorModel'
+              examples:
+                NamespaceNotFound:
+                  $ref: '#/components/examples/NoSuchNamespaceError'
+        419:
+          $ref: '#/components/responses/AuthenticationTimeoutResponse'
+        503:
+          $ref: '#/components/responses/ServiceUnavailableResponse'
+        5XX:
+          $ref: '#/components/responses/ServerErrorResponse'
+
+    post:
+      tags:
+        - Catalog API
+      summary: Create a view in the given namespace
+      description:
+        Create a view in the given namespace.
+      operationId: createView
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/CreateViewRequest'
+      responses:
+        200:
+          $ref: '#/components/responses/LoadViewResponse'
+        400:
+          $ref: '#/components/responses/BadRequestErrorResponse'
+        401:
+          $ref: '#/components/responses/UnauthorizedResponse'
+        403:
+          $ref: '#/components/responses/ForbiddenResponse'
+        404:
+          description: Not Found - The namespace specified does not exist
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorModel'
+              examples:
+                NamespaceNotFound:
+                  $ref: '#/components/examples/NoSuchNamespaceError'
+        409:
+          description: Conflict - The view already exists
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorModel'
+              examples:
+                NamespaceAlreadyExists:
+                  $ref: '#/components/examples/ViewAlreadyExistsError'
+        419:
+          $ref: '#/components/responses/AuthenticationTimeoutResponse'
+        503:
+          $ref: '#/components/responses/ServiceUnavailableResponse'
+        5XX:
+          $ref: '#/components/responses/ServerErrorResponse'
+
+  /v1/{prefix}/namespaces/{namespace}/views/{view}:
+    parameters:
+      - $ref: '#/components/parameters/prefix'
+      - $ref: '#/components/parameters/namespace'
+      - $ref: '#/components/parameters/view'
+
+    get:
+      tags:
+        - Catalog API
+      summary: Load a view from the catalog
+      operationId: loadView
+      description:
+        Load a view from the catalog.
+
+
+        The response contains both configuration and view metadata. The 
configuration, if non-empty is used
+        as additional configuration for the view that overrides catalog 
configuration.
+
+
+        The response also contains the view's full metadata, matching the view 
metadata JSON file.
+
+
+        The catalog configuration may contain credentials that should be used 
for subsequent requests for the
+        view. The configuration key "token" is used to pass an access token to 
be used as a bearer token
+        for view requests. Otherwise, a token may be passed using a RFC 8693 
token type as a configuration
+        key. For example, "urn:ietf:params:oauth:token-type:jwt=<JWT-token>".
+      responses:
+        200:
+          $ref: '#/components/responses/LoadViewResponse'
+        400:
+          $ref: '#/components/responses/BadRequestErrorResponse'
+        401:
+          $ref: '#/components/responses/UnauthorizedResponse'
+        403:
+          $ref: '#/components/responses/ForbiddenResponse'
+        404:
+          description:
+            Not Found - NoSuchViewException, view to load does not exist
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorModel'
+              examples:
+                ViewToLoadDoesNotExist:
+                  $ref: '#/components/examples/NoSuchViewError'
+        419:
+          $ref: '#/components/responses/AuthenticationTimeoutResponse'
+        503:
+          $ref: '#/components/responses/ServiceUnavailableResponse'
+        5XX:
+          $ref: '#/components/responses/ServerErrorResponse'
+
+    post:
+      tags:
+        - Catalog API
+      summary: Replace a view
+      operationId: replaceView
+      description:
+        Commit updates to a view.
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/CommitViewRequest'
+      responses:
+        200:
+          $ref: '#/components/responses/LoadViewResponse'
+        400:
+          $ref: '#/components/responses/BadRequestErrorResponse'
+        401:
+          $ref: '#/components/responses/UnauthorizedResponse'
+        403:
+          $ref: '#/components/responses/ForbiddenResponse'
+        404:
+          description:
+            Not Found - NoSuchViewException, view to load does not exist
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorModel'
+              examples:
+                ViewToUpdateDoesNotExist:
+                  $ref: '#/components/examples/NoSuchViewError'
+        409:
+          description:
+            Conflict - CommitFailedException. The client may retry.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorModel'
+        419:
+          $ref: '#/components/responses/AuthenticationTimeoutResponse'
+        500:
+          description:
+            An unknown server-side problem occurred; the commit state is 
unknown.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorModel'
+              example: {
+                "error": {
+                  "message": "Internal Server Error",
+                  "type": "CommitStateUnknownException",
+                  "code": 500
+                }
+              }
+        503:
+          $ref: '#/components/responses/ServiceUnavailableResponse'
+        502:
+          description:
+            A gateway or proxy received an invalid response from the upstream 
server; the commit state is unknown.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorModel'
+              example: {
+                "error": {
+                  "message": "Invalid response from the upstream server",
+                  "type": "CommitStateUnknownException",
+                  "code": 502
+                }
+              }
+        504:
+          description:
+            A server-side gateway timeout occurred; the commit state is 
unknown.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorModel'
+              example: {
+                "error": {
+                  "message": "Gateway timed out during commit",
+                  "type": "CommitStateUnknownException",
+                  "code": 504
+                }
+              }
+        5XX:
+          description:
+            A server-side problem that might not be addressable on the client.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorModel'
+              example: {
+                "error": {
+                  "message": "Bad Gateway",
+                  "type": "InternalServerError",
+                  "code": 502
+                }
+              }
+
+    delete:
+      tags:
+        - Catalog API
+      summary: Drop a view from the catalog
+      operationId: dropView
+      description: Remove a view from the catalog
+      responses:
+        204:
+          description: Success, no content
+        400:
+          $ref: '#/components/responses/BadRequestErrorResponse'
+        401:
+          $ref: '#/components/responses/UnauthorizedResponse'
+        403:
+          $ref: '#/components/responses/ForbiddenResponse'
+        404:
+          description:
+            Not Found - NoSuchViewException, view to drop does not exist
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorModel'
+              examples:
+                ViewToDeleteDoesNotExist:
+                  $ref: '#/components/examples/NoSuchViewError'
+        419:
+          $ref: '#/components/responses/AuthenticationTimeoutResponse'
+        503:
+          $ref: '#/components/responses/ServiceUnavailableResponse'
+        5XX:
+          $ref: '#/components/responses/ServerErrorResponse'
+
+    head:
+      tags:
+        - Catalog API
+      summary: Check if a view exists
+      operationId: viewExists
+      description:
+        Check if a view exists within a given namespace. This request does not 
return a response body.
+      responses:
+        204:
+          description: Success, no content
+        400:
+          description: Bad Request
+        401:
+          description: Unauthorized
+        404:
+          description: Not Found
+        419:
+          $ref: '#/components/responses/AuthenticationTimeoutResponse'
+        503:
+          $ref: '#/components/responses/ServiceUnavailableResponse'
+        5XX:
+          $ref: '#/components/responses/ServerErrorResponse'
+
+  /v1/{prefix}/views/rename:
+    parameters:
+      - $ref: '#/components/parameters/prefix'
+
+    post:
+      tags:
+        - Catalog API
+      summary: Rename a view from its current name to a new name
+      description:
+        Rename a view from one identifier to another. It's valid to move a view
+        across namespaces, but the server implementation is not required to 
support it.
+      operationId: renameView
+      requestBody:
+        description: Current view identifier to rename and new view identifier 
to rename to
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/RenameTableRequest'
+            examples:
+              RenameViewSameNamespace:
+                $ref: '#/components/examples/RenameViewSameNamespace'
+        required: true
+      responses:
+        200:
+          description: OK
+        400:
+          $ref: '#/components/responses/BadRequestErrorResponse'
+        401:
+          $ref: '#/components/responses/UnauthorizedResponse'
+        403:
+          $ref: '#/components/responses/ForbiddenResponse'
+        404:
+          description:
+            Not Found
+            - NoSuchViewException, view to rename does not exist
+            - NoSuchNamespaceException, The target namespace of the new 
identifier does not exist
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorModel'
+              examples:
+                ViewToRenameDoesNotExist:
+                  $ref: '#/components/examples/NoSuchViewError'
+                NamespaceToRenameToDoesNotExist:
+                  $ref: '#/components/examples/NoSuchNamespaceError'
+        406:
+          $ref: '#/components/responses/UnsupportedOperationResponse'
+        409:
+          description: Conflict - The target identifier to rename to already 
exists as a table or view
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorModel'
+              example:
+                $ref: '#/components/examples/ViewAlreadyExistsError'
+        419:
+          $ref: '#/components/responses/AuthenticationTimeoutResponse'
+        503:
+          $ref: '#/components/responses/ServiceUnavailableResponse'
+        5XX:
+          $ref: '#/components/responses/ServerErrorResponse'
+
 
 components:
   #######################################################
@@ -1094,6 +1444,15 @@ components:
         type: string
       example: "sales"
 
+    view:
+      name: view
+      in: path
+      description: A view name
+      required: true
+      schema:
+        type: string
+      example: "sales"
+
   ##############################
   # Application Schema Objects #
   ##############################
@@ -1671,6 +2030,105 @@ components:
         metadata-log:
           $ref: '#/components/schemas/MetadataLog'
 
+    SQLViewRepresentation:
+      type: object
+      required:
+        - type
+        - sql
+        - dialect
+      properties:
+        type:
+          type: string
+        sql:
+          type: string
+        dialect:
+          type: string
+
+    ViewRepresentation:
+      oneOf:
+        - $ref: '#/components/schemas/SQLViewRepresentation'
+
+    ViewHistoryEntry:
+      type: object
+      required:
+        - version-id
+        - timestamp-ms
+      properties:
+        version-id:
+          type: integer
+        timestamp-ms:
+          type: integer
+          format: int64
+
+    ViewVersion:
+      type: object
+      required:
+        - version-id
+        - timestamp-ms
+        - schema-id
+        - summary
+        - representations
+        - default-namespace
+      properties:
+        version-id:
+          type: integer
+        timestamp-ms:
+          type: integer
+          format: int64
+        schema-id:
+          type: integer
+          description: Schema ID to set as current, or -1 to set last added 
schema
+        summary:
+          type: object
+          additionalProperties:
+            type: string
+        representations:
+          type: array
+          items:
+            $ref: '#/components/schemas/ViewRepresentation'
+        default-catalog:
+          type: string
+        default-namespace:
+          $ref: '#/components/schemas/Namespace'
+
+    ViewMetadata:
+      type: object
+      required:
+        - view-uuid
+        - format-version
+        - location
+        - current-version-id
+        - versions
+        - version-log
+        - schemas
+      properties:
+        view-uuid:
+          type: string
+        format-version:
+          type: integer
+          minimum: 1
+          maximum: 1
+        location:
+          type: string
+        current-version-id:
+          type: integer
+        versions:
+          type: array
+          items:
+            $ref: '#/components/schemas/ViewVersion'
+        version-log:
+          type: array
+          items:
+            $ref: '#/components/schemas/ViewHistoryEntry'
+        schemas:
+          type: array
+          items:
+            $ref: '#/components/schemas/Schema'
+        properties:
+          type: object
+          additionalProperties:
+            type: string
+
     BaseUpdate:
       type: object
       required:
@@ -1694,6 +2152,8 @@ components:
             - set-location
             - set-properties
             - remove-properties
+            - add-view-version
+            - set-current-view-version
 
     AssignUUIDUpdate:
       description: Assigning a UUID to a table/view should only be done when 
creating the table/view. It is not safe to re-assign the UUID if a table/view 
already has a UUID assigned
@@ -1860,6 +2320,27 @@ components:
               items:
                 type: string
 
+    AddViewVersionUpdate:
+      allOf:
+        - $ref: '#/components/schemas/BaseUpdate'
+        - type: object
+          required:
+            - view-version
+          properties:
+            view-version:
+              $ref: '#/components/schemas/ViewVersion'
+
+    SetCurrentViewVersionUpdate:
+      allOf:
+        - $ref: '#/components/schemas/BaseUpdate'
+        - type: object
+          required:
+            - view-version-id
+          properties:
+            view-version-id:
+              type: integer
+              description: The view version id to set as current, or -1 to set 
last added view version id
+
     TableUpdate:
       anyOf:
         - $ref: '#/components/schemas/AssignUUIDUpdate'
@@ -1878,6 +2359,17 @@ components:
         - $ref: '#/components/schemas/SetPropertiesUpdate'
         - $ref: '#/components/schemas/RemovePropertiesUpdate'
 
+    ViewUpdate:
+      anyOf:
+        - $ref: '#/components/schemas/AssignUUIDUpdate'
+        - $ref: '#/components/schemas/UpgradeFormatVersionUpdate'
+        - $ref: '#/components/schemas/AddSchemaUpdate'
+        - $ref: '#/components/schemas/SetLocationUpdate'
+        - $ref: '#/components/schemas/SetPropertiesUpdate'
+        - $ref: '#/components/schemas/RemovePropertiesUpdate'
+        - $ref: '#/components/schemas/AddViewVersionUpdate'
+        - $ref: '#/components/schemas/SetCurrentViewVersionUpdate'
+
     TableRequirement:
       discriminator:
         propertyName: type
@@ -2076,6 +2568,19 @@ components:
           items:
             $ref: '#/components/schemas/TableUpdate'
 
+    CommitViewRequest:
+      type: object
+      required:
+        - updates
+      properties:
+        identifier:
+          description: View identifier to update
+          $ref: '#/components/schemas/TableIdentifier'
+        updates:
+          type: array
+          items:
+            $ref: '#/components/schemas/ViewUpdate'
+
     CommitTransactionRequest:
       type: object
       required:
@@ -2121,6 +2626,58 @@ components:
         metadata-location:
           type: string
 
+    CreateViewRequest:
+      type: object
+      required:
+        - name
+        - schema
+        - view-version
+        - properties
+      properties:
+        name:
+          type: string
+        location:
+          type: string
+        schema:
+          $ref: '#/components/schemas/Schema'
+        view-version:
+          $ref: '#/components/schemas/ViewVersion'
+          description: The view version to create, will replace the schema-id 
sent within the view-version with the id assigned to the provided schema
+        properties:
+          type: object
+          additionalProperties:
+            type: string
+
+    LoadViewResult:
+      description: |
+        Result used when a view is successfully loaded.
+
+
+        The view metadata JSON is returned in the `metadata` field. The 
corresponding file location of view metadata is returned in the 
`metadata-location` field.
+        Clients can check whether metadata has changed by comparing metadata 
locations after the view has been created.
+
+        The `config` map returns view-specific configuration for the view's 
resources.
+
+        The following configurations should be respected by clients:
+
+        ## General Configurations
+
+        - `token`: Authorization bearer token to use for view requests if 
OAuth2 security is enabled
+
+      type: object
+      required:
+        - metadata-location
+        - metadata
+      properties:
+        metadata-location:
+          type: string
+        metadata:
+          $ref: '#/components/schemas/ViewMetadata'
+        config:
+          type: object
+          additionalProperties:
+            type: string
+
     TokenType:
       type: string
       enum:
@@ -2742,6 +3299,13 @@ components:
           schema:
             $ref: '#/components/schemas/LoadTableResult'
 
+    LoadViewResponse:
+      description: View metadata result when loading a view
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/LoadViewResult'
+
     CommitTableResponse:
       description:
         Response used when a table is successfully updated.
@@ -2806,7 +3370,7 @@ components:
       }
 
     NoSuchTableError:
-      summary: The requested table does not
+      summary: The requested table does not exist
       value: {
         "error": {
           "message": "The given table does not exist",
@@ -2815,6 +3379,16 @@ components:
         }
       }
 
+    NoSuchViewError:
+      summary: The requested view does not exist
+      value: {
+        "error": {
+          "message": "The given view does not exist",
+          "type": "NoSuchViewException",
+          "code": 404
+        }
+      }
+
     NoSuchNamespaceError:
       summary: The requested namespace does not exist
       value: {
@@ -2832,6 +3406,13 @@ components:
         "destination": { "namespace": ["accounting", "tax"], "name": "owed" }
       }
 
+    RenameViewSameNamespace:
+      summary: Rename a view in the same namespace
+      value: {
+        "source": { "namespace": [ "accounting", "tax" ], "name": "paid-view" 
},
+        "destination": { "namespace": [ "accounting", "tax" ], "name": 
"owed-view" }
+      }
+
     TableAlreadyExistsError:
       summary: The requested table identifier already exists
       value: {
@@ -2842,6 +3423,16 @@ components:
         }
       }
 
+    ViewAlreadyExistsError:
+      summary: The requested view identifier already exists
+      value: {
+        "error": {
+          "message": "The given view already exists",
+          "type": "AlreadyExistsException",
+          "code": 409
+        }
+      }
+
     # This is an example response and is not meant to be prescriptive 
regarding the message or type.
     UnprocessableEntityDuplicateKey:
       summary:

Reply via email to