diff --git a/README.md b/README.md index fbb52e6..da79887 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ uv run hackproof reconcile uv run hackproof export ``` +Published dataset and API response JSON Schemas are indexed in [`apps/web/public/schema/index.json`](apps/web/public/schema/index.json). + ## Checks ```sh diff --git a/apps/api/src/hackproof/export/files.py b/apps/api/src/hackproof/export/files.py index fb1f2d7..5777392 100644 --- a/apps/api/src/hackproof/export/files.py +++ b/apps/api/src/hackproof/export/files.py @@ -1,3 +1,4 @@ +import json import re from collections import Counter from datetime import datetime @@ -7,16 +8,40 @@ import yaml from pydantic import BaseModel, ConfigDict, Field +from hackproof import schemas as api_schemas from hackproof.export.models import HackathonExport, Project, Tier, Track from hackproof.hackathon import Place SCHEMA_FILE = "schema-hackathon-project.yml" EVENT_FILE = "_event.yml" EVENT_SCHEMA_FILE = "schema-event.yml" +SCHEMA_VERSION = "1.0" SOURCE_URL = "https://github.com/devodii/hackproof" +SCHEMA_BASE_URL = f"{SOURCE_URL}/schema" SCHEMA_COMMENT = f"# yaml-language-server: $schema=../{SCHEMA_FILE}\n" EVENT_SCHEMA_COMMENT = f"# yaml-language-server: $schema=../{EVENT_SCHEMA_FILE}\n" +API_SCHEMA_MODELS: tuple[tuple[str, type[BaseModel]], ...] = ( + ("stats", api_schemas.Stats), + ("project", api_schemas.Project), + ("contributor", api_schemas.Contributor), + ("link", api_schemas.Link), + ("contract", api_schemas.Contract), + ("wasm-upload", api_schemas.WasmUpload), + ("failed-tx", api_schemas.FailedTx), + ("integration-use", api_schemas.IntegrationUse), + ("project-detail", api_schemas.ProjectDetail), + ("builder", api_schemas.Builder), + ("timeline-point", api_schemas.TimelinePoint), + ("integration", api_schemas.Integration), + ("ecosystem-tool", api_schemas.EcosystemTool), + ("country-count", api_schemas.CountryCount), + ("project-page", api_schemas.Page[api_schemas.Project]), + ("contract-page", api_schemas.Page[api_schemas.Contract]), + ("wasm-upload-page", api_schemas.Page[api_schemas.WasmUpload]), + ("failed-tx-page", api_schemas.Page[api_schemas.FailedTx]), +) + class FileModel(BaseModel): model_config = ConfigDict(extra="forbid") @@ -250,3 +275,51 @@ def write_project_files(export: HackathonExport, root: Path) -> int: ) return len(export.projects) + + +def api_schema(slug: str, model: type[BaseModel]) -> dict[str, Any]: + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": f"{SCHEMA_BASE_URL}/{slug}.schema.json", + "version": SCHEMA_VERSION, + **model.model_json_schema(), + } + + +def write_api_schemas(schema_dir: Path) -> dict[str, Any]: + schema_dir.mkdir(parents=True, exist_ok=True) + entries: list[dict[str, str]] = [ + { + "name": "hackathon-export", + "version": SCHEMA_VERSION, + "file": f"hackathon-export-{SCHEMA_VERSION}.json", + "$id": f"{SCHEMA_BASE_URL}/hackathon-export-{SCHEMA_VERSION}.json", + }, + { + "name": "hackathon-project", + "version": SCHEMA_VERSION, + "file": SCHEMA_FILE, + "$id": f"{SCHEMA_BASE_URL}/{SCHEMA_FILE}", + }, + { + "name": "event", + "version": SCHEMA_VERSION, + "file": EVENT_SCHEMA_FILE, + "$id": f"{SCHEMA_BASE_URL}/{EVENT_SCHEMA_FILE}", + }, + ] + for slug, model in API_SCHEMA_MODELS: + filename = f"{slug}.schema.json" + schema = api_schema(slug, model) + (schema_dir / filename).write_text(f"{json.dumps(schema, indent=2)}\n") + entries.append( + { + "name": slug, + "version": SCHEMA_VERSION, + "file": filename, + "$id": schema["$id"], + } + ) + index = {"version": SCHEMA_VERSION, "schemas": entries} + (schema_dir / "index.json").write_text(f"{json.dumps(index, indent=2)}\n") + return index diff --git a/apps/api/src/hackproof/export/jobs.py b/apps/api/src/hackproof/export/jobs.py index 7e543f6..212c6d7 100644 --- a/apps/api/src/hackproof/export/jobs.py +++ b/apps/api/src/hackproof/export/jobs.py @@ -3,12 +3,13 @@ from hackproof.config import ROOT from hackproof.db import open_pool, pool from hackproof.export.build import build_export -from hackproof.export.files import write_project_files +from hackproof.export.files import write_api_schemas, write_project_files from hackproof.hackathon import get_hackathon log = logging.getLogger("hackproof.export") DATA = ROOT / "data" +WEB_SCHEMA_DIR = ROOT / "apps" / "web" / "public" / "schema" async def run_export() -> None: @@ -18,4 +19,5 @@ async def run_export() -> None: export = await build_export(connection, hackathon) count = write_project_files(export, DATA) + write_api_schemas(WEB_SCHEMA_DIR) log.info("wrote %s project files to %s", count, DATA / str(export.hackathon.starts_at.year)) diff --git a/apps/api/tests/test_export_files.py b/apps/api/tests/test_export_files.py index 2d13938..6ec08d9 100644 --- a/apps/api/tests/test_export_files.py +++ b/apps/api/tests/test_export_files.py @@ -1,9 +1,14 @@ +import json +from datetime import UTC, datetime + import jsonschema import pytest import yaml +from hackproof import schemas as api_schemas from hackproof.export.build import build_export from hackproof.export.files import ( + API_SCHEMA_MODELS, EVENT_FILE, EVENT_SCHEMA_FILE, SCHEMA_FILE, @@ -12,6 +17,7 @@ file_schema, project_file, to_file_yaml, + write_api_schemas, write_project_files, ) from hackproof.hackathon import get_hackathon @@ -115,3 +121,159 @@ async def test_the_event_manifest_validates_and_names_the_event(scratch_db, tmp_ assert manifest["slug"] == export.hackathon.slug assert manifest["network"] == "testnet" assert manifest["ends_at"] > manifest["starts_at"] + + +def test_api_schemas_and_index_validate_sample_responses(tmp_path): + now = datetime(2026, 10, 12, 12, 0, tzinfo=UTC) + sample_project = api_schemas.Project( + project_id="repo:team/app", + full_name="team/app", + url="https://github.com/team/app", + footprint="contracts", + tier="verified", + contracts=1, + classic_ops=2, + assets=0, + builders=1, + wasm_uploads=1, + failed_txs=0, + first_activity=now, + last_activity=now, + description="Sample project", + homepage=None, + stars=5, + forks=1, + language="Rust", + license="MIT", + owner="team", + owner_avatar="https://avatars/team", + country="US", + dependencies=None, + window_commits=7, + total_commits=10, + stellar_added_in_window=True, + created_at=now, + topics=["stellar"], + track="genesis", + integrations=["privy"], + tools=[], + award="first", + ) + sample_contributor = api_schemas.Contributor( + login="ana", + url="https://github.com/ana", + avatar_url="https://avatars/ana", + contributions=12, + country="PT", + ) + sample_link = api_schemas.Link( + subject_kind="contract", + subject="CXYZ", + method="contract_in_repo", + path="README.md", + ) + sample_contract = api_schemas.Contract( + contract_id="CXYZ", + deployer="GDEPLOYER", + wasm_hash="w1", + tx_hash="tx1", + ledger=100, + deployed_at=now, + project_id="repo:team/app", + ) + sample_wasm = api_schemas.WasmUpload( + wasm_hash="w1", + uploader="GDEPLOYER", + tx_hash="tx1", + size=1024, + source_repo="team/app", + full_name="team/app", + contracts=1, + uploaded_at=now, + ) + sample_failed = api_schemas.FailedTx( + tx_hash="tx_fail", + source_account="GDEPLOYER", + kind="soroban", + result_code="tx_failed", + ledger=101, + created_at=now, + ) + sample_integration_use = api_schemas.IntegrationUse( + slug="privy", + name="Privy", + method="package", + evidence="@privy-io/node", + path="package.json", + ) + samples = { + "stats": api_schemas.Stats( + projects=10, + verified_projects=5, + likely_projects=3, + possible_projects=2, + unattributed_accounts=1, + contracts=8, + builders=6, + contributors=15, + countries=4, + wasm_uploads=9, + failed_txs=2, + ), + "project": sample_project, + "contributor": sample_contributor, + "link": sample_link, + "contract": sample_contract, + "wasm-upload": sample_wasm, + "failed-tx": sample_failed, + "integration-use": sample_integration_use, + "project-detail": api_schemas.ProjectDetail( + project=sample_project, + links=[sample_link], + integration_uses=[sample_integration_use], + tool_uses=[], + contributors=[sample_contributor], + contracts=[sample_contract], + wasm_uploads=[sample_wasm], + failed_txs=[sample_failed], + ), + "builder": api_schemas.Builder( + address="GDEPLOYER", + projects=[sample_project], + contracts=[sample_contract], + wasm_uploads=[sample_wasm], + failed_txs=[sample_failed], + ), + "timeline-point": api_schemas.TimelinePoint(hour=now, contracts=3), + "integration": api_schemas.Integration( + slug="privy", + name="Privy", + category="wallets", + site="https://privy.io", + projects=4, + ), + "ecosystem-tool": api_schemas.EcosystemTool( + slug="scaffold-stellar", + name="Scaffold Stellar", + site="https://scaffoldstellar.org", + projects=6, + ), + "country-count": api_schemas.CountryCount(country="PT", contributors=3), + "project-page": api_schemas.Page[api_schemas.Project](items=[sample_project], total=1), + "contract-page": api_schemas.Page[api_schemas.Contract](items=[sample_contract], total=1), + "wasm-upload-page": api_schemas.Page[api_schemas.WasmUpload](items=[sample_wasm], total=1), + "failed-tx-page": api_schemas.Page[api_schemas.FailedTx](items=[sample_failed], total=1), + } + + index = write_api_schemas(tmp_path) + loaded_index = json.loads((tmp_path / "index.json").read_text()) + assert loaded_index == index + indexed_names = {entry["name"] for entry in loaded_index["schemas"]} + assert {"hackathon-export", "hackathon-project", "event"} <= indexed_names + + for slug, _ in API_SCHEMA_MODELS: + schema_path = tmp_path / f"{slug}.schema.json" + schema = json.loads(schema_path.read_text()) + assert schema["$id"].endswith(f"/{slug}.schema.json") + assert schema["version"] == "1.0" + jsonschema.validate(samples[slug].model_dump(mode="json"), schema)