Skip to content

Commit

Permalink
Add ability to search terminology via API (mozilla#3532)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
mathjazz authored Jan 22, 2025
1 parent 729cc71 commit 0e4d906
Show file tree
Hide file tree
Showing 7 changed files with 238 additions and 102 deletions.
38 changes: 14 additions & 24 deletions pontoon/api/README.md
Original file line number Diff line number Diff line change
@@ -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:

Expand All @@ -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:
Expand All @@ -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`.
63 changes: 63 additions & 0 deletions pontoon/api/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@
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,
Project as ProjectModel,
ProjectLocale as ProjectLocaleModel,
)
from pontoon.tags.models import Tag as TagModel
from pontoon.terminology.models import (
Term as TermModel,
TermTranslation as TermTranslationModel,
)


class Stats:
Expand Down Expand Up @@ -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")

Expand All @@ -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)

Expand Down Expand Up @@ -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)
138 changes: 138 additions & 0 deletions pontoon/api/tests/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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 = {
Expand Down Expand Up @@ -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,
}
]
}
}
Loading

0 comments on commit 0e4d906

Please sign in to comment.