Skip to content

Commit cd6bb13

Browse files
authored
feat(functions): invoke functions by name (#65)
* feat(functions): invoke functions by name * fix(functions): preserve invocation responses
1 parent 6f6ffbf commit cd6bb13

11 files changed

Lines changed: 417 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ updated_user = client.auth.update_user(
3838
)
3939
assert updated_user.id == session.user_id
4040

41+
function = client.functions.invoke(
42+
"send-welcome",
43+
{"user_id": session.user_id},
44+
)
45+
print(function.status, function.version, function.data)
46+
4147
rows = client.database("main").from_("items").select("*").eq("slug", "a").execute()
4248

4349
bucket = client.storage.from_("assets")
@@ -129,6 +135,12 @@ object.
129135
`abort_upload_session(path, session_id=...)` abandons a session and discards its
130136
uploaded parts.
131137

138+
`functions.invoke()` resolves a DNS-safe function name and sends a JSON object.
139+
It uses the active user session when present, then a configured service key,
140+
then the anonymous key. The immutable result includes the response body, status,
141+
headers, and `X-Volcano-Version`. A function's own non-2xx response is returned
142+
when the version header proves it ran; platform failures raise typed SDK errors.
143+
132144
Database builders are immutable, so you can safely reuse a base query. Chain `neq()`, `gt()`,
133145
`gte()`, `lt()`, and `lte()` for comparison filters:
134146

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ ignore = [
9393
"src/volcano_sdk/_transport.py" = ["ANN401"]
9494
"src/volcano_sdk/auth.py" = ["SLF001"]
9595
"src/volcano_sdk/database.py" = ["SLF001"]
96+
"src/volcano_sdk/functions.py" = ["SLF001"]
9697
"src/volcano_sdk/locks.py" = ["SLF001"]
9798
"src/volcano_sdk/realtime.py" = ["ANN401", "SLF001"]
9899
"src/volcano_sdk/storage.py" = ["SLF001"]

‎scripts/generate_openapi.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@
2121
"download_storage_object.py",
2222
"force_release_project_lock.py",
2323
"get_project_lock.py",
24+
"invoke_function.py",
2425
"list_storage_objects.py",
2526
"move_storage_object.py",
2627
"query_database_select.py",
2728
"release_project_lock.py",
2829
"renew_project_lock.py",
30+
"resolve_function_for_invocation.py",
2931
"update_storage_object_visibility.py",
3032
"upload_storage_object.py",
3133
}

‎src/volcano_sdk/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
AuthStateCallback,
1919
AuthSubscription,
2020
EmailChangeResult,
21+
FunctionResponse,
2122
LinkedOAuthProvider,
2223
LockLease,
2324
LockState,
@@ -43,6 +44,7 @@
4344
"AuthenticationError",
4445
"ConflictError",
4546
"EmailChangeResult",
47+
"FunctionResponse",
4648
"LinkedOAuthProvider",
4749
"LockLease",
4850
"LockState",

‎src/volcano_sdk/_transport.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@
5858
query_database_select,
5959
query_database_update,
6060
)
61+
from ._generated.api.functions import resolve_function_for_invocation
62+
from ._generated.api.functions.invoke_function import (
63+
_get_kwargs as invoke_function_kwargs,
64+
)
6165
from ._generated.api.locks import (
6266
acquire_project_lock,
6367
force_release_project_lock,
@@ -142,6 +146,10 @@
142146
from ._generated.models.database_insert_request import DatabaseInsertRequest
143147
from ._generated.models.database_select_request import DatabaseSelectRequest
144148
from ._generated.models.database_update_request import DatabaseUpdateRequest
149+
from ._generated.models.function_invocation_request import FunctionInvocationRequest
150+
from ._generated.models.function_invocation_request_payload import (
151+
FunctionInvocationRequestPayload,
152+
)
145153
from ._generated.models.get_o_auth_provider_token_response_200 import (
146154
GetOAuthProviderTokenResponse200,
147155
)
@@ -1490,6 +1498,39 @@ def update_storage_object_visibility(
14901498
)
14911499
return self._response(response)
14921500

1501+
def resolve_function_for_invocation(
1502+
self,
1503+
*,
1504+
authorization: str,
1505+
name: str,
1506+
) -> TransportResponse:
1507+
with self._client(authorization) as client:
1508+
response = resolve_function_for_invocation.sync_detailed(
1509+
client=client,
1510+
name=name,
1511+
)
1512+
return self._response(response)
1513+
1514+
def invoke_function(
1515+
self,
1516+
*,
1517+
authorization: str,
1518+
function_id: str,
1519+
payload: Mapping[str, JSONValue],
1520+
) -> TransportResponse:
1521+
plain_payload = cast("dict[str, JSONValue]", _plain_json(payload))
1522+
body = FunctionInvocationRequest(
1523+
payload=FunctionInvocationRequestPayload.from_dict(plain_payload)
1524+
)
1525+
with self._client(authorization) as client:
1526+
response = client.get_httpx_client().request(
1527+
**invoke_function_kwargs(
1528+
UUID(function_id),
1529+
body=body,
1530+
)
1531+
)
1532+
return self._raw_response(response)
1533+
14931534
def acquire_project_lock(
14941535
self,
14951536
*,

‎src/volcano_sdk/client.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from ._transport import GeneratedTransport, Transport
1010
from .auth import Auth
1111
from .database import Database
12+
from .functions import Functions
1213
from .locks import Locks
1314
from .models import (
1415
AuthChangeEvent,
@@ -81,6 +82,7 @@ def __init__(
8182
else GeneratedTransport(api_url=self._api_url, timeout=timeout)
8283
)
8384
self.auth = Auth(self)
85+
self.functions = Functions(self)
8486
self.storage = Storage(self)
8587
self.locks = Locks(self)
8688
if _realtime_client_factory is None:
@@ -118,6 +120,14 @@ def _service_token(self) -> str:
118120
raise RuntimeError(_NO_SERVICE_KEY)
119121
return self._service_key
120122

123+
def _function_token(self) -> str:
124+
session = self._capture_session()[1]
125+
if session is not None:
126+
return session.access_token
127+
if self._service_key is not None:
128+
return self._service_key
129+
return self._anon_key
130+
121131
def _set_session(
122132
self,
123133
session: Session,

‎src/volcano_sdk/functions.py‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Serverless function invocation facade."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
from collections.abc import Mapping
7+
from typing import TYPE_CHECKING, Protocol, cast
8+
9+
from ._transport import TransportResponse, invoke, response_payload
10+
from .models import FunctionResponse, JSONValue
11+
12+
if TYPE_CHECKING:
13+
from .client import VolcanoClient
14+
15+
_FUNCTION_NAME = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
16+
_INVALID_FUNCTION_NAME = (
17+
"Function name must be DNS-safe: lowercase letters, numbers, and hyphens; "
18+
"1-63 characters"
19+
)
20+
_INVALID_FUNCTION_RESPONSE = "Expected a complete function response"
21+
_INVALID_FUNCTION_PAYLOAD = "Function payload must be a mapping"
22+
_HTTP_SUCCESS_MIN = 200
23+
_HTTP_SUCCESS_MAX = 300
24+
25+
26+
class FunctionsTransport(Protocol):
27+
"""Transport operations required by the functions facade."""
28+
29+
def resolve_function_for_invocation(
30+
self,
31+
*,
32+
authorization: str,
33+
name: str,
34+
) -> TransportResponse:
35+
"""Resolve a function name to its canonical identifier."""
36+
...
37+
38+
def invoke_function(
39+
self,
40+
*,
41+
authorization: str,
42+
function_id: str,
43+
payload: Mapping[str, JSONValue],
44+
) -> TransportResponse:
45+
"""Invoke a resolved function identifier."""
46+
...
47+
48+
49+
class Functions:
50+
"""Invoke deployed Volcano functions by name."""
51+
52+
def __init__(self, client: VolcanoClient) -> None:
53+
"""Bind function calls to a Volcano client."""
54+
self._client = client
55+
56+
def invoke(
57+
self,
58+
name: str,
59+
payload: Mapping[str, JSONValue] | None = None,
60+
) -> FunctionResponse:
61+
"""Resolve and invoke a function with an optional JSON object payload."""
62+
name = _function_name(name)
63+
request_payload = _function_payload(payload)
64+
authorization = self._client._function_token()
65+
transport = cast("FunctionsTransport", self._client._transport)
66+
resolved = invoke(
67+
transport.resolve_function_for_invocation,
68+
authorization=authorization,
69+
name=name,
70+
)
71+
function_id = self._function_id(response_payload(resolved, _HTTP_SUCCESS_MIN))
72+
response = invoke(
73+
transport.invoke_function,
74+
authorization=authorization,
75+
function_id=function_id,
76+
payload=request_payload,
77+
)
78+
return self._response(response)
79+
80+
@staticmethod
81+
def _function_id(payload: object) -> str:
82+
if not isinstance(payload, Mapping):
83+
raise TypeError(_INVALID_FUNCTION_RESPONSE)
84+
values = cast("Mapping[str, object]", payload)
85+
function_id = values.get("function_id")
86+
if not isinstance(function_id, str) or not function_id:
87+
raise TypeError(_INVALID_FUNCTION_RESPONSE)
88+
return function_id
89+
90+
@staticmethod
91+
def _response(response: TransportResponse) -> FunctionResponse:
92+
status = int(response.status_code)
93+
version = _header(response.headers, "X-Volcano-Version")
94+
if not _HTTP_SUCCESS_MIN <= status < _HTTP_SUCCESS_MAX and version is None:
95+
response_payload(response, _HTTP_SUCCESS_MIN)
96+
data = response.payload
97+
if not isinstance(data, Mapping):
98+
raise TypeError(_INVALID_FUNCTION_RESPONSE)
99+
headers = {} if response.headers is None else dict(response.headers)
100+
return FunctionResponse(
101+
data=cast("Mapping[str, JSONValue]", data),
102+
status=status,
103+
headers=headers,
104+
version=version,
105+
)
106+
107+
108+
def _header(headers: Mapping[str, str] | None, name: str) -> str | None:
109+
if headers is None:
110+
return None
111+
for key, value in headers.items():
112+
if key.casefold() == name.casefold():
113+
return value
114+
return None
115+
116+
117+
def _function_name(value: object) -> str:
118+
if not isinstance(value, str) or _FUNCTION_NAME.fullmatch(value) is None:
119+
raise ValueError(_INVALID_FUNCTION_NAME)
120+
return value
121+
122+
123+
def _function_payload(value: object) -> dict[str, JSONValue]:
124+
if value is None:
125+
return {}
126+
if not isinstance(value, Mapping):
127+
raise TypeError(_INVALID_FUNCTION_PAYLOAD)
128+
return dict(cast("Mapping[str, JSONValue]", value))

‎src/volcano_sdk/models.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,25 @@ class LockState:
192192
fencing_token: int | None
193193

194194

195+
@dataclass(frozen=True, slots=True)
196+
class FunctionResponse:
197+
"""Response returned by an invoked function."""
198+
199+
data: Mapping[str, JSONValue] = field(hash=False)
200+
status: int
201+
headers: Mapping[str, str] = field(hash=False)
202+
version: str | None
203+
204+
def __post_init__(self) -> None:
205+
"""Defensively freeze response data and headers."""
206+
object.__setattr__(self, "data", _freeze_json(self.data))
207+
object.__setattr__(
208+
self,
209+
"headers",
210+
MappingProxyType(dict(self.headers)),
211+
)
212+
213+
195214
@dataclass(frozen=True, slots=True)
196215
class UploadSession:
197216
"""Server-created state for a resumable storage upload."""

0 commit comments

Comments
 (0)