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
14 KiB
مرجع API
پایه: /api/v1/ · نسخهبندی از طریق namespace آدرسها · اسکیمای ماشینخوان: openapi.yaml
مستندات تعاملی (همیشه بهروز، از روی کد تولید میشود):
- Swagger UI — http://localhost:8080/api/docs/
- ReDoc — http://localhost:8080/api/redoc/
۱. قراردادهای عمومی
۱.۱ احراز هویت
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\"}"