Repository: gtalarico/pyairtable
Pull request #399 (2024-10-26 20:16:29)

Refactored methods/properties for constructing URLs in the API
This branch replaces the hodgepodge of `something_url()` methods on various classes with a `.urls` property that can contain both 'hardcoded' URLs (which are only calculated once per object) and URL-returning methods. It simplifies the namespace of each containing object and makes it clearer how to add more URLs in the future for any more API endpoints we need.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/gtalarico/pyairtable/pull/399?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`3dc0d35`)](https://app.codecov.io/gh/gtalarico/pyairtable/commit/3dc0d3526d3a04bcb6fc26b98ae23101151bb331?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico) to head [(`ec52015`)](https://app.codecov.io/gh/gtalarico/pyairtable/commit/ec520159f2800027eb9bcda308efcbca0df95cef?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##              main      #399    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files           27        27            
  Lines         3135      3242   +107     
==========================================
+ Hits          3135      3242   +107     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/gtalarico/pyairtable/pull/399?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico).


Patch:
diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst
index 680e5c4..c68d6ad 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -53,12 +53,14 @@ Changelog
   - `PR #395 <https://github.com/gtalarico/pyairtable/pull/395>`_
 * Dropped support for Pydantic 1.x.
   - `PR #397 <https://github.com/gtalarico/pyairtable/pull/397>`_
+* Refactored methods/properties for constructing URLs in the API.
+  - `PR #399 <https://github.com/gtalarico/pyairtable/pull/399>`_
 
 2.3.4 (2024-10-21)
 ------------------------
 
 * Fixed a crash at import time under Python 3.13.
-    `PR #396 <https://github.com/gtalarico/pyairtable/pull/396>`_
+  - `PR #396 <https://github.com/gtalarico/pyairtable/pull/396>`_
 
 2.3.3 (2024-03-22)
 ------------------------
diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst
index f85d023..c6fc6a8 100644
--- a/docs/source/migrations.rst
+++ b/docs/source/migrations.rst
@@ -27,6 +27,37 @@ Deprecated metadata module removed
 The 3.0 release removed the ``pyairtable.metadata`` module. For supported alternatives,
 see :doc:`metadata`.
 
+Changes to generating URLs
+---------------------------------------------
+
+The following properties and methods for constructing URLs have been renamed or removed.
+These methods now return instances of :class:`~pyairtable.utils.Url`, which is a
+subclass of ``str`` that has some overloaded operators. See docs for more details.
+
+.. list-table::
+    :header-rows: 1
+
+    * - Building a URL in 2.x
+      - Building a URL in 3.0
+    * - ``table.url``
+      - ``table.urls.records``
+    * - ``table.record_url(record_id)``
+      - ``table.urls.record(record_id)``
+    * - ``table.meta_url("one", "two")``
+      - ``table.urls.meta / "one" / "two"``
+    * - ``table.meta_url(*parts)``
+      - ``table.urls.meta // parts``
+    * - ``base.url``
+      - (removed; was invalid)
+    * - ``base.meta_url("one", "two")``
+      - ``base.urls.meta / "one" / "two"``
+    * - ``base.webhooks_url()``
+      - ``base.urls.webhooks``
+    * - ``enterprise.url``
+      - ``enterprise.urls.meta``
+    * - ``workspace.url``
+      - ``workspace.urls.meta``
+
 Changes to the formulas module
 ---------------------------------------------
 
diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py
index ec0928a..c892489 100644
--- a/pyairtable/api/api.py
+++ b/pyairtable/api/api.py
@@ -1,4 +1,4 @@
-import posixpath
+from functools import cached_property
 from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, TypeVar, Union
 
 import requests
@@ -11,7 +11,13 @@ from pyairtable.api.params import options_to_json_and_params, options_to_params
 from pyairtable.api.types import UserAndScopesDict, assert_typed_dict
 from pyairtable.api.workspace import Workspace
 from pyairtable.models.schema import Bases
-from pyairtable.utils import cache_unless_forced, chunked, enterprise_only
+from pyairtable.utils import (
+    Url,
+    UrlBuilder,
+    cache_unless_forced,
+    chunked,
+    enterprise_only,
+)
 
 T = TypeVar("T")
 TimeoutTuple: TypeAlias = Tuple[int, int]
@@ -40,10 +46,16 @@ class Api:
     # Cached metadata to reduce API calls
     _bases: Optional[Dict[str, "pyairtable.api.base.Base"]] = None
 
-    endpoint_url: str
+    endpoint_url: Url
     session: Session
     use_field_ids: bool
 
+    class _urls(UrlBuilder):
+        whoami = Url("meta/whoami")
+        bases = Url("meta/bases")
+
+    urls = cached_property(_urls)
+
     def __init__(
         self,
         api_key: str,
@@ -77,7 +89,7 @@ class Api:
         else:
             self.session = retrying._RetryingSession(retry_strategy)
 
-        self.endpoint_url = endpoint_url
+        self.endpoint_url = Url(endpoint_url)
         self.timeout = timeout
         self.api_key = api_key
         self.use_field_ids = use_field_ids
@@ -102,7 +114,7 @@ class Api:
         Return the current user ID and (if connected via OAuth) the list of scopes.
         See `Get user ID & scopes <https://airtable.com/developers/web/api/get-user-id-scopes>`_ for more information.
         """
-        data = self.request("GET", self.build_url("meta/whoami"))
+        data = self.request("GET", self.urls.whoami)
         return assert_typed_dict(UserAndScopesDict, data)
 
     def workspace(self, workspace_id: str) -> Workspace:
@@ -136,7 +148,7 @@ class Api:
         """
         Return a schema object that represents all bases available via the API.
         """
-        url = self.build_url("meta/bases")
+        url = self.urls.bases
         data = {
             "bases": [
                 base_info
@@ -211,12 +223,12 @@ class Api:
         base = self.base(base_id, validate=validate, force=force)
         return base.table(table_name, validate=validate, force=force)
 
-    def build_url(self, *components: str) -> str:
+    def build_url(self, *components: str) -> Url:
         """
         Build a URL to the Airtable API endpoint with the given URL components,
         including the API version number.
         """
-        return posixpath.join(self.endpoint_url, self.VERSION, *components)
+        return self.endpoint_url / self.VERSION // components
 
     def request(
         self,
diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py
index 263428a..317bf36 100644
--- a/pyairtable/api/base.py
+++ b/pyairtable/api/base.py
@@ -1,4 +1,5 @@
 import warnings
+from functools import cached_property
 from typing import Any, Dict, List, Optional, Sequence, Union
 
 import pyairtable.api.api
@@ -10,7 +11,7 @@ from pyairtable.models.webhook import (
     Webhook,
     WebhookSpecification,
 )
-from pyairtable.utils import cache_unless_forced, enterprise_only
+from pyairtable.utils import Url, UrlBuilder, cache_unless_forced, enterprise_only
 
 
 class Base:
@@ -37,6 +38,33 @@ class Base:
     _schema: Optional[BaseSchema] = None
     _shares: Optional[List[BaseShares.Info]] = None
 
+    class _urls(UrlBuilder):
+        #: URL for retrieving the base's metadata and collaborators.
+        meta = Url("meta/bases/{id}")
+
+        #: URL for retrieving information about the base's interfaces.
+        interfaces = meta / "interfaces"
+
+        #: URL for retrieving the base's shares.
+        shares = meta / "shares"
+
+        #: URL for retrieving the base's schema.
+        tables = meta / "tables"
+
+        #: URL for POST requests that modify collaborations on the base.
+        collaborators = meta / "collaborators"
+
+        #: URL for retrieving or modifying the base's webhooks.
+        webhooks = Url("bases/{id}/webhooks")
+
+        def interface(self, interface_id: str) -> Url:
+            """
+            URL for retrieving information about a specific interface on the base.
+            """
+            return self.interfaces / interface_id
+
+    urls = cached_property(_urls)
+
     def __init__(
         self,
         api: Union["pyairtable.api.api.Api", str],
@@ -154,23 +182,13 @@ class Base:
                 `Airtable field model <https://airtable.com/developers/web/api/field-model>`__.
             description: The table description. Must be no longer than 20k characters.
         """
-        url = self.meta_url("tables")
+        url = self.urls.tables
         payload = {"name": name, "fields": fields}
         if description:
             payload["description"] = description
         response = self.api.post(url, json=payload)
         return self.table(response["id"], validate=True, force=True)
 
-    @property
-    def url(self) -> str:
-        return self.api.build_url(self.id)
-
-    def meta_url(self, *components: Any) -> str:
-        """
-        Build a URL to a metadata endpoint for this base.
-        """
-        return self.api.build_url("meta/bases", self.id, *components)
-
     @cache_unless_forced
     def schema(self) -> BaseSchema:
         """
@@ -184,15 +202,11 @@ class Base:
             >>> base.schema().table("My Table")
             TableSchema(id="...", name="My Table", ...)
         """
-        url = self.meta_url("tables")
+        url = self.urls.tables
         params = {"include": ["visibleFieldIds"]}
         data = self.api.get(url, params=params)
         return BaseSchema.from_api(data, self.api, context=self)
 
-    @property
-    def webhooks_url(self) -> str:
-        return self.api.build_url("bases", self.id, "webhooks")
-
     def webhooks(self) -> List[Webhook]:
         """
         Retrieve all the base's webhooks
@@ -214,7 +228,7 @@ class Base:
                 )
             ]
         """
-        response = self.api.get(self.webhooks_url)
+        response = self.api.get(self.urls.webhooks)
         return [
             Webhook.from_api(data, self.api, context=self)
             for data in response["webhooks"]
@@ -280,7 +294,7 @@ class Base:
 
         create = CreateWebhook(notification_url=notify_url, specification=spec)
         request = create.model_dump(by_alias=True, exclude_unset=True)
-        response = self.api.post(self.webhooks_url, json=request)
+        response = self.api.post(self.urls.webhooks, json=request)
         return CreateWebhookResponse.from_api(response, self.api)
 
     @enterprise_only
@@ -290,7 +304,7 @@ class Base:
         Retrieve `base collaborators <https://airtable.com/developers/web/api/get-base-collaborators>`__.
         """
         params = {"include": ["collaborators", "inviteLinks", "interfaces"]}
-        data = self.api.get(self.meta_url(), params=params)
+        data = self.api.get(self.urls.meta, params=params)
         return BaseCollaborators.from_api(data, self.api, context=self)
 
     @enterprise_only
@@ -299,7 +313,7 @@ class Base:
         """
         Retrieve `base shares <https://airtable.com/developers/web/api/list-shares>`__.
         """
-        data = self.api.get(self.meta_url("shares"))
+        data = self.api.get(self.urls.shares)
         shares_obj = BaseShares.from_api(data, self.api, context=self)
         return shares_obj.shares
 
@@ -312,4 +326,4 @@ class Base:
             >>> base = api.base("appMxESAta6clCCwF")
             >>> base.delete()
         """
-        self.api.delete(self.meta_url())
+        self.api.delete(self.urls.meta)
diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py
index b5b133a..7fedcfd 100644
--- a/pyairtable/api/enterprise.py
+++ b/pyairtable/api/enterprise.py
@@ -1,4 +1,5 @@
 import datetime
+from functools import cached_property, partialmethod
 from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Union
 
 import pydantic
@@ -7,6 +8,8 @@ from pyairtable.models._base import AirtableModel, rebuild_models
 from pyairtable.models.audit import AuditLogResponse
 from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo
 from pyairtable.utils import (
+    Url,
+    UrlBuilder,
     cache_unless_forced,
     coerce_iso_str,
     coerce_list_str,
@@ -24,22 +27,66 @@ class Enterprise:
     ['wspmhESAta6clCCwF', ...]
     """
 
+    class _urls(UrlBuilder):
+        #: URL for retrieving basic information about the enterprise.
+        meta = Url("meta/enterpriseAccounts/{id}")
+
+        #: URL for retrieving information about all users.
+        users = meta / "users"
+
+        #: URL for retrieving information about all user groups.
+        groups = Url("meta/groups")
+
+        #: URL for claiming a user into an enterprise.
+        claim_users = meta / "claim/users"
+
+        #: URL for retrieving audit log events.
+        audit_log = meta / "auditLogEvents"
+
+        def user(self, user_id: str) -> Url:
+            """
+            URL for retrieving information about a single user.
+            """
+            return self.users / user_id
+
+        def group(self, group_id: str) -> Url:
+            """
+            URL for retrieving information about a single user group.
+            """
+            return self.groups / group_id
+
+        def admin_access(self, action: Literal["grant", "revoke"]) -> Url:
+            """
+            URL for granting or revoking admin access to one or more users.
+            """
+            return self.meta / f"users/{action}AdminAccess"
+
+        def remove_user(self, user_id: str) -> Url:
+            """
+            URL for removing a user from the enterprise.
+            """
+            return self.user(user_id) / "remove"
+
+        #: URL for granting admin access to one or more users.
+        grant_admin = partialmethod(admin_access, "grant")
+
+        #: URL for revoking admin access from one or more users.
+        revoke_admin = partialmethod(admin_access, "revoke")
+
+    urls = cached_property(_urls)
+
     def __init__(self, api: "pyairtable.api.api.Api", workspace_id: str):
         self.api = api
         self.id = workspace_id
         self._info: Optional[EnterpriseInfo] = None
 
-    @property
-    def url(self) -> str:
-        return self.api.build_url("meta/enterpriseAccounts", self.id)
-
     @cache_unless_forced
     def info(self) -> EnterpriseInfo:
         """
         Retrieve basic information about the enterprise, caching the result.
         """
         params = {"include": ["collaborators", "inviteLinks"]}
-        response = self.api.get(self.url, params=params)
+        response = self.api.get(self.urls.meta, params=params)
         return EnterpriseInfo.from_api(response, self.api)
 
     def group(self, group_id: str, collaborations: bool = True) -> UserGroup:
@@ -52,8 +99,7 @@ class Enterprise:
                 from Airtable. This may result in faster responses.
         """
         params = {"include": ["collaborations"] if collaborations else []}
-        url = self.api.build_url(f"meta/groups/{group_id}")
-        payload = self.api.get(url, params=params)
+        payload = self.api.get(self.urls.group(group_id), params=params)
         return UserGroup.model_validate(payload)
 
     def user(self, id_or_email: str, collaborations: bool = True) -> UserInfo:
@@ -89,7 +135,7 @@ class Enterprise:
             (emails if "@" in value else user_ids).append(value)
 
         response = self.api.get(
-            url=f"{self.url}/users",
+            url=self.urls.users,
             params={
                 "id": user_ids,
                 "email": emails,
@@ -212,10 +258,9 @@ class Enterprise:
         }
         params = {k: v for (k, v) in params.items() if v}
         offset_field = "next" if sort_asc else "previous"
-        url = self.api.build_url(f"meta/enterpriseAccounts/{self.id}/auditLogEvents")
         iter_requests = self.api.iterate_requests(
             method="GET",
-            url=url,
+            url=self.urls.audit_log,
             params=params,
             offset_field=offset_field,
         )
@@ -246,7 +291,7 @@ class Enterprise:
                 workspaces. If the user is not the sole owner of any workspaces,
                 this is optional and will be ignored if provided.
         """
-        url = f"{self.url}/users/{user_id}/remove"
+        url = self.urls.remove_user(user_id)
         payload: Dict[str, Any] = {"isDryRun": False}
         if replacement:
             payload["replacementOwnerId"] = replacement
@@ -277,7 +322,7 @@ class Enterprise:
                 for (key, value) in users.items()
             ]
         }
-        response = self.api.post(f"{self.url}/users/claim", json=payload)
+        response = self.api.post(self.urls.claim_users, json=payload)
         return ManageUsersResponse.from_api(response, self.api, context=self)
 
     def delete_users(self, emails: Iterable[str]) -> "DeleteUsersResponse":
@@ -287,7 +332,7 @@ class Enterprise:
         Args:
             emails: A list or other iterable of email addresses.
         """
-        response = self.api.delete(f"{self.url}/users", params={"email": list(emails)})
+        response = self.api.delete(self.urls.users, params={"email": list(emails)})
         return DeleteUsersResponse.from_api(response, self.api, context=self)
 
     def grant_admin(self, *users: Union[str, UserInfo]) -> "ManageUsersResponse":
@@ -311,10 +356,10 @@ class Enterprise:
         return self._post_admin_access("revoke", users)
 
     def _post_admin_access(
-        self, action: str, users: Iterable[Union[str, UserInfo]]
+        self, action: Literal["grant", "revoke"], users: Iterable[Union[str, UserInfo]]
     ) -> "ManageUsersResponse":
         response = self.api.post(
-            f"{self.url}/users/{action}AdminAccess",
+            self.urls.admin_access(action),
             json={
                 "users": [
                     {"email": user_id} if "@" in user_id else {"id": user_id}
diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py
index 687958c..235b598 100644
--- a/pyairtable/api/table.py
+++ b/pyairtable/api/table.py
@@ -1,9 +1,9 @@
 import base64
 import mimetypes
 import os
-import posixpath
 import urllib.parse
 import warnings
+from functools import cached_property
 from pathlib import Path
 from typing import Any, Dict, Iterable, Iterator, List, Optional, Union, overload
 
@@ -23,7 +23,7 @@ from pyairtable.api.types import (
 )
 from pyairtable.formulas import Formula, to_formula_str
 from pyairtable.models.schema import FieldSchema, TableSchema, parse_field_schema
-from pyairtable.utils import is_table_id
+from pyairtable.utils import Url, UrlBuilder, is_table_id
 
 
 class Table:
@@ -45,6 +45,36 @@ class Table:
     # Cached schema information to reduce API calls
     _schema: Optional[TableSchema] = None
 
+    class _urls(UrlBuilder):
+        #: URL for retrieving all records in the table
+        records = Url("{base.id}/{self.id_or_name}")
+
+        #: URL for retrieving all records in the table via POST,
+        #: when the request is too large to fit into GET parameters.
+        records_post = records / "listRecords"
+        fields = Url("meta/bases/{base.id}/tables/{self.id_or_name}/fields")
+
+        def record(self, record_id: RecordId) -> Url:
+            """
+            URL for a specific record in the table.
+            """
+            return self.records / record_id
+
+        def record_comments(self, record_id: RecordId) -> Url:
+            """
+            URL for comments on a specific record in the table.
+            """
+            return self.record(record_id) / "comments"
+
+        def upload_attachment(self, record_id: RecordId, field: str) -> Url:
+            """
+            URL for uploading an attachment to a specific field in a specific record.
+            """
+            url = self.build_url(f"{{base.id}}/{record_id}/{field}/uploadAttachment")
+            return url.replace_url(netloc="content.airtable.com")
+
+    urls = cached_property(_urls)
+
     @overload
     def __init__(
         self,
@@ -158,26 +188,26 @@ class Table:
         return self.schema().id
 
     @property
-    def url(self) -> str:
+    def id_or_name(self, quoted: bool = True) -> str:
         """
-        Build the URL for this table.
-        """
-        token = self._schema.id if self._schema else self.name
-        return self.api.build_url(self.base.id, urllib.parse.quote(token, safe=""))
+        Return the table ID if it is known, otherwise the table name used for the constructor.
+        This is the URL component used to identify the table in Airtable's API.
 
-    def meta_url(self, *components: str) -> str:
-        """
-        Build a URL to a metadata endpoint for this table.
-        """
-        return self.api.build_url(
-            f"meta/bases/{self.base.id}/tables/{self.id}", *components
-        )
+        Args:
+            quoted: Whether to return a URL-encoded value.
 
-    def record_url(self, record_id: RecordId, *components: str) -> str:
-        """
-        Build the URL for the given record ID, with optional trailing components.
+        Usage:
+
+            >>> table = base.table("Apartments")
+            >>> table.id_or_name
+            'Apartments'
+            >>> table.schema()
+            >>> table.id_or_name
+            'tblXXXXXXXXXXXXXX'
         """
-        return posixpath.join(self.url, record_id, *components)
+        value = self._schema.id if self._schema else self.name
+        value = value if not quoted else urllib.parse.quote(value, safe="")
+        return value
 
     @property
     def api(self) -> "pyairtable.api.api.Api":
@@ -204,7 +234,7 @@ class Table:
         """
         if self.api.use_field_ids:
             options.setdefault("use_field_ids", self.api.use_field_ids)
-        record = self.api.get(self.record_url(record_id), options=options)
+        record = self.api.get(self.urls.record(record_id), options=options)
         return assert_typed_dict(RecordDict, record)
 
     def iterate(self, **options: Any) -> Iterator[List[RecordDict]]:
@@ -241,8 +271,8 @@ class Table:
             options.setdefault("use_field_ids", self.api.use_field_ids)
         for page in self.api.iterate_requests(
             method="get",
-            url=self.url,
-            fallback=("post", f"{self.url}/listRecords"),
+            url=self.urls.records,
+            fallback=("post", self.urls.records_post),
             options=options,
         ):
             yield assert_typed_dicts(RecordDict, page.get("records", []))
@@ -316,7 +346,7 @@ class Table:
         if use_field_ids is None:
             use_field_ids = self.api.use_field_ids
         created = self.api.post(
-            url=self.url,
+            url=self.urls.records,
             json={
                 "fields": fields,
                 "typecast": typecast,
@@ -363,7 +393,7 @@ class Table:
         for chunk in self.api.chunked(records):
             new_records = [{"fields": fields} for fields in chunk]
             response = self.api.post(
-                url=self.url,
+                url=self.urls.records,
                 json={
                     "records": new_records,
                     "typecast": typecast,
@@ -402,7 +432,7 @@ class Table:
         method = "put" if replace else "patch"
         updated = self.api.request(
             method=method,
-            url=self.record_url(record_id),
+            url=self.urls.record(record_id),
             json={
                 "fields": fields,
                 "typecast": typecast,
@@ -442,7 +472,7 @@ class Table:
             chunk_records = [{"id": x["id"], "fields": x["fields"]} for x in chunk]
             response = self.api.request(
                 method=method,
-                url=self.url,
+                url=self.urls.records,
                 json={
                     "records": chunk_records,
                     "typecast": typecast,
@@ -510,7 +540,7 @@ class Table:
             ]
             response = self.api.request(
                 method=method,
-                url=self.url,
+                url=self.urls.records,
                 json={
                     "records": formatted_records,
                     "typecast": typecast,
@@ -541,7 +571,7 @@ class Table:
         """
         return assert_typed_dict(
             RecordDeletedDict,
-            self.api.delete(self.record_url(record_id)),
+            self.api.delete(self.urls.record(record_id)),
         )
 
     def batch_delete(self, record_ids: Iterable[RecordId]) -> List[RecordDeletedDict]:
@@ -566,7 +596,7 @@ class Table:
         record_ids = list(record_ids)
 
         for chunk in self.api.chunked(record_ids):
-            result = self.api.delete(self.url, params={"records[]": chunk})
+            result = self.api.delete(self.urls.records, params={"records[]": chunk})
             deleted_records += assert_typed_dicts(RecordDeletedDict, result["records"])
 
         return deleted_records
@@ -603,11 +633,10 @@ class Table:
         Args:
             record_id: |arg_record_id|
         """
-        url = self.record_url(record_id, "comments")
+        url = self.urls.record_comments(record_id)
+        ctx = {"record_url": self.urls.record(record_id)}
         return [
-            pyairtable.models.Comment.from_api(
-                comment, self.api, context={"record_url": self.record_url(record_id)}
-            )
+            pyairtable.models.Comment.from_api(comment, self.api, context=ctx)
             for page in self.api.iterate_requests("GET", url)
             for comment in page["comments"]
         ]
@@ -632,10 +661,10 @@ class Table:
             record_id: |arg_record_id|
             text: The text of the comment. Use ``@[usrIdentifier]`` to mention users.
         """
-        url = self.record_url(record_id, "comments")
+        url = self.urls.record_comments(record_id)
         response = self.api.post(url, json={"text": text})
         return pyairtable.models.Comment.from_api(
-            response, self.api, context={"record_url": self.record_url(record_id)}
+            response, self.api, context={"record_url": self.urls.record(record_id)}
         )
 
     def schema(self, *, force: bool = False) -> TableSchema:
@@ -691,7 +720,7 @@ class Table:
             request["description"] = description
         if options:
             request["options"] = options
-        response = self.api.post(self.meta_url("fields"), json=request)
+        response = self.api.post(self.urls.fields, json=request)
         # This hopscotch ensures that the FieldSchema object we return has an API and a URL,
         # and that developers don't need to reload our schema to be able to access it.
         field_schema = parse_field_schema(response)
@@ -763,7 +792,7 @@ class Table:
                 content_type = "application/octet-stream"
 
         # TODO: figure out how to handle the atypical subdomain in a more graceful fashion
-        url = f"https://content.airtable.com/v0/{self.base.id}/{record_id}/{field}/uploadAttachment"
+        url = self.urls.upload_attachment(record_id, field)
         content = content.encode() if isinstance(content, str) else content
         payload = {
             "contentType": content_type,
diff --git a/pyairtable/api/workspace.py b/pyairtable/api/workspace.py
index ed1cc45..5293d4e 100644
--- a/pyairtable/api/workspace.py
+++ b/pyairtable/api/workspace.py
@@ -1,7 +1,8 @@
+from functools import cached_property
 from typing import Any, Dict, List, Optional, Sequence, Union
 
 from pyairtable.models.schema import WorkspaceCollaborators
-from pyairtable.utils import cache_unless_forced, enterprise_only
+from pyairtable.utils import Url, UrlBuilder, cache_unless_forced, enterprise_only
 
 
 class Workspace:
@@ -20,14 +21,22 @@ class Workspace:
 
     _collaborators: Optional[WorkspaceCollaborators] = None
 
+    class _urls(UrlBuilder):
+        #: URL for retrieving the workspace's metadata and collaborators.
+        meta = Url("meta/workspaces/{id}")
+
+        #: URL for moving a base to a new workspace.
+        move_base = meta / "moveBase"
+
+        #: URL for POST requests that modify collaborations on the workspace.
+        collaborators = meta / "collaborators"
+
+    urls = cached_property(_urls)
+
     def __init__(self, api: "pyairtable.api.api.Api", workspace_id: str):
         self.api = api
         self.id = workspace_id
 
-    @property
-    def url(self) -> str:
-        return self.api.build_url("meta/workspaces", self.id)
-
     def create_base(
         self,
         name: str,
@@ -43,7 +52,7 @@ class Workspace:
             tables: A list of ``dict`` objects that conform to Airtable's
                 `Table model <https://airtable.com/developers/web/api/model/table-model>`__.
         """
-        url = self.api.build_url("meta/bases")
+        url = self.api.urls.bases
         payload = {"name": name, "workspaceId": self.id, "tables": list(tables)}
         response = self.api.post(url, json=payload)
         return self.api.base(response["id"], validate=True, force=True)
@@ -60,7 +69,7 @@ class Workspace:
         See https://airtable.com/developers/web/api/get-workspace-collaborators
         """
         params = {"include": ["collaborators", "inviteLinks"]}
-        payload = self.api.get(self.url, params=params)
+        payload = self.api.get(self.urls.meta, params=params)
         return WorkspaceCollaborators.from_api(payload, self.api, context=self)
 
     @enterprise_only
@@ -89,7 +98,7 @@ class Workspace:
             >>> ws = api.workspace("wspmhESAta6clCCwF")
             >>> ws.delete()
         """
-        self.api.delete(self.url)
+        self.api.delete(self.urls.meta)
 
     @enterprise_only
     def move_base(
@@ -113,8 +122,7 @@ class Workspace:
         payload: Dict[str, Any] = {"baseId": base_id, "targetWorkspaceId": target_id}
         if index is not None:
             payload["targetIndex"] = index
-        url = self.url + "/moveBase"
-        self.api.post(url, json=payload)
+        self.api.post(self.urls.move_base, json=payload)
 
 
 # These are at the bottom of the module to avoid circular imports
diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py
index 5bfb162..61d0256 100644
--- a/pyairtable/models/schema.py
+++ b/pyairtable/models/schema.py
@@ -546,7 +546,7 @@ class Collaborations(AirtableModel):
 class UserInfo(
     CanUpdateModel,
     CanDeleteModel,
-    url="{enterprise.url}/users/{self.id}",
+    url="{enterprise.urls.users}/{self.id}",
     writable=["state", "email", "first_name", "last_name"],
 ):
     """
diff --git a/pyairtable/utils.py b/pyairtable/utils.py
index 6b12744..4b6e66f 100644
--- a/pyairtable/utils.py
+++ b/pyairtable/utils.py
@@ -1,12 +1,15 @@
 import inspect
 import re
 import textwrap
+import urllib.parse
 import warnings
 from datetime import date, datetime
 from functools import partial, wraps
 from typing import (
+    TYPE_CHECKING,
     Any,
     Callable,
+    Dict,
     Generic,
     Iterable,
     Iterator,
@@ -19,10 +22,14 @@ from typing import (
 )
 
 import requests
-from typing_extensions import ParamSpec, Protocol
+from typing_extensions import ParamSpec, Protocol, Self
 
 from pyairtable.api.types import AnyRecordDict, CreateAttachmentByUrl, FieldValue
 
+if TYPE_CHECKING:
+    from pyairtable.api.api import Api
+
+
 P = ParamSpec("P")
 R = TypeVar("R", covariant=True)
 T = TypeVar("T")
@@ -180,8 +187,9 @@ def enterprise_only(wrapped: F, /, modify_docstring: bool = True) -> F:
     return _decorated  # type: ignore[return-value]
 
 
-def _prepend_docstring_text(obj: Any, text: str) -> None:
-    if not (doc := obj.__doc__):
+def _prepend_docstring_text(obj: Any, text: str, *, skip_empty: bool = True) -> None:
+    doc = obj.__doc__ or ""
+    if skip_empty and not doc:
         return
     doc = doc.lstrip("\n")
     if has_leading_spaces := re.match(r"^\s+", doc):
@@ -189,8 +197,9 @@ def _prepend_docstring_text(obj: Any, text: str) -> None:
     obj.__doc__ = f"{text}\n\n{doc}"
 
 
-def _append_docstring_text(obj: Any, text: str) -> None:
-    if not (doc := obj.__doc__):
+def _append_docstring_text(obj: Any, text: str, *, skip_empty: bool = True) -> None:
+    doc = obj.__doc__ or ""
+    if skip_empty and not doc:
         return
     doc = doc.rstrip("\n")
     if has_leading_spaces := re.match(r"^\s+", doc):
@@ -311,17 +320,196 @@ def fieldgetter(
     return _getter
 
 
-# [[[cog]]]
-# import re
-# contents = "".join(open(cog.inFile).readlines()[:cog.firstLineNum])
-# functions = re.findall(r"^def ([a-z]\w+)\(", contents, re.MULTILINE)
-# partials = re.findall(r"^([A-Za-z]\w+) = partial\(", contents, re.MULTILINE)
-# constants = re.findall(r"^([A-Z][A-Z_]+) = ", contents, re.MULTILINE)
-# cog.outl("__all__ = [")
-# for name in sorted(functions + partials + constants):
-#     cog.outl(f'    "{name}",')
-# cog.outl("]")
-# [[[out]]]
+class Url(str):
+    """
+    Wrapper for ``str`` that adds Path-like syntax for extending
+    URL components and adding query params.
+
+    >>> url = Url('http://example.com')
+    >>> url
+    Url('http://example.com')
+    >>> url / 'foo' & {'a': 1, 'b': [2, 3, 4]}
+    Url('http://example.com/foo?a=1&b=2&b=3&b=4')
+    >>> url // [1, 2, 3, 4]
+    Url('http://example.com/1/2/3/4')
+    """
+
+    def _parse(self) -> urllib.parse.ParseResult:
+        """
+        Shortcut for `urllib.parse.urlparse <https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlparse>`_.
+        """
+        return urllib.parse.urlparse(self)
+
+    def __repr__(self) -> str:
+        return f"{self.__class__.__name__}({super().__repr__()})"
+
+    def __truediv__(self, other: Any) -> Self:
+        return self.add_path(other)
+
+    def __floordiv__(self, others: Iterable[Any]) -> Self:
+        return self.add_path(*others)
+
+    def __and__(self, params: Dict[str, Any]) -> Self:
+        return self.add_qs(params)
+
+    def add_path(self, *others: Iterable[Any]) -> Self:
+        """
+        Build a copy of this URL with additional path segments.
+
+        >>> url = Url('http://example.com')
+        >>> url.add_path("a", "b", "c")
+        Url('http://example.com/a/b/c')
+
+        The shorthand ``/`` has the same effect and can be used with a single path segment.
+        The shorthand ``//`` can be used with an iterable of path segments.
+
+        >>> url / "a" / "b" / "c"
+        Url('http://example.com/a/b/c')
+        >>> url // ["a", "b", "c"]
+        Url('http://example.com/a/b/c')
+        """
+        if not others:
+            raise TypeError("add_path() requires at least one argument")
+        parsed = self._parse()
+        if parsed.query:
+            raise ValueError("cannot add path segments after params")
+        parts = [str(other) for other in others]
+        if parsed.path:
+            parts.insert(0, parsed.path.rstrip("/"))
+        return self.replace_url(path="/".join(parts))
+
+    def add_qs(
+        self,
+        params: Optional[Dict[str, Any]] = None,
+        **other_params: Any,
+    ) -> Self:
+        """
+        Build a copy of this URL with additional query parameters.
+        The shorthand ``&`` has the same effect.
+
+        >>> url = Url('http://example.com')
+        >>> url.add_qs({"a": 1}, b=[2, 3, 4])
+        Url('http://example.com?a=1&b=2&b=3&b=4')
+        >>> url & {"a": 1, "b": [2, 3, 4]}
+        Url('http://example.com?a=1&b=2&b=3&b=4')
+        """
+        if not (params or other_params):
+            raise TypeError("add_qs() requires at least one argument")
+        params = {} if params is None else params
+        params.update(other_params)
+        parsed = self._parse()
+        qs = urllib.parse.parse_qs(parsed.query)
+        qs.update(params)
+        return self.replace_url(query=urllib.parse.urlencode(qs, doseq=True))
+
+    def replace_url(self, **kwargs: Any) -> Self:
+        """
+        Build a copy of this URL with the given components replaced.
+
+        >>> url = Url('http://example.com')
+        >>> url.replace(scheme='https', path='/foo')
+        Url('https://example.com/foo')
+        """
+        return self.__class__(urllib.parse.urlunparse(self._parse()._replace(**kwargs)))
+
+
+class UrlBuilder:
+    """
+    Utility for defining URL patterns within an Airtable API class.
+    Each instance of UrlBuilder will inspect its own class attributes
+    and modify them to reflect the actual URL that should be used
+    based on the context (Table, Base, etc.) provided.
+
+    The pattern for use in pyAirtable is:
+
+    .. code-block:: python
+
+        from functools import cached_property
+        from pyairtable.utils import UrlBuilder
+
+        class SomeObject:
+            attr1: str
+            attr2: int
+
+            class _urls(UrlBuilder):
+                url1 = "/path/to/{attr1}"
+                url2 = "/path/to/{attr2}"
+
+            urls = cached_property(_urls)
+
+    ...which ensures the URLs are built only once and are accessible via ``.urls``,
+    and have the ``SomeObject`` instance available as context, and build
+    readable docstrings for the ``SomeObject`` class documentation.
+    """
+
+    context: Any
+    api: "Api"
+
+    def __init__(self, context: Any = None):
+        self.context = context
+        self.api = self._find_api(context)
+        for attr, value in vars(self.__class__).items():
+            if attr.startswith("_") or not isinstance(value, str):
+                continue
+            setattr(self, attr, self.build_url(value))
+
+    def build_url(self, value: str, **kwargs: Any) -> Url:
+        if "{" in value:
+            context = {**vars(self.context), **kwargs, "self": self.context}
+            value = value.format_map(context)
+        return self.api.build_url(value)
+
+    def __init_subclass__(cls, **kwargs: Any) -> None:
+        # This is a documentation hack for pyAirtable use cases only, where we
+        # subclass UrlBuilder within the definition of the class that uses it.
+        #
+        # We dynamically add a docstring to each subclass explaining its use,
+        # and we rely on Sphinx to document the cached_property, not the class.
+        #
+        # Will be skipped if the subclass is passed skip_docstring=True.
+        if "." not in cls.__qualname__ or kwargs.pop("skip_docstring", False):
+            return super().__init_subclass__(**kwargs)
+        try:
+            sample_url = next(k for (k, v) in vars(cls).items() if isinstance(v, Url))
+        except StopIteration:
+            # if no URLs defined, don't do anything
+            return super().__init_subclass__(**kwargs)
+
+        parent_clsname = cls.__qualname__.rsplit(".", 1)[0]
+        parent_modname = cls.__module__
+        parent_varname = parent_clsname.lower().replace(".", "_")
+        docstring = (
+            f"URLs associated with :class:`~{parent_modname}.{parent_clsname}`"
+            " can be accessed via ``.urls`` using the following syntax:\n\n"
+            ".. code-block:: python\n\n"
+            f"""
+            >>> {parent_varname} = {parent_clsname}(...)
+            >>> {parent_varname}.urls.{sample_url}
+            Url('https://api.airtable.com/...')
+            """
+            "\n\nThese properties are all instances of :class:`~pyairtable.utils.Url`."
+        )
+
+        for name, obj in vars(cls).items():
+            qualname = f"{parent_modname}::{cls.__qualname__}.{name}"
+            if isinstance(obj, Url):
+                docstring += f"\n\n.. autoattribute:: {qualname}\n    :noindex:"
+            elif callable(obj):
+                docstring += f"\n\n.. automethod:: {qualname}\n    :noindex:"
+
+        _append_docstring_text(cls, docstring, skip_empty=False)
+
+    @classmethod
+    def _find_api(self, context: Any) -> "Api":
+        from pyairtable.api.api import Api  # avoid circular import
+
+        if isinstance(context, Api):
+            return context
+        if isinstance(api := getattr(context, "api", None), Api):
+            return api
+        raise TypeError("context must be an instance of Api or have an 'api' attribute")
+
+
 __all__ = [
     "attachment",
     "cache_unless_forced",
@@ -341,5 +529,6 @@ __all__ = [
     "is_record_id",
     "is_table_id",
     "is_user_id",
+    "Url",
+    "UrlBuilder",
 ]
-# [[[end]]] (checksum: 7cf950d19fee128ae3f395ddbc475c0f)
diff --git a/tox.ini b/tox.ini
index f25eb58..e112cc7 100644
--- a/tox.ini
+++ b/tox.ini
@@ -47,7 +47,11 @@ commands =
 [testenv:coverage]
 passenv = COVERAGE_FORMAT
 commands =
-    python -m pytest -m 'not integration' --cov=pyairtable --cov-report={env:COVERAGE_FORMAT:html} --cov-fail-under=100
+    python -m pytest -m 'not integration' \
+        --cov=pyairtable \
+        --cov-report={env:COVERAGE_FORMAT:html} \
+        --cov-report=term-missing \
+        --cov-fail-under=100
 
 [testenv:docs]
 basepython = python3.9


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 9fcc597..b2790f7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -191,18 +191,18 @@ def schema_obj(api, sample_json):
 @pytest.fixture
 def mock_base_metadata(base, sample_json, requests_mock):
     base_json = sample_json("BaseCollaborators")
-    requests_mock.get(base.api.build_url("meta/bases"), json=sample_json("Bases"))
-    requests_mock.get(base.meta_url(), json=base_json)
-    requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema"))
-    requests_mock.get(base.meta_url("shares"), json=sample_json("BaseShares"))
+    requests_mock.get(base.api.urls.bases, json=sample_json("Bases"))
+    requests_mock.get(base.urls.meta, json=base_json)
+    requests_mock.get(base.urls.tables, json=sample_json("BaseSchema"))
+    requests_mock.get(base.urls.shares, json=sample_json("BaseShares"))
     for pbd_id, pbd_json in base_json["interfaces"].items():
-        requests_mock.get(base.meta_url("interfaces", pbd_id), json=pbd_json)
+        requests_mock.get(base.urls.interface(pbd_id), json=pbd_json)
 
 
 @pytest.fixture
 def mock_workspace_metadata(workspace, sample_json, requests_mock):
     workspace_json = sample_json("WorkspaceCollaborators")
-    requests_mock.get(workspace.url, json=workspace_json)
+    requests_mock.get(workspace.urls.meta, json=workspace_json)
 
 
 @pytest.fixture
diff --git a/tests/test_api_api.py b/tests/test_api_api.py
index 6e5f32f..6583f4b 100644
--- a/tests/test_api_api.py
+++ b/tests/test_api_api.py
@@ -7,7 +7,7 @@ from pyairtable import Api, Base, Table  # noqa
 
 @pytest.fixture
 def mock_bases_endpoint(api, requests_mock, sample_json):
-    return requests_mock.get(api.build_url("meta/bases"), json=sample_json("Bases"))
+    return requests_mock.get(api.urls.bases, json=sample_json("Bases"))
 
 
 def test_repr(api):
diff --git a/tests/test_api_base.py b/tests/test_api_base.py
index 4fadda8..307d070 100644
--- a/tests/test_api_base.py
+++ b/tests/test_api_base.py
@@ -9,7 +9,7 @@ from pyairtable.testing import fake_id
 
 @pytest.fixture
 def mock_tables_endpoint(base, requests_mock, sample_json):
-    return requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema"))
+    return requests_mock.get(base.urls.tables, json=sample_json("BaseSchema"))
 
 
 def test_constructor(api):
@@ -58,10 +58,6 @@ def test_repr(api, kwargs, expected):
     assert repr(base) == expected
 
 
-def test_url(base):
-    assert base.url == "https://api.airtable.com/v0/appLkNDICXNqxSDhG"
-
-
 def test_schema(base: Base, mock_tables_endpoint):
     table_schema = base.schema().table("tbltp8DGLhqbUmjK1")
     assert table_schema.name == "Apartments"
@@ -80,7 +76,7 @@ def test_table(base: Base, requests_mock):
     assert isinstance(rv, Table)
     assert rv.base == base
     assert rv.name == "tablename"
-    assert rv.url == f"https://api.airtable.com/v0/{base.id}/tablename"
+    assert rv.urls.records == f"https://api.airtable.com/v0/{base.id}/tablename"
 
 
 def test_table_validate(base: Base, mock_tables_endpoint):
@@ -114,14 +110,14 @@ def test_tables(base: Base, mock_tables_endpoint):
 
 
 def test_collaborators(base: Base, requests_mock, sample_json):
-    requests_mock.get(base.meta_url(), json=sample_json("BaseCollaborators"))
+    requests_mock.get(base.urls.meta, json=sample_json("BaseCollaborators"))
     result = base.collaborators()
     assert result.individual_collaborators.via_base[0].email == "foo@bam.com"
     assert result.group_collaborators.via_workspace[0].group_id == "ugp1mKGb3KXUyQfOZ"
 
 
 def test_shares(base: Base, requests_mock, sample_json):
-    requests_mock.get(base.meta_url("shares"), json=sample_json("BaseShares"))
+    requests_mock.get(base.urls.shares, json=sample_json("BaseShares"))
     result = base.shares()
     assert result[0].state == "enabled"
     assert result[1].effective_email_domain_allow_list == []
@@ -129,7 +125,7 @@ def test_shares(base: Base, requests_mock, sample_json):
 
 def test_webhooks(base: Base, requests_mock, sample_json):
     m = requests_mock.get(
-        base.webhooks_url,
+        base.urls.webhooks,
         json={"webhooks": [sample_json("Webhook")]},
     )
     webhooks = base.webhooks()
@@ -140,7 +136,7 @@ def test_webhooks(base: Base, requests_mock, sample_json):
 
 
 def test_webhook(base: Base, requests_mock, sample_json):
-    requests_mock.get(base.webhooks_url, json={"webhooks": [sample_json("Webhook")]})
+    requests_mock.get(base.urls.webhooks, json={"webhooks": [sample_json("Webhook")]})
     webhook = base.webhook("ach00000000000001")
     assert webhook.id == "ach00000000000001"
     assert webhook.notification_url == "https://example.com/receive-ping"
@@ -171,7 +167,7 @@ def test_add_webhook(base: Base, requests_mock):
             }
         }
     }
-    m = requests_mock.post(base.webhooks_url, json=_callback)
+    m = requests_mock.post(base.urls.webhooks, json=_callback)
     result = base.add_webhook("https://example.com/cb", spec)
 
     assert m.call_count == 1
@@ -187,7 +183,7 @@ def test_name(api, base, requests_mock):
     or if it is available in cached schema information.
     """
     requests_mock.get(
-        base.meta_url(),
+        base.urls.meta,
         json={
             "id": base.id,
             "name": "Mocked Base Name",
@@ -286,7 +282,7 @@ def test_delete(base, requests_mock):
     """
     Test that Base.delete() hits the right endpoint.
     """
-    m = requests_mock.delete(base.meta_url(), json={"id": base.id, "deleted": True})
+    m = requests_mock.delete(base.urls.meta, json={"id": base.id, "deleted": True})
     base.delete()
     assert m.call_count == 1
 
@@ -295,7 +291,7 @@ def test_delete__enterprise_only_table(api, base, requests_mock):
     """
     Test that Base.delete() explains why it might be getting a 404.
     """
-    requests_mock.delete(base.meta_url(), status_code=404)
+    requests_mock.delete(base.urls.meta, status_code=404)
     with pytest.raises(HTTPError) as excinfo:
         base.delete()
     assert "Base.delete() requires an enterprise billing plan" in str(excinfo)
diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py
index 21aea5c..86555b4 100644
--- a/tests/test_api_enterprise.py
+++ b/tests/test_api_enterprise.py
@@ -19,19 +19,24 @@ def enterprise_mocks(enterprise, requests_mock, sample_json):
     m.json_group = sample_json("UserGroup")
     m.user_id = m.json_user["id"]
     m.group_id = m.json_group["id"]
-    m.get_info = requests_mock.get(enterprise.url, json=sample_json("EnterpriseInfo"))
+    m.get_info = requests_mock.get(
+        enterprise_url := "https://api.airtable.com/v0/meta/enterpriseAccounts/entUBq2RGdihxl3vU",
+        json=sample_json("EnterpriseInfo"),
+    )
     m.get_user = requests_mock.get(
-        f"{enterprise.url}/users/{m.user_id}", json=m.json_user
+        f"{enterprise_url}/users/usrL2PNC5o3H4lBEi",
+        json=m.json_user,
+    )
+    m.get_users = requests_mock.get(
+        f"{enterprise_url}/users",
+        json=m.json_users,
     )
-    m.get_users = requests_mock.get(f"{enterprise.url}/users", json=m.json_users)
     m.get_group = requests_mock.get(
-        enterprise.api.build_url(f"meta/groups/{m.json_group['id']}"),
+        "https://api.airtable.com/v0/meta/groups/ugp1mKGb3KXUyQfOZ",
         json=m.json_group,
     )
     m.get_audit_log = requests_mock.get(
-        enterprise.api.build_url(
-            f"meta/enterpriseAccounts/{enterprise.id}/auditLogEvents"
-        ),
+        f"{enterprise_url}/auditLogEvents",
         response_list=[
             {
                 "json": {
@@ -45,11 +50,11 @@ def enterprise_mocks(enterprise, requests_mock, sample_json):
         ],
     )
     m.remove_user = requests_mock.post(
-        enterprise.url + f"/users/{m.user_id}/remove",
+        f"{enterprise_url}/users/{m.user_id}/remove",
         json=sample_json("UserRemoved"),
     )
     m.claim_users = requests_mock.post(
-        enterprise.url + "/users/claim",
+        f"{enterprise_url}/claim/users",
         json={"errors": []},
     )
     return m
@@ -240,7 +245,7 @@ def test_remove_user(enterprise, enterprise_mocks, kwargs, expected):
 @pytest.fixture
 def user_info(enterprise, enterprise_mocks):
     user_info = enterprise.user(enterprise_mocks.user_id)
-    assert user_info._url == f"{enterprise.url}/users/{user_info.id}"
+    assert user_info._url == f"{enterprise.urls.users}/{user_info.id}"
     return user_info
 
 
@@ -293,7 +298,7 @@ def test_delete_users(enterprise, requests_mock):
         ],
     }
     emails = [f"foo{n}@bar.com" for n in range(5)]
-    m = requests_mock.delete(enterprise.url + "/users", json=response)
+    m = requests_mock.delete(enterprise.urls.users, json=response)
     parsed = enterprise.delete_users(emails)
     assert m.call_count == 1
     assert m.last_request.qs == {"email": emails}
@@ -305,7 +310,7 @@ def test_delete_users(enterprise, requests_mock):
 @pytest.mark.parametrize("action", ["grant", "revoke"])
 def test_manage_admin_access(enterprise, enterprise_mocks, requests_mock, action):
     user = enterprise.user(enterprise_mocks.user_id)
-    m = requests_mock.post(f"{enterprise.url}/users/{action}AdminAccess", json={})
+    m = requests_mock.post(enterprise.urls.admin_access(action), json={})
     method = getattr(enterprise, f"{action}_admin")
     result = method(
         fake_user_id := fake_id("usr"),
diff --git a/tests/test_api_table.py b/tests/test_api_table.py
index 8e9277f..1de9e5b 100644
--- a/tests/test_api_table.py
+++ b/tests/test_api_table.py
@@ -1,5 +1,4 @@
 from datetime import datetime, timezone
-from posixpath import join as urljoin
 from unittest import mock
 
 import pytest
@@ -25,7 +24,7 @@ def mock_schema(table, requests_mock, sample_json):
     table_schema = sample_json("TableSchema")
     table_schema["id"] = table.name = fake_id("tbl")
     return requests_mock.get(
-        table.base.meta_url("tables") + "?include=visibleFieldIds",
+        table.base.urls.tables + "?include=visibleFieldIds",
         json={"tables": [table_schema]},
     )
 
@@ -45,7 +44,9 @@ def test_constructor_with_schema(base: Base, table_schema: TableSchema):
     assert table.api == base.api
     assert table.base == base
     assert table.name == table_schema.name
-    assert table.url == f"https://api.airtable.com/v0/{base.id}/{table_schema.id}"
+    assert (
+        table.urls.records == f"https://api.airtable.com/v0/{base.id}/{table_schema.id}"
+    )
     assert (
         repr(table)
         == f"<Table base='{base.id}' id='{table_schema.id}' name='{table_schema.name}'>"
@@ -91,7 +92,7 @@ def test_schema(base, requests_mock, sample_json):
     Test that we can load schema from API.
     """
     table = base.table("Apartments")
-    m = requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema"))
+    m = requests_mock.get(base.urls.tables, json=sample_json("BaseSchema"))
     assert isinstance(schema := table.schema(), TableSchema)
     assert m.call_count == 1
     assert schema.id == "tbltp8DGLhqbUmjK1"
@@ -102,7 +103,7 @@ def test_id(base, requests_mock, sample_json):
     Test that we load schema from API if we need the ID and don't have it,
     but if we get a name that *looks* like an ID, we trust it.
     """
-    m = requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema"))
+    m = requests_mock.get(base.urls.tables, json=sample_json("BaseSchema"))
 
     table = base.table("tbltp8DGLhqbUmjK1")
     assert table.id == "tbltp8DGLhqbUmjK1"
@@ -123,7 +124,7 @@ def test_id(base, requests_mock, sample_json):
 )
 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}"
+    assert table.urls.records == f"https://api.airtable.com/v0/{table_url_suffix}"
 
 
 def test_chunk(table: Table):
@@ -134,11 +135,6 @@ def test_chunk(table: Table):
     assert chunks[3] == [3]
 
 
-def test_record_url(table: Table):
-    rv = table.record_url("xxx")
-    assert rv == urljoin(table.url, "xxx")
-
-
 def test_api_key(table: Table, mock_response_single):
     def match_auth_header(request):
         expected_auth_header = "Bearer {}".format(table.api.api_key)
@@ -149,7 +145,7 @@ def test_api_key(table: Table, mock_response_single):
 
     with Mocker() as m:
         m.get(
-            table.record_url("rec"),
+            table.urls.record("rec"),
             status_code=200,
             json=mock_response_single,
             additional_matcher=match_auth_header,
@@ -161,7 +157,7 @@ def test_api_key(table: Table, mock_response_single):
 def test_get(table: Table, mock_response_single):
     _id = mock_response_single["id"]
     with Mocker() as mock:
-        mock.get(table.record_url(_id), status_code=200, json=mock_response_single)
+        mock.get(table.urls.record(_id), status_code=200, json=mock_response_single)
         resp = table.get(_id)
     assert dict_equals(resp, mock_response_single)
 
@@ -169,7 +165,7 @@ def test_get(table: 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.url, params={"maxRecords": 1}).prepare().url
+        url = Request("get", table.urls.records, params={"maxRecords": 1}).prepare().url
         mock.get(
             url,
             status_code=200,
@@ -183,7 +179,7 @@ def test_first(table: 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.url + "/listRecords"
+        url = table.urls.records_post
         formula = f"RECORD_ID() != '{'x' * 17000}'"
         mock_endpoint = mock.post(url, status_code=200, json=mock_response)
         rv = table.first(formula=formula)
@@ -199,7 +195,7 @@ def test_first_via_post(table: Table, mock_response_single):
 def test_first_none(table: Table, mock_response_single):
     mock_response = {"records": []}
     with Mocker() as mock:
-        url = Request("get", table.url, params={"maxRecords": 1}).prepare().url
+        url = Request("get", table.urls.records, params={"maxRecords": 1}).prepare().url
         mock.get(
             url,
             status_code=200,
@@ -211,7 +207,7 @@ def test_first_none(table: Table, mock_response_single):
 
 def test_all(table, requests_mock, mock_response_list, mock_records):
     requests_mock.get(
-        table.url,
+        table.urls.records,
         status_code=200,
         json=mock_response_list[0],
         complete_qs=True,
@@ -220,13 +216,13 @@ def test_all(table, requests_mock, mock_response_list, mock_records):
         offset = resp.get("offset", None)
         if not offset:
             continue
-        offset_url = table.url + "?offset={}".format(offset)
         requests_mock.get(
-            offset_url,
+            table.urls.records.add_qs(offset=offset),
             status_code=200,
             json=mock_response_list[1],
             complete_qs=True,
         )
+
     response = table.all()
 
     for n, resp in enumerate(response):
@@ -260,7 +256,7 @@ def test_all__params(table, requests_mock, kwargs, expected):
     """
     Test that parameters to all() get translated to query string correctly.
     """
-    m = requests_mock.get(table.url, status_code=200, json={"records": []})
+    m = requests_mock.get(table.urls.records, status_code=200, json={"records": []})
     table.all(**kwargs)
     assert m.last_request.qs == expected
 
@@ -268,7 +264,7 @@ def test_all__params(table, requests_mock, kwargs, expected):
 def test_iterate(table: Table, mock_response_list, mock_records):
     with Mocker() as mock:
         mock.get(
-            table.url,
+            table.urls.records,
             status_code=200,
             json=mock_response_list[0],
             complete_qs=True,
@@ -278,7 +274,7 @@ def test_iterate(table: Table, mock_response_list, mock_records):
             if not offset:
                 continue
             params = {"offset": offset}
-            offset_url = Request("get", table.url, params=params).prepare().url
+            offset_url = Request("get", table.urls.records, params=params).prepare().url
             mock.get(
                 offset_url,
                 status_code=200,
@@ -303,7 +299,7 @@ def test_iterate__formula_conversion(table):
 
     m.assert_called_once_with(
         method="get",
-        url=table.url,
+        url=table.urls.records,
         fallback=mock.ANY,
         options={
             "formula": "AND({Name}='Alice')",
@@ -315,7 +311,7 @@ def test_create(table: Table, mock_response_single):
     with Mocker() as mock:
         post_data = mock_response_single["fields"]
         mock.post(
-            table.url,
+            table.urls.records,
             status_code=201,
             json=mock_response_single,
             additional_matcher=match_request_data(post_data),
@@ -329,7 +325,7 @@ def test_batch_create(table: Table, container, mock_records):
     with Mocker() as mock:
         for chunk in _chunk(mock_records, 10):
             mock.post(
-                table.url,
+                table.urls.records,
                 status_code=201,
                 json={"records": chunk},
             )
@@ -345,7 +341,7 @@ def test_update(table: Table, mock_response_single, replace, http_method):
     with Mocker() as mock:
         mock.register_uri(
             http_method,
-            urljoin(table.url, id_),
+            table.urls.record(id_),
             status_code=201,
             json=mock_response_single,
             additional_matcher=match_request_data(post_data),
@@ -361,7 +357,7 @@ def test_batch_update(table: Table, container, replace, http_method):
     with Mocker() as mock:
         mock.register_uri(
             http_method,
-            table.url,
+            table.urls.records,
             response_list=[
                 {"json": {"records": chunk}} for chunk in table.api.chunked(records)
             ],
@@ -391,7 +387,7 @@ def test_batch_upsert(table: Table, container, replace, http_method, monkeypatch
     with Mocker() as mock:
         mock.register_uri(
             http_method,
-            table.url,
+            table.urls.records,
             response_list=[{"json": response} for response in responses],
         )
         monkeypatch.setattr(table.api, "MAX_RECORDS_PER_REQUEST", 1)
@@ -420,7 +416,7 @@ 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.url, id_), status_code=201, json=expected)
+        mock.delete(table.urls.record(id_), status_code=201, json=expected)
         resp = table.delete(id_)
     assert resp == expected
 
@@ -432,7 +428,9 @@ def test_batch_delete(table: Table, container, mock_records):
         for chunk in _chunk(ids, 10):
             json_response = {"records": [{"deleted": True, "id": id_} for id_ in chunk]}
             url_match = (
-                Request("get", table.url, params={"records[]": chunk}).prepare().url
+                Request("get", table.urls.records, params={"records[]": chunk})
+                .prepare()
+                .url
             )
             mock.delete(
                 url_match,
@@ -450,7 +448,7 @@ def test_create_field(table, mock_schema, requests_mock, sample_json):
     Tests the API for creating a field (but without actually performing the operation).
     """
     mock_create = requests_mock.post(
-        table.meta_url("fields"),
+        table.urls.fields,
         json=sample_json("field_schema/SingleSelectFieldSchema"),
     )
 
@@ -503,7 +501,7 @@ def test_use_field_ids__get_record(table, monkeypatch, requests_mock):
     (but not the explicit behavior) of Table.get()
     """
     record = fake_record()
-    url = table.record_url(record_id := record["id"])
+    url = table.urls.record(record_id := record["id"])
     m = requests_mock.register_uri("GET", url, json=record)
 
     # by default, we don't pass the param at all
@@ -531,7 +529,7 @@ def test_use_field_ids__get_records(table, monkeypatch, requests_mock, method_na
     Test that setting api.use_field_ids=True will change the default behavior
     (but not the explicit behavior) of Table.all() and Table.first()
     """
-    m = requests_mock.register_uri("GET", table.url, json={"records": []})
+    m = requests_mock.register_uri("GET", table.urls.records, json={"records": []})
 
     # by default, we don't pass the param at all
     method = getattr(table, method_name)
@@ -577,9 +575,9 @@ def test_use_field_ids__post(
     Test that setting api.use_field_ids=True will change the default behavior
     (but not the explicit behavior) of the create/update API methods on Table.
     """
-    url = f"{table.url}/{suffix}".rstrip("/")
+    url = table.urls.records / suffix
     print(f"{url=}")
-    m = requests_mock.register_uri(http_method, url, json=response)
+    m = requests_mock.register_uri(http_method, url.rstrip("/"), json=response)
 
     # by default, the param is False
     method = getattr(table, method_name)
diff --git a/tests/test_api_workspace.py b/tests/test_api_workspace.py
index ad66933..1bf5106 100644
--- a/tests/test_api_workspace.py
+++ b/tests/test_api_workspace.py
@@ -16,7 +16,9 @@ def workspace(api, workspace_id):
 
 @pytest.fixture
 def mock_info(workspace, requests_mock, sample_json):
-    return requests_mock.get(workspace.url, json=sample_json("WorkspaceCollaborators"))
+    return requests_mock.get(
+        workspace.urls.meta, json=sample_json("WorkspaceCollaborators")
+    )
 
 
 def test_collaborators(workspace, mock_info):
@@ -39,7 +41,7 @@ def test_bases(workspace, mock_info):
 
 
 def test_create_base(workspace, requests_mock, sample_json):
-    url = workspace.api.build_url("meta/bases")
+    url = workspace.api.urls.bases
     requests_mock.get(url, json=sample_json("Bases"))
     requests_mock.post(url, json={"id": "appLkNDICXNqxSDhG"})
     base = workspace.create_base("Base Name", [])
@@ -48,7 +50,9 @@ def test_create_base(workspace, requests_mock, sample_json):
 
 
 def test_delete(workspace, requests_mock):
-    m = requests_mock.delete(workspace.url, json={"id": workspace.id, "deleted": True})
+    m = requests_mock.delete(
+        workspace.urls.meta, json={"id": workspace.id, "deleted": True}
+    )
     workspace.delete()
     assert m.call_count == 1
 
@@ -73,7 +77,7 @@ def test_move_base(
     expected,
     requests_mock,
 ):
-    m = requests_mock.post(workspace.url + "/moveBase")
+    m = requests_mock.post(workspace.urls.move_base)
     workspace.move_base(locals()[base_param], locals()[workspace_param], **kwargs)
     assert m.call_count == 1
     assert m.request_history[-1].json() == {
diff --git a/tests/test_cli.py b/tests/test_cli.py
index aa0940e..8831a04 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -27,14 +27,12 @@ def mock_metadata(
     user_info = sample_json("UserInfo")
     user_group = sample_json("UserGroup")
     enterprise_info = sample_json("EnterpriseInfo")
-    requests_mock.get(api.build_url("meta/whoami"), json={"id": user_id})
-    requests_mock.get(enterprise.url, json=enterprise_info)
-    requests_mock.get(f"{enterprise.url}/users/{user_id}", json=user_info)
-    requests_mock.get(f"{enterprise.url}/users", json={"users": [user_info]})
+    requests_mock.get(api.urls.whoami, json={"id": user_id})
+    requests_mock.get(enterprise.urls.meta, json=enterprise_info)
+    requests_mock.get(enterprise.urls.users, json={"users": [user_info]})
+    requests_mock.get(enterprise.urls.user(user_id), json=user_info)
     for group_id in enterprise_info["groupIds"]:
-        requests_mock.get(
-            enterprise.api.build_url(f"meta/groups/{group_id}"), json=user_group
-        )
+        requests_mock.get(enterprise.urls.group(group_id), json=user_group)
 
 
 @pytest.fixture
diff --git a/tests/test_models_comment.py b/tests/test_models_comment.py
index 2f59747..4895ae5 100644
--- a/tests/test_models_comment.py
+++ b/tests/test_models_comment.py
@@ -16,7 +16,7 @@ def comment_json(sample_json):
 
 @pytest.fixture
 def comment(comment_json, table):
-    record_url = table.record_url(RECORD_ID)
+    record_url = table.urls.record(RECORD_ID)
     return Comment.from_api(comment_json, table.api, context={"record_url": record_url})
 
 
diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py
index 9c1aebc..65d2964 100644
--- a/tests/test_models_schema.py
+++ b/tests/test_models_schema.py
@@ -9,6 +9,16 @@ from pyairtable.models._base import AirtableModel
 from pyairtable.testing import fake_id
 
 
+@pytest.fixture
+def mock_base_metadata(base, sample_json, requests_mock):
+    base_json = sample_json("BaseCollaborators")
+    requests_mock.get(base.urls.meta, json=base_json)
+    requests_mock.get(base.urls.tables, json=sample_json("BaseSchema"))
+    requests_mock.get(base.urls.shares, json=sample_json("BaseShares"))
+    for pbd_id, pbd_json in base_json["interfaces"].items():
+        requests_mock.get(base.urls.interface(pbd_id), json=pbd_json)
+
+
 @pytest.mark.parametrize(
     "clsname",
     [
@@ -128,8 +138,8 @@ def test_base_collaborators__add(
     Test that we can call base.collaborators().add_{user,group}
     to grant access to the base.
     """
-    m = requests_mock.post(base.meta_url("collaborators"), body="")
     method = getattr(base.collaborators(), f"add_{kind}")
+    m = requests_mock.post(base.urls.collaborators, body="")
     method(id, "read")
     assert m.call_count == 1
     assert m.last_request.json() == {
@@ -151,9 +161,9 @@ def test_workspace_collaborators__add(api, kind, id, requests_mock, sample_json)
     """
     workspace_json = sample_json("WorkspaceCollaborators")
     workspace = api.workspace(workspace_json["id"])
-    requests_mock.get(workspace.url, json=workspace_json)
-    m = requests_mock.post(f"{workspace.url}/collaborators", body="")
+    requests_mock.get(workspace.urls.meta, json=workspace_json)
     method = getattr(workspace.collaborators(), f"add_{kind}")
+    m = requests_mock.post(workspace.urls.collaborators, body="")
     method(id, "read")
     assert m.call_count == 1
     assert m.last_request.json() == {
@@ -229,7 +239,7 @@ def test_invite_link__delete(
 
 @pytest.fixture
 def interface_url(base):
-    return base.meta_url("interfaces", "pbdLkNDICXNqxSDhG")
+    return base.urls.interface("pbdLkNDICXNqxSDhG")
 
 
 @pytest.mark.parametrize("kind", ("user", "group"))
diff --git a/tests/test_orm.py b/tests/test_orm.py
index 4f39b34..1ead27b 100644
--- a/tests/test_orm.py
+++ b/tests/test_orm.py
@@ -208,7 +208,7 @@ def test_linked_record():
     assert not contact.address[0].street
 
     with Mocker() as mock:
-        url = Address.meta.table.record_url(address.id)
+        url = Address.meta.table.urls.record(address.id)
         mock.get(url, status_code=200, json=record)
         contact.address[0].fetch()
 
@@ -227,11 +227,11 @@ def test_linked_record_can_be_saved(requests_mock, access_linked_records):
     """
     address_json = fake_record(Number=123, Street="Fake St")
     address_id = address_json["id"]
-    address_url_re = re.escape(Address.meta.table.url + "?filterByFormula=")
+    address_url_re = re.escape(Address.meta.table.urls.records + "?filterByFormula=")
     contact_json = fake_record(Email="alice@example.com", Link=[address_id])
     contact_id = contact_json["id"]
-    contact_url = Contact.meta.table.record_url(contact_id)
-    contact_url_re = re.escape(Contact.meta.table.url + "?filterByFormula=")
+    contact_url = Contact.meta.table.urls.record(contact_id)
+    contact_url_re = re.escape(Contact.meta.table.urls.records + "?filterByFormula=")
     requests_mock.get(re.compile(address_url_re), json={"records": [address_json]})
     requests_mock.get(re.compile(contact_url_re), json={"records": [contact_json]})
     requests_mock.get(contact_url, json=contact_json)
@@ -290,12 +290,12 @@ def test_undeclared_field(requests_mock, test_case):
     )
 
     requests_mock.get(
-        Address.meta.table.url,
+        Address.meta.table.urls.records,
         status_code=200,
         json={"records": [record]},
     )
     requests_mock.get(
-        Address.meta.table.record_url(record["id"]),
+        Address.meta.table.urls.record(record["id"]),
         status_code=200,
         json=record,
     )
diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py
index aed47ed..d291b89 100644
--- a/tests/test_orm_fields.py
+++ b/tests/test_orm_fields.py
@@ -697,13 +697,12 @@ def test_link_field__cycle(requests_mock):
     rec_b = {"id": id_b, "createdTime": DATETIME_S, "fields": {"Friends": [id_c]}}
     rec_c = {"id": id_c, "createdTime": DATETIME_S, "fields": {"Friends": [id_a]}}
 
-    requests_mock.get(Person.meta.table.record_url(id_a), json=rec_a)
+    requests_mock.get(Person.meta.table.urls.record(id_a), json=rec_a)
     a = Person.from_id(id_a)
 
+    url = Person.meta.table.urls.records
     for record in (rec_a, rec_b, rec_c):
-        url_re = re.compile(
-            re.escape(Person.meta.table.url + "?filterByFormula=") + ".*" + record["id"]
-        )
+        url_re = re.compile(re.escape(f"{url}?filterByFormula=") + ".*" + record["id"])
         requests_mock.get(url_re, json={"records": [record]})
 
     assert a.friends[0].id == id_b
@@ -718,7 +717,7 @@ def test_link_field__load_many(requests_mock):
     """
 
     person_id = fake_id("rec", "A")
-    person_url = Person.meta.table.record_url(person_id)
+    person_url = Person.meta.table.urls.record(person_id)
     friend_ids = [fake_id("rec", c) for c in "123456789ABCDEF"]
 
     person_json = {
@@ -740,7 +739,7 @@ def test_link_field__load_many(requests_mock):
     # The mocked URL specifically includes every record ID in our test set,
     # to ensure the library isn't somehow dropping records from its query.
     url_regex = ".*".join(
-        [re.escape(Person.meta.table.url + "?filterByFormula="), *friend_ids]
+        [re.escape(Person.meta.table.urls.records + "?filterByFormula="), *friend_ids]
     )
     mock_list = requests_mock.get(
         re.compile(url_regex),
@@ -796,10 +795,10 @@ def test_link_field__save(requests_mock, mutation):
         }
 
     requests_mock.get(
-        Book.meta.table.url,
+        Book.meta.table.urls.records,
         json={"records": [b1.to_record(), b2.to_record()]},
     )
-    m = requests_mock.patch(Author.meta.table.record_url(author.id), json=_cb)
+    m = requests_mock.patch(Author.meta.table.urls.record(author.id), json=_cb)
     exec(mutation, {}, {"author": author, "book": b2})
     assert author._changed["Books"]
     author.save()
@@ -1020,7 +1019,7 @@ def test_datetime_timezones(requests_mock):
             "fields": request.json()["fields"],
         }
 
-    m = requests_mock.patch(M.meta.table.record_url(obj.id), json=patch_callback)
+    m = requests_mock.patch(M.meta.table.urls.record(obj.id), json=patch_callback)
 
     # Test that we parse the "Z" into UTC correctly
     assert obj.dt.date() == datetime.date(2024, 2, 29)
diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py
index 2e1559b..ff4763f 100644
--- a/tests/test_orm_model.py
+++ b/tests/test_orm_model.py
@@ -223,8 +223,8 @@ def test_from_ids(mock_api):
     contacts = FakeModel.from_ids(fake_ids)
     mock_api.assert_called_once_with(
         method="get",
-        url=FakeModel.meta.table.url,
-        fallback=("post", FakeModel.meta.table.url + "/listRecords"),
+        url=FakeModel.meta.table.urls.records,
+        fallback=("post", FakeModel.meta.table.urls.records_post),
         options={
             "formula": (
                 "OR(%s)" % ", ".join(f"RECORD_ID()='{id}'" for id in sorted(fake_ids))
@@ -295,7 +295,10 @@ def test_get_fields_by_id(fake_records_by_id):
     """
     with Mocker() as mock:
         mock.get(
-            f"{FakeModelByIds.meta.table.url}?&returnFieldsByFieldId=1&cellFormat=json",
+            FakeModelByIds.meta.table.urls.records.add_qs(
+                returnFieldsByFieldId=1,
+                cellFormat="json",
+            ),
             json=fake_records_by_id,
             complete_qs=True,
             status_code=200,
diff --git a/tests/test_orm_model__memoization.py b/tests/test_orm_model__memoization.py
index 053d02d..3f90005 100644
--- a/tests/test_orm_model__memoization.py
+++ b/tests/test_orm_model__memoization.py
@@ -47,24 +47,24 @@ def record_mocks(requests_mock):
 
     # for Model.all
     mocks.get_authors = requests_mock.get(
-        Author.meta.table.url,
+        Author.meta.table.urls.records,
         json={"records": list(mocks.authors.values())},
     )
     mocks.get_books = requests_mock.get(
-        Book.meta.table.url,
+        Book.meta.table.urls.records,
         json={"records": list(mocks.books.values())},
     )
 
     # for Model.from_id
     mocks.get_author = {
         record_id: requests_mock.get(
-            Author.meta.table.record_url(record_id), json=record_data
+            Author.meta.table.urls.record(record_id), json=record_data
         )
         for record_id, record_data in mocks.authors.items()
     }
     mocks.get_book = {
         record_id: requests_mock.get(
-            Book.meta.table.record_url(record_id), json=record_data
+            Book.meta.table.urls.record(record_id), json=record_data
         )
         for record_id, record_data in mocks.books.items()
     }
diff --git a/tests/test_params.py b/tests/test_params.py
index bd23820..81820c9 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.url, url_params)
+        mock_url = "{0}?{1}".format(table.urls.records, 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_testing__mock_airtable.py b/tests/test_testing__mock_airtable.py
index 6016819..5443442 100644
--- a/tests/test_testing__mock_airtable.py
+++ b/tests/test_testing__mock_airtable.py
@@ -246,7 +246,7 @@ def test_passthrough(mock_airtable, requests_mock, base, monkeypatch):
     """
     Test that we can temporarily pass through unhandled methods to the requests library.
     """
-    requests_mock.get(base.meta_url("tables"), json={"tables": []})
+    requests_mock.get(base.urls.tables, json={"tables": []})
 
     with monkeypatch.context() as mctx:
         mctx.setattr(mock_airtable, "passthrough", True)
diff --git a/tests/test_typing.py b/tests/test_typing.py
index 412542e..4172478 100644
--- a/tests/test_typing.py
+++ b/tests/test_typing.py
@@ -10,6 +10,7 @@ from typing_extensions import assert_type
 import pyairtable
 import pyairtable.api.types as T
 import pyairtable.orm.lists as L
+import pyairtable.utils
 from pyairtable import orm
 
 if TYPE_CHECKING:
@@ -22,7 +23,7 @@ if TYPE_CHECKING:
 
     # 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.build_url("foo", "bar"), pyairtable.utils.Url)
     assert_type(api.base(base_id), pyairtable.Base)
     assert_type(api.table(base_id, table_name), pyairtable.Table)
     assert_type(api.whoami(), T.UserAndScopesDict)
@@ -30,7 +31,7 @@ if TYPE_CHECKING:
     # 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)
+    assert_type(base.id, str)
 
     # Ensure the type signatures for pyairtable.Table don't change.
     table = pyairtable.Table(None, base, table_name)
diff --git a/tests/test_url_escape.py b/tests/test_url_escape.py
index bb6c06b..240edb0 100644
--- a/tests/test_url_escape.py
+++ b/tests/test_url_escape.py
@@ -17,4 +17,4 @@ def test_url_escape(base, table_name, escaped):
     table names (which Airtable *will* allow).
     """
     table = base.table(table_name)
-    assert table.url.endswith(escaped)
+    assert table.urls.records.endswith(escaped)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index c8b9dd4..427628a 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -148,3 +148,88 @@ def test_fieldgetter__required_str():
     assert get_abc_require_b(record) == ("one", "two")
     with pytest.raises(KeyError):
         get_abc_require_b(fake_record(Alpha="one"))
+
+
+def test_url_builder(base):
+    class Example(utils.UrlBuilder):
+        static = "one/two/three"
+        with_attr = "id/{id}"
+        with_self_attr = "self.id/{self.id}"
+        with_property = "self.name/{self.name}"
+        _ignored = "ignored"
+
+    urls = Example(base)
+    assert urls.static == "https://api.airtable.com/v0/one/two/three"
+    assert urls.with_attr == f"https://api.airtable.com/v0/id/{base.id}"
+    assert urls.with_self_attr == f"https://api.airtable.com/v0/self.id/{base.id}"
+    assert urls.with_property == f"https://api.airtable.com/v0/self.name/{base.name}"
+    assert urls._ignored == "ignored"
+
+
+@pytest.mark.parametrize("obj", [None, object(), {"api": object()}])
+def test_url_builder__invalid_context(obj):
+    with pytest.raises(TypeError):
+        utils.UrlBuilder(obj)
+
+
+def test_url_builder__modifies_docstring():
+    """
+    This test is a bit meta, but it ensures that anyone else who wants to use UrlBuilder
+    can skip docstring creation by passing skip_docstring=True
+    """
+
+    class NormalBehavior(utils.UrlBuilder):
+        test = utils.Url("https://example.com")
+
+    class MissingDocstring(utils.UrlBuilder, skip_docstring=True):
+        test = utils.Url("https://example.com")
+
+    class ExistingDocstring(utils.UrlBuilder, skip_docstring=True):
+        """This is the docstring."""
+
+        test = utils.Url("https://example.com")
+
+    assert "URLs associated with :class:" in NormalBehavior.__doc__
+    assert MissingDocstring.__doc__ is None
+    assert ExistingDocstring.__doc__ == "This is the docstring."
+
+
+def test_url():
+    v = utils.Url("https://example.com")
+    assert v == "https://example.com"
+    assert v / "foo/bar" / "baz" == "https://example.com/foo/bar/baz"
+    assert v // [1, 2, "a", "b"] == "https://example.com/1/2/a/b"
+    assert v & {"a": 1, "b": [2, 3, 4]} == "https://example.com?a=1&b=2&b=3&b=4"
+    assert v.add_path(1, 2, "a", "b") == "https://example.com/1/2/a/b"
+    assert v.add_qs({"a": 1}, b=[2, 3, 4]) == "https://example.com?a=1&b=2&b=3&b=4"
+
+    with pytest.raises(TypeError):
+        v.add_path()
+    with pytest.raises(TypeError):
+        v.add_qs()
+
+
+def test_url__parse():
+    v = utils.Url("https://example.com:443/asdf?a=1&b=2&b=3#foo")
+    parsed = v._parse()
+    assert parsed.scheme == "https"
+    assert parsed.netloc == "example.com:443"
+    assert parsed.path == "/asdf"
+    assert parsed.query == "a=1&b=2&b=3"
+    assert parsed.fragment == "foo"
+    assert parsed.hostname == "example.com"
+    assert parsed.port == 443
+
+
+def test_url__replace():
+    v = utils.Url("https://example.com:443/asdf?a=1&b=2&b=3#foo")
+    assert v.replace_url(netloc="foo.com") == "https://foo.com/asdf?a=1&b=2&b=3#foo"
+
+
+def test_url_cannot_append_after_params():
+    # cannot add path segments after params
+    v = utils.Url("https://example.com?a=1&b=2")
+    with pytest.raises(ValueError):
+        v / "foo"
+    with pytest.raises(ValueError):
+        v // ["foo", "bar"]