diff --git a/README.md b/README.md index 626fac7..c6179b4 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Bild-Python -Python library for interacting with the Bild External API. +Python library for the [Bild External API](https://bildexternalapi.portledocs.com/#/docs/apireference?api_page=introduction&product_version=77). -> This repo is currently intended to be used directly from source (not from PyPI yet). +> This repo is currently intended to be used from source (not published to PyPI yet). ## 1) Clone and set up @@ -10,17 +10,41 @@ Python library for interacting with the Bild External API. git clone https://github.com/AJFrio/Bild-Python.git cd Bild-Python python3 -m venv .venv -source .venv/bin/activate -pip install requests +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e . ``` -## 2) Set your API token +## 2) Authenticate + +Bild APIs require a **JWT personal access token**. Admin users can issue one in the Bild web app. Tokens issued in the app are listed there with issued-at, issued-by, and expiry. + +The client sends that token on every request as: + +```text +Authorization: Bearer +``` + +Set it in the environment: ```bash export BILD_API_KEY="YOUR_JWT_TOKEN" ``` -Or pass token directly in code. +```powershell +$env:BILD_API_KEY = "YOUR_JWT_TOKEN" +``` + +Or pass it directly: + +```python +from bild import BildClient + +client = BildClient(token="YOUR_JWT_TOKEN") +``` + +`BildClient()` with no arguments reads `BILD_API_KEY`. A missing token raises `ValueError`. Invalid or expired tokens raise `BildAuthError` (HTTP 401/403). Other failed responses raise `BildAPIError`. + +Default API host: `https://api.getbild.com`. ## 3) Basic usage @@ -28,7 +52,6 @@ Or pass token directly in code. from bild import BildClient client = BildClient() # uses BILD_API_KEY from env -# or: client = BildClient(token="YOUR_JWT_TOKEN") projects = client.api.projects.list() print(projects) @@ -52,13 +75,13 @@ print("Users:", users) print("Projects:", projects) ``` -### Add users to your account +### Invite users to your account ```python -client.api.users.add( +client.api.users.invite( emails=["person@example.com"], - role="Member", - projects=[{"id": "project-id", "projectAccess": "Editor"}] + projects=[{"id": "project-id", "projectAccess": "Editor"}], + pdm_role="Member", ) ``` @@ -72,12 +95,11 @@ print(files) ### Convert a file to STL (auto-default branch + latest version) ```python -result = client.api.files.universal_format( +result = client.api.files.export_universal( project_id="project-id", branch_id=None, # auto-resolves main/default branch file_id="file-id", - file_version=None, # auto-resolves latest file version - output_format="stl" + output_format="stl", ) print(result) ``` @@ -88,40 +110,45 @@ print(result) links = client.api.shared_links.list("project-id") print(links) -new_link = client.api.shared_links.create("project-id", { - "name": "Review Link", - "fileIds": ["file-id"] -}) +new_link = client.api.shared_links.create_live( + "project-id", + "branch-id", + name="Review Link", + file_ids=["file-id"], +) print(new_link) ``` ### Search ```python -search_result = client.api.search.query({"query": "bolt"}) +search_result = client.api.search.files("bolt") print(search_result) ``` --- -## API groups available - -- `client.api.users` -- `client.api.projects` -- `client.api.project_users` -- `client.api.branches_commits` -- `client.api.files` -- `client.api.file_upload` -- `client.api.file_checkin_checkout` -- `client.api.shared_links` -- `client.api.files_move_delete` -- `client.api.files_metadata` -- `client.api.feedback_items` -- `client.api.packages` -- `client.api.revisions` -- `client.api.approvals` -- `client.api.boms` -- `client.api.search` +## API groups + +These map to the groups in the [Bild External API reference](https://bildexternalapi.portledocs.com/#/docs/apireference?api_page=introduction&product_version=77): + +- `client.api.users` — account users (list, invite, update, remove, create_token) +- `client.api.projects` — list projects +- `client.api.project_users` — add / update / remove project access +- `client.api.branches` — list branches +- `client.api.commits` — list/get commits +- `client.api.files` — list files/versions, export STL/STEP, move, delete +- `client.api.uploads` — initiate / complete file upload +- `client.api.checkouts` — checkout, cancel, initiate/complete check-in +- `client.api.shared_links` — list, create live/static links, refresh, delete +- `client.api.metadata` — metadata fields and file metadata +- `client.api.feedback` — feedback items and attachments +- `client.api.packages` — account and project packages +- `client.api.revisions` — list/get/release/cancel revisions +- `client.api.approvals` — list/get/close approvals +- `client.api.boms` — list/get/download BOMs +- `client.api.search` — search files +- `client.api.webhooks` — webhook subscriptions --- @@ -130,7 +157,7 @@ print(search_result) ```python client = BildClient( token="YOUR_JWT_TOKEN", - base_url="https://api.portle.io/api" + base_url="https://api.getbild.com" ) ``` @@ -140,3 +167,11 @@ client = BildClient( raw = client.get("projects") print(raw) ``` + +## Tests + +```bash +python -m unittest discover -s tests -p "test_*.py" -v +``` + +If `BILD_API_KEY` is set, a live auth smoke test also runs against `GET /users`. diff --git a/bild/client.py b/bild/client.py index 0ce9348..f564b2e 100644 --- a/bild/client.py +++ b/bild/client.py @@ -8,7 +8,7 @@ from .errors import BildAPIError, BildAuthError -DEFAULT_BASE_URL = "https://api.portle.io/api" +DEFAULT_BASE_URL = "https://api.getbild.com" @dataclass @@ -16,19 +16,20 @@ class _Resources: users: "UsersAPI" projects: "ProjectsAPI" project_users: "ProjectUsersAPI" - branches_commits: "BranchesCommitsAPI" + branches: "BranchesAPI" + commits: "CommitsAPI" files: "FilesAPI" - file_upload: "FileUploadAPI" - file_checkin_checkout: "FileCheckinCheckoutAPI" + uploads: "UploadsAPI" + checkouts: "CheckoutsAPI" shared_links: "SharedLinksAPI" - files_move_delete: "FilesMoveDeleteAPI" - files_metadata: "FilesMetadataAPI" - feedback_items: "FeedbackItemsAPI" + metadata: "MetadataAPI" + feedback: "FeedbackAPI" packages: "PackagesAPI" revisions: "RevisionsAPI" approvals: "ApprovalsAPI" boms: "BOMsAPI" search: "SearchAPI" + webhooks: "WebhooksAPI" class BildClient: @@ -47,10 +48,12 @@ def __init__( self.base_url = base_url.rstrip("/") self.timeout = timeout self.session = session or requests.Session() + # Do not set Content-Type on the session. Bild's API treats that header as + # "this request has a JSON body" and GET/DELETE calls then 500 with + # "Unexpected end of JSON input". requests sets Content-Type when json= is used. self.session.headers.update( { "Authorization": f"Bearer {self.token}", - "Content-Type": "application/json", "Accept": "application/json", } ) @@ -59,26 +62,33 @@ def __init__( users=UsersAPI(self), projects=ProjectsAPI(self), project_users=ProjectUsersAPI(self), - branches_commits=BranchesCommitsAPI(self), + branches=BranchesAPI(self), + commits=CommitsAPI(self), files=FilesAPI(self), - file_upload=FileUploadAPI(self), - file_checkin_checkout=FileCheckinCheckoutAPI(self), + uploads=UploadsAPI(self), + checkouts=CheckoutsAPI(self), shared_links=SharedLinksAPI(self), - files_move_delete=FilesMoveDeleteAPI(self), - files_metadata=FilesMetadataAPI(self), - feedback_items=FeedbackItemsAPI(self), + metadata=MetadataAPI(self), + feedback=FeedbackAPI(self), packages=PackagesAPI(self), revisions=RevisionsAPI(self), approvals=ApprovalsAPI(self), boms=BOMsAPI(self), search=SearchAPI(self), + webhooks=WebhooksAPI(self), ) def request(self, method: str, path: str, *, params=None, json=None) -> Any: url = f"{self.base_url}/{path.lstrip('/')}" - response = self.session.request( - method=method.upper(), url=url, params=params, json=json, timeout=self.timeout - ) + kwargs: dict[str, Any] = { + "method": method.upper(), + "url": url, + "params": params, + "timeout": self.timeout, + } + if json is not None: + kwargs["json"] = json + response = self.session.request(**kwargs) if response.status_code in (401, 403): raise BildAuthError( "Authentication/authorization failed", @@ -138,8 +148,8 @@ def resolve_file_version( ) -> str: if file_version: return file_version - latest = self.get(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/latestFileVersion") - value = _pick_from_response(latest, "fileVersion", "id", "versionId", "latestFileVersion") + latest = self.get(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/latest") + value = _pick_from_response(latest, "fileVersion", "fileVersionID", "id", "versionId", "latestFileVersion") if value: return str(value) raise ValueError("Could not determine file_version automatically") @@ -154,8 +164,55 @@ class UsersAPI(_BaseAPI): def list(self): return self.client.get("users") - def add(self, emails: list[str], role: str = "Member", projects: list[dict] | None = None): - return self.client.put("users/add", json={"emails": emails, "role": role, "projects": projects or []}) + def invite( + self, + emails: list[str], + projects: list[dict] | None = None, + *, + company_role: str | None = None, + pdm_role: str | None = None, + plm_role: str | None = None, + ): + return self.client.put( + "users/add", + json=_omit_none( + { + "emails": emails, + "projects": projects or [], + "companyRole": company_role, + "pdmRole": pdm_role, + "plmRole": plm_role, + } + ), + ) + + def remove(self, user_ids: list[str]): + return self.client.put("users/remove", json={"userIDs": user_ids}) + + def update( + self, + user_ids: list[str], + projects: list[dict] | None = None, + *, + company_role: str | None = None, + pdm_role: str | None = None, + plm_role: str | None = None, + ): + return self.client.put( + "users/update", + json=_omit_none( + { + "userIDs": user_ids, + "projects": projects or [], + "companyRole": company_role, + "pdmRole": pdm_role, + "plmRole": plm_role, + } + ), + ) + + def create_token(self, *, name: str | None = None, expiry: float | None = None): + return self.client.post("users/apiToken", json=_omit_none({"name": name, "expiry": expiry})) class ProjectsAPI(_BaseAPI): @@ -167,24 +224,37 @@ class ProjectUsersAPI(_BaseAPI): def list(self, project_id: str): return self.client.get(f"projects/{project_id}/users") - def add(self, project_id: str, payload: dict): - return self.client.post(f"projects/{project_id}/users", json=payload) + def add(self, users: list[dict], project_ids: list[str] | None = None): + return self.client.post( + "projects/users/add", + json=_omit_none({"users": users, "projectIDs": project_ids}), + ) - def update(self, project_id: str, user_id: str, payload: dict): - return self.client.put(f"projects/{project_id}/users/{user_id}", json=payload) + def remove(self, project_ids: list[str], user_ids: list[str]): + return self.client.put( + "projects/users/remove", + json={"projectIDs": project_ids, "userIDs": user_ids}, + ) + + def update(self, users: list[dict], project_ids: list[str] | None = None): + return self.client.put( + "projects/users/update", + json=_omit_none({"users": users, "projectIDs": project_ids}), + ) -class BranchesCommitsAPI(_BaseAPI): - def list_branches(self, project_id: str): +class BranchesAPI(_BaseAPI): + def list(self, project_id: str): return self.client.get(f"projects/{project_id}/branches") - def branch(self, project_id: str, branch_id: str): - return self.client.get(f"projects/{project_id}/branches/{branch_id}") - def commits(self, project_id: str, branch_id: str): - return self.client.get(f"projects/{project_id}/branches/{branch_id}/commits") +class CommitsAPI(_BaseAPI): + def list(self, project_id: str, branch_id: str | None = None): + if branch_id: + return self.client.get(f"projects/{project_id}/branches/{branch_id}/commits") + return self.client.get(f"projects/{project_id}/commits") - def commit(self, project_id: str, branch_id: str, commit_id: str): + def get(self, project_id: str, branch_id: str, commit_id: str): return self.client.get(f"projects/{project_id}/branches/{branch_id}/commits/{commit_id}") @@ -194,150 +264,369 @@ def list(self, project_id: str, branch_id: str | None = None): return self.client.get(f"projects/{project_id}/branches/{branch_id}/files") return self.client.get(f"projects/{project_id}/files") - def get(self, project_id: str, branch_id: str | None, file_id: str): + def list_released(self, from_time: str): + return self.client.get("files/released", params={"fromTime": from_time}) + + def list_versions(self, project_id: str, branch_id: str | None, file_id: str): + branch_id = self.client.resolve_branch_id(project_id, branch_id) + return self.client.get(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/versions") + + def get_latest(self, project_id: str, branch_id: str | None, file_id: str): branch_id = self.client.resolve_branch_id(project_id, branch_id) - return self.client.get(f"projects/{project_id}/branches/{branch_id}/files/{file_id}") + return self.client.get(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/latest") + + def get_released(self, project_id: str, branch_id: str | None, file_id: str): + branch_id = self.client.resolve_branch_id(project_id, branch_id) + return self.client.get(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/released") + + def get_version(self, project_id: str, branch_id: str | None, file_id: str, version_id: str): + branch_id = self.client.resolve_branch_id(project_id, branch_id) + return self.client.get( + f"projects/{project_id}/branches/{branch_id}/files/{file_id}/versions/{version_id}" + ) + + def get_thumbnail(self, project_id: str, branch_id: str | None, file_id: str, version_id: str): + branch_id = self.client.resolve_branch_id(project_id, branch_id) + return self.client.get( + f"projects/{project_id}/branches/{branch_id}/files/{file_id}/versions/{version_id}/thumbnail" + ) - def latest_version(self, project_id: str, branch_id: str | None, file_id: str): + def get_children(self, project_id: str, branch_id: str | None, file_id: str, version_id: str): branch_id = self.client.resolve_branch_id(project_id, branch_id) - return self.client.get(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/latestFileVersion") + return self.client.get( + f"projects/{project_id}/branches/{branch_id}/files/{file_id}/versions/{version_id}/children" + ) - def universal_format( + def export_universal( self, project_id: str, branch_id: str | None, file_id: str, *, - file_version: str | None, output_format: str, + file_version: str | None = None, + file_config: str | None = None, ): branch_id = self.client.resolve_branch_id(project_id, branch_id) file_version = self.client.resolve_file_version(project_id, branch_id, file_id, file_version) - return self.client.post( - f"projects/{project_id}/branches/{branch_id}/files/{file_id}/universalFormat", - json={"fileVersion": file_version, "universalFileFormat": output_format}, + return self.client.put( + f"projects/{project_id}/branches/{branch_id}/fileActions/{file_id}/universalFormat", + json=_omit_none( + { + "fileVersionID": file_version, + "universalFileFormat": output_format, + "fileConfig": file_config, + } + ), ) + def export_universal_many(self, project_id: str, branch_id: str, payload: dict): + return self.client.post( + f"projects/{project_id}/branches/{branch_id}/files/exportUniversalFiles", + json=payload, + ) -class FileUploadAPI(_BaseAPI): - def init_upload(self, project_id: str, branch_id: str, payload: dict): - return self.client.put(f"projects/{project_id}/branches/{branch_id}/fileUpload", json=payload) + def move(self, project_id: str, branch_id: str, file_ids: list[str], new_parent_id: str): + return self.client.put( + f"projects/{project_id}/branches/{branch_id}/fileActions/move", + json={"moveFiles": file_ids, "newParentID": new_parent_id}, + ) - def complete_upload(self, project_id: str, branch_id: str, payload: dict): - return self.client.post(f"projects/{project_id}/branches/{branch_id}/fileUpload", json=payload) + def delete(self, project_id: str, branch_id: str, file_ids: list[str]): + return self.client.put( + f"projects/{project_id}/branches/{branch_id}/fileActions/delete", + json={"fileIDs": file_ids}, + ) -class FileCheckinCheckoutAPI(_BaseAPI): - def checkout(self, project_id: str, branch_id: str, file_id: str, payload: dict | None = None): - return self.client.put(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/checkout", json=payload or {}) +class UploadsAPI(_BaseAPI): + def initiate(self, project_id: str, branch_id: str, files: list[dict]): + return self.client.put( + f"projects/{project_id}/branches/{branch_id}/fileActions/initiateUpload", + json={"files": files}, + ) - def checkin(self, project_id: str, branch_id: str, file_id: str, payload: dict | None = None): - return self.client.put(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/checkin", json=payload or {}) + def complete( + self, + project_id: str, + branch_id: str, + files: list[dict], + *, + keep_checked_out: bool | None = None, + ): + return self.client.post( + f"projects/{project_id}/branches/{branch_id}/fileActions/completeUpload", + json=_omit_none({"files": files, "keepFilesCheckedOut": keep_checked_out}), + ) - def discard_checkout(self, project_id: str, branch_id: str, file_id: str, payload: dict | None = None): - return self.client.put(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/discardCheckout", json=payload or {}) - def create_version(self, project_id: str, branch_id: str, file_id: str, payload: dict): - return self.client.post(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/versions", json=payload) +class CheckoutsAPI(_BaseAPI): + def checkout(self, project_id: str, branch_id: str, file_ids: list[str]): + return self.client.put( + f"projects/{project_id}/branches/{branch_id}/fileActions/checkout", + json={"fileIDs": file_ids}, + ) + def cancel(self, project_id: str, branch_id: str, file_ids: list[str]): + return self.client.put( + f"projects/{project_id}/branches/{branch_id}/fileActions/cancelCheckout", + json={"fileIDs": file_ids}, + ) -class SharedLinksAPI(_BaseAPI): - def list(self, project_id: str): - return self.client.get(f"projects/{project_id}/sharedLinks") + def initiate_checkin(self, project_id: str, branch_id: str, files: list[dict]): + return self.client.put( + f"projects/{project_id}/branches/{branch_id}/fileActions/initiateCheckin", + json={"files": files}, + ) - def get(self, project_id: str, link_id: str): - return self.client.get(f"projects/{project_id}/sharedLinks/{link_id}") + def complete_checkin( + self, + project_id: str, + branch_id: str, + files: list[dict], + *, + message: str | None = None, + ): + return self.client.post( + f"projects/{project_id}/branches/{branch_id}/fileActions/completeCheckin", + json=_omit_none({"files": files, "message": message}), + ) - def create(self, project_id: str, payload: dict): - return self.client.post(f"projects/{project_id}/sharedLinks", json=payload) - def update(self, project_id: str, link_id: str, payload: dict): - return self.client.put(f"projects/{project_id}/sharedLinks/{link_id}", json=payload) +class SharedLinksAPI(_BaseAPI): + def list(self, project_id: str | None = None, branch_id: str | None = None): + if project_id and branch_id: + return self.client.get(f"projects/{project_id}/branches/{branch_id}/sharedLinks") + if project_id: + return self.client.get(f"projects/{project_id}/sharedLinks") + return self.client.get("sharedLinks") + + def create_live( + self, + project_id: str, + branch_id: str, + name: str, + file_ids: list[str], + *, + types: list[str] | None = None, + config_map: dict | None = None, + ): + return self.client.post( + f"projects/{project_id}/branches/{branch_id}/files/sharedLink", + json=_omit_none( + { + "name": name, + "fileIDs": file_ids, + "types": types, + "configMap": config_map, + } + ), + ) + def create_static( + self, + project_id: str, + branch_id: str, + file_id: str, + version_id: str, + payload: dict | None = None, + ): + return self.client.post( + f"projects/{project_id}/branches/{branch_id}/files/{file_id}/fileVersion/{version_id}/sharedLink", + json=payload or {}, + ) -class FilesMoveDeleteAPI(_BaseAPI): - def move(self, project_id: str, branch_id: str, payload: dict): - return self.client.put(f"projects/{project_id}/branches/{branch_id}/files/move", json=payload) + def refresh(self, project_id: str, branch_id: str, link_id: str): + return self.client.put( + f"projects/{project_id}/branches/{branch_id}/sharedLinks/{link_id}/refresh" + ) - def delete_many(self, project_id: str, branch_id: str, payload: dict): - return self.client.put(f"projects/{project_id}/branches/{branch_id}/files/delete", json=payload) + def delete(self, project_id: str, branch_id: str, link_ids: list[str]): + return self.client.put( + f"projects/{project_id}/branches/{branch_id}/sharedLinks/delete", + json={"sharedLinkIDs": link_ids}, + ) -class FilesMetadataAPI(_BaseAPI): - def fields(self): +class MetadataAPI(_BaseAPI): + def list_fields(self): return self.client.get("metadataFields") - def file_metadata(self, project_id: str, branch_id: str, file_id: str): + def get(self, project_id: str, branch_id: str, file_id: str): return self.client.get(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/metadata") - def update_file_metadata(self, project_id: str, branch_id: str, file_id: str, payload: dict): - return self.client.put(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/metadata", json=payload) + def get_for_version(self, project_id: str, branch_id: str, file_id: str, version_id: str): + return self.client.get( + f"projects/{project_id}/branches/{branch_id}/files/{file_id}/versions/{version_id}/metadata" + ) + def update(self, project_id: str, branch_id: str, payload: dict): + return self.client.put( + f"projects/{project_id}/branches/{branch_id}/files/updateMetadata", + json=payload, + ) -class FeedbackItemsAPI(_BaseAPI): - def list(self, project_id: str): + +class FeedbackAPI(_BaseAPI): + def list(self, project_id: str, *, branch_id: str | None = None, file_id: str | None = None): + if file_id: + branch_id = self.client.resolve_branch_id(project_id, branch_id) + return self.client.get( + f"projects/{project_id}/branches/{branch_id}/files/{file_id}/feedbackItems" + ) return self.client.get(f"projects/{project_id}/feedbackItems") def get(self, project_id: str, item_id: str): return self.client.get(f"projects/{project_id}/feedbackItems/{item_id}") - def create(self, project_id: str, payload: dict): - return self.client.post(f"projects/{project_id}/feedbackItems", json=payload) - def update(self, project_id: str, item_id: str, payload: dict): return self.client.put(f"projects/{project_id}/feedbackItems/{item_id}", json=payload) - def delete(self, project_id: str, item_id: str): - return self.client.delete(f"projects/{project_id}/feedbackItems/{item_id}") + def initiate_attachment(self, project_id: str, item_id: str, payload: dict): + return self.client.put( + f"projects/{project_id}/feedbackItems/{item_id}/attachment", + json=payload, + ) + + def complete_attachment( + self, + project_id: str, + item_id: str, + attachment_id: str, + payload: dict | None = None, + ): + return self.client.post( + f"projects/{project_id}/feedbackItems/{item_id}/attachment/{attachment_id}", + json=payload or {}, + ) + + def delete_attachment(self, project_id: str, item_id: str, attachment_id: str): + return self.client.delete( + f"projects/{project_id}/feedbackItems/{item_id}/attachment/{attachment_id}" + ) class PackagesAPI(_BaseAPI): - def list(self, project_id: str): - return self.client.get(f"projects/{project_id}/packages") + def list(self, project_id: str | None = None): + if project_id: + return self.client.get(f"projects/{project_id}/packages") + return self.client.get("packages") def get(self, project_id: str, package_id: str): return self.client.get(f"projects/{project_id}/packages/{package_id}") class RevisionsAPI(_BaseAPI): - def list(self, project_id: str, branch_id: str, file_id: str): - return self.client.get(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/revisions") + def list( + self, + project_id: str | None = None, + branch_id: str | None = None, + file_id: str | None = None, + ): + if file_id: + if not project_id or not branch_id: + raise ValueError("project_id and branch_id are required when listing file revisions") + return self.client.get( + f"projects/{project_id}/branches/{branch_id}/files/{file_id}/revisions" + ) + if branch_id: + if not project_id: + raise ValueError("project_id is required when listing branch revisions") + return self.client.get(f"projects/{project_id}/branches/{branch_id}/revisions") + if project_id: + return self.client.get(f"projects/{project_id}/revisions") + return self.client.get("revisions") def get(self, project_id: str, branch_id: str, file_id: str, revision_id: str): - return self.client.get(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/revisions/{revision_id}") + return self.client.get( + f"projects/{project_id}/branches/{branch_id}/files/{file_id}/revisions/{revision_id}" + ) + + def get_closure(self, project_id: str, branch_id: str, file_id: str): + return self.client.get( + f"projects/{project_id}/branches/{branch_id}/files/{file_id}/closure" + ) - def restore(self, project_id: str, branch_id: str, file_id: str, revision_id: str, payload: dict | None = None): + def release(self, project_id: str, branch_id: str, revisions: list[dict]): return self.client.put( - f"projects/{project_id}/branches/{branch_id}/files/{file_id}/revisions/{revision_id}/restore", - json=payload or {}, + f"projects/{project_id}/branches/{branch_id}/revisions/release", + json=revisions, + ) + + def cancel(self, project_id: str, branch_id: str, revision_ids: list[str]): + return self.client.put( + f"projects/{project_id}/branches/{branch_id}/revisions/cancel", + json={"revisionIDs": revision_ids}, ) class ApprovalsAPI(_BaseAPI): - def list(self, project_id: str): - return self.client.get(f"projects/{project_id}/approvals") + def list(self, project_id: str | None = None): + if project_id: + return self.client.get(f"projects/{project_id}/approvals") + return self.client.get("approvals") def get(self, project_id: str, approval_id: str): return self.client.get(f"projects/{project_id}/approvals/{approval_id}") - def update(self, project_id: str, approval_id: str, payload: dict): - return self.client.put(f"projects/{project_id}/approvals/{approval_id}", json=payload) + def close(self, project_id: str, approval_id: str, status: str): + return self.client.put( + f"projects/{project_id}/approvals/{approval_id}/close", + params={"status": status}, + ) class BOMsAPI(_BaseAPI): - def list(self, project_id: str): - return self.client.get(f"projects/{project_id}/boms") + def list(self, project_id: str, branch_id: str): + return self.client.get(f"projects/{project_id}/branches/{branch_id}/boms") - def get(self, project_id: str, bom_id: str): - return self.client.get(f"projects/{project_id}/boms/{bom_id}") + def get(self, project_id: str, branch_id: str, bom_id: str): + return self.client.get(f"projects/{project_id}/branches/{branch_id}/boms/{bom_id}") - def create(self, project_id: str, payload: dict): - return self.client.post(f"projects/{project_id}/boms", json=payload) + def download(self, project_id: str, branch_id: str, bom_id: str, payload: dict): + return self.client.post( + f"projects/{project_id}/branches/{branch_id}/boms/{bom_id}/download", + json=payload, + ) class SearchAPI(_BaseAPI): - def query(self, payload: dict): - return self.client.put("search", json=payload) + def files( + self, + search_key: str, + *, + page_size: int | None = None, + from_offset: int | None = None, + ): + return self.client.put( + "search", + json={"search_key": search_key}, + params=_omit_none({"pageSize": page_size, "from": from_offset}) or None, + ) + + +class WebhooksAPI(_BaseAPI): + def list(self): + return self.client.get("webhooks/subscriptions") + + def create(self, payload: dict): + return self.client.post("webhooks/subscriptions", json=payload) + + def get(self, subscription_id: str): + return self.client.get(f"webhooks/subscriptions/{subscription_id}") + + def update(self, subscription_id: str, payload: dict): + return self.client.put(f"webhooks/subscriptions/{subscription_id}", json=payload) + + def delete(self, subscription_id: str): + return self.client.delete(f"webhooks/subscriptions/{subscription_id}") + + def rotate_secret(self, subscription_id: str): + return self.client.post(f"webhooks/subscriptions/{subscription_id}/rotate") + + +def _omit_none(data: dict) -> dict: + return {key: value for key, value in data.items() if value is not None} def _pick_from_response(payload: Any, *keys: str): diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..bc229bb --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import os +import sys +import types +import unittest +from dataclasses import dataclass +from urllib.parse import urlparse + +if "requests" not in sys.modules: + fake_requests = types.ModuleType("requests") + fake_requests.Session = object + fake_requests.Response = object + sys.modules["requests"] = fake_requests + +from bild import BildAuthError, BildClient +from bild.client import DEFAULT_BASE_URL + + +@dataclass +class FakeResponse: + status_code: int + payload: dict + + @property + def ok(self): + return 200 <= self.status_code < 300 + + def json(self): + return self.payload + + @property + def text(self): + return str(self.payload) + + +class RecordingSession: + def __init__(self, status_code: int = 200, payload: dict | None = None): + self.headers = {} + self.calls = [] + self.status_code = status_code + self.payload = payload or {"ok": True} + + def request(self, method, url, params=None, json=None, timeout=None, **kwargs): + self.calls.append( + { + "method": method.upper(), + "url": url, + "path": urlparse(url).path, + "params": params, + "json": json, + "json_passed": "json" in kwargs or json is not None, + "headers": dict(self.headers), + "timeout": timeout, + } + ) + return FakeResponse(self.status_code, self.payload) + + +class TestBildAuth(unittest.TestCase): + def test_missing_token_raises(self): + env = os.environ.pop("BILD_API_KEY", None) + try: + with self.assertRaises(ValueError): + BildClient(session=RecordingSession()) + finally: + if env is not None: + os.environ["BILD_API_KEY"] = env + + def test_bearer_header_and_default_host(self): + session = RecordingSession() + client = BildClient(token="jwt-token", session=session) + self.assertEqual(DEFAULT_BASE_URL, "https://api.getbild.com") + self.assertEqual(session.headers["Authorization"], "Bearer jwt-token") + self.assertEqual(session.headers["Accept"], "application/json") + self.assertNotIn("Content-Type", session.headers) + self.assertTrue(client.base_url.startswith("https://api.getbild.com")) + + def test_get_does_not_send_json_body(self): + session = RecordingSession() + client = BildClient(token="jwt-token", session=session) + client.api.users.list() + call = session.calls[-1] + self.assertEqual(call["method"], "GET") + self.assertEqual(call["path"], "/users") + self.assertIsNone(call["json"]) + self.assertFalse(call["json_passed"]) + self.assertEqual(call["headers"]["Authorization"], "Bearer jwt-token") + self.assertNotIn("Content-Type", call["headers"]) + + def test_write_sends_json_payload(self): + session = RecordingSession() + client = BildClient(token="jwt-token", session=session) + client.api.users.invite(["a@example.com"], projects=[]) + call = session.calls[-1] + self.assertEqual(call["method"], "PUT") + self.assertEqual(call["json"]["emails"], ["a@example.com"]) + self.assertTrue(call["json_passed"]) + + def test_401_raises_auth_error(self): + session = RecordingSession(status_code=401, payload={"message": "InvalidAuth"}) + client = BildClient(token="bad-token", session=session) + with self.assertRaises(BildAuthError) as ctx: + client.api.projects.list() + self.assertEqual(ctx.exception.status_code, 401) + self.assertEqual(ctx.exception.payload, {"message": "InvalidAuth"}) + + def test_403_raises_auth_error(self): + session = RecordingSession(status_code=403, payload={"message": "Forbidden"}) + client = BildClient(token="jwt-token", session=session) + with self.assertRaises(BildAuthError): + client.api.projects.list() + + +@unittest.skipUnless(os.getenv("BILD_API_KEY"), "BILD_API_KEY not set") +class TestLiveAuth(unittest.TestCase): + def test_list_users_with_real_token(self): + client = BildClient() + result = client.api.users.list() + self.assertIsNotNone(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_client_routes.py b/tests/test_client_routes.py index 4e44b07..39834b1 100644 --- a/tests/test_client_routes.py +++ b/tests/test_client_routes.py @@ -43,7 +43,7 @@ def request(self, method, url, params=None, json=None, timeout=None): if path.endswith("/branches"): return FakeResponse(200, {"data": [{"id": "branch-main", "isMain": True}]}) - if path.endswith("/latestFileVersion"): + if path.endswith("/latest"): return FakeResponse(200, {"data": {"fileVersion": "v-latest"}}) return FakeResponse(200, {"ok": True, "path": path}) @@ -59,71 +59,186 @@ def last(self): def test_full_route_coverage(self): c = self.client - c.api.users.list(); self.assertTrue(self.last()["path"].endswith("/api/users")) - c.api.users.add(["a@example.com"]); self.assertTrue(self.last()["path"].endswith("/api/users/add")) - - c.api.projects.list(); self.assertTrue(self.last()["path"].endswith("/api/projects")) - - c.api.project_users.list("p1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/users")) - c.api.project_users.add("p1", {"userId": "u1"}); self.assertEqual(self.last()["method"], "POST") - c.api.project_users.update("p1", "u1", {"role": "Editor"}); self.assertEqual(self.last()["method"], "PUT") - - c.api.branches_commits.list_branches("p1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/branches")) - c.api.branches_commits.branch("p1", "b1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/branches/b1")) - c.api.branches_commits.commits("p1", "b1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/branches/b1/commits")) - c.api.branches_commits.commit("p1", "b1", "c1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/branches/b1/commits/c1")) - - c.api.files.list("p1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/files")) - c.api.files.list("p1", "b1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/branches/b1/files")) - c.api.files.get("p1", None, "f1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/branches/branch-main/files/f1")) - c.api.files.latest_version("p1", None, "f1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/branches/branch-main/files/f1/latestFileVersion")) - c.api.files.universal_format("p1", None, "f1", file_version=None, output_format="stl") - self.assertTrue(self.last()["path"].endswith("/api/projects/p1/branches/branch-main/files/f1/universalFormat")) - self.assertEqual(self.last()["json"]["fileVersion"], "v-latest") - - c.api.file_upload.init_upload("p1", "b1", {"name": "x"}); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/branches/b1/fileUpload")) - c.api.file_upload.complete_upload("p1", "b1", {"id": "x"}); self.assertEqual(self.last()["method"], "POST") - - c.api.file_checkin_checkout.checkout("p1", "b1", "f1"); self.assertTrue(self.last()["path"].endswith("/checkout")) - c.api.file_checkin_checkout.checkin("p1", "b1", "f1"); self.assertTrue(self.last()["path"].endswith("/checkin")) - c.api.file_checkin_checkout.discard_checkout("p1", "b1", "f1"); self.assertTrue(self.last()["path"].endswith("/discardCheckout")) - c.api.file_checkin_checkout.create_version("p1", "b1", "f1", {"message": "v2"}); self.assertTrue(self.last()["path"].endswith("/versions")) - - c.api.shared_links.list("p1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/sharedLinks")) - c.api.shared_links.get("p1", "s1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/sharedLinks/s1")) - c.api.shared_links.create("p1", {"x": 1}); self.assertEqual(self.last()["method"], "POST") - c.api.shared_links.update("p1", "s1", {"x": 2}); self.assertEqual(self.last()["method"], "PUT") - - c.api.files_move_delete.move("p1", "b1", {"ids": ["f1"]}); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/branches/b1/files/move")) - c.api.files_move_delete.delete_many("p1", "b1", {"ids": ["f1"]}); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/branches/b1/files/delete")) - - c.api.files_metadata.fields(); self.assertTrue(self.last()["path"].endswith("/api/metadataFields")) - c.api.files_metadata.file_metadata("p1", "b1", "f1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/branches/b1/files/f1/metadata")) - c.api.files_metadata.update_file_metadata("p1", "b1", "f1", {"a": 1}); self.assertEqual(self.last()["method"], "PUT") - - c.api.feedback_items.list("p1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/feedbackItems")) - c.api.feedback_items.get("p1", "i1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/feedbackItems/i1")) - c.api.feedback_items.create("p1", {"x": 1}); self.assertEqual(self.last()["method"], "POST") - c.api.feedback_items.update("p1", "i1", {"x": 2}); self.assertEqual(self.last()["method"], "PUT") - c.api.feedback_items.delete("p1", "i1"); self.assertEqual(self.last()["method"], "DELETE") - - c.api.packages.list("p1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/packages")) - c.api.packages.get("p1", "pkg1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/packages/pkg1")) - - c.api.revisions.list("p1", "b1", "f1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/branches/b1/files/f1/revisions")) - c.api.revisions.get("p1", "b1", "f1", "r1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/branches/b1/files/f1/revisions/r1")) - c.api.revisions.restore("p1", "b1", "f1", "r1"); self.assertTrue(self.last()["path"].endswith("/restore")) - - c.api.approvals.list("p1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/approvals")) - c.api.approvals.get("p1", "a1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/approvals/a1")) - c.api.approvals.update("p1", "a1", {"status": "approved"}); self.assertEqual(self.last()["method"], "PUT") - - c.api.boms.list("p1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/boms")) - c.api.boms.get("p1", "bom1"); self.assertTrue(self.last()["path"].endswith("/api/projects/p1/boms/bom1")) - c.api.boms.create("p1", {"x": 1}); self.assertEqual(self.last()["method"], "POST") - - c.api.search.query({"query": "bolt"}); self.assertEqual(self.last()["method"], "PUT") - self.assertTrue(self.last()["path"].endswith("/api/search")) + c.api.users.list() + self.assertTrue(self.last()["path"].endswith("/users")) + c.api.users.invite(["a@example.com"], projects=[{"id": "p1"}]) + self.assertTrue(self.last()["path"].endswith("/users/add")) + self.assertEqual(self.last()["method"], "PUT") + c.api.users.remove(["u1"]) + self.assertTrue(self.last()["path"].endswith("/users/remove")) + c.api.users.update(["u1"], projects=[]) + self.assertTrue(self.last()["path"].endswith("/users/update")) + c.api.users.create_token(name="ci") + self.assertTrue(self.last()["path"].endswith("/users/apiToken")) + self.assertEqual(self.last()["method"], "POST") + + c.api.projects.list() + self.assertTrue(self.last()["path"].endswith("/projects")) + + c.api.project_users.list("p1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/users")) + c.api.project_users.add([{"id": "u1", "accessType": "Editor"}], project_ids=["p1"]) + self.assertTrue(self.last()["path"].endswith("/projects/users/add")) + self.assertEqual(self.last()["method"], "POST") + c.api.project_users.update([{"id": "u1", "accessType": "Viewer"}], project_ids=["p1"]) + self.assertTrue(self.last()["path"].endswith("/projects/users/update")) + c.api.project_users.remove(["p1"], ["u1"]) + self.assertTrue(self.last()["path"].endswith("/projects/users/remove")) + + c.api.branches.list("p1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/branches")) + + c.api.commits.list("p1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/commits")) + c.api.commits.list("p1", "b1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/branches/b1/commits")) + c.api.commits.get("p1", "b1", "c1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/branches/b1/commits/c1")) + + c.api.files.list("p1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/files")) + c.api.files.list("p1", "b1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/branches/b1/files")) + c.api.files.list_released("2024-01-01T00:00:00Z") + self.assertTrue(self.last()["path"].endswith("/files/released")) + self.assertEqual(self.last()["params"]["fromTime"], "2024-01-01T00:00:00Z") + c.api.files.list_versions("p1", None, "f1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/branches/branch-main/files/f1/versions")) + c.api.files.get_latest("p1", None, "f1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/branches/branch-main/files/f1/latest")) + c.api.files.get_released("p1", "b1", "f1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/branches/b1/files/f1/released")) + c.api.files.get_version("p1", "b1", "f1", "v1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/branches/b1/files/f1/versions/v1")) + c.api.files.get_thumbnail("p1", "b1", "f1", "v1") + self.assertTrue(self.last()["path"].endswith("/thumbnail")) + c.api.files.get_children("p1", "b1", "f1", "v1") + self.assertTrue(self.last()["path"].endswith("/children")) + c.api.files.export_universal("p1", None, "f1", output_format="stl") + self.assertTrue(self.last()["path"].endswith("/fileActions/f1/universalFormat")) + self.assertEqual(self.last()["method"], "PUT") + self.assertEqual(self.last()["json"]["fileVersionID"], "v-latest") + c.api.files.export_universal_many("p1", "b1", {"fileIDs": ["f1"], "formats": {"CAD": ["STL"]}}) + self.assertTrue(self.last()["path"].endswith("/files/exportUniversalFiles")) + c.api.files.move("p1", "b1", ["f1"], "parent-1") + self.assertTrue(self.last()["path"].endswith("/fileActions/move")) + c.api.files.delete("p1", "b1", ["f1"]) + self.assertTrue(self.last()["path"].endswith("/fileActions/delete")) + + c.api.uploads.initiate("p1", "b1", [{"name": "x"}]) + self.assertTrue(self.last()["path"].endswith("/fileActions/initiateUpload")) + c.api.uploads.complete("p1", "b1", [{"id": "x"}]) + self.assertTrue(self.last()["path"].endswith("/fileActions/completeUpload")) + self.assertEqual(self.last()["method"], "POST") + + c.api.checkouts.checkout("p1", "b1", ["f1"]) + self.assertTrue(self.last()["path"].endswith("/fileActions/checkout")) + c.api.checkouts.cancel("p1", "b1", ["f1"]) + self.assertTrue(self.last()["path"].endswith("/fileActions/cancelCheckout")) + c.api.checkouts.initiate_checkin("p1", "b1", [{"id": "f1"}]) + self.assertTrue(self.last()["path"].endswith("/fileActions/initiateCheckin")) + c.api.checkouts.complete_checkin("p1", "b1", [{"id": "f1"}], message="v2") + self.assertTrue(self.last()["path"].endswith("/fileActions/completeCheckin")) + + c.api.shared_links.list() + self.assertTrue(self.last()["path"].endswith("/sharedLinks")) + c.api.shared_links.list("p1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/sharedLinks")) + c.api.shared_links.list("p1", "b1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/branches/b1/sharedLinks")) + c.api.shared_links.create_live("p1", "b1", "Review Link", ["f1"]) + self.assertTrue(self.last()["path"].endswith("/files/sharedLink")) + self.assertEqual(self.last()["method"], "POST") + c.api.shared_links.create_static("p1", "b1", "f1", "v1") + self.assertTrue(self.last()["path"].endswith("/fileVersion/v1/sharedLink")) + c.api.shared_links.refresh("p1", "b1", "s1") + self.assertTrue(self.last()["path"].endswith("/sharedLinks/s1/refresh")) + c.api.shared_links.delete("p1", "b1", ["s1"]) + self.assertTrue(self.last()["path"].endswith("/sharedLinks/delete")) + + c.api.metadata.list_fields() + self.assertTrue(self.last()["path"].endswith("/metadataFields")) + c.api.metadata.get("p1", "b1", "f1") + self.assertTrue(self.last()["path"].endswith("/files/f1/metadata")) + c.api.metadata.get_for_version("p1", "b1", "f1", "v1") + self.assertTrue(self.last()["path"].endswith("/versions/v1/metadata")) + c.api.metadata.update("p1", "b1", {"fileIDs": ["f1"]}) + self.assertTrue(self.last()["path"].endswith("/files/updateMetadata")) + + c.api.feedback.list("p1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/feedbackItems")) + c.api.feedback.list("p1", file_id="f1") + self.assertTrue(self.last()["path"].endswith("/files/f1/feedbackItems")) + c.api.feedback.get("p1", "i1") + self.assertTrue(self.last()["path"].endswith("/feedbackItems/i1")) + c.api.feedback.update("p1", "i1", {"status": "inProgress"}) + self.assertEqual(self.last()["method"], "PUT") + c.api.feedback.initiate_attachment("p1", "i1", {"fileName": "a.txt"}) + self.assertTrue(self.last()["path"].endswith("/feedbackItems/i1/attachment")) + c.api.feedback.complete_attachment("p1", "i1", "att1", {"name": "a.txt"}) + self.assertTrue(self.last()["path"].endswith("/attachment/att1")) + self.assertEqual(self.last()["method"], "POST") + c.api.feedback.delete_attachment("p1", "i1", "att1") + self.assertEqual(self.last()["method"], "DELETE") + + c.api.packages.list() + self.assertTrue(self.last()["path"].endswith("/packages")) + c.api.packages.list("p1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/packages")) + c.api.packages.get("p1", "pkg1") + self.assertTrue(self.last()["path"].endswith("/packages/pkg1")) + + c.api.revisions.list() + self.assertTrue(self.last()["path"].endswith("/revisions")) + c.api.revisions.list("p1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/revisions")) + c.api.revisions.list("p1", "b1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/branches/b1/revisions")) + c.api.revisions.list("p1", "b1", "f1") + self.assertTrue(self.last()["path"].endswith("/files/f1/revisions")) + c.api.revisions.get("p1", "b1", "f1", "r1") + self.assertTrue(self.last()["path"].endswith("/revisions/r1")) + c.api.revisions.get_closure("p1", "b1", "f1") + self.assertTrue(self.last()["path"].endswith("/files/f1/closure")) + c.api.revisions.release("p1", "b1", [{"revisionID": "r1", "revisionNumber": "A"}]) + self.assertTrue(self.last()["path"].endswith("/revisions/release")) + c.api.revisions.cancel("p1", "b1", ["r1"]) + self.assertTrue(self.last()["path"].endswith("/revisions/cancel")) + + c.api.approvals.list() + self.assertTrue(self.last()["path"].endswith("/approvals")) + c.api.approvals.list("p1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/approvals")) + c.api.approvals.get("p1", "a1") + self.assertTrue(self.last()["path"].endswith("/approvals/a1")) + c.api.approvals.close("p1", "a1", "approved") + self.assertTrue(self.last()["path"].endswith("/approvals/a1/close")) + self.assertEqual(self.last()["params"]["status"], "approved") + + c.api.boms.list("p1", "b1") + self.assertTrue(self.last()["path"].endswith("/projects/p1/branches/b1/boms")) + c.api.boms.get("p1", "b1", "bom1") + self.assertTrue(self.last()["path"].endswith("/boms/bom1")) + c.api.boms.download("p1", "b1", "bom1", {"version_id": "v", "view_id": "w", "type": "Indented", "formats": {}}) + self.assertTrue(self.last()["path"].endswith("/boms/bom1/download")) + + c.api.search.files("bolt") + self.assertEqual(self.last()["method"], "PUT") + self.assertTrue(self.last()["path"].endswith("/search")) + self.assertEqual(self.last()["json"]["search_key"], "bolt") + + c.api.webhooks.list() + self.assertTrue(self.last()["path"].endswith("/webhooks/subscriptions")) + c.api.webhooks.create({"eventType": "file.updated", "targetURL": "https://example.com"}) + self.assertEqual(self.last()["method"], "POST") + c.api.webhooks.get("sub1") + self.assertTrue(self.last()["path"].endswith("/webhooks/subscriptions/sub1")) + c.api.webhooks.update("sub1", {"isActive": False}) + self.assertEqual(self.last()["method"], "PUT") + c.api.webhooks.rotate_secret("sub1") + self.assertTrue(self.last()["path"].endswith("/rotate")) + c.api.webhooks.delete("sub1") + self.assertEqual(self.last()["method"], "DELETE") if __name__ == "__main__":