diff --git a/pontoon/api/README.md b/pontoon/api/README.md index 254236d708..c8d710ce23 100644 --- a/pontoon/api/README.md +++ b/pontoon/api/README.md @@ -1,13 +1,16 @@ # GraphQL API Pontoon exposes some of its data via a public API endpoint. The API is -[GraphQL](http://graphql.org/)-based and available at `/graphql/`. +[GraphQL](https://graphql.org/)-based and available at `/graphql/`. -## Production Deployments +The endpoint has two modes of operation: a JSON one and an HTML one. -When run in production (`DEV is False`) the API returns `application/json` -responses to GET and POST requests. In case of GET requests, any whitespace in -the query must be escaped. +## JSON mode + +When a request is sent without any headers, with `Accept: application/json` or +if it explicitly contains a `raw` query argument, the endpoint will return JSON +`application/json` responses to GET and POST requests. In case of GET requests, +any whitespace in the query must be escaped. An example GET requests may look like this: @@ -21,35 +24,22 @@ An example POST requests may look like this: $ curl -X POST -d "query={ projects { name } }" https://example.com/graphql/ ``` -## Local Development - -In a local development setup (`DEV is True`) the endpoint has two modes of -operation: a JSON one and an HTML one. - -When a request is sent, without any headers, with `Accept: application/json` or -if it explicitly contains a `raw` query argument, the endpoint will behave like -a production one, returning JSON responses. - -The following query in the CLI will return a JSON response: - -```bash -$ curl --globoff "http://localhost:8000/graphql/?query={projects{name}}" -``` +## HTML mode -If however a request is sent with `Accept: text/html` such as is the case when +When a request is sent with `Accept: text/html` such as is the case when accessing the endpoint in a browser, a GUI query editor and explorer, [GraphiQL](https://github.com/graphql/graphiql), will be served:: - http://localhost:8000/graphql/?query={projects{name}} + https://example.com/graphql/?query={projects{name}} To preview the JSON response in the browser, pass in the `raw` query argument:: - http://localhost:8000/graphql/?query={projects{name}}&raw + https://example.com/graphql/?query={projects{name}}&raw ## Query IDE The [GraphiQL](https://github.com/graphql/graphiql) query IDE is available at -`http://localhost:8000/graphql/` when running Pontoon locally and the URL is +`https://example.com/graphql/` when running Pontoon locally and the URL is accessed with the `Accept: text/html` header, e.g. using a browser. It offers a query editor with: @@ -59,4 +49,4 @@ It offers a query editor with: - real-time error reporting, - results folding, - autogenerated docs on shapes and their fields, -- [introspection](http://docs.graphene-python.org/projects/django/en/latest/debug/) via the `_debug`. +- [introspection](https://docs.graphene-python.org/projects/django/en/latest/debug/) via the `_debug`. diff --git a/pontoon/api/schema.py b/pontoon/api/schema.py index d89283ea3c..cb5004658a 100644 --- a/pontoon/api/schema.py +++ b/pontoon/api/schema.py @@ -3,6 +3,8 @@ from graphene_django import DjangoObjectType from graphene_django.debug import DjangoDebug +from django.db.models import Prefetch, Q + from pontoon.api.util import get_fields from pontoon.base.models import ( Locale as LocaleModel, @@ -10,6 +12,10 @@ ProjectLocale as ProjectLocaleModel, ) from pontoon.tags.models import Tag as TagModel +from pontoon.terminology.models import ( + Term as TermModel, + TermTranslation as TermTranslationModel, +) class Stats: @@ -123,6 +129,35 @@ def resolve_localizations(obj, info, include_disabled, include_system): return records.distinct() +class TermTranslation(DjangoObjectType): + class Meta: + model = TermTranslationModel + fields = ("text", "locale") + + +class Term(DjangoObjectType): + class Meta: + model = TermModel + fields = ( + "text", + "part_of_speech", + "definition", + "usage", + ) + + translations = graphene.List(TermTranslation) + translation_text = graphene.String() + + def resolve_translations(self, info): + return self.translations.all() + + def resolve_translation_text(self, info): + # Returns the text of the translation for the specified locale, if available. + if hasattr(self, "locale_translations") and self.locale_translations: + return self.locale_translations[0].text + return None + + class Query(graphene.ObjectType): debug = graphene.Field(DjangoDebug, name="_debug") @@ -138,6 +173,13 @@ class Query(graphene.ObjectType): locales = graphene.List(Locale) locale = graphene.Field(Locale, code=graphene.String()) + # New query for searching terms + term_search = graphene.List( + Term, + search=graphene.String(required=True), + locale=graphene.String(required=True), + ) + def resolve_projects(obj, info, include_disabled, include_system): fields = get_fields(info) @@ -197,5 +239,26 @@ def resolve_locale(obj, info, code): return qs.get(code=code) + def resolve_term_search(self, info, search, locale): + term_query = Q(text__icontains=search) + + translation_query = Q(translations__text__icontains=search) & Q( + translations__locale__code=locale + ) + + # Prefetch translations for the specified locale + prefetch_translations = Prefetch( + "translations", + queryset=TermTranslationModel.objects.filter(locale__code=locale), + to_attr="locale_translations", + ) + + # Perform the query on the Term model and prefetch translations + return ( + TermModel.objects.filter(term_query | translation_query) + .prefetch_related(prefetch_translations) + .distinct() + ) + schema = graphene.Schema(query=Query) diff --git a/pontoon/api/tests/test_schema.py b/pontoon/api/tests/test_schema.py index b8403b2937..27e78f22d0 100644 --- a/pontoon/api/tests/test_schema.py +++ b/pontoon/api/tests/test_schema.py @@ -5,6 +5,7 @@ import pytest from pontoon.base.models import Project, ProjectLocale +from pontoon.terminology.models import Term, TermTranslation from pontoon.test.factories import ProjectFactory @@ -24,6 +25,27 @@ def setup_excepthook(): sys.excepthook = excepthook_orig +@pytest.fixture +def terms(locale_a): + term1 = Term.objects.create( + text="open", + part_of_speech="verb", + definition="Allow access", + usage="Open the door.", + ) + term2 = Term.objects.create( + text="close", + part_of_speech="verb", + definition="Shut or block access", + usage="Close the door.", + ) + + TermTranslation.objects.create(term=term1, locale=locale_a, text="odpreti") + TermTranslation.objects.create(term=term2, locale=locale_a, text="zapreti") + + return [term1, term2] + + @pytest.mark.django_db def test_projects(client): body = { @@ -316,3 +338,119 @@ def test_locale_localizations_cyclic(client): assert response.status_code == 200 assert b"Cyclic queries are forbidden" in response.content + + +@pytest.mark.django_db +def test_term_search_by_text(client, terms): + """Test searching terms by their text field.""" + body = { + "query": """{ + termSearch(search: "open", locale: "kg") { + text + translationText + } + }""" + } + response = client.get("/graphql/", body, HTTP_ACCEPT="application/json") + assert response.status_code == 200 + assert response.json() == { + "data": { + "termSearch": [ + { + "text": "open", + "translationText": "odpreti", + } + ] + } + } + + +@pytest.mark.django_db +def test_term_search_by_translation(client, terms): + """Test searching terms by their translations.""" + body = { + "query": """{ + termSearch(search: "odpreti", locale: "kg") { + text + translationText + } + }""" + } + response = client.get("/graphql/", body, HTTP_ACCEPT="application/json") + assert response.status_code == 200 + assert response.json() == { + "data": { + "termSearch": [ + { + "text": "open", + "translationText": "odpreti", + } + ] + } + } + + +@pytest.mark.django_db +def test_term_search_no_match(client, terms): + """Test searching with a term that doesn't match any text or translations.""" + body = { + "query": """{ + termSearch(search: "nonexistent", locale: "kg") { + text + translationText + } + }""" + } + response = client.get("/graphql/", body, HTTP_ACCEPT="application/json") + assert response.status_code == 200 + assert response.json() == {"data": {"termSearch": []}} + + +@pytest.mark.django_db +def test_term_search_multiple_matches(client, terms): + """Test searching with a term that matches multiple results.""" + body = { + "query": """{ + termSearch(search: "o", locale: "kg") { + text + translationText + } + }""" + } + response = client.get("/graphql/", body, HTTP_ACCEPT="application/json") + assert response.status_code == 200 + + # Sort the response data to ensure order doesn't affect test results + actual_data = response.json()["data"]["termSearch"] + expected_data = [ + {"text": "close", "translationText": "zapreti"}, + {"text": "open", "translationText": "odpreti"}, + ] + assert sorted(actual_data, key=lambda x: x["text"]) == sorted( + expected_data, key=lambda x: x["text"] + ) + + +@pytest.mark.django_db +def test_term_search_no_translations(client, terms): + """Test searching terms for a locale with no translations.""" + body = { + "query": """{ + termSearch(search: "open", locale: "en") { + text + translationText + } + }""" + } + response = client.get("/graphql/", body, HTTP_ACCEPT="application/json") + assert response.status_code == 200 + assert response.json() == { + "data": { + "termSearch": [ + { + "text": "open", + "translationText": None, + } + ] + } + } diff --git a/pontoon/api/tests/test_urls.py b/pontoon/api/tests/test_urls.py index c4dcad2b8a..8d0a954401 100644 --- a/pontoon/api/tests/test_urls.py +++ b/pontoon/api/tests/test_urls.py @@ -1,15 +1,5 @@ -import importlib -import sys - import pytest -from django.urls import clear_url_caches - - -def reload_urls(settings): - clear_url_caches() - importlib.reload(sys.modules[settings.ROOT_URLCONF]) - @pytest.fixture @pytest.mark.django_db @@ -18,67 +8,24 @@ def projects_query(): @pytest.mark.django_db -def test_graphql_dev_get(settings, projects_query, client): - settings.DEV = True - +def test_graphql_json_get(settings, projects_query, client): response = client.get("/graphql/", projects_query, HTTP_ACCEPT="application/json") assert response.status_code == 200 @pytest.mark.django_db -def test_graphql_dev_post(settings, projects_query, client): - settings.DEV = True - +def test_graphql_json_post(settings, projects_query, client): response = client.post("/graphql/", projects_query, HTTP_ACCEPT="application/json") assert response.status_code == 200 -@pytest.mark.skipif(reason="Overriding DEV does not work.") -@pytest.mark.django_db -def test_graphql_prod_get(settings, projects_query, client): - settings.DEV = True - - response = client.get("/graphql/", projects_query, HTTP_ACCEPT="application/json") - assert response.status_code == 200 - - -@pytest.mark.skipif(reason="Overriding DEV does not work.") @pytest.mark.django_db -def test_graphql_prod_post(settings, projects_query, client): - settings.DEV = False - - response = client.post("/graphql/", projects_query, HTTP_ACCEPT="application/json") - assert response.status_code == 200 - - -@pytest.mark.django_db -def test_graphiql_dev_get(settings, projects_query, client): - settings.DEV = True - +def test_graphiql_html_get(settings, projects_query, client): response = client.get("/graphql/", projects_query, HTTP_ACCEPT="text/html") assert response.status_code == 200 @pytest.mark.django_db -def test_graphiql_dev_post(settings, projects_query, client): - settings.DEV = True +def test_graphiql_html_post(settings, projects_query, client): response = client.post("/graphql/", projects_query, HTTP_ACCEPT="text/html") assert response.status_code == 200 - - -@pytest.mark.skipif(reason="Overriding DEV does not work.") -@pytest.mark.django_db -def test_graphiql_prod_get(settings, projects_query, client): - settings.DEV = False - reload_urls(settings) - response = client.get("/graphql/", projects_query, HTTP_ACCEPT="text/html") - assert response.status_code == 400 - - -@pytest.mark.skipif(reason="Overriding DEV does not work.") -@pytest.mark.django_db -def test_graphiql_prod_post(projects_query, client, settings): - settings.DEV = False - reload_urls(settings) - response = client.post("/graphql/", projects_query, HTTP_ACCEPT="text/html") - assert response.status_code == 400 diff --git a/pontoon/api/urls.py b/pontoon/api/urls.py index 6394030133..147194ea45 100644 --- a/pontoon/api/urls.py +++ b/pontoon/api/urls.py @@ -1,18 +1,18 @@ from graphene_django.views import GraphQLView from django.urls import include, path, re_path +from django.views.decorators.csrf import csrf_exempt from pontoon.api import views from pontoon.api.schema import schema -from pontoon.settings import DEV urlpatterns = [ - # GraphQL endpoint. In DEV mode it serves the GraphiQL IDE if accessed with Accept: text/html. + # GraphQL endpoint. Serves the GraphiQL IDE if accessed with Accept: text/html. # Explicitly support URLs with or without trailing slash in order to support curl requests. re_path( r"^graphql/?$", - GraphQLView.as_view(schema=schema, graphiql=DEV), + csrf_exempt(GraphQLView.as_view(schema=schema, graphiql=True)), ), # API v1 path( diff --git a/pontoon/settings/base.py b/pontoon/settings/base.py index 9bd4f97656..6eabef673d 100644 --- a/pontoon/settings/base.py +++ b/pontoon/settings/base.py @@ -916,16 +916,23 @@ def _default_from_email(): SECURE_SSL_REDIRECT = not (DEBUG or os.environ.get("CI", False)) # Content-Security-Policy headers -# 'blob:' is needed for confetti.browser.js CSP_DEFAULT_SRC = ("'none'",) CSP_FRAME_SRC = ("https:",) -CSP_WORKER_SRC = ("https:",) + ("blob:",) +CSP_WORKER_SRC = ( + "https:", + # Needed for confetti.browser.js + "blob:", +) CSP_CONNECT_SRC = ( "'self'", "https://bugzilla.mozilla.org/rest/bug", "https://region1.google-analytics.com/g/collect", ) -CSP_FONT_SRC = ("'self'",) +CSP_FONT_SRC = ( + "'self'", + # Needed for GraphiQL + "data:", +) CSP_IMG_SRC = ( "'self'", "https:", @@ -939,13 +946,18 @@ def _default_from_email(): "'self'", "'unsafe-eval'", "'sha256-fDsgbzHC0sNuBdM4W91nXVccgFLwIDkl197QEca/Cl4='", - # Rules related to Google Analytics + # Needed for Google Analytics "'sha256-MAn2iEyXLmB7sfv/20ImVRdQs8NCZ0A5SShdZsZdv20='", "https://www.googletagmanager.com/gtag/js", + # Needed for GraphiQL + "'sha256-HHh/PGb5Jp8ck+QB/v7zeWzuHf3vYssM0CBPvYgEHR4='", + "https://cdn.jsdelivr.net", ) CSP_STYLE_SRC = ( "'self'", "'unsafe-inline'", + # Needed for GraphiQL + "https://cdn.jsdelivr.net", ) # Needed if site not hosted on HTTPS domains (like local setup) diff --git a/pontoon/settings/dev.py b/pontoon/settings/dev.py index 338157ad6c..035e011364 100644 --- a/pontoon/settings/dev.py +++ b/pontoon/settings/dev.py @@ -41,18 +41,4 @@ re.VERBOSE, ) -CSP_FONT_SRC = base.CSP_FONT_SRC + ("data:",) -CSP_IMG_SRC = base.CSP_IMG_SRC + ("data:",) -CSP_SCRIPT_SRC = base.CSP_SCRIPT_SRC + ( - "http://ajax.googleapis.com", - # Needed for GraphiQL - "https://cdn.jsdelivr.net", - # Needed for GraphiQL (inline script) - "'sha256-HHh/PGb5Jp8ck+QB/v7zeWzuHf3vYssM0CBPvYgEHR4='", -) -CSP_STYLE_SRC = base.CSP_STYLE_SRC + ( - # Needed for GraphiQL - "https://cdn.jsdelivr.net", -) - GRAPHENE = {"MIDDLEWARE": ["graphene_django.debug.DjangoDebugMiddleware"]}