Skip to main content

plugin-service

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.

Source facts

Repository
NVIDIA-NeMo/nemo-platform
Last source activity
August 14, 2026 at 17:32
Detected SKILL.md language
English
Stars
79
Forks
24

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
2 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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 ```python from typing import ClassVar from nemo_platform_plugin.service import NemoService, RouterSpec class MyService(NemoService): name: ClassVar[str] = "my-plugin" # REQUIRED — entry-point key, URL prefix dependencies: ClassVar[list[str]] = ["entities"] # platform services to wait for def get_routers(self) -> list[RouterSpec]: # REQUIRED return [RouterSpec(_build_router(), tag="My Plugin", prefix="/v2/workspaces/{workspace}")] async def on_startup(self) -> None: ... # optional — async init async def on_shutdown(self) -> None: ... # optional — cleanup ``` 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): ```python from nemo_platform_plugin.authz import AuthzScope, CallerKind, PermissionSet, path_rule, perm scope = AuthzScope("my-plugin") # owns my-plugin:read / my-plugin:write class WidgetPerms(PermissionSet, namespace="my-plugin.widgets"): READ = perm("Read a widget") # -> "my-plugin.widgets.read" @router.get("/widgets/{name}", response_model=Widget) @scope.read # OAuth scope gate @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`](../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: ```python from nemo_platform_plugin.authz import AuthzScope, CallerKind, PermissionSet, path_rule, perm from nemo_platform_plugin.entity import NemoEntity scope = AuthzScope("my-plugin") # owns my-plugin:read / my-plugin:write class WidgetPerms(PermissionSet, namespace="my-plugin.widgets"): CREATE = perm("Create a widget") # -> "my-plugin.widgets.create" class Widget(NemoEntity, entity_type="my_plugin_widget"): colour: str weight_kg: float = 0.0 # Use the entity class directly as response_model: @router.post("/widgets", response_model=Widget, status_code=201) @scope.write # OAuth scope gate @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 ```python from nemo_platform_plugin.schema import NemoListResponse WidgetPage = NemoListResponse[Widget] # entity class used directly ``` `NemoListResponse` wire format: ```json { "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: > ```python > from nemo_platform_plugin.schema import PaginationData > pagination = PaginationData.model_validate(result.pagination.model_dump()) if result.pagination else None > ``` ## NemoFilter ```python from nemo_platform_plugin.schema import NemoFilter class WidgetFilter(NemoFilter): # extra="forbid" inherited — typos → 422 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: ```python 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 ```python 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 # transitive dep via nmp-common; not re-exported by nemo_platform_plugin 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") # -> "my-plugin.widgets.list" @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`](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 ```python 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 - [`crud-example.md`](crud-example.md) — Complete CRUD implementation with all imports - [`../plugin-entities/SKILL.md`](../plugin-entities/SKILL.md) — Entity definitions and client - [`../plugin-platform-services/SKILL.md`](../plugin-platform-services/SKILL.md) — Calling other platform services - [`../plugin-authz/SKILL.md`](../plugin-authz/SKILL.md) — Authorization for plugin routes
View on GitHub