Chore: Update API schema fields (#12611)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Trenton H
2026-04-20 11:05:00 -07:00
committed by GitHub
parent dfdf418adc
commit 5e609101d1
3 changed files with 226 additions and 22 deletions

View File

@@ -6,6 +6,12 @@ from rest_framework import status
from rest_framework.test import APITestCase
@pytest.fixture(scope="session")
def api_schema():
generator = SchemaGenerator()
return generator.get_schema(request=None, public=True)
class TestApiSchema(APITestCase):
ENDPOINT = "/api/schema/"
@@ -70,26 +76,17 @@ class TestApiSchema(APITestCase):
self.assertIn(action_method, advertised_methods)
# ---- session-scoped fixture: generate schema once for all TestXxx classes ----
@pytest.fixture(scope="session")
def api_schema():
generator = SchemaGenerator()
return generator.get_schema(request=None, public=True)
class TestTasksSummarySchema:
"""tasks_summary_retrieve: response must be an array of TaskSummarySerializer."""
def test_summary_response_is_array(self, api_schema):
def test_summary_response_is_array(self, api_schema: SchemaGenerator):
op = api_schema["paths"]["/api/tasks/summary/"]["get"]
resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"]
assert resp_200["type"] == "array", (
"tasks_summary_retrieve response must be type:array"
)
def test_summary_items_have_total_count(self, api_schema):
def test_summary_items_have_total_count(self, api_schema: SchemaGenerator):
op = api_schema["paths"]["/api/tasks/summary/"]["get"]
resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"]
items = resp_200.get("items", {})
@@ -107,14 +104,14 @@ class TestTasksSummarySchema:
class TestTasksActiveSchema:
"""tasks_active_retrieve: response must be an array of TaskSerializerV10."""
def test_active_response_is_array(self, api_schema):
def test_active_response_is_array(self, api_schema: SchemaGenerator):
op = api_schema["paths"]["/api/tasks/active/"]["get"]
resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"]
assert resp_200["type"] == "array", (
"tasks_active_retrieve response must be type:array"
)
def test_active_items_ref_named_schema(self, api_schema):
def test_active_items_ref_named_schema(self, api_schema: SchemaGenerator):
op = api_schema["paths"]["/api/tasks/active/"]["get"]
resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"]
items = resp_200.get("items", {})
@@ -122,3 +119,129 @@ class TestTasksActiveSchema:
component_name = ref.split("/")[-1] if ref else ""
assert component_name, "items should be a $ref to a named schema"
assert component_name in api_schema["components"]["schemas"]
class TestMetadataSchema:
"""Metadata component: array fields and optional archive fields."""
@pytest.mark.parametrize("field", ["original_metadata", "archive_metadata"])
def test_metadata_field_is_array(self, api_schema: SchemaGenerator, field: str):
props = api_schema["components"]["schemas"]["Metadata"]["properties"]
assert props[field]["type"] == "array", (
f"{field} should be type:array, not type:object"
)
@pytest.mark.parametrize("field", ["original_metadata", "archive_metadata"])
def test_metadata_items_have_key_field(
self,
api_schema: SchemaGenerator,
field: str,
):
props = api_schema["components"]["schemas"]["Metadata"]["properties"]
items = props[field]["items"]
ref = items.get("$ref", "")
component_name = ref.split("/")[-1] if ref else ""
if component_name:
item_props = api_schema["components"]["schemas"][component_name][
"properties"
]
else:
item_props = items.get("properties", {})
assert "key" in item_props
@pytest.mark.parametrize(
"field",
[
"archive_checksum",
"archive_media_filename",
"archive_size",
"archive_metadata",
],
)
def test_archive_field_not_required(self, api_schema, field):
schema = api_schema["components"]["schemas"]["Metadata"]
required = schema.get("required", [])
assert field not in required
props = schema["properties"]
assert props[field].get("nullable") is True, (
f"{field} should be nullable (allow_null=True)"
)
class TestStoragePathTestSchema:
"""storage_paths_test_create: response must be a string, not a StoragePath object."""
def test_test_action_response_is_string(self, api_schema: SchemaGenerator):
op = api_schema["paths"]["/api/storage_paths/test/"]["post"]
resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"]
assert resp_200.get("type") == "string", (
"storage_paths_test_create 200 response must be type:string"
)
def test_test_action_request_uses_storage_path_test_serializer(
self,
api_schema: SchemaGenerator,
):
op = api_schema["paths"]["/api/storage_paths/test/"]["post"]
content = (
op.get("requestBody", {}).get("content", {}).get("application/json", {})
)
schema_ref = content.get("schema", {}).get("$ref", "")
component_name = schema_ref.split("/")[-1]
# COMPONENT_SPLIT_REQUEST=True causes drf-spectacular to append "Request"
# to request body component names, so StoragePathTestSerializer -> StoragePathTestRequest
assert component_name == "StoragePathTestRequest", (
f"Request body should reference StoragePathTestRequest, got {component_name!r}"
)
class TestProcessedMailBulkDeleteSchema:
"""processed_mail_bulk_delete_create: response must be {result, deleted_mail_ids}."""
def _get_props(self, api_schema: SchemaGenerator):
op = api_schema["paths"]["/api/processed_mail/bulk_delete/"]["post"]
resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"]
ref = resp_200.get("$ref", "")
component_name = ref.split("/")[-1] if ref else ""
if component_name:
return api_schema["components"]["schemas"][component_name]["properties"]
return resp_200.get("properties", {})
@pytest.mark.parametrize("field", ["result", "deleted_mail_ids"])
def test_bulk_delete_response_has_field(
self,
api_schema: SchemaGenerator,
field: str,
):
props = self._get_props(api_schema)
assert field in props, f"bulk_delete 200 response must have a '{field}' field"
def test_bulk_delete_response_is_not_processed_mail_serializer(self, api_schema):
op = api_schema["paths"]["/api/processed_mail/bulk_delete/"]["post"]
resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"]
ref = resp_200.get("$ref", "")
component_name = ref.split("/")[-1] if ref else ""
assert component_name != "ProcessedMail", (
"bulk_delete 200 response must not be the full ProcessedMail serializer"
)
class TestShareLinkBundleRebuildSchema:
"""share_link_bundles_rebuild_create: 200 returns bundle data; 400 is documented."""
def test_rebuild_has_400_response(self, api_schema: SchemaGenerator):
op = api_schema["paths"]["/api/share_link_bundles/{id}/rebuild/"]["post"]
assert "400" in op["responses"], (
"rebuild must document the 400 response for 'Bundle is already being processed.'"
)
def test_rebuild_400_has_detail_field(self, api_schema: SchemaGenerator):
op = api_schema["paths"]["/api/share_link_bundles/{id}/rebuild/"]["post"]
resp_400 = op["responses"]["400"]["content"]["application/json"]["schema"]
ref = resp_400.get("$ref", "")
component_name = ref.split("/")[-1] if ref else ""
if component_name:
props = api_schema["components"]["schemas"][component_name]["properties"]
else:
props = resp_400.get("properties", {})
assert "detail" in props, "rebuild 400 response must have a 'detail' field"

View File

@@ -9,6 +9,7 @@ from collections import defaultdict
from collections import deque
from datetime import datetime
from datetime import timedelta
from http import HTTPStatus
from pathlib import Path
from time import mktime
from typing import TYPE_CHECKING
@@ -689,18 +690,49 @@ class EmailDocumentDetailSchema(EmailSerializer):
"original_mime_type": serializers.CharField(),
"media_filename": serializers.CharField(),
"has_archive_version": serializers.BooleanField(),
"original_metadata": serializers.DictField(),
"archive_checksum": serializers.CharField(),
"archive_media_filename": serializers.CharField(),
"original_metadata": serializers.ListField(
child=inline_serializer(
name="OriginalMetadataEntry",
fields={
"namespace": serializers.CharField(),
"prefix": serializers.CharField(),
"key": serializers.CharField(),
"value": serializers.CharField(),
},
),
),
"archive_checksum": serializers.CharField(
allow_null=True,
required=False,
),
"archive_media_filename": serializers.CharField(
allow_null=True,
required=False,
),
"original_filename": serializers.CharField(),
"archive_size": serializers.IntegerField(),
"archive_metadata": serializers.DictField(),
"archive_size": serializers.IntegerField(
allow_null=True,
required=False,
),
"archive_metadata": serializers.ListField(
child=inline_serializer(
name="ArchiveMetadataEntry",
fields={
"namespace": serializers.CharField(),
"prefix": serializers.CharField(),
"key": serializers.CharField(),
"value": serializers.CharField(),
},
),
allow_null=True,
required=False,
),
"lang": serializers.CharField(),
},
),
400: None,
403: None,
404: None,
HTTPStatus.BAD_REQUEST: None,
HTTPStatus.FORBIDDEN: None,
HTTPStatus.NOT_FOUND: None,
},
),
notes=extend_schema(
@@ -3528,7 +3560,17 @@ class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
return response
@extend_schema_view(**generate_object_with_permissions_schema(StoragePathSerializer))
@extend_schema_view(
**generate_object_with_permissions_schema(StoragePathSerializer),
test=extend_schema(
operation_id="storage_paths_test",
description="Test a storage path template against a document.",
request=StoragePathTestSerializer,
responses={
(HTTPStatus.OK, "application/json"): OpenApiTypes.STR,
},
),
)
class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[StoragePath]):
model = StoragePath
@@ -3985,6 +4027,19 @@ class ShareLinkViewSet(
ordering_fields = ("created", "expiration", "document")
@extend_schema_view(
rebuild=extend_schema(
operation_id="share_link_bundles_rebuild",
description="Reset and re-queue a share link bundle for processing.",
responses={
HTTPStatus.OK: ShareLinkBundleSerializer,
(HTTPStatus.BAD_REQUEST, "application/json"): inline_serializer(
name="RebuildBundleError",
fields={"detail": serializers.CharField()},
),
},
),
)
class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
model = ShareLinkBundle

View File

@@ -1,6 +1,7 @@
import datetime
import logging
from datetime import timedelta
from http import HTTPStatus
from typing import Any
from django.http import HttpResponseBadRequest
@@ -160,6 +161,31 @@ class MailAccountViewSet(PassUserMixin, ModelViewSet[MailAccount]):
return Response({"result": "OK"})
@extend_schema_view(
bulk_delete=extend_schema(
operation_id="processed_mail_bulk_delete",
description="Delete multiple processed mail records by ID.",
request=inline_serializer(
name="BulkDeleteMailRequest",
fields={
"mail_ids": serializers.ListField(child=serializers.IntegerField()),
},
),
responses={
(HTTPStatus.OK, "application/json"): inline_serializer(
name="BulkDeleteMailResponse",
fields={
"result": serializers.CharField(),
"deleted_mail_ids": serializers.ListField(
child=serializers.IntegerField(),
),
},
),
HTTPStatus.BAD_REQUEST: None,
HTTPStatus.FORBIDDEN: None,
},
),
)
class ProcessedMailViewSet(PassUserMixin, ReadOnlyModelViewSet[ProcessedMail]):
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
serializer_class = ProcessedMailSerializer