Skip to content

Commit df9491f

Browse files
Merge pull request #26 from datalogics-kam/kam-pdfcloud-5464-sign
PDFCLOUD-5556 Add digital signature support
2 parents bad764a + 6d004b8 commit df9491f

13 files changed

Lines changed: 2084 additions & 1 deletion

AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@
7171
- Avoid `@field_validator` on payload models. Prefer existing `BeforeValidator`
7272
helpers (e.g., `_allowed_mime_types`) so validation remains declarative and
7373
consistent across schemas.
74+
- In Pydantic validators, raise `ValueError`/`AssertionError` (or
75+
`PydanticCustomError` when needed), not `TypeError`, so callers consistently
76+
receive `ValidationError` surfaces.
7477
- Keep user-facing `PdfRestClient` and `AsyncPdfRestClient` endpoint helpers
7578
thin: they should primarily assemble payload dicts and delegate validation to
7679
payload models (`model_validate`). Avoid duplicating payload validation in
@@ -128,6 +131,10 @@
128131
helpers should pass sequences through without converting to raw IDs manually.
129132
- When a payload accepts uploaded content, validate MIME types via
130133
`_allowed_mime_types` to surface clear errors before making the request.
134+
- Payload models should mirror pdfRest's request layout field-for-field. Do not
135+
use `@model_serializer` on payload models. If callers need a friendlier input
136+
shape, use `@model_validator(mode="before")` to map inputs onto the existing
137+
pdfRest fields, and keep any wire formatting in field serializers.
131138
- When an endpoint expects JSON-encoded structures (e.g., arrays of redaction
132139
rules), expose typed arguments (TypedDicts, Literals, etc.) via
133140
`pdfrest.types` and let the payload serializer produce the JSON string for the

src/pdfrest/client.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@
111111
PdfRedactionPreviewPayload,
112112
PdfRestRawFileResponse,
113113
PdfRestrictPayload,
114+
PdfSignPayload,
114115
PdfSplitPayload,
115116
PdfTextWatermarkPayload,
116117
PdfToExcelPayload,
@@ -156,6 +157,8 @@
156157
PdfRedactionInstruction,
157158
PdfRestriction,
158159
PdfRGBColor,
160+
PdfSignatureConfiguration,
161+
PdfSignatureCredentials,
159162
PdfTextColor,
160163
PdfXType,
161164
PngColorModel,
@@ -3212,6 +3215,42 @@ def add_attachment_to_pdf(
32123215
timeout=timeout,
32133216
)
32143217

3218+
def sign_pdf(
3219+
self,
3220+
file: PdfRestFile | Sequence[PdfRestFile],
3221+
*,
3222+
signature_configuration: PdfSignatureConfiguration,
3223+
credentials: PdfSignatureCredentials,
3224+
logo: PdfRestFile | Sequence[PdfRestFile] | None = None,
3225+
output: str | None = None,
3226+
extra_query: Query | None = None,
3227+
extra_headers: AnyMapping | None = None,
3228+
extra_body: Body | None = None,
3229+
timeout: TimeoutTypes | None = None,
3230+
) -> PdfRestFileBasedResponse:
3231+
"""Digitally sign a PDF using PFX credentials or a certificate/private key."""
3232+
3233+
payload: dict[str, Any] = {
3234+
"files": file,
3235+
"signature_configuration": signature_configuration,
3236+
"credentials": credentials,
3237+
}
3238+
3239+
if logo is not None:
3240+
payload["logo"] = logo
3241+
if output is not None:
3242+
payload["output"] = output
3243+
3244+
return self._post_file_operation(
3245+
endpoint="/signed-pdf",
3246+
payload=payload,
3247+
payload_model=PdfSignPayload,
3248+
extra_query=extra_query,
3249+
extra_headers=extra_headers,
3250+
extra_body=extra_body,
3251+
timeout=timeout,
3252+
)
3253+
32153254
def blank_pdf(
32163255
self,
32173256
*,
@@ -5109,6 +5148,42 @@ async def add_attachment_to_pdf(
51095148
timeout=timeout,
51105149
)
51115150

5151+
async def sign_pdf(
5152+
self,
5153+
file: PdfRestFile | Sequence[PdfRestFile],
5154+
*,
5155+
signature_configuration: PdfSignatureConfiguration,
5156+
credentials: PdfSignatureCredentials,
5157+
logo: PdfRestFile | Sequence[PdfRestFile] | None = None,
5158+
output: str | None = None,
5159+
extra_query: Query | None = None,
5160+
extra_headers: AnyMapping | None = None,
5161+
extra_body: Body | None = None,
5162+
timeout: TimeoutTypes | None = None,
5163+
) -> PdfRestFileBasedResponse:
5164+
"""Digitally sign a PDF using PFX credentials or a certificate/private key."""
5165+
5166+
payload: dict[str, Any] = {
5167+
"files": file,
5168+
"signature_configuration": signature_configuration,
5169+
"credentials": credentials,
5170+
}
5171+
5172+
if logo is not None:
5173+
payload["logo"] = logo
5174+
if output is not None:
5175+
payload["output"] = output
5176+
5177+
return await self._post_file_operation(
5178+
endpoint="/signed-pdf",
5179+
payload=payload,
5180+
payload_model=PdfSignPayload,
5181+
extra_query=extra_query,
5182+
extra_headers=extra_headers,
5183+
extra_body=extra_body,
5184+
timeout=timeout,
5185+
)
5186+
51125187
async def blank_pdf(
51135188
self,
51145189
*,

src/pdfrest/models/_internal.py

Lines changed: 232 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,12 @@ def _serialize_text_objects(value: list[BaseModel]) -> str:
229229
return to_json(payload).decode()
230230

231231

232+
def _serialize_signature_configuration(
233+
value: _PdfSignatureConfigurationModel,
234+
) -> str:
235+
return value.model_dump_json(exclude_none=True)
236+
237+
232238
def _allowed_mime_types(
233239
allowed_mime_types: str, *more_allowed_mime_types: str, error_msg: str | None
234240
) -> Callable[[Any], Any]:
@@ -242,7 +248,10 @@ def allowed_mime_types_validator(
242248
_ = allowed_mime_types_validator(item)
243249
return value
244250
if value.type not in combined_allowed_mime_types:
245-
msg = error_msg or f"The file type must be one of: {allowed_mime_types}"
251+
msg = (
252+
error_msg
253+
or f"The file type must be one of: {combined_allowed_mime_types}"
254+
)
246255
raise ValueError(msg)
247256
return value
248257

@@ -978,6 +987,44 @@ class PdfPresetRedactionModel(BaseModel):
978987
value: PdfRedactionPreset
979988

980989

990+
class _PdfSignaturePointModel(BaseModel):
991+
x: float
992+
y: float
993+
994+
995+
class _PdfSignatureLocationModel(BaseModel):
996+
bottom_left: _PdfSignaturePointModel
997+
top_right: _PdfSignaturePointModel
998+
page: str | int
999+
1000+
1001+
class _PdfSignatureDisplayModel(BaseModel):
1002+
include_distinguished_name: bool | None = None
1003+
include_datetime: bool | None = None
1004+
contact: str | None = None
1005+
location: str | None = None
1006+
name: str | None = None
1007+
reason: str | None = None
1008+
1009+
1010+
class _PdfSignatureConfigurationModel(BaseModel):
1011+
type: Literal["new", "existing"]
1012+
name: str | None = None
1013+
logo_opacity: Annotated[float | None, Field(gt=0, le=1, default=None)] = None
1014+
location: _PdfSignatureLocationModel | None = None
1015+
display: _PdfSignatureDisplayModel | None = None
1016+
1017+
@model_validator(mode="after")
1018+
def _validate_location_requirements(self) -> _PdfSignatureConfigurationModel:
1019+
if self.type == "new" and self.location is None:
1020+
msg = (
1021+
"Missing location information for a new digital signature field. "
1022+
"See documentation for required fields."
1023+
)
1024+
raise ValueError(msg)
1025+
return self
1026+
1027+
9811028
_PdfRedactionVariant = Annotated[
9821029
PdfLiteralRedactionModel | PdfRegexRedactionModel | PdfPresetRedactionModel,
9831030
Field(discriminator="type"),
@@ -1435,6 +1482,190 @@ class PdfExportFormDataPayload(BaseModel):
14351482
] = None
14361483

14371484

1485+
class PdfSignPayload(BaseModel):
1486+
"""Adapt caller options into a pdfRest-ready sign request payload."""
1487+
1488+
files: Annotated[
1489+
list[PdfRestFile],
1490+
Field(
1491+
min_length=1,
1492+
max_length=1,
1493+
validation_alias=AliasChoices("file", "files"),
1494+
serialization_alias="id",
1495+
),
1496+
BeforeValidator(_ensure_list),
1497+
AfterValidator(
1498+
_allowed_mime_types("application/pdf", error_msg="Must be a PDF file")
1499+
),
1500+
PlainSerializer(_serialize_as_first_file_id),
1501+
]
1502+
signature_configuration: Annotated[
1503+
_PdfSignatureConfigurationModel,
1504+
Field(serialization_alias="signature_configuration"),
1505+
PlainSerializer(_serialize_signature_configuration),
1506+
]
1507+
pfx_credential: Annotated[
1508+
list[PdfRestFile] | None,
1509+
Field(
1510+
default=None,
1511+
min_length=1,
1512+
max_length=1,
1513+
validation_alias=AliasChoices("pfx", "pfx_credential"),
1514+
serialization_alias="pfx_credential_id",
1515+
),
1516+
BeforeValidator(_ensure_list),
1517+
AfterValidator(
1518+
_allowed_mime_types(
1519+
"application/x-pkcs12",
1520+
"application/pkcs12",
1521+
"application/octet-stream",
1522+
error_msg="PFX credentials must be a .pfx or .p12 file",
1523+
)
1524+
),
1525+
PlainSerializer(_serialize_as_first_file_id),
1526+
] = None
1527+
pfx_passphrase: Annotated[
1528+
list[PdfRestFile] | None,
1529+
Field(
1530+
default=None,
1531+
min_length=1,
1532+
max_length=1,
1533+
validation_alias=AliasChoices("pfx_passphrase", "passphrase"),
1534+
serialization_alias="pfx_passphrase_id",
1535+
),
1536+
BeforeValidator(_ensure_list),
1537+
AfterValidator(
1538+
_allowed_mime_types(
1539+
"text/plain",
1540+
"application/octet-stream",
1541+
error_msg="PFX passphrase must be a text file",
1542+
)
1543+
),
1544+
PlainSerializer(_serialize_as_first_file_id),
1545+
] = None
1546+
certificate: Annotated[
1547+
list[PdfRestFile] | None,
1548+
Field(
1549+
default=None,
1550+
min_length=1,
1551+
max_length=1,
1552+
validation_alias=AliasChoices("certificate", "cert"),
1553+
serialization_alias="certificate_id",
1554+
),
1555+
BeforeValidator(_ensure_list),
1556+
AfterValidator(
1557+
# DER cert/key uploads are frequently tagged as x509-ca-cert (or octet-stream
1558+
# in some environments), so we intentionally keep this allowlist broad.
1559+
_allowed_mime_types(
1560+
"application/pkix-cert",
1561+
"application/x-x509-ca-cert",
1562+
"application/x-pem-file",
1563+
"application/pem-certificate-chain",
1564+
"application/octet-stream",
1565+
error_msg="Certificate must be a .pem or .der file",
1566+
)
1567+
),
1568+
PlainSerializer(_serialize_as_first_file_id),
1569+
] = None
1570+
private_key: Annotated[
1571+
list[PdfRestFile] | None,
1572+
Field(
1573+
default=None,
1574+
min_length=1,
1575+
max_length=1,
1576+
validation_alias=AliasChoices("private_key", "key"),
1577+
serialization_alias="private_key_id",
1578+
),
1579+
BeforeValidator(_ensure_list),
1580+
AfterValidator(
1581+
# Keep parity with provider/browser MIME detection for DER private keys.
1582+
_allowed_mime_types(
1583+
"application/pkix-cert",
1584+
"application/x-x509-ca-cert",
1585+
"application/pkcs8",
1586+
"application/x-pem-file",
1587+
"application/pem-certificate-chain",
1588+
"application/octet-stream",
1589+
error_msg="Private key must be a .pem or .der file",
1590+
)
1591+
),
1592+
PlainSerializer(_serialize_as_first_file_id),
1593+
] = None
1594+
logo: Annotated[
1595+
list[PdfRestFile] | None,
1596+
Field(
1597+
default=None,
1598+
min_length=1,
1599+
max_length=1,
1600+
validation_alias=AliasChoices("logo", "logos"),
1601+
serialization_alias="logo_id",
1602+
),
1603+
BeforeValidator(_ensure_list),
1604+
AfterValidator(
1605+
_allowed_mime_types(
1606+
"image/jpeg",
1607+
"image/png",
1608+
"image/tiff",
1609+
"image/bmp",
1610+
error_msg="Logo must be an image file",
1611+
)
1612+
),
1613+
PlainSerializer(_serialize_as_first_file_id),
1614+
] = None
1615+
output: Annotated[
1616+
str | None,
1617+
Field(serialization_alias="output", min_length=1, default=None),
1618+
AfterValidator(_validate_output_prefix),
1619+
] = None
1620+
1621+
@model_validator(mode="before")
1622+
@classmethod
1623+
def _normalize_credentials(cls, data: Any) -> Any:
1624+
if not isinstance(data, Mapping):
1625+
return data
1626+
1627+
payload = cast(Mapping[object, Any], data)
1628+
credentials = payload.get("credentials")
1629+
if credentials is None:
1630+
return {str(key): value for key, value in payload.items()}
1631+
if not isinstance(credentials, Mapping):
1632+
msg = (
1633+
"credentials must be a mapping with either pfx/passphrase or "
1634+
"certificate/private_key."
1635+
)
1636+
raise ValueError(msg) # noqa: TRY004
1637+
1638+
normalized: dict[str, Any] = {str(key): value for key, value in payload.items()}
1639+
credential_map = cast(Mapping[object, Any], credentials)
1640+
for raw_key, value in credential_map.items():
1641+
key = str(raw_key)
1642+
if key not in normalized:
1643+
normalized[key] = value
1644+
return normalized
1645+
1646+
@model_validator(mode="after")
1647+
def _validate_credentials(self) -> PdfSignPayload:
1648+
has_pfx = self.pfx_credential is not None or self.pfx_passphrase is not None
1649+
has_pem = self.certificate is not None or self.private_key is not None
1650+
1651+
if has_pfx and has_pem:
1652+
msg = "Provide either PFX credentials (pfx + passphrase) or certificate/private_key, not both."
1653+
raise ValueError(msg)
1654+
if has_pfx:
1655+
if not self.pfx_credential or not self.pfx_passphrase:
1656+
msg = "Both pfx and passphrase are required when supplying PFX credentials."
1657+
raise ValueError(msg)
1658+
elif has_pem:
1659+
if not self.certificate or not self.private_key:
1660+
msg = "Both certificate and private_key are required when supplying PEM/DER credentials."
1661+
raise ValueError(msg)
1662+
else:
1663+
msg = "Either PFX credentials (pfx + passphrase) or certificate/private_key credentials are required."
1664+
raise ValueError(msg)
1665+
1666+
return self
1667+
1668+
14381669
class PdfCompressPayload(BaseModel):
14391670
"""Adapt caller options into a pdfRest-ready compress request payload."""
14401671

0 commit comments

Comments
 (0)