| name | plugin-service |
| description | Builds HTTP service surfaces for NeMo Platform plugins using NemoService, RouterSpec, NemoListResponse, and NemoFilter. Use when adding REST API routes to a plugin, implementing CRUD endpoints, handling pagination and filtering, or testing FastAPI routes. Trigger keywords: HTTP routes, REST API, FastAPI, CRUD, endpoint, router, NemoService, pagination, filter, list endpoint, NemoListResponse, RouterSpec. |
Plugin HTTP Service (NemoService)
Class Signature
from typing import ClassVar
from nemo_platform_plugin.service import NemoService, RouterSpec
class MyService(NemoService):
name: ClassVar[str] = "my-plugin"
dependencies: ClassVar[list[str]] = ["entities"]
def get_routers(self) -> list[RouterSpec]:
return [RouterSpec(_build_router(), tag="My Plugin", prefix="/v2/workspaces/{workspace}")]
async def on_startup(self) -> None: ...
async def on_shutdown(self) -> None: ...
Valid dependencies values: "entities", "auth", "jobs", "files", "secrets", "models", "inference-gateway". These services must be healthy before on_startup() is called.
URL Pattern
/apis/<name>/<spec.prefix>/<route-path>
Example: name="my-plugin", prefix="/v2/workspaces/{workspace}", route /widgets → /apis/my-plugin/v2/workspaces/{workspace}/widgets
Convention: always use prefix="/v2/workspaces/{workspace}" for resource endpoints.
Authorization
Every plugin HTTP route MUST carry a @path_rule — a route with no rule is a validation error, and under the default on_invalid_plugin=hard_fail the auth service refuses to build the OPA bundle rather than silently fence the route, so the platform 502s. Each route also declares exactly one OAuth scope with @AuthzScope.read / .write — a separate, orthogonal decorator. @router.<method> is the outermost decorator; @scope and @path_rule sit beneath it (their relative order is irrelevant):
from nemo_platform_plugin.authz import AuthzScope, CallerKind, PermissionSet, path_rule, perm
scope = AuthzScope("my-plugin")
class WidgetPerms(PermissionSet, namespace="my-plugin.widgets"):
READ = perm("Read a widget")
@router.get("/widgets/{name}", response_model=Widget)
@scope.read
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[WidgetPerms.READ])
async def get_widget(...) -> Widget: ...
Reference PermissionSet members (never bare strings — a bare string raises TypeError at import), and pass authz=scope to route factories (add_job_routes / add_function_routes) so their routes are ruled too. See ../plugin-authz/SKILL.md for the full surface — caller kinds, compound permission ids, role grants, and fail-mode config.
Response Schemas
Entity objects as responses
Return entity objects directly — no separate response class needed:
from nemo_platform_plugin.authz import AuthzScope, CallerKind, PermissionSet, path_rule, perm
from nemo_platform_plugin.entity import NemoEntity
scope = AuthzScope("my-plugin")
class WidgetPerms(PermissionSet, namespace="my-plugin.widgets"):
CREATE = perm("Create a widget")
class Widget(NemoEntity, entity_type="my_plugin_widget"):
colour: str
weight_kg: float = 0.0
@router.post("/widgets", response_model=Widget, status_code=201)
@scope.write
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[WidgetPerms.CREATE])
async def create_widget(...) -> Widget:
saved = await entity_client.create(widget)
return saved
NemoEntity subclasses expose id, created_at, updated_at, name, workspace, project as computed fields — they serialize correctly in responses without any adapter class.
For non-CRUD endpoints or computed responses, use a plain BaseModel subclass instead.
NemoListResponse
from nemo_platform_plugin.schema import NemoListResponse
WidgetPage = NemoListResponse[Widget]
NemoListResponse wire format:
{
"data": [...],
"pagination": {"page": 1, "page_size": 20, "current_page_size": 5,
"total_pages": 1, "total_results": 5},
"sort": "-created_at",
"filter": {"colour": "red"}
}
CRITICAL: entity_client.list() returns PaginationInfo (entity client internal). NemoListResponse expects PaginationData (API response layer). Always convert:
from nemo_platform_plugin.schema import PaginationData
pagination = PaginationData.model_validate(result.pagination.model_dump()) if result.pagination else None
NemoFilter
from nemo_platform_plugin.schema import NemoFilter
class WidgetFilter(NemoFilter):
colour: str | None = None
tag: str | None = None
extra="forbid" means unknown filter fields return 422 (not silently ignored)
- deepObject query syntax:
?filter[colour]=red
- Factory:
make_filter_obj_dep(WidgetFilter) from nmp.common.entities.filters
- Always check
isinstance(filter, dict) — make_filter_obj_dep can return either a NemoFilter instance or a raw dict for wildcard filters:
filter_dict = filter if isinstance(filter, dict) else filter.model_dump(exclude_none=True)
result = await entity_client.list(Widget, workspace=workspace, filter_obj=filter_dict or None)
Dependency Injection
from nemo_platform_plugin.entity_client import NemoEntitiesClient, get_entity_client
from nmp.common.auth.dependencies import get_auth_client
from nmp.common.auth.client import AuthClient
from nmp.common.service.dependencies import get_sdk_client
from nemo_platform import AsyncNeMoPlatform
from nemo_platform_plugin.authz import AuthzScope, CallerKind, PermissionSet, path_rule, perm
from fastapi import Depends
scope = AuthzScope("my-plugin")
class WidgetPerms(PermissionSet, namespace="my-plugin.widgets"):
LIST = perm("List widgets")
@router.get("/widgets")
@scope.read
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[WidgetPerms.LIST])
async def list_widgets(
workspace: str,
entity_client: NemoEntitiesClient = Depends(get_entity_client),
auth_client: AuthClient = Depends(get_auth_client),
sdk: AsyncNeMoPlatform = Depends(get_sdk_client),
) -> WidgetPage:
principal_id = auth_client.principal.id
...
get_entity_client is a placeholder — the platform injects the real implementation at startup via app.dependency_overrides.
CRUD Quick Reference
Complete implementation: See crud-example.md — full entity-backed CRUD service with all imports, error handling, and tests.
| Operation | Method | Status | Key errors |
|---|
| Create | POST /widgets | 201 | NemoEntityConflictError → 409 |
| List | GET /widgets | 200 | isinstance(filter, dict) check required |
| Get | GET /widgets/{name} | 200 | NemoEntityNotFoundError → 404 |
| Update | PATCH /widgets/{name} | 200 | NotFoundError on get → 404; ConflictError on update → 409 |
| Delete | DELETE /widgets/{name} | 204 | NemoEntityNotFoundError → 404 |
All raise statements must use from exc syntax for error chaining.
Testing Pattern
from fastapi import FastAPI
from fastapi.testclient import TestClient
from unittest.mock import AsyncMock
from nemo_platform_plugin.entity_client import get_entity_client
def _make_app(mock_client: AsyncMock) -> FastAPI:
service = MyService()
app = FastAPI()
for spec in service.get_routers():
app.include_router(spec.router, prefix=spec.prefix)
app.dependency_overrides[get_entity_client] = lambda: mock_client
return app
def test_get_widget():
mock_client = AsyncMock()
mock_client.get.return_value = _make_widget("w1")
app = _make_app(mock_client)
client = TestClient(app)
response = client.get("/v2/workspaces/default/widgets/w1")
assert response.status_code == 200
assert response.json()["name"] == "w1"
Gotchas
PaginationInfo ≠ PaginationData: entity_client.list() returns PaginationInfo; NemoListResponse expects PaginationData. Always convert: PaginationData.model_validate(result.pagination.model_dump()).
isinstance(filter, dict) check is required: make_filter_obj_dep may return either a NemoFilter instance or a raw dict for wildcard filters. Calling .model_dump() on a dict raises AttributeError.
entity.id is "" before persistence: Never return an entity from a route before saving it — the empty id will appear in the response.
from exc on all raises: Use raise HTTPException(...) from exc for all error re-raises to preserve the exception chain.
get_entity_client is a placeholder: The platform injects the real implementation via app.dependency_overrides. In tests, override it with lambda: mock_client.
NemoFilter extra="forbid": Unknown filter query params return 422. This is by design — it prevents typos from silently returning unfiltered results.
- URL formula: Routes mount at
/apis/<name>/<prefix>/<route>. The prefix in RouterSpec is appended after the service name, not replacing it.
- Unruled route → OPA bundle build fails: A route with no
@path_rule is a validation error; under the default on_invalid_plugin=hard_fail the auth service refuses to build the bundle and the platform 502s. Attach @path_rule (plus @scope.read/.write) to every handler, or authz= on a route factory.
See Also