Repository: readthedocs/readthedocs.org

Issue:
API: review `/_/readthedocs-config/` endpoint
Take a look at the structure of this response and think about how we want to adapt it to support the initial phase of the js client. See https://github.com/readthedocs/readthedocs-client/issues/26

https://github.com/readthedocs/readthedocs.org/blob/f141a8f931ffe78d6aad90a4710b7ec566994dcd/readthedocs/proxito/views/hosting.py#L35-L136

Besides, consider making the initial changes that will allow us use our current APIv3 serializers so we can standardize the way we are communicating our data in a more generic way through all our endpoints. 

The idea is to use "the same structure that would be returned by the APIv3 serializers, but without using them for now" due to https://github.com/readthedocs/readthedocs-ops/issues/1323


Discussion:

Resolving patch:
diff --git a/readthedocs/proxito/views/hosting.py b/readthedocs/proxito/views/hosting.py
--- 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",
+        }


Test patch:
diff --git a/readthedocs/proxito/tests/responses/v0.json b/readthedocs/proxito/tests/responses/v0.json
new file mode 100644
--- /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
--- /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
--- /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
--- 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")