Repository: gtalarico/pyairtable (language: python)

Issue:
Adding metadata API (2022-11-15 update)
Airtable has just released a major update to the API, detailed [in the new docs](https://airtable.com/developers/web/api/changelog).

This includes a change to personal access tokens for authentication, and a few new classes (with new methods) which I have tried to lay out below to plan adding them to this wrapper.

Completely new classes and their methods:

* Fields
    * Update Field
* Comments
    * Update comment
    * Delete comment
* Webhooks
     * List payloads
     * Disable/Enable notifications
     * Delete
     * Refresh

And new methods for existing classes (same format):

* Record
    * List Comments
    * Create Comment
* Table
    * List fields
    * List views
    * Create field
    * 'Upsert' option in the batch_update method
* Base
   * Get Schema (List tables)
   * List webhooks
* Api
   * List bases
   
Also added were a few methods for enterprise users only so probably not a priority. 

I don't know if this is the best way to go about tracking adding this functionality, so suggestions welcome.
Add meta methods for creating new tables and bases
Adds meta functions for creating new bases and tables as referenced in #235. I think separating out into a new "meta" file is probably a good idea.
- **api.create_table()** (https://api.airtable.com/v0/meta/bases/{baseId}/tables): takes a base ID, table name, list of fields, and an optional description and returns a new Table object after creating. 
- **api.create_base()** (https://api.airtable.com/v0/meta/bases): takes a workspace ID, a base name, and a list of dictionary-represented tables and returns a new Base object after creating.


Discussion / hints:
This is great. Thanks for organizing this @joe-irving 
Will be tons of work 😮‍💨 😆 
I'm about halfway through webhooks
Great work!
Would be nice to get this.


Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 032fbba1..c9e7b5cf 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,14 @@
 # See https://pre-commit.com for more information
 # See https://pre-commit.com/hooks.html for more hooks
 repos:
+-   repo: local
+    hooks:
+    -   id: cog
+        name: cog
+        language: python
+        additional_dependencies: [cogapp]
+        entry: python -m cogapp -cr --verbosity=1 --markers="[[[cog]]] [[[out]]] [[[end]]]"
+        files: \.py$
 -   repo: https://github.com/pre-commit/pre-commit-hooks
     rev: v4.4.0
     hooks:
diff --git a/docs/source/_substitutions.rst b/docs/source/_substitutions.rst
index b06a1ee3..fc65137a 100644
--- a/docs/source/_substitutions.rst
+++ b/docs/source/_substitutions.rst
@@ -60,3 +60,16 @@
 
 .. |kwarg_return_fields_by_field_id| replace:: An optional boolean value that lets you return field objects where the
     key is the field id. This defaults to `false`, which returns field objects where the key is the field name.
+
+.. |kwarg_force_metadata| replace::
+    By default, this method will only fetch information from the API if it has not been cached.
+    If called with ``force=True`` it will always call the API, and will overwrite any cached values.
+
+.. |kwarg_validate_metadata| replace::
+    If ``False``, will create an object without validating the ID/name provided.
+    If ``True``, will fetch information from the metadata API and validate the ID/name exists,
+    raising ``KeyError`` if it does not.
+
+.. |warn| unicode:: U+26A0 .. WARNING SIGN
+
+.. |enterprise_only| replace:: |warn| This feature is only available on Enterprise billing plans.
diff --git a/docs/source/api.rst b/docs/source/api.rst
index 480d51b6..8815c7de 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -6,7 +6,7 @@ API Reference
 =============
 
 
-Module: pyairtable
+API: pyairtable
 *******************************
 
 .. autoclass:: pyairtable.Api
@@ -18,48 +18,62 @@ Module: pyairtable
 .. autoclass:: pyairtable.Table
     :members:
 
+.. autoclass:: pyairtable.Workspace
+    :members:
+
+.. autoclass:: pyairtable.Enterprise
+    :members:
+
 .. autofunction:: pyairtable.retry_strategy
 
 
-Module: pyairtable.api.types
+API: pyairtable.api.types
 *******************************
 
 .. automodule:: pyairtable.api.types
     :members:
 
 
-Module: pyairtable.formulas
+API: pyairtable.formulas
 *******************************
 
 .. automodule:: pyairtable.formulas
     :members:
 
 
-Module: pyairtable.models
+API: pyairtable.models
 *******************************
 
 .. automodule:: pyairtable.models
     :members:
+    :inherited-members: AirtableModel
+
+
+API: pyairtable.models.schema
+********************************
+
+.. automodule:: pyairtable.models.schema
+    :members:
+    :inherited-members: AirtableModel
 
 
-Module: pyairtable.orm
+API: pyairtable.orm
 *******************************
 
 .. autoclass:: pyairtable.orm.Model
     :members:
 
 
-Module: pyairtable.orm.fields
+API: pyairtable.orm.fields
 *******************************
 
 .. automodule:: pyairtable.orm.fields
     :members:
-    :member-order: bysource
     :exclude-members: valid_types, contains_type
     :no-inherited-members:
 
 
-Module: pyairtable.utils
+API: pyairtable.utils
 *******************************
 
 .. automodule:: pyairtable.utils
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 380add5f..015095dd 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -47,8 +47,10 @@
 autodoc_member_order = "bysource"
 autoclass_content = "class"
 
-# See https://autodoc-pydantic.readthedocs.io/en/stable/users/configuration.html
+# See https://autodoc-pydantic.readthedocs.io/en/v1.9.0/users/configuration.html
 autodoc_pydantic_field_show_alias = False
+autodoc_pydantic_field_show_default = False
+autodoc_pydantic_field_show_required = False
 autodoc_pydantic_model_member_order = "bysource"
 autodoc_pydantic_model_show_config_summary = False
 autodoc_pydantic_model_show_field_summary = False
diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst
index 1b8a8c5a..e7f020b2 100644
--- a/docs/source/metadata.rst
+++ b/docs/source/metadata.rst
@@ -5,11 +5,116 @@
 Metadata
 ==============
 
-The metadata API gives you the ability to list all of your bases, tables, fields, and views.
+The Airtable API gives you the ability to list all of your bases, tables, fields, and views.
+pyAirtable allows you to inspect and interact with the metadata in your bases.
 
-.. warning::
-    This API is experimental and subject to change.
+There may be parts of the Airtable API which are not supported below;
+you can always use :meth:`Api.request <pyairtable.Api.request>` to call them directly.
 
 
-.. automodule:: pyairtable.metadata
-    :members:
+Reading schemas
+-----------------------------
+
+All of the methods below return complex nested data structures, some of which
+have their own convenience methods for searching their contents, such as
+:meth:`TableSchema.field() <pyairtable.models.schema.TableSchema.field>`.
+You'll find more detail in the API reference for :mod:`pyairtable.models.schema`.
+
+.. automethod:: pyairtable.Api.bases
+    :noindex:
+
+.. automethod:: pyairtable.Base.schema
+    :noindex:
+
+.. automethod:: pyairtable.Base.tables
+    :noindex:
+
+.. automethod:: pyairtable.Table.schema
+    :noindex:
+
+
+Enterprise information
+-----------------------------
+
+pyAirtable exposes a number of classes and methods for interacting with enterprise organizations.
+The following methods are only available on an `Enterprise plan <https://airtable.com/pricing>`__.
+If you call one of them against a base that is not part of an enterprise workspace, Airtable will
+return a 404 error, and pyAirtable will add a reminder to the exception to check your billing plan.
+
+.. automethod:: pyairtable.Api.enterprise
+    :noindex:
+
+.. automethod:: pyairtable.Base.collaborators
+    :noindex:
+
+.. automethod:: pyairtable.Base.shares
+    :noindex:
+
+.. automethod:: pyairtable.Workspace.collaborators
+    :noindex:
+
+.. automethod:: pyairtable.Enterprise.info
+    :noindex:
+
+
+Modifying existing schema
+-----------------------------
+
+To modify a table or field, you can modify its schema object directly and
+call ``save()``, as shown below. You can only change names and descriptions;
+the Airtable API does not permit changing a field's type or other options.
+
+.. code-block:: python
+
+    >>> schema = table.schema()
+    >>> schema.name = "Renamed"
+    >>> schema.save()
+    >>> field = schema.field("Name")
+    >>> field.name = "Label"
+    >>> field.description = "The primary field on the table"
+    >>> field.save()
+
+
+Creating schema elements
+-----------------------------
+
+The following methods allow creating bases, tables, or fields:
+
+.. automethod:: pyairtable.Api.create_base
+    :noindex:
+
+.. automethod:: pyairtable.Workspace.create_base
+    :noindex:
+
+.. automethod:: pyairtable.Workspace.move_base
+    :noindex:
+
+.. automethod:: pyairtable.Base.create_table
+    :noindex:
+
+.. automethod:: pyairtable.Table.create_field
+    :noindex:
+
+
+Deleting schema elements
+-----------------------------
+
+|enterprise_only|
+
+The Airtable API does not allow deleting tables or fields, but it does allow
+deleting workspaces, bases, and views. pyAirtable supports the following methods:
+
+To delete a :class:`~pyairtable.Workspace`:
+
+    >>> ws = api.workspace("wspmhESAta6clCCwF")
+    >>> ws.delete()
+
+To delete a :class:`~pyairtable.Base`:
+
+    >>> base = api.base("appMxESAta6clCCwF")
+    >>> base.delete()
+
+To delete a view, first retrieve its :class:`~pyairtable.models.schema.ViewSchema`:
+
+    >>> vw = table.schema().view("View Name")
+    >>> vw.delete()
diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst
index 5891c8c6..13cec4a8 100644
--- a/docs/source/migrations.rst
+++ b/docs/source/migrations.rst
@@ -76,7 +76,7 @@ You may need to change how your code looks up some pieces of connection metadata
       - :meth:`table.record_url() <pyairtable.Table.record_url>`
 
 There is no fully exhaustive list of changes; please refer to
-:ref:`the API documentation <Module: pyairtable>` for a list of available methods and attributes.
+:ref:`the API documentation <API: pyairtable>` for a list of available methods and attributes.
 
 Retry by default
 ----------------
@@ -97,7 +97,7 @@ Changes to types
 ----------------
 
 * All functions and methods in this library have full type annotations that will pass ``mypy --strict``.
-  See the :ref:`types <Module: pyairtable.api.types>` module for more information on the types this library accepts and returns.
+  See the :mod:`pyairtable.api.types` module for more information on the types this library accepts and returns.
 
 batch_upsert has a different return type
 --------------------------------------------
diff --git a/docs/source/orm.rst b/docs/source/orm.rst
index d79af837..86a97ed8 100644
--- a/docs/source/orm.rst
+++ b/docs/source/orm.rst
@@ -173,7 +173,7 @@ read `Field types and cell values <https://airtable.com/developers/web/api/field
      - `Single line text <https://airtable.com/developers/web/api/field-model#simpletext>`__, `Long text <https://airtable.com/developers/web/api/field-model#multilinetext>`__
    * - :class:`~pyairtable.orm.fields.UrlField`
      - `Url <https://airtable.com/developers/web/api/field-model#urltext>`__
-.. [[[end]]]
+.. [[[end]]] (checksum: 01c5696293e7571ac8250c4e8a2453e8)
 
 
 Formulas, Rollups, and Lookups
diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py
index cd64ed62..ff41b670 100644
--- a/pyairtable/__init__.py
+++ b/pyairtable/__init__.py
@@ -1,11 +1,15 @@
 __version__ = "2.1.0.post1"
 
 from .api import Api, Base, Table
+from .api.enterprise import Enterprise
 from .api.retrying import retry_strategy
+from .api.workspace import Workspace
 
 __all__ = [
     "Api",
     "Base",
+    "Enterprise",
     "Table",
+    "Workspace",
     "retry_strategy",
 ]
diff --git a/pyairtable/_compat.py b/pyairtable/_compat.py
index dbc06d85..b59654b3 100644
--- a/pyairtable/_compat.py
+++ b/pyairtable/_compat.py
@@ -6,7 +6,7 @@
     # Pydantic v2 broke a bunch of stuff. Luckily they provide a built-in v1.
     try:
         import pydantic.v1 as pydantic
-    except ImportError:
+    except ImportError:  # pragma: no cover
         import pydantic
 
 __all__ = ["pydantic"]
diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py
index 7f4f5dc0..682ff549 100644
--- a/pyairtable/api/api.py
+++ b/pyairtable/api/api.py
@@ -1,17 +1,17 @@
 import posixpath
-from functools import lru_cache
-from typing import Any, Dict, Iterator, Optional, Sequence, Tuple, TypeVar, Union
+from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, TypeVar, Union
 
 import requests
 from requests.sessions import Session
 from typing_extensions import TypeAlias
 
-import pyairtable.api.base
-import pyairtable.api.table
 from pyairtable.api import retrying
+from pyairtable.api.enterprise import Enterprise
 from pyairtable.api.params import options_to_json_and_params, options_to_params
 from pyairtable.api.types import UserAndScopesDict, assert_typed_dict
-from pyairtable.utils import chunked
+from pyairtable.api.workspace import Workspace
+from pyairtable.models.schema import Bases
+from pyairtable.utils import cache_unless_forced, chunked, enterprise_only
 
 T = TypeVar("T")
 TimeoutTuple: TypeAlias = Tuple[int, int]
@@ -37,6 +37,9 @@ class Api:
     #: Airtable-imposed limit on the length of a URL (including query parameters).
     MAX_URL_LENGTH = 16000
 
+    # Cached metadata to reduce API calls
+    _bases: Optional[Dict[str, "pyairtable.api.base.Base"]] = None
+
     def __init__(
         self,
         api_key: str,
@@ -86,22 +89,106 @@ def api_key(self, value: str) -> None:
     def __repr__(self) -> str:
         return "<pyairtable.Api>"
 
-    @lru_cache
-    def base(self, base_id: str) -> "pyairtable.api.base.Base":
+    def whoami(self) -> UserAndScopesDict:
+        """
+        Return the current user ID and (if connected via OAuth) the list of scopes.
+        See `Get user ID & scopes <https://airtable.com/developers/web/api/get-user-id-scopes>`_ for more information.
         """
-        Returns a new :class:`Base` instance that uses this instance of :class:`Api`.
+        data = self.request("GET", self.build_url("meta/whoami"))
+        return assert_typed_dict(UserAndScopesDict, data)
+
+    def workspace(self, workspace_id: str) -> Workspace:
+        return Workspace(self, workspace_id)
+
+    def base(
+        self,
+        base_id: str,
+        *,
+        validate: bool = False,
+    ) -> "pyairtable.api.base.Base":
         """
+        Return a new :class:`Base` instance that uses this instance of :class:`Api`.
+
+        Args:
+            base_id: |arg_base_id|
+            validate: |kwarg_validate_metadata|
+
+        Raises:
+            KeyError: if ``fetch=True`` and the given base ID does not exist.
+        """
+        if validate:
+            bases = {base.id: base for base in self.bases(force=True)}
+            return bases[base_id]
         return pyairtable.api.base.Base(self, base_id)
 
+    @cache_unless_forced
+    def _base_info(self) -> Bases:
+        """
+        Return a schema object that represents all bases available via the API.
+        """
+        url = self.build_url("meta/bases")
+        return Bases.parse_obj(
+            {
+                "bases": [
+                    base_info
+                    for page in self.iterate_requests("GET", url)
+                    for base_info in page["bases"]
+                ]
+            }
+        )
+
+    def bases(self, *, force: bool = False) -> List["pyairtable.api.base.Base"]:
+        """
+        Retrieve the base's schema and return a list of :class:`Base` instances.
+
+        Args:
+            force: |kwarg_force_metadata|
+
+        Usage:
+            >>> api.bases()
+            [
+                <pyairtable.Base base_id='appSW9...'>,
+                <pyairtable.Base base_id='appLkN...'>
+            ]
+        """
+        return [
+            pyairtable.api.base.Base(
+                self,
+                info.id,
+                name=info.name,
+                permission_level=info.permission_level,
+            )
+            for info in self._base_info(force=force).bases
+        ]
+
+    def create_base(
+        self,
+        workspace_id: str,
+        name: str,
+        tables: Sequence[Dict[str, Any]],
+    ) -> "pyairtable.api.base.Base":
+        """
+        Create a base in the given workspace.
+
+        See https://airtable.com/developers/web/api/create-base
+
+        Args:
+            workspace_id: The ID of the workspace where the new base will live.
+            name: The name to give to the new base. Does not need to be unique.
+            tables: A list of ``dict`` objects that conform to Airtable's
+                `Table model <https://airtable.com/developers/web/api/model/table-model>`__.
+        """
+        return self.workspace(workspace_id).create_base(name, tables)
+
     def table(self, base_id: str, table_name: str) -> "pyairtable.api.table.Table":
         """
-        Returns a new :class:`Table` instance that uses this instance of :class:`Api`.
+        Build a new :class:`Table` instance that uses this instance of :class:`Api`.
         """
         return self.base(base_id).table(table_name)
 
     def build_url(self, *components: str) -> str:
         """
-        Returns a URL to the Airtable API endpoint with the given URL components,
+        Build a URL to the Airtable API endpoint with the given URL components,
         including the API version number.
         """
         return posixpath.join(self.endpoint_url, self.VERSION, *components)
@@ -116,10 +203,8 @@ def request(
         json: Optional[Dict[str, Any]] = None,
     ) -> Any:
         """
-        Makes a request to the Airtable API, optionally converting a GET to a POST
-        if the URL exceeds the API's maximum URL length.
-
-        See https://support.airtable.com/docs/enforcement-of-url-length-limit-for-web-api-requests
+        Make a request to the Airtable API, optionally converting a GET to a POST if the URL exceeds the
+        `maximum URL length <https://support.airtable.com/docs/enforcement-of-url-length-limit-for-web-api-requests>`__.
 
         Args:
             method: HTTP method to use.
@@ -169,8 +254,6 @@ def _process_response(self, response: requests.Response) -> Any:
         try:
             response.raise_for_status()
         except requests.exceptions.HTTPError as exc:
-            err_msg = str(exc)
-
             # Attempt to get Error message from response, Issue #16
             try:
                 error_dict = response.json()
@@ -178,8 +261,7 @@ def _process_response(self, response: requests.Response) -> Any:
                 pass
             else:
                 if "error" in error_dict:
-                    err_msg += " [Error: {}]".format(error_dict["error"])
-            exc.args = (*exc.args, err_msg)
+                    exc.args = (*exc.args, repr(error_dict["error"]))
             raise exc
 
         # Some Airtable endpoints will respond with an empty body and a 200.
@@ -196,7 +278,7 @@ def iterate_requests(
         offset_field: str = "offset",
     ) -> Iterator[Any]:
         """
-        Makes one or more requests and iterates through each result.
+        Make one or more requests and iterates through each result.
 
         If the response payload contains an 'offset' value, this method will perform
         another request with that offset value as a parameter (query params for GET,
@@ -226,15 +308,18 @@ def iterate_requests(
 
     def chunked(self, iterable: Sequence[T]) -> Iterator[Sequence[T]]:
         """
-        Iterates through chunks of the given sequence that are equal in size
+        Iterate through chunks of the given sequence that are equal in size
         to the maximum number of records per request allowed by the API.
         """
         return chunked(iterable, self.MAX_RECORDS_PER_REQUEST)
 
-    def whoami(self) -> UserAndScopesDict:
+    @enterprise_only
+    def enterprise(self, enterprise_account_id: str) -> Enterprise:
         """
-        Return the current user ID and (if connected via OAuth) the list of scopes.
-        See `Get user ID & scopes <https://airtable.com/developers/web/api/get-user-id-scopes>`_ for more information.
+        Build an object representing an enterprise account.
         """
-        data = self.request("GET", self.build_url("meta/whoami"))
-        return assert_typed_dict(UserAndScopesDict, data)
+        return Enterprise(self, enterprise_account_id)
+
+
+import pyairtable.api.base  # noqa
+import pyairtable.api.table  # noqa
diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py
index 43dc3951..881fe431 100644
--- a/pyairtable/api/base.py
+++ b/pyairtable/api/base.py
@@ -1,20 +1,26 @@
 import warnings
-from functools import lru_cache
-from typing import Any, Dict, List, Union
+from typing import Any, Dict, List, Optional, Sequence, Union
 
 import pyairtable.api.api
 import pyairtable.api.table
+from pyairtable.models.schema import BaseCollaborators, BaseSchema, BaseShares
 from pyairtable.models.webhook import (
     CreateWebhook,
     CreateWebhookResponse,
     Webhook,
     WebhookSpecification,
 )
+from pyairtable.utils import cache_unless_forced, enterprise_only
 
 
 class Base:
     """
     Represents an Airtable base.
+
+    Usage:
+        >>> base = api.base("appNxslc6jG0XedVM")
+        >>> table = base.table("Table Name")
+        >>> records = table.all()
     """
 
     #: The connection to the Airtable API.
@@ -23,7 +29,22 @@ class Base:
     #: The base ID, in the format ``appXXXXXXXXXXXXXX``
     id: str
 
-    def __init__(self, api: Union["pyairtable.api.api.Api", str], base_id: str):
+    #: The permission level the current user has on the base
+    permission_level: Optional[str]
+
+    # Cached metadata to reduce API calls
+    _collaborators: Optional[BaseCollaborators] = None
+    _schema: Optional[BaseSchema] = None
+    _shares: Optional[List[BaseShares.Info]] = None
+
+    def __init__(
+        self,
+        api: Union["pyairtable.api.api.Api", str],
+        base_id: str,
+        *,
+        name: Optional[str] = None,
+        permission_level: Optional[str] = None,
+    ):
         """
         Old style constructor takes ``str`` arguments, and will create its own
         instance of :class:`Api`.
@@ -51,33 +72,126 @@ def __init__(self, api: Union["pyairtable.api.api.Api", str], base_id: str):
 
         self.api = api
         self.id = base_id
+        self.permission_level = permission_level
+        self._name = name
+
+    @property
+    def name(self) -> Optional[str]:
+        """
+        The name of the base, if provided to the constructor
+        or available in cached base information.
+        """
+        if self._collaborators:
+            return self._collaborators.name
+        return self._name
 
     def __repr__(self) -> str:
-        return f"<pyairtable.Base base_id={self.id!r}>"
+        repr = f"<Base id={self.id!r}"
+        if name := self.name:
+            repr += f" {name=}"
+        if permission_level := self.permission_level:
+            repr += f" {permission_level=}"
+        return repr + ">"
 
-    @lru_cache
-    def table(self, table_name: str) -> "pyairtable.api.table.Table":
+    def table(
+        self,
+        id_or_name: str,
+        *,
+        validate: bool = False,
+    ) -> "pyairtable.api.table.Table":
         """
-        Returns a new :class:`Table` instance using all shared
-        attributes from :class:`Base`.
+        Build a new :class:`Table` instance using this instance of :class:`Base`.
 
         Args:
-            table_name: An Airtable table name. Table name should be unencoded,
+            id_or_name: An Airtable table ID or name. Table name should be unencoded,
                 as shown on browser.
+            validate: |kwarg_validate_metadata|
+
+        Usage:
+            >>> base.table('Apartments')
+            <Table base='appLkNDICXNqxSDhG' name='Apartments'>
+        """
+        if validate:
+            schema = self.schema(force=True).table(id_or_name)
+            return pyairtable.api.table.Table(None, self, schema)
+        return pyairtable.api.table.Table(None, self, id_or_name)
+
+    def tables(self, *, force: bool = False) -> List["pyairtable.api.table.Table"]:
+        """
+        Retrieve the base's schema and returns a list of :class:`Table` instances.
+
+        Args:
+            force: |kwarg_force_metadata|
+
+        Usage:
+            >>> base.tables()
+            [
+                <Table base='appLkN...' id='tbltp8DGLhqbUmjK1' name='Apartments'>,
+                <Table base='appLkN...' id='tblK6MZHez0ZvBChZ' name='Districts'>
+            ]
+        """
+        return [
+            pyairtable.api.table.Table(None, self, table_schema)
+            for table_schema in self.schema(force=force).tables
+        ]
+
+    def create_table(
+        self,
+        name: str,
+        fields: Sequence[Dict[str, Any]],
+        description: Optional[str] = None,
+    ) -> "pyairtable.api.table.Table":
+        """
+        Create a table in the given base.
+
+        Args:
+            name: The unique table name.
+            fields: A list of ``dict`` objects that conform to the
+                `Airtable field model <https://airtable.com/developers/web/api/field-model>`__.
+            description: The table description. Must be no longer than 20k characters.
         """
-        return pyairtable.api.table.Table(None, self, table_name)
+        url = self.meta_url("tables")
+        payload = {"name": name, "fields": fields}
+        if description:
+            payload["description"] = description
+        response = self.api.request("POST", url, json=payload)
+        return self.table(response["id"], validate=True)
 
     @property
     def url(self) -> str:
         return self.api.build_url(self.id)
 
+    def meta_url(self, *components: Any) -> str:
+        """
+        Build a URL to a metadata endpoint for this base.
+        """
+        return self.api.build_url("meta/bases", self.id, *components)
+
+    @cache_unless_forced
+    def schema(self) -> BaseSchema:
+        """
+        Retrieve the schema of all tables in the base and caches it.
+
+        Usage:
+            >>> base.schema().tables
+            [TableSchema(...), TableSchema(...), ...]
+            >>> base.schema().table("tblXXXXXXXXXXXXXX")
+            TableSchema(id="tblXXXXXXXXXXXXXX", ...)
+            >>> base.schema().table("My Table")
+            TableSchema(id="...", name="My Table", ...)
+        """
+        url = self.meta_url("tables")
+        params = {"include": ["visibleFieldIds"]}
+        data = self.api.request("GET", url, params=params)
+        return BaseSchema.from_api(data, self.api, context=self)
+
     @property
     def webhooks_url(self) -> str:
         return self.api.build_url("bases", self.id, "webhooks")
 
     def webhooks(self) -> List[Webhook]:
         """
-        Retrieves all the base's webhooks from the API
+        Retrieve all the base's webhooks
         (see: `List webhooks <https://airtable.com/developers/web/api/list-webhooks>`_).
 
         Usage:
@@ -98,17 +212,13 @@ def webhooks(self) -> List[Webhook]:
         """
         response = self.api.request("GET", self.webhooks_url)
         return [
-            Webhook.from_api(
-                api=self.api,
-                url=f"{self.webhooks_url}/{data['id']}",
-                obj=data,
-            )
+            Webhook.from_api(data, self.api, context=self)
             for data in response["webhooks"]
         ]
 
     def webhook(self, webhook_id: str) -> Webhook:
         """
-        Returns a single webhook or raises ``KeyError`` if the given ID is invalid.
+        Build a single webhook or raises ``KeyError`` if the given ID is invalid.
 
         Airtable's API does not permit retrieving a single webhook, so this function
         will call :meth:`~webhooks` and simply return one item from the list.
@@ -124,7 +234,7 @@ def add_webhook(
         spec: Union[WebhookSpecification, Dict[Any, Any]],
     ) -> CreateWebhookResponse:
         """
-        Creates a webhook on the base with the given
+        Create a webhook on the base with the given
         `webhooks specification <https://airtable.com/developers/web/api/model/webhooks-specification>`_.
 
         The return value will contain a unique secret that must be saved
@@ -168,3 +278,33 @@ def add_webhook(
         request = create.dict(by_alias=True, exclude_unset=True)
         response = self.api.request("POST", self.webhooks_url, json=request)
         return CreateWebhookResponse.parse_obj(response)
+
+    @enterprise_only
+    @cache_unless_forced
+    def collaborators(self) -> "BaseCollaborators":
+        """
+        Retrieve `base collaborators <https://airtable.com/developers/web/api/get-base-collaborators>`__.
+        """
+        params = {"include": ["collaborators", "inviteLinks", "interfaces"]}
+        data = self.api.request("GET", self.meta_url(), params=params)
+        return BaseCollaborators.parse_obj(data)
+
+    @enterprise_only
+    @cache_unless_forced
+    def shares(self) -> List[BaseShares.Info]:
+        """
+        Retrieve `base shares <https://airtable.com/developers/web/api/list-shares>`__.
+        """
+        data = self.api.request("GET", self.meta_url("shares"))
+        return BaseShares.parse_obj(data).shares
+
+    @enterprise_only
+    def delete(self) -> None:
+        """
+        Delete the base.
+
+        Usage:
+            >>> base = api.base("appMxESAta6clCCwF")
+            >>> base.delete()
+        """
+        self.api.request("DELETE", self.meta_url())
diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py
new file mode 100644
index 00000000..e772993f
--- /dev/null
+++ b/pyairtable/api/enterprise.py
@@ -0,0 +1,95 @@
+from typing import Dict, Iterable, List, Optional
+
+from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo
+from pyairtable.utils import cache_unless_forced, enterprise_only, is_user_id
+
+
+@enterprise_only
+class Enterprise:
+    """
+    Represents an Airtable enterprise account.
+
+    >>> enterprise = api.enterprise("entUBq2RGdihxl3vU")
+    >>> enterprise.info().workspace_ids
+    ['wspmhESAta6clCCwF', ...]
+    """
+
+    def __init__(self, api: "pyairtable.api.api.Api", workspace_id: str):
+        self.api = api
+        self.id = workspace_id
+        self._info: Optional[EnterpriseInfo] = None
+
+    @property
+    def url(self) -> str:
+        return self.api.build_url("meta/enterpriseAccounts", self.id)
+
+    @cache_unless_forced
+    def info(self) -> EnterpriseInfo:
+        """
+        Retrieve basic information about the enterprise, caching the result.
+        """
+        params = {"include": ["collaborators", "inviteLinks"]}
+        payload = self.api.request("GET", self.url, params=params)
+        return EnterpriseInfo.parse_obj(payload)
+
+    def group(self, group_id: str) -> UserGroup:
+        url = self.api.build_url(f"meta/groups/{group_id}")
+        return UserGroup.parse_obj(self.api.request("GET", url))
+
+    def user(self, id_or_email: str) -> UserInfo:
+        """
+        Retrieve information on a single user with the given ID or email.
+
+        Args:
+            id_or_email: A user ID (``usrQBq2RGdihxl3vU``) or email address.
+        """
+        return self.users([id_or_email])[0]
+
+    def users(self, ids_or_emails: Iterable[str]) -> List[UserInfo]:
+        """
+        Retrieve information on the users with the given IDs or emails.
+
+        Following the Airtable API specification, pyAirtable will perform
+        one API request for each user ID. However, when given a list of emails,
+        pyAirtable only needs to perform one API request for the entire list.
+
+        Read more at `Get user by ID <https://airtable.com/developers/web/api/get-user-by-id>`__
+        and `Get user by email <https://airtable.com/developers/web/api/get-user-by-email>`__.
+
+        Args:
+            ids_or_emails: A sequence of user IDs (``usrQBq2RGdihxl3vU``)
+                or email addresses (or both).
+        """
+        users: Dict[str, UserInfo] = {}  # key by user ID to avoid returning duplicates
+        user_ids: List[str] = []
+        emails: List[str] = []
+        for value in ids_or_emails:
+            if "@" in value:
+                emails.append(value)
+            elif is_user_id(value):
+                user_ids.append(value)
+            else:
+                raise ValueError(f"unrecognized user ID or email: {value!r}")
+
+        for user_id in user_ids:
+            response = self.api.request("GET", f"{self.url}/users/{user_id}")
+            info = UserInfo.parse_obj(response)
+            users[info.id] = info
+
+        if emails:
+            params = {"email": emails}
+            response = self.api.request("GET", f"{self.url}/users", params=params)
+            users.update(
+                {
+                    info.id: info
+                    for user_obj in response["users"]
+                    if (info := UserInfo.parse_obj(user_obj))
+                }
+            )
+
+        return list(users.values())
+
+
+# These are at the bottom of the module to avoid circular imports
+import pyairtable.api.api  # noqa
+import pyairtable.api.base  # noqa
diff --git a/pyairtable/api/params.py b/pyairtable/api/params.py
index 55d6464e..82e22938 100644
--- a/pyairtable/api/params.py
+++ b/pyairtable/api/params.py
@@ -12,7 +12,7 @@ def dict_list_to_request_params(
     values: List[Dict[str, str]],
 ) -> Dict[str, str]:
     """
-    Returns dict to be used by request params from dict list
+    Build the dict to be used by request params from dict list
 
     Expected Airtable Url Params is:
         `?sort[0][field]=FieldOne&sort[0][direction]=asc`
@@ -95,7 +95,7 @@ def _option_to_param(name: str) -> str:
 
 def options_to_params(options: Dict[str, Any]) -> Dict[str, Any]:
     """
-    Converts Airtable options to a dict of query params.
+    Convert Airtable options to a dict of query params.
 
     Args:
         options: A dict of Airtable-specific options. See :ref:`parameters`.
@@ -120,7 +120,7 @@ def options_to_json_and_params(
     options: Dict[str, Any]
 ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
     """
-    Converts Airtable options to a JSON payload with (possibly) leftover query params.
+    Convert Airtable options to a JSON payload with (possibly) leftover query params.
 
     Args:
         options: A dict of Airtable-specific options. See :ref:`parameters`.
diff --git a/pyairtable/api/retrying.py b/pyairtable/api/retrying.py
index 43f78717..3a33bc5f 100644
--- a/pyairtable/api/retrying.py
+++ b/pyairtable/api/retrying.py
@@ -18,7 +18,7 @@ def retry_strategy(
     **kwargs: Any,
 ) -> Retry:
     """
-    Creates a `Retry <https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html#urllib3.util.Retry>`_
+    Create a `Retry <https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html#urllib3.util.Retry>`_
     instance with adjustable default values. :class:`~pyairtable.Api` accepts this via the
     ``retry_strategy=`` parameter.
 
diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py
index 0ac13289..d8f73a79 100644
--- a/pyairtable/api/table.py
+++ b/pyairtable/api/table.py
@@ -16,6 +16,8 @@
     assert_typed_dict,
     assert_typed_dicts,
 )
+from pyairtable.models.schema import FieldSchema, TableSchema, parse_field_schema
+from pyairtable.utils import is_table_id
 
 
 class Table:
@@ -34,6 +36,9 @@ class Table:
     #: Can be either the table name or the table ID (``tblXXXXXXXXXXXXXX``).
     name: str
 
+    # Cached schema information to reduce API calls
+    _schema: Optional[TableSchema] = None
+
     @overload
     def __init__(
         self,
@@ -56,11 +61,20 @@ def __init__(
     ):
         ...
 
+    @overload
+    def __init__(
+        self,
+        api_key: None,
+        base_id: "pyairtable.api.base.Base",
+        table_name: TableSchema,
+    ):
+        ...
+
     def __init__(
         self,
         api_key: Union[None, str],
         base_id: Union["pyairtable.api.base.Base", str],
-        table_name: str,
+        table_name: Union[str, TableSchema],
         **kwargs: Any,
     ):
         """
@@ -91,44 +105,87 @@ def __init__(
                 stacklevel=2,
             )
             api = pyairtable.api.api.Api(api_key, **kwargs)
-            base = api.base(base_id)
-        elif api_key is None and isinstance(base_id, pyairtable.api.base.Base):
-            base = base_id
+            self.base = api.base(base_id)
+        elif api_key is None and isinstance(base := base_id, pyairtable.api.base.Base):
+            self.base = base
         else:
             raise TypeError(
-                "Table() expects either (str, str, str) or (None, Base, str);"
+                "Table() expects (None, Base, str | TableSchema);"
                 f" got ({type(api_key)}, {type(base_id)}, {type(table_name)})"
             )
 
-        self.base = base
-        self.name = table_name
+        if isinstance(table_name, str):
+            self.name = table_name
+        elif isinstance(schema := table_name, TableSchema):
+            self._schema = schema
+            self.name = schema.name
+        else:
+            raise TypeError(
+                "Table() expects (None, Base, str | TableSchema);"
+                f" got ({type(api_key)}, {type(base_id)}, {type(table_name)})"
+            )
 
     def __repr__(self) -> str:
-        return f"<Table base_id={self.base.id!r} table_name={self.name!r}>"
+        if self._schema:
+            return f"<Table base={self.base.id!r} id={self._schema.id!r} name={self._schema.name!r}>"
+        return f"<Table base={self.base.id!r} name={self.name!r}>"
+
+    @property
+    def id(self) -> str:
+        """
+        Get the table's Airtable ID.
+
+        If the instance was created with a name rather than an ID, this property will perform
+        an API request to retrieve the base's schema. For example:
+
+        .. code-block:: python
+
+            # This will not create any network traffic
+            >>> table = base.table('tbl00000000000123')
+            >>> table.id
+            'tbl00000000000123'
+
+            # This will fetch schema for the base when `table.id` is called
+            >>> table = base.table('Table Name')
+            >>> table.id
+            'tbl00000000000123'
+        """
+        if is_table_id(self.name):
+            return self.name
+        return self.schema().id
 
     @property
     def url(self) -> str:
         """
-        Returns the URL for this table.
+        Build the URL for this table.
+        """
+        token = self._schema.id if self._schema else self.name
+        return self.api.build_url(self.base.id, urllib.parse.quote(token, safe=""))
+
+    def meta_url(self, *components: str) -> str:
         """
-        return self.api.build_url(self.base.id, urllib.parse.quote(self.name, safe=""))
+        Build a URL to a metadata endpoint for this table.
+        """
+        return self.api.build_url(
+            f"meta/bases/{self.base.id}/tables/{self.id}", *components
+        )
 
     def record_url(self, record_id: RecordId, *components: str) -> str:
         """
-        Returns the URL for the given record ID, with optional trailing components.
+        Build the URL for the given record ID, with optional trailing components.
         """
         return posixpath.join(self.url, record_id, *components)
 
     @property
     def api(self) -> "pyairtable.api.api.Api":
         """
-        Returns the same API connection as table's :class:`~pyairtable.Base`.
+        The API connection used by the table's :class:`~pyairtable.Base`.
         """
         return self.base.api
 
     def get(self, record_id: RecordId, **options: Any) -> RecordDict:
         """
-        Retrieves a record by its ID.
+        Retrieve a record by its ID.
 
         >>> table.get('recwPQIfs4wKPyc9D')
         {'id': 'recwPQIfs4wKPyc9D', 'fields': {'First Name': 'John', 'Age': 21}}
@@ -147,7 +204,7 @@ def get(self, record_id: RecordId, **options: Any) -> RecordDict:
 
     def iterate(self, **options: Any) -> Iterator[List[RecordDict]]:
         """
-        Iterates through each page of results from `List records <https://airtable.com/developers/web/api/list-records>`_.
+        Iterate through each page of results from `List records <https://airtable.com/developers/web/api/list-records>`_.
         To get all records at once, use :meth:`all`.
 
         >>> it = table.iterate()
@@ -183,7 +240,7 @@ def iterate(self, **options: Any) -> Iterator[List[RecordDict]]:
 
     def all(self, **options: Any) -> List[RecordDict]:
         """
-        Retrieves all matching records in a single list.
+        Retrieve all matching records in a single list.
 
         >>> table = api.table('base_id', 'table_name')
         >>> table.all(view='MyView', fields=['ColA', '-ColB'])
@@ -207,7 +264,7 @@ def all(self, **options: Any) -> List[RecordDict]:
 
     def first(self, **options: Any) -> Optional[RecordDict]:
         """
-        Retrieves the first matching record.
+        Retrieve the first matching record.
         Returns ``None`` if no records are returned.
 
         This is similar to :meth:`~pyairtable.Table.all`, except
@@ -236,7 +293,7 @@ def create(
         return_fields_by_field_id: bool = False,
     ) -> RecordDict:
         """
-        Creates a new record
+        Create a new record
 
         >>> record = {'Name': 'John'}
         >>> table = api.table('base_id', 'table_name')
@@ -265,7 +322,7 @@ def batch_create(
         return_fields_by_field_id: bool = False,
     ) -> List[RecordDict]:
         """
-        Creats a number of new records in batches.
+        Create a number of new records in batches.
 
         >>> table.batch_create([{'Name': 'John'}, {'Name': 'Marc'}])
         [
@@ -314,7 +371,7 @@ def update(
         typecast: bool = False,
     ) -> RecordDict:
         """
-        Updates a particular record ID with the given fields.
+        Update a particular record ID with the given fields.
 
         >>> table.update('recwPQIfs4wKPyc9D', {"Age": 21})
         {'id': 'recwPQIfs4wKPyc9D', 'fields': {'First Name': 'John', 'Age': 21}}
@@ -343,7 +400,7 @@ def batch_update(
         return_fields_by_field_id: bool = False,
     ) -> List[RecordDict]:
         """
-        Updates several records in batches.
+        Update several records in batches.
 
         Args:
             records: Records to update.
@@ -384,7 +441,7 @@ def batch_upsert(
         return_fields_by_field_id: bool = False,
     ) -> UpsertResultDict:
         """
-        Updates or creates records in batches, either using ``id`` (if given) or using a set of
+        Update or create records in batches, either using ``id`` (if given) or using a set of
         fields (``key_fields``) to look for matches. For more information on how this operation
         behaves, see Airtable's API documentation for `Update multiple records <https://airtable.com/developers/web/api/update-multiple-records#request-performupsert-fieldstomergeon>`__.
 
@@ -447,7 +504,7 @@ def batch_upsert(
 
     def delete(self, record_id: RecordId) -> RecordDeletedDict:
         """
-        Deletes the given record.
+        Delete the given record.
 
         >>> table.delete('recwPQIfs4wKPyc9D')
         {'id': 'recwPQIfs4wKPyc9D', 'deleted': True}
@@ -465,7 +522,7 @@ def delete(self, record_id: RecordId) -> RecordDeletedDict:
 
     def batch_delete(self, record_ids: Iterable[RecordId]) -> List[RecordDeletedDict]:
         """
-        Deletes the given records, operating in batches.
+        Delete the given records, operating in batches.
 
         >>> table.batch_delete(['recwPQIfs4wKPyc9D', 'recwDxIfs3wDPyc3F'])
         [
@@ -492,7 +549,7 @@ def batch_delete(self, record_ids: Iterable[RecordId]) -> List[RecordDeletedDict
 
     def comments(self, record_id: RecordId) -> List["pyairtable.models.Comment"]:
         """
-        Returns a list of comments on the given record.
+        Retrieve all comments on the given record.
 
         Usage:
             >>> table = Api.table("appNxslc6jG0XedVM", "tblslc6jG0XedVMNx")
@@ -525,9 +582,7 @@ def comments(self, record_id: RecordId) -> List["pyairtable.models.Comment"]:
         url = self.record_url(record_id, "comments")
         return [
             pyairtable.models.Comment.from_api(
-                api=self.api,
-                url=self.record_url(record_id, "comments", comment["id"]),
-                obj=comment,
+                comment, self.api, context={"record_url": self.record_url(record_id)}
             )
             for page in self.api.iterate_requests("GET", url)
             for comment in page["comments"]
@@ -539,7 +594,7 @@ def add_comment(
         text: str,
     ) -> "pyairtable.models.Comment":
         """
-        Creates a comment on a record.
+        Create a comment on a record.
         See `Create comment <https://airtable.com/developers/web/api/create-comment>`_ for details.
 
         Usage:
@@ -556,10 +611,66 @@ def add_comment(
         url = self.record_url(record_id, "comments")
         response = self.api.request("POST", url, json={"text": text})
         return pyairtable.models.Comment.from_api(
-            api=self.api,
-            url=self.record_url(record_id, "comments", response["id"]),
-            obj=response,
+            response, self.api, context={"record_url": self.record_url(record_id)}
+        )
+
+    def schema(self, *, force: bool = False) -> TableSchema:
+        """
+        Retrieve the schema of the current table.
+
+        Usage:
+            >>> table.schema()
+            TableSchema(
+                id='tblslc6jG0XedVMNx',
+                name='My Table',
+                primary_field_id='fld6jG0XedVMNxFQW',
+                fields=[...],
+                views=[...]
+            )
+
+        Args:
+            force: |kwarg_force_metadata|
+        """
+        if force or not self._schema:
+            self._schema = self.base.schema(force=force).table(self.name)
+        return self._schema
+
+    def create_field(
+        self,
+        name: str,
+        type: str,
+        description: Optional[str] = None,
+        options: Optional[Dict[str, Any]] = None,
+    ) -> FieldSchema:
+        """
+        Create a field on the table.
+
+        Args:
+            name: The unique name of the field.
+            field_type: One of the `Airtable field types <https://airtable.com/developers/web/api/model/field-type>`__.
+            description: A long form description of the table.
+            options: Only available for some field types. For more information, read about the
+                `Airtable field model <https://airtable.com/developers/web/api/field-model>`__.
+        """
+        request: Dict[str, Any] = {"name": name, "type": type}
+        if description:
+            request["description"] = description
+        if options:
+            request["options"] = options
+        response = self.api.request("POST", self.meta_url("fields"), json=request)
+        # This hopscotch ensures that the FieldSchema object we return has an API and a URL,
+        # and that developers don't need to reload our schema to be able to access it.
+        field_schema = parse_field_schema(response)
+        field_schema.set_api(
+            self.api,
+            context={
+                "base": self.base,
+                "table_schema": self._schema or self,
+            },
         )
+        if self._schema:
+            self._schema.fields.append(field_schema)
+        return field_schema
 
 
 # These are at the bottom of the module to avoid circular imports
diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py
index 097c720d..dc72cca8 100644
--- a/pyairtable/api/types.py
+++ b/pyairtable/api/types.py
@@ -325,7 +325,7 @@ class UserAndScopesDict(TypedDict, total=False):
 @lru_cache
 def _create_model_from_typeddict(cls: Type[T]) -> Type[pydantic.BaseModel]:
     """
-    Creates a pydantic model from a TypedDict to use as a validator.
+    Create a pydantic model from a TypedDict to use as a validator.
     Memoizes the result so we don't have to call this more than once per class.
     """
     # Mypy can't tell that we are using pydantic v1.
@@ -390,7 +390,7 @@ def assert_typed_dicts(cls: Type[T], objects: Any) -> List[T]:
 
 def is_airtable_error(obj: Any) -> bool:
     """
-    Returns whether the given object represents an Airtable error.
+    Determine whether the given object represents an Airtable error.
     """
     if isinstance(obj, dict):
         return set(obj) in ({"error"}, {"specialValue"})
diff --git a/pyairtable/api/workspace.py b/pyairtable/api/workspace.py
new file mode 100644
index 00000000..04d73e28
--- /dev/null
+++ b/pyairtable/api/workspace.py
@@ -0,0 +1,122 @@
+from typing import Any, Dict, List, Optional, Sequence, Union
+
+from pyairtable.models.schema import WorkspaceCollaborators
+from pyairtable.utils import cache_unless_forced, enterprise_only
+
+
+class Workspace:
+    """
+    Represents an Airtable workspace, which contains a number of bases
+    and its own set of collaborators.
+
+    >>> ws = api.workspace("wspmhESAta6clCCwF")
+    >>> ws.collaborators().name
+    'my first workspace'
+    >>> ws.create_base("Base Name", tables=[...])
+    <pyairtable.Base base_id="appMhESAta6clCCwF">
+
+    Most workspace functionality is limited to users on Enterprise billing plans.
+    """
+
+    _collaborators: Optional[WorkspaceCollaborators] = None
+
+    def __init__(self, api: "pyairtable.api.api.Api", workspace_id: str):
+        self.api = api
+        self.id = workspace_id
+
+    @property
+    def url(self) -> str:
+        return self.api.build_url("meta/workspaces", self.id)
+
+    def create_base(
+        self,
+        name: str,
+        tables: Sequence[Dict[str, Any]],
+    ) -> "pyairtable.api.base.Base":
+        """
+        Create a base in the given workspace.
+
+        See https://airtable.com/developers/web/api/create-base
+
+        Args:
+            name: The name to give to the new base. Does not need to be unique.
+            tables: A list of ``dict`` objects that conform to Airtable's
+                `Table model <https://airtable.com/developers/web/api/model/table-model>`__.
+        """
+        url = self.api.build_url("meta/bases")
+        payload = {"name": name, "workspaceId": self.id, "tables": list(tables)}
+        response = self.api.request("POST", url, json=payload)
+        return self.api.base(response["id"], validate=True)
+
+    # Everything below here requires .info() and is therefore Enterprise-only
+
+    @enterprise_only
+    @cache_unless_forced
+    def collaborators(self) -> WorkspaceCollaborators:
+        """
+        Retrieve basic information, collaborators, and invite links
+        for the given workspace, caching the result.
+
+        See https://airtable.com/developers/web/api/get-workspace-collaborators
+        """
+        params = {"include": ["collaborators", "inviteLinks"]}
+        payload = self.api.request("GET", self.url, params=params)
+        return WorkspaceCollaborators.parse_obj(payload)
+
+    @enterprise_only
+    def bases(self) -> List["pyairtable.api.base.Base"]:
+        """
+        Retrieve all bases within the workspace.
+        """
+        return [self.api.base(base_id) for base_id in self.collaborators().base_ids]
+
+    @property
+    @enterprise_only
+    def name(self) -> str:
+        """
+        The name of the workspace.
+        """
+        return self.collaborators().name
+
+    @enterprise_only
+    def delete(self) -> None:
+        """
+        Delete the workspace.
+
+        See https://airtable.com/developers/web/api/delete-workspace
+
+        Usage:
+            >>> ws = api.workspace("wspmhESAta6clCCwF")
+            >>> ws.delete()
+        """
+        self.api.request("DELETE", self.url)
+
+    @enterprise_only
+    def move_base(
+        self,
+        base: Union[str, "pyairtable.api.base.Base"],
+        target: Union[str, "Workspace"],
+        index: Optional[int] = None,
+    ) -> None:
+        """
+        Move the given base to a new workspace.
+
+        See https://airtable.com/developers/web/api/move-base
+
+        Usage:
+            >>> ws = api.workspace("wspmhESAta6clCCwF")
+            >>> base = api.workspace("appCwFmhESAta6clC")
+            >>> workspace.move_base(base, "wspSomeOtherPlace", index=0)
+        """
+        base_id = base if isinstance(base, str) else base.id
+        target_id = target if isinstance(target, str) else target.id
+        payload: Dict[str, Any] = {"baseId": base_id, "targetWorkspaceId": target_id}
+        if index is not None:
+            payload["targetIndex"] = index
+        url = self.url + "/moveBase"
+        self.api.request("POST", url, json=payload)
+
+
+# These are at the bottom of the module to avoid circular imports
+import pyairtable.api.api  # noqa
+import pyairtable.api.base  # noqa
diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py
index 4531bda3..09a40f7c 100644
--- a/pyairtable/formulas.py
+++ b/pyairtable/formulas.py
@@ -9,7 +9,7 @@
 
 def match(dict_values: Fields, *, match_any: bool = False) -> str:
     """
-    Creates one or more ``EQUAL()`` expressions for each provided dict value.
+    Create one or more ``EQUAL()`` expressions for each provided dict value.
     If more than one assetions is included, the expressions are
     groupped together into using ``AND()`` (all values must match).
 
@@ -117,7 +117,7 @@ def to_airtable_value(value: Any) -> Any:
 
 def EQUAL(left: Any, right: Any) -> str:
     """
-    Creates an equality assertion
+    Create an equality assertion
 
     >>> EQUAL(2,2)
     '2=2'
@@ -127,7 +127,7 @@ def EQUAL(left: Any, right: Any) -> str:
 
 def FIELD(name: str) -> str:
     """
-    Creates a reference to a field. Quotes are escaped.
+    Create a reference to a field. Quotes are escaped.
 
     Args:
         name: field name
@@ -143,7 +143,7 @@ def FIELD(name: str) -> str:
 
 def STR_VALUE(value: str) -> str:
     """
-    Wraps string in quotes. This is needed when referencing a string inside a formula.
+    Wrap string in quotes. This is needed when referencing a string inside a formula.
     Quotes are escaped.
 
     >>> STR_VALUE("John")
@@ -158,7 +158,7 @@ def STR_VALUE(value: str) -> str:
 
 def IF(logical: str, value1: str, value2: str) -> str:
     """
-    Creates an IF statement
+    Create an IF statement
 
     >>> IF(1=1, 0, 1)
     'IF(1=1, 0, 1)'
@@ -168,7 +168,7 @@ def IF(logical: str, value1: str, value2: str) -> str:
 
 def FIND(what: str, where: str, start_position: int = 0) -> str:
     """
-    Creates a FIND statement
+    Create a FIND statement
 
     >>> FIND(STR_VALUE(2021), FIELD('DatetimeCol'))
     "FIND('2021', {DatetimeCol})"
@@ -187,7 +187,7 @@ def FIND(what: str, where: str, start_position: int = 0) -> str:
 
 def AND(*args: str) -> str:
     """
-    Creates an AND Statement
+    Create an AND Statement
 
     >>> AND(1, 2, 3)
     'AND(1, 2, 3)'
diff --git a/pyairtable/metadata.py b/pyairtable/metadata.py
index 5cf815cf..b231eac5 100644
--- a/pyairtable/metadata.py
+++ b/pyairtable/metadata.py
@@ -1,18 +1,21 @@
+import warnings
 from typing import Any, Dict, Optional, Union
 
 from pyairtable.api import Api, Base, Table
 
 
-def get_api_bases(api: Union[Api, Base]) -> Dict[Any, Any]:
+def get_api_bases(api: Union[Api, Base]) -> Dict[Any, Any]:  # pragma: no cover
     """
     Return list of Bases from an Api or Base instance.
-    For More Details `Metadata Api Documentation <https://airtable.com/api/meta>`_
+
+    This function has been deprecated. Use
+    :meth:`Api.bases() <pyairtable.Api.bases>` instead.
 
     Args:
         api: :class:`Api` or :class:`Base` instance
 
     Usage:
-        >>> table.get_bases()
+        >>> get_api_bases(api)
         {
             "bases": [
                 {
@@ -28,6 +31,11 @@ def get_api_bases(api: Union[Api, Base]) -> Dict[Any, Any]:
             ]
         }
     """
+    warnings.warn(
+        "get_api_bases is deprecated; use Api.bases() instead.",
+        category=DeprecationWarning,
+        stacklevel=2,
+    )
     api = api.api if isinstance(api, Base) else api
     base_list_url = api.build_url("meta", "bases")
     return {
@@ -39,10 +47,12 @@ def get_api_bases(api: Union[Api, Base]) -> Dict[Any, Any]:
     }
 
 
-def get_base_schema(base: Union[Base, Table]) -> Dict[Any, Any]:
+def get_base_schema(base: Union[Base, Table]) -> Dict[Any, Any]:  # pragma: no cover
     """
     Returns Schema of a Base
-    For More Details `Metadata Api Documentation <https://airtable.com/api/meta>`_
+
+    This function has been deprecated. Use
+    :meth:`Base.schema() <pyairtable.Base.schema>` instead.
 
     Args:
         base: :class:`Base` or :class:`Table` instance
@@ -83,16 +93,24 @@ def get_base_schema(base: Union[Base, Table]) -> Dict[Any, Any]:
             ]
         }
     """
+    warnings.warn(
+        "get_base_schema is deprecated; use Base.schema() instead.",
+        category=DeprecationWarning,
+        stacklevel=2,
+    )
     base = base.base if isinstance(base, Table) else base
     base_schema_url = base.api.build_url("meta", "bases", base.id, "tables")
     assert isinstance(response := base.api.request("get", base_schema_url), dict)
     return response
 
 
-def get_table_schema(table: Table) -> Optional[Dict[Any, Any]]:
+def get_table_schema(table: Table) -> Optional[Dict[Any, Any]]:  # pragma: no cover
     """
     Returns the specific table schema record provided by base schema list
 
+    This function has been deprecated. Use
+    :meth:`Table.schema() <pyairtable.Table.schema>` instead.
+
     Args:
         table: :class:`Table` instance
 
@@ -118,6 +136,11 @@ def get_table_schema(table: Table) -> Optional[Dict[Any, Any]]:
             ]
         }
     """
+    warnings.warn(
+        "get_table_schema is deprecated; use Table.schema() instead.",
+        category=DeprecationWarning,
+        stacklevel=2,
+    )
     base_schema = get_base_schema(table)
     for table_record in base_schema.get("tables", {}):
         assert isinstance(table_record, dict)
diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py
index 8bf3ff8f..5703f688 100644
--- a/pyairtable/models/_base.py
+++ b/pyairtable/models/_base.py
@@ -1,10 +1,11 @@
 from functools import partial
-from typing import Any, ClassVar, Iterable, Mapping, Optional, Set, Type, Union
+from typing import Any, ClassVar, Dict, Iterable, Mapping, Optional, Set, Type, Union
 
 import inflection
 from typing_extensions import Self as SelfType
 
 from pyairtable._compat import pydantic
+from pyairtable.utils import _append_docstring_text
 
 
 class AirtableModel(pydantic.BaseModel):
@@ -34,86 +35,202 @@ def parse_obj(cls, obj: Any) -> SelfType:
         instance._raw = obj
         return instance
 
+    @classmethod
+    def from_api(
+        cls,
+        obj: Any,
+        api: "pyairtable.api.api.Api",
+        *,
+        context: Optional[Any] = None,
+    ) -> SelfType:
+        """
+        Construct an instance which is able to update itself using an
+        :class:`~pyairtable.Api`.
+
+        Args:
+            obj: The JSON data structure used to construct the instance.
+                 Will be passed to `parse_obj <https://docs.pydantic.dev/latest/usage/models/#helper-functions>`_.
+            api: The connection to use for saving updates.
+            context: An object, sequence of objects, or mapping of names to objects
+                which will be used as arguments to ``str.format()`` when constructing
+                the URL for a :class:`~pyairtable.models._base.RestfulModel`.
+        """
+        instance = cls.parse_obj(obj)
+        cascade_api(instance, api, context=context)
+        return instance
 
-class SerializableModel(AirtableModel):
+
+def _context_name(obj: Any) -> str:
+    return inflection.underscore(type(obj).__name__)
+
+
+def cascade_api(
+    obj: Any,
+    api: "pyairtable.api.api.Api",
+    *,
+    context: Optional[Any] = None,
+) -> None:
     """
-    Base model for any data structures that can be saved back to the API.
+    Ensure all nested objects have access to the given Api instance,
+    and trigger them to configure their URLs accordingly.
+
+    Args:
+        api: The instance of the API to set.
+        context: A mapping of class names to instances of that class.
+    """
+    if context is None:
+        context = {}
+    # context=Foo() is short for context={"foo": Foo()}
+    if context and not isinstance(context, dict):
+        context = {_context_name(context): context}
+
+    # Ensure we don't get stuck in infinite loops
+    visited: Set[int] = context.setdefault("__visited__", set())
+    if id(obj) in visited:
+        return
+    visited.add(id(obj))
+
+    # Iterate over containers and cascade API context down to contained models.
+    if isinstance(obj, (list, tuple, set)):
+        for value in obj:
+            cascade_api(value, api, context=context)
+    if isinstance(obj, dict):
+        for value in obj.values():
+            cascade_api(value, api, context=context)
+    if not isinstance(obj, AirtableModel):
+        return
+
+    # If we get this far, we're dealing with a model, so add it to the context.
+    # If it's a ModelNamedThis, the key will be model_named_this.
+    context = {**context, _context_name(obj): obj}
+
+    # This is what we came here for
+    if isinstance(obj, RestfulModel):
+        obj.set_api(api, context=context)
+
+    # Find and apply API/context to nested models in every Pydantic field.
+    for field_name in type(obj).__fields__:
+        if field_value := getattr(obj, field_name, None):
+            cascade_api(field_value, api, context=context)
+
+
+class RestfulModel(AirtableModel):
+    """
+    Base model for any data structures that wrap around a REST API endpoint.
 
     Subclasses can pass a number of keyword arguments to control serialization behavior:
 
-        * ``writable=``: field names that should be written to API on ``save()``.
-        * ``readonly=``: field names that should not be written to API on ``save()``.
-        * ``allow_update=``: boolean indicating whether to allow ``save()`` (default: true)
-        * ``allow_delete=``: boolean indicating whether to allow ``delete()`` (default: true)
+        * ``url=``: format string for building the URL to be used when saving changes to this model.
     """
 
-    __writable: ClassVar[Optional[Iterable[str]]]
-    __readonly: ClassVar[Optional[Iterable[str]]]
-    __allow_update: ClassVar[bool]
-    __allow_delete: ClassVar[bool]
-
-    def __init_subclass__(cls, **kwargs: Any) -> None:
-        # These are private to SerializableModel
-        if "writable" in kwargs and "readonly" in kwargs:
-            raise ValueError("incompatible kwargs 'writable' and 'readonly'")
-        cls.__writable = kwargs.get("writable")
-        cls.__readonly = kwargs.get("readonly")
-        cls.__allow_update = bool(kwargs.get("allow_update", True))
-        cls.__allow_delete = bool(kwargs.get("allow_delete", True))
+    __url_pattern: ClassVar[str] = ""
 
     _api: "pyairtable.api.api.Api" = pydantic.PrivateAttr()
-    _url: str = pydantic.PrivateAttr()
-    _deleted: bool = pydantic.PrivateAttr(default=False)
+    _url: str = pydantic.PrivateAttr(default="")
 
-    @classmethod
-    def from_api(cls, api: "pyairtable.api.api.Api", url: str, obj: Any) -> SelfType:
-        """
-        Constructs an instance which is able to update itself using an
-        :class:`~pyairtable.Api`.
+    def __init_subclass__(cls, **kwargs: Any) -> None:
+        cls.__url_pattern = kwargs.pop("url", cls.__url_pattern)
+        super().__init_subclass__()
 
-        Args:
-            api: The connection to use for saving updates.
-            url: The URL which can receive PATCH or DELETE requests for this object.
-            obj: The JSON data structure used to construct the instance.
-                 Will be passed to `parse_obj <https://docs.pydantic.dev/latest/usage/models/#helper-functions>`_.
+    def set_api(self, api: "pyairtable.api.api.Api", context: Dict[str, Any]) -> None:
         """
-        parsed = cls.parse_obj(obj)
-        parsed._api = api
-        parsed._url = url
-        return parsed
+        Set a link to the API and builds the REST URL used for this resource.
 
-    def save(self) -> None:
+        :meta private:
         """
-        Save any changes made to the instance's writable fields.
+        self._api = api
+        self._url = self.__url_pattern.format(**context, self=self)
+        if self._url and not self._url.startswith("http"):
+            self._url = api.build_url(self._url)
 
-        Will raise ``RuntimeError`` if the record has been deleted.
+
+class CanDeleteModel(RestfulModel):
+    """
+    Mix-in for RestfulModel that allows a model to be deleted.
+    """
+
+    _deleted: bool = pydantic.PrivateAttr(default=False)
+
+    @property
+    def deleted(self) -> bool:
         """
-        if not self.__allow_update:
-            raise NotImplementedError(f"{self.__class__.__name__}.save() not allowed")
-        if self._deleted:
-            raise RuntimeError("save() called after delete()")
-        include = set(self.__writable) if self.__writable else None
-        exclude = set(self.__readonly) if self.__readonly else None
-        data = self.dict(by_alias=True, include=include, exclude=exclude)
-        response = self._api.request("PATCH", self._url, json=data)
-        copyable = self.parse_obj(response)
-        self.__dict__.update(copyable.__dict__)
+        Indicates whether the record has been deleted since being returned from the API.
+        """
+        return self._deleted
 
     def delete(self) -> None:
         """
-        Delete the record on the server and marks this instance as deleted.
+        Delete the record on the server and mark this instance as deleted.
         """
-        if not self.__allow_delete:
-            raise NotImplementedError(f"{self.__class__.__name__}.delete() not allowed")
+        if not self._url:
+            raise RuntimeError("delete() called with no URL specified")
         self._api.request("DELETE", self._url)
         self._deleted = True
 
-    @property
-    def deleted(self) -> bool:
+
+class CanUpdateModel(RestfulModel):
+    """
+    Mix-in for RestfulModel that allows a model to be modified and saved.
+
+    Subclasses can pass a number of keyword arguments to control serialization behavior:
+
+        * ``writable=``: field names that should be written to API on ``save()``.
+        * ``readonly=``: field names that should not be written to API on ``save()``.
+        * ``save_null_values=``: boolean indicating whether ``save()`` should write nulls (default: true)
+    """
+
+    __writable: ClassVar[Optional[Iterable[str]]] = None
+    __readonly: ClassVar[Optional[Iterable[str]]] = None
+    __save_none: ClassVar[bool] = True
+
+    def __init_subclass__(cls, **kwargs: Any) -> None:
+        if "writable" in kwargs and "readonly" in kwargs:
+            raise ValueError("incompatible kwargs 'writable' and 'readonly'")
+        cls.__writable = kwargs.pop("writable", cls.__writable)
+        cls.__readonly = kwargs.pop("readonly", cls.__readonly)
+        cls.__save_none = bool(kwargs.pop("save_null_values", cls.__save_none))
+        if cls.__writable:
+            _append_docstring_text(
+                cls,
+                "The following fields can be modified and saved: "
+                + ", ".join(f"``{field}``" for field in cls.__writable),
+            )
+        if cls.__readonly:
+            _append_docstring_text(
+                cls,
+                "The following fields are read-only and cannot be modified:\n"
+                + ", ".join(f"``{field}``" for field in cls.__readonly),
+            )
+        super().__init_subclass__(**kwargs)
+
+    def save(self) -> None:
         """
-        Indicates whether the record has been deleted since being returned from the API.
+        Save any changes made to the instance's writable fields and update the
+        instance with any refreshed values returned from the API.
+
+        Will raise ``RuntimeError`` if the record has been deleted.
         """
-        return self._deleted
+        if getattr(self, "_deleted", None):
+            raise RuntimeError("save() called after delete()")
+        if not self._url:
+            raise RuntimeError("save() called with no URL specified")
+        include = set(self.__writable) if self.__writable else None
+        exclude = set(self.__readonly) if self.__readonly else None
+        data = self.dict(
+            by_alias=True,
+            include=include,
+            exclude=exclude,
+            exclude_none=(not self.__save_none),
+        )
+        response = self._api.request("PATCH", self._url, json=data)
+        copyable = type(self).parse_obj(response)
+        self.__dict__.update(
+            {
+                key: value
+                for (key, value) in copyable.__dict__.items()
+                if key in type(self).__fields__
+            }
+        )
 
     def __setattr__(self, name: str, value: Any) -> None:
         # Prevents implementers from changing values on readonly or non-writable fields.
@@ -147,7 +264,6 @@ def update_forward_refs(
         ...     class B_Two(AirtableModel): ...
         >>> update_forward_refs(vars())
     """
-    # Avoid infinite circular loops
     memo = set() if memo is None else memo
     # If it's a type, update its refs, then do the same for any nested classes.
     # This will raise AttributeError if given a non-AirtableModel type.
diff --git a/pyairtable/models/comment.py b/pyairtable/models/comment.py
index 1e7d3389..4d27a917 100644
--- a/pyairtable/models/comment.py
+++ b/pyairtable/models/comment.py
@@ -1,10 +1,15 @@
 from typing import Dict, Optional
 
-from ._base import AirtableModel, SerializableModel, update_forward_refs
+from ._base import AirtableModel, CanDeleteModel, CanUpdateModel, update_forward_refs
 from .collaborator import Collaborator
 
 
-class Comment(SerializableModel, writable=["text"]):
+class Comment(
+    CanUpdateModel,
+    CanDeleteModel,
+    writable=["text"],
+    url="{record_url}/comments/{self.id}",
+):
     """
     A record comment that has been retrieved from the Airtable API.
 
@@ -52,31 +57,32 @@ class Comment(SerializableModel, writable=["text"]):
     author: Collaborator
 
     #: Users or groups that were mentioned in the text.
-    mentioned: Optional[Dict[str, "Comment.Mentioned"]]
-
-    class Mentioned(AirtableModel):
-        """
-        A user or group that was mentioned within a comment.
-        Stored as a ``dict`` that is keyed by ID.
-
-        >>> comment = table.add_comment(record_id, "Hello, @[usrVMNxslc6jG0Xed]!")
-        >>> comment.mentioned
-        {
-            "usrVMNxslc6jG0Xed": Mentioned(
-                display_name='Alice',
-                email='alice@example.com',
-                id='usrVMNxslc6jG0Xed',
-                type='user'
-            )
-        }
+    mentioned: Optional[Dict[str, "Mentioned"]]
+
 
-        See `User mentioned <https://airtable.com/developers/web/api/model/user-mentioned>`_ for more details.
-        """
+class Mentioned(AirtableModel):
+    """
+    A user or group that was mentioned within a comment.
+    Stored as a ``dict`` that is keyed by ID.
+
+    >>> comment = table.add_comment(record_id, "Hello, @[usrVMNxslc6jG0Xed]!")
+    >>> comment.mentioned
+    {
+        "usrVMNxslc6jG0Xed": Mentioned(
+            display_name='Alice',
+            email='alice@example.com',
+            id='usrVMNxslc6jG0Xed',
+            type='user'
+        )
+    }
 
-        id: str
-        type: str
-        display_name: str
-        email: Optional[str] = None
+    See `User mentioned <https://airtable.com/developers/web/api/model/user-mentioned>`_ for more details.
+    """
+
+    id: str
+    type: str
+    display_name: str
+    email: Optional[str] = None
 
 
 update_forward_refs(vars())
diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py
new file mode 100644
index 00000000..1023d1c7
--- /dev/null
+++ b/pyairtable/models/schema.py
@@ -0,0 +1,1146 @@
+from functools import partial
+from typing import Any, Dict, List, Literal, Optional, TypeVar, Union
+
+from typing_extensions import TypeAlias
+
+from pyairtable._compat import pydantic
+
+from ._base import AirtableModel, CanDeleteModel, CanUpdateModel, update_forward_refs
+
+_T = TypeVar("_T", bound=Any)
+_FL = partial(pydantic.Field, default_factory=list)
+_FD = partial(pydantic.Field, default_factory=dict)
+
+
+def _find(collection: List[_T], id_or_name: str) -> _T:
+    """
+    For use on a collection model to find objects by either id or name.
+    """
+    items_by_name: Dict[str, _T] = {}
+
+    for item in collection:
+        if getattr(item, "deleted", None):
+            continue
+        if item.id == id_or_name:
+            return item
+        items_by_name[item.name] = item
+
+    return items_by_name[id_or_name]
+
+
+class Bases(AirtableModel):
+    """
+    The list of bases visible to the API token.
+
+    See https://airtable.com/developers/web/api/list-bases
+    """
+
+    bases: List["Bases.Info"] = _FL()
+
+    def base(self, base_id: str) -> "Bases.Info":
+        """
+        Get basic information about the base with the given ID.
+        """
+        return _find(self.bases, base_id)
+
+    class Info(AirtableModel):
+        id: str
+        name: str
+        permission_level: str
+
+
+class BaseCollaborators(AirtableModel):
+    """
+    Detailed information about who can access a base.
+
+    See https://airtable.com/developers/web/api/get-base-collaborators
+    """
+
+    id: str
+    name: str
+    permission_level: str
+    workspace_id: str
+    interfaces: Dict[str, "BaseCollaborators.InterfaceCollaborators"] = _FD()
+    group_collaborators: Optional["BaseCollaborators.GroupCollaborators"]
+    individual_collaborators: Optional["BaseCollaborators.IndividualCollaborators"]
+    invite_links: Optional["BaseCollaborators.InviteLinks"]
+
+    class InterfaceCollaborators(AirtableModel):
+        created_time: str
+        group_collaborators: List["GroupCollaborator"] = _FL()
+        individual_collaborators: List["IndividualCollaborator"] = _FL()
+        invite_links: List["InviteLink"] = _FL()
+
+    class GroupCollaborators(AirtableModel):
+        via_base: List["GroupCollaborator"] = _FL(alias="baseCollaborators")
+        via_workspace: List["GroupCollaborator"] = _FL(alias="workspaceCollaborators")
+
+    class IndividualCollaborators(AirtableModel):
+        via_base: List["IndividualCollaborator"] = _FL(alias="baseCollaborators")
+        via_workspace: List["IndividualCollaborator"] = _FL(
+            alias="workspaceCollaborators"
+        )
+
+    class InviteLinks(AirtableModel):
+        base_invite_links: List["InviteLink"] = _FL()
+        workspace_invite_links: List["InviteLink"] = _FL()
+
+
+class BaseShares(AirtableModel):
+    """
+    Collection of shared views in a base.
+
+    See https://airtable.com/developers/web/api/list-shares
+    """
+
+    shares: List["BaseShares.Info"]
+
+    class Info(AirtableModel):
+        state: str
+        created_by_user_id: str
+        created_time: str
+        share_id: str
+        type: str
+        is_password_protected: bool
+        block_installation_id: Optional[str] = None
+        restricted_to_email_domains: List[str] = _FL()
+        view_id: Optional[str] = None
+        effective_email_domain_allow_list: List[str] = _FL()
+
+
+class BaseSchema(AirtableModel):
+    """
+    Schema of all tables within the base.
+
+    See https://airtable.com/developers/web/api/get-base-schema
+
+    Usage:
+        >>> schema = api.base(base_id).schema()
+        >>> schema.tables
+        [TableSchema(...), ...]
+        >>> schema.table("Table Name")
+        TableSchema(
+            id='tbl6jG0XedVMNxFQW',
+            name='Table Name',
+            primary_field_id='fld0XedVMNxFQW6jG',
+            description=None,
+            fields=[...],
+            views=[...]
+        )
+    """
+
+    tables: List["TableSchema"]
+
+    def table(self, id_or_name: str) -> "TableSchema":
+        """
+        Get the schema for the table with the given ID or name.
+        """
+        return _find(self.tables, id_or_name)
+
+
+class TableSchema(
+    CanUpdateModel,
+    save_null_values=False,
+    writable=["name", "description"],
+    url="meta/bases/{base.id}/tables/{self.id}",
+):
+    """
+    Metadata for a table.
+
+    See https://airtable.com/developers/web/api/get-base-schema
+
+    Usage:
+        >>> schema = base.table("Table Name").schema()
+        >>> schema.id
+        'tbl6clmhESAtaCCwF'
+        >>> schema.name
+        'Table Name'
+
+        >>> schema.fields
+        [FieldSchema(...), ...]
+        >>> schema().field("fld6jG0XedVMNxFQW")
+        SingleLineTextFieldSchema(
+            id='fld6jG0XedVMNxFQW',
+            name='Name',
+            type='singleLineText'
+        )
+
+        >>> schema.views
+        [ViewSchema(...), ...]
+        >>> schema().view("View Name")
+        ViewSchema(
+            id='viw6jG0XedVMNxFQW',
+            name='My Grid View',
+            type='grid'
+        )
+    """
+
+    id: str
+    name: str
+    primary_field_id: str
+    description: Optional[str]
+    fields: List["FieldSchema"]
+    views: List["ViewSchema"]
+
+    def field(self, id_or_name: str) -> "FieldSchema":
+        """
+        Get the schema for the field with the given ID or name.
+        """
+        return _find(self.fields, id_or_name)
+
+    def view(self, id_or_name: str) -> "ViewSchema":
+        """
+        Get the schema for the view with the given ID or name.
+        """
+        return _find(self.views, id_or_name)
+
+
+class ViewSchema(CanDeleteModel, url="meta/bases/{base.id}/views/{self.id}"):
+    """
+    Metadata for a view.
+
+    See https://airtable.com/developers/web/api/get-view-metadata
+
+    Usage:
+        >>> vw = table.schema().view("View name")
+        >>> vw.name
+        'View name'
+        >>> vw.type
+        'grid'
+        >>> vw.delete()
+    """
+
+    id: str
+    type: str
+    name: str
+    personal_for_user_id: Optional[str]
+    visible_field_ids: Optional[List[str]]
+
+
+class GroupCollaborator(AirtableModel):
+    created_time: str
+    granted_by_user_id: str
+    group_id: str
+    name: str
+    permission_level: str
+
+
+class IndividualCollaborator(AirtableModel):
+    created_time: str
+    granted_by_user_id: str
+    user_id: str
+    email: str
+    permission_level: str
+
+
+class InviteLink(AirtableModel):
+    id: str
+    type: str
+    created_time: str
+    invited_email: Optional[str]
+    referred_by_user_id: str
+    permission_level: str
+    restricted_to_email_domains: List[str] = _FL()
+
+
+class BaseIndividualCollaborator(IndividualCollaborator):
+    base_id: str
+
+
+class BaseGroupCollaborator(GroupCollaborator):
+    base_id: str
+
+
+class BaseInviteLink(InviteLink):
+    base_id: str
+
+
+class EnterpriseInfo(AirtableModel):
+    """
+    Information about groups, users, workspaces, and email domains
+    associated with an enterprise account.
+
+    See https://airtable.com/developers/web/api/get-enterprise
+    """
+
+    id: str
+    created_time: str
+    group_ids: List[str]
+    user_ids: List[str]
+    workspace_ids: List[str]
+    email_domains: List["EnterpriseInfo.EmailDomain"]
+
+    class EmailDomain(AirtableModel):
+        email_domain: str
+        is_sso_required: bool
+
+
+class WorkspaceCollaborators(AirtableModel):
+    """
+    Detailed information about who can access a workspace.
+
+    See https://airtable.com/developers/web/api/get-workspace-collaborators
+    """
+
+    id: str
+    name: str
+    created_time: str
+    base_ids: List[str]
+    # We really don't need black to wrap these lines of text.
+    # fmt: off
+    restrictions: "WorkspaceCollaborators.Restrictions" = pydantic.Field(alias="workspaceRestrictions")
+    group_collaborators: Optional["WorkspaceCollaborators.GroupCollaborators"] = None
+    individual_collaborators: Optional["WorkspaceCollaborators.IndividualCollaborators"] = None
+    invite_links: Optional["WorkspaceCollaborators.InviteLinks"] = None
+    # fmt: on
+
+    class Restrictions(AirtableModel):
+        invite_creation: str = pydantic.Field(alias="inviteCreationRestriction")
+        share_creation: str = pydantic.Field(alias="shareCreationRestriction")
+
+    class GroupCollaborators(AirtableModel):
+        base_collaborators: List["BaseGroupCollaborator"]
+        workspace_collaborators: List["GroupCollaborator"]
+
+    class IndividualCollaborators(AirtableModel):
+        base_collaborators: List["BaseIndividualCollaborator"]
+        workspace_collaborators: List["IndividualCollaborator"]
+
+    class InviteLinks(AirtableModel):
+        base_invite_links: List["BaseInviteLink"]
+        workspace_invite_links: List["InviteLink"]
+
+
+class NestedId(AirtableModel):
+    id: str
+
+
+class NestedFieldId(AirtableModel):
+    field_id: str
+
+
+class UserInfo(AirtableModel):
+    """
+    Detailed information about a user.
+
+    See https://airtable.com/developers/web/api/get-user-by-id
+    """
+
+    id: str
+    name: str
+    email: str
+    state: str
+    is_sso_required: bool
+    is_two_factor_auth_enabled: bool
+    last_activity_time: Optional[str]
+    created_time: Optional[str]
+    enterprise_user_type: Optional[str]
+    invited_to_airtable_by_user_id: Optional[str]
+    is_managed: bool = False
+    collaborations: Optional["Collaborations"]
+    groups: List[NestedId] = pydantic.Field(default_factory=list)
+
+
+class Collaborations(AirtableModel):
+    """
+    The full set of collaborations granted to a user or user group.
+
+    See https://airtable.com/developers/web/api/model/collaborations
+    """
+
+    base_collaborations: List["Collaborations.BaseCollaboration"]
+    interface_collaborations: List["Collaborations.InterfaceCollaboration"]
+    workspace_collaborations: List["Collaborations.WorkspaceCollaboration"]
+
+    class BaseCollaboration(AirtableModel):
+        base_id: str
+        created_time: str
+        granted_by_user_id: str
+        permission_level: str
+
+    class InterfaceCollaboration(BaseCollaboration):
+        interface_id: str
+
+    class WorkspaceCollaboration(AirtableModel):
+        workspace_id: str
+        created_time: str
+        granted_by_user_id: str
+        permission_level: str
+
+
+class UserGroup(AirtableModel):
+    """
+    Detailed information about a user group and its members.
+
+    See https://airtable.com/developers/web/api/get-user-group
+    """
+
+    id: str
+    name: str
+    enterprise_account_id: str
+    created_time: str
+    updated_time: str
+    members: List["UserGroup.Member"]
+    collaborations: Optional["Collaborations"]
+
+    class Member(AirtableModel):
+        user_id: str
+        email: str
+        first_name: str
+        last_name: str
+        role: str
+        created_time: str
+
+
+# The data model is a bit confusing here, but it's designed for maximum reuse.
+# SomethingFieldConfig contains the `type` and `options` values for each field type.
+# _FieldSchemaBase contains the `id`, `name`, and `description` values.
+# SomethingFieldSchema inherits from _FieldSchemaBase and SomethingFieldConfig.
+# FieldConfig is a union of all available *FieldConfig classes.
+# FieldSchema is a union of all available *FieldSchema classes.
+
+
+class AITextFieldConfig(AirtableModel):
+    """
+    Field configuration for `AI text <https://airtable.com/developers/web/api/field-model#aitext>`__.
+    """
+
+    type: Literal["aiText"]
+    options: "AITextFieldOptions"
+
+
+class AITextFieldOptions(AirtableModel):
+    prompt: Optional[List[Union[str, "AITextFieldOptions.PromptField"]]]
+    referenced_field_ids: Optional[List[str]]
+
+    class PromptField(AirtableModel):
+        field: NestedFieldId
+
+
+class AutoNumberFieldConfig(AirtableModel):
+    """
+    Field configuration for `Auto number <https://airtable.com/developers/web/api/field-model#autonumber>`__.
+    """
+
+    type: Literal["autoNumber"]
+
+
+class BarcodeFieldConfig(AirtableModel):
+    """
+    Field configuration for `Barcode <https://airtable.com/developers/web/api/field-model#barcode>`__.
+    """
+
+    type: Literal["barcode"]
+
+
+class ButtonFieldConfig(AirtableModel):
+    """
+    Field configuration for `Button <https://airtable.com/developers/web/api/field-model#button>`__.
+    """
+
+    type: Literal["button"]
+
+
+class CheckboxFieldConfig(AirtableModel):
+    """
+    Field configuration for `Checkbox <https://airtable.com/developers/web/api/field-model#checkbox>`__.
+    """
+
+    type: Literal["checkbox"]
+    options: Optional["CheckboxFieldOptions"]
+
+
+class CheckboxFieldOptions(AirtableModel):
+    color: str
+    icon: str
+
+
+class CountFieldConfig(AirtableModel):
+    """
+    Field configuration for `Count <https://airtable.com/developers/web/api/field-model#count>`__.
+    """
+
+    type: Literal["count"]
+    options: Optional["CountFieldOptions"]
+
+
+class CountFieldOptions(AirtableModel):
+    is_valid: bool
+    record_link_field_id: Optional[str]
+
+
+class CreatedByFieldConfig(AirtableModel):
+    """
+    Field configuration for `Created by <https://airtable.com/developers/web/api/field-model#createdby>`__.
+    """
+
+    type: Literal["createdBy"]
+
+
+class CreatedTimeFieldConfig(AirtableModel):
+    """
+    Field configuration for `Created time <https://airtable.com/developers/web/api/field-model#createdtime>`__.
+    """
+
+    type: Literal["createdTime"]
+
+
+class CurrencyFieldConfig(AirtableModel):
+    """
+    Field configuration for `Currency <https://airtable.com/developers/web/api/field-model#currencynumber>`__.
+    """
+
+    type: Literal["currency"]
+    options: "CurrencyFieldOptions"
+
+
+class CurrencyFieldOptions(AirtableModel):
+    precision: int
+    symbol: str
+
+
+class DateFieldConfig(AirtableModel):
+    """
+    Field configuration for `Date <https://airtable.com/developers/web/api/field-model#dateonly>`__.
+    """
+
+    type: Literal["date"]
+    options: "DateFieldOptions"
+
+
+class DateFieldOptions(AirtableModel):
+    date_format: "DateTimeFieldOptions.DateFormat"
+
+
+class DateTimeFieldConfig(AirtableModel):
+    """
+    Field configuration for `Date and time <https://airtable.com/developers/web/api/field-model#dateandtime>`__.
+    """
+
+    type: Literal["dateTime"]
+    options: "DateTimeFieldOptions"
+
+
+class DateTimeFieldOptions(AirtableModel):
+    time_zone: str
+    date_format: "DateTimeFieldOptions.DateFormat"
+    time_format: "DateTimeFieldOptions.TimeFormat"
+
+    class DateFormat(AirtableModel):
+        format: str
+        name: str
+
+    class TimeFormat(AirtableModel):
+        format: str
+        name: str
+
+
+class DurationFieldConfig(AirtableModel):
+    """
+    Field configuration for `Duration <https://airtable.com/developers/web/api/field-model#durationnumber>`__.
+    """
+
+    type: Literal["duration"]
+    options: Optional["DurationFieldOptions"]
+
+
+class DurationFieldOptions(AirtableModel):
+    duration_format: str
+
+
+class EmailFieldConfig(AirtableModel):
+    """
+    Field configuration for `Email <https://airtable.com/developers/web/api/field-model#email>`__.
+    """
+
+    type: Literal["email"]
+
+
+class ExternalSyncSourceFieldConfig(AirtableModel):
+    """
+    Field configuration for `Sync source <https://airtable.com/developers/web/api/field-model#syncsource>`__.
+    """
+
+    type: Literal["externalSyncSource"]
+    options: Optional["SingleSelectFieldOptions"]
+
+
+class FormulaFieldConfig(AirtableModel):
+    """
+    Field configuration for `Formula <https://airtable.com/developers/web/api/field-model#formula>`__.
+    """
+
+    type: Literal["formula"]
+    options: Optional["FormulaFieldOptions"]
+
+
+class FormulaFieldOptions(AirtableModel):
+    formula: str
+    is_valid: bool
+    referenced_field_ids: Optional[List[str]]
+    result: Optional["FieldConfig"]
+
+
+class LastModifiedByFieldConfig(AirtableModel):
+    """
+    Field configuration for `Last modified by <https://airtable.com/developers/web/api/field-model#lastmodifiedby>`__.
+    """
+
+    type: Literal["lastModifiedBy"]
+
+
+class LastModifiedTimeFieldConfig(AirtableModel):
+    """
+    Field configuration for `Last modified time <https://airtable.com/developers/web/api/field-model#lastmodifiedtime>`__.
+    """
+
+    type: Literal["lastModifiedTime"]
+    options: Optional["LastModifiedTimeFieldOptions"]
+
+
+class LastModifiedTimeFieldOptions(AirtableModel):
+    is_valid: bool
+    referenced_field_ids: Optional[List[str]]
+    result: Optional[Union["DateFieldConfig", "DateTimeFieldConfig"]]
+
+
+class MultilineTextFieldConfig(AirtableModel):
+    """
+    Field configuration for `Long text <https://airtable.com/developers/web/api/field-model#multilinetext>`__.
+    """
+
+    type: Literal["multilineText"]
+
+
+class MultipleAttachmentsFieldConfig(AirtableModel):
+    """
+    Field configuration for `Attachments <https://airtable.com/developers/web/api/field-model#multipleattachment>`__.
+    """
+
+    type: Literal["multipleAttachments"]
+    options: Optional["MultipleAttachmentsFieldOptions"]
+
+
+class MultipleAttachmentsFieldOptions(AirtableModel):
+    """
+    Field configuration for `Attachments <https://airtable.com/developers/web/api/field-model#multipleattachment>`__.
+    """
+
+    is_reversed: bool
+
+
+class MultipleCollaboratorsFieldConfig(AirtableModel):
+    """
+    Field configuration for `Multiple Collaborators <https://airtable.com/developers/web/api/field-model#multicollaborator>`__.
+    """
+
+    type: Literal["multipleCollaborators"]
+
+
+class MultipleLookupValuesFieldConfig(AirtableModel):
+    """
+    Field configuration for `Lookup <https://airtable.com/developers/web/api/field-model#lookup>__`.
+    """
+
+    type: Literal["multipleLookupValues"]
+    options: Optional["MultipleLookupValuesFieldOptions"]
+
+
+class MultipleLookupValuesFieldOptions(AirtableModel):
+    field_id_in_linked_table: Optional[str]
+    is_valid: bool
+    record_link_field_id: Optional[str]
+    result: Optional["FieldConfig"]
+
+
+class MultipleRecordLinksFieldConfig(AirtableModel):
+    """
+    Field configuration for `Link to another record <https://airtable.com/developers/web/api/field-model#foreignkey>__`.
+    """
+
+    type: Literal["multipleRecordLinks"]
+    options: Optional["MultipleRecordLinksFieldOptions"]
+
+
+class MultipleRecordLinksFieldOptions(AirtableModel):
+    is_reversed: bool
+    linked_table_id: str
+    prefers_single_record_link: bool
+    inverse_link_field_id: Optional[str]
+    view_id_for_record_selection: Optional[str]
+
+
+class MultipleSelectsFieldConfig(AirtableModel):
+    """
+    Field configuration for `Multiple select <https://airtable.com/developers/web/api/field-model#multiselect>`__.
+    """
+
+    type: Literal["multipleSelects"]
+    options: Optional["SingleSelectFieldOptions"]
+
+
+class NumberFieldConfig(AirtableModel):
+    """
+    Field configuration for `Number <https://airtable.com/developers/web/api/field-model#decimalorintegernumber>`__.
+    """
+
+    type: Literal["number"]
+    options: Optional["NumberFieldOptions"]
+
+
+class NumberFieldOptions(AirtableModel):
+    precision: int
+
+
+class PercentFieldConfig(AirtableModel):
+    """
+    Field configuration for `Percent <https://airtable.com/developers/web/api/field-model#percentnumber>`__.
+    """
+
+    type: Literal["percent"]
+    options: Optional["NumberFieldOptions"]
+
+
+class PhoneNumberFieldConfig(AirtableModel):
+    """
+    Field configuration for `Phone <https://airtable.com/developers/web/api/field-model#phone>`__.
+    """
+
+    type: Literal["phoneNumber"]
+
+
+class RatingFieldConfig(AirtableModel):
+    """
+    Field configuration for `Rating <https://airtable.com/developers/web/api/field-model#rating>`__.
+    """
+
+    type: Literal["rating"]
+    options: Optional["RatingFieldOptions"]
+
+
+class RatingFieldOptions(AirtableModel):
+    color: str
+    icon: str
+    max: int
+
+
+class RichTextFieldConfig(AirtableModel):
+    """
+    Field configuration for `Rich text <https://airtable.com/developers/web/api/field-model#rich-text>`__.
+    """
+
+    type: Literal["richText"]
+
+
+class RollupFieldConfig(AirtableModel):
+    """
+    Field configuration for `Rollup <https://airtable.com/developers/web/api/field-model#rollup>__`.
+    """
+
+    type: Literal["rollup"]
+    options: Optional["RollupFieldOptions"]
+
+
+class RollupFieldOptions(AirtableModel):
+    field_id_in_linked_table: Optional[str]
+    is_valid: bool
+    record_link_field_id: Optional[str]
+    referenced_field_ids: Optional[List[str]]
+    result: Optional["FieldConfig"]
+
+
+class SingleCollaboratorFieldConfig(AirtableModel):
+    """
+    Field configuration for `Collaborator <https://airtable.com/developers/web/api/field-model#collaborator>`__.
+    """
+
+    type: Literal["singleCollaborator"]
+
+
+class SingleLineTextFieldConfig(AirtableModel):
+    """
+    Field configuration for `Single line text <https://airtable.com/developers/web/api/field-model#simpletext>`__.
+    """
+
+    type: Literal["singleLineText"]
+
+
+class SingleSelectFieldConfig(AirtableModel):
+    """
+    Field configuration for `Single select <https://airtable.com/developers/web/api/field-model#select>`__.
+    """
+
+    type: Literal["singleSelect"]
+    options: Optional["SingleSelectFieldOptions"]
+
+
+class SingleSelectFieldOptions(AirtableModel):
+    choices: List["SingleSelectFieldOptions.Choice"]
+
+    class Choice(AirtableModel):
+        id: str
+        name: str
+        color: Optional[str]
+
+
+class UrlFieldConfig(AirtableModel):
+    """
+    Field configuration for `Url <https://airtable.com/developers/web/api/field-model#urltext>`__.
+    """
+
+    type: Literal["url"]
+
+
+class UnknownFieldConfig(AirtableModel):
+    """
+    Field configuration class used as a fallback for unrecognized types.
+    This ensures we don't raise pydantic.ValidationError if Airtable adds new types.
+    """
+
+    type: str
+    options: Optional[Dict[str, Any]]
+
+
+class _FieldSchemaBase(
+    CanUpdateModel,
+    save_null_values=False,
+    writable=["name", "description"],
+    url="meta/bases/{base.id}/tables/{table_schema.id}/fields/{self.id}",
+):
+    id: str
+    name: str
+    description: Optional[str]
+
+
+# This section is auto-generated so that FieldSchema and FieldConfig are kept aligned.
+# See .pre-commit-config.yaml, or just run `tox -e pre-commit` to refresh it.
+# fmt: off
+r"""[[[cog]]]
+
+import re
+with open(cog.inFile) as fp:
+    field_types = re.findall(
+        r"class (\w+Field)Config\(.*?\):(?:\n    \"{3}(.*?)\"{3})?",
+        fp.read(),
+        re.MULTILINE + re.DOTALL
+    )
+
+cog.out("\n\n")
+
+cog.outl("FieldConfig: TypeAlias = Union[")
+for fld, _ in field_types:
+    cog.outl(f"    {fld}Config,")
+cog.outl("]")
+cog.out("\n\n")
+
+for fld, doc in field_types:
+    cog.out(f"class {fld}Schema(_FieldSchemaBase, {fld}Config):\n    ")
+    if doc:
+        doc = doc.replace('ield configuration', 'ield schema')
+        cog.outl("\"\"\"" + doc + "\"\"\"")
+    else:
+        cog.outl("pass")
+    cog.out("\n\n")
+
+cog.outl("FieldSchema: TypeAlias = Union[")
+for fld, _ in field_types:
+    cog.outl(f"    {fld}Schema,")
+cog.outl("]")
+
+[[[out]]]"""
+
+
+FieldConfig: TypeAlias = Union[
+    AITextFieldConfig,
+    AutoNumberFieldConfig,
+    BarcodeFieldConfig,
+    ButtonFieldConfig,
+    CheckboxFieldConfig,
+    CountFieldConfig,
+    CreatedByFieldConfig,
+    CreatedTimeFieldConfig,
+    CurrencyFieldConfig,
+    DateFieldConfig,
+    DateTimeFieldConfig,
+    DurationFieldConfig,
+    EmailFieldConfig,
+    ExternalSyncSourceFieldConfig,
+    FormulaFieldConfig,
+    LastModifiedByFieldConfig,
+    LastModifiedTimeFieldConfig,
+    MultilineTextFieldConfig,
+    MultipleAttachmentsFieldConfig,
+    MultipleCollaboratorsFieldConfig,
+    MultipleLookupValuesFieldConfig,
+    MultipleRecordLinksFieldConfig,
+    MultipleSelectsFieldConfig,
+    NumberFieldConfig,
+    PercentFieldConfig,
+    PhoneNumberFieldConfig,
+    RatingFieldConfig,
+    RichTextFieldConfig,
+    RollupFieldConfig,
+    SingleCollaboratorFieldConfig,
+    SingleLineTextFieldConfig,
+    SingleSelectFieldConfig,
+    UrlFieldConfig,
+    UnknownFieldConfig,
+]
+
+
+class AITextFieldSchema(_FieldSchemaBase, AITextFieldConfig):
+    """
+    Field schema for `AI text <https://airtable.com/developers/web/api/field-model#aitext>`__.
+    """
+
+
+class AutoNumberFieldSchema(_FieldSchemaBase, AutoNumberFieldConfig):
+    """
+    Field schema for `Auto number <https://airtable.com/developers/web/api/field-model#autonumber>`__.
+    """
+
+
+class BarcodeFieldSchema(_FieldSchemaBase, BarcodeFieldConfig):
+    """
+    Field schema for `Barcode <https://airtable.com/developers/web/api/field-model#barcode>`__.
+    """
+
+
+class ButtonFieldSchema(_FieldSchemaBase, ButtonFieldConfig):
+    """
+    Field schema for `Button <https://airtable.com/developers/web/api/field-model#button>`__.
+    """
+
+
+class CheckboxFieldSchema(_FieldSchemaBase, CheckboxFieldConfig):
+    """
+    Field schema for `Checkbox <https://airtable.com/developers/web/api/field-model#checkbox>`__.
+    """
+
+
+class CountFieldSchema(_FieldSchemaBase, CountFieldConfig):
+    """
+    Field schema for `Count <https://airtable.com/developers/web/api/field-model#count>`__.
+    """
+
+
+class CreatedByFieldSchema(_FieldSchemaBase, CreatedByFieldConfig):
+    """
+    Field schema for `Created by <https://airtable.com/developers/web/api/field-model#createdby>`__.
+    """
+
+
+class CreatedTimeFieldSchema(_FieldSchemaBase, CreatedTimeFieldConfig):
+    """
+    Field schema for `Created time <https://airtable.com/developers/web/api/field-model#createdtime>`__.
+    """
+
+
+class CurrencyFieldSchema(_FieldSchemaBase, CurrencyFieldConfig):
+    """
+    Field schema for `Currency <https://airtable.com/developers/web/api/field-model#currencynumber>`__.
+    """
+
+
+class DateFieldSchema(_FieldSchemaBase, DateFieldConfig):
+    """
+    Field schema for `Date <https://airtable.com/developers/web/api/field-model#dateonly>`__.
+    """
+
+
+class DateTimeFieldSchema(_FieldSchemaBase, DateTimeFieldConfig):
+    """
+    Field schema for `Date and time <https://airtable.com/developers/web/api/field-model#dateandtime>`__.
+    """
+
+
+class DurationFieldSchema(_FieldSchemaBase, DurationFieldConfig):
+    """
+    Field schema for `Duration <https://airtable.com/developers/web/api/field-model#durationnumber>`__.
+    """
+
+
+class EmailFieldSchema(_FieldSchemaBase, EmailFieldConfig):
+    """
+    Field schema for `Email <https://airtable.com/developers/web/api/field-model#email>`__.
+    """
+
+
+class ExternalSyncSourceFieldSchema(_FieldSchemaBase, ExternalSyncSourceFieldConfig):
+    """
+    Field schema for `Sync source <https://airtable.com/developers/web/api/field-model#syncsource>`__.
+    """
+
+
+class FormulaFieldSchema(_FieldSchemaBase, FormulaFieldConfig):
+    """
+    Field schema for `Formula <https://airtable.com/developers/web/api/field-model#formula>`__.
+    """
+
+
+class LastModifiedByFieldSchema(_FieldSchemaBase, LastModifiedByFieldConfig):
+    """
+    Field schema for `Last modified by <https://airtable.com/developers/web/api/field-model#lastmodifiedby>`__.
+    """
+
+
+class LastModifiedTimeFieldSchema(_FieldSchemaBase, LastModifiedTimeFieldConfig):
+    """
+    Field schema for `Last modified time <https://airtable.com/developers/web/api/field-model#lastmodifiedtime>`__.
+    """
+
+
+class MultilineTextFieldSchema(_FieldSchemaBase, MultilineTextFieldConfig):
+    """
+    Field schema for `Long text <https://airtable.com/developers/web/api/field-model#multilinetext>`__.
+    """
+
+
+class MultipleAttachmentsFieldSchema(_FieldSchemaBase, MultipleAttachmentsFieldConfig):
+    """
+    Field schema for `Attachments <https://airtable.com/developers/web/api/field-model#multipleattachment>`__.
+    """
+
+
+class MultipleCollaboratorsFieldSchema(_FieldSchemaBase, MultipleCollaboratorsFieldConfig):
+    """
+    Field schema for `Multiple Collaborators <https://airtable.com/developers/web/api/field-model#multicollaborator>`__.
+    """
+
+
+class MultipleLookupValuesFieldSchema(_FieldSchemaBase, MultipleLookupValuesFieldConfig):
+    """
+    Field schema for `Lookup <https://airtable.com/developers/web/api/field-model#lookup>__`.
+    """
+
+
+class MultipleRecordLinksFieldSchema(_FieldSchemaBase, MultipleRecordLinksFieldConfig):
+    """
+    Field schema for `Link to another record <https://airtable.com/developers/web/api/field-model#foreignkey>__`.
+    """
+
+
+class MultipleSelectsFieldSchema(_FieldSchemaBase, MultipleSelectsFieldConfig):
+    """
+    Field schema for `Multiple select <https://airtable.com/developers/web/api/field-model#multiselect>`__.
+    """
+
+
+class NumberFieldSchema(_FieldSchemaBase, NumberFieldConfig):
+    """
+    Field schema for `Number <https://airtable.com/developers/web/api/field-model#decimalorintegernumber>`__.
+    """
+
+
+class PercentFieldSchema(_FieldSchemaBase, PercentFieldConfig):
+    """
+    Field schema for `Percent <https://airtable.com/developers/web/api/field-model#percentnumber>`__.
+    """
+
+
+class PhoneNumberFieldSchema(_FieldSchemaBase, PhoneNumberFieldConfig):
+    """
+    Field schema for `Phone <https://airtable.com/developers/web/api/field-model#phone>`__.
+    """
+
+
+class RatingFieldSchema(_FieldSchemaBase, RatingFieldConfig):
+    """
+    Field schema for `Rating <https://airtable.com/developers/web/api/field-model#rating>`__.
+    """
+
+
+class RichTextFieldSchema(_FieldSchemaBase, RichTextFieldConfig):
+    """
+    Field schema for `Rich text <https://airtable.com/developers/web/api/field-model#rich-text>`__.
+    """
+
+
+class RollupFieldSchema(_FieldSchemaBase, RollupFieldConfig):
+    """
+    Field schema for `Rollup <https://airtable.com/developers/web/api/field-model#rollup>__`.
+    """
+
+
+class SingleCollaboratorFieldSchema(_FieldSchemaBase, SingleCollaboratorFieldConfig):
+    """
+    Field schema for `Collaborator <https://airtable.com/developers/web/api/field-model#collaborator>`__.
+    """
+
+
+class SingleLineTextFieldSchema(_FieldSchemaBase, SingleLineTextFieldConfig):
+    """
+    Field schema for `Single line text <https://airtable.com/developers/web/api/field-model#simpletext>`__.
+    """
+
+
+class SingleSelectFieldSchema(_FieldSchemaBase, SingleSelectFieldConfig):
+    """
+    Field schema for `Single select <https://airtable.com/developers/web/api/field-model#select>`__.
+    """
+
+
+class UrlFieldSchema(_FieldSchemaBase, UrlFieldConfig):
+    """
+    Field schema for `Url <https://airtable.com/developers/web/api/field-model#urltext>`__.
+    """
+
+
+class UnknownFieldSchema(_FieldSchemaBase, UnknownFieldConfig):
+    """
+    Field schema class used as a fallback for unrecognized types.
+    This ensures we don't raise pydantic.ValidationError if Airtable adds new types.
+    """
+
+
+FieldSchema: TypeAlias = Union[
+    AITextFieldSchema,
+    AutoNumberFieldSchema,
+    BarcodeFieldSchema,
+    ButtonFieldSchema,
+    CheckboxFieldSchema,
+    CountFieldSchema,
+    CreatedByFieldSchema,
+    CreatedTimeFieldSchema,
+    CurrencyFieldSchema,
+    DateFieldSchema,
+    DateTimeFieldSchema,
+    DurationFieldSchema,
+    EmailFieldSchema,
+    ExternalSyncSourceFieldSchema,
+    FormulaFieldSchema,
+    LastModifiedByFieldSchema,
+    LastModifiedTimeFieldSchema,
+    MultilineTextFieldSchema,
+    MultipleAttachmentsFieldSchema,
+    MultipleCollaboratorsFieldSchema,
+    MultipleLookupValuesFieldSchema,
+    MultipleRecordLinksFieldSchema,
+    MultipleSelectsFieldSchema,
+    NumberFieldSchema,
+    PercentFieldSchema,
+    PhoneNumberFieldSchema,
+    RatingFieldSchema,
+    RichTextFieldSchema,
+    RollupFieldSchema,
+    SingleCollaboratorFieldSchema,
+    SingleLineTextFieldSchema,
+    SingleSelectFieldSchema,
+    UrlFieldSchema,
+    UnknownFieldSchema,
+]
+# [[[end]]] (checksum: afb669896323650954a082cb4b079c16)
+# fmt: on
+
+
+# Shortcut to allow parsing unions, which is not possible otherwise in Pydantic v1.
+# See https://github.com/pydantic/pydantic/discussions/4950
+class _HasFieldSchema(AirtableModel):
+    field_schema: FieldSchema
+
+
+def parse_field_schema(obj: Any) -> FieldSchema:
+    return _HasFieldSchema.parse_obj({"field_schema": obj}).field_schema
+
+
+update_forward_refs(vars())
diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py
index 61a04e99..973f2d41 100644
--- a/pyairtable/models/webhook.py
+++ b/pyairtable/models/webhook.py
@@ -8,14 +8,14 @@
 from pyairtable._compat import pydantic
 from pyairtable.api.types import RecordId
 
-from ._base import AirtableModel, SerializableModel, update_forward_refs
+from ._base import AirtableModel, CanDeleteModel, update_forward_refs
 
 # Shortcuts to avoid lots of line wrapping
 FD: Callable[[], Any] = partial(pydantic.Field, default_factory=dict)
 FL: Callable[[], Any] = partial(pydantic.Field, default_factory=list)
 
 
-class Webhook(SerializableModel, allow_update=False):
+class Webhook(CanDeleteModel, url="bases/{base.id}/webhooks/{self.id}"):
     """
     A webhook that has been retrieved from the Airtable API.
 
@@ -214,7 +214,7 @@ def from_request(
         secret: Union[bytes, str],
     ) -> SelfType:
         """
-        Validates a request body and X-Airtable-Content-MAC header
+        Validate a request body and X-Airtable-Content-MAC header
         using the secret returned when the webhook was created.
 
         Args:
diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py
index a28721dc..7339c4b8 100644
--- a/pyairtable/orm/fields.py
+++ b/pyairtable/orm/fields.py
@@ -136,11 +136,7 @@ def _description(self) -> str:
         """
         if self._model and self._attribute_name:
             return f"{self._model.__name__}.{self._attribute_name}"
-        if self._model:
-            return f"{self._model.__name__}.{self.field_name}"
-        if self.field_name:
-            return f"{self.field_name!r} field"
-        return "Field"
+        return f"{self.field_name!r} field"
 
     # __get__ and __set__ are called when accessing an instance of Field on an object.
     # Model.field should return the Field instance itself, whereas
@@ -183,13 +179,13 @@ def _missing_value(self) -> Optional[T_ORM]:
 
     def to_record_value(self, value: Any) -> Any:
         """
-        Returns the value which should be persisted to the API.
+        Calculate the value which should be persisted to the API.
         """
         return value
 
     def to_internal_value(self, value: Any) -> Any:
         """
-        Converts a value from the API into the value's internal representation.
+        Convert a value from the API into the value's internal representation.
         """
         return value
 
@@ -322,13 +318,13 @@ class DatetimeField(Field[str, datetime]):
 
     def to_record_value(self, value: datetime) -> str:
         """
-        Converts a ``datetime`` into an ISO 8601 string, e.g. "2014-09-05T12:34:56.000Z".
+        Convert a ``datetime`` into an ISO 8601 string, e.g. "2014-09-05T12:34:56.000Z".
         """
         return utils.datetime_to_iso_str(value)
 
     def to_internal_value(self, value: str) -> datetime:
         """
-        Converts an ISO 8601 string, e.g. "2014-09-05T07:00:00.000Z" into a ``datetime``.
+        Convert an ISO 8601 string, e.g. "2014-09-05T07:00:00.000Z" into a ``datetime``.
         """
         return utils.datetime_from_iso_str(value)
 
@@ -344,13 +340,13 @@ class DateField(Field[str, date]):
 
     def to_record_value(self, value: date) -> str:
         """
-        Converts a ``date`` into an ISO 8601 string, e.g. "2014-09-05".
+        Convert a ``date`` into an ISO 8601 string, e.g. "2014-09-05".
         """
         return utils.date_to_iso_str(value)
 
     def to_internal_value(self, value: str) -> date:
         """
-        Converts an ISO 8601 string, e.g. "2014-09-05" into a ``date``.
+        Convert an ISO 8601 string, e.g. "2014-09-05" into a ``date``.
         """
         return utils.date_from_iso_str(value)
 
@@ -367,13 +363,13 @@ class DurationField(Field[int, timedelta]):
 
     def to_record_value(self, value: timedelta) -> float:
         """
-        Converts a ``timedelta`` into a number of seconds.
+        Convert a ``timedelta`` into a number of seconds.
         """
         return value.total_seconds()
 
     def to_internal_value(self, value: Union[int, float]) -> timedelta:
         """
-        Converts a number of seconds into a ``timedelta``.
+        Convert a number of seconds into a ``timedelta``.
         """
         return timedelta(seconds=value)
 
@@ -508,7 +504,7 @@ def __init__(
     @property
     def linked_model(self) -> Type[T_Linked]:
         """
-        Resolves a :class:`~pyairtable.orm.Model` class based on
+        Resolve a :class:`~pyairtable.orm.Model` class based on
         the ``model=`` constructor parameter to this field instance.
         """
         if isinstance(self._linked_model, str):
@@ -568,10 +564,8 @@ def _get_list_value(self, instance: "Model") -> List[T_Linked]:
 
     def to_record_value(self, value: Union[List[str], List[T_Linked]]) -> List[str]:
         """
-        Returns the list of record IDs which should be persisted to the API.
+        Build the list of record IDs which should be persisted to the API.
         """
-        if not value:
-            return []
         # If the _fields value contains str, it means we loaded it from the API
         # but we never actually accessed the value (see _get_list_value).
         # When persisting this model back to the API, we can just write those IDs.
@@ -887,3 +881,60 @@ class UrlField(TextField):
     cls: {key for (key, val) in FIELD_TYPES_TO_CLASSES.items() if val == cls}
     for cls in ALL_FIELDS
 }
+
+
+# Auto-generate __all__ to explicitly exclude any imported values
+r"""[[[cog]]]
+import re
+
+with open(cog.inFile) as fp:
+    src = fp.read()
+
+classes = re.findall(r"class ([A-Z]\w+Field)", src)
+constants = re.findall(r"^([A-Z][A-Z_+]) = ", src)
+extras = ["LinkSelf"]
+names = sorted(classes + constants + extras)
+
+cog.outl("\n\n__all__ = [")
+for name in names:
+    cog.outl(f'    "{name}",')
+cog.outl("]")
+[[[out]]]"""
+
+
+__all__ = [
+    "AITextField",
+    "AttachmentsField",
+    "AutoNumberField",
+    "BarcodeField",
+    "ButtonField",
+    "CheckboxField",
+    "CollaboratorField",
+    "CountField",
+    "CreatedByField",
+    "CreatedTimeField",
+    "CurrencyField",
+    "DateField",
+    "DatetimeField",
+    "DurationField",
+    "EmailField",
+    "ExternalSyncSourceField",
+    "FloatField",
+    "IntegerField",
+    "LastModifiedByField",
+    "LastModifiedTimeField",
+    "LinkField",
+    "LinkSelf",
+    "LookupField",
+    "MultipleCollaboratorsField",
+    "MultipleSelectField",
+    "NumberField",
+    "PercentField",
+    "PhoneNumberField",
+    "RatingField",
+    "RichTextField",
+    "SelectField",
+    "TextField",
+    "UrlField",
+]
+# [[[end]]] (checksum: 4722c0951e598ac999d3c16ebd3d8c1c)
diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py
index 97897382..f02e5d6c 100644
--- a/pyairtable/orm/model.py
+++ b/pyairtable/orm/model.py
@@ -85,7 +85,7 @@ def __repr__(self) -> str:
     @classmethod
     def _attribute_descriptor_map(cls) -> Dict[str, AnyField]:
         """
-        Returns a dictionary mapping the model's attribute names to the field's
+        Build a mapping of the model's attribute names to field descriptor instances.
 
         >>> class Test(Model):
         ...     first_name = TextField("First Name")
@@ -102,7 +102,7 @@ def _attribute_descriptor_map(cls) -> Dict[str, AnyField]:
     @classmethod
     def _field_name_descriptor_map(cls) -> Dict[FieldName, AnyField]:
         """
-        Returns a dictionary that maps field names to descriptor instances.
+        Build a mapping of the model's field names to field descriptor instances.
 
         >>> class Test(Model):
         ...     first_name = TextField("First Name")
@@ -116,26 +116,9 @@ def _field_name_descriptor_map(cls) -> Dict[FieldName, AnyField]:
         """
         return {f.field_name: f for f in cls._attribute_descriptor_map().values()}
 
-    @classmethod
-    def _field_name_attribute_map(cls) -> Dict[FieldName, str]:
-        """
-        Returns a dictionary that maps field names to attribute names.
-
-        >>> class Test(Model):
-        ...     first_name = TextField("First Name")
-        ...     age = NumberField("Age")
-        ...
-        >>> Test._field_name_attribute_map()
-        >>> {
-        ...     "First Name": "first_name"
-        ...     "Age": "age"
-        ... }
-        """
-        return {v.field_name: k for k, v in cls._attribute_descriptor_map().items()}
-
     def __init__(self, **fields: Any):
         """
-        Constructs a model instance with field values based on the given keyword args.
+        Construct a model instance with field values based on the given keyword args.
 
         >>> Contact(name="Alice", birthday=date(1980, 1, 1))
         <unsaved Contact>
@@ -215,12 +198,13 @@ def exists(self) -> bool:
 
     def save(self) -> bool:
         """
-        Saves or updates a model.
+        Save the model to the API.
 
         If the instance does not exist already, it will be created;
         otherwise, the existing record will be updated.
 
-        Returns ``True`` if a record was created and ``False`` if it was updated.
+        Returns:
+            ``True`` if a record was created, ``False`` if it was updated.
         """
         if self._deleted:
             raise RuntimeError(f"{self.id} was deleted")
@@ -240,7 +224,7 @@ def save(self) -> bool:
 
     def delete(self) -> bool:
         """
-        Deletes the record.
+        Delete the record.
 
         Raises:
             ValueError: if the record does not exist.
@@ -256,7 +240,7 @@ def delete(self) -> bool:
     @classmethod
     def all(cls, **kwargs: Any) -> List[SelfType]:
         """
-        Returns all records for this model. For all supported
+        Retrieve all records for this model. For all supported
         keyword arguments, see :meth:`Table.all <pyairtable.Table.all>`.
         """
         table = cls.get_table()
@@ -265,7 +249,7 @@ def all(cls, **kwargs: Any) -> List[SelfType]:
     @classmethod
     def first(cls, **kwargs: Any) -> Optional[SelfType]:
         """
-        Returns the first record for this model. For all supported
+        Retrieve the first record for this model. For all supported
         keyword arguments, see :meth:`Table.first <pyairtable.Table.first>`.
         """
         table = cls.get_table()
@@ -275,7 +259,8 @@ def first(cls, **kwargs: Any) -> Optional[SelfType]:
 
     def to_record(self, only_writable: bool = False) -> RecordDict:
         """
-        Returns a dictionary object as an Airtable record.
+        Build a :class:`~pyairtable.api.types.RecordDict` to represent this instance.
+
         This method converts internal field values into values expected by Airtable.
         For example, a ``datetime`` value from :class:`~pyairtable.orm.fields.DatetimeField`
         is converted into an ISO 8601 string.
@@ -335,7 +320,7 @@ def from_id(
 
     def fetch(self) -> None:
         """
-        Fetches field values from the API and resets instance field values.
+        Fetch field values from the API and resets instance field values.
         """
         if not self.id:
             raise ValueError("cannot be fetched because instance does not have an id")
@@ -378,7 +363,7 @@ def from_ids(
     @classmethod
     def batch_save(cls, models: List[SelfType]) -> None:
         """
-        Saves a list of model instances to the Airtable API with as few
+        Save a list of model instances to the Airtable API with as few
         network requests as possible. Can accept a mixture of new records
         (which have not been saved yet) and existing records that have IDs.
         """
@@ -408,7 +393,7 @@ def batch_save(cls, models: List[SelfType]) -> None:
     @classmethod
     def batch_delete(cls, models: List[SelfType]) -> None:
         """
-        Deletes a list of model instances from Airtable.
+        Delete a list of model instances from Airtable.
 
         Raises:
             ValueError: if the model has not been saved to Airtable.
diff --git a/pyairtable/utils.py b/pyairtable/utils.py
index 1ca55bd3..4c52b895 100644
--- a/pyairtable/utils.py
+++ b/pyairtable/utils.py
@@ -1,14 +1,23 @@
+import inspect
+import re
+import textwrap
 from datetime import date, datetime
-from typing import Iterator, Sequence, TypeVar, Union
+from functools import partial, wraps
+from typing import Any, Callable, Generic, Iterator, Sequence, TypeVar, Union, cast
+
+import requests
+from typing_extensions import ParamSpec, Protocol
 
 from pyairtable.api.types import CreateAttachmentDict
 
+P = ParamSpec("P")
+R = TypeVar("R", covariant=True)
 T = TypeVar("T")
 
 
 def datetime_to_iso_str(value: datetime) -> str:
     """
-    Converts ``datetime`` object into Airtable compatible ISO 8601 string
+    Convert ``datetime`` object into Airtable compatible ISO 8601 string
     e.g. "2014-09-05T12:34:56.000Z"
 
     Args:
@@ -29,7 +38,7 @@ def datetime_from_iso_str(value: str) -> datetime:
 
 def date_to_iso_str(value: Union[date, datetime]) -> str:
     """
-    Converts a ``date`` or ``datetime`` into an Airtable-compatible ISO 8601 string
+    Convert a ``date`` or ``datetime`` into an Airtable-compatible ISO 8601 string
 
     Args:
         value: date or datetime object, e.g. "2014-09-05"
@@ -39,7 +48,7 @@ def date_to_iso_str(value: Union[date, datetime]) -> str:
 
 def date_from_iso_str(value: str) -> date:
     """
-    Converts ISO 8601 date string into a ``date`` object.
+    Convert ISO 8601 date string into a ``date`` object.
 
     Args:
         value: date string, e.g. "2014-09-05"
@@ -49,7 +58,7 @@ def date_from_iso_str(value: str) -> date:
 
 def attachment(url: str, filename: str = "") -> CreateAttachmentDict:
     """
-    Returns a dictionary using the expected dictionary format for creating attachments.
+    Build a ``dict`` in the expected format for creating attachments.
 
     When creating an attachment, ``url`` is required, and ``filename`` is optional.
     Airtable will download the file at the given url and keep its own copy of it.
@@ -94,3 +103,108 @@ def chunked(iterable: Sequence[T], chunk_size: int) -> Iterator[Sequence[T]]:
     """
     for i in range(0, len(iterable), chunk_size):
         yield iterable[i : i + chunk_size]
+
+
+def is_airtable_id(value: Any, prefix: str = "") -> bool:
+    """
+    Check whether the given value is an Airtable ID.
+
+    Args:
+        value: The value to check.
+        prefix: If provided, the ID must have the given prefix.
+    """
+    if not isinstance(value, str):
+        return False
+    if prefix and not value.startswith(prefix):
+        return False
+    return len(value) == 17
+
+
+is_record_id = partial(is_airtable_id, prefix="rec")
+is_base_id = partial(is_airtable_id, prefix="app")
+is_table_id = partial(is_airtable_id, prefix="tbl")
+is_field_id = partial(is_airtable_id, prefix="fld")
+is_user_id = partial(is_airtable_id, prefix="usr")
+
+
+F = TypeVar("F", bound=Callable[..., Any])
+
+
+def enterprise_only(wrapped: F, /, modify_docstring: bool = True) -> F:
+    """
+    Wrap a function or method so that if Airtable returns a 404,
+    we will annotate the error with a helpful note to the user.
+    """
+
+    if modify_docstring:
+        _prepend_docstring_text(wrapped, "|enterprise_only|")
+
+    # Allow putting the decorator on a class
+    if inspect.isclass(wrapped):
+        for name, obj in vars(wrapped).items():
+            if inspect.isfunction(obj):
+                setattr(wrapped, name, enterprise_only(obj))
+        return cast(F, wrapped)
+
+    @wraps(wrapped)
+    def _decorated(*args: Any, **kwargs: Any) -> Any:
+        try:
+            return wrapped(*args, **kwargs)
+        except requests.exceptions.HTTPError as exc:
+            if exc.response is not None and exc.response.status_code == 404:
+                exc.args = (
+                    *exc.args,
+                    f"NOTE: {wrapped.__qualname__}() requires an enterprise billing plan.",
+                )
+            raise exc
+
+    return _decorated  # type: ignore[return-value]
+
+
+def _prepend_docstring_text(obj: Any, text: str) -> None:
+    if not (doc := obj.__doc__):
+        return
+    doc = doc.lstrip("\n")
+    if has_leading_spaces := re.match(r"^\s+", doc):
+        text = textwrap.indent(text, has_leading_spaces[0])
+    obj.__doc__ = f"{text}\n\n{doc}"
+
+
+def _append_docstring_text(obj: Any, text: str) -> None:
+    if not (doc := obj.__doc__):
+        return
+    doc = doc.rstrip("\n")
+    if has_leading_spaces := re.match(r"^\s+", doc):
+        text = textwrap.indent(text, has_leading_spaces[0])
+    obj.__doc__ = f"{doc}\n\n{text}"
+
+
+class FetchMethod(Protocol, Generic[R]):
+    def __get__(self, instance: Any, owner: Any) -> Callable[..., R]:
+        ...  # pragma: no cover
+
+    def __call__(self_, self: Any, *, force: bool = False) -> R:
+        ...  # pragma: no cover
+
+
+def cache_unless_forced(func: Callable[P, R]) -> FetchMethod[R]:
+    """
+    Wrap a method (e.g. ``Base.shares()``) in a decorator that will save
+    a memoized version of the return value for future reuse, but will also
+    allow callers to pass ``force=True`` to recompute the memoized version.
+    """
+
+    attr = f"_{func.__name__}"
+    if attr.startswith("__"):
+        attr = "_cached_" + attr.lstrip("_")
+
+    @wraps(func)
+    def _inner(self: Any, *, force: bool = False) -> R:
+        if force or getattr(self, attr, None) is None:
+            setattr(self, attr, func(self))
+        return cast(R, getattr(self, attr))
+
+    _inner.__annotations__["force"] = bool
+    _append_docstring_text(_inner, "Args:\n\tforce: |kwarg_force_metadata|")
+
+    return _inner
diff --git a/tox.ini b/tox.ini
index 58bdfffc..426fe49e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -43,7 +43,7 @@ basepython = python3.8
 deps =
     -r requirements-dev.txt
 commands =
-    python -m cogapp -r --verbosity=1 {toxinidir}/docs/source/*.rst
+    python -m cogapp -cr --verbosity=1 {toxinidir}/docs/source/*.rst
     python -m sphinx -T -E -b html {toxinidir}/docs/source {toxinidir}/docs/build
 
 [pytest]
@@ -53,7 +53,7 @@ markers =
 [flake8]
 filename = *.py
 count = True
-# Per Black Formmater Documentation
+# See https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html
 ignore = E203, E266, E501, W503
 select = B,C,E,F,W,T4,B9
 max-line-length = 88
@@ -70,3 +70,9 @@ omit =
     tests/*
     .venv/*
     .tox/*
+
+[coverage:report]
+# See https://github.com/nedbat/coveragepy/issues/970
+exclude_also =
+    @overload
+    if (typing\.)?TYPE_CHECKING:


Test patch:
diff --git a/pyairtable/testing.py b/pyairtable/testing.py
index 9cf0e04a..93f17cb0 100644
--- a/pyairtable/testing.py
+++ b/pyairtable/testing.py
@@ -11,7 +11,7 @@
 
 def fake_id(type: str = "rec", value: Any = None) -> str:
     """
-    Generates a fake Airtable-style ID.
+    Generate a fake Airtable-style ID.
 
     Args:
         type: the object type prefix, defaults to "rec"
@@ -35,7 +35,7 @@ def fake_meta(
     api_key: str = "patFakePersonalAccessToken",
 ) -> type:
     """
-    Returns a ``Meta`` class for inclusion in a ``Model`` subclass.
+    Generate a ``Meta`` class for inclusion in a ``Model`` subclass.
     """
     attrs = {"base_id": base_id, "table_name": table_name, "api_key": api_key}
     return type("Meta", (), attrs)
@@ -47,7 +47,7 @@ def fake_record(
     **other_fields: Any,
 ) -> RecordDict:
     """
-    Returns a fake record dict with the given field values.
+    Generate a fake record dict with the given field values.
 
     >>> fake_record({"Name": "Alice"})
     {'id': '...', 'createdTime': '...', 'fields': {'Name': 'Alice'}}
@@ -66,11 +66,24 @@ def fake_record(
 
 
 def fake_user(value: Any = None) -> CollaboratorDict:
+    """
+    Generate a fake user dict with the given value for an email prefix.
+
+    >>> fake_user("alice")
+    {'id': 'usr000000000Alice', 'email': 'alice@example.com', 'name': 'Fake User'}
+    """
     id = fake_id("usr", value)
-    return {"id": id, "email": f"{value or id}@example.com", "name": "Fake User"}
+    return {
+        "id": id,
+        "email": f"{value or id}@example.com",
+        "name": "Fake User",
+    }
 
 
 def fake_attachment() -> AttachmentDict:
+    """
+    Generate a fake attachment dict.
+    """
     return {
         "id": fake_id("att"),
         "url": "https://example.com/",
diff --git a/tests/conftest.py b/tests/conftest.py
index 5bf913c3..7bd9593f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -39,9 +39,14 @@ def api(constants) -> Api:
     return Api(constants["API_KEY"])
 
 
+@pytest.fixture
+def base_id(constants) -> str:
+    return constants["BASE_ID"]
+
+
 @pytest.fixture()
-def base(api: Api, constants) -> Base:
-    return api.base(constants["BASE_ID"])
+def base(api: Api, base_id) -> Base:
+    return api.base(base_id)
 
 
 @pytest.fixture()
diff --git a/tests/integration/test_integration_enterprise.py b/tests/integration/test_integration_enterprise.py
new file mode 100644
index 00000000..3fef91ea
--- /dev/null
+++ b/tests/integration/test_integration_enterprise.py
@@ -0,0 +1,101 @@
+import uuid
+
+import pytest
+from requests import HTTPError
+
+import pyairtable
+
+pytestmark = [pytest.mark.integration]
+
+
+@pytest.fixture
+def workspace_id():
+    return "wsp0HnyXmNnKzc5ng"
+
+
+@pytest.fixture
+def workspace(api: pyairtable.Api, workspace_id):
+    return api.workspace(workspace_id)
+
+
+@pytest.fixture(autouse=True)
+def confirm_enterprise_plan(workspace: pyairtable.Workspace):
+    try:
+        workspace.collaborators()
+    except HTTPError:
+        pytest.skip("This test requires creator access to an enterprise workspace")
+
+
+@pytest.fixture
+def blank_base(workspace: pyairtable.Workspace):
+    base = workspace.create_base(
+        f"Test {uuid.uuid1().hex}",
+        [{"name": "One", "fields": [{"type": "singleLineText", "name": "Label"}]}],
+    )
+    try:
+        yield base
+    finally:
+        base.delete()
+
+
+def test_create_table(blank_base: pyairtable.Base):
+    """
+    Test that we can create a new table on an existing base.
+    """
+    table = blank_base.create_table("Two", [{"type": "singleLineText", "name": "Name"}])
+    assert table.schema().field("Name").type == "singleLineText"
+
+
+def test_update_table(blank_base: pyairtable.Base):
+    """
+    Test that we can modify a table's name and description.
+    """
+    new_name = f"Renamed {uuid.uuid1().hex[-6:]}"
+    schema = blank_base.schema().tables[0]
+    schema.name = new_name
+    schema.save()
+    assert blank_base.schema(force=True).tables[0].name == new_name
+    schema.description = "Renamed"
+    schema.save()
+    assert blank_base.schema(force=True).tables[0].description == "Renamed"
+
+
+def test_create_field(blank_base: pyairtable.Base):
+    """
+    Test that we can create a new field on an existing table.
+    """
+    table = blank_base.tables()[0]
+    assert len(table.schema().fields) == 1
+    fld = table.create_field(
+        "Status",
+        type="singleSelect",
+        options={
+            "choices": [
+                {"name": "Todo"},
+                {"name": "In Progress"},
+                {"name": "Done"},
+            ]
+        },
+    )
+    # Ensure we don't need to reload the schema to see this new field
+    assert table._schema.field(fld.id).name == "Status"
+
+
+def test_update_field(blank_base: pyairtable.Base):
+    """
+    Test that we can modify a field's name and description.
+    """
+
+    def reload_field():
+        return blank_base.schema(force=True).tables[0].fields[0]
+
+    field = reload_field()
+
+    new_name = f"Renamed {uuid.uuid1().hex[-6:]}"
+    field.name = new_name
+    field.save()
+    assert reload_field().name == new_name
+
+    field.description = "Renamed"
+    field.save()
+    assert reload_field().description == "Renamed"
diff --git a/tests/integration/test_integration_metadata.py b/tests/integration/test_integration_metadata.py
index d1f25d60..96316821 100644
--- a/tests/integration/test_integration_metadata.py
+++ b/tests/integration/test_integration_metadata.py
@@ -1,18 +1,53 @@
 import pytest
+import requests
 
-from pyairtable import Base, Table
+from pyairtable import Api, Base, Table
 from pyairtable.metadata import get_api_bases, get_base_schema, get_table_schema
 
 pytestmark = [pytest.mark.integration]
 
 
-def test_get_api_bases(base: Base, base_name: str):
-    rv = get_api_bases(base)
+def test_api_bases(api: Api, base_id: str, base_name: str, table_name: str):
+    bases = {base.id: base for base in api.bases()}
+    assert bases[base_id].name == base_name
+    assert bases[base_id].table(table_name).name == table_name
+
+
+def test_api_base(api: Api, base_id: str, base_name: str):
+    base = api.base(base_id, validate=True)
+    assert base.name == base_name
+
+
+def test_base_info(base: Base):
+    with pytest.raises(
+        requests.HTTPError,
+        match=r"Base.collaborators\(\) requires an enterprise billing plan",
+    ):
+        base.collaborators()
+
+
+def test_base_schema(base: Base, table_name: str):
+    schema = base.schema()
+    assert table_name in [t.name for t in schema.tables]
+    assert schema.table(table_name).name == table_name
+
+
+def test_table_schema(base: Base, table_name: str, cols):
+    schema = base.table(table_name).schema()
+    assert cols.TEXT in [f.name for f in schema.fields]
+    assert schema.field(cols.TEXT).id == cols.TEXT_ID
+    assert schema.field(cols.TEXT_ID).name == cols.TEXT
+
+
+def test_deprecated_get_api_bases(base: Base, base_name: str):
+    with pytest.warns(DeprecationWarning):
+        rv = get_api_bases(base)
     assert base_name in [b["name"] for b in rv["bases"]]
 
 
-def test_get_base_schema(base: Base):
-    rv = get_base_schema(base)
+def test_deprecated_get_base_schema(base: Base):
+    with pytest.warns(DeprecationWarning):
+        rv = get_base_schema(base)
     assert sorted(table["name"] for table in rv["tables"]) == [
         "Address",
         "Contact",
@@ -21,11 +56,13 @@ def test_get_base_schema(base: Base):
     ]
 
 
-def test_get_table_schema(table: Table):
-    rv = get_table_schema(table)
+def test_deprecated_get_table_schema(table: Table):
+    with pytest.warns(DeprecationWarning):
+        rv = get_table_schema(table)
     assert rv and rv["name"] == table.name
 
 
-def test_get_table_schema__invalid_table(table, monkeypatch):
+def test_deprecated_get_table_schema__invalid_table(table, monkeypatch):
     monkeypatch.setattr(table, "name", "DoesNotExist")
-    assert get_table_schema(table) is None
+    with pytest.warns(DeprecationWarning):
+        assert get_table_schema(table) is None
diff --git a/tests/integration/test_integration_orm.py b/tests/integration/test_integration_orm.py
index 1d064ee7..6300f34e 100644
--- a/tests/integration/test_integration_orm.py
+++ b/tests/integration/test_integration_orm.py
@@ -180,7 +180,7 @@ def test_every_field(Everything):
         type(field) for field in vars(Everything).values() if isinstance(field, f.Field)
     }
     for field_class in f.ALL_FIELDS:
-        if field_class in {f.ExternalSyncSourceField}:
+        if field_class in {f.ExternalSyncSourceField, f.AITextField}:
             continue
         assert field_class in classes_used
 
diff --git a/tests/sample_data/BaseCollaborators.json b/tests/sample_data/BaseCollaborators.json
new file mode 100644
index 00000000..414aecf9
--- /dev/null
+++ b/tests/sample_data/BaseCollaborators.json
@@ -0,0 +1,93 @@
+{
+  "collaborators": {
+    "baseCollaborators": [
+      {
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "email": "foo@bam.com",
+        "grantedByUserId": "usrL2PNC5o3H4lBEi",
+        "permissionLevel": "create",
+        "userId": "usrsOEchC9xuwRgKk"
+      }
+    ],
+    "workspaceCollaborators": [
+      {
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "email": "foo@bar.com",
+        "grantedByUserId": "usrL2PNC5o3H4lBEi",
+        "permissionLevel": "owner",
+        "userId": "usrL2PNC5o3H4lBEi"
+      }
+    ]
+  },
+  "createdTime": "2019-01-03T12:33:12.421Z",
+  "groupCollaborators": {
+    "baseCollaborators": [
+      {
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "grantedByUserId": "usrL2PNC5o3H4lBEi",
+        "groupId": "ugpR8ZT9KtIgp8Bh3",
+        "name": "group 2",
+        "permissionLevel": "create"
+      }
+    ],
+    "workspaceCollaborators": [
+      {
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "grantedByUserId": "usrL2PNC5o3H4lBEi",
+        "groupId": "ugp1mKGb3KXUyQfOZ",
+        "name": "group 1",
+        "permissionLevel": "edit"
+      }
+    ]
+  },
+  "id": "appLkNDICXNqxSDhG",
+  "individualCollaborators": {
+    "baseCollaborators": [
+      {
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "email": "foo@bam.com",
+        "grantedByUserId": "usrL2PNC5o3H4lBEi",
+        "permissionLevel": "create",
+        "userId": "usrsOEchC9xuwRgKk"
+      }
+    ],
+    "workspaceCollaborators": [
+      {
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "email": "foo@bar.com",
+        "grantedByUserId": "usrL2PNC5o3H4lBEi",
+        "permissionLevel": "owner",
+        "userId": "usrL2PNC5o3H4lBEi"
+      }
+    ]
+  },
+  "inviteLinks": {
+    "baseInviteLinks": [
+      {
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "id": "invJiqaXmPqq6Ec87",
+        "invitedEmail": null,
+        "permissionLevel": "read",
+        "referredByUserId": "usrsOEchC9xuwRgKk",
+        "restrictedToEmailDomains": [
+          "bam.com"
+        ],
+        "type": "multiUse"
+      }
+    ],
+    "workspaceInviteLinks": [
+      {
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "id": "invJiqaXmPqq6Ec87",
+        "invitedEmail": "bam@bam.com",
+        "permissionLevel": "edit",
+        "referredByUserId": "usrL2PNC5o3H4lBEi",
+        "restrictedToEmailDomains": [],
+        "type": "singleUse"
+      }
+    ]
+  },
+  "name": "my first base",
+  "permissionLevel": "none",
+  "workspaceId": "wspmhESAta6clCCwF"
+}
diff --git a/tests/sample_data/BaseSchema.json b/tests/sample_data/BaseSchema.json
new file mode 100644
index 00000000..993cae6a
--- /dev/null
+++ b/tests/sample_data/BaseSchema.json
@@ -0,0 +1,72 @@
+{
+  "tables": [
+    {
+      "description": "Apartments to track.",
+      "fields": [
+        {
+          "description": "Name of the apartment",
+          "id": "fld1VnoyuotSTyxW1",
+          "name": "Name",
+          "type": "singleLineText"
+        },
+        {
+          "id": "fldoaIqdn5szURHpw",
+          "name": "Pictures",
+          "type": "multipleAttachments"
+        },
+        {
+          "id": "fldumZe00w09RYTW6",
+          "name": "District",
+          "options": {
+            "inverseLinkFieldId": "fldWnCJlo2z6ttT8Y",
+            "isReversed": false,
+            "linkedTableId": "tblK6MZHez0ZvBChZ",
+            "prefersSingleRecordLink": true
+          },
+          "type": "multipleRecordLinks"
+        }
+      ],
+      "id": "tbltp8DGLhqbUmjK1",
+      "name": "Apartments",
+      "primaryFieldId": "fld1VnoyuotSTyxW1",
+      "views": [
+        {
+          "id": "viwQpsuEDqHFqegkp",
+          "name": "Grid view",
+          "type": "grid"
+        }
+      ]
+    },
+    {
+      "fields": [
+        {
+          "id": "fldEVzvQOoULO38yl",
+          "name": "Name",
+          "type": "singleLineText"
+        },
+        {
+          "description": "Apartments that belong to this district",
+          "id": "fldWnCJlo2z6ttT8Y",
+          "name": "Apartments",
+          "options": {
+            "inverseLinkFieldId": "fldumZe00w09RYTW6",
+            "isReversed": false,
+            "linkedTableId": "tbltp8DGLhqbUmjK1",
+            "prefersSingleRecordLink": false
+          },
+          "type": "multipleRecordLinks"
+        }
+      ],
+      "id": "tblK6MZHez0ZvBChZ",
+      "name": "Districts",
+      "primaryFieldId": "fldEVzvQOoULO38yl",
+      "views": [
+        {
+          "id": "viwi3KXvrKug2mIBS",
+          "name": "Grid view",
+          "type": "grid"
+        }
+      ]
+    }
+  ]
+}
diff --git a/tests/sample_data/BaseShares.json b/tests/sample_data/BaseShares.json
new file mode 100644
index 00000000..20b67ebb
--- /dev/null
+++ b/tests/sample_data/BaseShares.json
@@ -0,0 +1,41 @@
+{
+  "shares": [
+    {
+      "createdByUserId": "usrL2PNC5o3H4lBEi",
+      "createdTime": "2019-01-01T00:00:00.000Z",
+      "effectiveEmailDomainAllowList": [
+        "foobar.com"
+      ],
+      "isPasswordProtected": true,
+      "restrictedToEmailDomains": [
+        "foobar.com"
+      ],
+      "shareId": "shr9SpczJvQpfAzSp",
+      "shareTokenPrefix": "shr9Spcz",
+      "state": "enabled",
+      "type": "base"
+    },
+    {
+      "createdByUserId": "usrL2PNC5o3H4lBEi",
+      "createdTime": "2019-01-01T00:00:00.000Z",
+      "isPasswordProtected": false,
+      "restrictedToEmailDomains": [],
+      "shareId": "shrMg5vs9SpczJvQp",
+      "shareTokenPrefix": "shrMg5vs",
+      "state": "disabled",
+      "type": "view",
+      "viewId": "viwQpsuEDqHFqegkp"
+    },
+    {
+      "blockInstallationId": "bliXyN0Q6zfajnDOG",
+      "createdByUserId": "usrL2PNC5o3H4lBEi",
+      "createdTime": "2019-01-01T00:00:00.000Z",
+      "isPasswordProtected": false,
+      "restrictedToEmailDomains": [],
+      "shareId": "shrjjKdhMg5vs9Spc",
+      "shareTokenPrefix": "shrjjKdh",
+      "state": "disabled",
+      "type": "blockInstallation"
+    }
+  ]
+}
diff --git a/tests/sample_data/Bases.json b/tests/sample_data/Bases.json
new file mode 100644
index 00000000..ad865743
--- /dev/null
+++ b/tests/sample_data/Bases.json
@@ -0,0 +1,14 @@
+{
+  "bases": [
+    {
+      "id": "appLkNDICXNqxSDhG",
+      "name": "Apartment Hunting",
+      "permissionLevel": "create"
+    },
+    {
+      "id": "appSW9R5uCNmRmfl6",
+      "name": "Project Tracker",
+      "permissionLevel": "edit"
+    }
+  ]
+}
diff --git a/tests/sample_data/EnterpriseInfo.json b/tests/sample_data/EnterpriseInfo.json
new file mode 100644
index 00000000..02ebde50
--- /dev/null
+++ b/tests/sample_data/EnterpriseInfo.json
@@ -0,0 +1,23 @@
+{
+  "createdTime": "2019-01-03T12:33:12.421Z",
+  "emailDomains": [
+    {
+      "emailDomain": "foobar.com",
+      "isSsoRequired": true
+    }
+  ],
+  "groupIds": [
+    "ugp1mKGb3KXUyQfOZ",
+    "ugpR8ZT9KtIgp8Bh3"
+  ],
+  "id": "entUBq2RGdihxl3vU",
+  "userIds": [
+    "usrL2PNC5o3H4lBEi",
+    "usrsOEchC9xuwRgKk",
+    "usrGcrteE5fUMqq0R"
+  ],
+  "workspaceIds": [
+    "wspmhESAta6clCCwF",
+    "wspHvvm4dAktsStZH"
+  ]
+}
diff --git a/tests/sample_data/TableSchema.json b/tests/sample_data/TableSchema.json
new file mode 100644
index 00000000..ddeeb3c1
--- /dev/null
+++ b/tests/sample_data/TableSchema.json
@@ -0,0 +1,37 @@
+{
+  "description": "Apartments to track.",
+  "fields": [
+    {
+      "description": "Name of the apartment",
+      "id": "fld1VnoyuotSTyxW1",
+      "name": "Name",
+      "type": "singleLineText"
+    },
+    {
+      "id": "fldoaIqdn5szURHpw",
+      "name": "Pictures",
+      "type": "multipleAttachments"
+    },
+    {
+      "id": "fldumZe00w09RYTW6",
+      "name": "District",
+      "options": {
+        "inverseLinkFieldId": "fldWnCJlo2z6ttT8Y",
+        "isReversed": false,
+        "linkedTableId": "tblK6MZHez0ZvBChZ",
+        "prefersSingleRecordLink": true
+      },
+      "type": "multipleRecordLinks"
+    }
+  ],
+  "id": "tbltp8DGLhqbUmjK1",
+  "name": "Apartments",
+  "primaryFieldId": "fld1VnoyuotSTyxW1",
+  "views": [
+    {
+      "id": "viwQpsuEDqHFqegkp",
+      "name": "Grid view",
+      "type": "grid"
+    }
+  ]
+}
diff --git a/tests/sample_data/UserGroup.json b/tests/sample_data/UserGroup.json
new file mode 100644
index 00000000..53518009
--- /dev/null
+++ b/tests/sample_data/UserGroup.json
@@ -0,0 +1,60 @@
+{
+  "collaborations": {
+    "baseCollaborations": [
+      {
+        "baseId": "appLkNDICXNqxSDhG",
+        "createdTime": "2021-06-02T07:37:50.000Z",
+        "grantedByUserId": "usrogvSbotRtzdtZW",
+        "permissionLevel": "create"
+      }
+    ],
+    "interfaceCollaborations": [
+      {
+        "baseId": "appLkNDICXNqxSDhG",
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "grantedByUserId": "usrqccqnMB2eHylqB",
+        "interfaceId": "pbdyGA3PsOziEHPDE",
+        "permissionLevel": "edit"
+      }
+    ],
+    "workspaceCollaborations": [
+      {
+        "createdTime": "2021-06-02T07:37:48.000Z",
+        "grantedByUserId": "usrqccqnMB2eHylqB",
+        "permissionLevel": "edit",
+        "workspaceId": "wspmhESAta6clCCwF"
+      }
+    ]
+  },
+  "createdTime": "2021-06-02T07:37:19.000Z",
+  "enterpriseAccountId": "entUBq2RGdihxl3vU",
+  "id": "ugp1mKGb3KXUyQfOZ",
+  "members": [
+    {
+      "createdTime": "2021-06-02T07:37:19.000Z",
+      "email": "foo@bar.com",
+      "firstName": "Jane",
+      "lastName": "Doe",
+      "role": "member",
+      "userId": "usrL2PNC5o3H4lBEi"
+    },
+    {
+      "createdTime": "2021-06-02T07:37:19.000Z",
+      "email": "foo@bam.com",
+      "firstName": "Alex",
+      "lastName": "Hay",
+      "role": "manager",
+      "userId": "usrsOEchC9xuwRgKk"
+    },
+    {
+      "createdTime": "2021-06-02T07:37:19.000Z",
+      "email": "bam@bam.com",
+      "firstName": "John",
+      "lastName": "Dane",
+      "role": "member",
+      "userId": "usrGcrteE5fUMqq0R"
+    }
+  ],
+  "name": "Group name",
+  "updatedTime": "2022-09-02T10:10:35:000Z"
+}
diff --git a/tests/sample_data/UserInfo.json b/tests/sample_data/UserInfo.json
new file mode 100644
index 00000000..2097ffe3
--- /dev/null
+++ b/tests/sample_data/UserInfo.json
@@ -0,0 +1,47 @@
+{
+  "collaborations": {
+    "baseCollaborations": [
+      {
+        "baseId": "appLkNDICXNqxSDhG",
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "grantedByUserId": "usrqccqnMB2eHylqB",
+        "permissionLevel": "edit"
+      }
+    ],
+    "interfaceCollaborations": [
+      {
+        "baseId": "appLkNDICXNqxSDhG",
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "grantedByUserId": "usrqccqnMB2eHylqB",
+        "interfaceId": "pbdyGA3PsOziEHPDE",
+        "permissionLevel": "edit"
+      }
+    ],
+    "workspaceCollaborations": [
+      {
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "grantedByUserId": "usrGcrteE5fUMqq0R",
+        "permissionLevel": "owner",
+        "workspaceId": "wspmhESAta6clCCwF"
+      }
+    ]
+  },
+  "createdTime": "2019-01-03T12:33:12.421Z",
+  "email": "foo@bar.com",
+  "groups": [
+    {
+      "id": "ugp1mKGb3KXUyQfOZ"
+    },
+    {
+      "id": "ugpR8ZT9KtIgp8Bh3"
+    }
+  ],
+  "id": "usrL2PNC5o3H4lBEi",
+  "invitedToAirtableByUserId": "usrsOEchC9xuwRgKk",
+  "isManaged": true,
+  "isSsoRequired": true,
+  "isTwoFactorAuthEnabled": false,
+  "lastActivityTime": "2019-01-03T12:33:12.421Z",
+  "name": "foo baz",
+  "state": "provisioned"
+}
diff --git a/tests/sample_data/ViewSchema.json b/tests/sample_data/ViewSchema.json
new file mode 100644
index 00000000..e5530fd0
--- /dev/null
+++ b/tests/sample_data/ViewSchema.json
@@ -0,0 +1,12 @@
+
+{
+  "id": "viwQpsuEDqHFqegkp",
+  "name": "My Grid View",
+  "personalForUserId": "usrL2PNC5o3H4lBEi",
+  "type": "grid",
+  "visibleFieldIds": [
+    "fldL2PNC5o3H4lBE1",
+    "fldL2PNC5o3H4lBE2",
+    "fldL2PNC5o3H4lBE3"
+  ]
+}
diff --git a/tests/sample_data/WorkspaceCollaborators.json b/tests/sample_data/WorkspaceCollaborators.json
new file mode 100644
index 00000000..ca14986f
--- /dev/null
+++ b/tests/sample_data/WorkspaceCollaborators.json
@@ -0,0 +1,103 @@
+{
+  "baseIds": [
+    "appLkNDICXNqxSDhG",
+    "appSW9R5uCNmRmfl6"
+  ],
+  "collaborators": {
+    "baseCollaborators": [
+      {
+        "baseId": "appLkNDICXNqxSDhG",
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "email": "foo@bam.com",
+        "grantedByUserId": "usrL2PNC5o3H4lBEi",
+        "permissionLevel": "create",
+        "userId": "usrsOEchC9xuwRgKk"
+      }
+    ],
+    "workspaceCollaborators": [
+      {
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "email": "foo@bar.com",
+        "grantedByUserId": "usrL2PNC5o3H4lBEi",
+        "permissionLevel": "owner",
+        "userId": "usrL2PNC5o3H4lBEi"
+      }
+    ]
+  },
+  "createdTime": "2019-01-03T12:33:12.421Z",
+  "groupCollaborators": {
+    "baseCollaborators": [
+      {
+        "baseId": "appLkNDICXNqxSDhG",
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "grantedByUserId": "usrL2PNC5o3H4lBEi",
+        "groupId": "ugpR8ZT9KtIgp8Bh3",
+        "name": "group 2",
+        "permissionLevel": "create"
+      }
+    ],
+    "workspaceCollaborators": [
+      {
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "grantedByUserId": "usrL2PNC5o3H4lBEi",
+        "groupId": "ugp1mKGb3KXUyQfOZ",
+        "name": "group 1",
+        "permissionLevel": "edit"
+      }
+    ]
+  },
+  "id": "wspmhESAta6clCCwF",
+  "individualCollaborators": {
+    "baseCollaborators": [
+      {
+        "baseId": "appLkNDICXNqxSDhG",
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "email": "foo@bam.com",
+        "grantedByUserId": "usrL2PNC5o3H4lBEi",
+        "permissionLevel": "create",
+        "userId": "usrsOEchC9xuwRgKk"
+      }
+    ],
+    "workspaceCollaborators": [
+      {
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "email": "foo@bar.com",
+        "grantedByUserId": "usrL2PNC5o3H4lBEi",
+        "permissionLevel": "owner",
+        "userId": "usrL2PNC5o3H4lBEi"
+      }
+    ]
+  },
+  "inviteLinks": {
+    "baseInviteLinks": [
+      {
+        "baseId": "appSW9R5uCNmRmfl6",
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "id": "invJiqaXmPqq6Ec87",
+        "invitedEmail": null,
+        "permissionLevel": "read",
+        "referredByUserId": "usrsOEchC9xuwRgKk",
+        "restrictedToEmailDomains": [],
+        "type": "multiUse"
+      }
+    ],
+    "workspaceInviteLinks": [
+      {
+        "createdTime": "2019-01-03T12:33:12.421Z",
+        "id": "invJiqaXmPqq6Ec87",
+        "invitedEmail": "bam@bam.com",
+        "permissionLevel": "owner",
+        "referredByUserId": "usrL2PNC5o3H4lBEi",
+        "restrictedToEmailDomains": [
+          "foobar.com"
+        ],
+        "type": "singleUse"
+      }
+    ]
+  },
+  "name": "my first workspace",
+  "workspaceRestrictions": {
+    "inviteCreationRestriction": "onlyOwners",
+    "shareCreationRestriction": "unrestricted"
+  }
+}
diff --git a/tests/sample_data/field_schema/AITextFieldSchema.json b/tests/sample_data/field_schema/AITextFieldSchema.json
new file mode 100644
index 00000000..76285c06
--- /dev/null
+++ b/tests/sample_data/field_schema/AITextFieldSchema.json
@@ -0,0 +1,13 @@
+{
+  "type": "aiText",
+  "id": "fld8cfZQtRNaiText",
+  "name": "AI Text",
+  "options": {
+    "prompt": [
+      {
+        "field": {"fieldId": "fldEHLmq3SvZCvgOT"}
+      }
+    ],
+    "referencedFieldIds": ["fldEHLmq3SvZCvgOT"]
+  }
+}
diff --git a/tests/sample_data/field_schema/AutoNumberFieldSchema.json b/tests/sample_data/field_schema/AutoNumberFieldSchema.json
new file mode 100644
index 00000000..7bd1fea3
--- /dev/null
+++ b/tests/sample_data/field_schema/AutoNumberFieldSchema.json
@@ -0,0 +1,5 @@
+{
+  "type": "autoNumber",
+  "id": "fldIbDLEAaZTbedTy",
+  "name": "Autonumber"
+}
diff --git a/tests/sample_data/field_schema/BarcodeFieldSchema.json b/tests/sample_data/field_schema/BarcodeFieldSchema.json
new file mode 100644
index 00000000..aa16f858
--- /dev/null
+++ b/tests/sample_data/field_schema/BarcodeFieldSchema.json
@@ -0,0 +1,5 @@
+{
+  "type": "barcode",
+  "id": "flduAhdFgj0sUOiJ6",
+  "name": "Barcode"
+}
diff --git a/tests/sample_data/field_schema/ButtonFieldSchema.json b/tests/sample_data/field_schema/ButtonFieldSchema.json
new file mode 100644
index 00000000..d4800ab1
--- /dev/null
+++ b/tests/sample_data/field_schema/ButtonFieldSchema.json
@@ -0,0 +1,5 @@
+{
+  "type": "button",
+  "id": "fldPamhjpzu0W8VQU",
+  "name": "Open URL"
+}
diff --git a/tests/sample_data/field_schema/CheckboxFieldSchema.json b/tests/sample_data/field_schema/CheckboxFieldSchema.json
new file mode 100644
index 00000000..ed50f53b
--- /dev/null
+++ b/tests/sample_data/field_schema/CheckboxFieldSchema.json
@@ -0,0 +1,9 @@
+{
+  "type": "checkbox",
+  "options": {
+    "icon": "check",
+    "color": "greenBright"
+  },
+  "id": "fldxX0QdH2ROzqDS9",
+  "name": "Done"
+}
diff --git a/tests/sample_data/field_schema/CountFieldSchema.json b/tests/sample_data/field_schema/CountFieldSchema.json
new file mode 100644
index 00000000..c146153b
--- /dev/null
+++ b/tests/sample_data/field_schema/CountFieldSchema.json
@@ -0,0 +1,9 @@
+{
+  "type": "count",
+  "options": {
+    "isValid": true,
+    "recordLinkFieldId": "fldNvFMYxBnf35WkO"
+  },
+  "id": "fldVCMT5NmDVX2IyW",
+  "name": "Link to Self (Count)"
+}
diff --git a/tests/sample_data/field_schema/CreatedByFieldSchema.json b/tests/sample_data/field_schema/CreatedByFieldSchema.json
new file mode 100644
index 00000000..aaaec311
--- /dev/null
+++ b/tests/sample_data/field_schema/CreatedByFieldSchema.json
@@ -0,0 +1,5 @@
+{
+  "type": "createdBy",
+  "id": "fldq5hwHSdDpt77oh",
+  "name": "Created By"
+}
diff --git a/tests/sample_data/field_schema/CreatedTimeFieldSchema.json b/tests/sample_data/field_schema/CreatedTimeFieldSchema.json
new file mode 100644
index 00000000..2f1c4f1e
--- /dev/null
+++ b/tests/sample_data/field_schema/CreatedTimeFieldSchema.json
@@ -0,0 +1,21 @@
+{
+  "type": "createdTime",
+  "options": {
+    "result": {
+      "type": "dateTime",
+      "options": {
+        "dateFormat": {
+          "name": "local",
+          "format": "l"
+        },
+        "timeFormat": {
+          "name": "12hour",
+          "format": "h:mma"
+        },
+        "timeZone": "client"
+      }
+    }
+  },
+  "id": "fldnejiVookFfecbE",
+  "name": "Created"
+}
diff --git a/tests/sample_data/field_schema/CurrencyFieldSchema.json b/tests/sample_data/field_schema/CurrencyFieldSchema.json
new file mode 100644
index 00000000..f80cc0cf
--- /dev/null
+++ b/tests/sample_data/field_schema/CurrencyFieldSchema.json
@@ -0,0 +1,9 @@
+{
+  "type": "currency",
+  "options": {
+    "precision": 2,
+    "symbol": "$"
+  },
+  "id": "fldotSLYreNpZ1oD3",
+  "name": "Dollars"
+}
diff --git a/tests/sample_data/field_schema/DateFieldSchema.json b/tests/sample_data/field_schema/DateFieldSchema.json
new file mode 100644
index 00000000..1ef04774
--- /dev/null
+++ b/tests/sample_data/field_schema/DateFieldSchema.json
@@ -0,0 +1,11 @@
+{
+  "type": "date",
+  "options": {
+    "dateFormat": {
+      "name": "local",
+      "format": "l"
+    }
+  },
+  "id": "fldgstMCbpPNgbXBN",
+  "name": "Date"
+}
diff --git a/tests/sample_data/field_schema/DateTimeFieldSchema.json b/tests/sample_data/field_schema/DateTimeFieldSchema.json
new file mode 100644
index 00000000..ca046956
--- /dev/null
+++ b/tests/sample_data/field_schema/DateTimeFieldSchema.json
@@ -0,0 +1,16 @@
+{
+  "type": "dateTime",
+  "options": {
+    "dateFormat": {
+      "name": "local",
+      "format": "l"
+    },
+    "timeFormat": {
+      "name": "12hour",
+      "format": "h:mma"
+    },
+    "timeZone": "client"
+  },
+  "id": "fldVK2DrQVUZDYfvu",
+  "name": "DateTime"
+}
diff --git a/tests/sample_data/field_schema/DurationFieldSchema.json b/tests/sample_data/field_schema/DurationFieldSchema.json
new file mode 100644
index 00000000..3369abc1
--- /dev/null
+++ b/tests/sample_data/field_schema/DurationFieldSchema.json
@@ -0,0 +1,8 @@
+{
+  "type": "duration",
+  "options": {
+    "durationFormat": "h:mm:ss.S"
+  },
+  "id": "fldiHUj34Rtni86oW",
+  "name": "Duration (h:mm:ss.s)"
+}
diff --git a/tests/sample_data/field_schema/EmailFieldSchema.json b/tests/sample_data/field_schema/EmailFieldSchema.json
new file mode 100644
index 00000000..c394932b
--- /dev/null
+++ b/tests/sample_data/field_schema/EmailFieldSchema.json
@@ -0,0 +1,5 @@
+{
+  "type": "email",
+  "id": "fldXk4Av7rZVXhjYW",
+  "name": "Email"
+}
diff --git a/tests/sample_data/field_schema/ExternalSyncSourceFieldSchema.json b/tests/sample_data/field_schema/ExternalSyncSourceFieldSchema.json
new file mode 100644
index 00000000..54b61de4
--- /dev/null
+++ b/tests/sample_data/field_schema/ExternalSyncSourceFieldSchema.json
@@ -0,0 +1,24 @@
+{
+  "type": "externalSyncSource",
+  "options": {
+    "choices": [
+      {
+        "id": "selX4cKixuDso5PEX",
+        "name": "One",
+        "color": "blueLight2"
+      },
+      {
+        "id": "seloqP1Gzj8Glrspo",
+        "name": "Two",
+        "color": "cyanLight2"
+      },
+      {
+        "id": "selqPFVai30QE6Kp8",
+        "name": "Three",
+        "color": "tealLight2"
+      }
+    ]
+  },
+  "id": "fldI5gjLnq0RXUleV",
+  "name": "Sync Source"
+}
diff --git a/tests/sample_data/field_schema/FormulaFieldSchema.json b/tests/sample_data/field_schema/FormulaFieldSchema.json
new file mode 100644
index 00000000..509c7bba
--- /dev/null
+++ b/tests/sample_data/field_schema/FormulaFieldSchema.json
@@ -0,0 +1,18 @@
+{
+  "type": "formula",
+  "options": {
+    "formula": "{fldgstMCbpPNgbXBN} + \"FOO\"",
+    "isValid": true,
+    "referencedFieldIds": [
+      "fldgstMCbpPNgbXBN"
+    ],
+    "result": {
+      "type": "number",
+      "options": {
+        "precision": 0
+      }
+    }
+  },
+  "id": "fldBeqw3negrW9MMK",
+  "name": "Formula NaN"
+}
diff --git a/tests/sample_data/field_schema/LastModifiedByFieldSchema.json b/tests/sample_data/field_schema/LastModifiedByFieldSchema.json
new file mode 100644
index 00000000..580d56e3
--- /dev/null
+++ b/tests/sample_data/field_schema/LastModifiedByFieldSchema.json
@@ -0,0 +1,5 @@
+{
+  "type": "lastModifiedBy",
+  "id": "fld3oKAJRAQAqHe25",
+  "name": "Last Modified By"
+}
diff --git a/tests/sample_data/field_schema/LastModifiedTimeFieldSchema.json b/tests/sample_data/field_schema/LastModifiedTimeFieldSchema.json
new file mode 100644
index 00000000..090b608c
--- /dev/null
+++ b/tests/sample_data/field_schema/LastModifiedTimeFieldSchema.json
@@ -0,0 +1,23 @@
+{
+  "type": "lastModifiedTime",
+  "options": {
+    "isValid": true,
+    "referencedFieldIds": [],
+    "result": {
+      "type": "dateTime",
+      "options": {
+        "dateFormat": {
+          "name": "local",
+          "format": "l"
+        },
+        "timeFormat": {
+          "name": "12hour",
+          "format": "h:mma"
+        },
+        "timeZone": "client"
+      }
+    }
+  },
+  "id": "fldoGZqYuPMGHyt51",
+  "name": "Last Modified"
+}
diff --git a/tests/sample_data/field_schema/MultilineTextFieldSchema.json b/tests/sample_data/field_schema/MultilineTextFieldSchema.json
new file mode 100644
index 00000000..fce38c94
--- /dev/null
+++ b/tests/sample_data/field_schema/MultilineTextFieldSchema.json
@@ -0,0 +1,5 @@
+{
+  "type": "multilineText",
+  "id": "fldEHLmq3SvZCvgOT",
+  "name": "Notes"
+}
diff --git a/tests/sample_data/field_schema/MultipleAttachmentsFieldSchema.json b/tests/sample_data/field_schema/MultipleAttachmentsFieldSchema.json
new file mode 100644
index 00000000..05d321c6
--- /dev/null
+++ b/tests/sample_data/field_schema/MultipleAttachmentsFieldSchema.json
@@ -0,0 +1,8 @@
+{
+  "type": "multipleAttachments",
+  "options": {
+    "isReversed": false
+  },
+  "id": "fldDACsrCOBiPrlZv",
+  "name": "Attachments"
+}
diff --git a/tests/sample_data/field_schema/MultipleCollaboratorsFieldSchema.json b/tests/sample_data/field_schema/MultipleCollaboratorsFieldSchema.json
new file mode 100644
index 00000000..b5fefa4b
--- /dev/null
+++ b/tests/sample_data/field_schema/MultipleCollaboratorsFieldSchema.json
@@ -0,0 +1,5 @@
+{
+  "type": "multipleCollaborators",
+  "id": "fldbDOAG70ADnuF1g",
+  "name": "Watchers"
+}
diff --git a/tests/sample_data/field_schema/MultipleLookupValuesFieldSchema.json b/tests/sample_data/field_schema/MultipleLookupValuesFieldSchema.json
new file mode 100644
index 00000000..c8982908
--- /dev/null
+++ b/tests/sample_data/field_schema/MultipleLookupValuesFieldSchema.json
@@ -0,0 +1,32 @@
+{
+  "type": "multipleLookupValues",
+  "options": {
+    "isValid": true,
+    "recordLinkFieldId": "fldNvFMYxBnf35WkO",
+    "fieldIdInLinkedTable": "fldI5gjLnq0RXUleV",
+    "result": {
+      "type": "multipleSelects",
+      "options": {
+        "choices": [
+          {
+            "id": "selX4cKixuDso5PEX",
+            "name": "One",
+            "color": "blueLight2"
+          },
+          {
+            "id": "seloqP1Gzj8Glrspo",
+            "name": "Two",
+            "color": "cyanLight2"
+          },
+          {
+            "id": "selqPFVai30QE6Kp8",
+            "name": "Three",
+            "color": "tealLight2"
+          }
+        ]
+      }
+    }
+  },
+  "id": "fldwYZ0pPzf0uvyg7",
+  "name": "Lookup Multi Select"
+}
diff --git a/tests/sample_data/field_schema/MultipleRecordLinksFieldSchema.json b/tests/sample_data/field_schema/MultipleRecordLinksFieldSchema.json
new file mode 100644
index 00000000..fd325c16
--- /dev/null
+++ b/tests/sample_data/field_schema/MultipleRecordLinksFieldSchema.json
@@ -0,0 +1,10 @@
+{
+  "type": "multipleRecordLinks",
+  "options": {
+    "linkedTableId": "tblFgcHgyO7LlhgUe",
+    "isReversed": false,
+    "prefersSingleRecordLink": false
+  },
+  "id": "fldNvFMYxBnf35WkO",
+  "name": "Link to Self"
+}
diff --git a/tests/sample_data/field_schema/MultipleSelectsFieldSchema.json b/tests/sample_data/field_schema/MultipleSelectsFieldSchema.json
new file mode 100644
index 00000000..ff9e227e
--- /dev/null
+++ b/tests/sample_data/field_schema/MultipleSelectsFieldSchema.json
@@ -0,0 +1,24 @@
+{
+  "type": "multipleSelects",
+  "options": {
+    "choices": [
+      {
+        "id": "selX4cKixuDso5PEX",
+        "name": "One",
+        "color": "blueLight2"
+      },
+      {
+        "id": "seloqP1Gzj8Glrspo",
+        "name": "Two",
+        "color": "cyanLight2"
+      },
+      {
+        "id": "selqPFVai30QE6Kp8",
+        "name": "Three",
+        "color": "tealLight2"
+      }
+    ]
+  },
+  "id": "fldI5gjLnq0RXUleV",
+  "name": "Tags"
+}
diff --git a/tests/sample_data/field_schema/NumberFieldSchema.json b/tests/sample_data/field_schema/NumberFieldSchema.json
new file mode 100644
index 00000000..8d8bf432
--- /dev/null
+++ b/tests/sample_data/field_schema/NumberFieldSchema.json
@@ -0,0 +1,8 @@
+{
+  "type": "number",
+  "options": {
+    "precision": 0
+  },
+  "id": "fldrkqRoHDtIko3wc",
+  "name": "Integer"
+}
diff --git a/tests/sample_data/field_schema/PercentFieldSchema.json b/tests/sample_data/field_schema/PercentFieldSchema.json
new file mode 100644
index 00000000..0935f448
--- /dev/null
+++ b/tests/sample_data/field_schema/PercentFieldSchema.json
@@ -0,0 +1,8 @@
+{
+  "type": "percent",
+  "options": {
+    "precision": 0
+  },
+  "id": "fldLETyD8dPtNqyIi",
+  "name": "Percent"
+}
diff --git a/tests/sample_data/field_schema/PhoneNumberFieldSchema.json b/tests/sample_data/field_schema/PhoneNumberFieldSchema.json
new file mode 100644
index 00000000..d7d122be
--- /dev/null
+++ b/tests/sample_data/field_schema/PhoneNumberFieldSchema.json
@@ -0,0 +1,5 @@
+{
+  "type": "phoneNumber",
+  "id": "fld94Wcs7zIYcWsem",
+  "name": "Phone"
+}
diff --git a/tests/sample_data/field_schema/RatingFieldSchema.json b/tests/sample_data/field_schema/RatingFieldSchema.json
new file mode 100644
index 00000000..2c58f02a
--- /dev/null
+++ b/tests/sample_data/field_schema/RatingFieldSchema.json
@@ -0,0 +1,10 @@
+{
+  "type": "rating",
+  "options": {
+    "icon": "star",
+    "max": 5,
+    "color": "yellowBright"
+  },
+  "id": "fldlvlcSYXyatQSZI",
+  "name": "Stars"
+}
diff --git a/tests/sample_data/field_schema/RichTextFieldSchema.json b/tests/sample_data/field_schema/RichTextFieldSchema.json
new file mode 100644
index 00000000..c67ca739
--- /dev/null
+++ b/tests/sample_data/field_schema/RichTextFieldSchema.json
@@ -0,0 +1,5 @@
+{
+  "type": "richText",
+  "id": "fldEHLmq3SvZCvgOT",
+  "name": "Notes"
+}
diff --git a/tests/sample_data/field_schema/RollupFieldSchema.json b/tests/sample_data/field_schema/RollupFieldSchema.json
new file mode 100644
index 00000000..8615a02a
--- /dev/null
+++ b/tests/sample_data/field_schema/RollupFieldSchema.json
@@ -0,0 +1,17 @@
+{
+  "type": "rollup",
+  "options": {
+    "isValid": true,
+    "recordLinkFieldId": "fldNvFMYxBnf35WkO",
+    "fieldIdInLinkedTable": "fld4QpwrhRppNhjFM",
+    "referencedFieldIds": [],
+    "result": {
+      "type": "number",
+      "options": {
+        "precision": 0
+      }
+    }
+  },
+  "id": "fldSTbXQ3nFW18CG5",
+  "name": "Rollup Error"
+}
diff --git a/tests/sample_data/field_schema/SingleCollaboratorFieldSchema.json b/tests/sample_data/field_schema/SingleCollaboratorFieldSchema.json
new file mode 100644
index 00000000..d64d3a75
--- /dev/null
+++ b/tests/sample_data/field_schema/SingleCollaboratorFieldSchema.json
@@ -0,0 +1,5 @@
+{
+  "type": "singleCollaborator",
+  "id": "fldx2Pz3OV1ikvqFw",
+  "name": "Assignee"
+}
diff --git a/tests/sample_data/field_schema/SingleLineTextFieldSchema.json b/tests/sample_data/field_schema/SingleLineTextFieldSchema.json
new file mode 100644
index 00000000..0b2a89c4
--- /dev/null
+++ b/tests/sample_data/field_schema/SingleLineTextFieldSchema.json
@@ -0,0 +1,5 @@
+{
+  "type": "singleLineText",
+  "id": "fldeica4zrxCGxsl1",
+  "name": "Name"
+}
diff --git a/tests/sample_data/field_schema/SingleSelectFieldSchema.json b/tests/sample_data/field_schema/SingleSelectFieldSchema.json
new file mode 100644
index 00000000..632709f1
--- /dev/null
+++ b/tests/sample_data/field_schema/SingleSelectFieldSchema.json
@@ -0,0 +1,24 @@
+{
+  "type": "singleSelect",
+  "options": {
+    "choices": [
+      {
+        "id": "selSwz6Sw0qCkFTll",
+        "name": "Todo",
+        "color": "redLight2"
+      },
+      {
+        "id": "selTYUU62nFD9JX50",
+        "name": "In progress",
+        "color": "yellowLight2"
+      },
+      {
+        "id": "selvoFITCGqwGxYtB",
+        "name": "Done",
+        "color": "greenLight2"
+      }
+    ]
+  },
+  "id": "fldqCjrs1UhXgHUIc",
+  "name": "Status"
+}
diff --git a/tests/sample_data/field_schema/UnknownFieldSchema.json b/tests/sample_data/field_schema/UnknownFieldSchema.json
new file mode 100644
index 00000000..460b7b23
--- /dev/null
+++ b/tests/sample_data/field_schema/UnknownFieldSchema.json
@@ -0,0 +1,9 @@
+{
+  "type": "somethingUnrecognizable",
+  "id": "fld8cfZQtRNP5OXu9",
+  "name": "Unknown Field Type",
+  "options": {
+    "something": "wacky",
+    "and": ["unusual"]
+  }
+}
diff --git a/tests/sample_data/field_schema/UrlFieldSchema.json b/tests/sample_data/field_schema/UrlFieldSchema.json
new file mode 100644
index 00000000..84a68631
--- /dev/null
+++ b/tests/sample_data/field_schema/UrlFieldSchema.json
@@ -0,0 +1,5 @@
+{
+  "type": "url",
+  "id": "fld8cfZQtRNP5OXu9",
+  "name": "URL"
+}
diff --git a/tests/test_api_api.py b/tests/test_api_api.py
index 9dcb4c17..6ead1686 100644
--- a/tests/test_api_api.py
+++ b/tests/test_api_api.py
@@ -1,3 +1,5 @@
+from unittest import mock
+
 from pyairtable import Api, Base, Table  # noqa
 
 
@@ -57,3 +59,57 @@ def test_whoami(api, requests_mock):
     }
     requests_mock.get("https://api.airtable.com/v0/meta/whoami", json=payload)
     assert api.whoami() == payload
+
+
+def test_bases(api, requests_mock, sample_json):
+    m = requests_mock.get(api.build_url("meta/bases"), json=sample_json("Bases"))
+    base_ids = [base.id for base in api.bases()]
+    assert m.call_count == 1
+    assert base_ids == ["appLkNDICXNqxSDhG", "appSW9R5uCNmRmfl6"]
+
+    # Should not make a second API call...
+    assert [base.id for base in api.bases()] == base_ids
+    assert m.call_count == 1
+    # ....unless we force it:
+    reloaded = api.bases(force=True)
+    assert [base.id for base in reloaded] == base_ids
+    assert m.call_count == 2
+
+
+def test_iterate_requests(api: Api, requests_mock):
+    url = "https://example.com"
+    response_list = [{"json": {"page": n, "offset": n + 1}} for n in range(1, 3)]
+    response_list[-1]["json"]["offset"] = None
+    requests_mock.get(url, response_list=response_list)
+    responses = list(api.iterate_requests("GET", url))
+    assert responses == [response["json"] for response in response_list]
+
+
+def test_iterate_requests__invalid_type(api: Api, requests_mock):
+    url = "https://example.com"
+    response_list = [{"json": {"page": n, "offset": n + 1}} for n in range(1, 3)]
+    response_list.append({"json": "anything but a dict, and we stop immediately"})
+    requests_mock.get(url, response_list=response_list)
+    responses = list(api.iterate_requests("GET", url))
+    assert responses == [response["json"] for response in response_list]
+
+
+def test_workspace(api):
+    assert api.workspace("wspFake").id == "wspFake"
+
+
+def test_enterprise(api, requests_mock, sample_json):
+    url = api.build_url("meta/enterpriseAccount/entUBq2RGdihxl3vU")
+    requests_mock.get(url, json=sample_json("EnterpriseInfo"))
+    enterprise = api.enterprise("entUBq2RGdihxl3vU")
+    assert enterprise.id == "entUBq2RGdihxl3vU"
+
+
+def test_create_base(api):
+    """
+    Test that Api.create_base is a passthrough to Workspace.create_base
+    """
+    with mock.patch("pyairtable.Workspace.create_base") as m:
+        api.create_base("wspFake", "Fake Name", [])
+
+    m.assert_called_once_with("Fake Name", [])
diff --git a/tests/test_api_base.py b/tests/test_api_base.py
index 3dee5131..a9e3ab31 100644
--- a/tests/test_api_base.py
+++ b/tests/test_api_base.py
@@ -1,6 +1,7 @@
 import datetime
 
 import pytest
+from requests import HTTPError
 
 from pyairtable import Base, Table
 from pyairtable.testing import fake_id
@@ -30,11 +31,47 @@ def test_invalid_constructor():
         Base("api_key", "base_id", timeout=(1, 1))
 
 
-def test_repr(base):
-    assert "Base" in base.__repr__()
+@pytest.mark.parametrize(
+    "kwargs,expected",
+    [
+        (
+            dict(base_id="appFake"),
+            "<Base id='appFake'>",
+        ),
+        (
+            dict(base_id="appFake", name="Some name"),
+            "<Base id='appFake' name='Some name'>",
+        ),
+        (
+            dict(base_id="appFake", permission_level="editor"),
+            "<Base id='appFake' permission_level='editor'>",
+        ),
+    ],
+)
+def test_repr(api, kwargs, expected):
+    base = Base(api, **kwargs)
+    assert repr(base) == expected
+
+
+def test_url(base):
+    assert base.url == "https://api.airtable.com/v0/appJMY16gZDQrMWpA"
+
+
+def test_schema(base: Base, requests_mock, sample_json):
+    m = requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema"))
+    table_schema = base.schema().table("tbltp8DGLhqbUmjK1")
+    assert table_schema.name == "Apartments"
+    assert m.call_count == 1
+
+    # Test that we cache the result unless force=True
+    base.schema()
+    assert m.call_count == 1
+    base.schema(force=True)
+    assert m.call_count == 2
 
 
-def test_get_table(base: Base):
+def test_table(base: Base, requests_mock):
+    # no network traffic expected; requests_mock will fail if it happens
     rv = base.table("tablename")
     assert isinstance(rv, Table)
     assert rv.base == base
@@ -42,6 +79,45 @@ def test_get_table(base: Base):
     assert rv.url == f"https://api.airtable.com/v0/{base.id}/tablename"
 
 
+def test_table_validate(base: Base, requests_mock, sample_json):
+    """
+    Test that Base.table(..., validate=True) allows us to look up a table
+    by either ID or name and get the correct properties.
+    """
+    m = requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema"))
+    base.table("tbltp8DGLhqbUmjK1", validate=True)
+    base.table("Apartments", validate=True)
+    assert m.call_count == 2
+    # ...and will raise an exception if called with an invalid ID/name:
+    with pytest.raises(KeyError):
+        base.table("DoesNotExist", validate=True)
+
+
+def test_tables(base: Base, requests_mock, sample_json):
+    """
+    Test that Base.tables() returns a list of validated Base instances.
+    """
+    requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema"))
+    result = base.tables()
+    assert len(result) == 2
+    assert result[0].name == "Apartments"
+    assert result[1].name == "Districts"
+
+
+def test_collaborators(base: Base, requests_mock, sample_json):
+    requests_mock.get(base.meta_url(), json=sample_json("BaseCollaborators"))
+    result = base.collaborators()
+    assert result.individual_collaborators.via_base[0].email == "foo@bam.com"
+    assert result.group_collaborators.via_workspace[0].group_id == "ugp1mKGb3KXUyQfOZ"
+
+
+def test_shares(base: Base, requests_mock, sample_json):
+    requests_mock.get(base.meta_url("shares"), json=sample_json("BaseShares"))
+    result = base.shares()
+    assert result[0].state == "enabled"
+    assert result[1].effective_email_domain_allow_list == []
+
+
 def test_webhooks(base: Base, requests_mock, sample_json):
     m = requests_mock.get(
         base.webhooks_url,
@@ -54,6 +130,15 @@ def test_webhooks(base: Base, requests_mock, sample_json):
     assert webhooks[0].last_notification_result.error
 
 
+def test_webhook(base: Base, requests_mock, sample_json):
+    requests_mock.get(base.webhooks_url, json={"webhooks": [sample_json("Webhook")]})
+    webhook = base.webhook("ach00000000000001")
+    assert webhook.id == "ach00000000000001"
+    assert webhook.notification_url == "https://example.com/receive-ping"
+    with pytest.raises(KeyError):
+        base.webhook("DoesNotExist")
+
+
 def test_add_webhook(base: Base, requests_mock):
     def _callback(request, context):
         expires = datetime.datetime.now() + datetime.timedelta(days=7)
@@ -85,3 +170,70 @@ def _callback(request, context):
     assert m.last_request.json()["specification"] == spec
     assert result.id.startswith("ach")
     assert result.mac_secret_base64 == "secret"
+
+
+def test_name(api, base, requests_mock):
+    """
+    Test that Base().name is only set if passed explicitly to the constructor,
+    or if it is available in cached schema information.
+    """
+    requests_mock.get(
+        base.meta_url(),
+        json={
+            "id": base.id,
+            "name": "Mocked Base Name",
+            "permissionLevel": "create",
+            "workspaceId": "wspFake",
+        },
+    )
+
+    assert api.base(base.id).name is None
+    assert base.name is None
+    assert base.collaborators().name == "Mocked Base Name"
+    assert base.name == "Mocked Base Name"
+
+    # Test behavior with older constructor pattern
+    with pytest.warns(DeprecationWarning):
+        assert Base("tok", "app").name is None
+    with pytest.warns(DeprecationWarning):
+        assert Base("tok", "app", name="Base Name").name == "Base Name"
+
+
+def test_create_table(base, requests_mock, sample_json):
+    """
+    Test that Base.create_table() makes two calls, one to create the table,
+    and another to re-retrieve the entire base's schema.
+    """
+    schema = sample_json("BaseSchema")
+    url = base.meta_url("tables")
+    m = requests_mock.post(url, json={"id": "tbltp8DGLhqbUmjK1"})
+    m_get = requests_mock.get(url + "?include=visibleFieldIds", json=schema)
+    table = base.create_table(
+        "Table Name", [{"name": "Whatever"}], description="Description"
+    )
+    assert isinstance(table, Table)
+    assert m.call_count == m_get.call_count == 1
+    assert m.request_history[-1].json() == {
+        "name": "Table Name",
+        "description": "Description",
+        "fields": [{"name": "Whatever"}],
+    }
+
+
+def test_delete(base, requests_mock):
+    """
+    Test that Base.delete() hits the right endpoint.
+    """
+    m = requests_mock.delete(base.meta_url(), json={"id": base.id, "deleted": True})
+    base.delete()
+    assert m.call_count == 1
+
+
+def test_delete__enterprise_only_table(api, base, requests_mock):
+    """
+    Test that Base.delete() explains why it might be getting a 404.
+    """
+    requests_mock.delete(base.meta_url(), status_code=404)
+    with pytest.raises(HTTPError) as excinfo:
+        base.delete()
+    assert "Base.delete() requires an enterprise billing plan" in str(excinfo)
diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py
new file mode 100644
index 00000000..e62b065e
--- /dev/null
+++ b/tests/test_api_enterprise.py
@@ -0,0 +1,83 @@
+from unittest.mock import Mock
+
+import pytest
+
+from pyairtable.api.enterprise import Enterprise
+from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo
+
+
+@pytest.fixture
+def enterprise(api):
+    return Enterprise(api, "entUBq2RGdihxl3vU")
+
+
+@pytest.fixture
+def enterprise_mocks(enterprise, requests_mock, sample_json):
+    user_json = sample_json("UserInfo")
+    group_json = sample_json("UserGroup")
+    m = Mock()
+    m.user_id = user_json["id"]
+    m.get_info = requests_mock.get(
+        enterprise.url,
+        json=sample_json("EnterpriseInfo"),
+    )
+    m.get_user = requests_mock.get(
+        f"{enterprise.url}/users/{m.user_id}",
+        json=user_json,
+    )
+    m.get_users = requests_mock.get(
+        f"{enterprise.url}/users",
+        json={"users": [user_json]},
+    )
+    m.get_group = requests_mock.get(
+        enterprise.api.build_url(f"meta/groups/{group_json['id']}"),
+        json=group_json,
+    )
+    return m
+
+
+def test_info(enterprise, enterprise_mocks):
+    assert isinstance(info := enterprise.info(), EnterpriseInfo)
+    assert info.id == "entUBq2RGdihxl3vU"
+    assert info.workspace_ids == ["wspmhESAta6clCCwF", "wspHvvm4dAktsStZH"]
+    assert info.email_domains[0].is_sso_required is True
+    assert enterprise_mocks.get_info.call_count == 1
+
+    assert enterprise.info(force=True).id == "entUBq2RGdihxl3vU"
+    assert enterprise_mocks.get_info.call_count == 2
+
+
+def test_user(enterprise, enterprise_mocks):
+    user = enterprise.user(enterprise_mocks.user_id)
+    assert isinstance(user, UserInfo)
+    assert enterprise_mocks.get_user.call_count == 1
+
+
+@pytest.mark.parametrize(
+    "search_for",
+    (
+        ["usrL2PNC5o3H4lBEi"],
+        ["foo@bar.com"],
+        ["usrL2PNC5o3H4lBEi", "foo@bar.com"],  # should not return duplicates
+    ),
+)
+def test_users(enterprise, enterprise_mocks, search_for):
+    results = enterprise.users(search_for)
+    assert len(results) == 1
+    assert isinstance(user := results[0], UserInfo)
+    assert user.id == "usrL2PNC5o3H4lBEi"
+    assert user.state == "provisioned"
+
+
+def test_users__invalid_value(enterprise, enterprise_mocks):
+    with pytest.raises(ValueError):
+        enterprise.users(["not an ID or email"])
+
+
+def test_group(enterprise, enterprise_mocks):
+    info = enterprise.group("ugp1mKGb3KXUyQfOZ")
+    assert enterprise_mocks.get_group.call_count == 1
+    assert isinstance(info, UserGroup)
+    assert info.id == "ugp1mKGb3KXUyQfOZ"
+    assert info.name == "Group name"
+    assert info.members[0].email == "foo@bar.com"
diff --git a/tests/test_api_retrying.py b/tests/test_api_retrying.py
index 46e6ad5d..d519b9c2 100644
--- a/tests/test_api_retrying.py
+++ b/tests/test_api_retrying.py
@@ -162,6 +162,30 @@ def _table_with_retry(retry_strategy):
     return _table_with_retry
 
 
+def test_without_retry_strategy__succeed(
+    table_with_retry_strategy,
+    mock_endpoint,
+    mock_response_single,
+):
+    mock_endpoint.canned_responses = [(200, mock_response_single)]
+    table = table_with_retry_strategy(None)
+    assert table.get("record") == mock_response_single
+
+
+def test_without_retry_strategy__fail(
+    table_with_retry_strategy,
+    mock_endpoint,
+    mock_response_single,
+):
+    mock_endpoint.canned_responses = [
+        (429, None),
+        (200, mock_response_single),
+    ]
+    table = table_with_retry_strategy(None)
+    with pytest.raises(requests.exceptions.HTTPError):
+        table.get("record")
+
+
 def test_retry_exceed(table_with_retry_strategy, mock_endpoint):
     """
     Test that we raise a RetryError if we get too many retryable error codes.
diff --git a/tests/test_api_table.py b/tests/test_api_table.py
index dda1632f..95d41167 100644
--- a/tests/test_api_table.py
+++ b/tests/test_api_table.py
@@ -5,10 +5,26 @@
 from requests_mock import Mocker
 
 from pyairtable import Api, Base, Table
-from pyairtable.testing import fake_record
+from pyairtable.models.schema import TableSchema
+from pyairtable.testing import fake_id, fake_record
 from pyairtable.utils import chunked
 
 
+@pytest.fixture()
+def table_schema(sample_json) -> TableSchema:
+    return TableSchema.parse_obj(sample_json("TableSchema"))
+
+
+@pytest.fixture
+def mock_schema(table, requests_mock, sample_json):
+    table_schema = sample_json("TableSchema")
+    table_schema["id"] = table.name = fake_id("tbl")
+    return requests_mock.get(
+        table.base.meta_url("tables") + "?include=visibleFieldIds",
+        json={"tables": [table_schema]},
+    )
+
+
 def test_constructor(base: Base):
     """
     Test the constructor.
@@ -19,6 +35,18 @@ def test_constructor(base: Base):
     assert table.name == "table_name"
 
 
+def test_constructor_with_schema(base: Base, table_schema: TableSchema):
+    table = Table(None, base, table_schema)
+    assert table.api == base.api
+    assert table.base == base
+    assert table.name == table_schema.name
+    assert table.url == f"https://api.airtable.com/v0/{base.id}/{table_schema.id}"
+    assert (
+        repr(table)
+        == f"<Table base='{base.id}' id='{table_schema.id}' name='{table_schema.name}'>"
+    )
+
+
 def test_deprecated_constructor(api: Api, base: Base):
     """
     Test that "legacy" constructor (passing strings instead of instances)
@@ -41,6 +69,7 @@ def test_invalid_constructor(api, base):
         [api, "base_id", "table_name"],
         ["api_key", base, "table_name"],
         [api, base, "table_name"],
+        [None, base, -1],
     ]:
         kwargs = args.pop() if isinstance(args[-1], dict) else {}
         with pytest.raises(TypeError):
@@ -49,7 +78,34 @@ def test_invalid_constructor(api, base):
 
 
 def test_repr(table: Table):
-    assert repr(table) == "<Table base_id='appJMY16gZDQrMWpA' table_name='Table Name'>"
+    assert repr(table) == "<Table base='appJMY16gZDQrMWpA' name='Table Name'>"
+
+
+def test_schema(base, requests_mock, sample_json):
+    """
+    Test that we can load schema from API.
+    """
+    table = base.table("Apartments")
+    m = requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema"))
+    assert isinstance(schema := table.schema(), TableSchema)
+    assert m.call_count == 1
+    assert schema.id == "tbltp8DGLhqbUmjK1"
+
+
+def test_id(base, requests_mock, sample_json):
+    """
+    Test that we load schema from API if we need the ID and don't have it,
+    but if we get a name that *looks* like an ID, we trust it.
+    """
+    m = requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema"))
+
+    table = base.table("tbltp8DGLhqbUmjK1")
+    assert table.id == "tbltp8DGLhqbUmjK1"
+    assert m.call_count == 0
+
+    table = base.table("Apartments")
+    assert table.id == "tbltp8DGLhqbUmjK1"
+    assert m.call_count == 1
 
 
 @pytest.mark.parametrize(
@@ -336,6 +392,55 @@ def test_batch_delete(table: Table, container, mock_records):
     assert resp == expected
 
 
+def test_create_field(table, mock_schema, requests_mock, sample_json):
+    """
+    Tests the API for creating a field (but without actually performing the operation).
+    """
+    mock_create = requests_mock.post(
+        table.meta_url("fields"),
+        json=sample_json("field_schema/SingleSelectFieldSchema"),
+    )
+
+    # Ensure we have pre-loaded our schema
+    table.schema()
+    assert mock_schema.call_count == 1
+
+    # Create the field
+    choices = ["Todo", "In progress", "Done"]
+    fld = table.create_field(
+        "Status",
+        "singleSelect",
+        description="field description",
+        options={"choices": choices},
+    )
+    assert mock_create.call_count == 1
+    assert mock_create.request_history[-1].json() == {
+        "name": "Status",
+        "type": "singleSelect",
+        "description": "field description",
+        "options": {"choices": choices},
+    }
+
+    # Test the result we got back
+    assert fld.id == "fldqCjrs1UhXgHUIc"
+    assert fld.name == "Status"
+    assert {c.name for c in fld.options.choices} == set(choices)
+
+    # Test that we constructed the URL correctly
+    assert fld._url.endswith(f"/{table.base.id}/tables/{table.name}/fields/{fld.id}")
+
+    # Test that the schema has been updated without a second API call
+    assert table._schema.field(fld.id).name == "Status"
+    assert mock_schema.call_count == 1
+
+
+def test_delete_view(table, mock_schema, requests_mock):
+    view = table.schema().view("Grid view")
+    m = requests_mock.delete(view._url)
+    view.delete()
+    assert m.call_count == 1
+
+
 # Helpers
 
 
diff --git a/tests/test_api_workspace.py b/tests/test_api_workspace.py
new file mode 100644
index 00000000..ad66933e
--- /dev/null
+++ b/tests/test_api_workspace.py
@@ -0,0 +1,83 @@
+import pytest
+
+from pyairtable.api.base import Base
+from pyairtable.api.workspace import Workspace
+
+
+@pytest.fixture
+def workspace_id():
+    return "wspFakeWorkspaceId"
+
+
+@pytest.fixture
+def workspace(api, workspace_id):
+    return Workspace(api, workspace_id)
+
+
+@pytest.fixture
+def mock_info(workspace, requests_mock, sample_json):
+    return requests_mock.get(workspace.url, json=sample_json("WorkspaceCollaborators"))
+
+
+def test_collaborators(workspace, mock_info):
+    assert workspace.collaborators().id == "wspmhESAta6clCCwF"
+    assert workspace.collaborators().name == "my first workspace"
+    assert mock_info.call_count == 1
+
+
+def test_name(workspace, mock_info):
+    assert workspace.name == "my first workspace"
+    assert mock_info.call_count == 1
+
+
+def test_bases(workspace, mock_info):
+    bases = workspace.bases()
+    assert len(bases) == 2
+    assert bases[0].id == "appLkNDICXNqxSDhG"
+    assert bases[1].id == "appSW9R5uCNmRmfl6"
+    assert mock_info.call_count == 1
+
+
+def test_create_base(workspace, requests_mock, sample_json):
+    url = workspace.api.build_url("meta/bases")
+    requests_mock.get(url, json=sample_json("Bases"))
+    requests_mock.post(url, json={"id": "appLkNDICXNqxSDhG"})
+    base = workspace.create_base("Base Name", [])
+    assert isinstance(base, Base)
+    assert base.id == "appLkNDICXNqxSDhG"
+
+
+def test_delete(workspace, requests_mock):
+    m = requests_mock.delete(workspace.url, json={"id": workspace.id, "deleted": True})
+    workspace.delete()
+    assert m.call_count == 1
+
+
+@pytest.mark.parametrize("workspace_param", ["workspace", "workspace_id"])
+@pytest.mark.parametrize("base_param", ["base", "base_id"])
+@pytest.mark.parametrize(
+    "kwargs,expected",
+    [
+        ({}, {}),
+        ({"index": 8}, {"targetIndex": 8}),
+    ],
+)
+def test_move_base(
+    workspace,
+    workspace_id,
+    workspace_param,
+    base,
+    base_id,
+    base_param,
+    kwargs,
+    expected,
+    requests_mock,
+):
+    m = requests_mock.post(workspace.url + "/moveBase")
+    workspace.move_base(locals()[base_param], locals()[workspace_param], **kwargs)
+    assert m.call_count == 1
+    assert m.request_history[-1].json() == {
+        "baseId": base_id,
+        "targetWorkspaceId": workspace_id,
+        **expected,
+    }
diff --git a/tests/test_models.py b/tests/test_models.py
index f8b06f8e..5a6a8857 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -2,7 +2,8 @@
 
 from pyairtable.models._base import (
     AirtableModel,
-    SerializableModel,
+    CanDeleteModel,
+    CanUpdateModel,
     update_forward_refs,
 )
 
@@ -15,12 +16,22 @@ def raw_data():
 @pytest.fixture
 def create_instance(api, raw_data):
     def _creates_instance(**kwargs):
-        class Subclass(SerializableModel, **kwargs):
+        # These kwargs used to be interpreted by __init_subclass__ but now that behavior
+        # is controlled by mixins. This weirdness is just to avoid redoing our tests.
+        base_classes = []
+        if kwargs.pop("allow_update", True):
+            base_classes.append(CanUpdateModel)
+        if kwargs.pop("allow_delete", True):
+            base_classes.append(CanDeleteModel)
+
+        kwargs.setdefault("url", "https://example.com/{self.foo}/{self.bar}/{self.baz}")
+
+        class Subclass(*base_classes, **kwargs):
             foo: int
             bar: int
             baz: int
 
-        return Subclass.from_api(api, "https://www.example.com", raw_data)
+        return Subclass.from_api(raw_data, api)
 
     return _creates_instance
 
@@ -37,15 +48,25 @@ def test_raw(raw_data):
     assert obj._raw == raw_data
 
 
-def test_from_api(raw_data):
+@pytest.mark.parametrize("prefix", ["https://api.airtable.com/v0/prefix", "prefix"])
+def test_from_api(raw_data, prefix, api):
     """
-    Test that SerializableModel.from_api persists its parameters correctly.
+    Test that CanUpdate.from_api persists its parameters correctly,
+    and that if `url=` is passed to the subclass, we'll always get a valid URL.
     """
-    url = "https://www.example.com"
-    obj = SerializableModel.from_api("api", url, raw_data)
-    assert obj._api == "api"
-    assert obj._url == url
+
+    class Dummy(CanUpdateModel, url="{prefix}/foo={self.foo}/bar={self.bar}"):
+        foo: int
+        bar: int
+
+    obj = Dummy.from_api(raw_data, api, context={"prefix": prefix})
+    assert obj._api == api
     assert obj._raw == raw_data
+    assert obj._url == "https://api.airtable.com/v0/prefix/foo=1/bar=2"
+    assert obj.foo == 1
+    assert obj.bar == 2
+    assert not hasattr(obj, "baz")
+    assert obj._raw["baz"] == 3
 
 
 def test_save(requests_mock, create_instance):
@@ -65,7 +86,17 @@ def test_save(requests_mock, create_instance):
 
 def test_save_not_allowed(create_instance):
     obj = create_instance(allow_update=False)
-    with pytest.raises(NotImplementedError):
+    with pytest.raises(AttributeError):
+        obj.save()
+
+
+def test_save_without_url(create_instance):
+    """
+    Test that if we do not provide context for computing a URL when an instance
+    is created, we won't be able to save it later.
+    """
+    obj = create_instance(url="")
+    with pytest.raises(RuntimeError):
         obj.save()
 
 
@@ -81,7 +112,17 @@ def test_delete(requests_mock, create_instance):
 
 def test_delete_not_allowed(create_instance):
     obj = create_instance(allow_delete=False)
-    with pytest.raises(NotImplementedError):
+    with pytest.raises(AttributeError):
+        obj.delete()
+
+
+def test_delete_without_url(create_instance):
+    """
+    Test that if we do not provide context for computing a URL when an instance
+    is created, we won't be able to delete it later.
+    """
+    obj = create_instance(url="")
+    with pytest.raises(RuntimeError):
         obj.delete()
 
 
diff --git a/tests/test_models_comment.py b/tests/test_models_comment.py
index 45564c6f..9b5b9799 100644
--- a/tests/test_models_comment.py
+++ b/tests/test_models_comment.py
@@ -32,8 +32,8 @@ def comment_json():
 
 @pytest.fixture
 def comment(comment_json, table):
-    url = table.record_url(RECORD_ID, "comments", comment_json["id"])
-    return Comment.from_api(table.api, url, comment_json)
+    record_url = table.record_url(RECORD_ID)
+    return Comment.from_api(comment_json, table.api, context={"record_url": record_url})
 
 
 @pytest.fixture
diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py
new file mode 100644
index 00000000..c6311918
--- /dev/null
+++ b/tests/test_models_schema.py
@@ -0,0 +1,118 @@
+from operator import attrgetter, itemgetter
+from typing import List, Optional
+
+import pytest
+
+import pyairtable.models.schema
+from pyairtable.models._base import AirtableModel
+
+
+@pytest.mark.parametrize(
+    "clsname",
+    [
+        "Bases",
+        "BaseCollaborators",
+        "BaseSchema",
+        "TableSchema",
+        "ViewSchema",
+    ],
+)
+def test_parse(sample_json, clsname):
+    cls = attrgetter(clsname)(pyairtable.models.schema)
+    cls.parse_obj(sample_json(clsname))
+
+
+@pytest.mark.parametrize("cls", pyairtable.models.schema.FieldSchema.__args__)
+def test_parse_field(sample_json, cls):
+    cls.parse_obj(sample_json("field_schema/" + cls.__name__))
+
+
+@pytest.mark.parametrize(
+    "clsname,method,id_or_name",
+    [
+        ("Bases", "base", "appLkNDICXNqxSDhG"),
+        ("Bases", "base", "Apartment Hunting"),
+        ("BaseSchema", "table", "tbltp8DGLhqbUmjK1"),
+        ("BaseSchema", "table", "Apartments"),
+        ("TableSchema", "field", "fld1VnoyuotSTyxW1"),
+        ("TableSchema", "field", "Name"),
+        ("TableSchema", "view", "viwQpsuEDqHFqegkp"),
+        ("TableSchema", "view", "Grid view"),
+    ],
+)
+def test_find_in_collection(clsname, method, id_or_name, sample_json):
+    cls = attrgetter(clsname)(pyairtable.models.schema)
+    obj = cls.parse_obj(sample_json(clsname))
+    assert getattr(obj, method)(id_or_name)
+
+
+@pytest.mark.parametrize(
+    "test_case",
+    {
+        "BaseCollaborators.individual_collaborators.via_base[0].permission_level": "create",
+        "BaseCollaborators.individual_collaborators.via_base[0].user_id": "usrsOEchC9xuwRgKk",
+        "BaseSchema.tables[0].fields[1].type": "multipleAttachments",
+        "BaseSchema.tables[0].fields[2].options.inverse_link_field_id": "fldWnCJlo2z6ttT8Y",
+        "BaseSchema.tables[0].name": "Apartments",
+        "BaseSchema.tables[0].views[0].type": "grid",
+        "BaseShares.shares[0].effective_email_domain_allow_list": ["foobar.com"],
+        "BaseShares.shares[2].state": "disabled",
+        "EnterpriseInfo.email_domains[0].email_domain": "foobar.com",
+        "EnterpriseInfo.email_domains[0].is_sso_required": True,
+        "UserGroup.collaborations.base_collaborations[0].base_id": "appLkNDICXNqxSDhG",
+        "UserGroup.members[1].user_id": "usrsOEchC9xuwRgKk",
+        "UserInfo.collaborations.interface_collaborations[0].interface_id": "pbdyGA3PsOziEHPDE",
+        "UserInfo.is_sso_required": True,
+        "UserInfo.is_two_factor_auth_enabled": False,
+        "UserInfo.name": "foo baz",
+        "WorkspaceCollaborators.base_ids": ["appLkNDICXNqxSDhG", "appSW9R5uCNmRmfl6"],
+        "WorkspaceCollaborators.invite_links.base_invite_links[0].id": "invJiqaXmPqq6Ec87",
+    }.items(),
+    ids=itemgetter(0),
+)
+def test_deserialized_values(test_case, sample_json):
+    """
+    Spot check that certain values get loaded correctly from JSON into Python.
+    This is not intended to be comprehensive, just another chance to catch regressions.
+    """
+    clsname_attr, expected = test_case
+    clsname = clsname_attr.split(".")[0]
+    cls = attrgetter(clsname)(pyairtable.models.schema)
+    obj = cls.parse_obj(sample_json(clsname))
+    val = eval(clsname_attr, None, {clsname: obj})
+    assert val == expected
+
+
+class Outer(AirtableModel):
+    inners: List["Outer.Inner"]
+
+    class Inner(AirtableModel):
+        id: str
+        name: str
+        deleted: Optional[bool] = None
+
+    def find(self, id_or_name):
+        return pyairtable.models.schema._find(self.inners, id_or_name)
+
+
+def test_find():
+    """
+    Test that _find() retrieves an object based on ID or name,
+    and skips any models that are marked as deleted.
+    """
+
+    collection = Outer.parse_obj(
+        {
+            "inners": [
+                {"id": "0001", "name": "One"},
+                {"id": "0002", "name": "Two"},
+                {"id": "0003", "name": "Three", "deleted": True},
+            ]
+        }
+    )
+    assert collection.find("0001").id == "0001"
+    assert collection.find("Two").id == "0002"
+    with pytest.raises(KeyError):
+        collection.find("0003")
+    with pytest.raises(KeyError):
+        collection.find("0004")
diff --git a/tests/test_models_webhook.py b/tests/test_models_webhook.py
index 5a83a76b..7c1bf29b 100644
--- a/tests/test_models_webhook.py
+++ b/tests/test_models_webhook.py
@@ -12,11 +12,7 @@
 @pytest.fixture
 def webhook(sample_json, base, api):
     webhook_json = sample_json("Webhook")
-    return Webhook.from_api(
-        api=api,
-        url=f"{base.webhooks_url}/{webhook_json['id']}",
-        obj=webhook_json,
-    )
+    return Webhook.from_api(webhook_json, api, context=base)
 
 
 @pytest.fixture
diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py
index 624ce73a..4cc32641 100644
--- a/tests/test_orm_fields.py
+++ b/tests/test_orm_fields.py
@@ -33,6 +33,16 @@ class T:
         del t.name
 
 
+def test_description():
+    class T:
+        name = f.Field("Name")
+
+    T.other = f.Field("Other")
+
+    assert T.name._description == "T.name"
+    assert T.other._description == "'Other' field"
+
+
 @pytest.mark.parametrize(
     "instance,expected",
     [
@@ -387,18 +397,28 @@ class T(Model):
     assert T.from_record(fake_record(Fld=None)).the_field == []
 
 
-def test_list_field_with_invalid_type():
+@pytest.mark.parametrize(
+    "field_class,invalid_value",
+    [
+        (f._ListField, object()),
+        (f.AttachmentsField, [1, 2, 3]),
+        (f.MultipleCollaboratorsField, [1, 2, 3]),
+        (f.MultipleSelectField, [{"complex": "type"}]),
+    ],
+)
+def test_list_field_with_invalid_type(field_class, invalid_value):
     """
-    Ensure that a ListField represents a null value as an empty list.
+    Ensure that a ListField raises TypeError when given a non-list,
+    or a list of objects that don't match `contains_type`.
     """
 
     class T(Model):
         Meta = fake_meta()
-        the_field = f._ListField("Field Name", str)
+        the_field = field_class("Field Name", str)
 
     obj = T.from_record(fake_record())
     with pytest.raises(TypeError):
-        obj.the_field = object()
+        obj.the_field = invalid_value
 
 
 def test_list_field_with_string():
diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py
index a9d3e38c..3f069305 100644
--- a/tests/test_orm_model.py
+++ b/tests/test_orm_model.py
@@ -70,6 +70,8 @@ def test_model_overlapping(name):
 
 class FakeModel(Model):
     Meta = fake_meta()
+    one = f.TextField("one")
+    two = f.TextField("two")
 
 
 def test_repr():
@@ -78,6 +80,39 @@ def test_repr():
     assert repr(FakeModel()) == "<unsaved FakeModel>"
 
 
+def test_delete():
+    obj = FakeModel.from_record(record := fake_record())
+    with mock.patch("pyairtable.Table.delete") as mock_delete:
+        obj.delete()
+
+    mock_delete.assert_called_once_with(record["id"])
+
+
+def test_delete__unsaved():
+    obj = FakeModel()
+    with pytest.raises(ValueError):
+        obj.delete()
+
+
+def test_fetch():
+    obj = FakeModel(id=fake_id())
+    assert not obj.one
+    assert not obj.two
+
+    with mock.patch("pyairtable.Table.get") as mock_get:
+        mock_get.return_value = fake_record(one=1, two=2)
+        obj.fetch()
+
+    assert obj.one == 1
+    assert obj.two == 2
+
+
+def test_fetch__unsaved():
+    obj = FakeModel()
+    with pytest.raises(ValueError):
+        obj.fetch()
+
+
 @pytest.mark.parametrize(
     "method,args",
     [
@@ -129,6 +164,15 @@ def test_from_ids(mock_all):
     mock_all.assert_called_once()
 
 
+@mock.patch("pyairtable.Table.all")
+def test_from_ids__no_fetch(mock_all):
+    fake_ids = [fake_id() for _ in range(10)]
+    contacts = FakeModel.from_ids(fake_ids, fetch=False)
+    assert mock_all.call_count == 0
+    assert len(contacts) == 10
+    assert set(contact.id for contact in contacts) == set(fake_ids)
+
+
 def test_dynamic_model_meta():
     """
     Test that we can provide callables in our Meta class to provide
diff --git a/tests/test_testing.py b/tests/test_testing.py
new file mode 100644
index 00000000..93ca3d9e
--- /dev/null
+++ b/tests/test_testing.py
@@ -0,0 +1,45 @@
+from unittest.mock import ANY, call
+
+import pytest
+
+from pyairtable import testing as T
+
+
+@pytest.mark.parametrize(
+    "funcname,sig,expected",
+    [
+        ("fake_id", call(value=123), "rec00000000000123"),
+        ("fake_id", call("tbl", "x"), "tbl0000000000000x"),
+        (
+            "fake_record",
+            call(id=123),
+            {"id": "rec00000000000123", "createdTime": ANY, "fields": {}},
+        ),
+        (
+            "fake_record",
+            call({"A": 1}, 123),
+            {"id": "rec00000000000123", "createdTime": ANY, "fields": {"A": 1}},
+        ),
+        (
+            "fake_record",
+            call(one=1, two=2),
+            {
+                "id": ANY,
+                "createdTime": ANY,
+                "fields": {"one": 1, "two": 2},
+            },
+        ),
+        (
+            "fake_user",
+            call("alice"),
+            {
+                "id": "usr000000000alice",
+                "email": "alice@example.com",
+                "name": "Fake User",
+            },
+        ),
+    ],
+)
+def test_fake_function(funcname, sig, expected):
+    func = getattr(T, funcname)
+    assert func(*sig.args, **sig.kwargs) == expected
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 2e2404be..2dfc0413 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -37,3 +37,24 @@ def test_attachment():
         "url": "https://url.com",
         "filename": "test.jpg",
     }
+
+
+@pytest.mark.parametrize(
+    "func,value,expected",
+    [
+        (utils.is_airtable_id, -1, False),
+        (utils.is_airtable_id, "appAkBDICXDqESDhF", True),
+        (utils.is_airtable_id, "app0000000000Fake", True),
+        (utils.is_airtable_id, "appWrongLength", False),
+        (utils.is_record_id, "rec0000000000Fake", True),
+        (utils.is_record_id, "app0000000000Fake", False),
+        (utils.is_base_id, "app0000000000Fake", True),
+        (utils.is_base_id, "rec0000000000Fake", False),
+        (utils.is_table_id, "tbl0000000000Fake", True),
+        (utils.is_table_id, "rec0000000000Fake", False),
+        (utils.is_field_id, "fld0000000000Fake", True),
+        (utils.is_field_id, "rec0000000000Fake", False),
+    ],
+)
+def test_id_check(func, value, expected):
+    assert func(value) is expected