Skip to content

Commit 99f5356

Browse files
g-despotclaude
andcommitted
feat(grpc-web): support grpc-web on the REST host:port via a base-path prefix
Add a configurable gRPC base-path prefix so the client can talk to a grpc-web endpoint multiplexed onto the REST host:port (the production wire contract: grpc-web served under "/grpc-web/" via an in-process transcoder). - ConnectionParams gains `grpc_path_prefix`, threaded through `from_params` / `from_url` and the `connect_to_custom` / `use_async_with_custom` helpers. Normalized to a single leading slash, no trailing slash; None/"" == native gRPC. - `_check_port_collision` no longer rejects the same host:port when a grpc-web prefix is set; native gRPC (no prefix) still raises, unchanged. - `_grpc_channel` forwards the prefix to the transport as a ("grpc-web.path_prefix", prefix) channel option only in grpc-web mode, so the native channel options stay byte-for-byte unchanged. - weaviate-python-grpc-web: the shim's channel factories read that option and GrpcWebChannel prepends the prefix, so requests go to <scheme>://<host>:<port><prefix>/weaviate.v1.Weaviate/<Method>. Tested: new ConnectionParams unit tests (collision relaxed only with a prefix; native same-port still raises; option forwarded/omitted) and grpc-web transport tests for the prefixed/normalized URL. End-to-end verified against a vanguard transcoder on a shared host:port (insert_many/fetch_objects/aggregate over grpc-web with grpc_host == http_host == localhost:8090). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8aa867a commit 99f5356

6 files changed

Lines changed: 243 additions & 5 deletions

File tree

packages/grpc-web/src/weaviate_grpc_web/_channel.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,16 @@ def __init__(
125125
target: Optional[str],
126126
secure: bool,
127127
options: Any = None,
128+
path_prefix: str = "",
128129
sender: Optional[Sender] = None,
129130
) -> None:
130131
if not target:
131132
raise ValueError("GrpcWebChannel requires a target (host:port)")
132133
scheme = "https" if secure else "http"
133134
self._base_url = f"{scheme}://{target}"
135+
# Normalize to a single leading slash and no trailing slash; "" == native path.
136+
cleaned = (path_prefix or "").strip("/")
137+
self._path_prefix = f"/{cleaned}" if cleaned else ""
134138
self._sender: Sender = sender or get_sender()
135139

136140
def unary_unary(
@@ -173,7 +177,7 @@ async def _unary(
173177
if timeout is not None:
174178
headers["grpc-timeout"] = _encode_timeout(timeout)
175179

176-
url = self._base_url + path
180+
url = self._base_url + self._path_prefix + path
177181
status, resp_headers, body = await self._sender(
178182
url, headers, encode_message(payload), timeout
179183
)

packages/grpc-web/src/weaviate_grpc_web/_shim.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,20 +170,38 @@ def _sync_channel_unsupported(*_args: Any, **_kwargs: Any) -> "AioChannel":
170170
raise RuntimeError(_ASYNC_ONLY_MESSAGE)
171171

172172

173+
def _path_prefix_from_options(options: Any) -> str:
174+
"""Extract the ``("grpc-web.path_prefix", prefix)`` channel option, or "" if absent."""
175+
for item in options or ():
176+
if isinstance(item, (tuple, list)) and len(item) == 2 and item[0] == "grpc-web.path_prefix":
177+
return item[1] or ""
178+
return ""
179+
180+
173181
def _aio_secure_channel(
174182
target: Optional[str] = None, credentials: Any = None, options: Any = None, **_kw: Any
175183
) -> AioChannel:
176184
from ._channel import GrpcWebChannel
177185

178-
return GrpcWebChannel(target=target, secure=True, options=options)
186+
return GrpcWebChannel(
187+
target=target,
188+
secure=True,
189+
options=options,
190+
path_prefix=_path_prefix_from_options(options),
191+
)
179192

180193

181194
def _aio_insecure_channel(
182195
target: Optional[str] = None, options: Any = None, **_kw: Any
183196
) -> AioChannel:
184197
from ._channel import GrpcWebChannel
185198

186-
return GrpcWebChannel(target=target, secure=False, options=options)
199+
return GrpcWebChannel(
200+
target=target,
201+
secure=False,
202+
options=options,
203+
path_prefix=_path_prefix_from_options(options),
204+
)
187205

188206

189207
def _noop(*_args: Any, **_kwargs: Any) -> None:

packages/grpc-web/tests/test_transport.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,48 @@ def test_close_is_awaitable_noop():
144144
assert asyncio.run(channel.close()) is None
145145

146146

147+
def test_path_prefix_prepended_to_url():
148+
sender = FakeSender(body=_ok_response(b"r"))
149+
channel = GrpcWebChannel(
150+
"example.com:8090", secure=False, sender=sender, path_prefix="/grpc-web"
151+
)
152+
mc = channel.unary_unary("/weaviate.v1.Weaviate/Search", lambda x: x, lambda b: b)
153+
asyncio.run(mc(b"q"))
154+
assert sender.calls[0][0] == "http://example.com:8090/grpc-web/weaviate.v1.Weaviate/Search"
155+
156+
157+
@pytest.mark.parametrize(
158+
"raw,expected_url",
159+
[
160+
("grpc-web", "http://h:1/grpc-web/svc/M"),
161+
("/grpc-web/", "http://h:1/grpc-web/svc/M"),
162+
("/a/b", "http://h:1/a/b/svc/M"),
163+
("", "http://h:1/svc/M"),
164+
],
165+
)
166+
def test_path_prefix_normalized_in_url(raw, expected_url):
167+
sender = FakeSender(body=_ok_response(b"r"))
168+
channel = GrpcWebChannel("h:1", secure=False, sender=sender, path_prefix=raw)
169+
mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b)
170+
asyncio.run(mc(b"q"))
171+
assert sender.calls[0][0] == expected_url
172+
173+
174+
def test_shim_factory_extracts_path_prefix_option():
175+
from weaviate_grpc_web._shim import _aio_insecure_channel
176+
177+
with_prefix = _aio_insecure_channel(
178+
target="h:1",
179+
options=[("grpc.max_send_message_length", 1), ("grpc-web.path_prefix", "/grpc-web")],
180+
)
181+
assert with_prefix._path_prefix == "/grpc-web"
182+
183+
without_prefix = _aio_insecure_channel(
184+
target="h:1", options=[("grpc.max_send_message_length", 1)]
185+
)
186+
assert without_prefix._path_prefix == ""
187+
188+
147189
def test_set_sender_overrides_default():
148190
sender = FakeSender(body=_ok_response(b"y"))
149191
set_sender(sender)

test/test_connection_params.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import pytest
2+
from pydantic import ValidationError
3+
4+
import weaviate.connect.base as base_mod
5+
from weaviate.connect.base import ConnectionParams
6+
7+
8+
def test_same_host_port_raises_without_prefix() -> None:
9+
with pytest.raises(ValidationError, match="must be different"):
10+
ConnectionParams.from_params(
11+
http_host="localhost",
12+
http_port=8090,
13+
http_secure=False,
14+
grpc_host="localhost",
15+
grpc_port=8090,
16+
grpc_secure=False,
17+
)
18+
19+
20+
def test_from_url_same_host_port_raises_without_prefix() -> None:
21+
with pytest.raises(ValidationError, match="must be different"):
22+
ConnectionParams.from_url("http://localhost:8090", grpc_port=8090)
23+
24+
25+
def test_same_host_port_allowed_with_grpc_web_prefix() -> None:
26+
params = ConnectionParams.from_params(
27+
http_host="localhost",
28+
http_port=8090,
29+
http_secure=False,
30+
grpc_host="localhost",
31+
grpc_port=8090,
32+
grpc_secure=False,
33+
grpc_path_prefix="/grpc-web",
34+
)
35+
assert params._grpc_web_path_prefix == "/grpc-web"
36+
37+
38+
def test_from_url_same_host_port_allowed_with_prefix() -> None:
39+
params = ConnectionParams.from_url(
40+
"http://localhost:8090", grpc_port=8090, grpc_path_prefix="/grpc-web"
41+
)
42+
assert params._grpc_web_path_prefix == "/grpc-web"
43+
44+
45+
def test_different_ports_still_ok_without_prefix() -> None:
46+
params = ConnectionParams.from_params(
47+
http_host="localhost",
48+
http_port=8080,
49+
http_secure=False,
50+
grpc_host="localhost",
51+
grpc_port=50051,
52+
grpc_secure=False,
53+
)
54+
assert params._grpc_web_path_prefix == ""
55+
56+
57+
@pytest.mark.parametrize(
58+
"raw,expected",
59+
[
60+
(None, ""),
61+
("", ""),
62+
("/", ""),
63+
("grpc-web", "/grpc-web"),
64+
("/grpc-web", "/grpc-web"),
65+
("grpc-web/", "/grpc-web"),
66+
("/a/b/", "/a/b"),
67+
],
68+
)
69+
def test_path_prefix_normalization(raw, expected) -> None:
70+
params = ConnectionParams.from_params(
71+
http_host="h",
72+
http_port=8080,
73+
http_secure=False,
74+
grpc_host="g",
75+
grpc_port=50051,
76+
grpc_secure=False,
77+
grpc_path_prefix=raw,
78+
)
79+
assert params._grpc_web_path_prefix == expected
80+
81+
82+
def test_grpc_channel_forwards_path_prefix_option(monkeypatch) -> None:
83+
captured: dict = {}
84+
85+
def fake_insecure_channel(target, options=None, **kwargs):
86+
captured["target"] = target
87+
captured["options"] = options
88+
return "CHANNEL"
89+
90+
monkeypatch.setattr(base_mod.grpc.aio, "insecure_channel", fake_insecure_channel)
91+
92+
params = ConnectionParams.from_params(
93+
http_host="localhost",
94+
http_port=8090,
95+
http_secure=False,
96+
grpc_host="localhost",
97+
grpc_port=8090,
98+
grpc_secure=False,
99+
grpc_path_prefix="/grpc-web",
100+
)
101+
channel = params._grpc_channel(proxies={}, grpc_msg_size=None, is_async=True)
102+
103+
assert channel == "CHANNEL"
104+
assert captured["target"] == "localhost:8090"
105+
assert ("grpc-web.path_prefix", "/grpc-web") in captured["options"]
106+
107+
108+
def test_grpc_channel_omits_option_without_prefix(monkeypatch) -> None:
109+
captured: dict = {}
110+
111+
def fake_insecure_channel(target, options=None, **kwargs):
112+
captured["options"] = options
113+
return "CHANNEL"
114+
115+
monkeypatch.setattr(base_mod.grpc.aio, "insecure_channel", fake_insecure_channel)
116+
117+
params = ConnectionParams.from_params(
118+
http_host="localhost",
119+
http_port=8080,
120+
http_secure=False,
121+
grpc_host="localhost",
122+
grpc_port=50051,
123+
grpc_secure=False,
124+
)
125+
params._grpc_channel(proxies={}, grpc_msg_size=None, is_async=True)
126+
127+
option_keys = [key for key, _ in captured["options"]]
128+
assert "grpc-web.path_prefix" not in option_keys

weaviate/connect/base.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,19 @@ def is_gcp(self) -> bool:
4747
class ConnectionParams(BaseModel):
4848
http: ProtocolParams
4949
grpc: ProtocolParams
50+
# Optional base-path prefix for a grpc-web endpoint served on the REST host:port
51+
# (e.g. "/grpc-web"). None/"" means native gRPC. When set, sharing the REST
52+
# host:port is permitted and the prefix is forwarded to the grpc-web transport.
53+
grpc_path_prefix: Optional[str] = None
5054

5155
@classmethod
52-
def from_url(cls, url: str, grpc_port: int, grpc_secure: bool = False) -> "ConnectionParams":
56+
def from_url(
57+
cls,
58+
url: str,
59+
grpc_port: int,
60+
grpc_secure: bool = False,
61+
grpc_path_prefix: Optional[str] = None,
62+
) -> "ConnectionParams":
5363
parsed_url = urlparse(url)
5464
if parsed_url.scheme not in ["http", "https"]:
5565
raise ValueError(f"Unsupported scheme: {parsed_url.scheme}")
@@ -69,6 +79,7 @@ def from_url(cls, url: str, grpc_port: int, grpc_secure: bool = False) -> "Conne
6979
port=grpc_port,
7080
secure=grpc_secure or parsed_url.scheme == "https",
7181
),
82+
grpc_path_prefix=grpc_path_prefix,
7283
)
7384

7485
@classmethod
@@ -80,6 +91,7 @@ def from_params(
8091
grpc_host: str,
8192
grpc_port: int,
8293
grpc_secure: bool,
94+
grpc_path_prefix: Optional[str] = None,
8395
) -> "ConnectionParams":
8496
return cls(
8597
http=ProtocolParams(
@@ -92,14 +104,18 @@ def from_params(
92104
port=grpc_port,
93105
secure=grpc_secure,
94106
),
107+
grpc_path_prefix=grpc_path_prefix,
95108
)
96109

97110
def is_gcp_on_wcd(self) -> bool:
98111
return "gcp" in self.http.host.lower() and is_weaviate_domain(self.http.host)
99112

100113
@model_validator(mode="after")
101114
def _check_port_collision(self: T) -> T:
102-
if self.http.host == self.grpc.host and self.http.port == self.grpc.port:
115+
same_endpoint = self.http.host == self.grpc.host and self.http.port == self.grpc.port
116+
# grpc-web can be multiplexed onto the REST port under a base-path prefix, so a
117+
# shared host:port is only a conflict for native gRPC (no prefix configured).
118+
if same_endpoint and self._grpc_web_path_prefix == "":
103119
raise ValueError("http.port and grpc.port must be different if using the same host")
104120
return self
105121

@@ -111,6 +127,16 @@ def _grpc_address(self) -> Tuple[str, int]:
111127
def _grpc_target(self) -> str:
112128
return f"{self.grpc.host}:{self.grpc.port}"
113129

130+
@property
131+
def _grpc_web_path_prefix(self) -> str:
132+
"""Return the normalized grpc-web base-path prefix; "" means native gRPC.
133+
134+
A configured prefix is returned with a single leading slash and no trailing
135+
slash (e.g. "grpc-web/" -> "/grpc-web"); empty/None -> "" (native gRPC).
136+
"""
137+
cleaned = (self.grpc_path_prefix or "").strip("/")
138+
return f"/{cleaned}" if cleaned else ""
139+
114140
def _grpc_channel(
115141
self,
116142
proxies: Dict[str, str],
@@ -134,6 +160,12 @@ def _grpc_channel(
134160
if grpc_config is not None and grpc_config.channel_options is not None:
135161
options.extend(grpc_config.channel_options)
136162

163+
# In grpc-web mode, forward the base-path prefix to the transport via channel
164+
# options (consumed by the weaviate-python-grpc-web shim). Not added for native
165+
# gRPC, so the native channel options stay byte-for-byte unchanged.
166+
if (prefix := self._grpc_web_path_prefix) != "":
167+
options.append(("grpc-web.path_prefix", prefix))
168+
137169
if is_async:
138170
mod = grpc.aio
139171
else:

weaviate/connect/helpers.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ def connect_to_custom(
290290
additional_config: Optional[AdditionalConfig] = None,
291291
auth_credentials: Optional[AuthCredentials] = None,
292292
skip_init_checks: bool = False,
293+
grpc_path_prefix: Optional[str] = None,
293294
) -> WeaviateClient:
294295
"""Connect to a Weaviate instance with custom connection parameters.
295296
@@ -312,6 +313,11 @@ def connect_to_custom(
312313
a bearer token, in which case use `weaviate.classes.init.Auth.bearer_token()`, a client secret, in which case use `weaviate.classes.init.Auth.client_credentials()`
313314
or a username and password, in which case use `weaviate.classes.init.Auth.client_password()`.
314315
skip_init_checks: Whether to skip the initialization checks when connecting to Weaviate.
316+
grpc_path_prefix: Optional base-path prefix for a grpc-web endpoint served on the
317+
same host:port as REST (e.g. "/grpc-web"). When set, gRPC requests are sent
318+
over grpc-web to ``<scheme>://<grpc_host>:<grpc_port><prefix>/...`` and sharing
319+
the REST host:port is allowed. Requires the ``weaviate-python-grpc-web``
320+
package. Defaults to None (native gRPC).
315321
316322
Returns:
317323
The client connected to the instance with the required parameters set appropriately.
@@ -353,6 +359,7 @@ def connect_to_custom(
353359
grpc_host=grpc_host,
354360
grpc_port=grpc_port,
355361
grpc_secure=grpc_secure,
362+
grpc_path_prefix=grpc_path_prefix,
356363
),
357364
auth_client_secret=__parse_auth_credentials(auth_credentials),
358365
additional_headers=headers,
@@ -587,6 +594,7 @@ def use_async_with_custom(
587594
additional_config: Optional[AdditionalConfig] = None,
588595
auth_credentials: Optional[AuthCredentials] = None,
589596
skip_init_checks: bool = False,
597+
grpc_path_prefix: Optional[str] = None,
590598
) -> WeaviateAsyncClient:
591599
"""Create an async client object ready to connect to a Weaviate instance with custom connection parameters.
592600
@@ -609,6 +617,11 @@ def use_async_with_custom(
609617
a bearer token, in which case use `weaviate.classes.init.Auth.bearer_token()`, a client secret, in which case use `weaviate.classes.init.Auth.client_credentials()`
610618
or a username and password, in which case use `weaviate.classes.init.Auth.client_password()`.
611619
skip_init_checks: Whether to skip the initialization checks when connecting to Weaviate.
620+
grpc_path_prefix: Optional base-path prefix for a grpc-web endpoint served on the
621+
same host:port as REST (e.g. "/grpc-web"). When set, gRPC requests are sent
622+
over grpc-web to ``<scheme>://<grpc_host>:<grpc_port><prefix>/...`` and sharing
623+
the REST host:port is allowed. Requires the ``weaviate-python-grpc-web``
624+
package. Defaults to None (native gRPC).
612625
613626
Returns:
614627
The client connected to the instance with the required parameters set appropriately.
@@ -652,6 +665,7 @@ def use_async_with_custom(
652665
grpc_host=grpc_host,
653666
grpc_port=grpc_port,
654667
grpc_secure=grpc_secure,
668+
grpc_path_prefix=grpc_path_prefix,
655669
),
656670
auth_client_secret=__parse_auth_credentials(auth_credentials),
657671
additional_headers=headers,

0 commit comments

Comments
 (0)