diff --git a/frontend/form/.env.example b/frontend/form/.env.example index 41ff2aa..1368206 100644 --- a/frontend/form/.env.example +++ b/frontend/form/.env.example @@ -1,5 +1 @@ VITE_FORM_SERVICE_URL=http://localhost:8002 -VITE_USER_SERVICE_URL=http://localhost:8001 -VITE_USER_SERVICE_TOKEN=dev-frontend-token - - diff --git a/frontend/form/.env.prod.example b/frontend/form/.env.prod.example index c1ce39d..0808158 100644 --- a/frontend/form/.env.prod.example +++ b/frontend/form/.env.prod.example @@ -1,3 +1 @@ VITE_FORM_SERVICE_URL=https://.vercel.app -VITE_USER_SERVICE_URL=https://.vercel.app -VITE_USER_SERVICE_TOKEN= diff --git a/frontend/form/src/components/AdminPasswordGate.tsx b/frontend/form/src/components/AdminPasswordGate.tsx index 1157923..e28d8be 100644 --- a/frontend/form/src/components/AdminPasswordGate.tsx +++ b/frontend/form/src/components/AdminPasswordGate.tsx @@ -1,5 +1,5 @@ -import { type FormEvent, useState } from "react"; -import { useAdminAuth } from "../hooks/useAdminAuth"; +import { type FormEvent, useEffect, useState } from "react"; +import { getAdminToken, useAdminAuth } from "../hooks/useAdminAuth"; import { verifyToken } from "../services/formService"; interface AdminTokenGateProps { @@ -7,11 +7,37 @@ interface AdminTokenGateProps { } const AdminTokenGate = ({ children }: AdminTokenGateProps) => { - const { isAuthorized, authorize } = useAdminAuth(); + const { isAuthorized, authorize, logout } = useAdminAuth(); const [token, setToken] = useState(""); const [authError, setAuthError] = useState(null); const [isLoading, setIsLoading] = useState(false); + // On mount, if a token is already stored verify it server-side. + // This prevents anyone from manually setting sessionStorage to bypass auth. + const [isVerifying, setIsVerifying] = useState(isAuthorized); + + useEffect(() => { + if (!isAuthorized) { + setIsVerifying(false); + return; + } + + const storedToken = getAdminToken(); + if (!storedToken) { + logout(); + setIsVerifying(false); + return; + } + + verifyToken(storedToken).then((result) => { + if (!result.valid) { + logout(); + } + setIsVerifying(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const onUnlock = async (event: FormEvent) => { event.preventDefault(); @@ -36,6 +62,14 @@ const AdminTokenGate = ({ children }: AdminTokenGateProps) => { setIsLoading(false); }; + if (isVerifying) { + return ( +
+
+
+ ); + } + if (isAuthorized) { return <>{children}; } diff --git a/frontend/form/src/hooks/useAdminAuth.ts b/frontend/form/src/hooks/useAdminAuth.ts index 862bb84..6576e18 100644 --- a/frontend/form/src/hooks/useAdminAuth.ts +++ b/frontend/form/src/hooks/useAdminAuth.ts @@ -1,15 +1,7 @@ import { useCallback, useState } from "react"; -const AUTH_KEY = "admin_authorized"; const TOKEN_KEY = "admin_api_token"; -function getStoredAuth(): boolean { - if (typeof window === "undefined") { - return false; - } - return sessionStorage.getItem(AUTH_KEY) === "true"; -} - /** * Get the stored admin API token. * This is exported for use by API services. @@ -22,12 +14,13 @@ export function getAdminToken(): string | null { } export function useAdminAuth() { - const [isAuthorized, setIsAuthorized] = useState(getStoredAuth); + const [isAuthorized, setIsAuthorized] = useState( + () => sessionStorage.getItem(TOKEN_KEY) !== null, + ); const authorize = useCallback((token: string): boolean => { if (token && token.trim().length > 0) { sessionStorage.setItem(TOKEN_KEY, token.trim()); - sessionStorage.setItem(AUTH_KEY, "true"); setIsAuthorized(true); return true; } @@ -35,7 +28,6 @@ export function useAdminAuth() { }, []); const logout = useCallback(() => { - sessionStorage.removeItem(AUTH_KEY); sessionStorage.removeItem(TOKEN_KEY); setIsAuthorized(false); }, []); diff --git a/frontend/form/src/hooks/useFormDetails.ts b/frontend/form/src/hooks/useFormDetails.ts index e4a6e53..4fa8d29 100644 --- a/frontend/form/src/hooks/useFormDetails.ts +++ b/frontend/form/src/hooks/useFormDetails.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { getFormById } from "../services/formService"; import type { FormResponse } from "../types"; +import { ApiClientError } from "../utils/apiClientError"; interface UseFormDetailsReturn { form: FormResponse | null; @@ -9,10 +10,10 @@ interface UseFormDetailsReturn { } function formatError(error: unknown): string { - if (error instanceof Error) { - if (error.message.includes("404")) { - return "Form bulunamadı."; - } + if (error instanceof ApiClientError && error.status === 404) { + return "Form bulunamadı."; + } + if (error instanceof ApiClientError) { return "Form yüklenirken bir hata oluştu."; } return "Form yüklenemedi."; diff --git a/frontend/form/src/hooks/useForms.ts b/frontend/form/src/hooks/useForms.ts index f5ef6a3..43eff7a 100644 --- a/frontend/form/src/hooks/useForms.ts +++ b/frontend/form/src/hooks/useForms.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { listForms } from "../services/formService"; import type { FormPreview } from "../types"; +import { ApiClientError } from "../utils/apiClientError"; interface UseFormsResult { forms: FormPreview[]; @@ -36,7 +37,11 @@ export function useForms(skip = 0, limit = 20): UseFormsResult { } } catch (err) { if (!isCancelled) { - setError(err instanceof Error ? err.message : "Failed to load forms"); + if (err instanceof ApiClientError && err.status === 401) { + setError("Oturum doğrulanamadı. Lütfen tekrar giriş yapın."); + } else { + setError("Formlar yüklenirken bir hata oluştu."); + } } } finally { if (!isCancelled) { diff --git a/frontend/form/src/pages/FormSubmissionPage.tsx b/frontend/form/src/pages/FormSubmissionPage.tsx index f83e5d1..60e4896 100644 --- a/frontend/form/src/pages/FormSubmissionPage.tsx +++ b/frontend/form/src/pages/FormSubmissionPage.tsx @@ -25,8 +25,10 @@ import type { UserPayload, UserResponse, } from "../types"; +import { ApiClientError } from "../utils/apiClientError"; import { buildFormSchema } from "../utils/buildFormSchema"; import { isQuestionVisible } from "../utils/fieldVisibility"; +import { messageForPublicFormSubmitError } from "../utils/publicFormMessages"; type FormValues = Record; @@ -199,13 +201,6 @@ function areValuesEqual(current: unknown, target: unknown): boolean { return current === target; } -function formatError(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return "Beklenmeyen bir hata oluştu."; -} - function buildUserPayload( email: string, values: FormValues, @@ -483,11 +478,14 @@ const FormSubmissionPage = () => { try { await createUser(payload); } catch (createError) { - const message = formatError(createError); - if (!message.includes("409")) { + if ( + createError instanceof ApiClientError && + createError.status === 409 + ) { + await updateUser(normalizedEmail, payload); + } else { throw createError; } - await updateUser(normalizedEmail, payload); } } @@ -505,7 +503,7 @@ const FormSubmissionPage = () => { setShowSuccessAlert(true); setSubmissionError(null); } catch (submitError) { - setSubmissionError(formatError(submitError)); + setSubmissionError(messageForPublicFormSubmitError(submitError)); } }; @@ -536,6 +534,45 @@ const FormSubmissionPage = () => { ); } + if (showSuccessAlert) { + return ( +
+
+
+ +
+

+ {form.title} +

+

+ Yanıtınız kaydedildi. Katılımınız için teşekkür ederiz. +

+
+ +
+
+
+

+ + GDG on Campus Yaşar Üniversitesi + {" "} + tarafından geliştirilmiştir. +

+
+
+ ); + } + return (
@@ -624,18 +661,6 @@ const FormSubmissionPage = () => { tarafından geliştirilmiştir.

- {showSuccessAlert && ( -
-
-

- Başvurunuz Alındı -

-

- Formunuz başarıyla gönderildi. Katılımınız için teşekkür ederiz. -

-
-
- )}
); }; diff --git a/frontend/form/src/pages/admin/AdminFormListPage.tsx b/frontend/form/src/pages/admin/AdminFormListPage.tsx index 172f4be..999f7c8 100644 --- a/frontend/form/src/pages/admin/AdminFormListPage.tsx +++ b/frontend/form/src/pages/admin/AdminFormListPage.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import AdminPasswordGate from "../../components/AdminPasswordGate"; import { useForms } from "../../hooks/useForms"; import { deleteForm } from "../../services/formService"; +import { ApiClientError } from "../../utils/apiClientError"; function formatDate(value: string | undefined): string { if (!value) return "-"; @@ -51,7 +52,11 @@ const AdminFormListPage = () => { setConfirmDeleteId(null); refetch(); } catch (err) { - setDeleteError(err instanceof Error ? err.message : "Bilinmeyen hata"); + if (err instanceof ApiClientError && err.status === 401) { + setDeleteError("Oturum doğrulanamadı. Lütfen tekrar giriş yapın."); + } else { + setDeleteError("Silme işlemi başarısız. Lütfen tekrar deneyin."); + } } finally { setDeletingId(null); } diff --git a/frontend/form/src/pages/admin/AdminFormViewsPage.tsx b/frontend/form/src/pages/admin/AdminFormViewsPage.tsx index 762dee3..b270b83 100644 --- a/frontend/form/src/pages/admin/AdminFormViewsPage.tsx +++ b/frontend/form/src/pages/admin/AdminFormViewsPage.tsx @@ -11,6 +11,7 @@ import type { FormResponse, SubmissionResponse, } from "../../types"; +import { ApiClientError } from "../../utils/apiClientError"; const EMAIL_FIELD_KEYS = ["email", "e_mail", "mail"]; @@ -71,10 +72,10 @@ function formatAnswer(value: unknown): string { } function formatError(error: unknown): string { - if (error instanceof Error) { - if (error.message.includes("404")) { - return "Form bulunamadı."; - } + if (error instanceof ApiClientError && error.status === 404) { + return "Form bulunamadı."; + } + if (error instanceof ApiClientError) { return "Veriler yüklenirken bir hata oluştu."; } return "Beklenmeyen bir hata oluştu."; diff --git a/frontend/form/src/pages/admin/FormEditorPage.tsx b/frontend/form/src/pages/admin/FormEditorPage.tsx index eee2fc7..4f25937 100644 --- a/frontend/form/src/pages/admin/FormEditorPage.tsx +++ b/frontend/form/src/pages/admin/FormEditorPage.tsx @@ -17,6 +17,7 @@ import type { FormResponse, FormUpdate, } from "../../types"; +import { ApiClientError } from "../../utils/apiClientError"; type FormValues = Record; @@ -196,7 +197,11 @@ const FormEditorPage = () => { reset(defaults); } catch (err) { if (isCancelled) return; - setLoadError(err instanceof Error ? err.message : "Form yüklenemedi"); + if (err instanceof ApiClientError && err.status === 404) { + setLoadError("Form bulunamadı."); + } else { + setLoadError("Form yüklenirken bir hata oluştu."); + } } finally { if (!isCancelled) setIsLoading(false); } @@ -305,7 +310,13 @@ const FormEditorPage = () => { navigate("/admin/forms"); } catch (err) { - setSaveError(err instanceof Error ? err.message : "Kaydetme başarısız"); + if (err instanceof ApiClientError && err.status === 401) { + setSaveError("Oturum süresi doldu veya yetkiniz yok."); + } else if (err instanceof ApiClientError) { + setSaveError("Kaydetme başarısız. Lütfen tekrar deneyin."); + } else { + setSaveError("Kaydetme başarısız."); + } } finally { setIsSaving(false); } diff --git a/frontend/form/src/services/formService.ts b/frontend/form/src/services/formService.ts index e644f7b..152e13d 100644 --- a/frontend/form/src/services/formService.ts +++ b/frontend/form/src/services/formService.ts @@ -8,6 +8,7 @@ import type { SubmissionCreate, SubmissionResponse, } from "../types"; +import { throwIfNotOk } from "../utils/apiClientError"; const FORM_SERVICE_URL = import.meta.env.VITE_FORM_SERVICE_URL ?? "http://localhost:8002"; @@ -21,14 +22,13 @@ async function request(path: string, init: RequestInit = {}): Promise { }, }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Form service request failed: ${response.status} ${errorText}`, - ); - } + await throwIfNotOk(response); - return (await response.json()) as T; + const text = await response.text(); + if (!text.trim()) { + return undefined as T; + } + return JSON.parse(text) as T; } /** @@ -76,7 +76,7 @@ export async function getSubmissionsByForm( limit: String(limit), }); - return request( + return authenticatedRequest( `/submissions/by-form/${encodeURIComponent(formId)}?${query.toString()}`, ); } @@ -115,7 +115,7 @@ export async function listForms( active_only: String(activeOnly), }); - return request(`/forms/?${query.toString()}`); + return authenticatedRequest(`/forms/?${query.toString()}`); } export async function createForm(payload: FormCreate): Promise { diff --git a/frontend/form/src/services/userService.ts b/frontend/form/src/services/userService.ts index 9e2c58d..2444133 100644 --- a/frontend/form/src/services/userService.ts +++ b/frontend/form/src/services/userService.ts @@ -1,29 +1,19 @@ import type { UserCreateResponse, UserPayload, UserResponse } from "../types"; +import { throwIfNotOk } from "../utils/apiClientError"; -const USER_SERVICE_URL = - import.meta.env.VITE_USER_SERVICE_URL ?? "http://localhost:8001"; -const USER_SERVICE_TOKEN = import.meta.env.VITE_USER_SERVICE_TOKEN ?? ""; - -function buildHeaders(headers?: HeadersInit): HeadersInit { - return { - "Content-Type": "application/json", - ...(USER_SERVICE_TOKEN ? { "X-API-Token": USER_SERVICE_TOKEN } : {}), - ...(headers ?? {}), - }; -} +const FORM_SERVICE_URL = + import.meta.env.VITE_FORM_SERVICE_URL ?? "http://localhost:8002"; async function request(path: string, init: RequestInit = {}): Promise { - const response = await fetch(`${USER_SERVICE_URL}${path}`, { + const response = await fetch(`${FORM_SERVICE_URL}${path}`, { ...init, - headers: buildHeaders(init.headers), + headers: { + "Content-Type": "application/json", + ...(init.headers ?? {}), + }, }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `User service request failed: ${response.status} ${errorText}`, - ); - } + await throwIfNotOk(response); return (await response.json()) as T; } @@ -32,9 +22,9 @@ export async function getUserByEmail( email: string, ): Promise { const response = await fetch( - `${USER_SERVICE_URL}/users/by-email/${encodeURIComponent(email)}`, + `${FORM_SERVICE_URL}/users/by-email/${encodeURIComponent(email)}`, { - headers: buildHeaders(), + headers: { "Content-Type": "application/json" }, }, ); @@ -42,10 +32,7 @@ export async function getUserByEmail( return null; } - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`User lookup failed: ${response.status} ${errorText}`); - } + await throwIfNotOk(response); return (await response.json()) as UserResponse; } diff --git a/frontend/form/src/utils/apiClientError.ts b/frontend/form/src/utils/apiClientError.ts new file mode 100644 index 0000000..e876ace --- /dev/null +++ b/frontend/form/src/utils/apiClientError.ts @@ -0,0 +1,51 @@ +/** + * Thrown when a fetch to our API returns a non-OK status. + * `detail` is FastAPI's `detail` field (string, object with `code`, or validation list). + */ +export class ApiClientError extends Error { + readonly status: number; + readonly detail: unknown; + + constructor(status: number, detail: unknown) { + super("ApiClientError"); + this.name = "ApiClientError"; + this.status = status; + this.detail = detail; + } +} + +export function unwrapFastApiDetail(body: unknown): unknown { + if (body && typeof body === "object" && "detail" in body) { + return (body as { detail: unknown }).detail; + } + return body; +} + +export function getErrorCode(detail: unknown): string | undefined { + if ( + detail && + typeof detail === "object" && + !Array.isArray(detail) && + "code" in detail && + typeof (detail as { code: unknown }).code === "string" + ) { + return (detail as { code: string }).code; + } + return undefined; +} + +/** Read error body and throw ApiClientError if response is not OK. */ +export async function throwIfNotOk(response: Response): Promise { + if (response.ok) { + return; + } + const text = await response.text(); + let parsed: unknown; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = text; + } + const detail = unwrapFastApiDetail(parsed); + throw new ApiClientError(response.status, detail); +} diff --git a/frontend/form/src/utils/publicFormMessages.ts b/frontend/form/src/utils/publicFormMessages.ts new file mode 100644 index 0000000..a142104 --- /dev/null +++ b/frontend/form/src/utils/publicFormMessages.ts @@ -0,0 +1,49 @@ +import { ApiClientError, getErrorCode } from "./apiClientError"; + +const SUBMIT_ERROR_MESSAGES: Record = { + form_not_active: "Bu form şu anda yanıt kabul etmiyor.", + form_not_started: "Bu form henüz başlamadı.", + form_deadline_passed: "Başvuru süresi sona erdi.", + required_answer_incomplete: "Lütfen tüm zorunlu alanları eksiksiz doldurun.", + invalid_form_schema: + "Form şu anda kullanılamıyor. Lütfen daha sonra tekrar deneyin.", + invalid_form_id: "Geçersiz form bağlantısı.", + form_not_found: "Form bulunamadı.", + invalid_submission_payload: + "Gönderdiğiniz bilgiler geçerli değil. Lütfen kontrol edip tekrar deneyin.", + user_service_unavailable: + "Kayıt işlemi şu anda tamamlanamadı. Lütfen bir süre sonra tekrar deneyin.", +}; + +const GENERIC_SUBMIT = + "Gönderiminiz alınamadı. Lütfen bilgilerinizi kontrol edip tekrar deneyin."; + +/** + * Maps API errors to safe Turkish copy for the public form submission flow. + * Never surfaces raw JSON, HTTP status text, or field_id / regex messages. + */ +export function messageForPublicFormSubmitError(error: unknown): string { + if (error instanceof ApiClientError) { + const code = getErrorCode(error.detail); + if (code && SUBMIT_ERROR_MESSAGES[code]) { + return SUBMIT_ERROR_MESSAGES[code]; + } + if (error.status === 404) { + return SUBMIT_ERROR_MESSAGES.form_not_found; + } + if (error.status === 409) { + return "Bu işlem şu anda tamamlanamadı. Lütfen tekrar deneyin."; + } + if (error.status === 422) { + return SUBMIT_ERROR_MESSAGES.invalid_submission_payload; + } + if (error.status === 502) { + return SUBMIT_ERROR_MESSAGES.user_service_unavailable; + } + if (error.status >= 500) { + return "Sunucu geçici olarak yanıt veremiyor. Lütfen daha sonra tekrar deneyin."; + } + return GENERIC_SUBMIT; + } + return "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin."; +} diff --git a/services/form/.env.example b/services/form/.env.example index f46b5d8..36ac6ae 100644 --- a/services/form/.env.example +++ b/services/form/.env.example @@ -5,7 +5,10 @@ PORT=8002 ENV=development CORS_ALLOW_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 CORS_ALLOW_ORIGIN_REGEX=https?://(localhost|127\\.0\\.0\\.1)(:\\d+)?$ -# Admin API token for protecting admin endpoints (POST, PUT, DELETE /forms) +# Admin API token for protecting admin endpoints (POST, PUT, DELETE /forms, GET /forms list, GET /submissions) # Generate a secure random token: python -c "import secrets; print(secrets.token_hex(32))" # WARNING: The previous token was exposed in git history - generate a NEW token for production! -ADMIN_API_TOKEN=your-secure-random-token-here \ No newline at end of file +ADMIN_API_TOKEN=your-secure-random-token-here +# User service proxy (form service forwards frontend user requests server-side) +USER_SERVICE_URL=http://localhost:8001 +USER_SERVICE_TOKEN=your-user-service-token-here \ No newline at end of file diff --git a/services/form/.env.prod.example b/services/form/.env.prod.example index 600596d..81e8f9d 100644 --- a/services/form/.env.prod.example +++ b/services/form/.env.prod.example @@ -5,3 +5,8 @@ PORT=8002 ENV=production CORS_ALLOW_ORIGINS=https://.vercel.app CORS_ALLOW_ORIGIN_REGEX=^$ +# Generate a secure random token: python -c "import secrets; print(secrets.token_hex(32))" +ADMIN_API_TOKEN= +# User service proxy (must match FORM_FRONTEND_TOKEN on user service) +USER_SERVICE_URL=https://.vercel.app +USER_SERVICE_TOKEN= diff --git a/services/form/app/auth/api_key.py b/services/form/app/auth/api_key.py index 5d13e4f..c7346e0 100644 --- a/services/form/app/auth/api_key.py +++ b/services/form/app/auth/api_key.py @@ -1,5 +1,7 @@ """API key authentication for admin endpoints.""" +import hmac + from fastapi import HTTPException, Security from fastapi.security import APIKeyHeader @@ -25,7 +27,7 @@ async def verify_api_key(api_key: str = Security(api_key_header)) -> str: logger.warning("API request without authentication token") raise HTTPException(status_code=401, detail="Missing API token") - if api_key != settings.ADMIN_API_TOKEN: + if not hmac.compare_digest(api_key, settings.ADMIN_API_TOKEN): logger.warning("API request with invalid authentication token") raise HTTPException(status_code=401, detail="Invalid API token") diff --git a/services/form/app/clients/__init__.py b/services/form/app/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/form/app/clients/user_client.py b/services/form/app/clients/user_client.py new file mode 100644 index 0000000..cc6715f --- /dev/null +++ b/services/form/app/clients/user_client.py @@ -0,0 +1,57 @@ +"""HTTP client for proxying requests to the user service.""" + +import httpx + +from app.config import settings +from app.utils.logger import logger + +_client: httpx.AsyncClient | None = None + + +def _get_client() -> httpx.AsyncClient: + """Lazy-initialize a module-level async HTTP client.""" + global _client + if _client is None or _client.is_closed: + _client = httpx.AsyncClient( + base_url=settings.USER_SERVICE_URL, + headers={"X-API-Token": settings.USER_SERVICE_TOKEN}, + timeout=httpx.Timeout(10.0), + ) + return _client + + +async def forward_get(path: str) -> httpx.Response: + """Forward a GET request to the user service.""" + client = _get_client() + logger.debug(f"Proxying GET {path} to user service") + return await client.get(path) + + +async def forward_post(path: str, body: bytes | None = None) -> httpx.Response: + """Forward a POST request to the user service.""" + client = _get_client() + logger.debug(f"Proxying POST {path} to user service") + return await client.post( + path, + content=body, + headers={"Content-Type": "application/json"}, + ) + + +async def forward_put(path: str, body: bytes | None = None) -> httpx.Response: + """Forward a PUT request to the user service.""" + client = _get_client() + logger.debug(f"Proxying PUT {path} to user service") + return await client.put( + path, + content=body, + headers={"Content-Type": "application/json"}, + ) + + +async def close() -> None: + """Close the HTTP client if open.""" + global _client + if _client is not None and not _client.is_closed: + await _client.aclose() + _client = None diff --git a/services/form/app/config.py b/services/form/app/config.py index ec57e1b..75a8492 100644 --- a/services/form/app/config.py +++ b/services/form/app/config.py @@ -14,6 +14,10 @@ class Settings(BaseSettings): # SECURITY: No default value - must be set via .env file ADMIN_API_TOKEN: str + # User service connection (for proxying frontend requests server-side) + USER_SERVICE_URL: str = "http://localhost:8001" + USER_SERVICE_TOKEN: str = "" + model_config = SettingsConfigDict( env_file=".env", env_ignore_empty=True, diff --git a/services/form/app/main.py b/services/form/app/main.py index b7cdb52..6f3e147 100644 --- a/services/form/app/main.py +++ b/services/form/app/main.py @@ -3,13 +3,18 @@ from contextlib import asynccontextmanager import uvicorn -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from app.clients import user_client from app.config import settings from app.db.mongodb import MongoDB from app.routers import forms_router from app.routers import submissions +from app.routers import users +from app.utils.logger import logger @asynccontextmanager @@ -17,14 +22,20 @@ async def lifespan(app: FastAPI): """Lifespan context manager for the FastAPI application.""" await MongoDB.connect() yield + await user_client.close() await MongoDB.close() +_is_dev = settings.ENV == "development" + app = FastAPI( title="Form Service", description="form microservice", version="0.1.0", lifespan=lifespan, + docs_url="/docs" if _is_dev else None, + redoc_url="/redoc" if _is_dev else None, + openapi_url="/openapi.json" if _is_dev else None, ) app.add_middleware( @@ -32,13 +43,30 @@ async def lifespan(app: FastAPI): allow_origins=settings.cors_allow_origins, allow_origin_regex=settings.CORS_ALLOW_ORIGIN_REGEX, allow_credentials=False, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "X-API-Token"], ) + +@app.exception_handler(RequestValidationError) +async def request_validation_exception_handler( + request: Request, exc: RequestValidationError +) -> JSONResponse: + """Hide Pydantic field paths from public POST /submissions responses.""" + path = request.url.path.rstrip("/") + if request.method == "POST" and path.endswith("/submissions"): + logger.warning(f"Submission payload validation failed: {exc.errors()}") + return JSONResponse( + status_code=422, + content={"detail": {"code": "invalid_submission_payload"}}, + ) + return JSONResponse(status_code=422, content={"detail": exc.errors()}) + + # Include routers app.include_router(submissions.router) app.include_router(forms_router) +app.include_router(users.router) @app.get("/health", tags=["health"]) diff --git a/services/form/app/routers/forms.py b/services/form/app/routers/forms.py index e6bd2e8..d28c3b5 100644 --- a/services/form/app/routers/forms.py +++ b/services/form/app/routers/forms.py @@ -88,7 +88,7 @@ async def get_form( raise HTTPException(status_code=500, detail="Database error occurred") -@router.get("/") +@router.get("/", dependencies=[Depends(verify_api_key)]) async def list_forms( skip: Annotated[int, Query(ge=0)] = 0, limit: Annotated[int, Query(ge=1, le=100)] = 10, @@ -126,7 +126,7 @@ async def list_forms( except ValueError as e: logger.warning(f"Invalid pagination parameters: {e}") - raise HTTPException(status_code=400, detail=str(e)) + raise HTTPException(status_code=400, detail="Invalid pagination parameters") except PyMongoError as e: logger.error(f"Database error while listing forms: {e}") raise HTTPException(status_code=500, detail="Database error occurred") diff --git a/services/form/app/routers/submissions.py b/services/form/app/routers/submissions.py index a17e51f..d9ce203 100644 --- a/services/form/app/routers/submissions.py +++ b/services/form/app/routers/submissions.py @@ -2,8 +2,9 @@ from typing import Annotated -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query +from app.auth import verify_api_key from app.models.submission import ( PaginatedSubmissionsResponse, SubmissionCreate, @@ -15,6 +16,7 @@ InvalidObjectIdError, SubmissionService, ) +from app.utils.logger import logger router = APIRouter(prefix="/submissions", tags=["submissions"]) @@ -32,14 +34,21 @@ async def create_submission( submission = await SubmissionService.create_submission(submission_data) return SubmissionResponse.from_db(submission) except FormNotFoundError: - raise HTTPException(status_code=404, detail="Form not found") + raise HTTPException(status_code=404, detail={"code": "form_not_found"}) except FormValidationError as e: - raise HTTPException(status_code=400, detail=str(e)) + logger.warning( + f"Submission rejected: code={e.code} internal={e.internal_note!r}" + ) + raise HTTPException(status_code=400, detail={"code": e.code}) except InvalidObjectIdError: - raise HTTPException(status_code=400, detail="Invalid form ID format") + raise HTTPException(status_code=400, detail={"code": "invalid_form_id"}) -@router.get("/{submission_id}", response_model=SubmissionResponse) +@router.get( + "/{submission_id}", + response_model=SubmissionResponse, + dependencies=[Depends(verify_api_key)], +) async def get_submission_by_id( submission_id: str, ) -> SubmissionResponse: @@ -53,7 +62,11 @@ async def get_submission_by_id( raise HTTPException(status_code=400, detail="Invalid submission ID format") -@router.get("/by-form/{form_id}", response_model=PaginatedSubmissionsResponse) +@router.get( + "/by-form/{form_id}", + response_model=PaginatedSubmissionsResponse, + dependencies=[Depends(verify_api_key)], +) async def get_submissions_by_form( form_id: str, skip: Annotated[int, Query(ge=0)] = 0, diff --git a/services/form/app/routers/users.py b/services/form/app/routers/users.py new file mode 100644 index 0000000..0324581 --- /dev/null +++ b/services/form/app/routers/users.py @@ -0,0 +1,52 @@ +"""Proxy router that forwards user-related requests to the user service. + +Keeps the user-service API token server-side instead of exposing it +in the frontend JavaScript bundle. +""" + +import httpx +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse + +from app.clients import user_client +from app.utils.logger import logger + +router = APIRouter(prefix="/users", tags=["users"]) + + +def _map_response(resp: httpx.Response) -> JSONResponse: + """Convert an httpx response into a FastAPI JSONResponse.""" + if resp.status_code >= 500: + logger.error(f"User service returned {resp.status_code}") + raise HTTPException(status_code=502, detail="User service unavailable") + return JSONResponse(content=resp.json(), status_code=resp.status_code) + + +@router.get("/by-email/{email}") +async def get_user_by_email(email: str) -> JSONResponse: + """Proxy: look up a user by email.""" + resp = await user_client.forward_get(f"/users/by-email/{email}") + return _map_response(resp) + + +@router.post("/", status_code=201) +async def create_user(request: Request) -> JSONResponse: + """Proxy: create a new user.""" + body = await request.body() + resp = await user_client.forward_post("/users/", body) + return _map_response(resp) + + +@router.put("/by-email/{email}") +async def update_user(email: str, request: Request) -> JSONResponse: + """Proxy: update user by email.""" + body = await request.body() + resp = await user_client.forward_put(f"/users/by-email/{email}", body) + return _map_response(resp) + + +@router.post("/by-email/{email}/forms/{form_id}") +async def record_form_submission(email: str, form_id: str) -> JSONResponse: + """Proxy: record a form submission for a user.""" + resp = await user_client.forward_post(f"/users/by-email/{email}/forms/{form_id}") + return _map_response(resp) diff --git a/services/form/app/services/submission_service.py b/services/form/app/services/submission_service.py index 22dad6f..22e7fc8 100644 --- a/services/form/app/services/submission_service.py +++ b/services/form/app/services/submission_service.py @@ -25,9 +25,18 @@ class FormNotFoundError(Exception): class FormValidationError(Exception): - """Raised when form or submission answer validation fails.""" + """Raised when form or submission answer validation fails. - pass + `code` is a stable machine-readable identifier returned to public clients. + `internal_note` is for server logs only (may contain field_id, etc.). + """ + + __slots__ = ("code", "internal_note") + + def __init__(self, code: str, *, internal_note: str | None = None) -> None: + self.code = code + self.internal_note = internal_note + super().__init__(internal_note or code) class InvalidObjectIdError(Exception): @@ -118,10 +127,10 @@ async def _validate_form(cls, form_id: PyObjectId) -> FormInDB: form = FormInDB.model_validate(form_doc) except Exception as e: logger.error(f"Error converting form document to FormInDB: {e}") - raise FormValidationError(f"Invalid form data for id: {form_id}") + raise FormValidationError("invalid_form_schema", internal_note=str(form_id)) if not form.is_active: - raise FormValidationError("Form is not active") + raise FormValidationError("form_not_active") now = datetime.now(timezone.utc) @@ -131,14 +140,14 @@ async def _validate_form(cls, form_id: PyObjectId) -> FormInDB: if start_date.tzinfo is None: start_date = start_date.replace(tzinfo=timezone.utc) if start_date > now: - raise FormValidationError("Form has not started yet") + raise FormValidationError("form_not_started") if form.deadline is not None: deadline = form.deadline if deadline.tzinfo is None: deadline = deadline.replace(tzinfo=timezone.utc) if deadline < now: - raise FormValidationError("Form deadline has passed") + raise FormValidationError("form_deadline_passed") return form @@ -206,7 +215,8 @@ def _validate_required_answers( if not cls._is_answer_provided(field, answers.get(field.field_id)): raise FormValidationError( - f"Required field '{field.field_id}' is missing or empty" + "required_answer_incomplete", + internal_note=field.field_id, ) @classmethod diff --git a/services/form/pyproject.toml b/services/form/pyproject.toml index d2cee19..53bc043 100644 --- a/services/form/pyproject.toml +++ b/services/form/pyproject.toml @@ -8,6 +8,7 @@ requires-python = ">=3.14" dependencies = [ "email-validator>=2.3.0", "fastapi>=0.128.0", + "httpx>=0.28.0", "motor>=3.7.1", "pydantic-settings>=2.12.0", "python-dotenv>=1.2.1", diff --git a/services/form/tests/test_api_forms.py b/services/form/tests/test_api_forms.py index c3636d8..8ce9ea6 100644 --- a/services/form/tests/test_api_forms.py +++ b/services/form/tests/test_api_forms.py @@ -86,26 +86,28 @@ def test_get_form_invalid_id(self, sync_client, mock_mongodb): class TestListFormsAPI: """Test GET /forms/ endpoint.""" - def test_list_forms_empty(self, sync_client, mock_mongodb): + def test_list_forms_empty(self, sync_client, mock_mongodb, auth_headers): """GET /forms/ returns empty list.""" mock_mongodb["forms"].count_documents = AsyncMock(return_value=0) cursor = create_async_cursor([]) mock_mongodb["forms"].find = MagicMock(return_value=cursor) - response = sync_client.get("/forms/") + response = sync_client.get("/forms/", headers=auth_headers) assert response.status_code == 200 data = response.json() assert data["forms"] == [] assert data["total"] == 0 - def test_list_forms_with_results(self, sync_client, mock_mongodb, sample_form_doc): + def test_list_forms_with_results( + self, sync_client, mock_mongodb, sample_form_doc, auth_headers + ): """GET /forms/ returns forms with pagination info.""" mock_mongodb["forms"].count_documents = AsyncMock(return_value=1) cursor = create_async_cursor([sample_form_doc]) mock_mongodb["forms"].find = MagicMock(return_value=cursor) - response = sync_client.get("/forms/?skip=0&limit=10") + response = sync_client.get("/forms/?skip=0&limit=10", headers=auth_headers) assert response.status_code == 200 data = response.json() diff --git a/services/form/tests/test_api_submissions.py b/services/form/tests/test_api_submissions.py index c4a1f99..c7e8ed7 100644 --- a/services/form/tests/test_api_submissions.py +++ b/services/form/tests/test_api_submissions.py @@ -162,14 +162,14 @@ def test_create_submission_missing_visible_conditional_field( ) assert response.status_code == 400 - assert "turkish_identity_number" in response.json()["detail"] + assert response.json()["detail"]["code"] == "required_answer_incomplete" class TestGetSubmissionAPI: """Test GET /submissions/{submission_id} endpoint.""" def test_get_submission_found( - self, sync_client, mock_mongodb, sample_submission_doc + self, sync_client, mock_mongodb, sample_submission_doc, auth_headers ): """GET /submissions/{id} returns 200 when submission exists.""" mock_mongodb["submissions"].find_one = AsyncMock( @@ -180,13 +180,15 @@ def test_get_submission_found( "app.services.submission_service.MongoDB.get_db", return_value=mock_mongodb["db"], ): - response = sync_client.get(f"/submissions/{SAMPLE_SUBMISSION_ID}") + response = sync_client.get( + f"/submissions/{SAMPLE_SUBMISSION_ID}", headers=auth_headers + ) assert response.status_code == 200 data = response.json() assert data["respondent_email"] == "john@example.com" - def test_get_submission_not_found(self, sync_client, mock_mongodb): + def test_get_submission_not_found(self, sync_client, mock_mongodb, auth_headers): """GET /submissions/{id} returns 404 when submission does not exist.""" mock_mongodb["submissions"].find_one = AsyncMock(return_value=None) @@ -194,17 +196,19 @@ def test_get_submission_not_found(self, sync_client, mock_mongodb): "app.services.submission_service.MongoDB.get_db", return_value=mock_mongodb["db"], ): - response = sync_client.get(f"/submissions/{SAMPLE_SUBMISSION_ID}") + response = sync_client.get( + f"/submissions/{SAMPLE_SUBMISSION_ID}", headers=auth_headers + ) assert response.status_code == 404 - def test_get_submission_invalid_id(self, sync_client, mock_mongodb): + def test_get_submission_invalid_id(self, sync_client, mock_mongodb, auth_headers): """GET /submissions/{id} returns 400 for invalid ObjectId.""" with patch( "app.services.submission_service.MongoDB.get_db", return_value=mock_mongodb["db"], ): - response = sync_client.get("/submissions/bad-id") + response = sync_client.get("/submissions/bad-id", headers=auth_headers) assert response.status_code == 400 @@ -213,7 +217,7 @@ class TestGetSubmissionsByFormAPI: """Test GET /submissions/by-form/{form_id} endpoint.""" def test_get_submissions_by_form( - self, sync_client, mock_mongodb, sample_submission_doc + self, sync_client, mock_mongodb, sample_submission_doc, auth_headers ): """GET /submissions/by-form/{form_id} returns paginated results.""" mock_mongodb["submissions"].count_documents = AsyncMock(return_value=1) @@ -224,19 +228,25 @@ def test_get_submissions_by_form( "app.services.submission_service.MongoDB.get_db", return_value=mock_mongodb["db"], ): - response = sync_client.get(f"/submissions/by-form/{SAMPLE_FORM_ID}") + response = sync_client.get( + f"/submissions/by-form/{SAMPLE_FORM_ID}", headers=auth_headers + ) assert response.status_code == 200 data = response.json() assert data["total"] == 1 assert len(data["submissions"]) == 1 - def test_get_submissions_by_form_invalid_id(self, sync_client, mock_mongodb): + def test_get_submissions_by_form_invalid_id( + self, sync_client, mock_mongodb, auth_headers + ): """GET /submissions/by-form/{form_id} returns 400 for invalid form ID.""" with patch( "app.services.submission_service.MongoDB.get_db", return_value=mock_mongodb["db"], ): - response = sync_client.get("/submissions/by-form/bad-id") + response = sync_client.get( + "/submissions/by-form/bad-id", headers=auth_headers + ) assert response.status_code == 400 diff --git a/services/form/tests/test_submission_service.py b/services/form/tests/test_submission_service.py index 79b3964..86b5dbe 100644 --- a/services/form/tests/test_submission_service.py +++ b/services/form/tests/test_submission_service.py @@ -94,8 +94,9 @@ async def test_create_submission_form_inactive( form_doc = _make_active_form_doc(is_active=False) mock_submission_collections["forms"].find_one = AsyncMock(return_value=form_doc) - with pytest.raises(FormValidationError, match="not active"): + with pytest.raises(FormValidationError) as exc_info: await SubmissionService.create_submission(sample_submission_data) + assert exc_info.value.code == "form_not_active" async def test_create_submission_form_not_started( self, mock_submission_collections, sample_submission_data @@ -105,8 +106,9 @@ async def test_create_submission_form_not_started( form_doc = _make_active_form_doc(start_date=future_date) mock_submission_collections["forms"].find_one = AsyncMock(return_value=form_doc) - with pytest.raises(FormValidationError, match="not started yet"): + with pytest.raises(FormValidationError) as exc_info: await SubmissionService.create_submission(sample_submission_data) + assert exc_info.value.code == "form_not_started" async def test_create_submission_deadline_passed( self, mock_submission_collections, sample_submission_data @@ -116,8 +118,9 @@ async def test_create_submission_deadline_passed( form_doc = _make_active_form_doc(deadline=past_date) mock_submission_collections["forms"].find_one = AsyncMock(return_value=form_doc) - with pytest.raises(FormValidationError, match="deadline has passed"): + with pytest.raises(FormValidationError) as exc_info: await SubmissionService.create_submission(sample_submission_data) + assert exc_info.value.code == "form_deadline_passed" async def test_create_submission_requires_conditional_field_when_visible( self, mock_submission_collections @@ -159,8 +162,10 @@ async def test_create_submission_requires_conditional_field_when_visible( mock_submission_collections["forms"].find_one = AsyncMock(return_value=form_doc) - with pytest.raises(FormValidationError, match="turkish_identity_number"): + with pytest.raises(FormValidationError) as exc_info: await SubmissionService.create_submission(submission_data) + assert exc_info.value.code == "required_answer_incomplete" + assert exc_info.value.internal_note == "turkish_identity_number" async def test_create_submission_skips_conditional_field_when_hidden( self, mock_submission_collections