Skip to content

Commit 6077d32

Browse files
committed
feat(grpc): enforce service policy, add presigned_url target, strongly-typed status enums
- Enforce the same ServicePolicy as REST on every RPC (allowed_target_types, max_sources_per_request, S3 pairing, presigned-url gating, OCR presets, image export modes); violations abort with INVALID_ARGUMENT - Add PreSignedUrlTarget to the Target oneof with REST-parity restrictions - Replace string status/component_type with ConversionStatus and DoclingComponentType enums plus *_raw forward-compat companions - Sanitize unhandled errors via PublicErrorInterceptor, mirroring REST's debug_error_details behavior; full tracebacks stay server-side - Document batch-convert stance and policy/error behavior in docs/grpc - buf lint + buf format -w; regenerate stubs; add policy, sanitization, and enum-drift tests Signed-off-by: Kristian Rickert <krickert@gmail.com>
1 parent a08448e commit 6077d32

9 files changed

Lines changed: 806 additions & 128 deletions

File tree

docling_serve/grpc/gen/ai/docling/serve/v1/docling_serve_types_pb2.py

Lines changed: 98 additions & 92 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docling_serve/grpc/mapping.py

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
S3Target,
3535
ZipTarget,
3636
)
37+
from docling.datamodel.service.targets import PresignedUrlTarget
3738

3839
from docling_serve.datamodel.convert import ConvertDocumentsRequestOptions
3940
from docling_serve.settings import docling_serve_settings
@@ -293,6 +294,8 @@ def to_task_target(proto_target: Optional[docling_serve_types_pb2.Target]):
293294
key_prefix=s3_tgt.key_prefix if s3_tgt.HasField("key_prefix") else "",
294295
verify_ssl=s3_tgt.verify_ssl,
295296
)
297+
if kind == "presigned_url":
298+
return PresignedUrlTarget()
296299
return InBodyTarget()
297300

298301

@@ -564,15 +567,65 @@ def _docling_document_to_proto(doc) -> docling_document_pb2.DoclingDocument:
564567
return docling_document_to_proto(doc)
565568

566569

570+
_COMPONENT_TYPE_TO_PROTO = {
571+
"document_backend": (
572+
docling_serve_types_pb2.DoclingComponentType.DOCLING_COMPONENT_TYPE_DOCUMENT_BACKEND
573+
),
574+
"model": docling_serve_types_pb2.DoclingComponentType.DOCLING_COMPONENT_TYPE_MODEL,
575+
"doc_assembler": (
576+
docling_serve_types_pb2.DoclingComponentType.DOCLING_COMPONENT_TYPE_DOC_ASSEMBLER
577+
),
578+
"user_input": (
579+
docling_serve_types_pb2.DoclingComponentType.DOCLING_COMPONENT_TYPE_USER_INPUT
580+
),
581+
"pipeline": (
582+
docling_serve_types_pb2.DoclingComponentType.DOCLING_COMPONENT_TYPE_PIPELINE
583+
),
584+
}
585+
586+
_CONVERSION_STATUS_TO_PROTO = {
587+
"pending": docling_serve_types_pb2.ConversionStatus.CONVERSION_STATUS_PENDING,
588+
"started": docling_serve_types_pb2.ConversionStatus.CONVERSION_STATUS_STARTED,
589+
"failure": docling_serve_types_pb2.ConversionStatus.CONVERSION_STATUS_FAILURE,
590+
"success": docling_serve_types_pb2.ConversionStatus.CONVERSION_STATUS_SUCCESS,
591+
"partial_success": (
592+
docling_serve_types_pb2.ConversionStatus.CONVERSION_STATUS_PARTIAL_SUCCESS
593+
),
594+
"skipped": docling_serve_types_pb2.ConversionStatus.CONVERSION_STATUS_SKIPPED,
595+
}
596+
597+
598+
def _conversion_status_enum_and_raw(status) -> tuple[int, Optional[str]]:
599+
"""Map a docling ConversionStatus (or string) to proto enum + raw fallback.
600+
601+
Follows the *_raw discriminator contract: a recognized value sets only the
602+
enum tag; an unrecognized value sets UNSPECIFIED and carries the original
603+
string in the raw companion.
604+
"""
605+
value = getattr(status, "value", status)
606+
value = str(value)
607+
enum_val = _CONVERSION_STATUS_TO_PROTO.get(value)
608+
if enum_val is None:
609+
return (
610+
docling_serve_types_pb2.ConversionStatus.CONVERSION_STATUS_UNSPECIFIED,
611+
value,
612+
)
613+
return enum_val, None
614+
615+
567616
def _error_item_to_proto(error) -> docling_serve_types_pb2.ErrorItem:
568-
component = error.component_type
569-
if hasattr(component, "value"):
570-
component = component.value
571-
return docling_serve_types_pb2.ErrorItem(
572-
component_type=str(component),
617+
component = getattr(error.component_type, "value", error.component_type)
618+
component = str(component)
619+
message = docling_serve_types_pb2.ErrorItem(
573620
error_message=error.error_message,
574621
module_name=error.module_name,
575622
)
623+
enum_val = _COMPONENT_TYPE_TO_PROTO.get(component)
624+
if enum_val is None:
625+
message.component_type_raw = component
626+
else:
627+
message.component_type = enum_val
628+
return message
576629

577630

578631
def _timings_to_proto(timings: dict[str, ProfilingItem]) -> dict[str, float]:
@@ -641,16 +694,16 @@ def convert_result_to_proto(
641694
processing_time: float,
642695
requested_formats: Optional[set[OutputFormat]] = None,
643696
) -> docling_serve_types_pb2.ConvertDocumentResponse:
644-
status = result.status
645-
if hasattr(status, "value"):
646-
status = status.value
697+
status_enum, status_raw = _conversion_status_enum_and_raw(result.status)
647698
response = docling_serve_types_pb2.ConvertDocumentResponse(
648699
document=document_response_to_proto(result.content, requested_formats),
649700
errors=[_error_item_to_proto(err) for err in result.errors],
650701
processing_time=processing_time,
651-
status=str(status),
702+
status=status_enum,
652703
timings=_timings_to_proto(result.timings),
653704
)
705+
if status_raw is not None:
706+
response.status_raw = status_raw
654707
return response
655708

656709

@@ -679,17 +732,16 @@ def chunk_result_to_proto(
679732

680733
documents = []
681734
for doc in result.documents:
682-
status = doc.status
683-
if hasattr(status, "value"):
684-
status = status.value
685-
documents.append(
686-
docling_serve_types_pb2.Document(
687-
kind=doc.kind,
688-
content=export_document_to_proto(doc.content, requested_formats),
689-
status=str(status),
690-
errors=[_error_item_to_proto(err) for err in doc.errors],
691-
)
735+
status_enum, status_raw = _conversion_status_enum_and_raw(doc.status)
736+
document = docling_serve_types_pb2.Document(
737+
kind=doc.kind,
738+
content=export_document_to_proto(doc.content, requested_formats),
739+
status=status_enum,
740+
errors=[_error_item_to_proto(err) for err in doc.errors],
692741
)
742+
if status_raw is not None:
743+
document.status_raw = status_raw
744+
documents.append(document)
693745

694746
return docling_serve_types_pb2.ChunkDocumentResponse(
695747
chunks=chunks,
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Bridge between the shared service policy and gRPC requests.
2+
3+
The REST layer enforces ``docling_serve.policy`` on Pydantic request models.
4+
gRPC builds sources/options/target separately (it never constructs a
5+
``ConvertSourcesRequest``), so this module applies the same rules to those
6+
parts. The option- and target-kind validators are reused directly from
7+
``policy.py``; only the request-shape rules (source count, S3 pairing,
8+
presigned-target restrictions) are mirrored here because they are expressed
9+
against REST request models upstream. ``policy.py`` remains the source of
10+
truth — when its rules change, this bridge must follow.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from typing import Optional
16+
17+
from fastapi import HTTPException
18+
19+
from docling.datamodel.service.options import ConvertDocumentsOptions
20+
from docling.datamodel.service.targets import PresignedUrlTarget
21+
from docling_jobkit.datamodel.s3_coords import S3Coordinates
22+
from docling_jobkit.datamodel.task_targets import S3Target
23+
24+
from docling_serve.policy import (
25+
ServicePolicy,
26+
normalize_convert_options,
27+
validate_convert_options,
28+
validate_target_kind,
29+
)
30+
31+
32+
def normalize_options(
33+
options: ConvertDocumentsOptions, policy: ServicePolicy
34+
) -> ConvertDocumentsOptions:
35+
"""Apply policy defaults (e.g. document_timeout) like the REST layer does."""
36+
return normalize_convert_options(options, policy)
37+
38+
39+
def validate_request(
40+
sources: list,
41+
options: ConvertDocumentsOptions,
42+
target,
43+
policy: ServicePolicy,
44+
*,
45+
chunk: bool = False,
46+
) -> Optional[str]:
47+
"""Validate a gRPC request against the service policy.
48+
49+
Returns an error detail string when the request violates policy,
50+
or None when it is allowed.
51+
"""
52+
try:
53+
validate_convert_options(options, policy)
54+
validate_target_kind(target.kind, policy)
55+
except HTTPException as exc:
56+
return str(exc.detail)
57+
58+
if len(sources) > policy.max_sources_per_request:
59+
return (
60+
f"Too many sources: {len(sources)} exceeds the "
61+
f"maximum of {policy.max_sources_per_request}."
62+
)
63+
64+
if isinstance(target, PresignedUrlTarget):
65+
if chunk:
66+
return "presigned_url target is not supported for chunk endpoints."
67+
if not policy.artifact_storage_enabled:
68+
return (
69+
"Presigned URL target requires artifact storage to be configured "
70+
"and enabled on the server."
71+
)
72+
73+
has_s3_source = any(isinstance(source, S3Coordinates) for source in sources)
74+
has_s3_target = isinstance(target, S3Target)
75+
76+
if has_s3_source:
77+
if not policy.s3_enabled:
78+
return 'source kind "s3" requires engine kind "KFP".'
79+
if not has_s3_target:
80+
return 'source kind "s3" requires target kind "s3".'
81+
82+
if has_s3_target and not has_s3_source:
83+
return 'target kind "s3" requires source kind "s3".'
84+
85+
return None

0 commit comments

Comments
 (0)