Skip to content

Commit 00d188e

Browse files
authored
Addons: accept project-slug and version-slug on endpoint (#10823)
* Addons: accept `project-slug` and `version-slug` on endpoint `/_/addons/` API endpoint now accepts `project-slug` and `version-slug`. This is useful to be able to cache these requests in our CDN. It still supports sending `url=` when necessary for those requests that relies on the full URL to generate specific data in the response. The NGINX config is updated to inject `meta` tags with project/version slugs, emulating what our CF worker does on production. Closes #10536 * Add tests and update logic for API permissions
1 parent 687f96e commit 00d188e

File tree

4 files changed

+198
-74
lines changed

4 files changed

+198
-74
lines changed

dockerfiles/nginx/proxito.conf.template

+3-2
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,10 @@ server {
111111
set $rtd_force_addons $upstream_http_x_rtd_force_addons;
112112
add_header X-RTD-Force-Addons $rtd_force_addons always;
113113

114-
# Inject our own script dynamically
114+
# Inject our own script dynamically and project/version slugs into the HTML to emulate what CF worker does
115115
# TODO: find a way to make this work _without_ running `npm run dev` from the `addons` repository
116-
sub_filter '</head>' '<script language="javascript" src="http://localhost:8000/readthedocs-addons.js"></script>\n</head>';
116+
sub_filter '</head>' '<script async language="javascript" src="http://localhost:8000/readthedocs-addons.js"></script>\n<meta name="readthedocs-project-slug" content="$rtd_project" />\n<meta name="readthedocs-version-slug" content="latest" />\n</head>';
117+
sub_filter_types text/html;
117118
sub_filter_last_modified on;
118119
sub_filter_once on;
119120
}

readthedocs/api/v2/permissions.py

+11
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ class IsAuthorizedToViewVersion(permissions.BasePermission):
4040
def has_permission(self, request, view):
4141
project = view._get_project()
4242
version = view._get_version()
43+
44+
# I had to add this condition here because I want to return a 400 when
45+
# the `project-slug` or `version-slug` are not sent to the API
46+
# endpoint. In those cases, we don't have a Project/Version and this
47+
# function was failing.
48+
#
49+
# I think it's a valid use case when Project/Version is invalid to be
50+
# able to return a good response from the API view.
51+
if project is None or version is None:
52+
return True
53+
4354
has_access = (
4455
Version.objects.public(
4556
user=request.user,

readthedocs/proxito/tests/test_hosting.py

+78
Original file line numberDiff line numberDiff line change
@@ -498,3 +498,81 @@ def test_flyout_subproject_urls(self):
498498
},
499499
]
500500
assert r.json()["addons"]["flyout"]["translations"] == expected_translations
501+
502+
def test_send_project_not_version_slugs(self):
503+
r = self.client.get(
504+
reverse("proxito_readthedocs_docs_addons"),
505+
{
506+
"api-version": "0.1.0",
507+
"client-version": "0.6.0",
508+
"project-slug": self.project.slug,
509+
},
510+
secure=True,
511+
headers={
512+
"host": "project.dev.readthedocs.io",
513+
},
514+
)
515+
assert r.status_code == 400
516+
assert r.json() == {
517+
"error": "'project-slug' and 'version-slug' GET attributes are required when not sending 'url'"
518+
}
519+
520+
def test_send_version_not_project_slugs(self):
521+
r = self.client.get(
522+
reverse("proxito_readthedocs_docs_addons"),
523+
{
524+
"api-version": "0.1.0",
525+
"client-version": "0.6.0",
526+
"version-slug": self.version.slug,
527+
},
528+
secure=True,
529+
headers={
530+
"host": "project.dev.readthedocs.io",
531+
},
532+
)
533+
assert r.status_code == 400
534+
assert r.json() == {
535+
"error": "'project-slug' and 'version-slug' GET attributes are required when not sending 'url'"
536+
}
537+
538+
def test_send_project_version_slugs(self):
539+
r = self.client.get(
540+
reverse("proxito_readthedocs_docs_addons"),
541+
{
542+
"api-version": "0.1.0",
543+
"client-version": "0.6.0",
544+
"project-slug": self.project.slug,
545+
"version-slug": self.version.slug,
546+
},
547+
secure=True,
548+
headers={
549+
"host": "project.dev.readthedocs.io",
550+
},
551+
)
552+
assert r.status_code == 200
553+
expected_response = self._get_response_dict("v0")
554+
# Remove `addons.doc_diff` from the response because it's not present when `url=` is not sent
555+
expected_response["addons"].pop("doc_diff")
556+
557+
assert self._normalize_datetime_fields(r.json()) == expected_response
558+
559+
def test_send_project_version_slugs_and_url(self):
560+
r = self.client.get(
561+
reverse("proxito_readthedocs_docs_addons"),
562+
{
563+
"api-version": "0.1.0",
564+
"client-version": "0.6.0",
565+
"url": "https://project.dev.readthedocs.io/en/latest/",
566+
# When sending `url=`, slugs are ignored
567+
"project-slug": "different-project-slug-than-url",
568+
"version-slug": "different-version-slug-than-url",
569+
},
570+
secure=True,
571+
headers={
572+
"host": "project.dev.readthedocs.io",
573+
},
574+
)
575+
assert r.status_code == 200
576+
assert self._normalize_datetime_fields(r.json()) == self._get_response_dict(
577+
"v0"
578+
)

readthedocs/proxito/views/hosting.py

+106-72
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from readthedocs.core.resolver import resolver
2323
from readthedocs.core.unresolver import UnresolverError, unresolver
2424
from readthedocs.core.utils.extend import SettingsOverrideObject
25-
from readthedocs.projects.models import Feature
25+
from readthedocs.projects.models import Feature, Project
2626

2727
log = structlog.get_logger(__name__) # noqa
2828

@@ -47,10 +47,19 @@ class BaseReadTheDocsConfigJson(CDNCacheTagsMixin, APIView):
4747
4848
Attributes:
4949
50-
url (required): absolute URL from where the request is performed
51-
(e.g. ``window.location.href``)
52-
5350
api-version (required): API JSON structure version (e.g. ``0``, ``1``, ``2``).
51+
52+
project-slug (required): slug of the project.
53+
Optional if "url" is sent.
54+
55+
version-slug (required): slug of the version.
56+
Optional if "url" is sent.
57+
58+
url (optional): absolute URL from where the request is performed.
59+
When sending "url" attribute, "project-slug" and "version-slug" are ignored.
60+
(e.g. ``window.location.href``).
61+
62+
client-version (optional): JavaScript client version (e.g. ``0.6.0``).
5463
"""
5564

5665
http_method_names = ["get"]
@@ -61,61 +70,73 @@ class BaseReadTheDocsConfigJson(CDNCacheTagsMixin, APIView):
6170
@lru_cache(maxsize=1)
6271
def _resolve_resources(self):
6372
url = self.request.GET.get("url")
64-
if not url:
65-
# TODO: not sure what to return here when it fails on the `has_permission`
66-
return None, None, None, None
73+
project_slug = self.request.GET.get("project-slug")
74+
version_slug = self.request.GET.get("version-slug")
75+
76+
project = None
77+
version = None
78+
build = None
79+
filename = None
6780

6881
unresolved_domain = self.request.unresolved_domain
6982

7083
# Main project from the domain.
7184
project = unresolved_domain.project
7285

73-
try:
74-
unresolved_url = unresolver.unresolve_url(url)
75-
# Project from the URL: if it's a subproject it will differ from
76-
# the main project got from the domain.
77-
project = unresolved_url.project
78-
version = unresolved_url.version
79-
filename = unresolved_url.filename
80-
# This query should use a particular index:
81-
# ``builds_build_version_id_state_date_success_12dfb214_idx``.
82-
# Otherwise, if the index is not used, the query gets too slow.
83-
build = version.builds.filter(
84-
success=True,
85-
state=BUILD_STATE_FINISHED,
86-
).last()
87-
88-
except UnresolverError as exc:
89-
# If an exception is raised and there is a ``project`` in the
90-
# exception, it's a partial match. This could be because of an
91-
# invalid URL path, but on a valid project domain. In this case, we
92-
# continue with the ``project``, but without a ``version``.
93-
# Otherwise, we return 404 NOT FOUND.
94-
project = getattr(exc, "project", None)
95-
if not project:
96-
raise Http404() from exc
97-
98-
version = None
99-
filename = None
100-
build = None
86+
if url:
87+
try:
88+
unresolved_url = unresolver.unresolve_url(url)
89+
# Project from the URL: if it's a subproject it will differ from
90+
# the main project got from the domain.
91+
project = unresolved_url.project
92+
version = unresolved_url.version
93+
filename = unresolved_url.filename
94+
# This query should use a particular index:
95+
# ``builds_build_version_id_state_date_success_12dfb214_idx``.
96+
# Otherwise, if the index is not used, the query gets too slow.
97+
build = version.builds.filter(
98+
success=True,
99+
state=BUILD_STATE_FINISHED,
100+
).last()
101+
102+
except UnresolverError as exc:
103+
# If an exception is raised and there is a ``project`` in the
104+
# exception, it's a partial match. This could be because of an
105+
# invalid URL path, but on a valid project domain. In this case, we
106+
# continue with the ``project``, but without a ``version``.
107+
# Otherwise, we return 404 NOT FOUND.
108+
project = getattr(exc, "project", None)
109+
if not project:
110+
raise Http404() from exc
111+
112+
else:
113+
project = Project.objects.filter(slug=project_slug).first()
114+
version = Version.objects.filter(slug=version_slug, project=project).first()
115+
if version:
116+
build = version.builds.last()
101117

102118
return project, version, build, filename
103119

104120
def _get_project(self):
105-
project, version, build, filename = self._resolve_resources()
121+
project, _, _, _ = self._resolve_resources()
106122
return project
107123

108124
def _get_version(self):
109-
project, version, build, filename = self._resolve_resources()
125+
_, version, _, _ = self._resolve_resources()
110126
return version
111127

112128
def get(self, request, format=None):
113129
url = request.GET.get("url")
130+
project_slug = request.GET.get("project-slug")
131+
version_slug = request.GET.get("version-slug")
114132
if not url:
115-
return JsonResponse(
116-
{"error": "'url' GET attribute is required"},
117-
status=400,
118-
)
133+
if not project_slug or not version_slug:
134+
return JsonResponse(
135+
{
136+
"error": "'project-slug' and 'version-slug' GET attributes are required when not sending 'url'"
137+
},
138+
status=400,
139+
)
119140

120141
addons_version = request.GET.get("api-version")
121142
if not addons_version:
@@ -148,6 +169,7 @@ def get(self, request, format=None):
148169
version,
149170
build,
150171
filename,
172+
url,
151173
user=request.user,
152174
)
153175
return JsonResponse(data, json_dumps_params={"indent": 4, "sort_keys": True})
@@ -199,6 +221,7 @@ def get(
199221
version=None,
200222
build=None,
201223
filename=None,
224+
url=None,
202225
user=None,
203226
):
204227
"""
@@ -208,12 +231,12 @@ def get(
208231
best JSON structure for that particular version.
209232
"""
210233
if addons_version.major == 0:
211-
return self._v0(project, version, build, filename, user)
234+
return self._v0(project, version, build, filename, url, user)
212235

213236
if addons_version.major == 1:
214-
return self._v1(project, version, build, filename, user)
237+
return self._v1(project, version, build, filename, url, user)
215238

216-
def _v0(self, project, version, build, filename, user):
239+
def _v0(self, project, version, build, filename, url, user):
217240
"""
218241
Initial JSON data structure consumed by the JavaScript client.
219242
@@ -308,27 +331,6 @@ def _v0(self, project, version, build, filename, user):
308331
versions_active_built_not_hidden.values_list("slug", flat=True)
309332
),
310333
},
311-
"doc_diff": {
312-
"enabled": Feature.ADDONS_DOC_DIFF_DISABLED not in project_features,
313-
# "http://test-builds-local.devthedocs.org/en/latest/index.html"
314-
"base_url": resolver.resolve(
315-
project=project,
316-
# NOTE: we are using LATEST version to compare against to for now.
317-
# Ideally, this should be configurable by the user.
318-
version_slug=LATEST,
319-
language=project.language,
320-
filename=filename,
321-
)
322-
if filename
323-
else None,
324-
"root_selector": "[role=main]",
325-
"inject_styles": True,
326-
# NOTE: `base_host` and `base_page` are not required, since
327-
# we are constructing the `base_url` in the backend instead
328-
# of the frontend, as the doc-diff extension does.
329-
"base_host": "",
330-
"base_page": "",
331-
},
332334
"flyout": {
333335
"enabled": Feature.ADDONS_FLYOUT_DISABLED not in project_features,
334336
"translations": [
@@ -347,22 +349,22 @@ def _v0(self, project, version, build, filename, user):
347349
"versions": [
348350
{
349351
# TODO: name this field "display_name"
350-
"slug": version.slug,
352+
"slug": version_.slug,
351353
"url": resolver.resolve(
352354
project=project,
353-
version_slug=version.slug,
354-
external=version.type == EXTERNAL,
355+
version_slug=version_.slug,
356+
external=version_.type == EXTERNAL,
355357
),
356358
}
357-
for version in versions_active_built_not_hidden
359+
for version_ in versions_active_built_not_hidden
358360
],
359361
"downloads": [
360362
{
361363
# TODO: name this field "display_name"
362364
"name": name,
363-
"url": url,
365+
"url": url_,
364366
}
365-
for name, url in version_downloads
367+
for name, url_ in version_downloads
366368
],
367369
# TODO: find a way to get this data in a reliably way.
368370
# We don't have a simple way to map a URL to a file in the repository.
@@ -417,6 +419,38 @@ def _v0(self, project, version, build, filename, user):
417419
},
418420
}
419421

422+
# DocDiff depends on `url=` GET attribute.
423+
# This attribute allows us to know the exact filename where the request was made.
424+
# If we don't know the filename, we cannot return the data required by DocDiff to work.
425+
# In that case, we just don't include the `doc_diff` object in the response.
426+
if url:
427+
data["addons"].update(
428+
{
429+
"doc_diff": {
430+
"enabled": Feature.ADDONS_DOC_DIFF_DISABLED
431+
not in project_features,
432+
# "http://test-builds-local.devthedocs.org/en/latest/index.html"
433+
"base_url": resolver.resolve(
434+
project=project,
435+
# NOTE: we are using LATEST version to compare against to for now.
436+
# Ideally, this should be configurable by the user.
437+
version_slug=LATEST,
438+
language=project.language,
439+
filename=filename,
440+
)
441+
if filename
442+
else None,
443+
"root_selector": "[role=main]",
444+
"inject_styles": True,
445+
# NOTE: `base_host` and `base_page` are not required, since
446+
# we are constructing the `base_url` in the backend instead
447+
# of the frontend, as the doc-diff extension does.
448+
"base_host": "",
449+
"base_page": "",
450+
},
451+
}
452+
)
453+
420454
# Update this data with the one generated at build time by the doctool
421455
if version and version.build_data:
422456
data.update(version.build_data)
@@ -450,7 +484,7 @@ def _v0(self, project, version, build, filename, user):
450484

451485
return data
452486

453-
def _v1(self, project, version, build, filename, user):
487+
def _v1(self, project, version, build, filename, url, user):
454488
return {
455489
"api_version": "1",
456490
"comment": "Undefined yet. Use v0 for now",

0 commit comments

Comments
 (0)