diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..20aa27d5 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Backend +BACKEND_PORT=8000 + +# Frontend +FRONTEND_PORT=3000 + +# Database +DATABASE_URL=postgresql+psycopg://paform:paform@postgres:5432/paform + +# CORS +CORS_ALLOW_ORIGINS=https://localhost:3000,https://paform.local + +# Hygraph +HYGRAPH_ENDPOINT= +HYGRAPH_TOKEN= +HYGRAPH_WEBHOOK_SECRET= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3afbe0e6..602398da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,84 +1,81 @@ name: CI on: - push: - branches: [ main ] pull_request: - branches: [ main ] + branches: [ "**" ] + push: + branches: [ "main" ] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: - pre-commit: + backend: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.12' - - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - version: "0.5.21" - enable-cache: true + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: { python-version: "3.11" } + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt', '**/pyproject.toml') }} + - name: Install backend deps + run: pip install -U pip && pip install -e ./backend[dev] + - name: Lint (ruff/black) + run: | + ruff check backend + black --check backend + - name: Tests + coverage + run: pytest backend/tests -q --cov=backend --cov-report=xml + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: backend-coverage + path: coverage.xml - - name: Install dependencies and run pre-commit - run: | - cd backend - uv sync --dev - source .venv/bin/activate - cd .. - pre-commit run --all-files - - backend-tests: + frontend: runs-on: ubuntu-latest - defaults: - run: - working-directory: ./backend - steps: - - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.12' - - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - version: "0.5.21" - enable-cache: true + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: "20" } + - name: Cache pnpm + uses: actions/cache@v4 + with: + path: ~/.pnpm-store + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + - run: corepack enable + - run: pnpm install --frozen-lockfile + working-directory: frontend + - run: pnpm run lint + working-directory: frontend + - run: pnpm run build + working-directory: frontend - - name: Install dependencies - run: | - uv sync --dev - source .venv/bin/activate - - - name: Test with pytest - run: | - source .venv/bin/activate - pytest - - frontend-tests: + openapi-and-docs: runs-on: ubuntu-latest - defaults: - run: - working-directory: ./frontend - + needs: [backend] steps: - - uses: actions/checkout@v5 - - name: Set up Node.js - uses: actions/setup-node@v5 - with: - node-version: '18' - cache: 'npm' - cache-dependency-path: ./frontend/package-lock.json - - - name: Install dependencies - run: npm ci + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: { python-version: "3.11" } + - name: Generate OpenAPI snapshot + run: python backend/scripts/generate_openapi.py + - name: Build docs (mkdocs) + run: | + pip install mkdocs mkdocs-material + mkdocs build --strict - - name: Lint - run: npm run lint - - - name: Build - run: npm run build + docker-images: + runs-on: ubuntu-latest + needs: [backend, frontend] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + - name: Build backend image + run: docker build -t ghcr.io/${{ github.repository }}/backend:latest -f backend/Dockerfile . + - name: Build frontend image + run: docker build -t ghcr.io/${{ github.repository }}/frontend:latest -f frontend/Dockerfile . diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 79b8458a..9e8cb42c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,89 +1,16 @@ repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 # Use the ref you want to point at - hooks: - - id: trailing-whitespace - - id: check-ast - - id: check-builtin-literals - - id: check-docstring-first - - id: check-executables-have-shebangs - - id: debug-statements - - id: end-of-file-fixer - - id: mixed-line-ending - args: [--fix=lf] - - id: check-byte-order-marker - - id: check-merge-conflict - - id: check-symlinks - - id: detect-private-key - - id: requirements-txt-fixer - - id: check-yaml - args: [--unsafe] - - id: check-toml - - - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.6.12 - hooks: - - id: uv-lock - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.11.2' - hooks: - - id: ruff - args: [--fix, --exit-non-zero-on-fix] - types_or: [python, jupyter] - - id: ruff-format - types_or: [python, jupyter] - - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.15.0 - hooks: - - id: mypy - entry: python3 -m mypy --config-file backend/pyproject.toml - language: system - types: [python] - exclude: 'tests' - - - repo: https://github.com/crate-ci/typos - rev: v1 # v1.19.0 - hooks: - - id: typos - args: [] - - - repo: https://github.com/nbQA-dev/nbQA - rev: 1.9.1 - hooks: - - id: nbqa-ruff - args: [--fix, --exit-non-zero-on-fix] - - - repo: local - hooks: - - id: prettier-js-format - name: prettier-js-format - entry: npm run format:fix - files: 'app/' - language: node - types: [javascript] - additional_dependencies: - - npm - - prettier - - - repo: local - hooks: - - id: nextjs-lint - name: Next.js Lint - entry: npm run lint - language: system - types: [javascript, jsx, tsx] - pass_filenames: false - -ci: - autofix_commit_msg: | - [pre-commit.ci] Add auto fixes from pre-commit.com hooks - - for more information, see https://pre-commit.ci - autofix_prs: true - autoupdate_branch: '' - autoupdate_commit_msg: '[pre-commit.ci] pre-commit autoupdate' - autoupdate_schedule: weekly - skip: [mypy, prettier-js-format, nextjs-lint] - submodules: false + rev: v0.6.8 + hooks: [{ id: ruff, args: ["--fix"] }] + - repo: https://github.com/psf/black + rev: 24.8.0 + hooks: [{ id: black }] + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: [{ id: isort }] + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.41.0 + hooks: [{ id: markdownlint }] + +default_language_version: + python: python3.11 diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 06a65ca2..bfcfb82e 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -6,7 +6,7 @@ RUN pip install --no-cache-dir --upgrade pip COPY . . -RUN pip install --no-cache-dir -e . +RUN pip install --no-cache-dir -e .[dev] ARG BACKEND_PORT ARG FRONTEND_PORT diff --git a/backend/api/routes_sync.py b/backend/api/routes_sync.py index aa27d8ef..a07c421b 100644 --- a/backend/api/routes_sync.py +++ b/backend/api/routes_sync.py @@ -1,48 +1,5 @@ -"""Sync endpoints for Hygraph webhooks (H4).""" - -from __future__ import annotations - -import logging -from typing import Any, Dict - -from fastapi import APIRouter, Depends, Header, HTTPException, Request - -from api.config import Settings -from services.hygraph_service import HygraphService - - -logger = logging.getLogger(__name__) -router = APIRouter(prefix="/api/sync", tags=["sync"]) - - -def get_hygraph_service() -> HygraphService: - settings = Settings() - # Using hygraph_token as webhook secret in MVP; adjust if separate secret provided - return HygraphService(webhook_secret=settings.hygraph_token) - - -@router.post("/hygraph") -async def sync_hygraph( - request: Request, - x_hygraph_signature: str | None = Header(default=None), - x_hygraph_event_id: str | None = Header(default=None), - service: HygraphService = Depends(get_hygraph_service), -) -> Dict[str, Any]: - try: - raw = await request.body() - if not x_hygraph_signature or not service.verify_signature(raw, x_hygraph_signature): - raise HTTPException(status_code=401, detail="invalid signature") - - event = await request.json() - event_id = x_hygraph_event_id or event.get("id") or "unknown" - if not service.is_idempotent(event_id): - return {"status": "duplicate", "event_id": event_id} - - return service.sync(event) - except HTTPException: - raise - except Exception as e: - logger.exception("Error syncing hygraph") - raise HTTPException(status_code=500, detail=str(e)) from e +from fastapi import APIRouter +router = APIRouter() +# TODO: Implement sync routes for Hygraph webhooks and manual pulls. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 44bd51ef..cdd878fe 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -22,7 +22,7 @@ build-backend = "hatchling.build" [tool.hatch.build] packages = ["api", "services"] -[dependency-groups] +[project.optional-dependencies] dev = [ "codecov>=2.1.13", "mypy>=1.14.1", @@ -34,6 +34,7 @@ dev = [ "pytest-cov>=6.0.0", "pytest-mock>=3.14.0", "ruff>=0.9.2", + "httpx>=0.27.0", ] docs = [ "jinja2>=3.1.6", # Pinning version to address vulnerability GHSA-cpwx-vrp4-4pq7 @@ -47,10 +48,6 @@ docs = [ "pymdown-extensions>=10.0.0", ] -# Default dependency groups to be installed -[tool.uv] -default-groups = ["dev", "docs"] - [tool.ruff] line-length = 88 target-version = "py312" diff --git a/backend/scripts/generate_openapi.py b/backend/scripts/generate_openapi.py new file mode 100644 index 00000000..2c0a427b --- /dev/null +++ b/backend/scripts/generate_openapi.py @@ -0,0 +1,22 @@ +# backend/scripts/generate_openapi.py +import json +import os +import sys + +# Add the backend directory to the Python path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from api.main import app # adjust import to your app entrypoint +from fastapi.openapi.utils import get_openapi + +schema = get_openapi(title=app.title, version=app.version, routes=app.routes) +# Optional: stable sort for dicts/lists to reduce churn +text = json.dumps(schema, indent=2, sort_keys=True) +out = os.path.join(os.path.dirname(__file__), "..", "..", "docs", "API_SPEC.md") +os.makedirs(os.path.dirname(out), exist_ok=True) +with open(out, "w") as f: + f.write("# API Specification (OpenAPI)\n\n") + f.write("```json\n") + f.write(text) + f.write("\n```\n") +print(f"Wrote OpenAPI -> {out}") diff --git a/backend/services/hygraph_client.py b/backend/services/hygraph_client.py new file mode 100644 index 00000000..6d0d33c1 --- /dev/null +++ b/backend/services/hygraph_client.py @@ -0,0 +1 @@ +# TODO: Implement Hygraph client for GraphQL queries with pagination. diff --git a/backend/services/hygraph_service.py b/backend/services/hygraph_service.py index cea6d12b..c77f0735 100644 --- a/backend/services/hygraph_service.py +++ b/backend/services/hygraph_service.py @@ -1,46 +1 @@ -"""Hygraph sync service with signature validation and idempotency (H4).""" - -from __future__ import annotations - -import hmac -import hashlib -import time -from typing import Any, Dict, Optional - - -class HygraphService: - def __init__(self, webhook_secret: str | None = None) -> None: - self._secret = webhook_secret or "" - # naive idempotency memory store; replace with persistent store in prod - self._seen_events: set[str] = set() - - def verify_signature(self, payload: bytes, signature_header: str) -> bool: - """Validate HMAC-SHA256 signature from Hygraph webhook.""" - if not self._secret: - return False - expected = hmac.new(self._secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() - try: - provided = signature_header.split("=", 1)[1] - except Exception: - return False - return hmac.compare_digest(expected, provided) - - def is_idempotent(self, event_id: str) -> bool: - if event_id in self._seen_events: - return False - self._seen_events.add(event_id) - return True - - def sync(self, event: Dict[str, Any]) -> Dict[str, Any]: - """Process incoming Hygraph event. - - MVP: returns a structured acknowledgement. Real sync logic will fetch - content via GraphQL and upsert into local DB. - """ - return { - "status": "accepted", - "received_at": int(time.time()), - "event": event, - } - - +# TODO: Implement Hygraph service for upserting data into SQLAlchemy models. diff --git a/backend/tests/test_sync_hygraph.py b/backend/tests/test_sync_hygraph.py new file mode 100644 index 00000000..770bb870 --- /dev/null +++ b/backend/tests/test_sync_hygraph.py @@ -0,0 +1,19 @@ +import pytest + +# TODO: Add tests for Hygraph sync routes. + +@pytest.mark.skip(reason="Not implemented") +def test_webhook_signature_valid__201_or_200(): + pass + +@pytest.mark.skip(reason="Not implemented") +def test_webhook_signature_invalid__401(): + pass + +@pytest.mark.skip(reason="Not implemented") +def test_webhook_duplicate_event__no_duplicate_rows(): + pass + +@pytest.mark.skip(reason="Not implemented") +def test_pull_materials_modules_systems__200_and_upserted(): + pass diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index be2ec185..5d7802da 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -39,9 +39,30 @@ services: - FRONTEND_PORT=${FRONTEND_PORT} volumes: - ./backend:/app + depends_on: + postgres: + condition: service_healthy + networks: + - app-network + + postgres: + image: postgres:13 + env_file: .env.development + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U paform -d paform"] + interval: 5s + timeout: 5s + retries: 5 networks: - app-network +volumes: + postgres_data: + networks: app-network: driver: bridge diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md index 230ab6fe..580a146a 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -1,30 +1 @@ -# Paform API Specification - -Base URL: `http://:` - -## Materials -- `GET /api/materials/` → 200 `[Material]` -- `GET /api/materials/{id}` → 200 `Material` -- `POST /api/materials/` (MaterialCreate) → 200 `Material` -- `PUT /api/materials/{id}` (MaterialUpdate) → 200 `Material` -- `DELETE /api/materials/{id}` → 200 `{ status: 'deleted' }` - -## Modules -- `GET /api/modules/` → 200 `[Module]` -- `GET /api/modules/{id}` → 200 `Module` -- `POST /api/modules/` (ModuleCreate) → 200 `Module` -- `PUT /api/modules/{id}` (ModuleUpdate) → 200 `Module` -- `DELETE /api/modules/{id}` → 200 `{ status: 'deleted' }` - -## Quote -- `POST /api/quote/generate` (QuoteRequest) → 200 `QuoteResponse` - -Quote formula: `price = Σ( module.base_price + material.cost_per_sq_ft × surfaceArea(module) )` - -## CNC Export -- `POST /api/cnc/export` (CNCExportRequest) → 200 `CNCExportResponse` - -## Sync (Hygraph) -- `POST /api/sync/hygraph` - - Headers: `x-hygraph-signature: sha256=`; `x-hygraph-event-id` - - Returns idempotent acknowledgement +{"openapi": "3.1.0", "info": {"title": "MVP API", "description": "API for MVP application", "version": "0.1.0"}, "paths": {"/api/example": {"get": {"tags": ["api"], "summary": "Example Endpoint", "description": "Example endpoint.\n\nReturns\n-------\nDict[str, str]\n Example response.", "operationId": "example_endpoint_api_example_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Response Example Endpoint Api Example Get"}}}}}}}, "/api/examples": {"get": {"tags": ["api"], "summary": "Get All Examples", "description": "Get all examples.\n\nReturns\n-------\nList[Dict[str, str]]\n List of all examples.", "operationId": "get_all_examples_api_examples_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"additionalProperties": {"type": "string"}, "type": "object"}, "type": "array", "title": "Response Get All Examples Api Examples Get"}}}}}}}, "/api/examples/{example_id}": {"get": {"tags": ["api"], "summary": "Get Example By Id", "description": "Get an example by ID.\n\nParameters\n----------\nexample_id : str\n ID of the example to retrieve.\n\nReturns\n-------\nDict[str, str]\n Example data.", "operationId": "get_example_by_id_api_examples__example_id__get", "parameters": [{"name": "example_id", "in": "path", "required": true, "schema": {"type": "string", "description": "ID of the example", "title": "Example Id"}, "description": "ID of the example"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "string"}, "title": "Response Get Example By Id Api Examples Example Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/materials/": {"get": {"tags": ["materials"], "summary": "List Materials", "operationId": "list_materials_api_materials__get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/Material"}, "type": "array", "title": "Response List Materials Api Materials Get"}}}}}}, "post": {"tags": ["materials"], "summary": "Create Material", "operationId": "create_material_api_materials__post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/MaterialCreate"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Material"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/materials/{material_id}": {"get": {"tags": ["materials"], "summary": "Get Material", "operationId": "get_material_api_materials__material_id__get", "parameters": [{"name": "material_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Material Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Material"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["materials"], "summary": "Update Material", "operationId": "update_material_api_materials__material_id__put", "parameters": [{"name": "material_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Material Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MaterialUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Material"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["materials"], "summary": "Delete Material", "operationId": "delete_material_api_materials__material_id__delete", "parameters": [{"name": "material_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Material Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Delete Material Api Materials Material Id Delete"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/modules/": {"get": {"tags": ["modules"], "summary": "List Modules", "operationId": "list_modules_api_modules__get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/Module"}, "type": "array", "title": "Response List Modules Api Modules Get"}}}}}}, "post": {"tags": ["modules"], "summary": "Create Module", "operationId": "create_module_api_modules__post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ModuleCreate"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Module"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/modules/{module_id}": {"get": {"tags": ["modules"], "summary": "Get Module", "operationId": "get_module_api_modules__module_id__get", "parameters": [{"name": "module_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Module Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Module"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["modules"], "summary": "Update Module", "operationId": "update_module_api_modules__module_id__put", "parameters": [{"name": "module_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Module Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ModuleUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Module"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["modules"], "summary": "Delete Module", "operationId": "delete_module_api_modules__module_id__delete", "parameters": [{"name": "module_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Module Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Delete Module Api Modules Module Id Delete"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/quote/generate": {"post": {"tags": ["quote"], "summary": "Generate Quote", "operationId": "generate_quote_api_quote_generate_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/QuoteRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QuoteResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/cnc/export": {"post": {"tags": ["cnc"], "summary": "Export Cnc", "operationId": "export_cnc_api_cnc_export_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CNCExportRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CNCExportResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/": {"get": {"summary": "Root", "description": "Root endpoint of the API.\n\nReturns\n-------\nDict[str, str]\n A welcome message for the API.", "operationId": "root__get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Response Root Get"}}}}}}}, "/healthcheck": {"get": {"summary": "Healthcheck", "description": "Health check endpoint.\n\nThis endpoint can be used to verify that the API is running and responsive.\n\nReturns\n-------\nDict[str, str]\n A dictionary indicating the health status of the API.", "operationId": "healthcheck_healthcheck_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Response Healthcheck Healthcheck Get"}}}}}}}}, "components": {"schemas": {"CNCExportRequest": {"properties": {"configuration_id": {"type": "string", "title": "Configuration Id"}, "modules": {"items": {"$ref": "#/components/schemas/QuoteItem"}, "type": "array", "title": "Modules"}}, "type": "object", "required": ["configuration_id", "modules"], "title": "CNCExportRequest"}, "CNCExportResponse": {"properties": {"panels": {"items": {"$ref": "#/components/schemas/CNCPanel"}, "type": "array", "title": "Panels"}}, "type": "object", "required": ["panels"], "title": "CNCExportResponse"}, "CNCPanel": {"properties": {"panel_id": {"type": "string", "title": "Panel Id"}, "width": {"type": "number", "title": "Width"}, "height": {"type": "number", "title": "Height"}, "material": {"type": "string", "title": "Material"}, "edge_band": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Edge Band"}}, "type": "object", "required": ["panel_id", "width", "height", "material"], "title": "CNCPanel"}, "ExternalIds": {"properties": {}, "additionalProperties": true, "type": "object", "title": "ExternalIds"}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "Material": {"properties": {"name": {"type": "string", "maxLength": 255, "title": "Name"}, "texture_url": {"anyOf": [{"type": "string", "maxLength": 2048}, {"type": "null"}], "title": "Texture Url"}, "cost_per_sq_ft": {"type": "number", "minimum": 0.0, "title": "Cost Per Sq Ft"}, "external_ids": {"anyOf": [{"$ref": "#/components/schemas/ExternalIds"}, {"type": "null"}]}, "id": {"type": "string", "title": "Id"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}}, "type": "object", "required": ["name", "cost_per_sq_ft", "id", "created_at", "updated_at"], "title": "Material"}, "MaterialCreate": {"properties": {"name": {"type": "string", "maxLength": 255, "title": "Name"}, "texture_url": {"anyOf": [{"type": "string", "maxLength": 2048}, {"type": "null"}], "title": "Texture Url"}, "cost_per_sq_ft": {"type": "number", "minimum": 0.0, "title": "Cost Per Sq Ft"}, "external_ids": {"anyOf": [{"$ref": "#/components/schemas/ExternalIds"}, {"type": "null"}]}}, "type": "object", "required": ["name", "cost_per_sq_ft"], "title": "MaterialCreate"}, "MaterialUpdate": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Name"}, "texture_url": {"anyOf": [{"type": "string", "maxLength": 2048}, {"type": "null"}], "title": "Texture Url"}, "cost_per_sq_ft": {"anyOf": [{"type": "number", "minimum": 0.0}, {"type": "null"}], "title": "Cost Per Sq Ft"}, "external_ids": {"anyOf": [{"$ref": "#/components/schemas/ExternalIds"}, {"type": "null"}]}}, "type": "object", "title": "MaterialUpdate"}, "Module": {"properties": {"name": {"type": "string", "maxLength": 255, "title": "Name"}, "width": {"type": "number", "exclusiveMinimum": 0.0, "title": "Width"}, "height": {"type": "number", "exclusiveMinimum": 0.0, "title": "Height"}, "depth": {"type": "number", "exclusiveMinimum": 0.0, "title": "Depth"}, "base_price": {"type": "number", "minimum": 0.0, "title": "Base Price"}, "assembly_attributes": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Assembly Attributes"}, "placement_constraints": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Placement Constraints"}, "connection_points": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Connection Points"}, "external_ids": {"anyOf": [{"$ref": "#/components/schemas/ExternalIds"}, {"type": "null"}]}, "id": {"type": "string", "title": "Id"}, "material_id": {"type": "string", "title": "Material Id"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}}, "type": "object", "required": ["name", "width", "height", "depth", "base_price", "id", "material_id", "created_at", "updated_at"], "title": "Module"}, "ModuleCreate": {"properties": {"name": {"type": "string", "maxLength": 255, "title": "Name"}, "width": {"type": "number", "exclusiveMinimum": 0.0, "title": "Width"}, "height": {"type": "number", "exclusiveMinimum": 0.0, "title": "Height"}, "depth": {"type": "number", "exclusiveMinimum": 0.0, "title": "Depth"}, "base_price": {"type": "number", "minimum": 0.0, "title": "Base Price"}, "assembly_attributes": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Assembly Attributes"}, "placement_constraints": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Placement Constraints"}, "connection_points": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Connection Points"}, "external_ids": {"anyOf": [{"$ref": "#/components/schemas/ExternalIds"}, {"type": "null"}]}, "material_id": {"type": "string", "title": "Material Id"}}, "type": "object", "required": ["name", "width", "height", "depth", "base_price", "material_id"], "title": "ModuleCreate"}, "ModuleUpdate": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "width": {"anyOf": [{"type": "number", "exclusiveMinimum": 0.0}, {"type": "null"}], "title": "Width"}, "height": {"anyOf": [{"type": "number", "exclusiveMinimum": 0.0}, {"type": "null"}], "title": "Height"}, "depth": {"anyOf": [{"type": "number", "exclusiveMinimum": 0.0}, {"type": "null"}], "title": "Depth"}, "base_price": {"anyOf": [{"type": "number", "minimum": 0.0}, {"type": "null"}], "title": "Base Price"}, "assembly_attributes": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Assembly Attributes"}, "placement_constraints": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Placement Constraints"}, "connection_points": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Connection Points"}, "external_ids": {"anyOf": [{"$ref": "#/components/schemas/ExternalIds"}, {"type": "null"}]}, "material_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Material Id"}}, "type": "object", "title": "ModuleUpdate"}, "QuoteItem": {"properties": {"module_id": {"type": "string", "title": "Module Id"}, "quantity": {"type": "integer", "minimum": 1.0, "title": "Quantity"}}, "type": "object", "required": ["module_id", "quantity"], "title": "QuoteItem"}, "QuoteLine": {"properties": {"module_id": {"type": "string", "title": "Module Id"}, "unit_price": {"type": "number", "title": "Unit Price"}, "quantity": {"type": "integer", "title": "Quantity"}, "total_price": {"type": "number", "title": "Total Price"}}, "type": "object", "required": ["module_id", "unit_price", "quantity", "total_price"], "title": "QuoteLine"}, "QuoteRequest": {"properties": {"modules": {"items": {"$ref": "#/components/schemas/QuoteItem"}, "type": "array", "title": "Modules"}}, "type": "object", "required": ["modules"], "title": "QuoteRequest"}, "QuoteResponse": {"properties": {"currency": {"type": "string", "title": "Currency", "default": "USD"}, "subtotal": {"type": "number", "title": "Subtotal"}, "tax": {"type": "number", "title": "Tax"}, "total": {"type": "number", "title": "Total"}, "items": {"items": {"$ref": "#/components/schemas/QuoteLine"}, "type": "array", "title": "Items"}}, "type": "object", "required": ["subtotal", "tax", "total", "items"], "title": "QuoteResponse"}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}}}}