{"id":245,"date":"2026-11-05T12:00:00","date_gmt":"2026-11-05T12:00:00","guid":{"rendered":"https:\/\/rezachegini.com\/?p=245"},"modified":"2026-11-05T20:32:17","modified_gmt":"2026-11-05T20:32:17","slug":"building-a-job-board-api-part-8-search-and-filters","status":"publish","type":"post","link":"https:\/\/rezachegini.com\/?p=245","title":{"rendered":"Building a Job Board API, Part 8: Search and Filters"},"content":{"rendered":"\n<h3 class=\"wp-block-heading\">Where we are<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is the last part of Phase 2. Until now, GET \/api\/jobs\/ returned every open job, 20 per page, newest first. That works for five jobs. It doesn\u2019t work for five thousand.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now a candidate can narrow the list down:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>?job_type=full_time: only one type of job<\/li>\n\n\n\n<li>?location=vancouver: only jobs in one city<\/li>\n\n\n\n<li>?skills=python,docker: only jobs needing at least one of these skills<\/li>\n\n\n\n<li>?search=platform engineer: free text, across title, description and company name<\/li>\n\n\n\n<li>?ordering=title: sort by title instead of newest first<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">All of these combine, sit on top of the \u201copen jobs only\u201d rule, and the result is still paginated.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why a library?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I could have read request.query_params by hand in my view. Instead, I installed django-filter:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>uv add django-filter\ndocker compose up -d --build<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three reasons:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>It validates the input. ?job_type=freelance returns a 400 with a clear message. A hand-written filter would silently return zero jobs, and a typo in a client would look exactly like \u201cthere are no jobs\u201d.<\/li>\n\n\n\n<li>All filters live in one class, not spread through if statements in a view.<\/li>\n\n\n\n<li>It documents itself. In Phase 5, drf-spectacular will read my filters and list every parameter in the API docs automatically.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">One small trap: the package is called django-filter (hyphen, singular), but you import django_filters (underscore, plural). Mixing them up gives ModuleNotFoundError.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How a list request flows<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">In DRF, filters are filter backends: a chain of classes that each take a list of rows and return a smaller one. I set the chain on the job view:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>    filter_backends = &#91;DjangoFilterBackend, SearchFilter, OrderingFilter]\n    filterset_class = JobFilter\n    search_fields = &#91;\"title\", \"description\", \"company__name\"]\n    ordering_fields = &#91;\"created_at\", \"title\"]\n    ordering = &#91;\"-created_at\"]<\/code><\/pre>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1288\" height=\"971\" src=\"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-8-filter-diagram.png\" alt=\"\" class=\"wp-image-247\" srcset=\"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-8-filter-diagram.png 1288w, https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-8-filter-diagram-300x226.png 300w, https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-8-filter-diagram-1024x772.png 1024w, https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-8-filter-diagram-768x579.png 768w\" sizes=\"auto, (max-width: 1288px) 100vw, 1288px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The important idea is in the first box. Every filter starts from get_queryset(), so every filter starts from open jobs only. The count in the response is the count after all the filters, not the count of all the jobs in the database.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I set the backends on this view only, not globally in settings, because no other view has a list worth filtering. If the application list in Part 7 needs filters later, I will add them there.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1: The FilterSet<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A FilterSet is to query parameters what a serializer is to a request body: one attribute per parameter, one type per attribute, one place to look. I made the file jobs\/filters.py, and in it a single class:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>class JobFilter(django_filters.FilterSet):\n    location = django_filters.CharFilter(lookup_expr=\"icontains\")\n    skills = django_filters.CharFilter(method=\"filter_skills\")\n\n    class Meta:\n        model = Job\n        fields = &#91;\"job_type\", \"location\", \"skills\"]\n\n    def filter_skills(self, queryset, name, value):\n        skills = &#91;skill.strip().lower() for skill in value.split(\",\") if skill.strip()]\n        if not skills:\n            return queryset\n        return queryset.filter(required_skills__overlap=skills)<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">job_type: free validation<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I didn\u2019t write anything for job_type. Because it is listed in Meta.fields, django-filter generates it for me, with a dropdown of the valid choices. The only special case is location.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>?job_type=freelance\nHTTP\/1.1 400 Bad Request\n{\"job_type\":&#91;\"Select a valid choice. freelance is not one of the available choices.\"]}<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">location: lookup_expr<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">CharFilter(lookup_expr=&#8221;icontains&#8221;) builds filter(location__icontains=value). The filter\u2019s name, location, is used as the model field, and lookup_expr is the part after __. icontains becomes SQL LIKE &#8216;%&#8230;%&#8217;, so the match is case-insensitive and works inside words.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">skills: method<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">skills is different, for two reasons. There is no model field called skills (it\u2019s required_skills), and the filter needs to split one text value into a list. So I wrote a method:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>value.split(&#8220;,&#8221;) makes a list from the text: &#8220;python, docker&#8221; becomes [&#8220;python&#8221;, &#8220;docker&#8221;].<\/li>\n\n\n\n<li>.strip() allows spaces after commas, and .lower() is essential: PostgreSQL array comparisons are case-sensitive, so Terraform would never match the stored terraform.<\/li>\n\n\n\n<li>if not skills: return queryset means ?skills= with nothing after it does not filter at all.<\/li>\n\n\n\n<li>required_skills__overlap is the PostgreSQL &amp;&amp; operator: &#8220;the two arrays share at least one item&#8221;. It is the same lookup I tested back in Part 2, the day I chose array fields.<\/li>\n\n\n\n<li>The method must return a queryset. Returning nothing would break the list.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Why \u201cat least one skill\u201d and not \u201call skills\u201d? With \u201call\u201d, a candidate searching for python and docker would need a job that needs both, and the list would be nearly empty. Overlap means \u201cat least one\u201d, so ?skills=python,docker finds any job needing either skill.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">lookup_expr vs method<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">These two arguments look almost the same, but do completely different things:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>lookup_expr=&#8221;icontains&#8221;: a built-in lookup. django-filter builds filter(location__icontains=value) for me.<\/li>\n\n\n\n<li>method=&#8221;filter_skills&#8221;: my own function. django-filter calls it with the queryset, the filter\u2019s name, and the raw value.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Mixing them up was this part\u2019s main bug. I\u2019ll come back to it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What I left out on purpose<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li>No is_open filter: the list already shows open jobs only.<\/li>\n\n\n\n<li>No company ID filter: it would expose internal database IDs. Candidates find a company\u2019s jobs by name with ?search= instead.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2: Search and ordering<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Both come built into DRF. I only had to configure them.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Search: free text<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Filters take exact, typed values. Search takes whatever the user types. Each listed field is searched with icontains:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>search_fields = &#91;\"title\", \"description\", \"company__name\"]<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Between fields it is OR: a match in any one of the fields is enough. So ?search=Initech can match a job because of its company name.<\/li>\n\n\n\n<li>Between words it is AND: the words are combined with AND, so ?search=platform engineer matches jobs that contain both words.<\/li>\n\n\n\n<li>company__name follows the foreign key, so the candidate does not need to know the company\u2019s ID.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">My checks: initech found Initech\u2019s job through the company name, django found two jobs through their description, and engineer found all three jobs through their titles. Each check proves a different field is in the list.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">An honest note on speed: ILIKE &#8216;%&#8230;%&#8217; can\u2019t use a normal database index, so PostgreSQL reads every row. That\u2019s fine for our seed data, and at real scale I\u2019d use a full-text search index or a GIN index. And in Phase 3, search results will be cached in Redis.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Ordering: an allow-list<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Ordering is an allow-list, not a free text field. I only allow ordering by created_at and title, and by default it orders by -created_at, so newest jobs come first.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ordering_fields = &#91;\"created_at\", \"title\"]\nordering = &#91;\"-created_at\"]<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>?ordering=title sorts alphabetically, with -title for reverse. Anything else is ignored.<\/li>\n\n\n\n<li>ordering is the default sort when the client doesn\u2019t ask for one. The &#8211; means descending: newest first. Remember that minus sign.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">The silent fallback<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">What happens with ?ordering=password? It\u2019s not in the allow-list, so DRF ignores it. No error, no warning, just the default order. That is DRF\u2019s design, and it is low risk here, but it means a client with a typo in ?ordering= never finds out. If I need strict behaviour later, I can validate the parameter myself.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">A side effect to know<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The detail view also runs the filters. So GET \/api\/jobs\/3\/?skills=python returns 404, because job 3 doesn\u2019t need python. Clients don\u2019t normally send filters to a detail URL, so it hasn\u2019t mattered. But it would be a very confusing bug to meet without knowing this.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3: Cleaning skills on the way in<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The skills filter lowercases the search input. But that only works if the stored skills are lowercase. A job saved with &#8220;Python&#8221; would never match python, because array comparisons are case-sensitive.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In Part 6, the match score cleaned skills when they were read. Now I also clean them when they are written. One line, in the job serializer:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>    def validate_required_skills(self, value):\n        return list(dict.fromkeys(skill.strip().lower() for skill in value if skill.strip()))<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>validate_ is called by DRF after the field\u2019s own checks, so it sees a real list.<\/li>\n\n\n\n<li>dict.fromkeys(&#8230;) is a neat Python trick. It builds a dictionary with each skill as a key. Keys are unique, so duplicates drop out, and the order stays as the client sent it.<\/li>\n\n\n\n<li>There are no square brackets inside fromkeys(&#8230;). That makes it a generator expression, so Python doesn\u2019t build a list it throws away.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The test: Acme updates a job with [&#8221; Python &#8220;, &#8220;DOCKER&#8221;, &#8220;python&#8221;]. The saved value is [&#8216;python&#8217;, &#8216;docker&#8217;]: trimmed, lowercased, no duplicate, order kept.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Some of my code does nothing<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I found this by accident. When I added an empty string to that test, [&#8221; Python &#8220;, &#8220;DOCKER&#8221;, &#8220;python&#8221;, &#8220;&#8221;], I didn\u2019t get a cleaned list. I got:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>400 {\"3\": &#91;\"This field may not be blank.\"]}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The 3 is the position of the bad item in the list. That\u2019s how DRF reports errors inside a list field: by the item\u2019s index. And that\u2019s why I check the test data, not the code: the code did exactly what I asked.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It also revealed something. DRF validates each item before my method runs. A CharField already trims whitespace from a plain string, so &#8220;&#8221; becomes a fail for the ListField, not for my method.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And rejecting a blank skill with a 400 is the better behaviour anyway. A blank skill is a client mistake, and the fix belongs in the client, not in my serializer.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Did old data need fixing?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Only jobs saved after this change get cleaned. If an old row had &#8220;Python&#8221;, I would need a data migration to fix it, or a one-off script. I have not done either yet, and I am recording it here so I do not forget. So before deciding, I checked every existing job. All the skills were already lowercase, so no migration was needed. Check the data before you decide.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Problems I hit<\/h3>\n\n\n\n<h3 class=\"wp-block-heading\">1. Every skills search returned 500<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">My small helper that prints the result titles suddenly printed &#8220;not JSON&#8221;. curl -i showed a 500, and the last line of docker compose logs api said:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>FieldError: Cannot resolve keyword 'skills' into field. Choices are: ... required_skills ...<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">My first version had:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>skills = django_filters.CharFilter(lookup_expr=\"filter_skills\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">lookup_expr instead of method. So django-filter didn\u2019t call my method at all. It built filter(skills__filter_skills=value) from the filter\u2019s name. There is no skills field, so Django crashed. My correctly written filter_skills method was never called.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix was one word: method=&#8221;filter_skills&#8221;. Every skills check passed after that. The error\u2019s list of choices told me the field was really called required_skills, and it pointed straight at the real field name.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">2. The newest jobs were at the bottom<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This bug was hiding in plain sight. While I was testing ?ordering=password, I looked at the default order. The jobs were oldest first, and I had assumed newest first.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I checked the dates instead of guessing. Job 1 was created on October 24, and the two newest jobs on October 28. So the order really was wrong. Then I searched for every ordering line:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>grep -n \"ordering\" jobs\/views.py jobs\/models.py<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The model said ordering = [&#8220;-created_at&#8221;], which is correct. But the view said ordering = [&#8220;created_at&#8221;], without the minus. One missing &#8211; and the default order flipped to oldest first.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the part I didn\u2019t know: OrderingFilter applies the view\u2019s ordering as an explicit order_by(), and that replaces the model\u2019s default. So that single missing character changed the order of every job list, with or without ?ordering=. One &#8211; fixed it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I have to admit something here. My first explanation for this order was that job 1 &#8220;had been recreated when I reseeded the database.&#8221; It was a good story, and it was wrong. The timestamps proved it: job 1 was created on October 24, and the two newest jobs on October 28. The order was wrong because of one missing -.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">3. Two tests where my expectation was wrong<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Not every failure is a bug in the code. Twice in this step, the code was right and I was wrong:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>?search=platform returned two jobs, and I expected one. But job 1\u2019s description is &#8220;Run our Kubernetes platform.&#8221; Search covers descriptions, by design.<\/li>\n\n\n\n<li>My normalisation test returned a 400, because I included an empty string, and DRF correctly rejected it (Step 3).<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">When a test fails, the first question should be: is the code wrong, or is my expectation wrong?<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The pattern, again<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This series keeps finding the same kind of bug: a hook with the wrong name. get_permission, url_path and now lookup_expr are all hooks I had to learn by getting them wrong. Now I check the exact name before I write the code.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Did it work?<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li>?location=vancouver&amp;skills=python: 1 job. The Burnaby job also needs python, but it fails the location filter, so it is out.<\/li>\n\n\n\n<li>?location=vancouver: 2 jobs, so the location filter alone works.<\/li>\n\n\n\n<li>?skills=python,docker: 2 jobs, so the overlap is &#8220;at least one&#8221;, not &#8220;all&#8221;.<\/li>\n\n\n\n<li>?search=platform: 2 jobs, so the description is searched.<\/li>\n\n\n\n<li>?search=platform engineer: 1 job, so the words combine with AND.<\/li>\n\n\n\n<li>?ordering=title: the jobs come back in title order, not date order.<\/li>\n\n\n\n<li>?ordering=password: ignored, and the jobs come back newest first.<\/li>\n\n\n\n<li>An invalid ?job_type= returns 400, with a message that says which values are allowed.<\/li>\n\n\n\n<li>A filter of ?skills=python returns only jobs with that skill, in lower case.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">What I learned<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A FilterSet is to query parameters what a serializer is to a request body: one attribute per parameter, one type per attribute, one place to look.<\/li>\n\n\n\n<li>lookup_expr and method are different arguments. One is a built-in lookup, the other is a function I write. Mixing them up gives a 500.<\/li>\n\n\n\n<li>Normalise in both places. Clean the search input and the stored data, because array lookups are case-sensitive.<\/li>\n\n\n\n<li>A missing &#8211; is a bug. Ordering by created_at is oldest first, and the default order is the one the client sees first.<\/li>\n\n\n\n<li>Know which line really does the work. Some of my code turned out to be dead, because DRF had already done the job.<\/li>\n\n\n\n<li>An allow-list is safer than a free text field. Anything not in the list is ignored.<\/li>\n\n\n\n<li>Check the data before you decide. A migration is only needed if the old rows are actually wrong.<\/li>\n\n\n\n<li>When a test fails, check the expectation first. The code might be right.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Phase 2 is complete<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">That was the last item on Phase 2\u2019s checklist. Over four parts:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Part 5 added permissions, so only the right user can see or change a job.<\/li>\n\n\n\n<li>Part 6 added the match score, so a candidate sees the best jobs first.<\/li>\n\n\n\n<li>Part 7 added resume uploads to S3, with presigned URLs for downloads.<\/li>\n\n\n\n<li>Part 8, this one, added search and filters, so a candidate can find the job.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The goal of the phase was &#8220;wrong user types get 403 and match scores return correctly&#8221;. Both are met. And the checklist I wrote in Part 4 said exactly that.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What comes next<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Phase 3 adds two things the app is still missing: Redis, to cache job searches like the ones in this article, and a background worker that reads a queue and sends an email when an application\u2019s status changes. The status endpoint from Part 5 is already waiting for it.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Where we are This is the last part of Phase 2. Until now, GET \/api\/jobs\/ returned every open job, 20 per page,&hellip;<\/p>\n","protected":false},"author":2,"featured_media":246,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_rezaops_part":"8","_rezaops_linkedin_url":"https:\/\/www.linkedin.com\/pulse\/building-job-board-api-part-8-search-filters-reza-chegini-esnic","footnotes":""},"categories":[10,116],"tags":[8,111,197,191,196,118,188,25,112,195],"series":[117],"class_list":["post-245","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","category-django","tag-devops","tag-django","tag-django-filter","tag-drf","tag-filters","tag-job-board","tag-postgresql","tag-python","tag-rest-api","tag-search","series-job-board-api"],"jetpack_featured_media_url":"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-8-cover.png","_links":{"self":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/245","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=245"}],"version-history":[{"count":1,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/245\/revisions"}],"predecessor-version":[{"id":248,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/245\/revisions\/248"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/media\/246"}],"wp:attachment":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=245"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=245"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=245"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=245"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}