Repository: gtalarico/pyairtable (language: python)

Issue:
For discussion: consider removing `ApiAbstract`
This is a proposal for discussion. It's not a "must have". I think it's worth considering prior to a major version release, since 2.0.0 is our best opportunity to make potentially breaking API changes.

## tl;dr

I think we should consider removing `ApiAbstract`. I think the library API would be simpler for implementers to understand if it favored composition over inheritance. I think this will make it easier to add more endpoints in the future.

## Why change it?

I'm suggesting this change to the structure of the API because:

1. It involves a lot of duplication. Adding one method (like #250) involves adding three other methods which call the abstract class via `super()`. We need to keep the signatures and API documentation for each of those methods up-to-date.

2. This will not scale gracefully if we want to add dozens of endpoints. Keeping four separate method signatures up-to-date for every action we might take (update a webhook, post a comment, etc) will become burdensome over time.

3. The current approach exposes methods which don't make sense. Take the following code snippet as an example:

    ```python
    >>> table = Table("key", "app", "tbl")
    >>> table.get_table_url()
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    TypeError: ApiAbstract.get_table_url() missing 2 required positional arguments: 'base_id' and 'table_name'
    ```

    ...the correct thing to do is call `table.table_url` rather than `table.get_table_url()`, but the presence of both (a byproduct of inheritance) is confusing clutter, especially if you're using an IDE that does autocompletion.

4.  There are a number of endpoints (particularly around metadata) that only ever make sense in the context of a base, or a table, or a record; keeping those methods scoped to just the one appropriate class will simplify maintenance _and_ make it easier for implementers to understand their use.

## How would this work?

This is just a proposal, but one way I might arrange this is:

- [ ] **ApiAbstract** goes away
- [ ] **Api** contains basic utilities for connecting to the Airtable API, and nothing more.
  - [x] `__init__(access_token)`
  - [x] `build_url(...) -> str`
  - [ ] `build_request(...) -> requests.Request`
  - [ ] `prepare_request(...) -> requests.PreparedRequest`
  - [x] `request(...) -> dict` executes a request and returns the deserialized JSON payload.
  - [x] `base(base_id) -> Base` (renamed from `get_base`)
  - [ ] `table(base_id, table_name) -> Table` (renamed from `get_table`)
  - [ ] [`create_base`](https://airtable.com/developers/web/api/create-base) (to be implemented)
- [ ] **Base** is where we'd add future methods for interacting with [schemas](https://airtable.com/developers/web/api/get-base-schema), [webhooks](https://airtable.com/developers/web/api/webhooks-overview), etc.
  - [x] `__init__(api, base_id)` could take an instance of `Api` or `str`
  - [x] `api`, `url` would be available as properties
  - [x] `table(table_name) -> Table` (renamed from `get_table`)
  - [ ] [`create_table`](https://airtable.com/developers/web/api/create-table) (to be implemented)
- [ ] **Table** would contain a lot of the methods that exist in ApiAbstract today.
  - [x] `__init__(access_token: str, base_id: str, table_name: str)` (old style constructor)
  - [x] `__init__(base: Base, table_name: str)` (new style constructor)
  - [x] `api`, `base`, `url` would be available as properties
  - [x] `record_url(record_id) -> str` (renamed from `get_record_url`)
  - [x] `get(record_id: str, **options)`
  - [x] `iterate(**options)`
  - [x] `first(**options)`
  - [x] `all(**options)`
  - [x] `create(fields: dict)`
  - [x] `batch_create(records)`
  - [x] `update(record_id: str, fields: dict)`
  - [x] `batch_update(records: List[dict])`
  - [x] `batch_upsert(records: List[dict], key_fields: List[str])`
  - [x] `delete(record_id: str)`
  - [x] `batch_delete(record_ids: List[str])`
  - [ ] [`update_schema`](https://airtable.com/developers/web/api/update-table) (to be implemented)
  - [ ] [`create_field`](https://airtable.com/developers/web/api/create-field) (to be implemented)
  - [ ] [`update_field`](https://airtable.com/developers/web/api/update-field) (to be implemented)


So the most typical use cases would still be supported:

```python
>>> table = Table("token", "appWhatever", "tblWhatever")
>>> records = table.all(formula=formula)
>>> # do stuff
```

...but could also be achieved through composition, which might be useful in some circumstances (depending on how the implementer is using this library):

```python
>>> api = Api("token")
>>> table = Table(api, "appWhatever", "tblWhatever")
>>> records = table.all(formula=formula)
```

...or perhaps like this:

```python
>>> api = Api("token")
>>> table = api.base("appWhatever").table("tblWhatever")
>>> records = table.all(formula=formula)
```

There would be less confusion over inherited methods that don't quite apply:

```python
>>> table.get_table_url()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Table' object has no attribute 'get_table_url'
>>> table.url
'https://api.airtable.com/v0/appWhatever/tblWhatever'
```

...and there would be less boilerplate required every time we need to add a method, since we'd only ever add it in one place.

## Why _shouldn't_ we do this?

I can think of two big reasons:

1. Breaking API changes are never _fun_ and maybe we just don't want the hassle. I think this can be mitigated by (A) getting the constructors to take old-style arguments (`str`s) as well as new-style arguments (instances), and (B) using wrappers that emit deprecation warnings to preserve some of the old method names for a period of time.

2. It's not strictly necessary. We could start adding lots of things to the existing `Base` or `Table` classes and _not_ add them to the `ApiAbstract` base class. We'd be breaking an existing pattern, though, and it would be somewhat arbitrary which methods existed in all places and which ones only existed where they "belong". There would also still be some methods in `ApiAbstract` which don't belong in subclasses (like `get_table_url`).

I'm open to hearing about more reasons not to consider this :grin:



Discussion / hints:
@mesozoic I think that's pretty compelling and a great approach. 
I personally be ok with a clean break on the major bump. I think the majority of users tend to use only the table class, but your call .

Fix patch:
diff --git a/docs/source/api.rst b/docs/source/api.rst
index 2b0efc30..6ad62c6f 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -1,268 +1,27 @@
 .. include:: substitutions.rst
 
-Airtable Api
-============
-
-Overview
-********
-
-This client offers three classes you can use to access the Airtable Api:
-
-* :class:`~pyairtable.api.Table` - represents an Airtable **Table**
-* :class:`~pyairtable.api.Base` - represents an Airtable **Base**
-* :class:`~pyairtable.api.Api` - represents an Airtable **Api**
-
-The interfaces of these are nearly identical, the main difference
-is if ``base_id`` and ``table_id`` are provided on initialization or on calls.
-
-For example, the three ``all()`` calls below would return the same result:
-
-.. code-block:: python
-
-  from pyairtable import Api, Base, Table
-
-  api = Api('auth_token')
-  api.all('base_id', 'table_name')
-
-  base = Base('auth_token', 'base_id')
-  base.all('table_name')
-
-  table = Table('auth_token', 'base_id', 'table_name')
-  table.all()
-
-Interface
-***********
-
-The table below shows a comparison of the methods used in the library compared
-with the official API equivalent.
-
-.. list-table:: pyAirtable Api
-   :widths: 30 30 40
-   :header-rows: 1
-
-   * - Type
-     - pyAirtable
-     - Airtable Api
-   * - Retrieve a single Record
-     - ``get()``
-     - ``GET baseId/recordId``
-   * - Iterate over record pages
-     - ``iterate()``
-     - ``GET baseId/``
-   * - Get all records
-     - ``all()``
-     - ``GET baseId/``
-   * - Get all matches
-     - ``all(formula=match(...)``
-     - ``GET baseId/?filterByFormula=...``
-   * - Get first match
-     - ``first(formula=match(...)``
-     - ``GET baseId/?filterByFormula=...&maxRecords=1``
-   * - Create record
-     - ``create()``
-     - ``POST baseId/``
-   * - Update a record
-     - ``update()``
-     - ``PATCH baseId/``
-   * - Replace a record
-     - ``update(replace=True)``
-     - ``PUT base/``
-   * - Delete a record
-     - ``delete()``
-     - ``DELETE baseId/``
-
-Examples
-***********
-
-Examples below use the :class:`~pyairtable.api.Table` Api for conciseness -
-all methods are available for all three interfaces (``Api``, ``Base``, and ``Table``).
-
-Fetching Records
------------------
-
-:meth:`~pyairtable.api.Table.iterate`
-
-Iterate over a set of records of size ``page_size``, up until ``max_records`` or end of
-table, whichever is shorter.
-
-.. code-block:: python
-
-  >>> for records in table.iterate(page_size=100, max_records=1000):
-  ...     print(records)
-  [{'id': 'rec123asa23', 'fields': {'Last Name': 'Alfred', 'Age': 84}, ...}, ...]
-  [{'id': 'rec123asa23', 'fields': {'Last Name': 'Jameson', 'Age': 42}, ...}, ...]
-
-:meth:`~pyairtable.api.Table.all`
-
-This method returns a single list with all records in a table. Note that under the
-hood it uses :meth:`~pyairtable.api.Table.iterate` to fetch records so multiple requests might be made.
-
-.. code-block:: python
-
-  >>> table.all(sort=["First Name", "-Age"]):
-  [{'id': 'rec123asa23', 'fields': {'Last Name': 'Alfred', 'Age': 84}, ...}, ...]
-
-
-Creating Records
------------------
-
-:meth:`~pyairtable.api.Table.create`
-
-Creates a single record from a dictionary representing the table's fields.
-
-.. code-block:: python
-
-  >>> table.create({'First Name': 'John'})
-  {'id': 'rec123asa23', 'fields': {'First Name': 'John', ...}}
-
-
-:meth:`~pyairtable.api.Table.batch_create`
-
-Batch create records from a list of dictionaries representing the table's fields.
-
-.. code-block:: python
-
-  >>> table.batch_create([{'First Name': 'John'}, ...])
-  [{'id': 'rec123asa23', 'fields': {'First Name': 'John'}}, ...]
-
-
-Updating Records
------------------
-
-:meth:`~pyairtable.api.Table.update`
-
-Updates a single record for the provided ``record_id`` using a
-dictionary representing the table's fields.
-
-.. code-block:: python
-
-  >>> table.update('recwPQIfs4wKPyc9D', {"Age": 21})
-  [{'id': 'recwPQIfs4wKPyc9D', 'fields': {"First Name": "John", "Age": 21}}, ...]
-
-
-:meth:`~pyairtable.api.Table.batch_update`
-
-Batch update records from a list of records.
-
-.. code-block:: python
-
-  >>> table.batch_update([{"id": "recwPQIfs4wKPyc9D", "fields": {"First Name": "Matt"}}, ...])
-  [{'id': 'recwPQIfs4wKPyc9D', 'fields': {"First Name": "Matt", ...}}, ...]
+API Reference
+=============
 
 
-:meth:`~pyairtable.api.Table.batch_upsert`
-
-.. versionadded:: 1.5.0
-
-Batch upsert (create or update) records from a list of records. For details on the behavior
-of this Airtable API endpoint, see `Update multiple records <https://airtable.com/developers/web/api/update-multiple-records#request-performupsert-fieldstomergeon>`_.
-
-.. code-block:: python
-
-  >>> table.batch_upsert(
-  ...     [{"id": "recwPQIfs4wKPyc9D", "fields": {"First Name": "Matt"}}, ...],
-  ...     key_fields=["First Name"]
-  ... )
-  [{'id': 'recwPQIfs4wKPyc9D', 'fields': {'First Name': 'Matt', 'Age': 21, ...}}, ...]
-
-
-Deleting Records
------------------
-
-:meth:`~pyairtable.api.Table.delete`
-
-Deletes a single record using the provided ``record_id``.
-
-.. code-block:: python
-
-  >>> table.delete('recwPQIfs4wKPyc9D')
-  {'deleted': True, 'id': 'recwPQIfs4wKPyc9D'}
-
-:meth:`~pyairtable.api.Table.batch_delete`
-
-Batch delete records using a list of record ids.
-
-.. code-block:: python
-
-  >>> table.batch_delete(['recwPQIfs4wKPyc9D', 'recwAcQdqwe21asdf'])
-  [{'deleted': True, 'id': 'recwPQIfs4wKPyc9D'},
-   {'deleted': True, 'id': 'recwAcQdqwe21asdf'}]
-
-
-Return Values
--------------
-
-Return Values: when records are returned,
-will most often be a list of Airtable records (dictionary) in a format as shown below.
-
-.. code-block:: python
-
-  >>> table.all()
-  [
-      {
-          "records": [
-              {
-                  "id": "recwPQIfs4wKPyc9D",
-                  "fields": {
-                      "COLUMN_ID": "1",
-                  },
-                  "createdTime": "2017-03-14T22:04:31.000Z"
-              },
-              {
-                  "id": "rechOLltN9SpPHq5o",
-                  "fields": {
-                      "COLUMN_ID": "2",
-                  },
-                  "createdTime": "2017-03-20T15:21:50.000Z"
-              },
-              {
-                  "id": "rec5eR7IzKSAOBHCz",
-                  "fields": {
-                      "COLUMN_ID": "3",
-                  },
-                  "createdTime": "2017-08-05T21:47:52.000Z"
-              }
-          ],
-          "offset": "rec5eR7IzKSAOBHCz"
-      },
-      ...
-  ]
-
-
-The :class:`~pyairtable.api.Base` class is similar to :class:`~pyairtable.api.Table`, the main difference is that
-`table_name` is not provided during initialization. Instead, it can be
-specified on each request.
-
-.. code-block:: python
-
-  >>> base = Base('auth_token', 'base_id')
-  >>> base.all('Contacts')
-  [{'id': 'rec123asa23', 'fields': {'Last Name': 'Alfred', 'Age': 84}, ...]
-
-
--------------------------
-
-Classes
+Api
 *******
 
-Api
------
+.. autoclass:: pyairtable.Api
+    :members:
 
-.. versionadded:: 1.0.0
-.. autoclass:: pyairtable.api.Api
-  :members:
 
 Base
------
+*******
 
-.. versionadded:: 1.0.0
-.. autoclass:: pyairtable.api.Base
+.. autoclass:: pyairtable.Base
     :members:
 
+
 Table
------
-.. versionadded:: 1.0.0
-.. autoclass:: pyairtable.api.Table
+*******
+
+.. autoclass:: pyairtable.Table
     :members:
 
 
@@ -271,16 +30,16 @@ Retrying
 
 .. versionadded:: 1.4.0
 
-You may provide an instance of ``urllib3.util.Retry`` to configure
-retrying behaviour.
+You may provide an instance of ``urllib3.util.Retry`` to configure retrying behaviour,
+or you can use :func:`~pyairtable.retry_strategy` to quickly generate a
+``Retry`` instance with reasonable defaults (which you can adjust).
 
-The library also provides :func:`~pyairtable.api.retrying.retry_strategy` to quickly generate a
-``Retry`` instance with reasonable defaults that you can use as-is or with tweaks.
-
-.. note:: for backwards-compatibility, the default behavior is no retry (`retry_strategy=None`).
+.. note:: for backwards-compatibility, the default behavior is to not retry (`retry_strategy=None`).
   This may change in future releases.
 
-Default Retry Strategy
+Out of the box, :func:`~pyairtable.retry_strategy` will retry a request several times
+if it receives a 429 (which indicates you've exceeded Airtable's limit of 5 QPS per base) or
+if it receives a potentially transient server-side error (500, 502, 503, or 504).
 
 .. code-block:: python
 
@@ -288,25 +47,7 @@ Default Retry Strategy
   api = Api('auth_token', retry_strategy=retry_strategy())
 
 
-Adjusted Default Strategy
-
-.. code-block:: python
-
-  from pyairtable import Api, retry_strategy
-  api = Api('auth_token', retry_strategy=retry_strategy(total=3))
-
-Custom Retry
-
-.. code-block:: python
-
-  from pyairtable import Api, retry_strategy
-  from urllib3.util import Retry
-
-  myRetry = Retry(**kwargs)
-  api = Api('auth_token', retry_strategy=myRetry)
-
-
-.. autofunction:: pyairtable.api.retrying.retry_strategy
+.. autofunction:: pyairtable.retry_strategy
 
 
 
@@ -315,54 +56,43 @@ Parameters
 
 Airtable offers a variety of options to control how you fetch data.
 
-Most options in the Airtable Api (e.g. ``sort``, ``fields``, etc.)
-have a corresponding ``kwargs`` that can be used with fetching methods like :meth:`~pyairtable.api.Table.iterate`.
+Most options in the Airtable API (e.g. ``sort``, ``fields``, etc.)
+have a corresponding keyword argument that can be used with fetching methods
+like :meth:`~pyairtable.Table.iterate` or :meth:`~pyairtable.Table.all`.
 
 
 .. list-table::
-   :widths: 25 25 50
+   :widths: 35 65
    :header-rows: 1
 
    * - Keyword Argument
-     - Airtable Parameter
      - Notes
    * - ``max_records``
-     - ``maxRecords``
      - |kwarg_max_records|
    * - ``sort``
-     - ``sort``
      - |kwarg_sort|
    * - ``view``
-     - ``view``
      - |kwarg_view|
    * - ``page_size``
-     - ``pageSize``
      - |kwarg_page_size|
    * - ``formula``
-     - ``filterByFormula``
      - |kwarg_formula|
    * - ``fields``
-     - ``fields``
      - |kwarg_fields|
    * - ``cell_format``
-     - ``cellFormat``
      - |kwarg_cell_format|
    * - ``user_locale``
-     - ``userLocale``
      - |kwarg_user_locale|
    * - ``time_zone``
-     - ``timeZone``
      - |kwarg_time_zone|
    * - ``return_fields_by_field_id``
         .. versionadded:: 1.3.0
-     - ``returnFieldsByFieldId``
      - |kwarg_return_fields_by_field_id|
 
 
 Formulas
 ********
 
-.. versionadded:: 1.0.0
 
 The formula module provides functionality to help you compose Airtable formulas.
 For more information see `Airtable Formula Reference <https://support.airtable.com/hc/en-us/articles/203255215-Formula-field-reference>`_
@@ -375,14 +105,14 @@ against a Python dictionary:
 
 .. code-block:: python
 
-  >>> from pyairtable import Table
+  >>> import pyairtable
   >>> from pyairtable.formulas import match
-  >>> table = Table("auth_token", "base_id", "Contact")
+  >>> table = pyairtable.Api("auth_token").table("base_id", "Contact")
   >>> formula = match({"First Name": "John", "Age": 21})
-  >>> table.first(formula=formula)
-  {"id": "recUwKa6lbNSMsetH", "fields": {"First Name": "John", "Age": 21}}
   >>> formula
   "AND({First Name}='John',{Age}=21)"
+  >>> table.first(formula=formula)
+  {"id": "recUwKa6lbNSMsetH", "fields": {"First Name": "John", "Age": 21}}
 
 .. autofunction:: pyairtable.formulas.match
 
@@ -397,7 +127,6 @@ Formula Helpers
 Raw Formulas
 ------------
 
-.. versionadded:: 1.0.0
 
 This module also includes many lower level functions you
 can use if you want to compose formulas:
@@ -418,3 +147,9 @@ Types
 
 .. automodule:: pyairtable.api.types
     :members:
+
+Utilities
+***********
+
+.. automodule:: pyairtable.utils
+    :members:
diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst
index d5377c19..70dafe4b 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -2,68 +2,67 @@
 Changelog
 =========
 
-1.5.0
-------
+2.0.0 (TBD)
+------------------------
 
-Release Date: 2023-05-15
+See :ref:`Migrating from 1.x to 2.0` for detailed migration notes.
+
+* Removed ``ApiAbstract``.
+* Implemented strict type annotations on all functions and methods.
+* Added :ref:`ORM` support for all Airtable field types.
+  - `PR #260 <https://github.com/gtalarico/pyairtable/pull/260>`_.
+* Return Model instances, not dicts, from
+  :meth:`Model.all <pyairtable.orm.Model.all>` and :meth:`Model.first <pyairtable.orm.Model.first>`.
+  - `PR #262 <https://github.com/gtalarico/pyairtable/pull/262>`_.
+
+1.5.0 (2023-05-15)
+-------------------------
 
 * Add support for Airtable's upsert operation (see :ref:`Updating Records`).
   - `PR #255 <https://github.com/gtalarico/pyairtable/pull/255>`_.
-* Fix ``return_fields_by_field_id`` in :meth:`~pyairtable.api.Api.batch_create` and :meth:`~pyairtable.api.Api.batch_update`.
+* Fix ``return_fields_by_field_id`` in :meth:`~pyairtable.Api.batch_create` and :meth:`~pyairtable.Api.batch_update`.
   - `PR #252 <https://github.com/gtalarico/pyairtable/pull/252>`_.
 * Fix ORM crash when Airtable returned additional fields.
   - `PR #250 <https://github.com/gtalarico/pyairtable/pull/250>`_.
 * Use POST for URLs that are longer than the 16k character limit set by the Airtable API.
   - `PR #247 <https://github.com/gtalarico/pyairtable/pull/247>`_.
-* Added ``endpoint_url=`` param to :class:`~pyairtable.api.Table`, :class:`~pyairtable.api.Base`, :class:`~pyairtable.api.Api`.
+* Added ``endpoint_url=`` param to :class:`~pyairtable.Table`, :class:`~pyairtable.Base`, :class:`~pyairtable.Api`.
   - `PR #243 <https://github.com/gtalarico/pyairtable/pull/243>`_.
 * Added ORM :class:`~pyairtable.orm.fields.LookupField`.
   - `PR #182 <https://github.com/gtalarico/pyairtable/pull/182>`_.
 * Dropped support for Python 3.6 (reached end of life 2021-12-23)
   - `PR #213 <https://github.com/gtalarico/pyairtable/pull/213>`_.
 
-1.4.0
-------
-
-Release Date: 2022-12-14
+1.4.0 (2022-12-14)
+-------------------------
 
 * Added :ref:`Retrying` ()
 * Misc fix in sleep for batch requests `PR #180 <https://github.com/gtalarico/pyairtable/pull/180>`_.
 
-1.3.0
-------
-
-Release Date: 2022-08-23
+1.3.0 (2022-08-23)
+-------------------------
 
 * Added new ``LOWER`` formula - `PR #171 <https://github.com/gtalarico/pyairtable/pull/171>`_. See updated :ref:`Formulas`.
 * Added ``match(..., match_any=True)`` to :meth:`~pyairtable.formulas.match`
-* Added ``return_fields_by_field_id`` in :meth:`~pyairtable.api.Api.get`
+* Added ``return_fields_by_field_id`` in :meth:`~pyairtable.Api.get`
 
-1.2.0
-------
+1.2.0 (2022-07-09)
+-------------------------
 
-Release Date: 2022-07-09
-
-* Fixed missing rate limit in :meth:`~pyairtable.api.Api.batch_update` - `PR #162 <https://github.com/gtalarico/pyairtable/pull/162>`_.
+* Fixed missing rate limit in :meth:`~pyairtable.Api.batch_update` - `PR #162 <https://github.com/gtalarico/pyairtable/pull/162>`_.
 * Added support for new parameter `return_fields_by_field_id` - `PR #161 <https://github.com/gtalarico/pyairtable/pull/161>`_. See updated :ref:`Parameters`.
 * Added new ``OR`` formula - `PR #148 <https://github.com/gtalarico/pyairtable/pull/148>`_. See updated :ref:`Formulas`.
 
-1.1.0
-------
-
-Release Date: 2022-02-21
+1.1.0 (2022-02-21)
+-------------------------
 
 * Added support for ``cellFormat`` - `PR #140 <https://github.com/gtalarico/pyairtable/pull/140>`_.  See updated :ref:`Parameters`.
 
 
-1.0.0
-------
-
-Release Date: 2021-08-11
+1.0.0 (2021-08-11)
+-------------------------
 
 * pyAirtable rewrite for 1.x - see :doc:`migrations`.
 
-0.15.3
-------
-
-Release Date: 2021-07-26
+0.15.3 (2021-07-26)
+-------------------------
diff --git a/docs/source/conf.py b/docs/source/conf.py
index b224c46e..479dad27 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -38,6 +38,8 @@
 autodoc_default_options = {
     "exclude-members": "__new__",
 }
+autodoc_member_order = "bysource"
+
 
 # See https://github.com/tox-dev/sphinx-autodoc-typehints#options
 typehints_defaults = "comma"
@@ -53,9 +55,9 @@ def typehints_formatter(annotation, config):
         if annotation != value:
             continue
         if isinstance(value, type) and issubclass(value, dict):  # TypedDict
-            return f":data:`{name} <pyairtable.api.types.{name}>`"
+            return f":data:`~pyairtable.api.types.{name}`"
         if isinstance(value, typing._GenericAlias):  # Union, Dict, etc.
-            return f":data:`{name} <pyairtable.api.types.{name}>`"
+            return f":data:`~pyairtable.api.types.{name}`"
 
     return None
 
diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst
index be0ecd3c..f4351328 100644
--- a/docs/source/getting-started.rst
+++ b/docs/source/getting-started.rst
@@ -7,70 +7,261 @@ Getting Started
 
 
 Installation
-************
+------------
+
+Add the `pyairtable <https://pypi.org/project/pyairtable>`_ library to your project just as you would any other:
 
 .. code-block:: shell
 
     $ pip install pyairtable
 
-.. warning::
-    Looking for **airtable-python-wrapper**? Check out :doc:`migrations`.
-
-_______________________________________________
-
-
-Authorization Token
-*******************
-
-Airtable accepts two types of authentication tokens: Api Keys, and Personal Access tokens.
-Your auth token should be securely stored.
-A common way to do this, is to `store it as an environment variable <https://www.twilio.com/blog/2017/01/how-to-set-environment-variables.html>`_,
-and load it using ``os.environ``:
 
-.. code-block:: python
-
-    import os
-    api_key = os.environ["AIRTABLE_API_KEY"]
-
-.. Note:
-     Personal access tokens are a new, more secure way to grant API access to your Airtable data.
-     They can create multiple tokens, grant them access to specific bases, and manage them individually.
+Connecting to Airtable
+----------------------
 
-     Api keys are scheduled to be deprecated in upcoming versions.
+To begin, you will need an API key or a personal access token. If you do not have one yet,
+see `the Airtable guide to personal access tokens <https://airtable.com/developers/web/guides/personal-access-tokens>`_.
+`Airtable has deprecated API keys <https://support.airtable.com/docs/airtable-api-key-deprecation-notice>`_
+and they will stop working with the API in February 2024. Both can be used interchangeably with this library.
 
-Quickstart
-**********
+This library will not persist your access token anywhere. Your access token should be securely stored.
+A common method is to `store it as an environment variable <https://www.twilio.com/blog/2017/01/how-to-set-environment-variables.html>`_
+and load it using ``os.environ``.
 
-The easiest way to use this client is to use the :class:`~pyairtable.api.Table` class to fetch
-or update your records:
+The :class:`~pyairtable.Api` class represents a connection to Airtable, while the
+:class:`~pyairtable.Table` class exposes methods for retrieving, creating, and modifying
+records in Airtable:
 
 .. code-block:: python
 
     >>> import os
-    >>> from pyairtable import Table
-    >>> api_key = os.environ['AIRTABLE_API_KEY']
-    >>> table = Table(api_key, 'base_id', 'table_name')
+    >>> from pyairtable import Api
+    >>> api = Api(os.environ['AIRTABLE_API_KEY'])
+    >>> table = api.table('appExampleBaseId', 'tblExampleTableId')
     >>> table.all()
     [
         {
             "id": "rec5eR7IzKSAOBHCz",
             "createdTime": "2017-03-14T22:04:31.000Z",
-            "fields": {...}
+            "fields": {
+                "Name": "Alice",
+                "Exail": "alice@example.com"
+            }
         }
     ]
-    >>> table.create({"Foo": "Bar"})
+    >>> table.create({"Name": "Bob"})
     {
         "id": "recwAcQdqwe21asdf",
         "createdTime": "...",
-        "fields": {"Foo": "Bar"}
+        "fields": {"Name": "Bob"}
     }
-    >>> table.update("recwAcQdqwe21asdf", {"Foo": "Foo"})
+    >>> table.update("recwAcQdqwe21asdf", {"Name": "Robert"})
     {
         "id": "recwAcQdqwe21asdf",
         "createdTime": "...",
-        "fields": {"Foo": "Foo"}
+        "fields": {"Name": "Robert"}
     }
     >>> table.delete("recwAcQdqwe21asdf")
     {'id': 'recwAcQdqwe21asdf', 'deleted': True}
 
 For more details on the available classes and methods check out the :doc:`api` section.
+
+
+Supported Endpoints
+-------------------
+
+The Airtable API exposes a number of endpoints for manipulating data within tables.
+The grid below shows a comparison of the methods provided by this library alongside
+the official API equivalents.
+
+.. list-table::
+   :widths: 30 30 40
+   :header-rows: 1
+
+   * - Endpoint
+     - pyAirtable
+     - Airtable API
+   * - `Get a record <https://airtable.com/developers/web/api/get-record>`_
+     - :meth:`~pyairtable.Table.get`
+     - ``GET baseId/tableId/recordId``
+   * - `Get all records <https://airtable.com/developers/web/api/list-records>`_
+     - :meth:`~pyairtable.Table.all`
+     - ``GET baseId/tableId``
+   * - `Get matching records <https://airtable.com/developers/web/api/list-records>`_
+     - :meth:`all(formula=...) <pyairtable.Table.all>`
+     - ``GET baseId/tableId?filterByFormula=...``
+   * - `Get first match <https://airtable.com/developers/web/api/list-records>`_
+     - :meth:`~pyairtable.Table.first`
+     - ``GET baseId/tableId?maxRecords=1``
+   * - `Create a record <https://airtable.com/developers/web/api/create-records>`_
+     - :meth:`~pyairtable.Table.create`
+     - ``POST baseId/tableId``
+   * - `Update a record <https://airtable.com/developers/web/api/update-record>`_
+     - :meth:`~pyairtable.Table.update`
+     - ``PATCH baseId/tableId/recordId``
+   * - `Replace a record <https://airtable.com/developers/web/api/update-record>`_
+     - :meth:`update(replace=True) <pyairtable.Table.update>`
+     - ``PUT baseId/tableId/recordId``
+   * - `Delete a record <https://airtable.com/developers/web/api/delete-record>`_
+     - :meth:`~pyairtable.Table.delete`
+     - ``DELETE baseId/tableId/recordId``
+   * - `Create multiple records <https://airtable.com/developers/web/api/create-records>`_
+     - :meth:`~pyairtable.Table.batch_create`
+     - ``POST baseId/tableId``
+   * - `Update multiple records <https://airtable.com/developers/web/api/update-multiple-records>`_
+     - :meth:`~pyairtable.Table.batch_update`
+     - ``PATCH baseId/tableId``
+   * - `Upsert multiple records <https://airtable.com/developers/web/api/update-multiple-records>`_
+     - :meth:`~pyairtable.Table.batch_upsert`
+     - ``PATCH baseId/tableId``
+   * - `Delete multiple records <https://airtable.com/developers/web/api/delete-multiple-records>`_
+     - :meth:`~pyairtable.Table.batch_delete`
+     - ``DELETE baseId/tableId``
+
+
+Fetching Records
+-----------------
+
+.. warning:: |warn_rate_limit|
+
+:meth:`~pyairtable.Table.iterate`
+
+Iterate over a set of records of size ``page_size``, up until ``max_records`` or end of table.
+
+.. code-block:: python
+
+  >>> for records in table.iterate(page_size=100, max_records=1000):
+  ...     print(records)
+  ...
+  [{'id': 'rec123asa23', 'fields': {'Last Name': 'Alfred', 'Age': 84}, ...}, ...]
+  [{'id': 'rec123asa23', 'fields': {'Last Name': 'Jameson', 'Age': 42}, ...}, ...]
+
+:meth:`~pyairtable.Table.all`
+
+This method returns a single list with all records in a table. Note that under the
+hood it uses :meth:`~pyairtable.Table.iterate` to fetch records so it might make
+multiple requests.
+
+.. code-block:: python
+
+  >>> table.all(sort=["Name", "-Age"])
+  [{'id': 'rec123asa23', 'fields': {'Last Name': 'Alfred', 'Age': 84}, ...}, ...]
+
+
+Creating Records
+-----------------
+
+:meth:`~pyairtable.Table.create`
+
+Creates a single record from a dictionary representing the table's fields.
+
+.. code-block:: python
+
+  >>> table.create({'Name': 'John'})
+  {'id': 'rec123asa23', 'fields': {'Name': 'John', ...}}
+
+
+:meth:`~pyairtable.Table.batch_create`
+
+Create multiple records from a list of :class:`~pyairtable.api.types.Fields` dicts.
+
+.. code-block:: python
+
+  >>> table.batch_create([{'Name': 'John'}, ...])
+  [{'id': 'rec123asa23', 'fields': {'Name': 'John'}}, ...]
+
+
+Updating Records
+-----------------
+
+:meth:`~pyairtable.Table.update`
+
+Updates a single record for the provided ``record_id`` using a
+dictionary representing the table's fields.
+
+.. code-block:: python
+
+  >>> table.update('recwPQIfs4wKPyc9D', {"Age": 21})
+  [{'id': 'recwPQIfs4wKPyc9D', 'fields': {"Name": "John", "Age": 21}}, ...]
+
+
+:meth:`~pyairtable.Table.batch_update`
+
+Update multiple records from a list of :class:`~pyairtable.api.types.UpdateRecordDict`.
+
+.. code-block:: python
+
+  >>> table.batch_update([{"id": "recwPQIfs4wKPyc9D", "fields": {"Name": "Matt"}}, ...])
+  [{'id': 'recwPQIfs4wKPyc9D', 'fields': {"Name": "Matt", ...}}, ...]
+
+
+:meth:`~pyairtable.Table.batch_upsert`
+
+.. versionadded:: 1.5.0
+
+Batch upsert (create or update) records from a list of records. For details on the behavior
+of this Airtable API endpoint, see `Update multiple records`_.
+
+.. code-block:: python
+
+  >>> table.batch_upsert(
+  ...     [{"id": "recwPQIfs4wKPyc9D", "fields": {"Name": "Matt"}}, ...],
+  ...     key_fields=["Name"]
+  ... )
+  [{'id': 'recwPQIfs4wKPyc9D', 'fields': {'Name': 'Matt', ...}}, ...]
+
+
+Deleting Records
+-----------------
+
+:meth:`~pyairtable.Table.delete`
+
+Deletes a single record using the provided ``record_id``.
+
+.. code-block:: python
+
+  >>> table.delete('recwPQIfs4wKPyc9D')
+  {'deleted': True, 'id': 'recwPQIfs4wKPyc9D'}
+
+:meth:`~pyairtable.Table.batch_delete`
+
+Batch delete records using a list of record ids.
+
+.. code-block:: python
+
+  >>> table.batch_delete(['recwPQIfs4wKPyc9D', 'recwAcQdqwe21asdf'])
+  [{'deleted': True, 'id': 'recwPQIfs4wKPyc9D'},
+   {'deleted': True, 'id': 'recwAcQdqwe21asdf'}]
+
+
+Return Values
+-------------
+
+This library will return records as :class:`~pyairtable.api.types.RecordDict`.
+
+.. code-block:: python
+
+  >>> table.all()
+  [
+      {
+          'id': 'recwPQIfs4wKPyc9D',
+          'createdTime': '2017-03-14T22:04:31.000Z'
+          'fields': {
+              'Name': 'Alice',
+          },
+      },
+      {
+          'id': 'rechOLltN9SpPHq5o',
+          'createdTime': '2017-03-20T15:21:50.000Z'
+          'fields': {
+              'Name': 'Bob',
+          },
+      },
+      {
+          'id': 'rec5eR7IzKSAOBHCz',
+          'createdTime': '2017-08-05T21:47:52.000Z'
+          'fields': {
+              'Name': 'Carol',
+          },
+      }
+  ]
diff --git a/docs/source/index.rst b/docs/source/index.rst
index aacb0e1d..e16ef6c6 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -24,7 +24,6 @@ pyAirtable
 
    getting-started
    api
-   utils
    experimental
    migrations
 
diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst
index fd412b38..67c943e6 100644
--- a/docs/source/metadata.rst
+++ b/docs/source/metadata.rst
@@ -3,8 +3,6 @@
 Metadata Api
 ==============
 
-.. versionadded:: 1.0.0
-
 The metadata api gives you the ability to list all of your bases, tables, fields, and views.
 
 .. warning::
diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst
index f07e41a5..8bee5f90 100644
--- a/docs/source/migrations.rst
+++ b/docs/source/migrations.rst
@@ -1,30 +1,94 @@
+Migration Guide
+*****************
 
-0.x Migration
-**************
 
+Migrating from 1.x to 2.0
+============================
+
+With the 2.0 release, we've made some breaking changes to the pyAirtable API. These are summarized below.
+You can read more about the rationale behind these changes in `#257 <https://github.com/gtalarico/pyairtable/issues/257>`_,
+or you can read more about new library features in the :ref:`Changelog`.
+
+ApiAbstract removed
+-----------------------
+
+We've removed the ``pyairtable.api.abstract`` module. If you had code which inherited from ``ApiAbstract``,
+you will need to refactor it. We recommend taking an instance of :class:`~pyairtable.Api` as a
+constructor parameter, and using that to construct :class:`~pyairtable.Table` instances as needed.
+
+Changes to Api, Base, and Table
+-----------------------------------
+
+:class:`~pyairtable.Api`, :class:`~pyairtable.Base`, and :class:`~pyairtable.Table`
+no longer inherit from the same base class. Each has its own scope of responsibility and has
+methods which refer to the other classes as needed. See :ref:`Getting Started`.
+
+For a period of time, the constructor for :class:`~pyairtable.Base`, and :class:`~pyairtable.Table`
+will remain backwards-compatible with the previous approach (passing in ``str`` values),
+but doing so will produce deprecation warnings.
+
+See below for supported and unsupported patterns:
+
+.. code-block:: python
+
+    # The following are supported:
+    >>> api = Api(api_key, timeout=..., retry_strategy=..., endpoint_url=...)
+    >>> base = api.base(base_id)  # [str]
+    >>> base = Base(api, base_id)  # [Api, str]
+    >>> table = base.table(table_name)  # [str]
+    >>> table = api.table(base_id, table_name)  # [str, str]
+    >>> table = Table(None, base, table_name)  # [None, Base, str]
+
+    # The following are still supported but will issue a DeprecationWarning:
+    >>> base = Base(api_key, base_id)  # [str, str]
+    >>> table = Table(api_key, base_id, table_name)  # [str, str, str]
+
+    # The following will raise a TypeError for mixing str & instances:
+    >>> table = Table(api_key, base, table_name)  # [str, Base, str]
+    >>> table = Table(api, base_id, table_name)  # [Api, str, str]
+
+    # The following will raise a TypeError. We do this proactively
+    # to avoid situations where self.api and self.base don't align.
+    >>> table = Table(api, base_id, table_name)  # [Api, Base, str]
+
+Changes to the ORM
+------------------
+
+* :meth:`Model.all <pyairtable.orm.Model.all>` and :meth:`Model.first <pyairtable.orm.Model.first>`
+  return instances of the model class instead of returning dicts.
+
+Changes to types
+----------------
+
+* All functions and methods in this library have full type annotations that will pass ``mypy --strict``.
+  See the :ref:`Types` section for more information on the types this library accepts and returns.
+
+
+Migrating from 0.x to 1.0
+============================
 
 **Airtable Python Wrapper** was renamed to **pyAirtable** starting on its first major release, ``1.0.0``.
-The docs for the older release will remain `on Read the Docs <https://airtable-python-wrapper.readthedocs.io/>`_,
-the source code on `this branch <https://github.com/gtalarico/airtable-python-wrapper>`_.
-The last ``0.x`` release will remain available on `PYPI <https://pypi.org/project/airtable-python-wrapper/>`_.
+The docs for the older release will remain `on Read the Docs <https://airtable-python-wrapper.readthedocs.io/>`__,
+the source code on `this branch <https://github.com/gtalarico/airtable-python-wrapper>`__.
+The last ``0.x`` release will remain available on `PyPI <https://pypi.org/project/airtable-python-wrapper/>`__.
 
-You can read about the reasons behind the renaming `here <https://github.com/gtalarico/airtable-python-wrapper/issues/125#issuecomment-891439661>`_.
+You can read about the reasons behind the renaming `here <https://github.com/gtalarico/airtable-python-wrapper/issues/125#issuecomment-891439661>`__.
 
-New Features
-------------
+New Features in 1.0
+-------------------
 
 * Type Annotations
 * Simpler Api
 * Formulas
 * ORM Models
 
-API Changes
-------------
+API Changes in 1.0
+------------------
 
 We used this new major release to make a few breaking changes:
 
 * Introduced a simpler api that's more closely aligned with Airtable Api's patterns.
-* Created more a flexible API (:class:`~pyairtable.api.Api`, :class:`~pyairtable.api.Base`, :class:`~pyairtable.api.Table`)
+* Created more a flexible API (:class:`~pyairtable.Api`, :class:`~pyairtable.Base`, :class:`~pyairtable.Table`)
 
 
 .. list-table:: Changes
@@ -34,7 +98,7 @@ We used this new major release to make a few breaking changes:
    * - 0.x (airtable-python-wrapper)
      - 1.0 (pyAirtable)
    * - ``Airtable()``
-     - :class:`~pyairtable.api.Api`, :class:`~pyairtable.api.Base`, :class:`~pyairtable.api.Table`
+     - :class:`~pyairtable.Api`, :class:`~pyairtable.Base`, :class:`~pyairtable.Table`
    * - ``get()``
      - ``get()``
    * - ``get_iter()``
diff --git a/docs/source/orm.rst b/docs/source/orm.rst
index 84f8eed5..8fa1cfe3 100644
--- a/docs/source/orm.rst
+++ b/docs/source/orm.rst
@@ -3,8 +3,6 @@
 ORM
 ==============
 
-.. versionadded:: 1.0.0
-
 .. warning:: This feature is experimental. Feel free to submit suggestions or feedback in our
     `Github repo <https://github.com/gtalarico/pyairtable>`_
 
diff --git a/docs/source/substitutions.rst b/docs/source/substitutions.rst
index 71f6f607..3280dc9b 100644
--- a/docs/source/substitutions.rst
+++ b/docs/source/substitutions.rst
@@ -1,38 +1,18 @@
-
-
-.. |arg_api_key| replace:: An Airtable API Key or An Airtable Authorization Token.
-
 .. |arg_base_id| replace:: An Airtable base id.
 
 .. |arg_record_id| replace:: An Airtable record id.
 
-.. |arg_table_name| replace:: An Airtable table name. Table name should be unencoded,
-    as shown on browser.
-
-.. |arg_timeout| replace:: A tuple indicating a connect and read timeout.
-    eg. ``timeout=(2,5)`` would configure a 2 second timeout for
-    the connection to be established  and 5 seconds for a
-    server read timeout. Default is ``None`` (no timeout).
-
-.. |arg_retry_strategy| replace:: An instance of
-    `urllib3.util.Retry <https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html#urllib3.util.Retry>`__.
-    You can use :func:`~pyairtable.api.retrying.retry_strategy` to build one with reasonable
-    defaults, or provide your own custom instance of ``Retry``.
-    Default is ``None`` (no retry).
-
-.. |arg_endpoint_url| replace:: The API endpoint to hit. You might want to override it if you are using
-    a proxy to debug your API calls. Default is ``https://api.airtable.com``.
-
 .. |kwarg_view| replace:: The name or ID of a view.
     If set, only the records in that view will be returned.
     The records will be sorted according to the order of the view.
 
 .. |kwarg_page_size| replace:: The number of records returned
     in each request. Must be less than or equal to 100.
-    Default is 100.
+    If no value given, `Airtable's default <https://airtable.com/developers/web/api/list-records>`__ is 100.
 
 .. |kwarg_max_records| replace:: The maximum total number of
-    records that will be returned. If this value is larger than `page_size` multiple requests will be needed
+    records that will be returned. If this value is larger than
+    ``page_size``, multiple requests will be needed
     to fetch all records.
 
 .. |kwarg_fields| replace:: Name of field or fields  to
@@ -50,13 +30,13 @@
     prefixing the column name with a minus sign ``-``.
 
 .. |kwarg_formula| replace:: An Airtable formula. The formula will be evaluated for each record, and if the result
-    is not 0, false, "", NaN, [], or #Error! the record will be included
+    is none of ``0``, ``false``, ``""``, ``NaN``, ``[]``, or ``#Error!`` the record will be included
     in the response. If combined with view, only records in that view which satisfy the
     formula will be returned. For example, to only include records where
-    ``COLUMN_A`` isn't empty, pass in: ``"NOT({COLUMN_A}='')"``.
+    ``COLUMN_A`` isn't empty, pass in ``formula="{COLUMN_A}"``.
 
 .. |kwarg_typecast| replace:: The Airtable API will perform best-effort
-    automatic data conversion from string values. Default is False.
+    automatic data conversion from string values.
 
 .. |kwarg_cell_format| replace:: The cell format to request from the Airtable
     API. Supported options are `json` (the default) and `string`.
@@ -80,3 +60,8 @@
 
 .. |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.
+
+.. |warn_rate_limit| replace::
+    Due to Airtable API limits, the library inserts a 0.2 second delay in between requests.
+    If you retrieve many pages of records, expect the library to take several seconds to return.
+    Read more: `Rate limits <https://airtable.com/developers/web/api/rate-limits>`__
diff --git a/docs/source/utils.rst b/docs/source/utils.rst
deleted file mode 100644
index 7c2b49ac..00000000
--- a/docs/source/utils.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-Utilities
-=========
-
-.. versionadded:: 1.0.0
-
-.. automodule:: pyairtable.utils
-    :members:
diff --git a/pyairtable/api/abstract.py b/pyairtable/api/abstract.py
deleted file mode 100644
index a0bff4b7..00000000
--- a/pyairtable/api/abstract.py
+++ /dev/null
@@ -1,377 +0,0 @@
-import abc
-import posixpath
-import time
-from functools import lru_cache
-from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Tuple
-from urllib.parse import quote
-
-import requests
-from requests.sessions import Session
-from typing_extensions import TypeAlias
-
-from pyairtable.api.types import (
-    FieldName,
-    Fields,
-    RecordDeletedDict,
-    RecordDict,
-    RecordId,
-    UpdateRecordDict,
-    assert_typed_dict,
-    assert_typed_dicts,
-)
-from pyairtable.utils import chunked
-
-from .params import options_to_json_and_params, options_to_params
-from .retrying import Retry, _RetryingSession
-
-TimeoutTuple: TypeAlias = Tuple[int, int]
-
-
-if TYPE_CHECKING:
-    from pyairtable.api.base import Base  # noqa
-    from pyairtable.api.table import Table  # noqa
-
-
-class ApiAbstract(metaclass=abc.ABCMeta):
-    VERSION = "v0"
-    API_LIMIT = 1.0 / 5  # 5 per second
-    MAX_RECORDS_PER_REQUEST = 10
-    MAX_URL_LENGTH = 16000
-
-    session: Session
-    endpoint_url: str
-    timeout: Optional[TimeoutTuple]
-
-    def __init__(
-        self,
-        api_key: str,
-        timeout: Optional[TimeoutTuple] = None,
-        retry_strategy: Optional[Retry] = None,
-        endpoint_url: str = "https://api.airtable.com",
-    ):
-        if not retry_strategy:
-            self.session = Session()
-        else:
-            self.session = _RetryingSession(retry_strategy)
-
-        self.endpoint_url = endpoint_url
-        self.timeout = timeout
-        self.api_key = api_key
-
-    @property
-    def api_key(self) -> str:
-        """Returns the Airtable API Key"""
-        return self._api_key
-
-    @api_key.setter
-    def api_key(self, value: str) -> None:
-        """Returns the Airtable API Key"""
-        self._update_api_key(value)
-        self._api_key = value
-
-    def build_url(self, *components: str) -> str:
-        """
-        Returns 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)
-
-    def _update_api_key(self, api_key: str) -> None:
-        self.session.headers.update({"Authorization": "Bearer {}".format(api_key)})
-
-    @lru_cache()
-    def get_table_url(self, base_id: str, table_name: str) -> str:
-        url_safe_table_name = quote(table_name, safe="")
-        return self.build_url(base_id, url_safe_table_name)
-
-    @lru_cache()
-    def _get_record_url(
-        self, base_id: str, table_name: str, record_id: RecordId
-    ) -> str:
-        """Builds URL with record id"""
-        table_url = self.get_table_url(base_id, table_name)
-        return posixpath.join(table_url, record_id)
-
-    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()
-            except ValueError:
-                pass
-            else:
-                if "error" in error_dict:
-                    err_msg += " [Error: {}]".format(error_dict["error"])
-            exc.args = (*exc.args, err_msg)
-            raise exc
-        else:
-            return response.json()
-
-    def _request(
-        self,
-        method: str,
-        url: str,
-        fallback_post_url: Optional[str] = None,
-        options: Optional[Dict[str, Any]] = None,
-        params: Optional[Dict[str, Any]] = None,
-        json_data: Optional[Dict[Any, 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
-
-        Args:
-            method: HTTP method to use.
-            url: The URL we're attempting to call.
-
-        Keyword Args:
-            fallback_post_url (``str``, optional): The URL to use if we have to convert a GET to a POST.
-            options (``dict``, optional): Airtable-specific query params to use while fetching records.
-            params (``dict``, optional): Additional query params to append to the URL as-is.
-            json_data (``dict``, optional): The JSON payload for a POST/PUT/PATCH/DELETE request.
-        """
-        # Convert Airtable-specific options to query params, but give priority to query params
-        # that are explicitly passed via `params=`. This is to preserve backwards-compatibility for
-        # any library users who might be calling `self._request` directly.
-        request_params = {
-            **options_to_params(options or {}),
-            **(params or {}),
-        }
-
-        # Build a requests.PreparedRequest so we can examine how long the URL is.
-        prepared = self.session.prepare_request(
-            requests.Request(
-                method,
-                url=url,
-                params=request_params,
-                json=json_data,
-            )
-        )
-
-        # If our URL is too long, move *most* (not all) query params into a POST body.
-        if (
-            len(str(prepared.url)) >= self.MAX_URL_LENGTH
-            and method.upper() == "GET"
-            and fallback_post_url
-        ):
-            json_data, spare_params = options_to_json_and_params(options or {})
-            return self._request(
-                method="POST",
-                url=fallback_post_url,
-                params={**spare_params, **(params or {})},
-                json_data=json_data,
-            )
-
-        response = self.session.send(prepared, timeout=self.timeout)
-        return self._process_response(response)
-
-    def _get_record(
-        self,
-        base_id: str,
-        table_name: str,
-        record_id: RecordId,
-        **options: Any,
-    ) -> RecordDict:
-        record_url = self._get_record_url(base_id, table_name, record_id)
-        return assert_typed_dict(
-            RecordDict, self._request("get", record_url, options=options)
-        )
-
-    def _iterate(
-        self, base_id: str, table_name: str, **options: Any
-    ) -> Iterator[List[RecordDict]]:
-        offset = None
-        table_url = self.get_table_url(base_id, table_name)
-        while True:
-            if offset:
-                options.update({"offset": offset})
-            data = self._request(
-                method="get",
-                url=table_url,
-                fallback_post_url=f"{table_url}/listRecords",
-                options=options,
-            )
-            records = assert_typed_dicts(RecordDict, data.get("records", []))
-            yield records
-            offset = data.get("offset")
-            if not offset:
-                break
-            time.sleep(self.API_LIMIT)
-
-    def _first(
-        self, base_id: str, table_name: str, **options: Any
-    ) -> Optional[RecordDict]:
-        for records in self._iterate(
-            base_id, table_name, page_size=1, max_records=1, **options
-        ):
-            for record in records:
-                return record
-        return None
-
-    def _all(self, base_id: str, table_name: str, **options: Any) -> List[RecordDict]:
-        all_records = []
-        for records in self._iterate(base_id, table_name, **options):
-            all_records.extend(records)
-        return all_records
-
-    def _create(
-        self,
-        base_id: str,
-        table_name: str,
-        fields: Fields,
-        typecast: bool = False,
-        return_fields_by_field_id: bool = False,
-    ) -> RecordDict:
-        table_url = self.get_table_url(base_id, table_name)
-        created = self._request(
-            "post",
-            table_url,
-            json_data={
-                "fields": fields,
-                "typecast": typecast,
-                "returnFieldsByFieldId": return_fields_by_field_id,
-            },
-        )
-        return assert_typed_dict(RecordDict, created)
-
-    def _batch_create(
-        self,
-        base_id: str,
-        table_name: str,
-        records: List[Fields],
-        typecast: bool = False,
-        return_fields_by_field_id: bool = False,
-    ) -> List[RecordDict]:
-        table_url = self.get_table_url(base_id, table_name)
-        inserted_records = []
-        for chunk in chunked(records, self.MAX_RECORDS_PER_REQUEST):
-            new_records = [{"fields": fields} for fields in chunk]
-            response = self._request(
-                "post",
-                table_url,
-                json_data={
-                    "records": new_records,
-                    "typecast": typecast,
-                    "returnFieldsByFieldId": return_fields_by_field_id,
-                },
-            )
-            inserted_records += assert_typed_dicts(RecordDict, response["records"])
-            time.sleep(self.API_LIMIT)
-        return inserted_records
-
-    def _update(
-        self,
-        base_id: str,
-        table_name: str,
-        record_id: RecordId,
-        fields: Fields,
-        replace: bool = False,
-        typecast: bool = False,
-    ) -> RecordDict:
-        record_url = self._get_record_url(base_id, table_name, record_id)
-        method = "put" if replace else "patch"
-        updated = self._request(
-            method,
-            record_url,
-            json_data={"fields": fields, "typecast": typecast},
-        )
-        return assert_typed_dict(RecordDict, updated)
-
-    def _batch_update(
-        self,
-        base_id: str,
-        table_name: str,
-        records: List[UpdateRecordDict],
-        replace: bool = False,
-        typecast: bool = False,
-        return_fields_by_field_id: bool = False,
-    ) -> List[RecordDict]:
-        updated_records = []
-        table_url = self.get_table_url(base_id, table_name)
-        method = "put" if replace else "patch"
-        for chunk in chunked(records, self.MAX_RECORDS_PER_REQUEST):
-            chunk_records = [{"id": x["id"], "fields": x["fields"]} for x in chunk]
-            response = self._request(
-                method,
-                table_url,
-                json_data={
-                    "records": chunk_records,
-                    "typecast": typecast,
-                    "returnFieldsByFieldId": return_fields_by_field_id,
-                },
-            )
-            updated_records += assert_typed_dicts(RecordDict, response["records"])
-            time.sleep(self.API_LIMIT)
-
-        return updated_records
-
-    def _batch_upsert(
-        self,
-        base_id: str,
-        table_name: str,
-        records: List[UpdateRecordDict],
-        key_fields: List[FieldName],
-        replace: bool = False,
-        typecast: bool = False,
-        return_fields_by_field_id: bool = False,
-    ) -> List[RecordDict]:
-        # The API will reject a request where a record is missing any of fieldsToMergeOn,
-        # but we might not reach that error until we've done several batch operations.
-        # To spare implementers from having to recover from a partially applied upsert,
-        # and to simplify our API, we will raise an exception before any network calls.
-        for record in records:
-            if "id" in record:
-                continue
-            missing = set(key_fields) - set(record.get("fields", []))
-            if missing:
-                raise ValueError(f"missing {missing!r} in {record['fields'].keys()!r}")
-
-        updated_records = []
-        table_url = self.get_table_url(base_id, table_name)
-        method = "put" if replace else "patch"
-        for chunk in chunked(records, self.MAX_RECORDS_PER_REQUEST):
-            formatted_records = [
-                {k: v for (k, v) in record.items() if k in ("id", "fields")}
-                for record in chunk
-            ]
-            response = self._request(
-                method,
-                table_url,
-                json_data={
-                    "records": formatted_records,
-                    "typecast": typecast,
-                    "returnFieldsByFieldId": return_fields_by_field_id,
-                    "performUpsert": {"fieldsToMergeOn": key_fields},
-                },
-            )
-            updated_records += assert_typed_dicts(RecordDict, response["records"])
-            time.sleep(self.API_LIMIT)
-
-        return updated_records
-
-    def _delete(
-        self, base_id: str, table_name: str, record_id: RecordId
-    ) -> RecordDeletedDict:
-        record_url = self._get_record_url(base_id, table_name, record_id)
-        return assert_typed_dict(RecordDeletedDict, self._request("delete", record_url))
-
-    def _batch_delete(
-        self, base_id: str, table_name: str, record_ids: List[RecordId]
-    ) -> List[RecordDeletedDict]:
-        deleted_records = []
-        table_url = self.get_table_url(base_id, table_name)
-        for chunk in chunked(record_ids, self.MAX_RECORDS_PER_REQUEST):
-            delete_results = self._request(
-                "delete", table_url, params={"records[]": chunk}
-            )
-            deleted_records += assert_typed_dicts(
-                RecordDeletedDict, delete_results["records"]
-            )
-            time.sleep(self.API_LIMIT)
-        return deleted_records
diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py
index 4c9fe3b9..8e6a4be3 100644
--- a/pyairtable/api/api.py
+++ b/pyairtable/api/api.py
@@ -1,33 +1,42 @@
-from typing import Any, Iterator, List, Optional
+import posixpath
+import time
+from functools import lru_cache
+from typing import Any, Dict, Iterator, Optional, Sequence, Tuple, TypeVar
 
-from pyairtable.api.types import (
-    Fields,
-    RecordDeletedDict,
-    RecordDict,
-    RecordId,
-    UpdateRecordDict,
-)
+import requests
+from requests.sessions import Session
+from typing_extensions import TypeAlias
 
-from . import base, table
-from .abstract import ApiAbstract, TimeoutTuple
-from .retrying import Retry
+import pyairtable.api.base
+import pyairtable.api.table
+from pyairtable.api.params import options_to_json_and_params, options_to_params
+from pyairtable.utils import chunked
 
+from .retrying import Retry, _RetryingSession
 
-class Api(ApiAbstract):
-    """
-    Represents an Airtable Api.
+T = TypeVar("T")
+TimeoutTuple: TypeAlias = Tuple[int, int]
 
-    The Api Key or Authorization Token is provided on init and ``base_id`` and ``table_id``
-    can be provided on each method call.
 
-    If you are only operating on one Table, or one Base, consider using
-    :class:`Base` or :class:`Table`.
+class Api:
+    """
+    Represents an Airtable API. Implements basic URL construction,
+    session and request management, and retrying logic.
 
     Usage:
         >>> api = Api('auth_token')
         >>> api.all('base_id', 'table_name')
     """
 
+    VERSION = "v0"
+    API_LIMIT = 1.0 / 5  # 5 per second
+
+    #: Airtable-imposed limit on number of records per batch create/update operation.
+    MAX_RECORDS_PER_REQUEST = 10
+
+    #: Airtable-imposed limit on the length of a URL (including query parameters).
+    MAX_URL_LENGTH = 16000
+
     def __init__(
         self,
         api_key: str,
@@ -37,385 +46,152 @@ def __init__(
         endpoint_url: str = "https://api.airtable.com",
     ):
         """
-
-        Args:
-            api_key: |arg_api_key|
-
-        Keyword Args:
-            timeout: |arg_timeout|
-            retry_strategy: |arg_retry_strategy|
-            endpoint_url: |arg_endpoint_url|
-        """
-        super().__init__(
-            api_key,
-            timeout=timeout,
-            retry_strategy=retry_strategy,
-            endpoint_url=endpoint_url,
-        )
-
-    def get_table(self, base_id: str, table_name: str) -> "table.Table":
-        """
-        Returns a new :class:`Table` instance using all shared
-        attributes from :class:`Api`
-        """
-        return table.Table(
-            self.api_key,
-            base_id,
-            table_name,
-            timeout=self.timeout,
-            endpoint_url=self.endpoint_url,
-        )
-
-    def get_base(self, base_id: str) -> "base.Base":
-        """
-        Returns a new :class:`Base` instance using all shared
-        attributes from :class:`Api`
-        """
-        return base.Base(
-            self.api_key, base_id, timeout=self.timeout, endpoint_url=self.endpoint_url
-        )
-
-    def get_record_url(self, base_id: str, table_name: str, record_id: RecordId) -> str:
-        """
-        Returns a url for the provided record
-
         Args:
-            base_id: |arg_base_id|
-            table_name: |arg_table_name|
-
-        """
-        return super()._get_record_url(base_id, table_name, record_id)
-
-    def get(
-        self, base_id: str, table_name: str, record_id: RecordId, **options: Any
-    ) -> RecordDict:
-        """
-        Retrieves a record by its id
-
-        >>> record = api.get('base_id', 'table_name', 'recwPQIfs4wKPyc9D')
-
-        Args:
-            base_id: |arg_base_id|
-            table_name: |arg_table_name|
-            record_id: |arg_record_id|
-            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
-        """
-        return super()._get_record(base_id, table_name, record_id, **options)
-
-    def iterate(
-        self, base_id: str, table_name: str, **options: Any
-    ) -> Iterator[List[RecordDict]]:
-        """
-        Record Retriever Iterator
-
-        Returns iterator with lists in batches according to pageSize.
-        To get all records at once use :meth:`all`
-
-        >>> for page in api.iterate('base_id', 'table_name'):
-        ...     for record in page:
-        ...         print(record)
-        {"id": ... }
-        ...
-
-        Args:
-            base_id: |arg_base_id|
-            table_name: |arg_table_name|
-
-        Keyword Args:
-            view: |kwarg_view|
-            page_size: |kwarg_page_size|
-            max_records: |kwarg_max_records|
-            fields: |kwarg_fields|
-            sort: |kwarg_sort|
-            formula: |kwarg_formula|
-            cell_format: |kwarg_cell_format|
-            user_locale: |kwarg_user_locale|
-            time_zone: |kwarg_time_zone|
-            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
-
-        Returns:
-            Iterator of pages of records, no greater than ``page_size``
-        """
-        gen = super()._iterate(base_id, table_name, **options)
-        for i in gen:
-            yield i
-
-    def first(
-        self, base_id: str, table_name: str, **options: Any
-    ) -> Optional[RecordDict]:
-        """
-        Retrieves the first found record or ``None`` if no records are returned.
-
-        This is similar to :meth:`~pyairtable.api.api.Api.all`, except it
-        it sets ``page_size`` and ``max_records`` to ``1``.
-
-        Args:
-            base_id: |arg_base_id|
-            table_name: |arg_table_name|
-
-        Keyword Args:
-            view: |kwarg_view|
-            fields: |kwarg_fields|
-            sort: |kwarg_sort|
-            formula: |kwarg_formula|
-            cell_format: |kwarg_cell_format|
-            user_locale: |kwarg_user_locale|
-            time_zone: |kwarg_time_zone|
-            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
-        """
-        return super()._first(base_id, table_name, **options)
-
-    def all(self, base_id: str, table_name: str, **options: Any) -> List[RecordDict]:
-        """
-        Retrieves all records repetitively and returns a single list.
-
-        >>> api.all('base_id', 'table_name', view='MyView', fields=['ColA', '-ColB'])
-        [{'fields': ... }, ...]
-        >>> api.all('base_id', 'table_name', max_records=50)
-        [{'fields': ... }, ...]
-
-        Args:
-            base_id: |arg_base_id|
-            table_name: |arg_table_name|
-
-        Keyword Args:
-            view: |kwarg_view|
-            page_size: |kwarg_page_size|
-            max_records: |kwarg_max_records|
-            fields: |kwarg_fields|
-            sort: |kwarg_sort|
-            formula: |kwarg_formula|
-            cell_format: |kwarg_cell_format|
-            user_locale: |kwarg_user_locale|
-            time_zone: |kwarg_time_zone|
-            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
+            api_key: An Airtable API key or personal access token.
+            timeout: A tuple indicating a connect and read timeout.
+                e.g. ``timeout=(2,5)`` would configure a 2 second timeout for
+                the connection to be established  and 5 seconds for a
+                server read timeout. Default is ``None`` (no timeout).
+            retry_strategy: An instance of
+                `urllib3.util.Retry <https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html#urllib3.util.Retry>`__.
+                You can use :func:`~pyairtable.retry_strategy` to build one with reasonable
+                defaults, or provide your own custom instance of ``Retry``.
+            endpoint_url: The API endpoint to use. Override this if you are using
+                a debugging or caching proxy.
+        """
+        if not retry_strategy:
+            self.session = Session()
+        else:
+            self.session = _RetryingSession(retry_strategy)
+
+        self.endpoint_url = endpoint_url
+        self.timeout = timeout
+        self.api_key = api_key
+
+    @property
+    def api_key(self) -> str:
+        """
+        Airtable API key or access token to use on all connections.
+        """
+        return self._api_key
+
+    @api_key.setter
+    def api_key(self, value: str) -> None:
+        self.session.headers.update({"Authorization": "Bearer {}".format(value)})
+        self._api_key = value
 
-        Returns:
-            List of records retrieved.
-
-        >>> records = all(max_records=3, view='All')
-
-        """
-        return super()._all(base_id, table_name, **options)
-
-    def create(
-        self,
-        base_id: str,
-        table_name: str,
-        fields: Fields,
-        typecast: bool = False,
-        return_fields_by_field_id: bool = False,
-    ) -> RecordDict:
-        """
-        Creates a new record
-
-        >>> record = {'Name': 'John'}
-        >>> api.create('base_id', 'table_name', record)
-
-        Args:
-            base_id: |arg_base_id|
-            table_name: |arg_table_name|
-            fields: Fields to insert.
-            typecast: |kwarg_typecast|
-            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
-
-        Returns:
-            The created record.
-        """
-        return super()._create(
-            base_id,
-            table_name,
-            fields,
-            typecast=typecast,
-            return_fields_by_field_id=return_fields_by_field_id,
-        )
+    def __repr__(self) -> str:
+        return "<pyairtable.Api>"
 
-    def batch_create(
-        self,
-        base_id: str,
-        table_name: str,
-        records: List[Fields],
-        typecast: bool = False,
-        return_fields_by_field_id: bool = False,
-    ) -> List[RecordDict]:
+    @lru_cache
+    def base(self, base_id: str) -> "pyairtable.api.base.Base":
         """
-        Breaks records into chunks of 10 and inserts them in batches.
-        Follows the set API rate.
-        To change the rate limit you can change ``API_LIMIT = 0.2``
-        (5 per second)
-
-        >>> records = [{'Name': 'John'}, {'Name': 'Marc'}]
-        >>> api.batch_create('base_id', 'table_name', records)
-
-        Args:
-            base_id: |arg_base_id|
-            table_name: |arg_table_name|
-            records: List of dicts representing records to be created.
-            typecast: |kwarg_typecast|
-            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
-
-        Returns:
-            List of created records.
+        Returns a new :class:`Base` instance that uses this instance of :class:`Api`.
         """
-        return super()._batch_create(
-            base_id,
-            table_name,
-            records,
-            typecast=typecast,
-            return_fields_by_field_id=return_fields_by_field_id,
-        )
+        return pyairtable.api.base.Base(self, base_id)
 
-    def update(
-        self,
-        base_id: str,
-        table_name: str,
-        record_id: RecordId,
-        fields: Fields,
-        replace: bool = False,
-        typecast: bool = False,
-    ) -> RecordDict:
+    def table(self, base_id: str, table_name: str) -> "pyairtable.api.table.Table":
         """
-        Updates a record by its record id.
-        Only Fields passed are updated, the rest are left as is.
-
-        >>> table.update('recwPQIfs4wKPyc9D', {"Age": 21})
-        {id:'recwPQIfs4wKPyc9D', fields': {"First Name": "John", "Age": 21}}
-        >>> table.update('recwPQIfs4wKPyc9D', {"Age": 21}, replace=True)
-        {id:'recwPQIfs4wKPyc9D', fields': {"Age": 21}}
-
-        Args:
-            base_id: |arg_base_id|
-            table_name: |arg_table_name|
-            record_id: |arg_record_id|
-            fields: Fields to update. Must be a dict with column names or IDs as keys.
-            replace: |kwarg_replace|
-            typecast: |kwarg_typecast|
-
-        Returns:
-            The updated record.
+        Returns a new :class:`Table` instance that uses this instance of :class:`Api`.
         """
+        return self.base(base_id).table(table_name)
 
-        return super()._update(
-            base_id,
-            table_name,
-            record_id,
-            fields,
-            replace=replace,
-            typecast=typecast,
-        )
-
-    def batch_update(
-        self,
-        base_id: str,
-        table_name: str,
-        records: List[UpdateRecordDict],
-        replace: bool = False,
-        typecast: bool = False,
-        return_fields_by_field_id: bool = False,
-    ) -> List[RecordDict]:
+    def build_url(self, *components: str) -> str:
         """
-        Updates a records by their record id's in batch.
-
-        Args:
-            base_id: |arg_base_id|
-            table_name: |arg_table_name|
-            records: List of dicts with ``"id"`` and ``"fields"`` as keys.
-            replace: |kwarg_replace|
-            typecast: |kwarg_typecast|
-            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
-
-        Returns:
-            List of updated records.
+        Returns a URL to the Airtable API endpoint with the given URL components,
+        including the API version number.
         """
-        return super()._batch_update(
-            base_id,
-            table_name,
-            records,
-            replace=replace,
-            typecast=typecast,
-            return_fields_by_field_id=return_fields_by_field_id,
-        )
+        return posixpath.join(self.endpoint_url, self.VERSION, *components)
 
-    def batch_upsert(
+    def request(
         self,
-        base_id: str,
-        table_name: str,
-        records: List[UpdateRecordDict],
-        key_fields: List[str],
-        replace: bool = False,
-        typecast: bool = False,
-        return_fields_by_field_id: bool = False,
-    ) -> List[RecordDict]:
+        method: str,
+        url: str,
+        fallback: Optional[Tuple[str, str]] = None,
+        options: Optional[Dict[str, Any]] = None,
+        params: Optional[Dict[str, Any]] = None,
+        json: Optional[Dict[str, Any]] = None,
+    ) -> Any:
         """
-        Updates or creates 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>`_.
+        Makes a request to the Airtable API, optionally converting a GET to a POST
+        if the URL exceeds the API's maximum URL length.
 
-        .. versionadded:: 1.5.0
+        See https://support.airtable.com/docs/enforcement-of-url-length-limit-for-web-api-requests
 
         Args:
-            base_id: |arg_base_id|
-            table_name: |arg_table_name|
-            records: List of dicts with ``"id"`` and ``"fields"`` as keys.
-            key_fields: List of field names that Airtable should use to match
-                records in the input with existing records on the server.
-            replace: |kwarg_replace|
-            typecast: |kwarg_typecast|
-            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
-
-        Returns:
-            List of updated records.
-        """
-        return super()._batch_upsert(
-            base_id=base_id,
-            table_name=table_name,
-            records=records,
-            key_fields=key_fields,
-            replace=replace,
-            typecast=typecast,
-            return_fields_by_field_id=return_fields_by_field_id,
+            method: HTTP method to use.
+            url: The URL we're attempting to call.
+            fallback: The method and URL to use if we have to convert a GET to a POST.
+            options: Airtable-specific query params to use while fetching records.
+                See :ref:`Parameters` for valid options.
+            params: Additional query params to append to the URL as-is.
+            json: The JSON payload for a POST/PUT/PATCH/DELETE request.
+        """
+        # Convert Airtable-specific options to query params, but give priority to query params
+        # that are explicitly passed via `params=`. This is to preserve backwards-compatibility for
+        # any library users who might be calling `self._request` directly.
+        request_params = {
+            **options_to_params(options or {}),
+            **(params or {}),
+        }
+
+        # Build a requests.PreparedRequest so we can examine how long the URL is.
+        prepared = self.session.prepare_request(
+            requests.Request(
+                method,
+                url=url,
+                params=request_params,
+                json=json,
+            )
         )
 
-    def delete(
-        self, base_id: str, table_name: str, record_id: RecordId
-    ) -> RecordDeletedDict:
-        """
-        Deletes a record by its id
-
-        >>> record = api.match('base_id', 'table_name', 'Employee Id', 'DD13332454')
-        >>> api.delete('base_id', 'table_name', record['id'])
-
-        Args:
-            base_id: |arg_base_id|
-            table_name: |arg_table_name|
-            record_id: |arg_record_id|
-
-        Returns:
-            Confirmation of the deleted record.
-        """
-        return super()._delete(base_id, table_name, record_id)
-
-    def batch_delete(
-        self, base_id: str, table_name: str, record_ids: List[RecordId]
-    ) -> List[RecordDeletedDict]:
-        """
-        Breaks records into batches of 10 and deletes in batches, following set
-        API Rate Limit (5/sec).
-        To change the rate limit set value of ``API_LIMIT`` to
-        the time in seconds it should sleep before calling the function again.
-
-        >>> record_ids = ['recwPQIfs4wKPyc9D', 'recwDxIfs3wDPyc3F']
-        >>> api.batch_delete('base_id', 'table_name', records_ids)
-
-        Args:
-            base_id: |arg_base_id|
-            table_name: |arg_table_name|
-            record_ids: Record IDs to delete
-
-        Returns:
-            Confirmation of each record deleted.
-        """
-        return super()._batch_delete(base_id, table_name, record_ids)
-
-    def __repr__(self) -> str:
-        return "<pyairtable.Api>"
+        # If our URL is too long, move *most* (not all) query params into a POST body.
+        if (
+            fallback
+            and method.upper() == "GET"
+            and len(str(prepared.url)) >= self.MAX_URL_LENGTH
+        ):
+            json, spare_params = options_to_json_and_params(options or {})
+            return self.request(
+                method=fallback[0],
+                url=fallback[1],
+                params={**spare_params, **(params or {})},
+                json=json,
+            )
+
+        response = self.session.send(prepared, timeout=self.timeout)
+        return self._process_response(response)
+
+    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()
+            except ValueError:
+                pass
+            else:
+                if "error" in error_dict:
+                    err_msg += " [Error: {}]".format(error_dict["error"])
+            exc.args = (*exc.args, err_msg)
+            raise exc
+        else:
+            return response.json()
+
+    def chunked(self, iterable: Sequence[T]) -> Iterator[Sequence[T]]:
+        """
+        Iterates 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 wait(self) -> None:
+        """
+        Sleep for 1/N seconds, where N is the maximum RPS allowed by the Airtable API.
+
+        :meta private: because we expect to remove this soon.
+        """
+        time.sleep(self.API_LIMIT)
diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py
index 3aa7c2af..2d2d2b12 100644
--- a/pyairtable/api/base.py
+++ b/pyairtable/api/base.py
@@ -1,223 +1,63 @@
-from typing import Any, Iterator, List, Optional
+import warnings
+from functools import lru_cache
+from typing import Union
 
-from pyairtable.api.types import (
-    FieldName,
-    Fields,
-    RecordDeletedDict,
-    RecordDict,
-    RecordId,
-    UpdateRecordDict,
-)
+import pyairtable.api.api
+import pyairtable.api.table
 
-from . import table
-from .abstract import ApiAbstract, TimeoutTuple
-from .retrying import Retry
 
-
-class Base(ApiAbstract):
+class Base:
     """
-    Represents an Airtable Base. This class is similar to :class:`~pyairtable.api.Api`,
-    except ``base_id`` is provided on init instead of provided on each method call.
-
-    Usage:
-        >>> base = Base('auth_token', 'base_id')
-        >>> base.all()
+    Represents an Airtable base.
     """
 
-    base_id: str
+    api: "pyairtable.api.api.Api"
+    id: str
 
-    def __init__(
-        self,
-        api_key: str,
-        base_id: str,
-        *,
-        timeout: Optional[TimeoutTuple] = None,
-        retry_strategy: Optional[Retry] = None,
-        endpoint_url: str = "https://api.airtable.com",
-    ):
+    def __init__(self, api: Union["pyairtable.api.api.Api", str], base_id: str):
         """
-        Args:
-            api_key: |arg_api_key|
-            base_id: |arg_base_id|
+        Old style constructor takes ``str`` arguments, and will create its own
+        instance of :class:`Api`.
 
-        Keyword Args:
-            timeout: |arg_timeout|
-            retry_strategy: |arg_retry_strategy|
-            endpoint_url: |arg_endpoint_url|
-        """
+        This approach is deprecated, and will likely be removed in the future.
 
-        self.base_id = base_id
-        super().__init__(
-            api_key,
-            timeout=timeout,
-            retry_strategy=retry_strategy,
-            endpoint_url=endpoint_url,
-        )
+            >>> Base("access_token", "base_id")
 
-    def get_table(self, table_name: str) -> "table.Table":
-        """
-        Returns a new :class:`Table` instance using all shared
-        attributes from :class:`Base`
-        """
-        return table.Table(self.api_key, self.base_id, table_name, timeout=self.timeout)
+        New style constructor takes an instance of :class:`Api`:
 
-    def get_record_url(self, table_name: str, record_id: RecordId) -> str:
-        """
-        Same as :meth:`Api.get_record_url <pyairtable.api.Api.get_record_url>`
-        but without ``base_id`` arg.
-        """
-        return super()._get_record_url(self.base_id, table_name, record_id)
+            >>> Base(api, "table_name")
 
-    def get(self, table_name: str, record_id: RecordId) -> RecordDict:
-        """
-        Same as :meth:`Api.get <pyairtable.api.Api.get>`
-        but without ``base_id`` arg.
-        """
-        return super()._get_record(self.base_id, table_name, record_id)
-
-    def iterate(self, table_name: str, **options: Any) -> Iterator[List[RecordDict]]:
-        """
-        Same as :meth:`Api.iterate <pyairtable.api.Api.iterate>`
-        but without ``base_id`` arg.
-        """
-        gen = super()._iterate(self.base_id, table_name, **options)
-        for i in gen:
-            yield i
-
-    def first(self, table_name: str, **options: Any) -> Optional[RecordDict]:
-        """
-        Same as :meth:`Api.first <pyairtable.api.Api.first>`
-        but without ``base_id`` arg.
-        """
-        return super()._first(self.base_id, table_name, **options)
-
-    def all(self, table_name: str, **options: Any) -> List[RecordDict]:
-        """
-        Same as :meth:`Api.all <pyairtable.api.Api.all>`
-        but without ``base_id`` arg.
-        """
-        return super()._all(self.base_id, table_name, **options)
-
-    def create(
-        self,
-        table_name: str,
-        fields: Fields,
-        typecast: bool = False,
-        return_fields_by_field_id: bool = False,
-    ) -> RecordDict:
-        """
-        Same as :meth:`Api.create <pyairtable.api.Api.create>`
-        but without ``base_id`` arg.
-        """
-        return super()._create(
-            self.base_id,
-            table_name,
-            fields,
-            typecast=typecast,
-            return_fields_by_field_id=return_fields_by_field_id,
-        )
-
-    def batch_create(
-        self,
-        table_name: str,
-        records: List[Fields],
-        typecast: bool = False,
-        return_fields_by_field_id: bool = False,
-    ) -> List[RecordDict]:
-        """
-        Same as :meth:`Api.batch_create <pyairtable.api.Api.batch_create>`
-        but without ``base_id`` arg.
-        """
-        return super()._batch_create(
-            self.base_id,
-            table_name,
-            records,
-            typecast=typecast,
-            return_fields_by_field_id=return_fields_by_field_id,
-        )
-
-    def update(
-        self,
-        table_name: str,
-        record_id: RecordId,
-        fields: Fields,
-        replace: bool = False,
-        typecast: bool = False,
-    ) -> RecordDict:
-        """
-        Same as :meth:`Api.update <pyairtable.api.Api.update>`
-        but without ``base_id`` arg.
+        Args:
+            api: An instance of :class:`Api` or an Airtable access token.
+            base_id: |arg_base_id|
         """
-        return super()._update(
-            self.base_id,
-            table_name,
-            record_id,
-            fields,
-            replace=replace,
-            typecast=typecast,
-        )
+        if isinstance(api, str):
+            warnings.warn(
+                "Passing API keys to pyairtable.Base is deprecated; use Api.base() instead."
+                " See https://pyairtable.rtfd.org/en/latest/migrations.html for details.",
+                category=DeprecationWarning,
+                stacklevel=2,
+            )
+            api = pyairtable.api.api.Api(api)
 
-    def batch_update(
-        self,
-        table_name: str,
-        records: List[UpdateRecordDict],
-        replace: bool = False,
-        typecast: bool = False,
-        return_fields_by_field_id: bool = False,
-    ) -> List[RecordDict]:
-        """
-        Same as :meth:`Api.batch_update <pyairtable.api.Api.batch_update>`
-        but without ``base_id`` arg.
-        """
-        return super()._batch_update(
-            self.base_id,
-            table_name,
-            records,
-            replace=replace,
-            typecast=typecast,
-            return_fields_by_field_id=return_fields_by_field_id,
-        )
+        self.api = api
+        self.id = base_id
 
-    def batch_upsert(
-        self,
-        table_name: str,
-        records: List[UpdateRecordDict],
-        key_fields: List[FieldName],
-        replace: bool = False,
-        typecast: bool = False,
-        return_fields_by_field_id: bool = False,
-    ) -> List[RecordDict]:
-        """
-        Same as :meth:`Api.batch_upsert <pyairtable.api.Api.batch_upsert>`
-        but without ``base_id`` arg.
-        """
-        return super()._batch_upsert(
-            self.base_id,
-            table_name,
-            records,
-            key_fields=key_fields,
-            replace=replace,
-            typecast=typecast,
-            return_fields_by_field_id=return_fields_by_field_id,
-        )
+    def __repr__(self) -> str:
+        return f"<pyairtable.Base base_id={self.id!r}>"
 
-    def delete(self, table_name: str, record_id: RecordId) -> RecordDeletedDict:
+    @lru_cache
+    def table(self, table_name: str) -> "pyairtable.api.table.Table":
         """
-        Same as :meth:`Api.delete <pyairtable.api.Api.delete>`
-        but without ``base_id`` arg.
-        """
-        return super()._delete(self.base_id, table_name, record_id)
+        Returns a new :class:`Table` instance using all shared
+        attributes from :class:`Base`.
 
-    def batch_delete(
-        self,
-        table_name: str,
-        record_ids: List[RecordId],
-    ) -> List[RecordDeletedDict]:
-        """
-        Same as :meth:`Api.batch_delete <pyairtable.api.Api.batch_delete>`
-        but without ``base_id`` arg.
+        Args:
+            table_name: An Airtable table name. Table name should be unencoded,
+                as shown on browser.
         """
-        return super()._batch_delete(self.base_id, table_name, record_ids)
+        return pyairtable.api.table.Table(None, self, table_name)
 
-    def __repr__(self) -> str:
-        return "<Airtable Base id={}>".format(self.base_id)
+    @property
+    def url(self) -> str:
+        return self.api.build_url(self.id)
diff --git a/pyairtable/api/retrying.py b/pyairtable/api/retrying.py
index 3780370e..dfdf8981 100644
--- a/pyairtable/api/retrying.py
+++ b/pyairtable/api/retrying.py
@@ -18,17 +18,20 @@ def retry_strategy(
 ) -> Retry:
     """
     Creates a ``Retry`` instance with optional default values.
-    See `urllib3 Retry docs <https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html>`__
-    for more details.
+    See `urllib3.util.Retry`_ for more details.
 
     .. versionadded:: 1.4.0
 
-    Keyword Args:
-        status_forcelist (``Tuple[int, ...]``): list of status codes which should be retried.
-        backoff_factor (``int`` or ``float``): backoff factor.
-        total (``int``): max. number of retries. Note ``0`` means no retries,
-            while ``1`` will execute a total of two requests (1 + 1 retry).
-        **kwargs: Any valid parameter to `urllib3.util.Retry <https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html#urllib3.util.Retry>`__.
+    Args:
+        status_forcelist: Status codes which should be retried.
+        backoff_factor:
+            A backoff factor to apply between attempts after the second try.
+            Sleep time between each request will be calculated as
+            ``backoff_factor * (2 ** (retry_count - 1))``
+        total:
+            Maximum number of retries. Note that ``0`` means no retries,
+            whereas ``1`` will execute a total of two requests (original + 1 retry).
+        **kwargs: Any valid parameter to `urllib3.util.Retry <https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html#urllib3.util.Retry>`_.
     """
     return Retry(
         total=total,
diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py
index 385e1937..e0f61d2b 100644
--- a/pyairtable/api/table.py
+++ b/pyairtable/api/table.py
@@ -1,5 +1,10 @@
-from typing import Any, Iterator, List, Optional
+import urllib.parse
+import warnings
+from typing import Any, Iterator, List, Optional, Union, overload
 
+import pyairtable.api.api
+import pyairtable.api.base
+from pyairtable.api.retrying import Retry
 from pyairtable.api.types import (
     FieldName,
     Fields,
@@ -7,105 +12,221 @@
     RecordDict,
     RecordId,
     UpdateRecordDict,
+    assert_typed_dict,
+    assert_typed_dicts,
 )
 
-from . import base
-from .abstract import ApiAbstract, TimeoutTuple
-from .retrying import Retry
 
-
-class Table(ApiAbstract):
+class Table:
     """
-    Represents an Airtable Table. This class is similar to :class:`~pyairtable.api.Api`,
-    except ``base_id`` and ``table_id`` are provided on init instead of provided
-    on each method call.
+    Represents an Airtable table.
 
     Usage:
-        >>> table = Table('apikey/accesstoken', 'base_id', 'table_name')
-        >>> table.all()
+        >>> api = Api(access_token)
+        >>> table = api.table("base_id", "table_name")
+        >>> records = table.all()
     """
 
-    base_id: str
-    table_name: str
+    api: "pyairtable.api.api.Api"
+    base: "pyairtable.api.base.Base"
+    name: str
 
+    @overload
     def __init__(
         self,
         api_key: str,
         base_id: str,
         table_name: str,
         *,
-        timeout: Optional[TimeoutTuple] = None,
+        timeout: Optional["pyairtable.api.api.TimeoutTuple"] = None,
         retry_strategy: Optional[Retry] = None,
         endpoint_url: str = "https://api.airtable.com",
+    ):
+        ...
+
+    @overload
+    def __init__(
+        self,
+        api_key: None,
+        base_id: "pyairtable.api.base.Base",
+        table_name: str,
+    ):
+        ...
+
+    def __init__(
+        self,
+        api_key: Union[None, str],
+        base_id: Union["pyairtable.api.base.Base", str],
+        table_name: str,
+        **kwargs: Any,
     ):
         """
-        Args:
-            api_key: |arg_api_key|
-            base_id: |arg_base_id|
-            table_name: |arg_table_name|
+        Old style constructor takes ``str`` arguments, and will create its own
+        instance of :class:`Api`. This constructor can also be provided with
+        keyword arguments to the :class:`Api` class.
 
-        Keyword Args:
-            timeout: |arg_timeout|
-            retry_strategy: |arg_retry_strategy|
-            endpoint_url: |arg_endpoint_url|
-        """
-        self.base_id = base_id
-        self.table_name = table_name
-        super().__init__(
-            api_key,
-            timeout=timeout,
-            retry_strategy=retry_strategy,
-            endpoint_url=endpoint_url,
-        )
+        This approach is deprecated, and will likely be removed in the future.
 
-    @property
-    def table_url(self) -> str:
-        """Returns the table URL"""
-        return super().get_table_url(self.base_id, self.table_name)
+            >>> Table("api_key", "base_id", "table_name", timeout=(1, 1))
 
-    def get_base(self) -> "base.Base":
+        New style constructor has an odd signature to preserve backwards-compatibility
+        with the old style (above), requiring ``None`` as the first argument, followed by
+        an instance of :class:`Base`, followed by the table name.
+
+            >>> Table(None, base, "table_name")
+
+        These signatures may change in the future. Developers using this library are
+        encouraged to not construct Table instances directly, and instead fetch tables
+        via :meth:`Api.table() <pyairtable.Api.table>`.
         """
-        Returns a new :class:`Base` instance using all shared
-        attributes from :class:`Table`
+        if isinstance(api_key, str) and isinstance(base_id, str):
+            warnings.warn(
+                "Passing API keys or base IDs to pyairtable.Table is deprecated;"
+                " use Api.table() or Base.table() instead."
+                " See https://pyairtable.rtfd.org/en/latest/migrations.html for details.",
+                category=DeprecationWarning,
+                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
+        else:
+            raise TypeError(
+                "Table() expects either (str, str, str) or (None, Base, str);"
+                f" got ({type(api_key)}, {type(base_id)}, {type(table_name)})"
+            )
+
+        self.api = base.api
+        self.base = base
+        self.name = table_name
+
+    def __repr__(self) -> str:
+        return f"<Table base_id={self.base.id!r} table_name={self.name!r}>"
+
+    @property
+    def url(self) -> str:
+        """
+        Return the URL for this table.
         """
-        return base.Base(self.api_key, self.base_id, timeout=self.timeout)
+        return self.api.build_url(self.base.id, urllib.parse.quote(self.name, safe=""))
 
-    def get_record_url(self, record_id: RecordId) -> str:
+    def record_url(self, record_id: RecordId) -> str:
         """
-        Same as :meth:`Api.get_record_url <pyairtable.api.Api.get_record_url>`
-        but without ``base_id`` and ``table_name`` arg.
+        Return the URL for the given record ID.
         """
-        return super()._get_record_url(self.base_id, self.table_name, record_id)
+        return f"{self.url}/{record_id}"
 
     def get(self, record_id: RecordId, **options: Any) -> RecordDict:
         """
-        Same as :meth:`Api.get <pyairtable.api.Api.get>`
-        but without ``base_id`` and ``table_name`` arg.
+        Retrieves a record by its ID.
+
+        >>> table.get('recwPQIfs4wKPyc9D')
+        {'id': 'recwPQIfs4wKPyc9D', 'fields': {'First Name': 'John', 'Age': 21}}
+
+        Args:
+            record_id: |arg_record_id|
+
+        Keyword Args:
+            cell_format: |kwarg_cell_format|
+            time_zone: |kwarg_time_zone|
+            user_locale: |kwarg_user_locale|
+            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
         """
-        return super()._get_record(self.base_id, self.table_name, record_id, **options)
+        record = self.api.request("get", self.record_url(record_id), options=options)
+        return assert_typed_dict(RecordDict, record)
 
     def iterate(self, **options: Any) -> Iterator[List[RecordDict]]:
         """
-        Same as :meth:`Api.iterate <pyairtable.api.Api.iterate>`
-        but without ``base_id`` and ``table_name`` arg.
+        Iterates 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()
+        >>> next(it)
+        [{"id": ...}, {"id": ...}, {"id": ...}, ...]
+        >>> next(it)
+        [{"id": ...}, {"id": ...}, {"id": ...}, ...]
+        >>> next(it)
+        [{"id": ...}]
+        >>> next(it)
+        Traceback (most recent call last):
+        StopIteration
+
+        Keyword Args:
+            view: |kwarg_view|
+            page_size: |kwarg_page_size|
+            max_records: |kwarg_max_records|
+            fields: |kwarg_fields|
+            sort: |kwarg_sort|
+            formula: |kwarg_formula|
+            cell_format: |kwarg_cell_format|
+            user_locale: |kwarg_user_locale|
+            time_zone: |kwarg_time_zone|
+            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
         """
-        gen = super()._iterate(self.base_id, self.table_name, **options)
-        for i in gen:
-            yield i
+        offset = None
+        while True:
+            if offset:
+                options.update({"offset": offset})
+            data = self.api.request(
+                method="get",
+                url=self.url,
+                fallback=("post", f"{self.url}/listRecords"),
+                options=options,
+            )
+            records = assert_typed_dicts(RecordDict, data.get("records", []))
+            yield records
+            offset = data.get("offset")
+            if not offset:
+                break
+            self.api.wait()
 
-    def first(self, **options: Any) -> Optional[RecordDict]:
+    def all(self, **options: Any) -> List[RecordDict]:
         """
-        Same as :meth:`Api.first <pyairtable.api.Api.first>`
-        but without ``base_id`` and ``table_name`` arg.
+        Retrieves all matching records in a single list.
+
+        >>> api.all('base_id', 'table_name', view='MyView', fields=['ColA', '-ColB'])
+        [{'fields': ...}, ...]
+        >>> api.all('base_id', 'table_name', max_records=50)
+        [{'fields': ...}, ...]
+
+        Keyword Args:
+            view: |kwarg_view|
+            page_size: |kwarg_page_size|
+            max_records: |kwarg_max_records|
+            fields: |kwarg_fields|
+            sort: |kwarg_sort|
+            formula: |kwarg_formula|
+            cell_format: |kwarg_cell_format|
+            user_locale: |kwarg_user_locale|
+            time_zone: |kwarg_time_zone|
+            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
         """
-        return super()._first(self.base_id, self.table_name, **options)
+        return [record for page in self.iterate(**options) for record in page]
 
-    def all(self, **options: Any) -> List[RecordDict]:
+    def first(self, **options: Any) -> Optional[RecordDict]:
         """
-        Same as :meth:`Api.all <pyairtable.api.Api.all>`
-        but without ``base_id`` and ``table_name`` arg.
+        Retrieves the first matching record.
+        Returns ``None`` if no records are returned.
+
+        This is similar to :meth:`~pyairtable.Table.all`, except
+        it sets ``page_size`` and ``max_records`` to ``1``.
+
+        Keyword Args:
+            view: |kwarg_view|
+            fields: |kwarg_fields|
+            sort: |kwarg_sort|
+            formula: |kwarg_formula|
+            cell_format: |kwarg_cell_format|
+            user_locale: |kwarg_user_locale|
+            time_zone: |kwarg_time_zone|
+            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
         """
-        return super()._all(self.base_id, self.table_name, **options)
+        options.update(dict(page_size=1, max_records=1))
+        for page in self.iterate(**options):
+            for record in page:
+                return record
+        return None
 
     def create(
         self,
@@ -114,16 +235,26 @@ def create(
         return_fields_by_field_id: bool = False,
     ) -> RecordDict:
         """
-        Same as :meth:`Api.create <pyairtable.api.Api.create>`
-        but without ``base_id`` and ``table_name`` arg.
+        Creates a new record
+
+        >>> record = {'Name': 'John'}
+        >>> api.create('base_id', 'table_name', record)
+
+        Args:
+            fields: Fields to insert. Must be a dict with field names or IDs as keys.
+            typecast: |kwarg_typecast|
+            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
         """
-        return super()._create(
-            self.base_id,
-            self.table_name,
-            fields,
-            typecast=typecast,
-            return_fields_by_field_id=return_fields_by_field_id,
+        created = self.api.request(
+            method="post",
+            url=self.url,
+            json={
+                "fields": fields,
+                "typecast": typecast,
+                "returnFieldsByFieldId": return_fields_by_field_id,
+            },
         )
+        return assert_typed_dict(RecordDict, created)
 
     def batch_create(
         self,
@@ -132,16 +263,44 @@ def batch_create(
         return_fields_by_field_id: bool = False,
     ) -> List[RecordDict]:
         """
-        Same as :meth:`Api.batch_create <pyairtable.api.Api.batch_create>`
-        but without ``base_id`` and ``table_name`` arg.
+        Creats a number of new records in batches.
+
+        >>> table.batch_create([{'Name': 'John'}, {'Name': 'Marc'}])
+        [
+            {
+                'id': 'recW9e0c9w0er9gug',
+                'createdTime': '2017-03-14T22:04:31.000Z',
+                'fields': {'Name': 'John'}
+            },
+            {
+                'id': 'recW9e0c9w0er9guh',
+                'createdTime': '2017-03-14T22:04:31.000Z',
+                'fields': {'Name': 'Marc'}
+            }
+        ]
+
+        Args:
+            records: List of dicts representing records to be created.
+            typecast: |kwarg_typecast|
+            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
         """
-        return super()._batch_create(
-            self.base_id,
-            self.table_name,
-            records,
-            typecast=typecast,
-            return_fields_by_field_id=return_fields_by_field_id,
-        )
+        inserted_records = []
+
+        for chunk in self.api.chunked(records):
+            new_records = [{"fields": fields} for fields in chunk]
+            response = self.api.request(
+                method="post",
+                url=self.url,
+                json={
+                    "records": new_records,
+                    "typecast": typecast,
+                    "returnFieldsByFieldId": return_fields_by_field_id,
+                },
+            )
+            inserted_records += assert_typed_dicts(RecordDict, response["records"])
+            self.api.wait()
+
+        return inserted_records
 
     def update(
         self,
@@ -151,17 +310,26 @@ def update(
         typecast: bool = False,
     ) -> RecordDict:
         """
-        Same as :meth:`Api.update <pyairtable.api.Api.update>`
-        but without ``base_id`` and ``table_name`` arg.
+        Updates a particular record ID with the given fields.
+
+        >>> table.update('recwPQIfs4wKPyc9D', {"Age": 21})
+        {'id': 'recwPQIfs4wKPyc9D', 'fields': {'First Name': 'John', 'Age': 21}}
+        >>> table.update('recwPQIfs4wKPyc9D', {"Age": 22}, replace=True)
+        {'id': 'recwPQIfs4wKPyc9D', 'fields': {'Age': 22}}
+
+        Args:
+            record_id: |arg_record_id|
+            fields: Fields to update. Must be a dict with column names or IDs as keys.
+            replace: |kwarg_replace|
+            typecast: |kwarg_typecast|
         """
-        return super()._update(
-            self.base_id,
-            self.table_name,
-            record_id,
-            fields,
-            replace=replace,
-            typecast=typecast,
+        method = "put" if replace else "patch"
+        updated = self.api.request(
+            method=method,
+            url=self.record_url(record_id),
+            json={"fields": fields, "typecast": typecast},
         )
+        return assert_typed_dict(RecordDict, updated)
 
     def batch_update(
         self,
@@ -171,17 +339,35 @@ def batch_update(
         return_fields_by_field_id: bool = False,
     ) -> List[RecordDict]:
         """
-        Same as :meth:`Api.batch_update <pyairtable.api.Api.batch_update>`
-        but without ``base_id`` and ``table_name`` arg.
+        Updates several records in batches.
+
+        Args:
+            records: Records to update.
+            replace: |kwarg_replace|
+            typecast: |kwarg_typecast|
+            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
+
+        Returns:
+            The list of updated records.
         """
-        return super()._batch_update(
-            self.base_id,
-            self.table_name,
-            records,
-            replace=replace,
-            typecast=typecast,
-            return_fields_by_field_id=return_fields_by_field_id,
-        )
+        updated_records = []
+        method = "put" if replace else "patch"
+
+        for chunk in self.api.chunked(records):
+            chunk_records = [{"id": x["id"], "fields": x["fields"]} for x in chunk]
+            response = self.api.request(
+                method=method,
+                url=self.url,
+                json={
+                    "records": chunk_records,
+                    "typecast": typecast,
+                    "returnFieldsByFieldId": return_fields_by_field_id,
+                },
+            )
+            updated_records += assert_typed_dicts(RecordDict, response["records"])
+            self.api.wait()
+
+        return updated_records
 
     def batch_upsert(
         self,
@@ -192,32 +378,95 @@ def batch_upsert(
         return_fields_by_field_id: bool = False,
     ) -> List[RecordDict]:
         """
-        Same as :meth:`Api.batch_upsert <pyairtable.api.Api.batch_upsert>`
-        but without ``base_id`` and ``table_name`` arg.
-        """
-        return super()._batch_upsert(
-            self.base_id,
-            self.table_name,
-            records,
-            key_fields=key_fields,
-            replace=replace,
-            typecast=typecast,
-            return_fields_by_field_id=return_fields_by_field_id,
-        )
+        Updates or creates 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>`__.
+
+        .. versionadded:: 1.5.0
+
+        Args:
+            records: Records to update.
+            key_fields: List of field names that Airtable should use to match
+                records in the input with existing records on the server.
+            replace: |kwarg_replace|
+            typecast: |kwarg_typecast|
+            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
+
+        Returns:
+            The list of updated records.
+        """
+        # The API will reject a request where a record is missing any of fieldsToMergeOn,
+        # but we might not reach that error until we've done several batch operations.
+        # To spare implementers from having to recover from a partially applied upsert,
+        # and to simplify our API, we will raise an exception before any network calls.
+        for record in records:
+            if "id" in record:
+                continue
+            missing = set(key_fields) - set(record.get("fields", []))
+            if missing:
+                raise ValueError(f"missing {missing!r} in {record['fields'].keys()!r}")
+
+        updated_records = []
+        method = "put" if replace else "patch"
+        for chunk in self.api.chunked(records):
+            formatted_records = [
+                {k: v for (k, v) in record.items() if k in ("id", "fields")}
+                for record in chunk
+            ]
+            response = self.api.request(
+                method=method,
+                url=self.url,
+                json={
+                    "records": formatted_records,
+                    "typecast": typecast,
+                    "returnFieldsByFieldId": return_fields_by_field_id,
+                    "performUpsert": {"fieldsToMergeOn": key_fields},
+                },
+            )
+            updated_records += assert_typed_dicts(RecordDict, response["records"])
+            self.api.wait()
+
+        return updated_records
 
     def delete(self, record_id: RecordId) -> RecordDeletedDict:
         """
-        Same as :meth:`Api.delete <pyairtable.api.Api.delete>`
-        but without ``base_id`` and ``table_name`` arg.
+        Deletes the given record.
+
+        >>> table.delete('recwPQIfs4wKPyc9D')
+        {'id': 'recwPQIfs4wKPyc9D', 'deleted': True}
+
+        Args:
+            record_id: |arg_record_id|
+
+        Returns:
+            Confirmation that the record was deleted.
         """
-        return super()._delete(self.base_id, self.table_name, record_id)
+        return assert_typed_dict(
+            RecordDeletedDict,
+            self.api.request("delete", self.record_url(record_id)),
+        )
 
     def batch_delete(self, record_ids: List[RecordId]) -> List[RecordDeletedDict]:
         """
-        Same as :meth:`Api.batch_delete <pyairtable.api.Api.batch_delete>`
-        but without ``base_id`` and ``table_name`` arg.
+        Deletes the given records, operating in batches.
+
+        >>> table.batch_delete(['recwPQIfs4wKPyc9D', 'recwDxIfs3wDPyc3F'])
+        [
+            {'id': 'recwPQIfs4wKPyc9D', 'deleted': True},
+            {'id': 'recwDxIfs3wDPyc3F', 'deleted': True}
+        ]
+
+        Args:
+            record_ids: Record IDs to delete
+
+        Returns:
+            Confirmation that the records were deleted.
         """
-        return super()._batch_delete(self.base_id, self.table_name, record_ids)
+        deleted_records = []
 
-    def __repr__(self) -> str:
-        return "<Table base_id={} table_name={}>".format(self.base_id, self.table_name)
+        for chunk in self.api.chunked(record_ids):
+            result = self.api.request("delete", self.url, params={"records[]": chunk})
+            deleted_records += assert_typed_dicts(RecordDeletedDict, result["records"])
+            self.api.wait()
+
+        return deleted_records
diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py
index b71efb3c..7995092b 100644
--- a/pyairtable/api/types.py
+++ b/pyairtable/api/types.py
@@ -10,23 +10,23 @@
 
 T = TypeVar("T")
 
-#: Used internally to disambiguate different uses for ``str``.
+#: An alias for ``str`` used internally for disambiguation.
 #: Record IDs for Airtable look like ``"recAdw9EjV90xbZ"``.
 RecordId: TypeAlias = str
 
-#: Used internally to disambiguate different uses for ``str``.
+#: An alias for ``str`` used internally for disambiguation.
 #: Airtable returns timestamps as ISO 8601 UTC strings,
 #: e.g. ``"2023-05-22T21:24:15.333134Z"``
 Timestamp: TypeAlias = str
 
-#: Used internally to disambiguate different uses for ``str``.
+#: An alias for ``str`` used internally for disambiguation.
 #: Field names can be any valid string.
 FieldName: TypeAlias = str
 
 
 class AttachmentDict(TypedDict, total=False):
     """
-    A dict representing an attachment stored in an Attachments field.
+    A ``dict`` representing an attachment stored in an Attachments field.
 
     >>> record = table.get('recW8eG2x0ew1Af')
     >>> record['fields']['Attachments']
@@ -53,7 +53,7 @@ class AttachmentDict(TypedDict, total=False):
 
 class CreateAttachmentDict(TypedDict, total=False):
     """
-    A dict representing a new attachment to be written to the Airtable API.
+    A ``dict`` representing a new attachment to be written to the Airtable API.
 
     >>> new_attachment = {
     ...     "url": "https://example.com/image.jpg",
@@ -70,9 +70,9 @@ class CreateAttachmentDict(TypedDict, total=False):
 
 class BarcodeDict(TypedDict, total=False):
     """
-    A dict representing the value stored in a Barcode field.
+    A ``dict`` representing the value stored in a Barcode field.
 
-    >>> record = api.get('base_id', 'table_name', 'recW8eG2x0ew1Af')
+    >>> record = table.get('recW8eG2x0ew1Af')
     >>> record['fields']['Barcode']
     {'type': 'upce', 'text': '01234567'}
 
@@ -85,9 +85,9 @@ class BarcodeDict(TypedDict, total=False):
 
 class ButtonDict(TypedDict):
     """
-    A dict representing the value stored in a Button field.
+    A ``dict`` representing the value stored in a Button field.
 
-    >>> record = api.get('base_id', 'table_name', 'recW8eG2x0ew1Af')
+    >>> record = table.get('recW8eG2x0ew1Af')
     >>> record['fields']['Click Me']
     {'label': 'Click Me', 'url': 'http://example.com'}
 
@@ -102,7 +102,7 @@ class CollaboratorDict(TypedDict, total=False):
     """
     A dict representing the value stored in a User field returned from the API.
 
-    >>> record = api.get('base_id', 'table_name', 'recW8eG2x0ew1Af')
+    >>> record = table.get('recW8eG2x0ew1Af')
     >>> record['fields']['Created By']
     {
         'id': 'usrAdw9EjV90xbW',
@@ -205,15 +205,16 @@ class FormulaNotANumberDict(TypedDict):
 
 class RecordDict(TypedDict):
     """
-    Represents a record returned from the Airtable API.
+    A ``dict`` representing a record returned from the Airtable API.
     See `List records <https://airtable.com/developers/web/api/list-records>`__.
 
     Usage:
-        >>> record = table.first(formula="Name = 'Alice'")
-        >>> record
-        {"id": "recAdw9EjV90xbW",
-         "createdTime": "2023-05-22T21:24:15.333134Z",
-         "fields": {"Name": "Alice", "Department": "Engineering"}}
+        >>> table.first(formula="Name = 'Alice'")
+        {
+            'id': 'recAdw9EjV90xbW',
+            'createdTime': '2023-05-22T21:24:15.333134Z',
+            'fields': {'Name': 'Alice', 'Department': 'Engineering'}
+        }
     """
 
     id: RecordId
@@ -223,7 +224,7 @@ class RecordDict(TypedDict):
 
 class CreateRecordDict(TypedDict):
     """
-    Represents the payload passed to the Airtable API to create a record.
+    A ``dict`` representing the payload passed to the Airtable API to create a record.
     """
 
     fields: Fields
@@ -231,7 +232,7 @@ class CreateRecordDict(TypedDict):
 
 class UpdateRecordDict(TypedDict):
     """
-    Represents the payload passed to the Airtable API to update a record.
+    A ``dict`` representing the payload passed to the Airtable API to update a record.
 
     Usage:
         >>> update_records = [
@@ -247,11 +248,11 @@ class UpdateRecordDict(TypedDict):
 
 class RecordDeletedDict(TypedDict):
     """
-    Represents the payload returned by the Airtable API to confirm a deletion.
+    A ``dict`` representing the payload returned by the Airtable API to confirm a deletion.
 
     Usage:
         >>> table.delete("recAdw9EjV90xbZ")
-        {"id": "recAdw9EjV90xbZ", "deleted": True}
+        {'id': 'recAdw9EjV90xbZ', 'deleted': True}
     """
 
     id: RecordId
@@ -285,9 +286,11 @@ def assert_typed_dict(cls: Type[T], obj: Any) -> T:
         ...         "fields": {},
         ...     }
         ... )
-        {'id': 'recAdw9EjV90xbZ',
-        'createdTime': '2023-05-22T21:24:15.333134Z',
-        'fields': {}}
+        {
+            'id': 'recAdw9EjV90xbZ',
+            'createdTime': '2023-05-22T21:24:15.333134Z',
+            'fields': {}
+        }
 
         >>> assert_typed_dict(RecordDict, {"foo": "bar"})
         Traceback (most recent call last):
diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py
index ac9df09d..3e9e1f50 100644
--- a/pyairtable/formulas.py
+++ b/pyairtable/formulas.py
@@ -136,7 +136,7 @@ def FIELD(name: str) -> str:
         >>> FIELD("First Name")
         '{First Name}'
         >>> FIELD("Guest's Name")
-        '{Guest\\' Names}'
+        "{Guest\\'s Name}"
     """
     return "{%s}" % escape_quotes(name)
 
@@ -168,10 +168,10 @@ def IF(logical: str, value1: str, value2: str) -> str:
 
 def FIND(what: str, where: str, start_position: int = 0) -> str:
     """
-    Creates an FIND statement
+    Creates a FIND statement
 
     >>> FIND(STR(2021), FIELD('DatetimeCol'))
-    'FIND('2021', {DatetimeCol})'
+    "FIND('2021', {DatetimeCol})"
 
     Args:
         what: String to search for
diff --git a/pyairtable/metadata.py b/pyairtable/metadata.py
index 0fcccbe2..7c7cfb6a 100644
--- a/pyairtable/metadata.py
+++ b/pyairtable/metadata.py
@@ -28,8 +28,9 @@ def get_api_bases(api: Union[Api, Base]) -> Dict[Any, Any]:
             ]
         }
     """
+    api = api.api if isinstance(api, Base) else api
     base_list_url = api.build_url("meta", "bases")
-    assert isinstance(response := api._request("get", base_list_url), dict)
+    assert isinstance(response := api.request("get", base_list_url), dict)
     return response
 
 
@@ -77,8 +78,9 @@ def get_base_schema(base: Union[Base, Table]) -> Dict[Any, Any]:
             ]
         }
     """
-    base_schema_url = base.build_url("meta", "bases", base.base_id, "tables")
-    assert isinstance(response := base._request("get", base_schema_url), dict)
+    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
 
 
@@ -114,6 +116,6 @@ def get_table_schema(table: Table) -> Optional[Dict[Any, Any]]:
     base_schema = get_base_schema(table)
     for table_record in base_schema.get("tables", {}):
         assert isinstance(table_record, dict)
-        if table.table_name == table_record["name"]:
+        if table.name == table_record["name"]:
             return table_record
     return None
diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py
index 43167a45..7a56f91e 100644
--- a/pyairtable/orm/model.py
+++ b/pyairtable/orm/model.py
@@ -60,10 +60,13 @@
 True
 """
 import abc
+from functools import lru_cache
 from typing import Any, Dict, List, Optional
 
 from typing_extensions import Self as SelfType
 
+from pyairtable.api.api import Api
+from pyairtable.api.base import Base
 from pyairtable.api.table import Table
 from pyairtable.api.types import FieldName, RecordDict, RecordId
 from pyairtable.orm.fields import AnyField, Field
@@ -153,6 +156,8 @@ def _field_name_attribute_map(cls) -> Dict[FieldName, str]:
         return {v.field_name: k for k, v in cls._attribute_descriptor_map().items()}
 
     def __init__(self, **fields: Any):
+        self._typecast = bool(self._get_meta("typecast", default=True))
+
         # To Store Fields
         self._fields = {}
 
@@ -162,8 +167,6 @@ def __init__(self, **fields: Any):
                 raise AttributeError(key)
             setattr(self, key, value)
 
-        self.typecast = bool(self._get_meta("typecast", default=True))
-
     @classmethod
     def _get_meta(cls, name: str, default: Any = None, required: bool = False) -> Any:
         if not hasattr(cls, "Meta"):
@@ -192,16 +195,22 @@ def _validate_class(cls) -> None:
             )
 
     @classmethod
+    @lru_cache
+    def get_api(cls) -> Api:
+        return Api(
+            api_key=cls._get_meta("api_key"),
+            timeout=cls._get_meta("timeout"),
+        )
+
+    @classmethod
+    @lru_cache
+    def get_base(cls) -> Base:
+        return cls.get_api().base(cls._get_meta("base_id"))
+
+    @classmethod
+    @lru_cache
     def get_table(cls) -> Table:
-        """Return Airtable :class:`~pyairtable.api.Table` class instance"""
-        if not hasattr(cls, "_table"):
-            cls._table = Table(
-                cls._get_meta("api_key"),
-                cls._get_meta("base_id"),
-                cls._get_meta("table_name"),
-                timeout=cls._get_meta("timeout"),
-            )
-        return cls._table
+        return cls.get_base().table(cls._get_meta("table_name"))
 
     def exists(self) -> bool:
         """Returns boolean indicating if instance exists (has 'id' attribute)"""
@@ -219,10 +228,10 @@ def save(self) -> bool:
         fields = record["fields"]
 
         if not self.id:
-            record = table.create(fields, typecast=self.typecast)
+            record = table.create(fields, typecast=self._typecast)
             did_create = True
         else:
-            record = table.update(self.id, fields, typecast=self.typecast)
+            record = table.update(self.id, fields, typecast=self._typecast)
             did_create = False
 
         self.id = record["id"]
@@ -242,7 +251,7 @@ def delete(self) -> bool:
     def all(cls, **kwargs: Any) -> List[SelfType]:
         """
         Returns all records for this model. For the full list of
-        keyword arguments, see :meth:`~pyairtable.api.Api.all`
+        keyword arguments, see :meth:`~pyairtable.Api.all`
         """
         table = cls.get_table()
         return [cls.from_record(record) for record in table.all(**kwargs)]
@@ -251,7 +260,7 @@ def all(cls, **kwargs: Any) -> List[SelfType]:
     def first(cls, **kwargs: Any) -> Optional[SelfType]:
         """
         Returns the first record for this model. For the full list of
-        keyword arguments, see :meth:`~pyairtable.api.Api.all`
+        keyword arguments, see :meth:`~pyairtable.Api.all`
         """
         table = cls.get_table()
         if record := table.first(**kwargs):
diff --git a/pyairtable/utils.py b/pyairtable/utils.py
index d669a2a4..e23b1c45 100644
--- a/pyairtable/utils.py
+++ b/pyairtable/utils.py
@@ -86,7 +86,11 @@ def attachment(url: str, filename: str = "") -> CreateAttachmentDict:
 
 def chunked(iterable: Sequence[T], chunk_size: int) -> Iterator[Sequence[T]]:
     """
-    Break a sequence into chunks
+    Break a sequence into chunks.
+
+    Args:
+        iterable: Any sequence.
+        chunk_size: Maximum items to yield per chunk.
     """
     for i in range(0, len(iterable), chunk_size):
         yield iterable[i : i + chunk_size]


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 7582c00a..8ad73ad1 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -32,23 +32,18 @@ def constants():
 
 
 @pytest.fixture()
-def api(constants):
+def api(constants) -> Api:
     return Api(constants["API_KEY"])
 
 
 @pytest.fixture()
-def api_with_endpoint_url(constants):
-    return Api(constants["API_KEY"], endpoint_url="https://api.example.com")
+def base(api: Api, constants) -> Base:
+    return api.base(constants["BASE_ID"])
 
 
 @pytest.fixture()
-def base(constants):
-    return Base(constants["API_KEY"], constants["BASE_ID"])
-
-
-@pytest.fixture()
-def table(constants):
-    return Table(constants["API_KEY"], constants["BASE_ID"], constants["TABLE_NAME"])
+def table(base: Base, constants) -> Table:
+    return base.table(constants["TABLE_NAME"])
 
 
 @pytest.fixture
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index 68214b99..13227218 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -2,7 +2,7 @@
 
 import pytest
 
-from pyairtable import Base, Table
+from pyairtable import Api, Base, Table
 
 BASE_ID = "appaPqizdsNHDvlEm"
 
@@ -33,20 +33,18 @@ class Columns:
 
 
 @pytest.fixture
-def base():
-    base_id = BASE_ID
-    base = Base(os.environ["AIRTABLE_API_KEY"], base_id)
-    yield base
-    table_name = "TEST_TABLE"
-    records = base.all(table_name)
-    base.batch_delete(table_name, [r["id"] for r in records])
+def api() -> Api:
+    return Api(os.environ["AIRTABLE_API_KEY"])
 
 
 @pytest.fixture
-def table():
-    base_id = BASE_ID
-    table_name = "TEST_TABLE"
-    table = Table(os.environ["AIRTABLE_API_KEY"], base_id, table_name)
+def base(api: Api) -> Base:
+    return api.base(BASE_ID)
+
+
+@pytest.fixture
+def table(base: Base) -> Table:
+    table = base.table("TEST_TABLE")
     yield table
     records = table.all()
     table.batch_delete([r["id"] for r in records])
diff --git a/tests/integration/test_integration_metadata.py b/tests/integration/test_integration_metadata.py
index 1d55e87d..8babc319 100644
--- a/tests/integration/test_integration_metadata.py
+++ b/tests/integration/test_integration_metadata.py
@@ -20,4 +20,4 @@ def test_get_base_schema(base: Base):
 @pytest.mark.skip("metadata api returning 404 for base schema")
 def test_get_table_schema(table: Table):
     rv = get_table_schema(table)
-    assert rv and rv["name"] == table.table_name
+    assert rv and rv["name"] == table.name
diff --git a/tests/test_api_api.py b/tests/test_api_api.py
index 5037fb65..05b6000a 100644
--- a/tests/test_api_api.py
+++ b/tests/test_api_api.py
@@ -1,32 +1,22 @@
-from unittest import mock
-
-from pyairtable import Api, Base, Table
-from pyairtable.api.abstract import ApiAbstract
+from pyairtable import Api, Base, Table  # noqa
 
 
 def test_repr(api):
     assert "Api" in api.__repr__()
 
 
-def test_record_url(api: Api):
-    rv = api.get_record_url("baseid", "tablename", "rec")
-    assert rv == ApiAbstract("x")._get_record_url("baseid", "tablename", "rec")
-
-
 def test_get_base(api: Api):
-    rv = api.get_base("appTest")
+    rv = api.base("appTest")
     assert isinstance(rv, Base)
-    assert rv.base_id == "appTest"
-    assert rv.endpoint_url == api.endpoint_url
+    assert rv.id == "appTest"
+    assert rv.api == api
 
 
 def test_get_table(api: Api):
-    rv = api.get_table("appTest", "tblTest")
+    rv = api.table("appTest", "tblTest")
     assert isinstance(rv, Table)
-    assert rv.base_id == "appTest"
-    assert rv.table_name == "tblTest"
-    assert rv.endpoint_url == api.endpoint_url
-    assert rv.table_url == "https://api.airtable.com/v0/appTest/tblTest"
+    assert rv.name == "tblTest"
+    assert rv.base.id == "appTest"
 
 
 def test_default_endpoint_url(api: Api):
@@ -34,8 +24,9 @@ def test_default_endpoint_url(api: Api):
     assert rv == "https://api.airtable.com/v0/appTest/tblTest"
 
 
-def test_endpoint_url(api_with_endpoint_url: Api):
-    rv = api_with_endpoint_url.build_url("appTest", "tblTest")
+def test_endpoint_url():
+    api = Api("apikey", endpoint_url="https://api.example.com")
+    rv = api.build_url("appTest", "tblTest")
     assert rv == "https://api.example.com/v0/appTest/tblTest"
 
 
@@ -45,30 +36,9 @@ def test_endpoint_url_with_trailing_slash():
     assert rv == "https://api.example.com/v0/appTest/tblTest"
 
 
-@mock.patch.object(ApiAbstract, "_get_record")
-def test_get(m, api: Api, mock_response_single):
-    m.return_value = mock_response_single
-    rv = api.get("x", "y", "rec")
-    assert rv == mock_response_single
-
-
-@mock.patch.object(ApiAbstract, "_first")
-def test_first(m, api: Api, mock_response_single):
-    m.return_value = mock_response_single
-    rv = api.first("x", "y")
-    assert rv == mock_response_single
-
-
-@mock.patch.object(ApiAbstract, "_all")
-def test_all(m, api: Api, mock_response_list):
-    m.return_value = mock_response_list
-    rv = api.all("x", "y")
-    assert rv == mock_response_list
-
-
-@mock.patch.object(ApiAbstract, "_update")
-def test_update(m, api: Api, mock_response_single):
-    m.return_value = mock_response_single
-    rv = api.update("x", "y", "rec", {"test": "test"})
-    assert rv == mock_response_single
-    assert ("x", "y", "rec", {"test": "test"}) == m.call_args[0][:4]
+def test_update_api_key(api):
+    """
+    Test that changing the access token also changes the default request headers.
+    """
+    api.api_key = "123"
+    assert "123" in api.session.headers["Authorization"]
diff --git a/tests/test_api_base.py b/tests/test_api_base.py
index f5aff128..2e6632d1 100644
--- a/tests/test_api_base.py
+++ b/tests/test_api_base.py
@@ -1,50 +1,39 @@
-from unittest import mock
+import pytest
 
 from pyairtable import Base, Table
-from pyairtable.api.abstract import ApiAbstract
 
 
-def test_repr(base):
-    assert "Base" in base.__repr__()
-
+def test_constructor(api):
+    base = Base(api, "base_id")
+    assert base.api == api
+    assert base.id == "base_id"
 
-def test_record_url(base: Base):
-    rv = base.get_record_url("tablename", "rec")
-    assert rv == f"https://api.airtable.com/v0/{base.base_id}/tablename/rec"
 
+def test_deprecated_constructor():
+    with pytest.warns(DeprecationWarning):
+        base = Base("api_key", "base_id")
 
-def test_get_table(base: Base):
-    rv = base.get_table("tablename")
-    assert isinstance(rv, Table)
-    assert rv.base_id == base.base_id
-    assert rv.table_name == "tablename"
-    assert rv.table_url == f"https://api.airtable.com/v0/{base.base_id}/tablename"
-
+    assert base.api.api_key == "api_key"
+    assert base.id == "base_id"
 
-@mock.patch.object(ApiAbstract, "_get_record")
-def test_get(m, base: Base, mock_response_single):
-    m.return_value = mock_response_single
-    rv = base.get("tablename", "rec")
-    assert rv == mock_response_single
 
+def test_invalid_constructor():
+    """
+    Test that we get a TypeError if passing invalid kwargs to Base.
+    """
+    with pytest.raises(TypeError):
+        Base(api_key="api_key", base_id="base_id")
+    with pytest.raises(TypeError):
+        Base("api_key", "base_id", timeout=(1, 1))
 
-@mock.patch.object(ApiAbstract, "_first")
-def test_first(m, base: Base, mock_response_single):
-    m.return_value = mock_response_single
-    rv = base.first("tablename")
-    assert rv == mock_response_single
 
-
-@mock.patch.object(ApiAbstract, "_all")
-def test_all(m, base: Base, mock_response_list):
-    m.return_value = mock_response_list
-    rv = base.all("tablename")
-    assert rv == mock_response_list
+def test_repr(base):
+    assert "Base" in base.__repr__()
 
 
-@mock.patch.object(ApiAbstract, "_update")
-def test_update(m, base: Base, mock_response_single):
-    m.return_value = mock_response_single
-    rv = base.update("tablename", "rec", {"test": "test"})
-    assert rv == mock_response_single
-    assert {"test": "test"} in m.call_args[0][:4]
+def test_get_table(base: Base):
+    rv = base.table("tablename")
+    assert isinstance(rv, Table)
+    assert rv.base == base
+    assert rv.name == "tablename"
+    assert rv.url == f"https://api.airtable.com/v0/{base.id}/tablename"
diff --git a/tests/test_api_retrying.py b/tests/test_api_retrying.py
index d42f11ea..e0e3e296 100644
--- a/tests/test_api_retrying.py
+++ b/tests/test_api_retrying.py
@@ -14,8 +14,8 @@
 import pytest
 import requests
 
+from pyairtable.api import Api
 from pyairtable.api.retrying import retry_strategy
-from pyairtable.api.table import Table
 from pyairtable.testing import fake_record
 
 
@@ -151,14 +151,13 @@ def mock_endpoint(mock_endpoint_server):
 @pytest.fixture
 def table_with_retry_strategy(constants, mock_endpoint_server):
     def _table_with_retry(retry_strategy):
-        return Table(
-            constants["API_KEY"],
-            constants["BASE_ID"],
-            constants["TABLE_NAME"],
+        api = Api(
+            api_key=constants["API_KEY"],
             timeout=(0.1, 0.1),
             retry_strategy=retry_strategy,
             endpoint_url=mock_endpoint_server.url,
         )
+        return api.table(constants["BASE_ID"], constants["TABLE_NAME"])
 
     return _table_with_retry
 
diff --git a/tests/test_api_table.py b/tests/test_api_table.py
index 3a9c37de..b2817f4d 100644
--- a/tests/test_api_table.py
+++ b/tests/test_api_table.py
@@ -4,12 +4,51 @@
 from requests import Request
 from requests_mock import Mocker
 
-from pyairtable import Table
+from pyairtable import Api, Base, Table
 from pyairtable.utils import chunked
 
 
-def test_repr(table):
-    assert "<Table" in table.__repr__()
+def test_constructor(base: Base):
+    """
+    Test the constructor.
+    """
+    table = Table(None, base, "table_name")
+    assert table.api == base.api
+    assert table.base == base
+    assert table.name == "table_name"
+
+
+def test_deprecated_constructor(api: Api, base: Base):
+    """
+    Test that "legacy" constructor (passing strings instead of instances)
+    will throw deprecation warning, but it _will_ work.
+    """
+    with pytest.warns(DeprecationWarning):
+        table = Table(api.api_key, base.id, "table_name", timeout=(1, 99))
+
+    assert table.api.api_key == api.api_key
+    assert table.api.timeout == (1, 99)
+    assert table.base.id == base.id
+    assert table.name == "table_name"
+
+
+def test_invalid_constructor(api, base):
+    """
+    Test that we get a TypeError if passing invalid args to Table.
+    """
+    for args in [
+        [api, "base_id", "table_name"],
+        ["api_key", base, "table_name"],
+        [api, base, "table_name"],
+    ]:
+        kwargs = args.pop() if isinstance(args[-1], dict) else {}
+        with pytest.raises(TypeError):
+            print(args, kwargs)
+            Table(*args, **kwargs)
+
+
+def test_repr(table: Table):
+    assert repr(table) == "<Table base_id='appJMY16gZDQrMWpA' table_name='Table Name'>"
 
 
 @pytest.mark.parametrize(
@@ -20,12 +59,12 @@ def test_repr(table):
         ("abc", "Table-fake", "abc/Table-fake"),
     ],
 )
-def test_url(base_id, table_name, table_url_suffix):
-    table = Table("apikey", base_id, table_name)
-    assert table.table_url == f"https://api.airtable.com/v0/{table_url_suffix}"
+def test_url(api: Api, base_id, table_name, table_url_suffix):
+    table = api.table(base_id, table_name)
+    assert table.url == f"https://api.airtable.com/v0/{table_url_suffix}"
 
 
-def test_chunk(table):
+def test_chunk(table: Table):
     chunks = [chunk for chunk in chunked([0, 0, 1, 1, 2, 2, 3], 2)]
     assert chunks[0] == [0, 0]
     assert chunks[1] == [1, 1]
@@ -33,14 +72,14 @@ def test_chunk(table):
     assert chunks[3] == [3]
 
 
-def test_record_url(table):
-    rv = table.get_record_url("xxx")
-    assert rv == urljoin(table.table_url, "xxx")
+def test_record_url(table: Table):
+    rv = table.record_url("xxx")
+    assert rv == urljoin(table.url, "xxx")
 
 
-def test_api_key(table, mock_response_single):
+def test_api_key(table: Table, mock_response_single):
     def match_auth_header(request):
-        expected_auth_header = "Bearer {}".format(table.api_key)
+        expected_auth_header = "Bearer {}".format(table.api.api_key)
         return (
             "Authorization" in request.headers
             and request.headers["Authorization"] == expected_auth_header
@@ -48,7 +87,7 @@ def match_auth_header(request):
 
     with Mocker() as m:
         m.get(
-            table.get_record_url("rec"),
+            table.record_url("rec"),
             status_code=200,
             json=mock_response_single,
             additional_matcher=match_auth_header,
@@ -57,28 +96,18 @@ def match_auth_header(request):
         table.get("rec")
 
 
-def test_update_api_key(table):
-    table.api_key = "123"
-    assert "123" in table.session.headers["Authorization"]
-
-
-def test_get_base(table):
-    base = table.get_base()
-    assert base.base_id == table.base_id and base.api_key == table.api_key
-
-
-def test_get(table, mock_response_single):
+def test_get(table: Table, mock_response_single):
     _id = mock_response_single["id"]
     with Mocker() as mock:
-        mock.get(table.get_record_url(_id), status_code=200, json=mock_response_single)
+        mock.get(table.record_url(_id), status_code=200, json=mock_response_single)
         resp = table.get(_id)
     assert dict_equals(resp, mock_response_single)
 
 
-def test_first(table, mock_response_single):
+def test_first(table: Table, mock_response_single):
     mock_response = {"records": [mock_response_single]}
     with Mocker() as mock:
-        url = Request("get", table.table_url, params={"maxRecords": 1}).prepare().url
+        url = Request("get", table.url, params={"maxRecords": 1}).prepare().url
         mock.get(
             url,
             status_code=200,
@@ -89,10 +118,10 @@ def test_first(table, mock_response_single):
     assert rv["id"] == mock_response_single["id"]
 
 
-def test_first_via_post(table, mock_response_single):
+def test_first_via_post(table: Table, mock_response_single):
     mock_response = {"records": [mock_response_single]}
     with Mocker() as mock:
-        url = table.table_url + "/listRecords"
+        url = table.url + "/listRecords"
         formula = f"RECORD_ID() != '{'x' * 17000}'"
         mock_endpoint = mock.post(url, status_code=200, json=mock_response)
         rv = table.first(formula=formula)
@@ -105,10 +134,10 @@ def test_first_via_post(table, mock_response_single):
     assert rv == mock_response_single
 
 
-def test_first_none(table, mock_response_single):
+def test_first_none(table: Table, mock_response_single):
     mock_response = {"records": []}
     with Mocker() as mock:
-        url = Request("get", table.table_url, params={"maxRecords": 1}).prepare().url
+        url = Request("get", table.url, params={"maxRecords": 1}).prepare().url
         mock.get(
             url,
             status_code=200,
@@ -118,10 +147,10 @@ def test_first_none(table, mock_response_single):
         assert rv is None
 
 
-def test_all(table, mock_response_list, mock_records):
+def test_all(table: Table, mock_response_list, mock_records):
     with Mocker() as mock:
         mock.get(
-            table.table_url,
+            table.url,
             status_code=200,
             json=mock_response_list[0],
             complete_qs=True,
@@ -130,7 +159,7 @@ def test_all(table, mock_response_list, mock_records):
             offset = resp.get("offset", None)
             if not offset:
                 continue
-            offset_url = table.table_url + "?offset={}".format(offset)
+            offset_url = table.url + "?offset={}".format(offset)
             mock.get(
                 offset_url,
                 status_code=200,
@@ -143,10 +172,10 @@ def test_all(table, mock_response_list, mock_records):
         assert dict_equals(resp, mock_records[n])
 
 
-def test_iterate(table, mock_response_list, mock_records):
+def test_iterate(table: Table, mock_response_list, mock_records):
     with Mocker() as mock:
         mock.get(
-            table.table_url,
+            table.url,
             status_code=200,
             json=mock_response_list[0],
             complete_qs=True,
@@ -156,7 +185,7 @@ def test_iterate(table, mock_response_list, mock_records):
             if not offset:
                 continue
             params = {"offset": offset}
-            offset_url = Request("get", table.table_url, params=params).prepare().url
+            offset_url = Request("get", table.url, params=params).prepare().url
             mock.get(
                 offset_url,
                 status_code=200,
@@ -172,11 +201,11 @@ def test_iterate(table, mock_response_list, mock_records):
         assert seq_equals(pages[n], response["records"])
 
 
-def test_create(table, mock_response_single):
+def test_create(table: Table, mock_response_single):
     with Mocker() as mock:
         post_data = mock_response_single["fields"]
         mock.post(
-            table.table_url,
+            table.url,
             status_code=201,
             json=mock_response_single,
             additional_matcher=match_request_data(post_data),
@@ -185,11 +214,11 @@ def test_create(table, mock_response_single):
     assert dict_equals(resp, mock_response_single)
 
 
-def test_batch_create(table, mock_records):
+def test_batch_create(table: Table, mock_records):
     with Mocker() as mock:
         for chunk in _chunk(mock_records, 10):
             mock.post(
-                table.table_url,
+                table.url,
                 status_code=201,
                 json={"records": chunk},
             )
@@ -199,13 +228,13 @@ def test_batch_create(table, mock_records):
 
 
 @pytest.mark.parametrize("replace,http_method", [(False, "PATCH"), (True, "PUT")])
-def test_update(table, mock_response_single, replace, http_method):
+def test_update(table: Table, mock_response_single, replace, http_method):
     id_ = mock_response_single["id"]
     post_data = mock_response_single["fields"]
     with Mocker() as mock:
         mock.register_uri(
             http_method,
-            urljoin(table.table_url, id_),
+            urljoin(table.url, id_),
             status_code=201,
             json=mock_response_single,
             additional_matcher=match_request_data(post_data),
@@ -215,7 +244,7 @@ def test_update(table, mock_response_single, replace, http_method):
 
 
 @pytest.mark.parametrize("replace,http_method", [(False, "PATCH"), (True, "PUT")])
-def test_batch_update(table, mock_response_batch, replace, http_method):
+def test_batch_update(table: Table, mock_response_batch, replace, http_method):
     records = [
         {"id": x["id"], "fields": x["fields"]} for x in mock_response_batch["records"]
     ]
@@ -223,7 +252,7 @@ def test_batch_update(table, mock_response_batch, replace, http_method):
         for chunk in _chunk(mock_response_batch["records"], 10):
             mock.register_uri(
                 http_method,
-                table.table_url,
+                table.url,
                 status_code=201,
                 json={"records": chunk},
             )
@@ -233,7 +262,7 @@ def test_batch_update(table, mock_response_batch, replace, http_method):
 
 
 @pytest.mark.parametrize("replace,http_method", [(False, "PATCH"), (True, "PUT")])
-def test_batch_upsert(table, mock_response_batch, replace, http_method):
+def test_batch_upsert(table: Table, mock_response_batch, replace, http_method):
     records = [
         {"id": x["id"], "fields": x["fields"]} for x in mock_response_batch["records"]
     ]
@@ -242,7 +271,7 @@ def test_batch_upsert(table, mock_response_batch, replace, http_method):
         for chunk in _chunk(mock_response_batch["records"], 10):
             mock.register_uri(
                 http_method,
-                table.table_url,
+                table.url,
                 status_code=201,
                 json={"records": chunk},
             )
@@ -251,7 +280,7 @@ def test_batch_upsert(table, mock_response_batch, replace, http_method):
     assert resp == mock_response_batch["records"]
 
 
-def test_batch_upsert__missing_field(table, requests_mock):
+def test_batch_upsert__missing_field(table: Table, requests_mock):
     """
     Test that batch_upsert raises an exception if a record in the input
     is missing one of the key_fields, since this will create an error
@@ -261,24 +290,22 @@ def test_batch_upsert__missing_field(table, requests_mock):
         table.batch_upsert([{"fields": {"Name": "Alice"}}], key_fields=["Email"])
 
 
-def test_delete(table, mock_response_single):
+def test_delete(table: Table, mock_response_single):
     id_ = mock_response_single["id"]
     expected = {"deleted": True, "id": id_}
     with Mocker() as mock:
-        mock.delete(urljoin(table.table_url, id_), status_code=201, json=expected)
+        mock.delete(urljoin(table.url, id_), status_code=201, json=expected)
         resp = table.delete(id_)
     assert resp == expected
 
 
-def test_batch_delete(table, mock_records):
+def test_batch_delete(table: Table, mock_records):
     ids = [i["id"] for i in mock_records]
     with Mocker() as mock:
         for chunk in _chunk(ids, 10):
             json_response = {"records": [{"deleted": True, "id": id_} for id_ in chunk]}
             url_match = (
-                Request("get", table.table_url, params={"records[]": chunk})
-                .prepare()
-                .url
+                Request("get", table.url, params={"records[]": chunk}).prepare().url
             )
             mock.delete(
                 url_match,
diff --git a/tests/test_orm.py b/tests/test_orm.py
index b72e6c49..8493a39c 100644
--- a/tests/test_orm.py
+++ b/tests/test_orm.py
@@ -189,7 +189,7 @@ def test_linked_record():
     assert not contact.address[0].street
 
     with Mocker() as mock:
-        url = address.get_table().get_record_url(address.id)
+        url = Address.get_table().record_url(address.id)
         mock.get(url, status_code=200, json=record)
         contact.address[0].fetch()
 
@@ -221,12 +221,12 @@ def test_undeclared_field(requests_mock, test_case):
     )
 
     requests_mock.get(
-        Address.get_table().table_url,
+        Address.get_table().url,
         status_code=200,
         json={"records": [record]},
     )
     requests_mock.get(
-        Address.get_table().get_record_url(record["id"]),
+        Address.get_table().record_url(record["id"]),
         status_code=200,
         json=record,
     )
diff --git a/tests/test_params.py b/tests/test_params.py
index ec5f8c87..1ca0430d 100644
--- a/tests/test_params.py
+++ b/tests/test_params.py
@@ -30,7 +30,7 @@ def test_params_integration(table, mock_records, mock_response_iterator):
             "&returnFieldsByFieldId=1"
             ""
         )
-        mock_url = "{0}?{1}".format(table.table_url, url_params)
+        mock_url = "{0}?{1}".format(table.url, url_params)
         m.get(mock_url, status_code=200, json=mock_response_iterator)
         response = table.all(**params)
     for n, resp in enumerate(response):
diff --git a/tests/test_request_errors.py b/tests/test_request_errors.py
index fee25ac8..c9717ea8 100644
--- a/tests/test_request_errors.py
+++ b/tests/test_request_errors.py
@@ -3,22 +3,22 @@
 from requests import HTTPError
 
 
-def test_error_mesg_in_json(table, response):
+def test_error_mesg_in_json(api, response):
     response.status_code = 400
     response.json = Mock(return_value={"error": "here's what went wrong"})
     with pytest.raises(HTTPError):
-        table._process_response(response)
+        api._process_response(response)
 
 
-def test_error_without_mesg_in_json(table, response):
+def test_error_without_mesg_in_json(api, response):
     response.status_code = 404
     response.json = Mock(return_value={})
     with pytest.raises(HTTPError):
-        table._process_response(response)
+        api._process_response(response)
 
 
-def test_non_422_error_with_json_decode_error(table, response):
+def test_non_422_error_with_json_decode_error(api, response):
     response.status_code = 400
     response.json.side_effect = ValueError
     with pytest.raises(HTTPError):
-        table._process_response(response)
+        api._process_response(response)
diff --git a/tests/test_typing.py b/tests/test_typing.py
index 43142692..89f7c8bc 100644
--- a/tests/test_typing.py
+++ b/tests/test_typing.py
@@ -5,7 +5,7 @@
 
 from typing_extensions import assert_type
 
-import pyairtable.api
+import pyairtable
 import pyairtable.api.types as T
 
 if TYPE_CHECKING:
@@ -16,39 +16,20 @@
     record_id = "recSomeFakeRecord"
     now = "2023-01-01T00:00:00.0000Z"
 
-    # Ensure the type signatures for pyairtable.api.Api don't change.
-    api = pyairtable.api.Api(access_token)
-    assert_type(api.get(base_id, table_name, record_id), T.RecordDict)
-    assert_type(api.iterate(base_id, table_name), Iterator[List[T.RecordDict]])
-    assert_type(api.all(base_id, table_name), List[T.RecordDict])
-    assert_type(api.first(base_id, table_name), Optional[T.RecordDict])
-    assert_type(api.create(base_id, table_name, {}), T.RecordDict)
-    assert_type(api.update(base_id, table_name, record_id, {}), T.RecordDict)
-    assert_type(api.delete(base_id, table_name, record_id), T.RecordDeletedDict)
-    assert_type(api.batch_create(base_id, table_name, []), List[T.RecordDict])
-    assert_type(api.batch_update(base_id, table_name, []), List[T.RecordDict])
-    assert_type(api.batch_upsert(base_id, table_name, [], []), List[T.RecordDict])
-    assert_type(api.batch_delete(base_id, table_name, []), List[T.RecordDeletedDict])
-    assert_type(api.get_base(base_id), pyairtable.api.Base)
-    assert_type(api.get_table(base_id, table_name), pyairtable.api.Table)
+    # Ensure the type signatures for pyairtable.Api don't change.
+    api = pyairtable.Api(access_token)
+    assert_type(api.build_url("foo", "bar"), str)
+    assert_type(api.base(base_id), pyairtable.Base)
+    assert_type(api.table(base_id, table_name), pyairtable.Table)
 
-    # Ensure the type signatures for pyairtable.api.Base don't change.
-    base = pyairtable.api.Base(access_token, base_id)
-    assert_type(base.get(table_name, record_id), T.RecordDict)
-    assert_type(base.iterate(table_name), Iterator[List[T.RecordDict]])
-    assert_type(base.all(table_name), List[T.RecordDict])
-    assert_type(base.first(table_name), Optional[T.RecordDict])
-    assert_type(base.create(table_name, {}), T.RecordDict)
-    assert_type(base.update(table_name, record_id, {}), T.RecordDict)
-    assert_type(base.delete(table_name, record_id), T.RecordDeletedDict)
-    assert_type(base.batch_create(table_name, []), List[T.RecordDict])
-    assert_type(base.batch_update(table_name, []), List[T.RecordDict])
-    assert_type(base.batch_upsert(table_name, [], []), List[T.RecordDict])
-    assert_type(base.batch_delete(table_name, []), List[T.RecordDeletedDict])
-    assert_type(base.get_table(table_name), pyairtable.api.Table)
+    # Ensure the type signatures for pyairtable.Base don't change.
+    base = pyairtable.Base(api, base_id)
+    assert_type(base.table(table_name), pyairtable.Table)
+    assert_type(base.url, str)
 
-    # Ensure the type signatures for pyairtable.api.Table don't change.
-    table = pyairtable.api.Table(access_token, base_id, table_name)
+    # Ensure the type signatures for pyairtable.Table don't change.
+    table = pyairtable.Table(None, base, table_name)
+    assert_type(table, pyairtable.Table)
     assert_type(table.get(record_id), T.RecordDict)
     assert_type(table.iterate(), Iterator[List[T.RecordDict]])
     assert_type(table.all(), List[T.RecordDict])
diff --git a/tests/test_url_escape.py b/tests/test_url_escape.py
index 3fee77d9..bb6c06bf 100644
--- a/tests/test_url_escape.py
+++ b/tests/test_url_escape.py
@@ -1,27 +1,20 @@
 import pytest
 
-from pyairtable.api import Table
 
-
-@pytest.fixture
-def table_names():
-    """Dict of some test table names with characters that are not url safe with
-    url-escaped versions.
-    """
-    return (
+@pytest.mark.parametrize(
+    "table_name,escaped",
+    [
         ("Test table 1", "Test%20table%201"),
         ("Test/Table 2", "Test%2FTable%202"),
         ("Another (test) table", "Another%20%28test%29%20table"),
         ("A & test & table", "A%20%26%20test%20%26%20table"),
         ("percentage % table", "percentage%20%25%20table"),
-    )
-
-
-def test_url_escape(table_names):
-    """Test for proper escaping of urls including unsafe characters in table
-    names (which airtable allows).
+    ],
+)
+def test_url_escape(base, table_name, escaped):
+    """
+    Test for proper escaping of urls including unsafe characters in
+    table names (which Airtable *will* allow).
     """
-    for table_name, escaped in table_names:
-        table = Table("apikey", "base_id", table_name)
-        # Class-generated url should be properly escaped
-        assert escaped in table.table_url
+    table = base.table(table_name)
+    assert table.url.endswith(escaped)