Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Official Python SDK for [textbee.dev](https://textbee.dev), the open source SMS

Zero dependencies, fully typed, works on Python 3.9+.

Writing JavaScript or TypeScript? Use the official [JavaScript SDK](https://github.com/textbee/textbee-js): `npm install @textbee/sdk`.

## Install

```bash
Expand Down Expand Up @@ -262,7 +264,7 @@ Textbee(api_key=api_key, base_url="https://sms.example.com/api/v1")
Textbee(api_key=api_key, base_url="http://localhost:3001")
```

If the API sits under a path prefix, give the full path, for example `https://example.com/textbee/api/v1`. Any path other than `/api/v1` is used as given, so a proxy that maps `/sms` onto the API works with `https://example.com/sms`. A value with no scheme gets `https://`. An invalid URL, or one with a username or password, raises `ValueError` instead of falling back to the hosted API, so a typo never sends your key to textbee.dev.
If the API sits under a path prefix, give the prefix: `https://example.com/textbee` reaches `https://example.com/textbee/api/v1`. The SDK adds `/api/v1` unless the path already contains it, the same rule the textbee MCP server uses for `TEXTBEE_BASE_URL`. A value with no scheme gets `https://`. An invalid URL, or one with a username or password, raises `ValueError` instead of falling back to the hosted API, so a typo never sends your key to textbee.dev.

## What is not covered yet

Expand Down
28 changes: 12 additions & 16 deletions src/textbee/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,13 @@ def __init__(
``x-api-key`` header.

``base_url`` points the client at a self-hosted instance instead of
``https://api.textbee.dev/api/v1``. A bare origin such as
``https://sms.example.com`` gets ``/api/v1`` added. A URL that contains
``/api/v1`` is cut there, so a pasted endpoint URL still works. Any
other path is used as given, for proxies that map a prefix onto the
API. A value with no scheme gets ``https://``. An invalid URL raises
``ValueError`` rather than falling back, so a typo never sends your key
to the public API.
``https://api.textbee.dev/api/v1``. ``/api/v1`` is added unless the
path already contains it, so ``https://sms.example.com`` and
``https://example.com/textbee`` both work. A URL that contains
``/api/v1`` is cut there, so a pasted endpoint URL still works. A value
with no scheme gets ``https://``. An invalid URL raises ``ValueError``
rather than falling back, so a typo never sends your key to the public
API.

``timeout`` is in seconds and applies to each connect and read, not to
the request as a whole.
Expand Down Expand Up @@ -340,8 +340,8 @@ def _data(payload: dict[str, Any]) -> Any:
def _normalize_base_url(raw: str | None) -> str:
"""Resolve the API root from a base URL. Same rule as textbee-js.

A bare origin gets /api/v1, a path containing /api/v1 is cut there, and
any other path is kept as given for proxies that map a prefix onto the API.
/api/v1 is added unless the path already contains it, and a path that
contains /api/v1 is cut there.
"""
value = (raw or "").strip()
if not value:
Expand Down Expand Up @@ -383,13 +383,9 @@ def _normalize_base_url(raw: str | None) -> str:
cut = index + 2
break

if not segments:
path = ["api", "v1"]
elif cut == -1:
path = segments
else:
# Anything after /api/v1 is a route, not part of the root, so it is dropped.
path = [*segments[: cut - 2], "api", "v1"]
# Anything after /api/v1 is a route, not part of the root, so it is dropped.
root = segments if cut == -1 else segments[: cut - 2]
path = [*root, "api", "v1"]

return "/".join([origin, *(quote(segment, safe=_PATH_SAFE) for segment in path)])

Expand Down
2 changes: 1 addition & 1 deletion src/textbee/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.1.0"
__version__ = "0.1.1"
8 changes: 4 additions & 4 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,9 +461,9 @@ def test_strips_trailing_slashes_from_a_custom_base_url(self, http: FakeUrlopen)
("https://sms.example.com/api/v1", "https://sms.example.com/api/v1"),
("https://sms.example.com/api/v1/", "https://sms.example.com/api/v1"),
("https://sms.example.com/textbee/api/v1", "https://sms.example.com/textbee/api/v1"),
# A prefix without /api/v1 is kept as given, for proxies that rewrite it.
("https://sms.example.com/textbee", "https://sms.example.com/textbee"),
("https://example.com/sms/", "https://example.com/sms"),
# A subpath deployment gets /api/v1 added after its prefix.
("https://sms.example.com/textbee", "https://sms.example.com/textbee/api/v1"),
("https://example.com/sms/", "https://example.com/sms/api/v1"),
("https://api.textbee.dev/api/v1/gateway/send-sms", "https://api.textbee.dev/api/v1"),
("api.textbee.dev", "https://api.textbee.dev/api/v1"),
("localhost:3001", "https://localhost:3001/api/v1"),
Expand All @@ -472,7 +472,7 @@ def test_strips_trailing_slashes_from_a_custom_base_url(self, http: FakeUrlopen)
("http://[::1]:3001", "http://[::1]:3001/api/v1"),
("http://sms.example.com", "http://sms.example.com/api/v1"),
("https://sms.example.com/API/V1", "https://sms.example.com/api/v1"),
("https://sms.example.com/v1", "https://sms.example.com/v1"),
("https://sms.example.com/v1", "https://sms.example.com/v1/api/v1"),
("https://example.com/my app/api/v1", "https://example.com/my%20app/api/v1"),
("https://b" + chr(0xFC) + "cher.example", "https://xn--bcher-kva.example/api/v1"),
("HTTPS://SMS.Example.com:443/?q=1#top", "https://sms.example.com/api/v1"),
Expand Down
Loading