diff --git a/readthedocs/proxito/tests/responses/v0.json b/readthedocs/proxito/tests/responses/v0.json new file mode 100644 index 00000000000..b890bd80e4f --- /dev/null +++ b/readthedocs/proxito/tests/responses/v0.json @@ -0,0 +1,120 @@ +{ + "comment": "THIS RESPONSE IS IN ALPHA FOR TEST PURPOSES ONLY AND IT'S GOING TO CHANGE COMPLETELY -- DO NOT USE IT!", + "projects": { + "current": { + "created": "2019-04-29T10:00:00Z", + "default_branch": "master", + "default_version": "latest", + "id": 1, + "language": { + "code": "en", + "name": "English" + }, + "modified": "2019-04-29T12:00:00Z", + "name": "project", + "programming_language": { + "code": "words", + "name": "Only Words" + }, + "homepage": "http://project.com", + "repository": { + "type": "git", + "url": "https://github.com/readthedocs/project" + }, + "slug": "project", + "subproject_of": null, + "tags": ["project", "tag", "test"], + "translation_of": null, + "users": [ + { + "username": "testuser" + } + ] + } + }, + "versions": { + "current": { + "active": true, + "hidden": false, + "built": true, + "downloads": {}, + "id": 1, + "identifier": "a1b2c3", + "ref": null, + "slug": "latest", + "type": "tag", + "verbose_name": "latest" + } + }, + "builds": { + "current": { + "commit": "a1b2c3", + "created": "2019-04-29T10:00:00Z", + "duration": 60, + "error": "", + "finished": "2019-04-29T10:01:00Z", + "id": 1, + "project": "project", + "state": { + "code": "finished", + "name": "Finished" + }, + "success": true, + "version": "latest" + } + }, + "domains": { + "dashboard": "readthedocs.org" + }, + "readthedocs": { + "analytics": { + "code": null + } + }, + "addons": { + "analytics": { + "enabled": true, + "code": null + }, + "external_version_warning": { + "enabled": true, + "query_selector": "[role=main]" + }, + "non_latest_version_warning": { + "enabled": true, + "query_selector": "[role=main]", + "versions": ["latest"] + }, + "doc_diff": { + "enabled": true, + "base_url": "https://project.dev.readthedocs.io/en/latest/index.html", + "root_selector": "[role=main]", + "inject_styles": true, + "base_host": "", + "base_page": "" + }, + "flyout": { + "translations": [], + "versions": [{ "slug": "latest", "url": "/en/latest/" }], + "downloads": [], + "vcs": { + "url": "https://github.com", + "username": "readthedocs", + "repository": "test-builds", + "branch": "a1b2c3", + "filepath": "/docs/index.rst" + } + }, + "search": { + "enabled": true, + "api_endpoint": "/_/api/v3/search/", + "default_filter": "subprojects:project/latest", + "filters": [ + ["Search only in this project", "project:project/latest"], + ["Search subprojects", "subprojects:project/latest"] + ], + "project": "project", + "version": "latest" + } + } +} diff --git a/readthedocs/proxito/tests/responses/v1.json b/readthedocs/proxito/tests/responses/v1.json new file mode 100644 index 00000000000..21a1c1b6dfb --- /dev/null +++ b/readthedocs/proxito/tests/responses/v1.json @@ -0,0 +1,3 @@ +{ + "comment": "Undefined yet. Use v0 for now" +} diff --git a/readthedocs/proxito/tests/responses/v2.json b/readthedocs/proxito/tests/responses/v2.json new file mode 100644 index 00000000000..7251149988f --- /dev/null +++ b/readthedocs/proxito/tests/responses/v2.json @@ -0,0 +1,3 @@ +{ + "error": "The version specified in 'X-RTD-Hosting-Integrations-Version' is currently not supported" +} diff --git a/readthedocs/proxito/tests/test_hosting.py b/readthedocs/proxito/tests/test_hosting.py index fb3569d18e2..f315898e1bc 100644 --- a/readthedocs/proxito/tests/test_hosting.py +++ b/readthedocs/proxito/tests/test_hosting.py @@ -1,13 +1,15 @@ """Test hosting views.""" +import json +from pathlib import Path + import django_dynamic_fixture as fixture import pytest -from django.conf import settings from django.contrib.auth.models import User from django.test import TestCase, override_settings from django.urls import reverse -from readthedocs.builds.constants import EXTERNAL, INTERNAL, LATEST +from readthedocs.builds.constants import LATEST from readthedocs.builds.models import Build from readthedocs.projects.constants import PUBLIC from readthedocs.projects.models import Project @@ -21,114 +23,89 @@ @pytest.mark.proxito class TestReadTheDocsConfigJson(TestCase): def setUp(self): - self.user = fixture.get(User, username="user") - self.user.set_password("user") + self.user = fixture.get(User, username="testuser") + self.user.set_password("testuser") self.user.save() self.project = fixture.get( Project, slug="project", + name="project", language="en", privacy_level=PUBLIC, external_builds_privacy_level=PUBLIC, - repo="git://10.10.0.1/project", + repo="https://github.com/readthedocs/project", programming_language="words", single_version=False, users=[self.user], main_language_project=None, + project_url="http://project.com", ) + + for tag in ("tag", "project", "test"): + self.project.tags.add(tag) + self.project.versions.update( privacy_level=PUBLIC, built=True, active=True, - type=INTERNAL, - identifier="1a2b3c", + type="tag", + identifier="a1b2c3", ) self.version = self.project.versions.get(slug=LATEST) self.build = fixture.get( Build, + project=self.project, version=self.version, + commit="a1b2c3", + length=60, + state="finished", + success=True, + ) + + def _get_response_dict(self, view_name, filepath=None): + filepath = filepath or __file__ + filename = Path(filepath).absolute().parent / "responses" / f"{view_name}.json" + return json.load(open(filename)) + + def _normalize_datetime_fields(self, obj): + obj["projects"]["current"]["created"] = "2019-04-29T10:00:00Z" + obj["projects"]["current"]["modified"] = "2019-04-29T12:00:00Z" + obj["builds"]["current"]["created"] = "2019-04-29T10:00:00Z" + obj["builds"]["current"]["finished"] = "2019-04-29T10:01:00Z" + return obj + + def test_get_config_v0(self): + r = self.client.get( + reverse("proxito_readthedocs_config_json"), + {"url": "https://project.dev.readthedocs.io/en/latest/"}, + secure=True, + HTTP_HOST="project.dev.readthedocs.io", + HTTP_X_RTD_HOSTING_INTEGRATIONS_VERSION="0.1.0", + ) + assert r.status_code == 200 + assert self._normalize_datetime_fields(r.json()) == self._get_response_dict( + "v0" ) - def test_get_config(self): + def test_get_config_v1(self): r = self.client.get( reverse("proxito_readthedocs_config_json"), {"url": "https://project.dev.readthedocs.io/en/latest/"}, secure=True, HTTP_HOST="project.dev.readthedocs.io", + HTTP_X_RTD_HOSTING_INTEGRATIONS_VERSION="1.0.0", ) assert r.status_code == 200 + assert r.json() == self._get_response_dict("v1") - expected = { - "comment": "THIS RESPONSE IS IN ALPHA FOR TEST PURPOSES ONLY AND IT'S GOING TO CHANGE COMPLETELY -- DO NOT USE IT!", - "project": { - "slug": self.project.slug, - "language": self.project.language, - "repository_url": self.project.repo, - "programming_language": self.project.programming_language, - }, - "version": { - "slug": self.version.slug, - "external": self.version.type == EXTERNAL, - }, - "build": { - "id": self.build.pk, - }, - "domains": { - "dashboard": settings.PRODUCTION_DOMAIN, - }, - "readthedocs": { - "analytics": { - "code": None, - } - }, - "features": { - "analytics": { - "code": None, - }, - "external_version_warning": { - "enabled": True, - "query_selector": "[role=main]", - }, - "non_latest_version_warning": { - "enabled": True, - "query_selector": "[role=main]", - "versions": [ - "latest", - ], - }, - "doc_diff": { - "enabled": True, - "base_url": "https://project.dev.readthedocs.io/en/latest/index.html", - "root_selector": "[role=main]", - "inject_styles": True, - "base_host": "", - "base_page": "", - }, - "flyout": { - "translations": [], - "versions": [ - {"slug": "latest", "url": "/en/latest/"}, - ], - "downloads": [], - "vcs": { - "url": "https://github.com", - "username": "readthedocs", - "repository": "test-builds", - "branch": self.version.identifier, - "filepath": "/docs/index.rst", - }, - }, - "search": { - "api_endpoint": "/_/api/v3/search/", - "default_filter": "subprojects:project/latest", - "filters": [ - ["Search only in this project", "project:project/latest"], - ["Search subprojects", "subprojects:project/latest"], - ], - "project": "project", - "version": "latest", - }, - }, - } - assert r.json() == expected + def test_get_config_unsupported_version(self): + r = self.client.get( + reverse("proxito_readthedocs_config_json"), + {"url": "https://project.dev.readthedocs.io/en/latest/"}, + secure=True, + HTTP_HOST="project.dev.readthedocs.io", + HTTP_X_RTD_HOSTING_INTEGRATIONS_VERSION="2.0.0", + ) + assert r.status_code == 400 + assert r.json() == self._get_response_dict("v2") diff --git a/readthedocs/proxito/views/hosting.py b/readthedocs/proxito/views/hosting.py index 2ed7ef4fb88..9cb39f85c8c 100644 --- a/readthedocs/proxito/views/hosting.py +++ b/readthedocs/proxito/views/hosting.py @@ -1,11 +1,16 @@ """Views for hosting features.""" +import packaging import structlog from django.conf import settings from django.http import JsonResponse from django.views import View -from readthedocs.builds.constants import EXTERNAL +from readthedocs.api.v3.serializers import ( + BuildSerializer, + ProjectSerializer, + VersionSerializer, +) from readthedocs.core.mixins import CDNCacheControlMixin from readthedocs.core.resolver import resolver from readthedocs.core.unresolver import unresolver @@ -13,7 +18,34 @@ log = structlog.get_logger(__name__) # noqa +ADDONS_VERSIONS_SUPPORTED = (0, 1) + + +class ClientError(Exception): + VERSION_NOT_CURRENTLY_SUPPORTED = ( + "The version specified in 'X-RTD-Hosting-Integrations-Version'" + " is currently not supported" + ) + VERSION_INVALID = "'X-RTD-Hosting-Integrations-Version' header version is invalid" + VERSION_HEADER_MISSING = ( + "'X-RTD-Hosting-Integrations-Version' header attribute is required" + ) + + class ReadTheDocsConfigJson(CDNCacheControlMixin, View): + + """ + API response consumed by our JavaScript client. + + The code for the JavaScript client lives at: + https://github.com/readthedocs/readthedocs-client/ + + Attributes: + + url (required): absolute URL from where the request is performed + (e.g. ``window.location.href``) + """ + def get(self, request): url = request.GET.get("url") @@ -23,37 +55,126 @@ def get(self, request): status=400, ) + addons_version = request.headers.get("X-RTD-Hosting-Integrations-Version") + if not addons_version: + return JsonResponse( + { + "error": ClientError.VERSION_HEADER_MISSING, + }, + status=400, + ) + try: + addons_version = packaging.version.parse(addons_version) + if addons_version.major not in ADDONS_VERSIONS_SUPPORTED: + raise ClientError + except packaging.version.InvalidVersion: + return JsonResponse( + { + "error": ClientError.VERSION_INVALID, + }, + status=400, + ) + except ClientError: + return JsonResponse( + {"error": ClientError.VERSION_NOT_CURRENTLY_SUPPORTED}, + status=400, + ) + unresolved_domain = request.unresolved_domain project = unresolved_domain.project unresolved_url = unresolver.unresolve_url(url) version = unresolved_url.version + filename = unresolved_url.filename project.get_default_version() build = version.builds.last() - # TODO: define how it will be the exact JSON object returned here - # NOTE: we could use the APIv3 serializers for some of these objects - # if we want to keep consistency. However, those may require some - # extra db calls that we probably want to avoid. + data = AddonsResponse().get(addons_version, project, version, build, filename) + return JsonResponse(data, json_dumps_params=dict(indent=4)) + + +class NoLinksMixin: + + """Mixin to remove conflicting fields from serializers.""" + + FIELDS_TO_REMOVE = ( + "_links", + "urls", + ) + + def __init__(self, *args, **kwargs): + super(NoLinksMixin, self).__init__(*args, **kwargs) + + for field in self.FIELDS_TO_REMOVE: + if field in self.fields: + del self.fields[field] + + if field in self.Meta.fields: + del self.Meta.fields[self.Meta.fields.index(field)] + + +# NOTE: the following serializers are required only to remove some fields we +# can't expose yet in this API endpoint because it running under El Proxito +# which cannot resolve some dashboard URLs because they are not defined on El +# Proxito. +# +# See https://github.com/readthedocs/readthedocs-ops/issues/1323 +class ProjectSerializerNoLinks(NoLinksMixin, ProjectSerializer): + pass + + +class VersionSerializerNoLinks(NoLinksMixin, VersionSerializer): + pass + + +class BuildSerializerNoLinks(NoLinksMixin, BuildSerializer): + pass + + +class AddonsResponse: + def get(self, addons_version, project, version, build, filename): + """ + Unique entry point to get the proper API response. + + It will evaluate the ``addons_version`` passed and decide which is the + best JSON structure for that particular version. + """ + if addons_version.major == 0: + return self._v0(project, version, build, filename) + + if addons_version.major == 1: + return self._v1(project, version, build, filename) + + def _v0(self, project, version, build, filename): + """ + Initial JSON data structure consumed by the JavaScript client. + + This response is definitely in *alpha* state currently and shouldn't be + used for anyone to customize their documentation or the integration + with the Read the Docs JavaScript client. It's under active development + and anything can change without notice. + + It tries to follow some similarity with the APIv3 for already-known resources + (Project, Version, Build, etc). + """ + data = { "comment": ( "THIS RESPONSE IS IN ALPHA FOR TEST PURPOSES ONLY" " AND IT'S GOING TO CHANGE COMPLETELY -- DO NOT USE IT!" ), - "project": { - "slug": project.slug, - "language": project.language, - "repository_url": project.repo, - "programming_language": project.programming_language, + "projects": { + "current": ProjectSerializerNoLinks(project).data, }, - "version": { - "slug": version.slug, - "external": version.type == EXTERNAL, + "versions": { + "current": VersionSerializerNoLinks(version).data, }, - "build": { - "id": build.pk, + "builds": { + "current": BuildSerializerNoLinks(build).data, }, + # TODO: consider creating one serializer per field here. + # The resulting JSON will be the same, but maybe it's easier/cleaner? "domains": { "dashboard": settings.PRODUCTION_DOMAIN, }, @@ -62,8 +183,13 @@ def get(self, request): "code": settings.GLOBAL_ANALYTICS_CODE, }, }, - "features": { + # TODO: the ``features`` is not polished and we expect to change drastically. + # Mainly, all the fields including a Project, Version or Build will use the exact same + # serializer than the keys ``project``, ``version`` and ``build`` from the top level. + "addons": { "analytics": { + "enabled": True, + # TODO: consider adding this field into the ProjectSerializer itself. "code": project.analytics_code, }, "external_version_warning": { @@ -86,7 +212,7 @@ def get(self, request): project=project, version_slug=project.get_default_version(), language=project.language, - filename=unresolved_url.filename, + filename=filename, ), "root_selector": "[role=main]", "inject_styles": True, @@ -116,6 +242,7 @@ def get(self, request): }, }, "search": { + "enabled": True, "project": project.slug, "version": version.slug, "api_endpoint": "/_/api/v3/search/", @@ -139,4 +266,9 @@ def get(self, request): if version.build_data: data.update(version.build_data) - return JsonResponse(data, json_dumps_params=dict(indent=4)) + return data + + def _v1(self, project, version, build, filename): + return { + "comment": "Undefined yet. Use v0 for now", + }