Files
mobiniqow a464e550c9 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
2026-09-03 08:13:50 +00:00

14 KiB
Raw Permalink Blame History

مرجع API

پایه: /api/v1/ · نسخه‌بندی از طریق namespace آدرس‌ها · اسکیمای ماشین‌خوان: openapi.yaml

مستندات تعاملی (همیشه به‌روز، از روی کد تولید می‌شود):


۱. قراردادهای عمومی

۱.۱ احراز هویت

Authorization: Bearer <access_token>

سیاست پیش‌فرض deny است: هر endpoint که صریحاً عمومی اعلام نشده، لاگین می‌خواهد.

۱.۲ قالب خطا

{
  "error": {
    "code": "validation_error",
    "message": "The request payload failed validation.",
    "details": { "min_experience": ["min_experience cannot be greater than max_experience."] },
    "request_id": "9f2c1a7e8b4d4c0e"
  }
}
code HTTP معنی
validation_error 400 ورودی نامعتبر (details فیلد به فیلد)
authentication_failed / not_authenticated 401 توکن نبود یا منقضی شده
permission_denied 403 نقش کافی نیست
not_found 404 منبع وجود ندارد
method_not_allowed 405 متد پشتیبانی نمی‌شود
throttled 429 سقف نرخ رد شده
search_unavailable 503 موتور سرچ در دسترس نیست
internal_error 500 خطای پیش‌بینی‌نشده (جزئیات فقط در لاگ)

۱.۳ هدرها

هدر جهت توضیح
X-Request-ID ورودی (اختیاری) اگر بفرستید همان برمی‌گردد، اگر نه تولید می‌شود
X-Request-ID خروجی کلید ردیابی درخواست در Kibana

۱.۴ سقف نرخ

گروه پیش‌فرض کلید
ناشناس ۳۰/دقیقه IP
کاربر لاگین‌کرده ۳۰۰/دقیقه user id
سرچ ۱۲۰/دقیقه user id
لاگین/ثبت‌نام ۱۰/دقیقه IP (چون نام کاربری را مهاجم انتخاب می‌کند)

۲. احراز هویت

POST /api/v1/auth/register/ — عمومی

// درخواست
{ "email": "me@example.com", "full_name": "Me", 
  "password": "Str0ng-Passw0rd!", "password_confirm": "Str0ng-Passw0rd!" }

// 201
{ "id": 4, "email": "me@example.com", "full_name": "Me",
  "role": "viewer", "can_view_pii": false, "date_joined": "2026-09-03T04:00:00Z" }

ثبت‌نام همیشه نقش viewer می‌دهد؛ ارتقا یک اقدام صریح ادمین است. ایمیل نرمال (lowercase) می‌شود. رمز از اعتبارسنج‌های جنگو رد می‌شود (حداقل ۱۰ کاراکتر، غیرعددی، غیرمعمول، غیرمشابه ایمیل).

POST /api/v1/auth/login/ — عمومی

// درخواست
{ "email": "analyst@example.com", "password": "Analyst-Passw0rd!" }

// 200
{ "access": "eyJhbGciOi...",   // عمر ۱۵ دقیقه
  "refresh": "eyJhbGciOi...",  // عمر ۷ روز، چرخشی
  "user": { "id": 2, "email": "analyst@example.com", "role": "analyst",
            "can_view_pii": true, ... } }

POST /api/v1/auth/refresh/ — عمومی

{ "refresh": "..." }   →   { "access": "...", "refresh": "..." }

توکن refresh چرخشی است: هر بار یک جفت جدید می‌گیرید و توکن قبلی بلاک‌لیست می‌شود. اگر یک توکن دزدیده و استفاده شود، استفاده بعدی کاربر واقعی رد می‌شود — یعنی نشت قابل تشخیص است.

POST /api/v1/auth/logout/ — لاگین لازم

{ "refresh": "..." }   →   205 No Content

GET /api/v1/auth/me/ — لاگین لازم

{ "id": 2, "email": "analyst@example.com", "full_name": "Demo Analyst",
  "role": "analyst", "can_view_pii": true, "date_joined": "..." }

۳. سرچ

GET /api/v1/search/ — لاگین لازم

پارامترها

نام نوع پیش‌فرض توضیح
q string "" کلیدواژه؛ حداکثر ۲۰۰ کاراکتر. از عبارت داخل گیومه و -حذف پشتیبانی می‌کند
skills لیست — مهارت‌ها (OR داخلی)
roles لیست — خانواده شغلی، مثل engineering
seniority لیست — senior, manager, director, vp, cxo, ...
countries لیست — کشور محل سکونت
industries لیست — صنعت کارفرمای فعلی
companies لیست — نام کارفرمای فعلی
min_experience int — ۰ تا ۷۰
max_experience int — ۰ تا ۷۰
sort enum relevance relevance | -experience | experience | name
page int 1
page_size int 20 حداکثر ۱۰۰
facets bool true false یعنی aggregation اجرا نشود

لیست‌ها هر دو نگارش را می‌پذیرند: ?skills=a,b یا ?skills=a&skills=b. حداکثر ۲۰ مقدار در هر فیلتر.

پاسخ ۲۰۰

{
  "count": 41,
  "page": 1,
  "page_size": 20,
  "total_pages": 3,
  "took_ms": 12,
  "backend": "elasticsearch",          // یا "postgres"
  "results": [
    {
      "id": 118,
      "score": 14.82,
      "full_name": "joseph holland",
      "headline": "recruiting manager at garver",
      "job_title": "recruiting manager",
      "job_title_role": "human_resources",
      "seniority_levels": ["manager"],
      "company": "garver",
      "industry": "civil engineering",
      "location_name": "denton, texas, united states",
      "location_country": "united states",
      "years_experience": 12,
      "linkedin_url": "linkedin.com/in/joeyholland",
      "skills": ["recruiting", "leadership", "human resources", "training"],
      "highlight": {
        "skills": ["<mark>leadership</mark>"],
        "job_title": ["recruiting <mark>manager</mark>"]
      }
    }
  ],
  "facets": {
    "skills":         [{ "key": "leadership", "count": 41 }, { "key": "management", "count": 33 }],
    "job_title_role": [{ "key": "human_resources", "count": 12 }],
    "seniority":      [{ "key": "manager", "count": 29 }],
    "country":        [{ "key": "united states", "count": 41 }],
    "industry":       [{ "key": "banking", "count": 6 }],
    "company":        [{ "key": "garver", "count": 2 }]
  }
}

نکته‌ها

  • score فقط با sort=relevance معنادار است؛ در مرتب‌سازی‌های دیگر نمایش داده نمی‌شود.
  • highlight فقط وقتی q داده شده باشد پر می‌شود و شامل تگ <mark> است. آن را به‌عنوان HTML خام رندر نکنید — کامپوننت Highlight در فرانت‌اند نشان می‌دهد چطور امن رندر شود.
  • facets روی مجموعه فیلترشده فعلی محاسبه می‌شود، نه کل دیتاست.
  • backend برای شفافیت عملیاتی است: مشخص می‌کند کدام موتور جواب داده.

خطاها

// 400 - بازه نامعتبر
{"error":{"code":"validation_error","message":"The request payload failed validation.",
          "details":{"min_experience":["min_experience cannot be greater than max_experience."]},
          "request_id":"..."}}

// 400 - صفحه‌بندی عمیق
{"error":{"code":"validation_error","details":{"page":["Cannot page beyond 10000 results."]}, ...}}

// 429
{"error":{"code":"throttled","message":"Request was throttled. Expected available in 42 seconds.", ...}}

// 503 - موتور سرچ در دسترس نیست
{"error":{"code":"search_unavailable","message":"The search backend is currently unavailable.", ...}}

GET /api/v1/search/suggest/ — لاگین لازم

?q=mar&limit=8      →     { "suggestions": ["maria frontend", "mark heinrich", ...] }

از فیلد edge n-gram روی نام و مهارت استفاده می‌کند. limit بین ۱ تا ۲۰.

POST /api/v1/search/reindex/ — فقط نقش admin

// 202
{ "alias": "linkedin-profiles",
  "index": "linkedin-profiles-20260903040012",
  "indexed": 284, "errors": 0,
  "replaced": ["linkedin-profiles-20260901101500"] }

بازسازی بدون قطعی: ایندکس فیزیکی جدید ساخته می‌شود، پر می‌شود، و بعد alias به‌صورت اتمیک جابه‌جا می‌شود. کاربر هیچ‌وقت ایندکس نیمه‌پر نمی‌بیند.


۴. پروفایل‌ها

GET /api/v1/profiles/ — لاگین لازم

لیست ساده و بدون رتبه‌بندی، با صفحه‌بندی استاندارد. برای مرور و بررسی دیتاست مفید است؛ سرچ رتبه‌بندی‌شده در /search/ است.

{ "count": 284, "page": 1, "page_size": 20, "total_pages": 15,
  "next": "http://.../profiles/?page=2", "previous": null,
  "results": [ /* همان شکل کارت نتیجه */ ] }

GET /api/v1/profiles/{id}/ — لاگین لازم

{
  "id": 118,
  "full_name": "joseph holland",
  "headline": "recruiting manager at garver",
  "current_company": { "id": 42, "name": "garver", "industry": "civil engineering",
                       "size": "501-1000", "founded": 1919, "location_name": "..." },
  "summary": "Celebrating its 100th year, Garver is an employee-owned ...",
  "years_experience": 12,
  "connections": 3761,
  "interests": ["guitar", "aviation", "hiking"],
  "languages": ["english"],
  "skills": [{ "name": "recruiting", "slug": "recruiting" }, ...],
  "experiences": [
    { "id": 903, "title": "recruiting manager", "company_name": "garver",
      "start_date": "2019-10", "end_date": "", "is_primary": true,
      "company": { "id": 42, "name": "garver", ... } }
  ],
  "educations": [
    { "id": 210, "school_name": "university of north texas",
      "degrees": ["bachelors", "bachelor of arts"],
      "majors": ["politics", "international studies"],
      "start_date": "2007", "end_date": "2008" }
  ],
  "emails": [{ "address": "j3*******@yahoo.com", "type": "personal" }],
  "phone_numbers": ["+1940*****28"],
  "pii_masked": true
}

pii_masked به فرانت‌اند می‌گوید آیا مقادیر تماس ماسک شده‌اند یا نه، تا بتواند یک پیام روشن نشان دهد به‌جای اینکه کاربر فکر کند داده خراب است.

نقش emails / phone_numbers
viewer ماسک‌شده (j3*******@yahoo.com)
analyst کامل
admin کامل

GET /api/v1/profiles/skills/ — لاگین لازم

?q=py&limit=50   →   [{ "id": 12, "name": "python", "slug": "python", "profile_count": 8 }, ...]

دیکشنری مهارت‌ها، مرتب بر اساس محبوبیت. فرانت‌اند از آن برای type-ahead فیلتر مهارت استفاده می‌کند بدون اینکه برای هر کلید به Elasticsearch بزند.


۵. سلامت

GET /api/v1/health/live/ — عمومی

{ "status": "ok", "service": "linkedin-search-api" }

فقط می‌گوید پروسه زنده است. مناسب liveness probe.

GET /api/v1/health/ready/ — عمومی

// 200
{ "status": "ok",
  "checks": { "database": { "healthy": true, "detail": "reachable" },
              "search":   { "healthy": true, "detail": "elasticsearch reachable" } } }

// 503
{ "status": "degraded",
  "checks": { "database": { "healthy": true,  "detail": "reachable" },
              "search":   { "healthy": false, "detail": "Elasticsearch did not answer a ping." } } }

وقتی هر وابستگی‌ای خراب باشد کد ۵۰۳ برمی‌گرداند تا orchestrator ترافیک را به این نمونه نفرستد.


۶. یک جریان کامل با curl

BASE=http://localhost:8080/api/v1

# ۱) ثبت‌نام (اختیاری — کاربران نمونه از قبل هستند)
curl -s -X POST $BASE/auth/register/ -H 'Content-Type: application/json' \
  -d '{"email":"me@example.com","full_name":"Me",
       "password":"Str0ng-Passw0rd!","password_confirm":"Str0ng-Passw0rd!"}' | jq

# ۲) لاگین
TOKENS=$(curl -s -X POST $BASE/auth/login/ -H 'Content-Type: application/json' \
  -d '{"email":"analyst@example.com","password":"Analyst-Passw0rd!"}')
ACCESS=$(echo "$TOKENS"  | jq -r .access)
REFRESH=$(echo "$TOKENS" | jq -r .refresh)

# ۳) هویت
curl -s $BASE/auth/me/ -H "Authorization: Bearer $ACCESS" | jq

# ۴) سرچ با کلیدواژه و دو فیلتر
curl -s -G $BASE/search/ -H "Authorization: Bearer $ACCESS" \
  --data-urlencode 'q=leadership' \
  --data-urlencode 'skills=management,training' \
  --data-urlencode 'countries=united states' \
  --data-urlencode 'sort=-experience' | jq '{count, backend, took_ms,
      top: [.results[:3][] | {full_name, headline, years_experience}]}'

# ۵) جزئیات اولین نتیجه
ID=$(curl -s -G $BASE/search/ -H "Authorization: Bearer $ACCESS" \
     --data-urlencode 'q=leadership' | jq -r '.results[0].id')
curl -s $BASE/profiles/$ID/ -H "Authorization: Bearer $ACCESS" | jq '{full_name, pii_masked, emails}'

# ۶) چرخش توکن
curl -s -X POST $BASE/auth/refresh/ -H 'Content-Type: application/json' \
  -d "{\"refresh\":\"$REFRESH\"}" | jq

# ۷) خروج
curl -s -o /dev/null -w '%{http_code}\n' -X POST $BASE/auth/logout/ \
  -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \
  -d "{\"refresh\":\"$REFRESH\"}"