Skip to content
Open
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
10 changes: 5 additions & 5 deletions pkgs/standards/tigrbl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,11 @@ primary key placeholder.
| Verb | REST route | RPC method | Arity | Input type | Output type |
|------|------------|------------|-------|------------|-------------|
| `create` ➕ | `POST /{resource}` | `Model.create` | collection | dict | dict |
| `read` 🔍 | `GET /{resource}/{id}` | `Model.read` | member | – | dict |
| `update` ✏️ | `PATCH /{resource}/{id}` | `Model.update` | member | dict | dict |
| `replace` ♻️ | `PUT /{resource}/{id}` | `Model.replace` | member | dict | dict |
| `merge` 🧬 | `PATCH /{resource}/{id}` | `Model.merge` | member | dict | dict |
| `delete` 🗑️ | `DELETE /{resource}/{id}` | `Model.delete` | member | – | dict |
| `read` 🔍 | `GET /{resource}/__/{id}` | `Model.read` | member | – | dict |
| `update` ✏️ | `PATCH /{resource}/__/{id}` | `Model.update` | member | dict | dict |
| `replace` ♻️ | `PUT /{resource}/__/{id}` | `Model.replace` | member | dict | dict |
| `merge` 🧬 | `PATCH /{resource}/__/{id}` | `Model.merge` | member | dict | dict |
| `delete` 🗑️ | `DELETE /{resource}/__/{id}` | `Model.delete` | member | – | dict |
| `list` 📃 | `GET /{resource}` | `Model.list` | collection | dict | array |
| `clear` 🧹 | `DELETE /{resource}` | `Model.clear` | collection | dict | dict |
| `bulk_create` 📦➕ | `POST /{resource}` | `Model.bulk_create` | collection | array | array |
Expand Down
4 changes: 2 additions & 2 deletions pkgs/standards/tigrbl/tigrbl/bindings/rest/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ def _build_router(

# Register collection-level bulk routes before member routes so static paths
# like "/resource/bulk" aren't captured by dynamic member routes such as
# "/resource/{item_id}". ASGI matches routes in the order they are
# "/resource/__/{item_id}". ASGI matches routes in the order they are
# added, so sorting here prevents "bulk" from being treated as an
# identifier.
specs = sorted(
Expand Down Expand Up @@ -210,7 +210,7 @@ def _build_router(
"merge",
"delete",
}:
path = f"{base}/{{{pk_param}}}{suffix}"
path = f"{base}/__/{{{pk_param}}}{suffix}"
is_member = True
else:
path = f"{base}{suffix}"
Expand Down
2 changes: 1 addition & 1 deletion pkgs/standards/tigrbl/tigrbl/bindings/rest/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def _path_for_spec(
"merge",
"delete",
}:
return f"/{resource}/{{{pk_param}}}{suffix}", True
return f"/{resource}/__/{{{pk_param}}}{suffix}", True
return f"/{resource}{suffix}", False


Expand Down
2 changes: 1 addition & 1 deletion pkgs/standards/tigrbl/tigrbl/types/op.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class _Op(NamedTuple):

verb: str # e.g. "create", "list"
http: str # "POST" | "GET" | "PATCH" | …
path: str # URL suffix, e.g. "/{item_id}"
path: str # URL suffix, e.g. "/__/{item_id}"
In: Type | None # Pydantic input model (or None)
Out: Type # Pydantic output model
core: Callable[..., Any] # The actual implementation
Expand Down
25 changes: 25 additions & 0 deletions pkgs/standards/tigrbl_client/tests/unit/test_nested_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from tigrbl_client import TigrblClient


def test_collection_path_builds_collection_and_collection_op() -> None:
assert TigrblClient.collection_path("widget") == "/widget"
assert TigrblClient.collection_path("widget", "bulk_merge") == "/widget/bulk_merge"


def test_member_path_builds_member_and_member_op() -> None:
assert TigrblClient.member_path("widget", "abc-123") == "/widget/__/abc-123"
assert (
TigrblClient.member_path("widget", "abc-123", "rotate")
== "/widget/__/abc-123/rotate"
)


def test_child_member_path_builds_nested_member_path() -> None:
assert (
TigrblClient.child_member_path("widget", "w1", "version", "v2")
== "/widget/__/w1/version/__/v2"
)
assert (
TigrblClient.child_member_path("widget", "w1", "version", "v2", "read")
== "/widget/__/w1/version/__/v2/read"
)
22 changes: 11 additions & 11 deletions pkgs/standards/tigrbl_client/tests/unit/test_tigrbl_async_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ async def fake_aget(self, url, *, params=None, headers=None):

with patch.object(httpx.AsyncClient, "get", new=fake_aget):
client = TigrblClient("http://example.com/api")
result = await client.aget("/users/1")
result = await client.aget("/users/__/1")

assert captured["url"] == "http://example.com/api/users/1"
assert captured["url"] == "http://example.com/api/users/__/1"
assert result == {"id": 1, "name": "test"}


Expand Down Expand Up @@ -71,7 +71,7 @@ async def fake_aget(self, url, *, params=None, headers=None):

with patch.object(httpx.AsyncClient, "get", new=fake_aget):
client = TigrblClient("http://example.com/api")
result = await client.aget("/items/1", out_schema=DummySchema)
result = await client.aget("/items/__/1", out_schema=DummySchema)

assert isinstance(result, DummySchema)
assert result._data == {"name": "test", "value": 42}
Expand Down Expand Up @@ -154,9 +154,9 @@ async def fake_aput(self, url, *, json=None, headers=None):

with patch.object(httpx.AsyncClient, "put", new=fake_aput):
client = TigrblClient("http://example.com/api")
result = await client.aput("/users/1", data=data)
result = await client.aput("/users/__/1", data=data)

assert captured["url"] == "http://example.com/api/users/1"
assert captured["url"] == "http://example.com/api/users/__/1"
assert captured["json"] == data
assert result == {"id": 1, **data}

Expand All @@ -178,9 +178,9 @@ async def fake_apatch(self, url, *, json=None, headers=None):

with patch.object(httpx.AsyncClient, "patch", new=fake_apatch):
client = TigrblClient("http://example.com/api")
await client.apatch("/users/1", data=data)
await client.apatch("/users/__/1", data=data)

assert captured["url"] == "http://example.com/api/users/1"
assert captured["url"] == "http://example.com/api/users/__/1"
assert captured["json"] == data


Expand All @@ -198,9 +198,9 @@ async def fake_adelete(self, url, *, headers=None):

with patch.object(httpx.AsyncClient, "delete", new=fake_adelete):
client = TigrblClient("http://example.com/api")
result = await client.adelete("/users/1")
result = await client.adelete("/users/__/1")

assert captured["url"] == "http://example.com/api/users/1"
assert captured["url"] == "http://example.com/api/users/__/1"
assert result is None # 204 No Content


Expand All @@ -217,7 +217,7 @@ async def fake_adelete(self, url, *, headers=None):

with patch.object(httpx.AsyncClient, "delete", new=fake_adelete):
client = TigrblClient("http://example.com/api")
result = await client.adelete("/users/1")
result = await client.adelete("/users/__/1")

assert result == {"message": "User deleted successfully"}

Expand All @@ -241,7 +241,7 @@ async def fake_aget(self, url, *, params=None, headers=None):
with patch.object(httpx.AsyncClient, "get", new=fake_aget):
client = TigrblClient("http://example.com/api")
with pytest.raises(httpx.HTTPStatusError):
await client.aget("/users/999")
await client.aget("/users/__/999")


# Async Complex Filter Tests
Expand Down
22 changes: 11 additions & 11 deletions pkgs/standards/tigrbl_client/tests/unit/test_tigrbl_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ def fake_get(self, url, *, params=None, headers=None):

with patch.object(httpx.Client, "get", new=fake_get):
client = TigrblClient("http://example.com/api")
result = client.get("/users/1")
result = client.get("/users/__/1")

assert captured["url"] == "http://example.com/api/users/1"
assert captured["url"] == "http://example.com/api/users/__/1"
assert result == {"id": 1, "name": "test"}


Expand Down Expand Up @@ -94,7 +94,7 @@ def fake_get(self, url, *, params=None, headers=None):

with patch.object(httpx.Client, "get", new=fake_get):
client = TigrblClient("http://example.com/api")
result = client.get("/items/1", out_schema=DummySchema)
result = client.get("/items/__/1", out_schema=DummySchema)

assert isinstance(result, DummySchema)
assert result._data == {"name": "test", "value": 42}
Expand Down Expand Up @@ -173,9 +173,9 @@ def fake_put(self, url, *, json=None, headers=None):

with patch.object(httpx.Client, "put", new=fake_put):
client = TigrblClient("http://example.com/api")
result = client.put("/users/1", data=data)
result = client.put("/users/__/1", data=data)

assert captured["url"] == "http://example.com/api/users/1"
assert captured["url"] == "http://example.com/api/users/__/1"
assert captured["json"] == data
assert result == {"id": 1, **data}

Expand All @@ -196,9 +196,9 @@ def fake_patch(self, url, *, json=None, headers=None):

with patch.object(httpx.Client, "patch", new=fake_patch):
client = TigrblClient("http://example.com/api")
client.patch("/users/1", data=data)
client.patch("/users/__/1", data=data)

assert captured["url"] == "http://example.com/api/users/1"
assert captured["url"] == "http://example.com/api/users/__/1"
assert captured["json"] == data


Expand All @@ -215,9 +215,9 @@ def fake_delete(self, url, *, headers=None):

with patch.object(httpx.Client, "delete", new=fake_delete):
client = TigrblClient("http://example.com/api")
result = client.delete("/users/1")
result = client.delete("/users/__/1")

assert captured["url"] == "http://example.com/api/users/1"
assert captured["url"] == "http://example.com/api/users/__/1"
assert result is None # 204 No Content


Expand All @@ -233,7 +233,7 @@ def fake_delete(self, url, *, headers=None):

with patch.object(httpx.Client, "delete", new=fake_delete):
client = TigrblClient("http://example.com/api")
result = client.delete("/users/1")
result = client.delete("/users/__/1")

assert result == {"message": "User deleted successfully"}

Expand All @@ -256,7 +256,7 @@ def fake_get(self, url, *, params=None, headers=None):
with patch.object(httpx.Client, "get", new=fake_get):
client = TigrblClient("http://example.com/api")
with pytest.raises(httpx.HTTPStatusError):
client.get("/users/999")
client.get("/users/__/999")


# Complex Filter Tests
Expand Down
4 changes: 2 additions & 2 deletions pkgs/standards/tigrbl_client/tigrbl_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ class TigrblClient(RPCMixin, CRUDMixin, NestedCRUDMixin):

# REST CRUD usage
client = TigrblClient("http://api.example.com")
user = client.get("/users/123")
user = client.get("/users/__/123")
new_user = client.post("/users", data={"name": "John", "email": "john@example.com"})

# Async usage
user = await client.aget("/users/123")
user = await client.aget("/users/__/123")
result = await client.apost("/users", data={"name": "Jane"})
"""

Expand Down
90 changes: 51 additions & 39 deletions pkgs/standards/tigrbl_client/tigrbl_client/_nested_crud.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,62 @@
# tigrbl_client/_nested_crud.py
"""
Placeholder module for nested CRUD operations.

This module will be developed in the future to support nested resource paths
and more complex CRUD operations with hierarchical data structures.

Examples of future functionality:
- /users/{user_id}/posts/{post_id}/comments
- /organizations/{org_id}/teams/{team_id}/members
- Complex resource relationships and nested operations
"""Helpers for constructing canonical Tigrbl REST paths.

The canonical route shapes are:
- ``/{collection}/__/{id}``
- ``/{collection}/__/{id}/{member_op}``
- ``/{collection}/__/{id}/{child_collection}/__/{child_id}``
- ``/{collection}/__/{id}/{child_collection}/__/{child_id}/{member_op}``
- ``/{collection}/{collection_op}``
"""

from __future__ import annotations

from typing import Any, TypeVar, Protocol
from typing import runtime_checkable
from urllib.parse import quote

T = TypeVar("T")

class NestedCRUDMixin:
"""Utility methods for generating canonical Tigrbl REST paths."""

@staticmethod
def _segment(value: str | int) -> str:
"""Encode a path segment safely for URL usage."""
return quote(str(value), safe="")

@runtime_checkable
class _Schema(Protocol[T]): # anything with Pydantic-v2 interface
@classmethod
def model_validate(cls, data: Any) -> T: ...
@classmethod
def model_dump_json(cls, **kw) -> str: ...
def collection_path(cls, collection: str, collection_op: str | None = None) -> str:
"""Build ``/{collection}`` or ``/{collection}/{collection_op}``."""
base = f"/{cls._segment(collection)}"
if collection_op:
return f"{base}/{cls._segment(collection_op)}"
return base

@classmethod
def member_path(
cls,
collection: str,
item_id: str | int,
member_op: str | None = None,
) -> str:
"""Build ``/{collection}/__/{id}`` with optional member operation."""
path = f"/{cls._segment(collection)}/__/{cls._segment(item_id)}"
if member_op:
return f"{path}/{cls._segment(member_op)}"
return path

class NestedCRUDMixin:
"""
Placeholder mixin class for nested CRUD functionality.

This will be developed to support complex nested resource paths
and hierarchical data operations.
"""

def __init__(self):
"""Initialize the NestedCRUDMixin."""
# Placeholder for future initialization
pass

def _placeholder_method(self) -> str:
"""
Placeholder method to demonstrate future functionality.

Returns:
A message indicating this is a placeholder
"""
return "This is a placeholder for future nested CRUD functionality"
@classmethod
def child_member_path(
cls,
collection: str,
item_id: str | int,
child_collection: str,
child_id: str | int,
member_op: str | None = None,
) -> str:
"""Build nested member paths with optional member operation."""
path = (
f"/{cls._segment(collection)}/__/{cls._segment(item_id)}"
f"/{cls._segment(child_collection)}/__/{cls._segment(child_id)}"
)
if member_op:
return f"{path}/{cls._segment(member_op)}"
return path
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def test_key_creation_seeded_version(client_app):
key = _create_key(client)
key_id = key["id"]

read = client.get(f"/kms/key/{key_id}")
read = client.get(f"/kms/key/__/{key_id}")
assert read.status_code == 200
data = read.json()
assert data["id"] == key_id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ def key_routes(tmp_path, monkeypatch):
@pytest.mark.parametrize(
"alias,path,methods",
[
("read", "/kms/key/{item_id}", {"GET"}),
("update", "/kms/key/{item_id}", {"PATCH"}),
("replace", "/kms/key/{item_id}", {"PUT"}),
("delete", "/kms/key/{item_id}", {"DELETE"}),
("read", "/kms/key/__/{item_id}", {"GET"}),
("update", "/kms/key/__/{item_id}", {"PATCH"}),
("replace", "/kms/key/__/{item_id}", {"PUT"}),
("delete", "/kms/key/__/{item_id}", {"DELETE"}),
("list", "/kms/key", {"GET"}),
("bulk_create", "/kms/key", {"POST"}),
("bulk_update", "/kms/key", {"PATCH"}),
Expand Down
2 changes: 1 addition & 1 deletion pkgs/standards/tigrbl_kms/tests/unit/test_key_rotate.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ async def fetch_versions():
def test_rotate_openapi_spec(client_app):
client, _ = client_app
spec = client.get("/openapi.json").json()
rotate_spec = spec["paths"]["/kms/key/{item_id}/rotate"]["post"]
rotate_spec = spec["paths"]["/kms/key/__/{item_id}/rotate"]["post"]
assert "requestBody" not in rotate_spec
assert "201" in rotate_spec["responses"]
assert rotate_spec["responses"]["201"].get("content") is None
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ def key_version_routes(tmp_path, monkeypatch):
@pytest.mark.parametrize(
"alias,path,methods",
[
("read", "/kms/key_version/{item_id}", {"GET"}),
("update", "/kms/key_version/{item_id}", {"PATCH"}),
("replace", "/kms/key_version/{item_id}", {"PUT"}),
("delete", "/kms/key_version/{item_id}", {"DELETE"}),
("read", "/kms/key_version/__/{item_id}", {"GET"}),
("update", "/kms/key_version/__/{item_id}", {"PATCH"}),
("replace", "/kms/key_version/__/{item_id}", {"PUT"}),
("delete", "/kms/key_version/__/{item_id}", {"DELETE"}),
("list", "/kms/key_version", {"GET"}),
("bulk_create", "/kms/key_version", {"POST"}),
("bulk_update", "/kms/key_version", {"PATCH"}),
Expand Down
Loading
Loading