|
""" |
|
Normalize Form.io submission payloads into Athena-friendly answer rows. |
|
|
|
Recommended pattern: |
|
1) Keep the original payload in a raw/ prefix for audit/replay. |
|
2) Write normalized answer rows to answers/ as JSON Lines or send them to Firehose. |
|
3) Convert/batch to Parquet for Athena if query volume grows. |
|
|
|
This module is generic across Form.io-style payloads that use: |
|
- _label_data.components for field metadata |
|
- submission.data for answer values |
|
|
|
Upstream label-data enrichment: |
|
- Read x-original-url from the incoming event headers |
|
- Remove the trailing /submission segment |
|
- Prefix the remaining path with API_FORMS_TLD |
|
- GET that URL and assign the returned JSON to payload["_label_data"] |
|
- Only then perform normalization |
|
""" |
|
|
|
from __future__ import annotations |
|
|
|
import base64 |
|
import datetime as dt |
|
import html |
|
import json |
|
import logging |
|
import os |
|
import re |
|
import uuid |
|
from typing import Any, Dict, Iterable, List, Optional |
|
from urllib.parse import urljoin, urlparse |
|
from urllib.request import Request, urlopen |
|
|
|
import boto3 |
|
|
|
logger = logging.getLogger() |
|
logger.setLevel(logging.INFO) |
|
|
|
s3 = boto3.client("s3") |
|
|
|
|
|
def is_truthy_env(value: Optional[str]) -> bool: |
|
if value is None: |
|
return False |
|
return str(value).strip().lower() in {"true", "1", "yes"} |
|
|
|
|
|
DEBUG = is_truthy_env(os.getenv("DEBUGGING", "false")) |
|
|
|
|
|
def debug_log(message: str, data: Any = None) -> None: |
|
if not DEBUG: |
|
return |
|
|
|
if data is None: |
|
logger.info("[DEBUG] %s", message) |
|
return |
|
|
|
try: |
|
rendered = json.dumps(data, ensure_ascii=False, default=str) |
|
except Exception: |
|
rendered = str(data) |
|
logger.info("[DEBUG] %s | %s", message, rendered) |
|
|
|
|
|
def get_header(headers: Optional[Dict[str, Any]], name: str) -> Optional[str]: |
|
if not isinstance(headers, dict): |
|
return None |
|
target = name.lower() |
|
for key, value in headers.items(): |
|
if str(key).lower() == target: |
|
return str(value) if value is not None else None |
|
return None |
|
|
|
|
|
def strip_html(text: Optional[str]) -> str: |
|
if text is None: |
|
return "" |
|
return html.unescape(re.sub(r"<[^>]+>", "", str(text))).strip() |
|
|
|
|
|
def build_label_data_url(event: Dict[str, Any]) -> str: |
|
""" |
|
Build the upstream form-definition URL from the incoming x-original-url header. |
|
|
|
Rules: |
|
- Read x-original-url from the API Gateway event headers |
|
- Remove the trailing /submission segment |
|
- Prefix the remaining path with API_FORMS_TLD |
|
""" |
|
headers = event.get("headers") |
|
debug_log("Incoming request headers", headers) |
|
|
|
original_url = get_header(headers, "x-original-url") |
|
debug_log("Extracted x-original-url header", original_url) |
|
|
|
if not original_url: |
|
raise ValueError("Missing required x-original-url header") |
|
|
|
parsed = urlparse(original_url) |
|
path = parsed.path or original_url |
|
debug_log("Parsed path from x-original-url", path) |
|
|
|
if path.endswith("/submission"): |
|
path = path[: -len("/submission")] |
|
elif "/submission/" in path: |
|
path = path.rsplit("/submission", 1)[0] |
|
else: |
|
path = path.rstrip("/") |
|
debug_log("Path after stripping /submission", path) |
|
|
|
forms_tld = os.getenv("API_FORMS_TLD", "").strip() |
|
debug_log("Loaded API_FORMS_TLD", forms_tld) |
|
if not forms_tld: |
|
raise ValueError("Missing required API_FORMS_TLD environment variable") |
|
|
|
final_url = urljoin(forms_tld.rstrip("/") + "/", path.lstrip("/")) |
|
debug_log("Constructed label data URL", final_url) |
|
return final_url |
|
|
|
|
|
def fetch_label_data(event: Dict[str, Any]) -> Dict[str, Any]: |
|
"""Fetch the form definition JSON and return it for payload['_label_data'].""" |
|
url = build_label_data_url(event) |
|
request = Request(url, method="GET", headers={"Accept": "application/json"}) |
|
debug_log("Issuing GET request for label data", {"url": url, "method": "GET"}) |
|
|
|
with urlopen(request, timeout=15) as response: |
|
status_code = getattr(response, "status", None) |
|
debug_log("Received HTTP response from label data endpoint", {"status": status_code}) |
|
body = response.read().decode("utf-8") |
|
|
|
debug_log("Raw label data response body", body) |
|
parsed = json.loads(body) |
|
debug_log("Parsed label data JSON", parsed) |
|
return parsed |
|
|
|
|
|
def parse_component_options(component: Dict[str, Any]) -> Dict[str, str]: |
|
""" |
|
Build a value->label lookup for radio/select components that use a values[] array. |
|
""" |
|
values = component.get("values") |
|
if not isinstance(values, list): |
|
return {} |
|
|
|
lookup: Dict[str, str] = {} |
|
for item in values: |
|
value = item.get("value") |
|
if value is None: |
|
continue |
|
lookup[str(value)] = strip_html(item.get("label")) |
|
|
|
debug_log( |
|
"Parsed component options", |
|
{ |
|
"component_key": component.get("key"), |
|
"component_type": component.get("type"), |
|
"option_count": len(lookup), |
|
}, |
|
) |
|
return lookup |
|
|
|
|
|
def walk_components( |
|
components: List[Dict[str, Any]], |
|
parent_path: Optional[List[str]] = None, |
|
section_label: Optional[str] = None, |
|
label_path: Optional[List[str]] = None, |
|
) -> List[Dict[str, Any]]: |
|
""" |
|
Flatten Form.io component metadata into leaf field definitions. |
|
|
|
Matching rule: |
|
- top-level leaf input: submission.data[key] |
|
- leaf under container X: submission.data[X][child_key] |
|
- radio/select label lookup: component.values[].value -> component.values[].label |
|
""" |
|
parent_path = parent_path or [] |
|
label_path = label_path or [] |
|
out: List[Dict[str, Any]] = [] |
|
|
|
for comp in components or []: |
|
ctype = comp.get("type") |
|
key = comp.get("key") |
|
label = strip_html(comp.get("label")) |
|
legend = strip_html(comp.get("legend")) |
|
|
|
debug_log( |
|
"Walking component", |
|
{ |
|
"type": ctype, |
|
"key": key, |
|
"label": label, |
|
"legend": legend, |
|
"parent_path": parent_path, |
|
"label_path": label_path, |
|
}, |
|
) |
|
|
|
current_section = section_label |
|
current_label_path = list(label_path) |
|
|
|
if legend: |
|
current_section = legend |
|
current_label_path.append(legend) |
|
elif label and ctype not in {"columns", "fieldset", "htmlelement"}: |
|
if not current_label_path or current_label_path[-1] != label: |
|
current_label_path.append(label) |
|
|
|
if ctype == "columns": |
|
for col in comp.get("columns", []): |
|
out.extend( |
|
walk_components( |
|
col.get("components", []), |
|
parent_path=parent_path, |
|
section_label=current_section, |
|
label_path=current_label_path, |
|
) |
|
) |
|
continue |
|
|
|
if ctype in {"fieldset", "container"}: |
|
child_parent = list(parent_path) |
|
if ctype == "container" and comp.get("input") and key: |
|
child_parent.append(key) |
|
|
|
out.extend( |
|
walk_components( |
|
comp.get("components", []), |
|
parent_path=child_parent, |
|
section_label=current_section, |
|
label_path=current_label_path, |
|
) |
|
) |
|
continue |
|
|
|
if comp.get("input") and key and ctype != "button": |
|
full_path = ".".join(parent_path + [key]) |
|
field_def = { |
|
"field_path": full_path, |
|
"field_key": key, |
|
"field_type": ctype, |
|
"section_label": current_section or "", |
|
"field_label": label, |
|
"field_label_path": " > ".join(current_label_path) if current_label_path else label, |
|
"options": parse_component_options(comp), |
|
} |
|
out.append(field_def) |
|
debug_log("Emitted leaf field definition", field_def) |
|
|
|
return out |
|
|
|
|
|
def get_nested(data: Any, path: List[str]) -> Any: |
|
current = data |
|
for segment in path: |
|
if not isinstance(current, dict): |
|
debug_log("Nested lookup aborted because current node is not a dict", {"path": path, "segment": segment}) |
|
return None |
|
current = current.get(segment) |
|
if current is None: |
|
debug_log("Nested lookup returned None", {"path": path, "segment": segment}) |
|
return None |
|
return current |
|
|
|
|
|
def stringify(value: Any) -> Optional[str]: |
|
if value is None: |
|
return None |
|
if isinstance(value, bool): |
|
return "true" if value else "false" |
|
if isinstance(value, (str, int, float)): |
|
return str(value) |
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":")) |
|
|
|
|
|
def sanitize_form_slug(value: str) -> str: |
|
slug = re.sub(r"[^A-Za-z0-9_-]+", "_", value or "").strip("_") |
|
return slug or "unknown_form" |
|
|
|
|
|
def parse_apigw_payload(event: Dict[str, Any]) -> Dict[str, Any]: |
|
""" |
|
Supports common API Gateway/Lambda proxy shapes and direct invocation with a dict payload. |
|
""" |
|
debug_log("Parsing incoming Lambda event", event) |
|
|
|
if "body" not in event: |
|
debug_log("Event has no body; assuming direct invocation payload") |
|
if not isinstance(event, dict): |
|
raise ValueError("Direct invocation payload must be a dictionary") |
|
return event |
|
|
|
body = event["body"] |
|
debug_log("Raw event body before base64 handling", body) |
|
|
|
if event.get("isBase64Encoded"): |
|
debug_log("Event body is base64 encoded") |
|
body = base64.b64decode(body).decode("utf-8") |
|
debug_log("Decoded base64 event body", body) |
|
|
|
if isinstance(body, str): |
|
parsed = json.loads(body) |
|
debug_log("Parsed JSON body", parsed) |
|
return parsed |
|
if isinstance(body, dict): |
|
debug_log("Event body is already a dict") |
|
return body |
|
raise ValueError("Unsupported event body type") |
|
|
|
|
|
def enrich_payload_with_label_data(event: Dict[str, Any], payload: Dict[str, Any]) -> Dict[str, Any]: |
|
""" |
|
Fetch _label_data from the upstream forms API and inject it into the payload |
|
before any normalization work occurs. |
|
""" |
|
if not isinstance(payload, dict): |
|
raise ValueError("Payload must be a dictionary") |
|
|
|
debug_log("Payload before _label_data enrichment", payload) |
|
label_data = fetch_label_data(event) |
|
payload["_label_data"] = label_data |
|
debug_log("Payload after _label_data enrichment", payload) |
|
return payload |
|
|
|
|
|
def normalize_payload(payload: Dict[str, Any]) -> Dict[str, Any]: |
|
debug_log("Starting payload normalization") |
|
|
|
field_defs = walk_components(payload.get("_label_data", {}).get("components", [])) |
|
debug_log("Completed component walk", {"field_definition_count": len(field_defs)}) |
|
|
|
submission_data = payload.get("submission", {}).get("data", {}) |
|
debug_log("Submission data extracted", submission_data) |
|
|
|
form_machine_name = payload.get("_label_data", {}).get("machineName") or "" |
|
form_slug = sanitize_form_slug(form_machine_name) |
|
received_at = payload.get("receivedAt") |
|
ingest_date = received_at[:10] if isinstance(received_at, str) and len(received_at) >= 10 else dt.date.today().isoformat() |
|
|
|
submission_record = { |
|
"ingest_date": ingest_date, |
|
"form_machine_name_partition": form_slug, |
|
"source_system": payload.get("source"), |
|
"received_at": payload.get("receivedAt"), |
|
"form_id": payload.get("form", {}).get("_id") or payload.get("_label_data", {}).get("_id"), |
|
"form_machine_name": form_machine_name, |
|
"form_title": payload.get("_label_data", {}).get("title"), |
|
"submission_id": payload.get("submission", {}).get("_id"), |
|
"submission_created_at": payload.get("submission", {}).get("created"), |
|
"submission_modified_at": payload.get("submission", {}).get("modified"), |
|
} |
|
debug_log("Built submission record", submission_record) |
|
|
|
answer_records: List[Dict[str, Any]] = [] |
|
for field_def in field_defs: |
|
raw = get_nested(submission_data, field_def["field_path"].split(".")) |
|
|
|
if field_def["field_type"] == "select" and isinstance(raw, dict) and "value" in raw: |
|
debug_log( |
|
"Select component stored as object; extracting .value", |
|
{"field_path": field_def["field_path"], "raw_before": raw}, |
|
) |
|
raw = raw.get("value") |
|
|
|
raw_value = stringify(raw) |
|
display_value = field_def["options"].get(raw_value, raw_value) if raw_value is not None else None |
|
|
|
answer_record = { |
|
**submission_record, |
|
"field_path": field_def["field_path"], |
|
"field_key": field_def["field_key"], |
|
"field_type": field_def["field_type"], |
|
"section_label": field_def["section_label"], |
|
"field_label": field_def["field_label"], |
|
"field_label_path": field_def["field_label_path"], |
|
"raw_value": raw_value, |
|
"display_value": display_value, |
|
"is_answered": raw is not None, |
|
} |
|
answer_records.append(answer_record) |
|
debug_log("Resolved field into answer record", answer_record) |
|
|
|
answered_only = [row for row in answer_records if row["is_answered"]] |
|
submission_record["answer_count"] = len(answered_only) |
|
debug_log( |
|
"Finished normalization", |
|
{ |
|
"total_fields": len(answer_records), |
|
"answered_fields": len(answered_only), |
|
"submission_id": submission_record.get("submission_id"), |
|
}, |
|
) |
|
|
|
return { |
|
"submission_record": submission_record, |
|
"answer_records": answered_only, |
|
"raw_payload": payload, |
|
} |
|
|
|
|
|
def put_json(bucket: str, key: str, obj: Dict[str, Any]) -> None: |
|
debug_log("Writing JSON object to S3", {"bucket": bucket, "key": key}) |
|
s3.put_object( |
|
Bucket=bucket, |
|
Key=key, |
|
Body=json.dumps(obj, ensure_ascii=False).encode("utf-8"), |
|
ContentType="application/json", |
|
) |
|
|
|
|
|
def put_ndjson(bucket: str, key: str, rows: Iterable[Dict[str, Any]]) -> None: |
|
rows_list = list(rows) |
|
debug_log("Writing NDJSON to S3", {"bucket": bucket, "key": key, "row_count": len(rows_list)}) |
|
payload = "\n".join(json.dumps(row, ensure_ascii=False, separators=(",", ":")) for row in rows_list) |
|
s3.put_object( |
|
Bucket=bucket, |
|
Key=key, |
|
Body=payload.encode("utf-8"), |
|
ContentType="application/x-ndjson", |
|
) |
|
|
|
|
|
def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: |
|
""" |
|
Example handler. |
|
|
|
Environment variables: |
|
API_FORMS_TLD required for upstream form-definition lookup |
|
DEBUGGING true/false, yes/no, 1/0 (default false) |
|
RAW_BUCKET optional |
|
NORMALIZED_BUCKET optional |
|
RAW_PREFIX defaults to 'formio/raw' |
|
ANSWERS_PREFIX defaults to 'formio/answers' |
|
STORE_RAW true/false (default true) |
|
STORE_ANSWERS_JSONL true/false (default true) |
|
|
|
Note: |
|
Writing one S3 object per request is fine at low volume, but if volume grows, |
|
send answer_records to Amazon Data Firehose (or batch/compact later) so Athena |
|
reads fewer, larger Parquet files. |
|
""" |
|
debug_log( |
|
"Lambda invoked", |
|
{ |
|
"aws_request_id": getattr(context, "aws_request_id", None), |
|
"debug_enabled": DEBUG, |
|
}, |
|
) |
|
|
|
payload = parse_apigw_payload(event) |
|
payload = enrich_payload_with_label_data(event, payload) |
|
normalized = normalize_payload(payload) |
|
|
|
submission = normalized["submission_record"] |
|
answers = normalized["answer_records"] |
|
|
|
raw_bucket = os.getenv("RAW_BUCKET") |
|
normalized_bucket = os.getenv("NORMALIZED_BUCKET") or raw_bucket |
|
raw_prefix = os.getenv("RAW_PREFIX", "formio/raw").strip("/") |
|
answers_prefix = os.getenv("ANSWERS_PREFIX", "formio/answers").strip("/") |
|
store_raw = is_truthy_env(os.getenv("STORE_RAW", "true")) |
|
store_answers = is_truthy_env(os.getenv("STORE_ANSWERS_JSONL", "true")) |
|
|
|
debug_log( |
|
"Resolved storage configuration", |
|
{ |
|
"raw_bucket": raw_bucket, |
|
"normalized_bucket": normalized_bucket, |
|
"raw_prefix": raw_prefix, |
|
"answers_prefix": answers_prefix, |
|
"store_raw": store_raw, |
|
"store_answers": store_answers, |
|
}, |
|
) |
|
|
|
object_id = submission["submission_id"] or str(uuid.uuid4()) |
|
dt_part = submission["ingest_date"] |
|
form_part = submission["form_machine_name_partition"] |
|
|
|
debug_log( |
|
"Computed storage object metadata", |
|
{"object_id": object_id, "dt_part": dt_part, "form_part": form_part}, |
|
) |
|
|
|
if store_raw and raw_bucket: |
|
raw_key = ( |
|
f"{raw_prefix}/dt={dt_part}/form_slug={form_part}/" |
|
f"submission_id={object_id}.json" |
|
) |
|
put_json(raw_bucket, raw_key, normalized["raw_payload"]) |
|
else: |
|
debug_log("Skipping raw payload write", {"store_raw": store_raw, "raw_bucket": raw_bucket}) |
|
|
|
if store_answers and normalized_bucket: |
|
answers_key = ( |
|
f"{answers_prefix}/dt={dt_part}/form_slug={form_part}/" |
|
f"submission_id={object_id}.ndjson" |
|
) |
|
put_ndjson(normalized_bucket, answers_key, answers) |
|
else: |
|
debug_log( |
|
"Skipping normalized answers write", |
|
{"store_answers": store_answers, "normalized_bucket": normalized_bucket}, |
|
) |
|
|
|
response = { |
|
"statusCode": 202, |
|
"headers": {"Content-Type": "application/json"}, |
|
"body": json.dumps( |
|
{ |
|
"ok": True, |
|
"submission_id": submission["submission_id"], |
|
"answer_count": submission["answer_count"], |
|
"form_machine_name_partition": submission["form_machine_name_partition"], |
|
} |
|
), |
|
} |
|
debug_log("Returning Lambda response", response) |
|
return response |