Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions sphinxcontrib/openapi/renderers/_httpdomain.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ def indented(generator, indent=3):
yield item


def _split_option(value):
"""Parse a whitespace delimited option value into a list of tokens."""

# An option may be passed with no value at all, in which case docutils
# hands over 'None' instead of a string. Since every option parsed here
# takes one or more values, and since an empty one would silently turn
# 'response-examples-for' into "no examples at all", let's reject it. The
# 'ValueError' is turned into a directive error by docutils.
tokens = (value or "").split()
if not tokens:
raise ValueError("expected one or more whitespace delimited values")
return tokens


def _iterinorder(iterable, order_by, key=lambda x: x, case_sensitive=False):
"""Iterate over iterable in a given order."""

Expand Down Expand Up @@ -195,12 +209,12 @@ class HttpdomainRenderer(abc.RestructuredTextRenderer):

option_spec = {
"markup": functools.partial(directives.choice, values=_markup_converters),
"http-methods-order": lambda s: s.split(),
"response-examples-for": None,
"request-parameters-order": None,
"example-preference": None,
"request-example-preference": None,
"response-example-preference": None,
"http-methods-order": _split_option,
"response-examples-for": _split_option,
"request-parameters-order": _split_option,
"example-preference": _split_option,
"request-example-preference": _split_option,
"response-example-preference": _split_option,
"generate-examples-from-schemas": directives.flag,
"no-json-schema-description": directives.flag,
}
Expand Down
32 changes: 29 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import io
import os
import pathlib
import sys
import textwrap

import pytest
Expand Down Expand Up @@ -34,6 +36,18 @@ def pytest_collection_modifyitems(items):
items[:] = items_new


class _Tee(io.StringIO):
"""Accumulate everything written, and pass it through to a stream."""

def __init__(self, stream):
super().__init__()
self._stream = stream

def write(self, text):
self._stream.write(text)
return super().write(text)


def _format_option_raw(key, val):
if isinstance(val, bool) and val:
return ':%s:' % key
Expand All @@ -45,11 +59,15 @@ def run_sphinx(tmpdir):
src = tmpdir.ensure('src', dir=True)
out = tmpdir.ensure('out', dir=True)

def run(spec, options={}):
def run(spec, options={}, renderer=None):
options_raw = '\n'.join([
' %s' % _format_option_raw(key, val)
for key, val in options.items()])

conf_raw = ''
if renderer:
conf_raw = "openapi_default_renderer = '%s'" % renderer

src.join('conf.py').write_text(
textwrap.dedent('''
import os
Expand All @@ -60,21 +78,29 @@ def run(spec, options={}):
extensions = ['sphinxcontrib.openapi']
source_suffix = '.rst'
master_doc = 'index'
'''),
''') + conf_raw,
encoding='utf-8')

src.join('index.rst').write_text(
'.. openapi:: %s\n%s' % (spec, options_raw),
encoding='utf-8')

# Warnings are captured and returned so tests can assert on them. They
# keep going to stderr as well, so that a test that doesn't care about
# them still shows them when it fails.
warning = _Tee(sys.stderr)

Sphinx(
srcdir=src.strpath,
confdir=src.strpath,
outdir=out.strpath,
doctreedir=out.join('.doctrees').strpath,
buildername='html'
buildername='html',
warning=warning
).build()

return warning.getvalue()

yield run


Expand Down
219 changes: 219 additions & 0 deletions tests/renderers/httpdomain/test_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
"""Test the directive options the 'httpdomain' renderer accepts."""

import re

import pytest

from sphinxcontrib.openapi import renderers

# Exercises every option below at once. Two methods on one endpoint for
# 'http-methods-order', two kinds of parameter for 'request-parameters-order',
# and two media types carrying distinguishable examples, for both a request and
# a response, for the preference options. The '404' response is what
# 'response-examples-for' has to be widened to reach.
_SPEC = """\
openapi: 3.0.0
info:
title: An example spec.
version: 1.0.0
paths:
/evidences:
get:
parameters:
- name: q
in: query
schema:
type: string
- name: X-Evidence-Token
in: header
schema:
type: string
responses:
'200':
description: An evidence.
content:
application/json:
example: {'id': 'json-response'}
text/plain:
example: plain-response
'404':
description: An evidence is not found.
content:
text/plain:
example: no-such-evidence
post:
requestBody:
content:
application/json:
example: {'id': 'json-request'}
text/plain:
example: plain-request
responses:
'201':
description: An evidence created.
"""


_WHITESPACE_DELIMITED_OPTIONS = [
pytest.param("http-methods-order", "head get", ["head", "get"]),
pytest.param(
"response-examples-for", "200 201 2XX 404", ["200", "201", "2XX", "404"]
),
pytest.param(
"request-parameters-order",
"query path header cookie",
["query", "path", "header", "cookie"],
),
pytest.param(
"example-preference",
"text/plain application/json",
["text/plain", "application/json"],
),
pytest.param(
"request-example-preference",
"text/plain application/json",
["text/plain", "application/json"],
),
pytest.param(
"response-example-preference",
"text/plain application/json",
["text/plain", "application/json"],
),
]


@pytest.fixture(scope="function")
def build_warnings(tmpdir, run_sphinx):
"""Build '_SPEC' with the given options, and return reported warnings."""

def build_warnings(options):
tmpdir.join("src", "test-spec.yml").write_text(_SPEC, encoding="utf-8")
return run_sphinx("test-spec.yml", options=options, renderer="httpdomain")

return build_warnings


@pytest.fixture(scope="function")
def build(tmpdir, build_warnings):
"""Build '_SPEC' with the given options, and return the rendered text.

Tags are stripped since httpdomain splits a method and its path into
separate elements, so the rendered markup can't be searched as is.
"""

def build(options):
assert "unknown option" not in build_warnings(options)
html = tmpdir.join("out", "index.html").read_text(encoding="utf-8")
return re.sub(r"<[^>]+>", "", html)

return build


@pytest.mark.parametrize(["option", "value", "expected"], _WHITESPACE_DELIMITED_OPTIONS)
def test_option_is_accepted(build, option, value, expected):
"""A whitespace delimited option is not rejected as an unknown one."""

build({option: value})


@pytest.mark.parametrize(["option", "value", "expected"], _WHITESPACE_DELIMITED_OPTIONS)
def test_option_is_parsed(option, value, expected):
"""A whitespace delimited option is parsed into a list of tokens."""

convertor = renderers.HttpdomainRenderer.option_spec[option]
assert convertor(value) == expected


@pytest.mark.parametrize(["option", "value", "expected"], _WHITESPACE_DELIMITED_OPTIONS)
def test_option_without_value_is_rejected(option, value, expected):
"""A whitespace delimited option passed with no value is an error."""

convertor = renderers.HttpdomainRenderer.option_spec[option]

with pytest.raises(ValueError):
convertor(None)


@pytest.mark.parametrize(
["option", "value"],
[
pytest.param("response-examples-for", ""),
pytest.param("response-examples-for", " "),
],
)
def test_option_with_blank_value_is_reported(build_warnings, option, value):
"""A blank option value is reported instead of silently taking effect."""

# Left to itself, an empty 'response-examples-for' would override the
# default and quietly disable every response example.
assert "invalid option value" in build_warnings({option: value})


def test_http_methods_order_is_effective(build):
"""The 'http-methods-order' option reaches the renderer."""

text = build({"http-methods-order": "post get"})

# Natural order puts 'get' first, since that's how the spec declares them.
assert text.index("POST /evidences") < text.index("GET /evidences")


def test_request_parameters_order_is_effective(build):
"""The 'request-parameters-order' option reaches the renderer."""

text = build({"request-parameters-order": "query header"})

# The renderer's own default order puts header parameters first.
assert text.index("Query Parameters") < text.index("Request Headers")


def test_response_examples_for_is_effective(build):
"""The 'response-examples-for' option reaches the renderer."""

# By default examples are rendered for successful status codes only, so the
# '404' example is rendered if and only if the option took effect.
assert "no-such-evidence" not in build({"http-methods-order": "get"})
assert "no-such-evidence" in build({"response-examples-for": "404"})


@pytest.mark.parametrize(
["option", "expected"],
[
pytest.param("example-preference", "plain-response"),
pytest.param("response-example-preference", "plain-response"),
],
)
def test_response_example_preference_is_effective(build, option, expected):
"""The response example preference options reach the renderer."""

# 'application/json' is declared first, so it wins without a preference.
assert expected not in build({"http-methods-order": "get"})
assert expected in build({option: "text/plain application/json"})


@pytest.mark.parametrize(
["option", "expected"],
[
pytest.param("example-preference", "plain-request"),
pytest.param("request-example-preference", "plain-request"),
],
)
def test_request_example_preference_is_effective(build, option, expected):
"""The request example preference options reach the renderer."""

assert expected not in build({"http-methods-order": "post"})
assert expected in build({option: "text/plain application/json"})


def test_request_example_preference_takes_precedence(build):
"""A request specific preference wins over the shared one."""

text = build(
{
"example-preference": "application/json text/plain",
"request-example-preference": "text/plain application/json",
}
)

assert "plain-request" in text
assert "json-response" in text