зеркало из https://github.com/mozilla/pontoon.git
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:
Родитель
729cc71664
Коммит
0e4d906382
|
@ -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"]}
|
||||
|
|
Загрузка…
Ссылка в новой задаче