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:
+412
@@ -0,0 +1,412 @@
|
||||
# مرجع 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\"}"
|
||||
```
|
||||
Reference in New Issue
Block a user