Option sub_category -> sub_categories[] (default []). Both pool filters (instance category_filter, nested @{} ref) become boolean expressions (and/or/not/parens; comma=or) matched against an option's tag set via the shared engine/syntax/subcat_filter matcher, applied BEFORE constraints. Exclude-null is a separate flag: instance exclude_null + the trailing !null ref marker, replacing the old 'null' keyword.
4-segment ref grammar @{uuid#name:expr!null} single-sourced across tokenize.py/scan.py/pipeline.py/_constraints.py (! excluded from name/expr captures); tokenizer REF meta now carries filter_expr + exclude_null instead of a comma-split list. scan.py findall unpack sites updated for the 4th group.
validate_payload: per-option sub_categories[] must be in the registry; optional tag_groups (UI-only axes, members in-registry, <=1 group); registry names validated via subcat_filter.validate_subcat_name (rejects whitespace, boolean/ref chars, reserved words incl. null). wp_api/_validators delegates to the same function.
Constraints stay alive via a primary-tag stopgap: _apply_constraint_to_options + _record_pick use sub_categories[0] as the matrix key; true multi-tag matrix matching is SP3. Cascade fix_subcat_delete/rename + orchestrator scrub the per-option list; instance_schemas category_filter -> string. Existing engine tests migrated to the v2 shape; added tests for the ref regex, payload validation, and multi-tag pool filtering.
164 lines
6.9 KiB
Python
164 lines
6.9 KiB
Python
"""Shared API-level validators for meta + identifier shape.
|
||
|
||
Centralises the "between-the-handler-and-the-engine" guards so both
|
||
modules and bundles enforce the same name / description / tag rules,
|
||
and so the engine-level ``validate_payload`` calls aren't responsible
|
||
for the API-layer concerns (length caps, tag-element types, etc).
|
||
|
||
Caps are deliberately generous — the goal is "block obvious abuse"
|
||
(megabyte names, non-string tag entries, runaway identifiers) without
|
||
constraining real authoring. Bump these if a real workflow needs more.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any
|
||
|
||
from engine.syntax.subcat_filter import validate_subcat_name
|
||
|
||
# ─── Length caps ─────────────────────────────────────────────────────
|
||
MAX_NAME_LEN = 200
|
||
MAX_DESC_LEN = 4000
|
||
MAX_TAG_LEN = 64
|
||
MAX_TAGS = 50
|
||
MAX_IDENT_LEN = 64
|
||
MAX_TEMPLATE_LEN = 8000
|
||
# Hard cap on the raw POST/PUT body size. Real bundles serialise to a
|
||
# few hundred KB at most; the largest realistic payload is a wildcard
|
||
# with a couple hundred options. 5 MB is roughly 100× the realistic
|
||
# upper bound — enough headroom for unusual library entries without
|
||
# letting a 50k-option DoS slip through.
|
||
MAX_BODY_BYTES = 5 * 1024 * 1024
|
||
|
||
# Identifier rule: Python-style ident, no leading underscore-underscore
|
||
# (those collide with the engine's `__wp_*__` internal-key convention —
|
||
# see CLAUDE.md and engine/modules/types.py:strip_internals).
|
||
_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
||
|
||
# Characters reserved by the nested-ref grammar `@{uuid[#name][:expr][!null]}`.
|
||
# A wildcard `name` becomes the `#name` segment, so it must not contain
|
||
# the segment delimiters `# : } @ { !` (comma kept forbidden for
|
||
# continuity). Sub-category NAMES follow stricter §3.6 rules (the boolean
|
||
# grammar) and are validated via the shared `validate_subcat_name`.
|
||
REF_GRAMMAR_FORBIDDEN_CHARS = frozenset("{}:#@,!")
|
||
|
||
|
||
def validate_wildcard_name(name: str) -> str | None:
|
||
"""Wildcard-only name guard. Returns an error string when the name
|
||
contains characters reserved by the nested-ref grammar, ``None``
|
||
otherwise. Generic shape checks live in :func:`validate_meta`."""
|
||
bad = sorted(set(name) & REF_GRAMMAR_FORBIDDEN_CHARS)
|
||
if bad:
|
||
return (
|
||
f"wildcard name must not contain {bad!r} — these characters "
|
||
f"are reserved by the @{{uuid#name:subcat}} ref grammar"
|
||
)
|
||
return None
|
||
|
||
|
||
def validate_wildcard_subcats(payload: Any) -> str | None:
|
||
"""Wildcard payload `sub_categories[]` guard — mirrors the engine
|
||
handler's per-entry check ``engine/modules/wildcard_handler.py`` so
|
||
the import boundary rejects forbidden chars BEFORE the engine
|
||
importer commits the row. Lets the import-commit endpoint enforce
|
||
the same grammar contract the live editor enforces."""
|
||
if not isinstance(payload, dict):
|
||
return None
|
||
subs = payload.get("sub_categories")
|
||
if not isinstance(subs, list):
|
||
return None
|
||
for i, sc in enumerate(subs):
|
||
if not isinstance(sc, str):
|
||
return f"sub_categories[{i}] must be a string"
|
||
# Shared §3.6 name rules (single token; no whitespace /
|
||
# boolean-grammar / ref chars; not a reserved word incl. `null`).
|
||
err = validate_subcat_name(sc)
|
||
if err:
|
||
return f"sub_categories[{i}]: {err}"
|
||
return None
|
||
|
||
|
||
def validate_identifier(value: Any, where: str) -> None:
|
||
"""Raise ``ValueError`` when ``value`` is not a usable engine identifier.
|
||
|
||
Stricter than the engine handler's own regex check: also rejects
|
||
empty strings (the handler check guards with ``if value and …`` so
|
||
empty falls through), rejects ``__dunder`` prefixes (would collide
|
||
with engine-internal key stripping), and caps length.
|
||
"""
|
||
if not isinstance(value, str):
|
||
raise ValueError(f"{where} must be a string")
|
||
if not value:
|
||
raise ValueError(f"{where} must not be empty")
|
||
if len(value) > MAX_IDENT_LEN:
|
||
raise ValueError(
|
||
f"{where} must be at most {MAX_IDENT_LEN} chars (got {len(value)})"
|
||
)
|
||
if value.startswith("__"):
|
||
raise ValueError(
|
||
f"{where} must not start with '__' (reserved for engine-internal keys)"
|
||
)
|
||
if not _IDENT_RE.match(value):
|
||
raise ValueError(f"{where} {value!r} is not a valid identifier")
|
||
|
||
|
||
def validate_meta(body: dict[str, Any]) -> str | None:
|
||
"""Validate the meta fields shared between modules and bundles.
|
||
|
||
Returns an error string when something's off, ``None`` when every
|
||
present field passes. Missing fields are skipped — this validates
|
||
shape/length, not required-field presence (POST handlers separately
|
||
enforce ``required = {"type", "name", "payload"}`` etc).
|
||
|
||
Tags receive the strictest treatment: the SPA's TypeScript declares
|
||
``tags: string[]`` and the manager UI renders chips with naive
|
||
coercion (``null`` collapses to an empty label, objects stringify
|
||
to ``[object Object]``). Catching non-string entries here preserves
|
||
the type contract end-to-end.
|
||
"""
|
||
if "name" in body:
|
||
name = body["name"]
|
||
if not isinstance(name, str):
|
||
return "name must be a string"
|
||
if not name.strip():
|
||
return "name must not be empty"
|
||
if len(name) > MAX_NAME_LEN:
|
||
return f"name must be at most {MAX_NAME_LEN} chars (got {len(name)})"
|
||
if "description" in body:
|
||
desc = body["description"]
|
||
if desc is not None and not isinstance(desc, str):
|
||
return "description must be a string"
|
||
if isinstance(desc, str) and len(desc) > MAX_DESC_LEN:
|
||
return f"description must be at most {MAX_DESC_LEN} chars (got {len(desc)})"
|
||
if "tags" in body:
|
||
tags = body["tags"]
|
||
if not isinstance(tags, list):
|
||
return "tags must be a list"
|
||
if len(tags) > MAX_TAGS:
|
||
return f"tags must contain at most {MAX_TAGS} entries (got {len(tags)})"
|
||
for i, t in enumerate(tags):
|
||
if not isinstance(t, str):
|
||
return f"tags[{i}] must be a string"
|
||
if len(t) > MAX_TAG_LEN:
|
||
return f"tags[{i}] must be at most {MAX_TAG_LEN} chars (got {len(t)})"
|
||
if "category_id" in body:
|
||
cat = body["category_id"]
|
||
if cat is not None and not isinstance(cat, str):
|
||
return "category_id must be a string or null"
|
||
return None
|
||
|
||
|
||
def validate_body_size(content_length: int | None) -> str | None:
|
||
"""Reject requests whose ``Content-Length`` exceeds the API cap.
|
||
|
||
Pre-flight guard; the aiohttp ``client_max_size`` setting backs
|
||
this up at the transport layer, but we want a clean ``400`` JSON
|
||
error instead of aiohttp's stock 413 HTML.
|
||
"""
|
||
if content_length is not None and content_length > MAX_BODY_BYTES:
|
||
return (
|
||
f"request body too large ({content_length} bytes); "
|
||
f"limit is {MAX_BODY_BYTES} bytes"
|
||
)
|
||
return None
|