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
178 changes: 178 additions & 0 deletions docs/source/http-status-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,183 @@ myst:
This is the list of HTTP status codes that are used in `plone.restapi`.
Here is a [full list of all HTTP status codes](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes).

## Error Response Format (RFC 7807)

All error responses follow [RFC 7807 (Problem Details for HTTP APIs)](https://tools.ietf.org/html/rfc7807) format.

### OpenAPI Schema

For OpenAPI/Swagger documentation, use the `RFC7807Error` schema:

```yaml
RFC7807Error:
type: object
properties:
type:
type: string
format: uri
description: A URI reference that identifies the problem type.
example: /problem-types/validation-error
title:
type: string
description: A short, human-readable summary of the problem type.
example: Bad Request
status:
type: integer
description: The HTTP status code.
example: 400
detail:
type: string
description: A human-readable explanation specific to this occurrence of the problem.
example: Login and password must be provided in body.
instance:
type: string
format: uri
description: The request path that caused the error.
example: /plone/@login
message:
type: string
description: "[DEPRECATED] Human-readable error message. Same as 'detail'. Will be removed in future releases."
example: Login and password must be provided in body.
deprecated: true
context:
type: string
format: uri
description: "[DEPRECATED] URL of the closest visible context. Will be removed in future releases."
example: https://example.com/plone
deprecated: true
error_type:
type: string
description: "[DEPRECATED] Legacy field for backwards compatibility. Will be removed in future releases."
example: Missing credentials
deprecated: true
traceback:
type: array
items:
type: string
description: "[DEPRECATED] Stack trace for debugging. Only visible to users with ManagePortal permission. Will be removed in future releases."
deprecated: true
required:
- type
- title
- status
- detail
```

### Response Fields

| Field | Type | Description |
|-------|------|-------------|
| `type` | string (URI) | A relative URI that identifies the problem type |
| `title` | string | A short, human-readable summary of the problem |
| `status` | integer | The HTTP status code |
| `detail` | string | A human-readable explanation specific to this occurrence |
| `instance` | string | The request path that caused the error |

### Backwards Compatible Fields (DEPRECATED)

For backwards compatibility, error responses also include these fields.
**They will be removed in future releases.**

| Field | Type | Description |
|-------|------|-------------|
| `message` | string | **DEPRECATED** - The error message (same as `detail`) |
| `context` | string | **DEPRECATED** - URL of the closest visible context |
| `traceback` | array | **DEPRECATED** - Stack trace (only visible to users with `ManagePortal` permission) |
| `error_type` | string | **DEPRECATED** - Legacy error type identifier |

### Backwards Compatibility Configuration

By default, deprecated fields are included in error responses for backwards compatibility.
You can disable this to get a cleaner RFC 7807-only response:

```python
from plone.restapi.problem_types import set_backwards_compat

# Disable deprecated fields in error responses
set_backwards_compat(False)

# Re-enable (default)
set_backwards_compat(True)
```

When disabled, error responses will only contain RFC 7807 fields:
- `type`, `title`, `status`, `detail`, `instance`

When enabled (default), error responses will also include:
- `message`, `context`, `error_type`, `traceback` (deprecated)

### Example Responses

**400 Bad Request (Validation Error):**

```json
{
"type": "/problem-types/validation-error",
"title": "Bad Request",
"status": 400,
"detail": "Login and password must be provided in body.",
"instance": "/plone/@login",
"message": "Login and password must be provided in body.",
"error_type": "Missing credentials"
}
```

**401 Unauthorized (Invalid Credentials):**

```json
{
"type": "/problem-types/invalid-credentials",
"title": "Unauthorized",
"status": 401,
"detail": "Wrong login and/or password.",
"instance": "/plone/@login",
"message": "Wrong login and/or password.",
"error_type": "Invalid credentials"
}
```

**403 Forbidden:**

```json
{
"type": "/problem-types/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource.",
"instance": "/plone/document",
"message": "You do not have permission to access this resource."
}
```

**404 Not Found:**

```json
{
"type": "/problem-types/resource-not-found",
"title": "Not Found",
"status": 404,
"detail": "The requested resource could not be found.",
"instance": "/plone/non-existent",
"message": "The requested resource could not be found."
}
```

### Problem Types

| Problem Type | URI | HTTP Status |
|--------------|-----|-------------|
| Validation Error | `/problem-types/validation-error` | 400 |
| Missing Credentials | `/problem-types/missing-credentials` | 400 |
| Invalid Credentials | `/problem-types/invalid-credentials` | 401 |
| Unauthorized | `/problem-types/unauthorized` | 401 |
| Forbidden | `/problem-types/forbidden` | 403 |
| Resource Not Found | `/problem-types/resource-not-found` | 404 |
| Conflict | `/problem-types/conflict` | 409 |
| Internal Error | `/problem-types/internal-error` | 500 |

## HTTP Status Codes

```{glossary}
:sorted: true

Expand Down Expand Up @@ -59,3 +236,4 @@ Here is a [full list of all HTTP status codes](https://en.wikipedia.org/wiki/Lis
500 Internal Server Error
The server failed to fulfill an apparently valid request.
```

1 change: 1 addition & 0 deletions news/165.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implement RFC 7807 Problem Details for error responses. Errors now include standardized `type`, `title`, `status`, `detail`, and `instance` fields. Exceptions are logged to stderr with full traceback. Messages are translated via i18n. Backwards compatibility maintained with `message`, `context`, and `traceback` fields. Fixes #165.
6 changes: 6 additions & 0 deletions src/plone/restapi/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,10 @@
provides="plone.restapi.interfaces.IBlockFieldLinkIntegrityRetriever"
/>

<adapter
factory=".errorhandling.ErrorHandling"
provides="zope.interface.Interface"
name="index.html"
/>

</configure>
67 changes: 67 additions & 0 deletions src/plone/restapi/errorhandling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from plone.rest.errors import ErrorHandling as BaseErrorHandling
from plone.restapi.problem_types import INTERNAL_ERROR
from plone.restapi.problem_types import STATUS_MAP
from plone.restapi.problem_types import get_backwards_compat
from plone.restapi.problem_types import translate_message

import logging
import sys
import traceback

logger = logging.getLogger("plone.restapi")


class ErrorHandling(BaseErrorHandling):
"""Extended error handling for plone.restapi.

- Logs exceptions to stderr with full traceback
- Extends plone.rest error format with RFC 7807 fields
- Optionally maintains backwards compatibility
"""

def __call__(self):
exception = self.context
self._log_exception(exception)
return super().__call__()

def _log_exception(self, exception):
"""Log exception with full traceback to stderr."""
exc_info = sys.exc_info()
tb = "".join(traceback.format_exception(*exc_info))
logger.error(
"Exception during request %s %s:\n%s",
self.request.get("METHOD"),
self.request.get("PATH_INFO"),
tb,
)

def render_exception(self, exception):
result = super().render_exception(exception)

if result is None:
return None

if get_backwards_compat():
legacy_result = dict(result)
legacy_result["message"] = translate_message(
legacy_result.get("message", ""), self.request
)
return legacy_result

status = self.request.response.getStatus()
problem_type, title = STATUS_MAP.get(
status, (INTERNAL_ERROR, "Internal Server Error")
)

message = result.get("message", "")
translated_message = translate_message(message, self.request)

error_response = {
"type": problem_type,
"title": title,
"status": status,
"detail": translated_message,
"instance": self.request.get("PATH_INFO", ""),
}

return error_response
Loading
Loading