feat: LinkedIn profile search engine (Django + React + Elasticsearch + ELK)
Full-stack implementation of the Cyberyan full-stack trial task. Backend (Django 5 / DRF) - Normalised PostgreSQL model (Profile, Company, School, Skill, Experience, Education) as the source of truth, with GIN + trigram indexes. - Self-healing CSV reader: the supplied export carries an injected watermark column, missing columns, rows split by broken quoting and a duplicated header. A signature-scored Needleman-Wunsch alignment reconstructs 284 clean records out of 336 physical rows; only 10 rows parse correctly without it. - Pluggable search layer (Strategy): Elasticsearch by default, PostgreSQL full-text search as a fallback selected by a single environment variable. - Keyword search with per-field boosts, fuzziness and phrase boosting, six faceted filters (OR within a filter, AND across filters), an experience range, four sort orders, highlighting and facet aggregations. - JWT auth with rotation + blacklist, three roles, PII masking, scoped rate limits, a single error envelope, OpenAPI schema and health probes. Frontend (React 18 / TypeScript / Vite) - Debounced search with type-ahead, faceted filter panel, result cards, profile drawer, URL-synced search state, automatic token refresh. - Highlight snippets rendered without dangerouslySetInnerHTML. Infrastructure - Hardened multi-stage images (non-root, read-only rootfs, healthchecks). - docker-compose for the full stack plus dev and production overlays. - ELK: structured JSON logs shipped asynchronously to Logstash, routed per event type, with a composable index template and a Kibana setup script. Quality - 61 backend tests (85% coverage), ruff/black/bandit/mypy, ESLint + tsc, GitHub Actions running lint, tests and both image builds. - Persian documentation: README plus architecture, search, API, data, security and operations guides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GnFo6fNaHCTAkJbfcdkqtG
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
A single, predictable error envelope for the whole API.
|
||||
|
||||
Every failure - validation, permission, throttling, or an unexpected crash -
|
||||
comes back in the same shape:
|
||||
|
||||
{"error": {"code": "validation_error", "message": "...", "details": {...},
|
||||
"request_id": "..."}}
|
||||
|
||||
Unhandled exceptions are logged with their stack trace but never leak internals
|
||||
to the client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import Http404
|
||||
from rest_framework import exceptions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import exception_handler as drf_exception_handler
|
||||
|
||||
from apps.core.logging import get_request_id
|
||||
|
||||
logger = logging.getLogger("apps.errors")
|
||||
|
||||
_CODE_BY_STATUS = {
|
||||
status.HTTP_400_BAD_REQUEST: "validation_error",
|
||||
status.HTTP_401_UNAUTHORIZED: "authentication_failed",
|
||||
status.HTTP_403_FORBIDDEN: "permission_denied",
|
||||
status.HTTP_404_NOT_FOUND: "not_found",
|
||||
status.HTTP_405_METHOD_NOT_ALLOWED: "method_not_allowed",
|
||||
status.HTTP_429_TOO_MANY_REQUESTS: "throttled",
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE: "service_unavailable",
|
||||
}
|
||||
|
||||
|
||||
class ServiceUnavailable(exceptions.APIException):
|
||||
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
default_detail = "A downstream dependency is unavailable. Please retry shortly."
|
||||
default_code = "service_unavailable"
|
||||
|
||||
|
||||
class SearchBackendError(ServiceUnavailable):
|
||||
default_detail = "The search backend is currently unavailable."
|
||||
default_code = "search_unavailable"
|
||||
|
||||
|
||||
def api_exception_handler(exc: Exception, context: dict[str, Any]) -> Response | None:
|
||||
response = drf_exception_handler(exc, context)
|
||||
|
||||
if response is None:
|
||||
# Anything DRF does not know about is a bug on our side.
|
||||
if isinstance(exc, Http404):
|
||||
response = Response(status=status.HTTP_404_NOT_FOUND)
|
||||
elif isinstance(exc, PermissionDenied):
|
||||
response = Response(status=status.HTTP_403_FORBIDDEN)
|
||||
else:
|
||||
logger.exception(
|
||||
"unhandled_exception",
|
||||
extra={"event": "unhandled_exception", "view": str(context.get("view"))},
|
||||
)
|
||||
return Response(
|
||||
_envelope("internal_error", "An unexpected error occurred.", None),
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
detail = getattr(exc, "detail", None)
|
||||
if isinstance(exc, exceptions.ValidationError):
|
||||
# DRF labels every field error "invalid"; one stable code per failure
|
||||
# mode is far easier for a client to branch on.
|
||||
code = "validation_error"
|
||||
else:
|
||||
code = getattr(exc, "default_code", None) or _CODE_BY_STATUS.get(
|
||||
response.status_code, "error"
|
||||
)
|
||||
|
||||
if isinstance(detail, dict):
|
||||
message = "The request payload failed validation."
|
||||
details: Any = detail
|
||||
elif isinstance(detail, list):
|
||||
message = str(detail[0]) if detail else "Request failed."
|
||||
details = detail
|
||||
elif detail is not None:
|
||||
message = str(detail)
|
||||
details = None
|
||||
else:
|
||||
message = response.status_text
|
||||
details = None
|
||||
|
||||
response.data = _envelope(code, message, details)
|
||||
return response
|
||||
|
||||
|
||||
def _envelope(code: str, message: str, details: Any) -> dict[str, Any]:
|
||||
error: dict[str, Any] = {"code": code, "message": message, "request_id": get_request_id()}
|
||||
if details:
|
||||
error["details"] = details
|
||||
return {"error": error}
|
||||
Reference in New Issue
Block a user