Skip to content

Add typed exception subclasses for the JSON API error envelope - #96

Open
ramonski wants to merge 2 commits into
refactor/api-serializationfrom
refactor/typed-exceptions
Open

Add typed exception subclasses for the JSON API error envelope#96
ramonski wants to merge 2 commits into
refactor/api-serializationfrom
refactor/typed-exceptions

Conversation

@ramonski

Copy link
Copy Markdown
Contributor

Depends on senaite/senaite.core#2998

The type field in the JSON error envelope this PR populates only reaches the client after #2998 lands (it fixes bika.lims.jsonapi.handle_errors to include the exception class name in the response body). Without #2998, the HTTP status changes here are visible but the type field is discarded by the current error handler. Merge #2998 first.

Description of the issue/feature this PR addresses

Every route in senaite.jsonapi used api.fail(status, msg) with a magic number for the HTTP status. That is fine when there is one call site, but with 8+ different failure modes across the routes it becomes hard to review whether the right status is used, and clients cannot distinguish "not found" from "unauthorized" from a validation error without parsing the free-form message text.

What this PR does

Introduces six typed subclasses of APIError in senaite.jsonapi.exceptions:

Class Status Use for
BadRequestError 400 malformed payload, missing required field
UnauthorizedError 401 caller is anonymous
ForbiddenError 403 caller is authenticated but lacks permission
NotFoundError 404 addressed resource does not exist
ConflictError 409 request conflicts with current state
ValidationError 422 payload well-formed but semantically invalid

Each subclass carries its own default HTTP status; the previous APIError(status, message) positional signature becomes APIError(message, status=None) so typed subclasses can be raised as raise NotFoundError("...") without repeating the status number.

Converts every api.fail() and raw APIError() call site inside senaite.jsonapi itself to the typed form (see commit for the full list). One tightening worth calling out:

  • v1/routes/push.py used api.fail(500, ...) for every failure mode, mixing "no data sent" (client error) with "consumer raised an exception" (server error) under one status. Now split: BadRequestError for missing/malformed input, NotFoundError for a missing consumer, APIError(..., status=500) only for consumer-side exceptions. Route-shape update in push.rst reflects the 404 for the non-registered adapter case.
  • v1/routes/content.py action route used api.fail(500, "API has no member named X") for unknown action names. That is a 4xx (caller asked for something we don't offer), now BadRequestError.

Backward compatibility

  • All typed errors inherit APIError. Any except APIError: handler catches every subclass.
  • api.fail(status, msg) still works and still raises plain APIError with the runtime status. Downstream callers do not have to change.
  • APIError.setStatus(x) alias retained for the same reason.
  • Route responses keep the success: false, message: ... envelope; the new type field is additive (once #2998 lands).

Coverage

New doctest tests/doctests/typed_exceptions.rst:

  • Every typed exception subclasses APIError (so existing catch-all still works)
  • Each subclass carries the expected default HTTP status
  • Instantiation preserves the message and class-level status
  • Explicit status= kwarg overrides the class default
  • APIError accepts status= for the legacy api.fail path
  • End-to-end HTTP: anon /registry returns 401, non-Manager /registry returns 403, unknown resource returns 404

Existing doctests (read, create, push, search, users, login, bearer, api_settings, api_serialization, security_fixes, …) still pass. 20 total, 0 failures.

Base branch

Stacked on top of #95 (refactor/api-serialization). Merge order: #92#93#94#95#96. Also blocks on senaite/senaite.core#2998 for the type field to reach the client.

Verify

bin/test-senaite -s senaite.jsonapi
# Ran 20 tests with 0 failures, 0 errors and 0 skipped

--
I confirm I have tested the PR thoroughly and coded it according to PEP8 standards.

ramonski added 2 commits July 25, 2026 12:52
Introduce six typed subclasses of APIError so route code can raise a
specific error class instead of calling api.fail(status, msg) with a
magic number:

  400  BadRequestError
  401  UnauthorizedError
  403  ForbiddenError
  404  NotFoundError
  409  ConflictError
  422  ValidationError

Each subclass carries its own default HTTP status; the previous
APIError(status, message) positional signature becomes
APIError(message, status=None) so typed subclasses can be raised as
raise NotFoundError("...") without repeating the status number.

Backward compatibility:

- All typed errors inherit APIError. Any existing except APIError:
  handler catches every subclass.
- api.fail(status, msg) still works and still raises APIError with the
  runtime status. Downstream callers do not have to change.
- APIError.setStatus(x) alias retained for the same reason.

Converts every api.fail() and raw APIError() call site inside
senaite.jsonapi itself to the typed form:

- request.get_request_data: BadRequestError (was APIError(400))
- v1/routes/content.get: NotFoundError for unknown resource
- v1/routes/content.action: BadRequestError for unknown API member
  (was api.fail(500), which was misleading: the client asked for an
   unknown action, that is a 4xx, not a 5xx)
- v1/routes/push: BadRequest/Unauthorized/NotFound as appropriate
  (was api.fail(500) for every failure mode, mixing client errors
   with server errors under one status)
- v1/routes/users.login: UnauthorizedError (was api.fail(401))
- api.check_permission: Unauthorized/ForbiddenError

Route-shape update for push.rst: the "non-registered adapter" case
now returns 404 (correct: no consumer with that name is registered),
where it previously returned 500.

Depends on senaite/senaite.core#2998 for the JSON error envelope to
actually surface the exception class name in the response body as a
'type' field. Without #2998, only the HTTP status changes are
visible; the type field is discarded by the current handle_errors
decorator.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Enhancement ✨ Improvement to existing functionality

Development

Successfully merging this pull request may close these issues.

1 participant