From 8e0e9449f310699f0f4726a8cf3b0f1a2cfdfa39 Mon Sep 17 00:00:00 2001 From: Alvin Nahabwe Date: Sun, 19 Jul 2026 08:14:02 +0300 Subject: [PATCH] Add API form-field contract + CI freshness check scripts/dump_contract.py introspects the FastAPI OpenAPI schema and emits dl_api_contract.json: for every form/multipart endpoint, the exact set of accepted field names. This is the source of truth for the R client, whose drift from this contract is what took the platform down (missing key, data_zip vs data_file, weight_decay vs weight_decay_hf). A CI job regenerates the contract and fails if the committed dl_api_contract.json is stale, so it can never silently fall behind the code. The no-code-app repo vendors this file and checks its requests against it (companion PR there). Verified: generator resolves the OpenAPI $ref bodies and emits correct field sets (object-detection train = 33 fields incl. weight_decay_hf / weight_decay_yolo; upload = data_file / data_name). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 19 ++++++ dl_api_contract.json | 134 +++++++++++++++++++++++++++++++++++++++ scripts/dump_contract.py | 56 ++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 dl_api_contract.json create mode 100644 scripts/dump_contract.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5a2815..e00930d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,25 @@ jobs: - name: Scan git history for secrets run: gitleaks detect --source . --redact --no-banner + contract: + name: API contract is fresh + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install import-only deps + run: pip install fastapi "uvicorn[standard]" python-multipart celery redis sqlalchemy pydantic psutil + - name: Regenerate the contract and verify it is committed + run: | + python scripts/dump_contract.py > /tmp/contract.json + if ! diff -u dl_api_contract.json /tmp/contract.json; then + echo "::error::dl_api_contract.json is stale. Regenerate it with: python scripts/dump_contract.py > dl_api_contract.json" + exit 1 + fi + smoke: name: Pipeline smoke test (CPU) runs-on: ubuntu-latest diff --git a/dl_api_contract.json b/dl_api_contract.json new file mode 100644 index 0000000..c4ba266 --- /dev/null +++ b/dl_api_contract.json @@ -0,0 +1,134 @@ +{ + "/data/upload/{task_type}": { + "POST": [ + "data_file", + "data_name" + ] + }, + "/explain/image-classification": { + "POST": [ + "image", + "model_checkpoint" + ] + }, + "/inference/image-classification": { + "POST": [ + "image", + "model_checkpoint" + ] + }, + "/inference/image-segmentation": { + "POST": [ + "image", + "model_checkpoint" + ] + }, + "/inference/object-detection": { + "POST": [ + "classes", + "image", + "imgsz", + "iou", + "max_det", + "model_checkpoint", + "threshold" + ] + }, + "/train/image-classification": { + "POST": [ + "brightness", + "contrast", + "dataset_id", + "dev_ratio", + "early_stopping_patience", + "early_stopping_threshold", + "enable_augmentation", + "epochs", + "eval_batch_size", + "flip_prob", + "gradient_accumulation_steps", + "gradient_checkpointing", + "is_presplit", + "learning_rate", + "max_image_size", + "model_checkpoint", + "optimizer", + "rotate_limit", + "run_name", + "scheduler", + "seed", + "train_batch_size", + "train_ratio", + "version", + "warmup_epochs", + "weight_decay" + ] + }, + "/train/image-segmentation": { + "POST": [ + "brightness", + "contrast", + "dataset_id", + "dev_ratio", + "early_stopping_patience", + "early_stopping_threshold", + "enable_augmentation", + "epochs", + "eval_batch_size", + "flip_prob", + "gradient_accumulation_steps", + "gradient_checkpointing", + "is_presplit", + "learning_rate", + "max_image_size", + "model_checkpoint", + "optimizer", + "rotate_limit", + "run_name", + "scheduler", + "seed", + "train_batch_size", + "train_ratio", + "version", + "warmup_epochs", + "weight_decay" + ] + }, + "/train/object-detection": { + "POST": [ + "brightness", + "contrast", + "dataset_id", + "early_stopping_patience", + "early_stopping_threshold", + "enable_augmentation", + "epochs", + "eval_batch_size", + "flip_prob", + "gradient_accumulation_steps", + "gradient_checkpointing", + "hsv_h", + "hsv_s", + "hsv_v", + "learning_rate", + "lr0", + "max_image_size", + "mixup", + "model_checkpoint", + "momentum", + "mosaic", + "optimizer", + "optimizer_hf", + "rotate_limit", + "run_name", + "scheduler_hf", + "seed", + "train_batch_size", + "version", + "warmup_epochs", + "warmup_epochs_hf", + "weight_decay_hf", + "weight_decay_yolo" + ] + } +} diff --git a/scripts/dump_contract.py b/scripts/dump_contract.py new file mode 100644 index 0000000..965dd3a --- /dev/null +++ b/scripts/dump_contract.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Emit the API's form-field contract as JSON. + +For every endpoint that accepts a form/multipart body, lists the accepted field +names. This is the source of truth the R client is checked against: if the +client sends a field not listed here, that's the drift class of bug that took +the platform down. The R repo vendors the emitted file and validates its +requests against it in CI. + +Usage: python scripts/dump_contract.py > dl_api_contract.json +""" +import json +import os +import sys + + +def _resolve_props(schema: dict, node: dict) -> dict: + """Return the properties of a schema node, following a $ref if present.""" + if "$ref" in node: + ref = node["$ref"].split("/")[-1] + node = schema.get("components", {}).get("schemas", {}).get(ref, {}) + return node.get("properties", {}) + + +def extract_contract(app) -> dict: + schema = app.openapi() + contract = {} + for path, methods in schema.get("paths", {}).items(): + for method, op in methods.items(): + body = op.get("requestBody", {}) + content = body.get("content", {}) + form = content.get("multipart/form-data") or content.get( + "application/x-www-form-urlencoded" + ) + if not form: + continue + props = _resolve_props(schema, form.get("schema", {})) + contract.setdefault(path, {})[method.upper()] = sorted(props.keys()) + return contract + + +def main(): + # Avoid needing a live broker just to import the app. + os.environ.setdefault("CELERY_BROKER_URL", "memory://") + os.environ.setdefault("CELERY_RESULT_BACKEND", "cache+memory://") + # Ensure the repo root (parent of scripts/) is importable. + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + import fastapi_app + + contract = extract_contract(fastapi_app.app) + json.dump(contract, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main()