Files
cyberyan_linkedin_search/docs/API.md
T
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

413 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# مرجع API
<div dir="rtl">
پایه: `/api/v1/` · نسخه‌بندی از طریق namespace آدرس‌ها · اسکیمای ماشین‌خوان: [`openapi.yaml`](openapi.yaml)
مستندات تعاملی (همیشه به‌روز، از روی کد تولید می‌شود):
- Swagger UI — <http://localhost:8080/api/docs/>
- ReDoc — <http://localhost:8080/api/redoc/>
</div>
---
## ۱. قراردادهای عمومی
### ۱.۱ احراز هویت
```http
Authorization: Bearer <access_token>
```
<div dir="rtl">
سیاست پیش‌فرض **deny** است: هر endpoint که صریحاً عمومی اعلام نشده، لاگین می‌خواهد.
</div>
### ۱.۲ قالب خطا
```json
{
"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/` — عمومی
```jsonc
// درخواست
{ "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" }
```
<div dir="rtl">
ثبت‌نام همیشه نقش `viewer` می‌دهد؛ ارتقا یک اقدام صریح ادمین است. ایمیل نرمال (lowercase) می‌شود. رمز از اعتبارسنج‌های جنگو رد می‌شود (حداقل ۱۰ کاراکتر، غیرعددی، غیرمعمول، غیرمشابه ایمیل).
</div>
### `POST /api/v1/auth/login/` — عمومی
```jsonc
// درخواست
{ "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/` — عمومی
```jsonc
{ "refresh": "..." } → { "access": "...", "refresh": "..." }
```
<div dir="rtl">
توکن refresh **چرخشی** است: هر بار یک جفت جدید می‌گیرید و توکن قبلی بلاک‌لیست می‌شود. اگر یک توکن دزدیده و استفاده شود، استفاده بعدی کاربر واقعی رد می‌شود — یعنی نشت قابل تشخیص است.
</div>
### `POST /api/v1/auth/logout/` — لاگین لازم
```jsonc
{ "refresh": "..." } → 205 No Content
```
### `GET /api/v1/auth/me/` — لاگین لازم
```jsonc
{ "id": 2, "email": "analyst@example.com", "full_name": "Demo Analyst",
"role": "analyst", "can_view_pii": true, "date_joined": "..." }
```
---
## ۳. سرچ
### `GET /api/v1/search/` — لاگین لازم
<div dir="rtl">
**پارامترها**
| نام | نوع | پیش‌فرض | توضیح |
|---|---|---|---|
| `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`. حداکثر ۲۰ مقدار در هر فیلتر.
**پاسخ ۲۰۰**
</div>
```jsonc
{
"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 }]
}
}
```
<div dir="rtl">
**نکته‌ها**
- `score` فقط با `sort=relevance` معنادار است؛ در مرتب‌سازی‌های دیگر نمایش داده نمی‌شود.
- `highlight` فقط وقتی `q` داده شده باشد پر می‌شود و شامل تگ `<mark>` است. آن را به‌عنوان HTML خام رندر نکنید — کامپوننت `Highlight` در فرانت‌اند نشان می‌دهد چطور امن رندر شود.
- `facets` روی مجموعه فیلترشده فعلی محاسبه می‌شود، نه کل دیتاست.
- `backend` برای شفافیت عملیاتی است: مشخص می‌کند کدام موتور جواب داده.
**خطاها**
</div>
```jsonc
// 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", ...] }
```
<div dir="rtl">
از فیلد edge n-gram روی نام و مهارت استفاده می‌کند. `limit` بین ۱ تا ۲۰.
</div>
### `POST /api/v1/search/reindex/` — فقط نقش admin
```jsonc
// 202
{ "alias": "linkedin-profiles",
"index": "linkedin-profiles-20260903040012",
"indexed": 284, "errors": 0,
"replaced": ["linkedin-profiles-20260901101500"] }
```
<div dir="rtl">
بازسازی بدون قطعی: ایندکس فیزیکی جدید ساخته می‌شود، پر می‌شود، و بعد alias به‌صورت اتمیک جابه‌جا می‌شود. کاربر هیچ‌وقت ایندکس نیمه‌پر نمی‌بیند.
</div>
---
## ۴. پروفایل‌ها
### `GET /api/v1/profiles/` — لاگین لازم
<div dir="rtl">
لیست ساده و بدون رتبه‌بندی، با صفحه‌بندی استاندارد. برای مرور و بررسی دیتاست مفید است؛ سرچ رتبه‌بندی‌شده در `/search/` است.
</div>
```jsonc
{ "count": 284, "page": 1, "page_size": 20, "total_pages": 15,
"next": "http://.../profiles/?page=2", "previous": null,
"results": [ /* همان شکل کارت نتیجه */ ] }
```
### `GET /api/v1/profiles/{id}/` — لاگین لازم
```jsonc
{
"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
}
```
<div dir="rtl">
**`pii_masked`** به فرانت‌اند می‌گوید آیا مقادیر تماس ماسک شده‌اند یا نه، تا بتواند یک پیام روشن نشان دهد به‌جای اینکه کاربر فکر کند داده خراب است.
| نقش | `emails` / `phone_numbers` |
|---|---|
| `viewer` | ماسک‌شده (`j3*******@yahoo.com`) |
| `analyst` | کامل |
| `admin` | کامل |
</div>
### `GET /api/v1/profiles/skills/` — لاگین لازم
```
?q=py&limit=50 → [{ "id": 12, "name": "python", "slug": "python", "profile_count": 8 }, ...]
```
<div dir="rtl">
دیکشنری مهارت‌ها، مرتب بر اساس محبوبیت. فرانت‌اند از آن برای type-ahead فیلتر مهارت استفاده می‌کند بدون اینکه برای هر کلید به Elasticsearch بزند.
</div>
---
## ۵. سلامت
### `GET /api/v1/health/live/` — عمومی
```jsonc
{ "status": "ok", "service": "linkedin-search-api" }
```
<div dir="rtl">
فقط می‌گوید پروسه زنده است. مناسب liveness probe.
</div>
### `GET /api/v1/health/ready/` — عمومی
```jsonc
// 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." } } }
```
<div dir="rtl">
وقتی هر وابستگی‌ای خراب باشد کد ۵۰۳ برمی‌گرداند تا orchestrator ترافیک را به این نمونه نفرستد.
</div>
---
## ۶. یک جریان کامل با curl
```bash
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\"}"
```