44- GET /healthz
55- POST /generate-form-simple : build PDF from profile + (optional) layout/theme
66- /api/profiles/* : save/load JSON profiles (via profiles router)
7+ - GET / : PWA home (serves templates/index.html)
8+ - GET /manifest.json : PWA manifest (root scope)
9+ - GET /service-worker.js : PWA service worker (root scope)
710"""
811
912from __future__ import annotations
1417from pathlib import Path
1518from typing import Any , Dict , Optional
1619
17- from fastapi import FastAPI , HTTPException , Response
20+ from fastapi import FastAPI , HTTPException , Response , Request
1821from fastapi .middleware .cors import CORSMiddleware
22+ from fastapi .responses import HTMLResponse , FileResponse
23+ from fastapi .staticfiles import StaticFiles
24+ from fastapi .templating import Jinja2Templates
1925from pydantic import BaseModel , Field , ValidationError , field_validator
2026
2127# 1) Register fonts (side-effect)
2228from api .pdf_utils import fonts # noqa: F401
2329from api .pdf_utils .builder import build_resume_pdf
2430from api .pdf_utils .mapper import profile_to_overrides
2531from api .routes import profiles as profiles_routes # /api/profiles/*
26- from api .pdf_utils .schema import ensure_profile_schema # ✅ أضفنا هذا الاستيراد
32+ from api .pdf_utils .schema import ensure_profile_schema
33+
34+ import asyncio
35+ import httpx
36+ from starlette .responses import StreamingResponse
37+
2738
2839log = logging .getLogger ("resume.api" )
2940logging .basicConfig (level = logging .INFO )
3041
31- APP_ROOT = Path (__file__ ).resolve ().parent .parent
42+ # Project paths
43+ APP_ROOT = Path (__file__ ).resolve ().parent .parent # build/
44+ API_DIR = Path (__file__ ).resolve ().parent # build/api
3245THEMES_DIR = APP_ROOT / "themes"
3346LAYOUTS_DIR = APP_ROOT / "layouts"
3447
48+ # ---------------------------------------------------------------------
49+ # FastAPI app
50+ # ---------------------------------------------------------------------
3551app = FastAPI (title = "Resume API" )
3652
37- # ─────────────────────────────────────────────────────────────
53+ # ---------------------------------------------------------------------
54+ # PWA: root endpoints (so SW has scope '/')
55+ # ---------------------------------------------------------------------
56+ @app .get ("/manifest.json" )
57+ def manifest () -> FileResponse :
58+ return FileResponse (API_DIR / "static" / "manifest.json" , media_type = "application/json" )
59+
60+ @app .get ("/service-worker.js" )
61+ def service_worker () -> FileResponse :
62+ # Important: correct JS media type
63+ return FileResponse (API_DIR / "static" / "service-worker.js" , media_type = "application/javascript" )
64+
65+ # ---------------------------------------------------------------------
3866# CORS (tighten in production)
39- # ─────────────────────────────────────────────────────────────
67+ # ---------------------------------------------------------------------
4068ALLOWED_ORIGINS = [
4169 "http://localhost:8501" , # Streamlit (local)
4270 "http://127.0.0.1:8501" ,
4977 allow_headers = ["Content-Type" , "Authorization" ],
5078)
5179
52- # Routes for profiles CRUD
80+ # ---------------------------------------------------------------------
81+ # Routers
82+ # ---------------------------------------------------------------------
5383app .include_router (profiles_routes .router , prefix = "/api" )
5484
85+ # ---------------------------------------------------------------------
86+ # Static & Templates
87+ # ---------------------------------------------------------------------
88+ # Serve api/static at /static (icons, extra assets...)
89+ app .mount ("/static" , StaticFiles (directory = str (API_DIR / "static" )), name = "static" )
90+
91+ # Templates
92+ templates = Jinja2Templates (directory = str (API_DIR / "templates" ))
93+
94+ @app .get ("/" , response_class = HTMLResponse )
95+ def index (request : Request ) -> HTMLResponse :
96+ """PWA home page (renders templates/index.html)."""
97+ return templates .TemplateResponse ("index.html" , {"request" : request })
98+
99+
100+
101+
55102
103+
104+ # ---------------------------------------------------------------------
105+ # Helpers
106+ # ---------------------------------------------------------------------
56107def normalize_theme_name (tn : Optional [str ]) -> str :
57108 """Normalize an incoming theme name (drop '.theme.json' if present)."""
58109 if not tn :
59110 return "default"
60111 return tn [:- 11 ] if tn .endswith (".theme.json" ) else tn
61112
62-
63113def coerce_summary (profile : Dict [str , Any ]) -> None :
64114 """If summary is a stringified list, convert it to a single joined string."""
65115 val = profile .get ("summary" )
@@ -68,14 +118,12 @@ def coerce_summary(profile: Dict[str, Any]) -> None:
68118 if s .startswith ("[" ) and s .endswith ("]" ):
69119 try :
70120 import ast
71-
72121 lst = ast .literal_eval (s )
73122 if isinstance (lst , list ):
74123 profile ["summary" ] = " " .join (str (x ) for x in lst if x )
75124 except Exception :
76125 pass
77126
78-
79127def _decode_headshots (node : Any ) -> None :
80128 """Recursively convert avatar_circle.data.photo_b64 -> photo_bytes."""
81129 if isinstance (node , dict ):
@@ -93,7 +141,6 @@ def _decode_headshots(node: Any) -> None:
93141 for it in node :
94142 _decode_headshots (it )
95143
96-
97144def _deep_merge_fill_missing (dst : dict , src : dict ) -> dict :
98145 """
99146 Merge without overwriting existing keys in dst (fill-only-missing).
@@ -107,7 +154,6 @@ def _deep_merge_fill_missing(dst: dict, src: dict) -> dict:
107154 dst [k ] = v
108155 return dst
109156
110-
111157def _safe_read_layout_by_name (layout_name : str ) -> Dict [str , Any ]:
112158 """Read a JSON layout by name safely (prevent path traversal)."""
113159 candidate = (LAYOUTS_DIR / layout_name ).resolve ()
@@ -120,7 +166,9 @@ def _safe_read_layout_by_name(layout_name: str) -> Dict[str, Any]:
120166 except Exception as exc :
121167 raise HTTPException (status_code = 400 , detail = f"Failed to read layout: { exc } " )
122168
123-
169+ # ---------------------------------------------------------------------
170+ # Payload model
171+ # ---------------------------------------------------------------------
124172class GeneratePayload (BaseModel ):
125173 theme_name : Optional [str ] = Field (default = None , description = "Theme name or 'x.theme.json'" )
126174 theme : Optional [str ] = Field (default = None , description = "Legacy alias for theme_name" )
@@ -139,7 +187,9 @@ def _trim_lang(cls, v: str) -> str:
139187 def effective_theme_name (self ) -> str :
140188 return normalize_theme_name (self .theme_name or self .theme )
141189
142-
190+ # ---------------------------------------------------------------------
191+ # Lifecycle & health
192+ # ---------------------------------------------------------------------
143193@app .on_event ("startup" )
144194def _startup () -> None :
145195 try :
@@ -148,12 +198,13 @@ def _startup() -> None:
148198 except Exception as exc :
149199 log .warning ("Font registration failed: %s" , exc )
150200
151-
152201@app .get ("/healthz" )
153202def healthz () -> Dict [str , bool ]:
154203 return {"ok" : True }
155204
156-
205+ # ---------------------------------------------------------------------
206+ # PDF generation endpoint
207+ # ---------------------------------------------------------------------
157208@app .post ("/generate-form-simple" )
158209def generate_form_simple (payload : Dict [str , Any ]) -> Response :
159210 """Generate a resume PDF from the provided payload."""
@@ -170,7 +221,7 @@ def generate_form_simple(payload: Dict[str, Any]) -> Response:
170221 "profile" : args .profile or {},
171222 }
172223
173- # ✅ Normalize profile data before PDF build
224+ # Normalize profile data before PDF build
174225 data ["profile" ] = ensure_profile_schema (data ["profile" ])
175226
176227 # Resolve layout_inline (prefer inline, else by name)
0 commit comments