Skip to content

Commit 7c4ccbf

Browse files
authored
Merge pull request #8319 from readthedocs/humitos/embed-api-v3
2 parents f1c3c4f + 7edb272 commit 7c4ccbf

27 files changed

+986
-52
lines changed

.circleci/config.yml

+11
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ jobs:
1717
- run: pip install --user tox
1818
- run: tox -e py36,codecov
1919

20+
tests-embedapi:
21+
docker:
22+
- image: 'cimg/python:3.6'
23+
steps:
24+
- checkout
25+
- run: git submodule sync
26+
- run: git submodule update --init
27+
- run: pip install --user tox
28+
- run: tox -c tox.embedapi.ini
29+
2030
checks:
2131
docker:
2232
- image: 'cimg/python:3.6'
@@ -45,3 +55,4 @@ workflows:
4555
jobs:
4656
- checks
4757
- tests
58+
- tests-embedapi

pytest.ini

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
[pytest]
2-
addopts = --reuse-db --strict-markers
2+
addopts = --strict-markers
33
markers =
44
search
55
serve
66
proxito
7+
embed_api
8+
sphinx
79
python_files = tests.py test_*.py *_tests.py
810
filterwarnings =
911
# Ignore external dependencies warning deprecations
@@ -13,3 +15,9 @@ filterwarnings =
1315
ignore:Pagination may yield inconsistent results with an unordered object_list.*:django.core.paginator.UnorderedObjectListWarning
1416
# docutils
1517
ignore:'U' mode is deprecated:DeprecationWarning
18+
# slumber
19+
ignore:Using 'method_whitelist' with Retry is deprecated and will be removed in v2.0.*:DeprecationWarning
20+
# kombu
21+
ignore:SelectableGroups dict interface is deprecated.*:DeprecationWarning
22+
# django
23+
ignore:Remove the context parameter from JSONField.*:django.utils.deprecation.RemovedInDjango30Warning

readthedocs/conftest.py

+6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import pytest
22
from rest_framework.test import APIClient
33

4+
5+
pytest_plugins = (
6+
'sphinx.testing.fixtures',
7+
)
8+
9+
410
@pytest.fixture
511
def api_client():
612
return APIClient()

readthedocs/embed/tests/test_links.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pytest
44
from pyquery import PyQuery
55

6-
from readthedocs.embed.views import clean_links
6+
from readthedocs.embed.utils import clean_links
77

88
URLData = namedtuple('URLData', ['docurl', 'href', 'expected'])
99

readthedocs/embed/utils.py

+55
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Embed utils."""
22

3+
from urllib.parse import urlparse
4+
from pyquery import PyQuery as PQ # noqa
5+
36

47
def recurse_while_none(element):
58
"""Recursively find the leaf node with the ``href`` attribute."""
@@ -10,3 +13,55 @@ def recurse_while_none(element):
1013
if not href:
1114
href = element.attrib.get('id')
1215
return {element.text: href}
16+
17+
18+
def clean_links(obj, url, html_raw_response=False):
19+
"""
20+
Rewrite (internal) links to make them absolute.
21+
22+
1. external links are not changed
23+
2. prepend URL to links that are just fragments (e.g. #section)
24+
3. prepend URL (without filename) to internal relative links
25+
"""
26+
27+
# TODO: do not depend on PyQuery
28+
obj = PQ(obj)
29+
30+
if url is None:
31+
return obj
32+
33+
for link in obj.find('a'):
34+
base_url = urlparse(url)
35+
# We need to make all internal links, to be absolute
36+
href = link.attrib['href']
37+
parsed_href = urlparse(href)
38+
if parsed_href.scheme or parsed_href.path.startswith('/'):
39+
# don't change external links
40+
continue
41+
42+
if not parsed_href.path and parsed_href.fragment:
43+
# href="#section-link"
44+
new_href = base_url.geturl() + href
45+
link.attrib['href'] = new_href
46+
continue
47+
48+
if not base_url.path.endswith('/'):
49+
# internal relative link
50+
# href="../../another.html" and ``base_url`` is not HTMLDir
51+
# (e.g. /en/latest/deep/internal/section/page.html)
52+
# we want to remove the trailing filename (page.html) and use the rest as base URL
53+
# The resulting absolute link should be
54+
# https://slug.readthedocs.io/en/latest/deep/internal/section/../../another.html
55+
56+
# remove the filename (page.html) from the original document URL (base_url) and,
57+
path, _ = base_url.path.rsplit('/', 1)
58+
# append the value of href (../../another.html) to the base URL.
59+
base_url = base_url._replace(path=path + '/')
60+
61+
new_href = base_url.geturl() + href
62+
link.attrib['href'] = new_href
63+
64+
if html_raw_response:
65+
return obj.outerHtml()
66+
67+
return obj

readthedocs/embed/v3/__init__.py

Whitespace-only changes.

readthedocs/embed/v3/tests/__init__.py

Whitespace-only changes.
+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import os
2+
import shutil
3+
import pytest
4+
5+
from .utils import srcdir
6+
7+
8+
@pytest.fixture(autouse=True, scope='module')
9+
def remove_sphinx_build_output():
10+
"""Remove _build/ folder, if exist."""
11+
for path in (srcdir,):
12+
build_path = os.path.join(path, '_build')
13+
if os.path.exists(build_path):
14+
shutil.rmtree(build_path)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
sphinxcontrib-bibtex
2+
====================
3+
4+
See https://sphinxcontrib-bibtex.readthedocs.io/en/latest/ for more information about how to use ``sphinxcontrib-bibtex``.
5+
6+
See :cite:t:`1987:nelson` for an introduction to non-standard analysis.
7+
Non-standard analysis is fun :cite:p:`1987:nelson`.
8+
9+
.. bibliography::
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
:orphan:
2+
3+
Chapter I
4+
=========
5+
6+
This is Chapter I.
7+
8+
Section I
9+
---------
10+
11+
This the Section I inside Chapter I.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# conf.py to run tests
2+
import sphinxcontrib.bibtex
3+
4+
master_doc = 'index'
5+
extensions = [
6+
'sphinx.ext.autosectionlabel',
7+
'sphinxcontrib.bibtex',
8+
]
9+
10+
bibtex_bibfiles = ['refs.bib']
11+
12+
def setup(app):
13+
app.add_object_type(
14+
'confval', # directivename
15+
'confval', # rolename
16+
'pair: %s; configuration value', # indextemplate
17+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Configuration
2+
=============
3+
4+
Examples of configurations.
5+
6+
.. confval:: config1
7+
8+
Description: This the description for config1
9+
10+
Default: ``'Default value for config'``
11+
12+
Type: bool
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Glossary
2+
--------
3+
4+
Example using a ``:term:`` role :term:`Read the Docs`.
5+
6+
.. glossary::
7+
8+
Read the Docs
9+
Best company ever.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Title
2+
=====
3+
4+
This is an example page used to test EmbedAPI parsing features.
5+
6+
Sub-title
7+
---------
8+
9+
This is a reference to :ref:`sub-title`.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
@Book{1987:nelson,
2+
author = {Edward Nelson},
3+
title = {Radically Elementary Probability Theory},
4+
publisher = {Princeton University Press},
5+
year = {1987}
6+
}
+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import pytest
2+
3+
from django.conf import settings
4+
from django.core.cache import cache
5+
from django.urls import reverse
6+
7+
from .utils import srcdir
8+
9+
10+
@pytest.mark.django_db
11+
@pytest.mark.embed_api
12+
class TestEmbedAPIv3Basics:
13+
14+
@pytest.fixture(autouse=True)
15+
def setup_method(self, settings):
16+
settings.USE_SUBDOMAIN = True
17+
settings.PUBLIC_DOMAIN = 'readthedocs.io'
18+
settings.RTD_EMBED_API_EXTERNAL_DOMAINS = ['docs.project.com']
19+
20+
self.api_url = reverse('embed_api_v3')
21+
22+
yield
23+
cache.clear()
24+
25+
def test_not_url_query_argument(self, client):
26+
params = {}
27+
response = client.get(self.api_url, params)
28+
assert response.status_code == 400
29+
assert response.json() == {'error': 'Invalid arguments. Please provide "url".'}
30+
31+
def test_not_allowed_domain(self, client):
32+
params = {
33+
'url': 'https://docs.notalloweddomain.com#title',
34+
}
35+
response = client.get(self.api_url, params)
36+
assert response.status_code == 400
37+
assert response.json() == {'error': 'External domain not allowed. domain=docs.notalloweddomain.com'}
38+
39+
def test_malformed_url(self, client):
40+
params = {
41+
'url': 'https:///page.html#title',
42+
}
43+
response = client.get(self.api_url, params)
44+
assert response.status_code == 400
45+
assert response.json() == {'error': f'The URL requested is malformed. url={params["url"]}'}
46+
47+
def test_rate_limit_domain(self, client):
48+
params = {
49+
'url': 'https://docs.project.com#title',
50+
}
51+
cache_key = 'embed-api-docs.project.com'
52+
cache.set(cache_key, settings.RTD_EMBED_API_DOMAIN_RATE_LIMIT)
53+
54+
response = client.get(self.api_url, params)
55+
assert response.status_code == 429
56+
assert response.json() == {'error': 'Too many requests for this domain. domain=docs.project.com'}
57+
58+
def test_infinite_redirect(self, client, requests_mock):
59+
requests_mock.get(
60+
'https://docs.project.com',
61+
status_code=302,
62+
headers={
63+
'Location': 'https://docs.project.com',
64+
},
65+
)
66+
params = {
67+
'url': 'https://docs.project.com#title',
68+
}
69+
response = client.get(self.api_url, params)
70+
assert response.status_code == 400
71+
assert response.json() == {'error': f'The URL requested generates too many redirects. url={params["url"]}'}

0 commit comments

Comments
 (0)