♻️(views) split views in 2 urls: /index and /search
We need to make a POST to search documents so that we can post a list of documents the current user has already "visited" This is necessary to limited the number of documents we return among the ones available to any authenticated or anonymous user.
This commit is contained in:
committed by
Florian Fabre
parent
64687d3045
commit
0bceab930c
@@ -81,8 +81,9 @@ class DocumentSchema(BaseModel):
|
||||
class SearchQueryParametersSchema(BaseModel):
|
||||
"""Schema for validating the querystring on the search API endpoint"""
|
||||
|
||||
services: Union[str, List[str], None] = Field(default_factory=list)
|
||||
q: str
|
||||
services: Union[str, List[str], None] = Field(default_factory=list)
|
||||
visited: List[str] = Field(default_factory=list)
|
||||
reach: Optional[enums.ReachEnum] = None
|
||||
order_by: Optional[Literal[enums.ORDER_BY_OPTIONS]] = Field(default=enums.RELEVANCE)
|
||||
order_direction: Optional[Literal["asc", "desc"]] = Field(default="desc")
|
||||
|
||||
@@ -16,7 +16,7 @@ def test_api_documents_index_bulk_anonymous():
|
||||
"""Anonymous requests should not be allowed to index documents in bulk."""
|
||||
documents = factories.DocumentSchemaFactory.build_batch(3)
|
||||
|
||||
response = APIClient().post("/api/v1.0/documents/", documents, format="json")
|
||||
response = APIClient().post("/api/v1.0/documents/index/", documents, format="json")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
@@ -29,7 +29,7 @@ def test_api_documents_index_bulk_invalid_token():
|
||||
documents = factories.DocumentSchemaFactory.build_batch(3)
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/",
|
||||
"/api/v1.0/documents/index/",
|
||||
documents,
|
||||
HTTP_AUTHORIZATION="Bearer invalid",
|
||||
format="json",
|
||||
@@ -45,7 +45,7 @@ def test_api_documents_index_bulk_success():
|
||||
documents = factories.DocumentSchemaFactory.build_batch(3)
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/",
|
||||
"/api/v1.0/documents/index/",
|
||||
documents,
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
format="json",
|
||||
@@ -184,7 +184,7 @@ def test_api_documents_index_bulk_invalid_document(
|
||||
documents[0][field] = invalid_value
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/",
|
||||
"/api/v1.0/documents/index/",
|
||||
documents,
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
format="json",
|
||||
@@ -225,7 +225,7 @@ def test_api_documents_index_bulk_required(field):
|
||||
del documents[0][field]
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/",
|
||||
"/api/v1.0/documents/index/",
|
||||
documents,
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
format="json",
|
||||
@@ -258,7 +258,7 @@ def test_api_documents_index_bulk_default(field, default_value):
|
||||
del documents[0][field]
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/",
|
||||
"/api/v1.0/documents/index/",
|
||||
documents,
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
format="json",
|
||||
@@ -286,7 +286,7 @@ def test_api_documents_index_bulk_updated_at_before_created_at():
|
||||
)
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/",
|
||||
"/api/v1.0/documents/index/",
|
||||
documents,
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
format="json",
|
||||
@@ -319,7 +319,7 @@ def test_api_documents_index_bulk_datetime_future(field):
|
||||
documents[0][field] = now + datetime.timedelta(seconds=3)
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/",
|
||||
"/api/v1.0/documents/index/",
|
||||
documents,
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
format="json",
|
||||
|
||||
@@ -16,7 +16,7 @@ def test_api_documents_index_single_anonymous():
|
||||
"""Anonymous requests should not be allowed to index documents."""
|
||||
document = factories.DocumentSchemaFactory.build()
|
||||
|
||||
response = APIClient().post("/api/v1.0/documents/", document, format="json")
|
||||
response = APIClient().post("/api/v1.0/documents/index/", document, format="json")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
@@ -29,7 +29,7 @@ def test_api_documents_index_single_invalid_token():
|
||||
document = factories.DocumentSchemaFactory.build()
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/",
|
||||
"/api/v1.0/documents/index/",
|
||||
document,
|
||||
HTTP_AUTHORIZATION="Bearer invalid",
|
||||
format="json",
|
||||
@@ -45,7 +45,7 @@ def test_api_documents_index_single_success():
|
||||
document = factories.DocumentSchemaFactory.build()
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/",
|
||||
"/api/v1.0/documents/index/",
|
||||
document,
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
format="json",
|
||||
@@ -181,7 +181,7 @@ def test_api_documents_index_single_invalid_document(
|
||||
document[field] = invalid_value
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/",
|
||||
"/api/v1.0/documents/index/",
|
||||
document,
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
format="json",
|
||||
@@ -215,7 +215,7 @@ def test_api_documents_index_single_required(field):
|
||||
del document[field]
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/",
|
||||
"/api/v1.0/documents/index/",
|
||||
document,
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
format="json",
|
||||
@@ -242,7 +242,7 @@ def test_api_documents_index_single_default(field, default_value):
|
||||
del document[field]
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/",
|
||||
"/api/v1.0/documents/index/",
|
||||
document,
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
format="json",
|
||||
@@ -265,7 +265,7 @@ def test_api_documents_index_single_udpated_at_before_created():
|
||||
document["updated_at"] = document["created_at"] - datetime.timedelta(seconds=1)
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/",
|
||||
"/api/v1.0/documents/index/",
|
||||
document,
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
format="json",
|
||||
@@ -292,7 +292,7 @@ def test_api_documents_index_single_datetime_future(field):
|
||||
document[field] = now + datetime.timedelta(seconds=3)
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/",
|
||||
"/api/v1.0/documents/index/",
|
||||
document,
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
format="json",
|
||||
|
||||
@@ -6,9 +6,9 @@ of documents is slow and better done only once.
|
||||
"""
|
||||
|
||||
import operator
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import enums, factories
|
||||
@@ -22,23 +22,30 @@ def test_api_documents_search_query_title():
|
||||
"""Searching a document by its title should work as expected"""
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
document = factories.DocumentSchemaFactory.build(
|
||||
title="The quick brown fox", content="the wolf"
|
||||
title="The quick brown fox",
|
||||
content="the wolf",
|
||||
reach=random.choice(["public", "authenticated"]),
|
||||
)
|
||||
|
||||
# Add other documents
|
||||
other_fox_document = factories.DocumentSchemaFactory.build(
|
||||
title="The blue fox", content="the wolf"
|
||||
title="The blue fox",
|
||||
content="the wolf",
|
||||
reach=random.choice(["public", "authenticated"]),
|
||||
)
|
||||
no_fox_document = factories.DocumentSchemaFactory.build(
|
||||
title="The brown goat", content="the wolf"
|
||||
title="The brown goat",
|
||||
content="the wolf",
|
||||
reach=random.choice(["public", "authenticated"]),
|
||||
)
|
||||
documents = [document, other_fox_document, no_fox_document]
|
||||
prepare_index(service.name, documents)
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/",
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "a quick fox"},
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -85,22 +92,29 @@ def test_api_documents_search_query_content():
|
||||
"""Searching a document by its content should work as expected"""
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
document = factories.DocumentSchemaFactory.build(
|
||||
title="the wolf", content="The quick brown fox"
|
||||
title="the wolf",
|
||||
content="The quick brown fox",
|
||||
reach=random.choice(["public", "authenticated"]),
|
||||
)
|
||||
|
||||
# Add other documents
|
||||
other_fox_document = factories.DocumentSchemaFactory.build(
|
||||
title="the wolf", content="The blue fox"
|
||||
title="the wolf",
|
||||
content="The blue fox",
|
||||
reach=random.choice(["public", "authenticated"]),
|
||||
)
|
||||
no_fox_document = factories.DocumentSchemaFactory.build(
|
||||
title="the wolf", content="The brown goat"
|
||||
title="the wolf",
|
||||
content="The brown goat",
|
||||
reach=random.choice(["public", "authenticated"]),
|
||||
)
|
||||
prepare_index(service.name, [document, other_fox_document, no_fox_document])
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/",
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "a quick fox"},
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -146,7 +160,9 @@ def test_api_documents_search_query_content():
|
||||
def test_api_documents_search_ordering_by_fields():
|
||||
"""It should be possible to order by several fields"""
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(4)
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
4, reach=random.choice(["public", "authenticated"])
|
||||
)
|
||||
prepare_index(service.name, documents)
|
||||
|
||||
parameters = [
|
||||
@@ -163,9 +179,11 @@ def test_api_documents_search_ordering_by_fields():
|
||||
]
|
||||
|
||||
for field, direction in parameters:
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/documents/?q=*&order_by={field}&order_direction={direction}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token}",
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "order_by": field, "order_direction": direction},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -183,13 +201,17 @@ def test_api_documents_search_ordering_by_fields():
|
||||
def test_api_documents_search_ordering_by_relevance():
|
||||
"""It should be possible to order by relevance (score)"""
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(4)
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
4, reach=random.choice(["public", "authenticated"])
|
||||
)
|
||||
prepare_index(service.name, documents)
|
||||
|
||||
for direction in ["asc", "desc"]:
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/documents/?q=*&order_by=relevance&order_direction={direction}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token}",
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "order_by": "relevance", "order_direction": direction},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -204,10 +226,11 @@ def test_api_documents_search_ordering_by_relevance():
|
||||
|
||||
def test_api_documents_search_ordering_by_unknown_field():
|
||||
"""Trying to sort by an unknown field should return a 400 error"""
|
||||
|
||||
# Setup: Initialize the service and documents only once
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(2)
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
2, reach=random.choice(["public", "authenticated"])
|
||||
)
|
||||
prepare_index(service.name, documents)
|
||||
|
||||
# Define the parameters manually
|
||||
@@ -215,9 +238,11 @@ def test_api_documents_search_ordering_by_unknown_field():
|
||||
|
||||
# Perform the parameterized tests
|
||||
for direction in directions:
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/documents/?q=*&order_by=unknown&order_direction={direction}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token}",
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "order_by": "unknown", "order_direction": direction},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
@@ -236,13 +261,17 @@ def test_api_documents_search_ordering_by_unknown_field():
|
||||
def test_api_documents_search_ordering_by_unknown_direction():
|
||||
"""Trying to sort with an unknown direction should return a 400 error"""
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(2)
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
2, reach=random.choice(["public", "authenticated"])
|
||||
)
|
||||
prepare_index(service.name, documents)
|
||||
|
||||
for field in enums.ORDER_BY_OPTIONS:
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/documents/?q=*&order_by={field}&order_direction=unknown",
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token}",
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "order_by": field, "order_direction": "unknown"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
@@ -258,13 +287,17 @@ def test_api_documents_search_ordering_by_unknown_direction():
|
||||
def test_api_documents_search_filtering_by_reach():
|
||||
"""It should be possible to filter results by their reach"""
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(4)
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
4, reach=random.choice(["public", "authenticated"])
|
||||
)
|
||||
prepare_index(service.name, documents)
|
||||
|
||||
for reach in enums.ReachEnum:
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/documents/?q=*&reach={reach.value}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token}",
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "reach": reach.value},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -280,24 +313,31 @@ def test_api_documents_search_filtering_by_reach():
|
||||
def test_api_documents_search_pagination_basic():
|
||||
"""Pagination should correctly return documents for the specified page and page size"""
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(9)
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
9, reach=random.choice(["public", "authenticated"])
|
||||
)
|
||||
ids = [str(doc["id"]) for doc in documents]
|
||||
prepare_index(service.name, documents)
|
||||
|
||||
# Request the first page with a page size of 3
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/?q=*&page_number=1&page_size=3",
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "page_number": 1, "page_size": 3},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
responses = response.json()
|
||||
assert len(responses) == 3 # Page size is 3
|
||||
assert [r["_id"] for r in responses] == ids[0:3]
|
||||
|
||||
# Request the second page with a page size of 3
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/?q=*&page_number=2&page_size=3",
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "page_number": 2, "page_size": 3},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
responses = response.json()
|
||||
@@ -305,9 +345,11 @@ def test_api_documents_search_pagination_basic():
|
||||
assert [r["_id"] for r in responses] == ids[3:6]
|
||||
|
||||
# Request the third page with a page size of 5 (should contain the remaining 3 documents)
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/?q=*&page_number=3&page_size=3",
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "page_number": 3, "page_size": 3},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -319,14 +361,18 @@ def test_api_documents_search_pagination_basic():
|
||||
def test_api_documents_search_pagination_last_page_edge_case():
|
||||
"""Requesting the last page should return the correct number of remaining documents"""
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(8)
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
8, reach=random.choice(["public", "authenticated"])
|
||||
)
|
||||
ids = [str(doc["id"]) for doc in documents]
|
||||
prepare_index(service.name, documents)
|
||||
|
||||
# Request the first page with a page size of 3
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/?q=*&page_number=1&page_size=3",
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "page_number": 1, "page_size": 3},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -334,9 +380,11 @@ def test_api_documents_search_pagination_last_page_edge_case():
|
||||
assert [r["_id"] for r in response.json()] == ids[0:3]
|
||||
|
||||
# Request the third page with a page size of 3 (should contain the last 1 document)
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/?q=*&page_number=3&page_size=3",
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "page_number": 3, "page_size": 3},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -349,13 +397,17 @@ def test_api_documents_search_pagination_out_of_bounds():
|
||||
Requesting a page number that exceeds the total number of pages should return an empty list
|
||||
"""
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(4)
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
4, reach=random.choice(["public", "authenticated"])
|
||||
)
|
||||
prepare_index(service.name, documents)
|
||||
|
||||
# Request the fourth page with a page size of 2 (there are only 2 pages)
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/?q=*&page_number=4&page_size=2",
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "page_number": 4, "page_size": 2},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -365,7 +417,9 @@ def test_api_documents_search_pagination_out_of_bounds():
|
||||
def test_api_documents_search_pagination_invalid_parameters():
|
||||
"""Invalid pagination parameters should result in a 400 error"""
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(4)
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
4, reach=random.choice(["public", "authenticated"])
|
||||
)
|
||||
prepare_index(service.name, documents)
|
||||
|
||||
parameters = [
|
||||
@@ -388,9 +442,11 @@ def test_api_documents_search_pagination_invalid_parameters():
|
||||
]
|
||||
|
||||
for page_number, page_size, error_type, error_message in parameters:
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/documents/?q=*&page_number={page_number}&page_size={page_size}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token}",
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "page_number": page_number, "page_size": page_size},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
@@ -409,18 +465,22 @@ def test_api_documents_search_pagination_with_filtering():
|
||||
prepare_index(service.name, public_documents + private_documents)
|
||||
|
||||
# Filter by public documents, request first page
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/?q=*&reach=public&page_number=1&page_size=2",
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "reach": "public", "page_number": 1, "page_size": 2},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 2
|
||||
assert [r["_id"] for r in response.json()] == public_ids[0:2]
|
||||
|
||||
# Request second page for public documents (remaining 1 document)
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/?q=*&reach=public&page_number=2&page_size=2",
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "reach": "public", "page_number": 2, "page_size": 2},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Test suite for access control when searching documents over the API.
|
||||
|
||||
Don't use pytest parametrized tests because batch generation and indexing
|
||||
of documents is slow and better done only once.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import enums, factories
|
||||
|
||||
from .utils import prepare_index
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_search_access_control_anonymous():
|
||||
"""Anonymous users should not be allowed to search documents even public."""
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = []
|
||||
for reach in enums.ReachEnum:
|
||||
documents.extend(factories.DocumentSchemaFactory.build_batch(3, reach=reach))
|
||||
prepare_index(service.name, documents)
|
||||
|
||||
response = APIClient().post("/api/v1.0/documents/search/?q=*")
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from .views import DocumentView
|
||||
from .views import IndexDocumentView, SearchDocumentView
|
||||
|
||||
urlpatterns = [
|
||||
path("documents/", DocumentView.as_view(), name="document"),
|
||||
path("documents/index/", IndexDocumentView.as_view(), name="document"),
|
||||
path("documents/search/", SearchDocumentView.as_view(), name="document"),
|
||||
]
|
||||
|
||||
+82
-26
@@ -2,6 +2,8 @@
|
||||
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from rest_framework import status, views
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from urllib3.exceptions import ReadTimeoutError
|
||||
|
||||
@@ -11,33 +13,17 @@ from .opensearch import client, ensure_index_exists
|
||||
from .permissions import IsAuthAuthenticated
|
||||
|
||||
|
||||
class DocumentView(views.APIView):
|
||||
class IndexDocumentView(views.APIView):
|
||||
"""
|
||||
API view for managing documents in an OpenSearch index.
|
||||
|
||||
This view provides functionality for both indexing and searching documents
|
||||
within an OpenSearch index dedicated to the authenticated service. The class
|
||||
supports the following operations:
|
||||
|
||||
1. **Document Indexing (POST)**:
|
||||
API view for indexing documents in OpenSearch.
|
||||
- Handles both single document and bulk document indexing.
|
||||
- The index is dynamically determined based on the service authentication token,
|
||||
ensuring that each service has its own isolated index.
|
||||
|
||||
2. **Document Search (GET)**:
|
||||
- Enables searching through indexed documents with support for various filters
|
||||
and sorting options.
|
||||
- The search results can be sorted or filtered via querystring parameters.
|
||||
ensuring that each service has its own isolated index.
|
||||
"""
|
||||
|
||||
authentication_classes = [ServiceTokenAuthentication]
|
||||
permission_classes = [IsAuthAuthenticated]
|
||||
|
||||
@property
|
||||
def index_name(self):
|
||||
"""Compute index name from the service name extracted during authentication"""
|
||||
return f"find-{self.request.auth}"
|
||||
|
||||
# pylint: disable=too-many-locals
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
@@ -141,18 +127,30 @@ class DocumentView(views.APIView):
|
||||
{"status": "created", "_id": _id}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
class SearchDocumentView(views.APIView):
|
||||
"""
|
||||
API view for searching documents in OpenSearch.
|
||||
- Enables searching through indexed documents with support for various filters
|
||||
and sorting options.
|
||||
- The search results can be sorted or filtered via querystring parameters.
|
||||
"""
|
||||
|
||||
authentication_classes = []
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
Handle GET requests to perform a search on indexed documents with optional filtering
|
||||
Handle POST requests to perform a search on indexed documents with optional filtering
|
||||
and ordering.
|
||||
|
||||
The search query should be provided as a query parameter 'q'. The method constructs a
|
||||
The search query should be provided as a "q" parameter. The method constructs a
|
||||
search request to OpenSearch using the specified query, with the option to filter by
|
||||
'is_public' and order by 'relevance', 'created_at', 'updated_at', or 'size'.
|
||||
'reach' and order by 'relevance', 'created_at', 'updated_at', or 'size'.
|
||||
The results are further filtered by 'users' and 'groups' based on the authentication
|
||||
header.
|
||||
|
||||
Query Parameters:
|
||||
Body Parameters:
|
||||
---------------
|
||||
q : str
|
||||
The search query string. This is a required parameter.
|
||||
@@ -170,6 +168,11 @@ class DocumentView(views.APIView):
|
||||
page_size : int, optional
|
||||
The number of results to return per page.
|
||||
Defaults to 50 if not specified.
|
||||
services: List[str], optional
|
||||
List of services on which we intend to run the query (current service if left empty)
|
||||
visited: List[uuid]
|
||||
List of public/authenticated documents the user has visited to limit
|
||||
the document returned to the ones the current user has seen.
|
||||
|
||||
Returns:
|
||||
--------
|
||||
@@ -177,8 +180,29 @@ class DocumentView(views.APIView):
|
||||
- 200 OK: Returns a list of search results matching the query.
|
||||
- 400 Bad Request: If the query parameter 'q' is not provided or invalid.
|
||||
"""
|
||||
# TODO resource server:
|
||||
# - Replace authentication by resource server
|
||||
# - Introspect access token and get user sub from access token
|
||||
# - Get authorized index names for access token service ID
|
||||
# - Check intersection between params.services and authorized index names
|
||||
# - Raise 400 error if not all requested services are authorized
|
||||
# - Implement filtering to limit response to only "visited" documents among those accessible with "public" and "authenticated" reach
|
||||
# - V2: Get list of groups related to the user from SCIM provider (consider caching result)
|
||||
|
||||
# //////////////////////////////
|
||||
# // Make it work temporarily //
|
||||
# //////////////////////////////
|
||||
authorization_header = request.headers.get("Authorization")
|
||||
try:
|
||||
user_sub = authorization_header.split(" ")[1]
|
||||
except (IndexError, AttributeError) as err:
|
||||
raise AuthenticationFailed("Invalid Authorization header format") from err
|
||||
|
||||
groups = []
|
||||
# //////////////////////////////////////////////////
|
||||
|
||||
# Extract and validate query parameters using Pydantic schema
|
||||
params = schemas.SearchQueryParametersSchema(**request.GET)
|
||||
params = schemas.SearchQueryParametersSchema(**request.data)
|
||||
|
||||
# Compute pagination parameters
|
||||
from_value = (params.page_number - 1) * params.page_size
|
||||
@@ -219,7 +243,39 @@ class DocumentView(views.APIView):
|
||||
{params.order_by: {"order": params.order_direction}}
|
||||
)
|
||||
|
||||
# Filter by reach if provided
|
||||
# Apply access control based on documents reach
|
||||
search_body["query"]["bool"]["must"].append(
|
||||
{
|
||||
"bool": {
|
||||
"should": [
|
||||
{
|
||||
"bool": {
|
||||
"must_not": {
|
||||
"term": {enums.REACH: enums.ReachEnum.RESTRICTED}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"bool": {
|
||||
"must": {
|
||||
"term": {enums.REACH: enums.ReachEnum.RESTRICTED}
|
||||
},
|
||||
"should": [
|
||||
{"term": {enums.USERS: user_sub}},
|
||||
{"terms": {enums.GROUPS: groups}},
|
||||
],
|
||||
# At least one of the 2 optional should clauses must apply
|
||||
"minimum_should_match": 1,
|
||||
}
|
||||
},
|
||||
],
|
||||
# At least one of the 2 optional should clauses must apply
|
||||
"minimum_should_match": 1,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Optional filter by reach if explicitly provided in the query
|
||||
if params.reach is not None:
|
||||
search_body["query"]["bool"]["filter"].append(
|
||||
{"term": {enums.REACH: params.reach}}
|
||||
|
||||
Reference in New Issue
Block a user