"""Request validation and response shaping for the search endpoints.""" from __future__ import annotations from dataclasses import asdict from typing import Any from django.conf import settings from rest_framework import serializers from apps.search.types import SORT_OPTIONS, SORT_RELEVANCE, SearchRequest, SearchResult MAX_QUERY_LENGTH = 200 MAX_FILTER_VALUES = 20 class CommaSeparatedField(serializers.CharField): """ Accepts ``?skills=python,django`` **and** repeated ``?skills=python&skills=django``. Both spellings are common in the wild; normalising here keeps the views and both backends free of query-string trivia. """ def __init__(self, **kwargs: Any) -> None: kwargs.setdefault("required", False) kwargs.setdefault("allow_blank", True) super().__init__(**kwargs) def get_value(self, dictionary: Any) -> Any: # ``QueryDict.get`` silently keeps only the last occurrence, which would # turn ``?skills=a&skills=b`` into ``b``. ``getlist`` keeps both. if hasattr(dictionary, "getlist"): values = dictionary.getlist(self.field_name) if values: return values return super().get_value(dictionary) def to_internal_value(self, data: Any) -> tuple[str, ...]: raw: list[str] = [] if isinstance(data, list | tuple): raw = [str(item) for item in data] elif data: raw = [str(data)] values: list[str] = [] for chunk in raw: for item in chunk.split(","): cleaned = item.strip().lower()[:128] if cleaned and cleaned not in values: values.append(cleaned) if len(values) > MAX_FILTER_VALUES: raise serializers.ValidationError( f"At most {MAX_FILTER_VALUES} values are allowed per filter." ) return tuple(values) class SearchRequestSerializer(serializers.Serializer): """Validates and normalises the query string of ``GET /api/v1/search/``.""" q = serializers.CharField( required=False, allow_blank=True, max_length=MAX_QUERY_LENGTH, trim_whitespace=True, help_text="Free-text keyword. Supports quoted phrases and -exclusions.", ) # fmt: skip skills = CommaSeparatedField(help_text="Skill names, OR-combined.") roles = CommaSeparatedField(help_text="Job family, e.g. engineering, human_resources.") seniority = CommaSeparatedField(help_text="Seniority level, e.g. senior, manager, cxo.") countries = CommaSeparatedField(help_text="Country of residence.") industries = CommaSeparatedField(help_text="Industry of the current employer.") companies = CommaSeparatedField(help_text="Current employer name.") min_experience = serializers.IntegerField(required=False, min_value=0, max_value=70) max_experience = serializers.IntegerField(required=False, min_value=0, max_value=70) sort = serializers.ChoiceField(choices=SORT_OPTIONS, required=False, default=SORT_RELEVANCE) page = serializers.IntegerField(required=False, min_value=1, default=1) page_size = serializers.IntegerField(required=False, min_value=1, max_value=100, default=20) facets = serializers.BooleanField(required=False, default=True) def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: low, high = attrs.get("min_experience"), attrs.get("max_experience") if low is not None and high is not None and low > high: raise serializers.ValidationError( {"min_experience": "min_experience cannot be greater than max_experience."} ) page, page_size = attrs.get("page", 1), attrs.get("page_size", 20) # Deep pagination is both useless to a human and expensive for the # engine, so it is rejected instead of silently truncated. if page * page_size > settings.SEARCH["MAX_RESULT_WINDOW"]: raise serializers.ValidationError( {"page": f"Cannot page beyond {settings.SEARCH['MAX_RESULT_WINDOW']} results."} ) return attrs def to_search_request(self) -> SearchRequest: data = self.validated_data return SearchRequest( q=data.get("q", "").strip(), skills=data.get("skills", ()), roles=data.get("roles", ()), seniority=data.get("seniority", ()), countries=data.get("countries", ()), industries=data.get("industries", ()), companies=data.get("companies", ()), min_experience=data.get("min_experience"), max_experience=data.get("max_experience"), sort=data.get("sort", SORT_RELEVANCE), page=data.get("page", 1), page_size=data.get("page_size", 20), include_facets=data.get("facets", True), ) # --------------------------------------------------------------------------- # Response serializers (declared explicitly so the OpenAPI schema is accurate) # --------------------------------------------------------------------------- class SearchHitSerializer(serializers.Serializer): id = serializers.IntegerField() score = serializers.FloatField() full_name = serializers.CharField() headline = serializers.CharField() job_title = serializers.CharField() job_title_role = serializers.CharField() seniority_levels = serializers.ListField(child=serializers.CharField()) company = serializers.CharField() industry = serializers.CharField() location_name = serializers.CharField() location_country = serializers.CharField() years_experience = serializers.IntegerField(allow_null=True) linkedin_url = serializers.CharField() skills = serializers.ListField(child=serializers.CharField()) highlight = serializers.DictField(child=serializers.ListField(child=serializers.CharField())) class FacetBucketSerializer(serializers.Serializer): key = serializers.CharField() count = serializers.IntegerField() class SearchResponseSerializer(serializers.Serializer): count = serializers.IntegerField() page = serializers.IntegerField() page_size = serializers.IntegerField() total_pages = serializers.IntegerField() took_ms = serializers.IntegerField() backend = serializers.CharField() results = SearchHitSerializer(many=True) facets = serializers.DictField(child=FacetBucketSerializer(many=True)) @staticmethod def from_result(result: SearchResult, request: SearchRequest) -> dict[str, Any]: total_pages = max(1, -(-result.total // request.page_size)) return { "count": result.total, "page": request.page, "page_size": request.page_size, "total_pages": total_pages, "took_ms": result.took_ms, "backend": result.backend, "results": [asdict(hit) for hit in result.hits], "facets": { name: [asdict(bucket) for bucket in buckets] for name, buckets in result.facets.items() }, } class SuggestResponseSerializer(serializers.Serializer): suggestions = serializers.ListField(child=serializers.CharField())