Add ability to search terminology via API (#3532)

Adds a GraphQL API call support for simple case-insensitive search of terms and their translations for a given locale.

Example:

```graphql
query {
  termSearch(search: "open", locale: "sl") {
    text
    partOfSpeech
    definition
    usage
    translationText
  }
}

```

Response:
```json
{
  "data": {
    "termSearch": [
      {
        "text": "open source",
        "partOfSpeech": "ADJECTIVE",
        "definition": "Refers to any program whose source code is made available for use or modification.",
        "usage": "These terms are not intended to limit any rights granted under open source licenses",
        "translationText": "odprtokoden"
      }
    ]
  }
}
```

Also enables GraphiQL everywhere.
This commit is contained in:
Matjaž Horvat 2025-01-23 00:09:06 +01:00 коммит произвёл GitHub
Родитель 729cc71664
Коммит 0e4d906382
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
7 изменённых файлов: 238 добавлений и 102 удалений

Просмотреть файл

@ -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
## HTML mode
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}}"
```
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`.

Просмотреть файл

@ -3,6 +3,8 @@ import graphene
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 @@ from pontoon.base.models import (
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 @@ class Locale(DjangoObjectType, Stats):
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 @@ class Query(graphene.ObjectType):
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)

Просмотреть файл

@ -5,6 +5,7 @@ from itertools import product
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,
}
]
}
}

Просмотреть файл

@ -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
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
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.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

Просмотреть файл

@ -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(

Просмотреть файл

@ -916,16 +916,23 @@ SECURE_BROWSER_XSS_FILTER = True
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 @@ CSP_SCRIPT_SRC = (
"'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)

Просмотреть файл

@ -41,18 +41,4 @@ TEMPLATES[0]["OPTIONS"]["match_regex"] = re.compile(
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"]}