Guidelines for backend development in the NimbusImage Girder plugin including API patterns, access control, database queries, testing, and Docker development.
| Value | Constant | Meaning |
|---|---|---|
| -1 | (none) | No access / Remove access |
| 0 | AccessType.READ |
View-only access |
| 1 | AccessType.WRITE |
Edit access |
| 2 | AccessType.ADMIN |
Owner — can manage access (share, set public, delete) |
Important: AccessType.ADMIN means owner of that document, not a site-wide admin. The creator of a project/dataset gets ADMIN on it and can share it with others.
Use -1 (not null) to remove a user's access.
from girder.api import access
@access.public # Anyone can access
@access.user # Requires authenticated user
@access.admin # Requires site-wide Girder admin (NOT document owner)
Note: @access.admin (decorator) and AccessType.ADMIN (document level) are different. The decorator requires site-wide admin; the access level means document owner.
from girder.constants import AccessType
doc = Model().load(id, user=user, level=AccessType.WRITE, exc=True)
doc = Model().load(id, force=True) # Admin bypass
For detailed access patterns including sharing: read references/access-control-patterns.md
When modifying sharing/access code: read codebaseDocumentation/SHARING.md
Always use modelParam when accepting IDs that reference resources requiring access control:
# Good - validates existence AND checks WRITE access
.modelParam('datasetId', model=Folder, level=AccessType.WRITE,
destName='dataset', paramType='formData')
# Bad - no validation, no access check
.param('datasetId', 'Dataset ID to add.', paramType='formData')
Use .param() only for simple string/number values, enums, or search filters.
| Resource | Plugin Model | NOT Girder's |
|---|---|---|
| Datasets | Folder (Girder) |
- |
| Collections/Configs | Collection (plugin) |
Item |
| Projects | Project (plugin) |
- |
| Annotations | Annotation (plugin) |
- |
| Dataset Views | DatasetView (plugin) |
- |
# Good - uses plugin's Collection model
from upenncontrast_annotation.server.models.collection import Collection
.modelParam('collectionId', model=Collection, level=AccessType.WRITE, ...)
# Bad - Girder's Item model won't find plugin collections!
from girder.models.item import Item
.modelParam('collectionId', model=Item, level=AccessType.WRITE, ...)
Always use Model().find(), never Model().collection.find():
docs = list(MyModel().find({'_id': {'$in': list(ids)}}))
# With field projection
users = list(User().find(
{'_id': {'$in': userIds}},
fields=['email', 'login']
))
For detailed query patterns: read references/database-query-patterns.md
class MyResource(Resource):
def __init__(self):
super().__init__()
self.resourceName = "my_resource"
self.route("GET", (":id",), self.get)
self.route("POST", (), self.create)
self.route("PUT", (":id",), self.update)
self.route("DELETE", (":id",), self.delete)
self.route("GET", (), self.find)
@access.user
@autoDescribeRoute(
Description("Create a new thing")
.notes("Detailed explanation.")
.jsonParam("body", "Request body", paramType="body",
schema={...}, required=True)
.errorResponse("ID was invalid.")
.errorResponse("Write access denied.", 403)
)
def create(self, body):
...
@access.user
@autoDescribeRoute(
Description("Bulk create items (READ OPERATION via POST)")
.notes("Uses POST to avoid URL length limits")
.jsonParam("body", "Array of items", paramType="body")
)
def createBulk(self, body):
items = body.get('items', [])
return [self._model.create(item) for item in items]
For an endpoint that just needs the JSON body, use a plain signature and
call getBodyJson() directly (pattern: datasetView.py::create):
@describeRoute(
Description("...").param("body", "...", paramType="body")
)
def create(self, params):
doc = self.getBodyJson()
Do NOT write def handler(self, params, *args, **kwargs) +
@memoizeBodyJson + kwargs["memoizedBodyJson"] for such endpoints.
@memoizeBodyJson exists for exactly one case: the endpoint is also
@recordable and its findDatasetIdFn needs the body (so the body
is parsed once and shared between the decorator and the handler). If the
@recordable finder reads the loaded model instead (e.g.
getDatasetIdFromLoadedAnnotation), or there is no @recordable,
memoizing is pointless — reviewers flag it (Paul, PR #1203).
Model() construction returns a cached instance (_ModelSingleton
metaclass in girder's model_base). Never hand-roll lazy caching
(getattr(self, "_cache", None) properties) around a model — just
assign it in __init__:
self._pvModel = AnnotationPropertyValues() # cheap: cached singleton
MAX_*) at the top of the class definition, not between methods.$count aggregation output fields count (not n) so pipeline
results are self-describing when debugging.$addFields/$cond/$ifNull stages with what the
stage computes and why (e.g. "$ifNull maps missing → null so one
$ne-null test catches absent and null").from bson import ObjectId
query = {'_id': ObjectId(string_id)}
query = {'_id': {'$in': [ObjectId(id) for id in string_ids]}}
Note: Model().load() handles ObjectId conversion internally.
The API and model layers have strict responsibilities. Never mix them.
server/api/*.py)RestException for HTTP error responsesserver/models/*.py)ValueError or ValidationException — never RestException# Good - API handles input, model handles logic
# In server/api/annotation.py
def update(self, annotation, body):
tag_ids = [ObjectId(t) for t in body.get('tags', [])]
return AnnotationModel().updateTags(annotation, tag_ids)
# In server/models/annotation.py
def updateTags(self, annotation, tag_ids):
if not tag_ids:
raise ValueError("At least one tag required")
# ... business logic
# Bad - model raising HTTP exceptions
# In server/models/annotation.py
def updateTags(self, annotation, body):
if 'tags' not in body:
raise RestException("tags required", 400) # WRONG - HTTP in model
from girder.exceptions import (
RestException, ValidationException, AccessException
)
# API layer - HTTP errors
raise RestException("Bad request message", code=400)
# Model layer - domain errors
raise ValidationException("Field X is invalid")
raise ValueError("Invalid state")
# Either layer - access errors
raise AccessException("Permission denied")
except Exception: or bare except: — too broad, swallows system errorsexcept bson.errors.InvalidId:)ObjectId() conversion)bson.errors.InvalidId's MRO is InvalidId → BSONError → Exception — except ValueError does not catch it. ObjectId("notanobjectid") raises InvalidId; ObjectId(123) raises TypeError. When mapping malformed caller-supplied ids to a clean 400, catch InvalidId explicitly at the API boundary:
from bson.errors import InvalidId
try:
obj_ids = [ObjectId(s) for s in raw_ids]
except InvalidId:
raise RestException("Invalid annotation id", 400)
Convert ids once at the API boundary and pass ObjectIds down — don't convert deep in a model/aggregation where the failure surfaces as a 500.
This is the single most-recurring review finding in this plugin: an endpoint calls .get() / len() / int() / indexes request data without first checking its type, so a malformed payload (JSON-array body, filters.tags: "bad", scalar annotationIds, non-string datasetId, oversized limit) raises an uncaught AttributeError/TypeError → 500 instead of a clean 400 — and unbounded limits let callers force huge DB/serialization work. Applies to @access.user endpoints too, not only @access.public: 400-not-500 is the house style regardless of auth.
Use the shared validators in server/helpers/validation.py (added by PR #1203). Do NOT hand-roll inline isinstance guards for new/edited endpoints — call the helpers, which raise RestException(code=400) at the boundary. Real example, server/api/dataImport.py::importData:
from ..helpers.validation import (
requireObjectBody, requireList, requireObjectId,
)
body = requireObjectBody(kwargs["memoizedBodyJson"])
datasetId = requireObjectId(body.get("datasetId"), "datasetId")
annotations = requireList(body.get("annotations", []), "annotations")
propertyValues = requireObjectBody(body.get("propertyValues", {}), "propertyValues")
Match each kind of request-data access to its helper:
| Access to guard | Helper |
|---|---|
.get() on a body / nested object |
requireObjectBody(value, name) → dict-or-400 |
len() / iteration on a field |
requireList(value, field) → list-or-400 |
ObjectId(id) on caller-supplied ids |
requireObjectId(value, field) → ObjectId-or-400 (handles None, InvalidId, AND non-string TypeError) |
int(param) on a query param |
requireInt(value, field) → int-or-400 |
| unbounded counts | requireCountWithin(count, limit, name) / module-level MAX_* consts (MAX_ANNOTATION_IDS, MAX_LIST_LIMIT, ...) read at call time (monkeypatchable in tests) |
| filter / sort / propertyPaths shape | validateListInputs(...), validatePropertyPaths(...), validateUncomputedCountsProperties(...) |
requireObjectId catches TypeError as well as InvalidId — a non-string id like {"datasetId": 123} is a clean 400, not a 500 (see the bson section above).
Rules: validation and RestException live in the API layer, never in models. Validate NESTED elements, not just the top-level container — each list entry ([123]) and nested map (propertyValues: {"a1": 5}) is caller-supplied; .get()/.items() on a non-dict entry → 500. Add a backend test per malformed-input case (malformed body → 400, not 500); test/test_validation.py unit-tests the helpers directly, and endpoint tests assert the 400. When you fix one endpoint, sweep the other endpoints in the same file for the identical gap — reviewers flag one instance per round.
assertStatus(resp, 400) alone is not a regression test for input validation. These endpoints have other 400 paths — a missing datasetId, an unknown dataset id, a failed schema validation — so a malformed-body test can pass while the body is never validated at all. Observed for real: a /upenn_annotation/compute test using a syntactically valid but nonexistent datasetId passed before its fix, because the model's dataset lookup rejected the request first.
Two habits close it:
assert "must be a JSON object" in resp.json["message"]), and set up the request so the code actually reaches the validation — use a real utilities.createFolder(...) dataset when the handler looks one up before touching the body.git stash push <source files> and confirm the test fails, leaving the new test file in place (untracked files aren't stashed). A malformed-input test that passes both ways is worse than none.Shape validation stops 500s. It does not stop one valid request from exhausting the process. PR #1302 took three consecutive review rounds finding instances of this one class, so check it deliberately.
For every public endpoint, enumerate the dimensions that multiply, and bound each one — plus their product where the product is what costs:
| dimension | why a per-item cap is not enough |
|---|---|
| items × per-item work | 100 plots × a full-dataset coordinate build = ~130 s of CPU from one request |
| a product cap alone | a 512×512 cell budget still allows one axis with 262,144 categories when the other collapses to 1 bin |
| inner-loop length × collection size | points_in_polygon does one full-length numpy pass PER VERTEX: 10,000 vertices × 708K points ≈ 10 s per gate, and no DB timeout covers Python work |
| response size | an unbounded id response is ~380 MB of JSON that lands on Girder and the browser |
| client concurrency | one request per plot = N concurrent full-dataset scans; serialize or pool them |
Three rules that each came from a real finding:
ValueError → API maps to 400).null, and the changed-input path had already cleared state — every
gate stopped filtering with no path to recovery.Pick limits from measurement, not intuition. A "whichever is smaller"
rule for $in vs $nin looked obviously right and lost time near the
crossover, because $nin costs ~1.4× per element. Time both and put the
table in the comment.
When a request dict is validated at the boundary and then written to by internal code, an allowlist is not enough — the validator must remove keys it does not own. Two things conspire:
setdefault(...) / .get(...) or [], which
appends to a client-supplied value instead of replacing it.On PR #1302 filters["gateMatchClauses"] was internal — the gate resolver
wrote it and the pipeline builder spliced its contents straight into
$match.$and. A client could set it on three @access.public endpoints:
# Uncaught 500: andClauses += "x" -> {"$and": ["x"]} -> OperationFailure
{"filters": {"gateMatchClauses": "x"}}
# Arbitrary operator ANDed into the dataset match, on a public endpoint
{"filters": {"gateMatchClauses": [{"tags": {"$regex": "(a+)+$"}}]}}
Rules:
filters.pop("gateMatchClauses", None). Stripping (not
rejecting) is right for a key that is not part of the client-facing
shape — the request simply ignores it._buildListMatchStages) looks innocuous; the bug lives in the fact that
the same dict has two authors. Search for setdefault, .get(x) or [],
and dict[...] = against any name that also reaches a request body.The girder container bakes the plugin into its image (no source mount). After editing backend plugin code:
docker compose restart girder does NOT load the change — new routes return No matching route while old ones work.docker compose build girder && docker compose up -d girder (fast — cached layers; girder is back in ~7s).tox runs against plugin source, so tests pass even when the live :8080 API is stale. Always rebuild before verifying endpoints with curl or the browser.WiredTiger largely no-ops a $set that writes the value a document already
holds. So a benchmark loop that repeats the same operation measures real work
on its first iteration and near-nothing afterwards — and a median over those
runs is meaningless. This actively misled a real optimization: repeated
identical colorings made the write path look 2.6s when it is ~5s, which
pointed the work at the read path while 80% of the request was writes.
Two rules for any write-path measurement:
print(...) in the model,
read back with docker logs girder, is enough (girder's logprint is not
importable from girder and its logger's INFO does not reach stdout).Also worth knowing before reaching for a clever pipeline: a server-side
$merge that computes the new value and merges it into the target collection —
no ids crossing the wire, no separate clearing pass — measured 12.6s against
~4.5s for a plain batched bulk_write of UpdateMany ops. Measure it before
assuming "push it into the database" is faster.
import logging
logger = logging.getLogger(__name__)
logprint.info("Informational message")
logprint.warning("Warning message")
logprint.error(f"Error: {details}")
from girder_jobs.constants import JobStatus
# JobStatus values:
# INACTIVE = 0 (not yet scheduled)
# QUEUED = 1 (waiting to run)
# RUNNING = 2 (currently executing)
# SUCCESS = 3 (completed successfully)
# ERROR = 4 (failed)
# CANCELED = 5 (cancelled)
Warning: Status 3 means SUCCESS, not "running". This is a common source of confusion.
Frontend equivalent: src/store/jobConstants.ts (jobStates.success === 3).
For tasks that run inside the Girder process (not via Girder Worker/Celery), use createLocalJob. The target module must define a run(job) function.
from girder_jobs.models.job import Job as JobModel
job = JobModel().createLocalJob(
module='upenncontrast_annotation.server.helpers.zenodo_job',
title='My Job',
type='my_job_type',
user=user,
kwargs={'projectId': str(project['_id'])},
asynchronous=True,
)
JobModel().scheduleJob(job)
Job titles are user-visible: they are listed in Settings → Jobs & Logs and quoted in the frontend's job notifications (src/store/jobs.ts). A title that doesn't identify the work is a support burden — issue #1294 was a girder_job_title defaulting to the literal "unknown" for worker interface requests (containers named unknown_None_<ts>), which users saw appear right before their segmentation run with no way to tell the two apart.
Rules when adding a job, or a helper that creates jobs:
params.get("name")), fall back to the request/job type, never to "unknown". Check every caller — one caller omitting the field is how the placeholder reaches production.[a-zA-Z0-9_.-], so sanitizing shared text costs the title its spaces, / and :. runJobRequest takes an explicit jobTitle for this reason (server/helpers/tasks.py).None into a name. Join only the parts that exist — datasetId is absent for interface requests.re.findall on an int is a 500; guard with isinstance(name, str).girder-claude-chat/girder_claude_chat/help/troubleshooting.md, so the assistant can answer "what is this job?" instead of guessing.Jobs report progress through Job().updateJob() which emits SSE events:
from girder_jobs.models.job import Job
job_model = Job()
job_model.updateJob(
job,
status=JobStatus.RUNNING,
log='Progress message\n', # Sent via SSE
)
# Terminal state:
job_model.updateJob(job, status=JobStatus.SUCCESS)
The frontend subscribes to job SSE events via src/store/jobs.ts. Log entries can be JSON strings for structured progress data.
For detailed testing patterns beyond basics: read references/testing-patterns.md
Testing basics (running tox, test structure, linting): see CLAUDE.md
codebaseDocumentation/SHARING.mdcodebaseDocumentation/PROJECTS.md