Files
cyberyan_linkedin_search/docs/openapi.yaml
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

1056 lines
25 KiB
YAML

openapi: 3.0.3
info:
title: LinkedIn Profile Search API
version: 1.0.0 (v1)
description: Keyword search and faceted filtering over a LinkedIn profile dataset.
Backed by Elasticsearch with a PostgreSQL full-text fallback.
paths:
/api/v1/auth/login/:
post:
operationId: auth_login_create
description: Exchange credentials for an access/refresh token pair.
tags:
- auth
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TokenObtainPairRequest'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/TokenObtainPairRequest'
multipart/form-data:
schema:
$ref: '#/components/schemas/TokenObtainPairRequest'
required: true
security:
- {}
responses:
'200':
description: No response body
/api/v1/auth/logout/:
post:
operationId: auth_logout_create
description: Blacklist a refresh token so it can no longer be rotated.
tags:
- auth
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/LogoutRequest'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/LogoutRequest'
multipart/form-data:
schema:
$ref: '#/components/schemas/LogoutRequest'
required: true
security:
- jwtAuth: []
responses:
'205':
description: No response body
/api/v1/auth/me/:
get:
operationId: auth_me_retrieve
description: Identity and effective privileges of the calling user.
tags:
- auth
security:
- jwtAuth: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/User'
description: ''
/api/v1/auth/refresh/:
post:
operationId: auth_refresh_create
description: Rotate a refresh token; the presented token is blacklisted afterwards.
tags:
- auth
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TokenRefreshRequest'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/TokenRefreshRequest'
multipart/form-data:
schema:
$ref: '#/components/schemas/TokenRefreshRequest'
required: true
security:
- {}
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/TokenRefresh'
description: ''
/api/v1/auth/register/:
post:
operationId: auth_register_create
description: Self-service sign-up. New accounts always get the ``viewer`` role.
tags:
- auth
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterRequest'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/RegisterRequest'
multipart/form-data:
schema:
$ref: '#/components/schemas/RegisterRequest'
required: true
security:
- jwtAuth: []
- {}
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/Register'
description: ''
/api/v1/health/live/:
get:
operationId: health_live_retrieve
description: 'Cheap check: the process is up and can answer HTTP.'
tags:
- health
security:
- {}
responses:
'200':
content:
application/json:
schema:
type: object
additionalProperties: {}
description: ''
/api/v1/health/ready/:
get:
operationId: health_ready_retrieve
description: |-
Deep check: verifies every dependency the API needs to serve traffic.
Returns 503 when any dependency is down so an orchestrator stops routing
traffic to this instance.
tags:
- health
security:
- {}
responses:
'200':
content:
application/json:
schema:
type: object
additionalProperties: {}
description: ''
'503':
content:
application/json:
schema:
type: object
additionalProperties: {}
description: ''
/api/v1/profiles/:
get:
operationId: profiles_list
description: |-
Plain, unranked listing.
Useful for browsing and for verifying the dataset; ranked keyword search is
served by ``/api/v1/search/``.
parameters:
- name: page
required: false
in: query
description: A page number within the paginated result set.
schema:
type: integer
- name: page_size
required: false
in: query
description: Number of results to return per page.
schema:
type: integer
tags:
- profiles
security:
- jwtAuth: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedProfileListList'
description: ''
/api/v1/profiles/{id}/:
get:
operationId: profiles_retrieve
description: Full profile including work history, education and (masked) contacts.
parameters:
- in: path
name: id
schema:
type: integer
required: true
tags:
- profiles
security:
- jwtAuth: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/ProfileDetail'
description: ''
/api/v1/profiles/skills/:
get:
operationId: profiles_skills_list
description: |-
Skill dictionary ordered by popularity.
The frontend uses it to power the skill filter's type-ahead without hitting
Elasticsearch for every keystroke.
tags:
- profiles
security:
- jwtAuth: []
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Skill'
description: ''
/api/v1/search/:
get:
operationId: search_retrieve
description: Ranked keyword search across name, job title, skills, employer,
education, location and summary, combined with structured filters. Filter
values are OR-ed inside one filter and AND-ed across filters.
summary: Keyword search with filters and facets
parameters:
- in: query
name: companies
schema:
type: string
description: Current employer name.
- in: query
name: countries
schema:
type: string
description: Country of residence.
- in: query
name: facets
schema:
type: boolean
default: true
- in: query
name: industries
schema:
type: string
description: Industry of the current employer.
- in: query
name: max_experience
schema:
type: integer
maximum: 70
minimum: 0
- in: query
name: min_experience
schema:
type: integer
maximum: 70
minimum: 0
- in: query
name: page
schema:
type: integer
minimum: 1
default: 1
- in: query
name: page_size
schema:
type: integer
maximum: 100
minimum: 1
default: 20
- in: query
name: q
schema:
type: string
maxLength: 200
description: Free-text keyword. Supports quoted phrases and -exclusions.
- in: query
name: roles
schema:
type: string
description: Job family, e.g. engineering, human_resources.
- in: query
name: seniority
schema:
type: string
description: Seniority level, e.g. senior, manager, cxo.
- in: query
name: skills
schema:
type: string
description: Skill names, OR-combined.
- in: query
name: sort
schema:
enum:
- relevance
- -experience
- experience
- name
type: string
default: relevance
minLength: 1
description: |-
* `relevance` - relevance
* `-experience` - -experience
* `experience` - experience
* `name` - name
tags:
- search
security:
- jwtAuth: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/SearchResponse'
description: ''
/api/v1/search/reindex/:
post:
operationId: search_reindex_create
description: |-
Triggers a zero-downtime rebuild of the Elasticsearch index.
Restricted to the admin role: it is an expensive, cluster-wide operation.
summary: Rebuild the search index (admin only)
tags:
- search
security:
- jwtAuth: []
responses:
'202':
content:
application/json:
schema:
type: object
additionalProperties: {}
description: ''
/api/v1/search/suggest/:
get:
operationId: search_suggest_retrieve
description: Edge n-gram completions used by the search box.
summary: Type-ahead suggestions
parameters:
- in: query
name: limit
schema:
type: integer
description: Maximum suggestions (1-20).
- in: query
name: q
schema:
type: string
description: Partial name or skill.
required: true
tags:
- search
security:
- jwtAuth: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/SuggestResponse'
description: ''
components:
schemas:
BlankEnum:
enum:
- ''
Company:
type: object
properties:
id:
type: integer
readOnly: true
name:
type: string
maxLength: 255
industry:
type: string
maxLength: 128
size:
type: string
description: Head-count bucket, e.g. 51-200
maxLength: 32
founded:
type: integer
maximum: 2147483647
minimum: 0
nullable: true
website:
type: string
maxLength: 255
location_name:
type: string
maxLength: 255
required:
- id
- name
Education:
type: object
properties:
id:
type: integer
readOnly: true
school:
allOf:
- $ref: '#/components/schemas/School'
readOnly: true
school_name:
type: string
maxLength: 255
degrees:
type: array
items:
type: string
maxLength: 128
majors:
type: array
items:
type: string
maxLength: 128
minors:
type: array
items:
type: string
maxLength: 128
gpa:
type: string
format: decimal
pattern: ^-?\d{0,2}(?:\.\d{0,2})?$
nullable: true
start_date:
type: string
maxLength: 10
end_date:
type: string
maxLength: 10
end_year:
type: integer
maximum: 32767
minimum: 0
nullable: true
required:
- id
- school
Experience:
type: object
properties:
id:
type: integer
readOnly: true
title:
type: string
maxLength: 255
title_role:
type: string
maxLength: 64
title_sub_role:
type: string
maxLength: 64
levels:
type: array
items:
type: string
maxLength: 32
company:
allOf:
- $ref: '#/components/schemas/Company'
readOnly: true
company_name:
type: string
maxLength: 255
start_date:
type: string
maxLength: 10
end_date:
type: string
maxLength: 10
start_year:
type: integer
maximum: 32767
minimum: 0
nullable: true
end_year:
type: integer
maximum: 32767
minimum: 0
nullable: true
is_primary:
type: boolean
location_name:
type: string
maxLength: 255
summary:
type: string
required:
- company
- id
FacetBucket:
type: object
properties:
key:
type: string
count:
type: integer
required:
- count
- key
GenderEnum:
enum:
- male
- female
type: string
description: |-
* `male` - Male
* `female` - Female
* `` - Unknown
LogoutRequest:
type: object
properties:
refresh:
type: string
minLength: 1
required:
- refresh
PaginatedProfileListList:
type: object
required:
- count
- results
properties:
count:
type: integer
example: 123
next:
type: string
nullable: true
format: uri
example: http://api.example.org/accounts/?page=4
previous:
type: string
nullable: true
format: uri
example: http://api.example.org/accounts/?page=2
results:
type: array
items:
$ref: '#/components/schemas/ProfileList'
ProfileDetail:
type: object
description: Masks contact details unless the caller is allowed to see them.
properties:
id:
type: integer
readOnly: true
external_id:
type: string
description: LinkedIn numeric id from the source export.
maxLength: 64
full_name:
type: string
maxLength: 255
first_name:
type: string
maxLength: 128
last_name:
type: string
maxLength: 128
gender:
oneOf:
- $ref: '#/components/schemas/GenderEnum'
- $ref: '#/components/schemas/BlankEnum'
headline:
type: string
readOnly: true
job_title:
type: string
maxLength: 255
job_title_role:
type: string
description: Coarse job family, e.g. engineering.
maxLength: 64
job_title_sub_role:
type: string
maxLength: 64
seniority_levels:
type: array
items:
type: string
maxLength: 32
description: e.g. ['manager'], ['senior'], ['cxo'].
current_company:
allOf:
- $ref: '#/components/schemas/Company'
readOnly: true
industry:
type: string
maxLength: 128
job_summary:
type: string
job_start_date:
type: string
maxLength: 10
location_name:
type: string
maxLength: 255
location_locality:
type: string
maxLength: 128
location_region:
type: string
maxLength: 128
location_country:
type: string
maxLength: 128
location_continent:
type: string
maxLength: 64
summary:
type: string
years_experience:
type: integer
maximum: 32767
minimum: 0
nullable: true
inferred_salary:
type: string
maxLength: 32
connections:
type: integer
maximum: 2147483647
minimum: 0
nullable: true
interests:
type: array
items:
type: string
maxLength: 128
languages:
type: array
items:
type: string
maxLength: 64
linkedin_url:
type: string
maxLength: 255
linkedin_username:
type: string
maxLength: 128
github_url:
type: string
maxLength: 255
twitter_url:
type: string
maxLength: 255
skills:
type: array
items:
type: object
additionalProperties: {}
readOnly: true
experiences:
type: array
items:
$ref: '#/components/schemas/Experience'
readOnly: true
educations:
type: array
items:
$ref: '#/components/schemas/Education'
readOnly: true
emails:
type: array
items:
type: object
additionalProperties:
type: string
readOnly: true
phone_numbers:
type: array
items:
type: string
readOnly: true
pii_masked:
type: boolean
readOnly: true
required:
- current_company
- educations
- emails
- experiences
- full_name
- headline
- id
- phone_numbers
- pii_masked
- skills
ProfileList:
type: object
description: Compact representation used for result cards.
properties:
id:
type: integer
readOnly: true
full_name:
type: string
maxLength: 255
headline:
type: string
readOnly: true
job_title:
type: string
maxLength: 255
job_title_role:
type: string
description: Coarse job family, e.g. engineering.
maxLength: 64
seniority_levels:
type: array
items:
type: string
maxLength: 32
description: e.g. ['manager'], ['senior'], ['cxo'].
company:
type: string
readOnly: true
default: ''
industry:
type: string
maxLength: 128
location_name:
type: string
maxLength: 255
location_country:
type: string
maxLength: 128
years_experience:
type: integer
maximum: 32767
minimum: 0
nullable: true
linkedin_url:
type: string
maxLength: 255
skills:
type: array
items:
type: string
readOnly: true
emails:
type: array
items:
type: object
additionalProperties:
type: string
readOnly: true
required:
- company
- emails
- full_name
- headline
- id
- skills
Register:
type: object
properties:
email:
type: string
format: email
title: Email address
maxLength: 254
full_name:
type: string
maxLength: 150
required:
- email
RegisterRequest:
type: object
properties:
email:
type: string
format: email
minLength: 1
title: Email address
maxLength: 254
full_name:
type: string
maxLength: 150
password:
type: string
writeOnly: true
minLength: 1
password_confirm:
type: string
writeOnly: true
minLength: 1
required:
- email
- password
- password_confirm
RoleEnum:
enum:
- admin
- analyst
- viewer
type: string
description: |-
* `admin` - Administrator
* `analyst` - Analyst
* `viewer` - Viewer
School:
type: object
properties:
id:
type: integer
readOnly: true
name:
type: string
maxLength: 255
type:
type: string
maxLength: 64
location_name:
type: string
maxLength: 255
required:
- id
- name
SearchHit:
type: object
properties:
id:
type: integer
score:
type: number
format: double
full_name:
type: string
headline:
type: string
job_title:
type: string
job_title_role:
type: string
seniority_levels:
type: array
items:
type: string
company:
type: string
industry:
type: string
location_name:
type: string
location_country:
type: string
years_experience:
type: integer
nullable: true
linkedin_url:
type: string
skills:
type: array
items:
type: string
highlight:
type: object
additionalProperties:
type: array
items:
type: string
required:
- company
- full_name
- headline
- highlight
- id
- industry
- job_title
- job_title_role
- linkedin_url
- location_country
- location_name
- score
- seniority_levels
- skills
- years_experience
SearchResponse:
type: object
properties:
count:
type: integer
page:
type: integer
page_size:
type: integer
total_pages:
type: integer
took_ms:
type: integer
backend:
type: string
results:
type: array
items:
$ref: '#/components/schemas/SearchHit'
facets:
type: object
additionalProperties:
type: array
items:
$ref: '#/components/schemas/FacetBucket'
required:
- backend
- count
- facets
- page
- page_size
- results
- took_ms
- total_pages
Skill:
type: object
properties:
id:
type: integer
readOnly: true
name:
type: string
maxLength: 128
slug:
type: string
maxLength: 140
pattern: ^[-a-zA-Z0-9_]+$
profile_count:
type: integer
maximum: 2147483647
minimum: 0
description: Denormalised counter used to rank filter suggestions.
required:
- id
- name
- slug
SuggestResponse:
type: object
properties:
suggestions:
type: array
items:
type: string
required:
- suggestions
TokenObtainPairRequest:
type: object
description: Adds identity claims to the access token and records an audit event.
properties:
email:
type: string
writeOnly: true
minLength: 1
password:
type: string
writeOnly: true
minLength: 1
required:
- email
- password
TokenRefresh:
type: object
properties:
access:
type: string
readOnly: true
refresh:
type: string
required:
- access
- refresh
TokenRefreshRequest:
type: object
properties:
refresh:
type: string
minLength: 1
required:
- refresh
User:
type: object
properties:
id:
type: integer
readOnly: true
email:
type: string
format: email
readOnly: true
title: Email address
full_name:
type: string
readOnly: true
role:
allOf:
- $ref: '#/components/schemas/RoleEnum'
readOnly: true
description: |-
Determines which fields and actions the user has access to.
* `admin` - Administrator
* `analyst` - Analyst
* `viewer` - Viewer
can_view_pii:
type: boolean
readOnly: true
date_joined:
type: string
format: date-time
readOnly: true
required:
- can_view_pii
- date_joined
- email
- full_name
- id
- role
securitySchemes:
jwtAuth:
type: http
scheme: bearer
bearerFormat: JWT