api_docs: Detect missing arguments in curl examples.

This commit adds automated tests that make sure that every curl
example command in our API docs has the '-X (POST|GET)' argument.

Fixes: #11927
This commit is contained in:
Eeshan Garg 2019-05-16 18:08:53 -02:30 committed by Tim Abbott
parent 8339c21637
commit cecea75457
38 changed files with 175 additions and 63 deletions

View File

@ -15,7 +15,7 @@ appear in messages and topics.
{tab|curl}
```
``` curl
curl -X POST {{ api_url }}/v1/realm/filters \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
-d "pattern=#(?P<id>[0-9]+)" \

View File

@ -52,8 +52,8 @@ zulip(config).then((client) => {
{tab|curl}
```
curl {{ api_url }}/v1/users/me/subscriptions \
``` curl
curl -X POST {{ api_url }}/v1/users/me/subscriptions \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
-d 'subscriptions=[{"name": "Verona"}]'
```
@ -61,8 +61,8 @@ curl {{ api_url }}/v1/users/me/subscriptions \
To subscribe another user to a stream, you may pass in
the `principals` argument, like so:
```
curl {{ api_url }}/v1/users/me/subscriptions \
``` curl
curl -X POST {{ api_url }}/v1/users/me/subscriptions \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
-d 'subscriptions=[{"name": "Verona"}]' \
-d 'principals=["ZOE@zulip.com"]'

View File

@ -38,8 +38,8 @@ zulip(config).then((client) => {
{tab|curl}
```
curl {{ api_url }}/v1/users \
``` curl
curl -X POST {{ api_url }}/v1/users \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
-d "email=newbie@zulip.com" \
-d "full_name=New User" \

View File

@ -19,7 +19,7 @@ the Zulip Help Center.
{tab|curl}
```
``` curl
curl -X DELETE {{ api_url }}/v1/messages/{message_id} \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
```

View File

@ -41,7 +41,7 @@ zulip(config).then((client) => {
{tab|curl}
```
``` curl
curl -X "DELETE" {{ api_url }}/v1/events \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY
-d 'queue_id=1515096410:1'

View File

@ -17,8 +17,8 @@ in a Zulip production server.
{start_tabs}
{tab|curl}
```
curl {{ api_url }}/v1/dev_fetch_api_key \
``` curl
curl -X POST {{ api_url }}/v1/dev_fetch_api_key \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
-d "username=iago@zulip.com"
```

View File

@ -31,14 +31,14 @@ zulip(config).then((client) => {
{tab|curl}
```
``` curl
curl -X GET {{ api_url }}/v1/streams -u BOT_EMAIL_ADDRESS:BOT_API_KEY
```
You may pass in one or more of the parameters mentioned above
as URL query parameters, like so:
```
``` curl
curl -X GET {{ api_url }}/v1/streams?include_public=false \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY
```

View File

@ -33,13 +33,13 @@ zulip(config).then((client) => {
{tab|curl}
```
``` curl
curl -X GET {{ api_url }}/v1/users -u BOT_EMAIL_ADDRESS:BOT_API_KEY
```
You may pass the `client_gravatar` query parameter as follows:
```
``` curl
curl -X GET {{ api_url }}/v1/users?client_gravatar=true \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY
```

View File

@ -62,8 +62,8 @@ zulip(config).then((client) => {
{tab|curl}
```
curl -G {{ api_url }}/v1/events \
``` curl
curl -X GET {{ api_url }}/v1/events \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY
-d "queue_id=1375801870:2942" \
-d "last_event_id=-1"

View File

@ -18,7 +18,7 @@ Note that edit history may be disabled in some organizations; see the
{tab|curl}
```
``` curl
curl -X GET {{ api_url }}/v1/messages/<message_id>/history \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY
```

View File

@ -63,7 +63,7 @@ zulip(config).then((client) => {
{tab|curl}
```
``` curl
curl -X GET {{ api_url }}/v1/messages \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
-d "anchor=42" \

View File

@ -29,7 +29,7 @@ zulip(config).then((client) => {
{tab|curl}
```
``` curl
curl -X GET {{ api_url }}/v1/realm/emoji \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY
```

View File

@ -23,7 +23,7 @@ for details on the data model for presence in Zulip.
{tab|curl}
```
``` curl
curl -X GET {{ api_url }}/v1/users/<email>/presence \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY
```

View File

@ -31,7 +31,7 @@ zulip(config).then((client) => {
{tab|curl}
```
``` curl
curl -X GET {{ api_url }}/v1/users/me \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY
```

View File

@ -18,7 +18,7 @@ UI).
{tab|curl}
```
``` curl
curl -X GET {{ api_url }}/v1/messages/<msg_id> \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
```

View File

@ -30,8 +30,8 @@ zulip(config).then((client) => {
{tab|curl}
```
curl X GET {{ api_url }}/v1/get_stream_id?stream=Denmark \
``` curl
curl -X GET {{ api_url }}/v1/get_stream_id?stream=Denmark \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY
```

View File

@ -31,7 +31,7 @@ zulip(config).then((client) => {
{tab|curl}
```
``` curl
curl -X GET {{ api_url }}/v1/users/me/<stream_id>/topics \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY
```

View File

@ -32,7 +32,7 @@ zulip(config).then((client) => {
{tab|curl}
```
``` curl
curl -X GET {{ api_url }}/v1/users/me/subscriptions \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY
```

View File

@ -15,7 +15,7 @@ Fetches all of the user groups in the organization.
<div data-language="curl" markdown="1">
```
``` curl
curl -X GET {{ api_url }}/v1/user_groups \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY
```

View File

@ -16,8 +16,8 @@ in messages and topics.
{tab|curl}
```
curl {{ api_url }}/v1/realm/filters \
``` curl
curl -X GET {{ api_url }}/v1/realm/filters \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
```

View File

@ -13,7 +13,7 @@ Marks all of the current user's unread messages as read.
{tab|curl}
```
``` curl
curl -X POST {{ api_url }}/v1/mark_all_as_read \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY
```
@ -48,7 +48,7 @@ Mark all the unread messages in a stream as read.
{tab|curl}
```
``` curl
curl -X POST {{ api_url }}/v1/mark_stream_as_read \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
-d "stream_id=42"
@ -84,7 +84,7 @@ Mark all the unread messages in a topic as read.
{tab|curl}
```
``` curl
curl -X POST {{ api_url }}/v1/mark_topic_as_read \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
-d "stream_id=42" \

View File

@ -15,7 +15,7 @@ UI, and are not included in the user's unread count totals.
{tab|curl}
```
``` curl
curl -X PATCH {{ api_url }}/v1/users/me/subscriptions/muted_topics \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
-d "stream=Verona"

View File

@ -75,8 +75,8 @@ zulip(config).then((client) => {
{tab|curl}
```
curl {{ api_url }}/v1/register \
``` curl
curl -X POST {{ api_url }}/v1/register \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY
-d 'event_types=["message"]'
```

View File

@ -15,7 +15,7 @@ in messages and topics.
{tab|curl}
```
``` curl
curl -X DELETE {{ api_url }}/v1/realm/filters/<filter_id> \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY
```

View File

@ -40,7 +40,7 @@ zulip(config).then((client) => {
{tab|curl}
```
``` curl
curl -X "DELETE" {{ api_url }}/v1/users/me/subscriptions \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
-d 'subscriptions=["Denmark"]'
@ -48,7 +48,7 @@ curl -X "DELETE" {{ api_url }}/v1/users/me/subscriptions \
You may specify the `principals` argument like so:
```
``` curl
curl -X "DELETE" {{ api_url }}/v1/users/me/subscriptions \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
-d 'subscriptions=["Denmark"]' \

View File

@ -34,8 +34,8 @@ zulip(config).then((client) => {
{tab|curl}
```
curl {{ api_url }}/v1/messages/render \
``` curl
curl -X POST {{ api_url }}/v1/messages/render \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
-d $"content=**foo**"

View File

@ -51,7 +51,7 @@ zulip(config).then((client) => {
{tab|curl}
```
``` curl
# For stream messages
curl -X POST {{ api_url }}/v1/messages \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \

View File

@ -21,8 +21,8 @@ Fetch global settings for a Zulip server.
{tab|curl}
```
curl {{ api_url }}/v1/server_settings \
``` curl
curl -X GET {{ api_url }}/v1/server_settings \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY
```

View File

@ -38,7 +38,7 @@ zulip(config).then((client) => {
{tab|curl}
```
``` curl
curl -X POST {{ api_url }}/v1/typing \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
-d "op=start" \

View File

@ -44,7 +44,7 @@ zulip(config).then((client) => {
{tab|curl}
```
``` curl
curl -X POST {{ api_url }}/v1/messages/flags \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
-d "messages=[4,8,15]" \

View File

@ -38,7 +38,7 @@ zulip(config).then((client) => {
{tab|curl}
```
``` curl
curl -X "PATCH" {{ api_url }}/v1/messages/<msg_id> \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
-d $"content=New content"

View File

@ -15,7 +15,7 @@ per-stream notification settings.
{tab|curl}
```
``` curl
curl -X POST {{ api_url }}/v1/users/me/subscriptions/properties \
-u BOT_EMAIL_ADDRESS:BOT_API_KEY \
-d 'subscription_data=[{"stream_id": 1, \

View File

@ -16,8 +16,8 @@ organization. Access to this endpoint depends on the
{tab|curl}
```
curl {{ api_url }}/v1/realm/emoji/<emoji_name> \
``` curl
curl -X POST {{ api_url }}/v1/realm/emoji/<emoji_name> \
-F "data=@/path/to/img.png" \
-u USER_EMAIL:API_KEY
```

View File

@ -80,8 +80,9 @@ import re
import markdown
from django.utils.html import escape
from markdown.extensions.codehilite import CodeHilite, CodeHiliteExtension
from zerver.lib.exceptions import BugdownRenderingException
from zerver.lib.tex import render_tex
from typing import Any, Dict, Iterable, List, MutableSequence
from typing import Any, Dict, Iterable, List, MutableSequence, Optional
# Global vars
FENCE_RE = re.compile("""
@ -107,12 +108,44 @@ FENCE_RE = re.compile("""
CODE_WRAP = '<pre><code%s>%s\n</code></pre>'
LANG_TAG = ' class="%s"'
def validate_curl_content(lines: List[str]) -> None:
error_msg = """
Missing required -X argument in curl command:
{command}
""".strip()
for line in lines:
regex = r'curl [-]X "?(GET|DELETE|PATCH|POST)"?'
if line.startswith('curl'):
if re.search(regex, line) is None:
raise BugdownRenderingException(error_msg.format(command=line.strip()))
CODE_VALIDATORS = {
'curl': validate_curl_content,
}
class FencedCodeExtension(markdown.Extension):
def __init__(self, config: Optional[Dict[str, Any]]=None) -> None:
if config is None:
config = {}
self.config = {
'run_content_validators': [
config.get('run_content_validators', False),
'Boolean specifying whether to run content validation code in CodeHandler'
]
}
for key, value in config.items():
self.setConfig(key, value)
def extendMarkdown(self, md: markdown.Markdown, md_globals: Dict[str, Any]) -> None:
""" Add FencedBlockPreprocessor to the Markdown instance. """
md.registerExtension(self)
md.preprocessors.register(FencedBlockPreprocessor(md), 'fenced_code_block', 25)
processor = FencedBlockPreprocessor(
md, run_content_validators=self.config['run_content_validators'][0])
md.preprocessors.register(processor, 'fenced_code_block', 25)
class BaseHandler:
@ -122,42 +155,51 @@ class BaseHandler:
def done(self) -> None:
raise NotImplementedError()
def generic_handler(processor: Any, output: MutableSequence[str], fence: str, lang: str) -> BaseHandler:
def generic_handler(processor: Any, output: MutableSequence[str],
fence: str, lang: str,
run_content_validators: Optional[bool]=False) -> BaseHandler:
if lang in ('quote', 'quoted'):
return QuoteHandler(processor, output, fence)
elif lang in ('math', 'tex', 'latex'):
return TexHandler(processor, output, fence)
else:
return CodeHandler(processor, output, fence, lang)
return CodeHandler(processor, output, fence, lang, run_content_validators)
def check_for_new_fence(processor: Any, output: MutableSequence[str], line: str) -> None:
def check_for_new_fence(processor: Any, output: MutableSequence[str], line: str,
run_content_validators: Optional[bool]=False) -> None:
m = FENCE_RE.match(line)
if m:
fence = m.group('fence')
lang = m.group('lang')
handler = generic_handler(processor, output, fence, lang)
handler = generic_handler(processor, output, fence, lang, run_content_validators)
processor.push(handler)
else:
output.append(line)
class OuterHandler(BaseHandler):
def __init__(self, processor: Any, output: MutableSequence[str]) -> None:
def __init__(self, processor: Any, output: MutableSequence[str],
run_content_validators: Optional[bool]=False) -> None:
self.output = output
self.processor = processor
self.run_content_validators = run_content_validators
def handle_line(self, line: str) -> None:
check_for_new_fence(self.processor, self.output, line)
check_for_new_fence(self.processor, self.output, line,
self.run_content_validators)
def done(self) -> None:
self.processor.pop()
class CodeHandler(BaseHandler):
def __init__(self, processor: Any, output: MutableSequence[str], fence: str, lang: str) -> None:
def __init__(self, processor: Any, output: MutableSequence[str],
fence: str, lang: str, run_content_validators: Optional[bool]=False) -> None:
self.processor = processor
self.output = output
self.fence = fence
self.lang = lang
self.lines = [] # type: List[str]
self.run_content_validators = run_content_validators
def handle_line(self, line: str) -> None:
if line.rstrip() == self.fence:
@ -167,6 +209,12 @@ class CodeHandler(BaseHandler):
def done(self) -> None:
text = '\n'.join(self.lines)
# run content validators (if any)
if self.run_content_validators:
validator = CODE_VALIDATORS.get(self.lang, lambda text: None)
validator(self.lines)
text = self.processor.format_code(self.lang, text)
text = self.processor.placeholder(text)
processed_lines = text.split('\n')
@ -222,10 +270,11 @@ class TexHandler(BaseHandler):
class FencedBlockPreprocessor(markdown.preprocessors.Preprocessor):
def __init__(self, md: markdown.Markdown) -> None:
def __init__(self, md: markdown.Markdown, run_content_validators: Optional[bool]=False) -> None:
markdown.preprocessors.Preprocessor.__init__(self, md)
self.checked_for_codehilite = False
self.run_content_validators = run_content_validators
self.codehilite_conf = {} # type: Dict[str, List[Any]]
def push(self, handler: BaseHandler) -> None:
@ -242,7 +291,7 @@ class FencedBlockPreprocessor(markdown.preprocessors.Preprocessor):
processor = self
self.handlers = [] # type: List[BaseHandler]
handler = OuterHandler(processor, output)
handler = OuterHandler(processor, output, self.run_content_validators)
self.push(handler)
for line in lines:
@ -324,7 +373,7 @@ class FencedBlockPreprocessor(markdown.preprocessors.Preprocessor):
def makeExtension(*args: Any, **kwargs: None) -> FencedCodeExtension:
return FencedCodeExtension(*args, **kwargs)
return FencedCodeExtension(kwargs)
if __name__ == "__main__":
import doctest

View File

@ -103,7 +103,9 @@ def render_markdown_path(markdown_file_path: str,
linenums=False,
guess_lang=False
),
zerver.lib.bugdown.fenced_code.makeExtension(),
zerver.lib.bugdown.fenced_code.makeExtension(
run_content_validators=context.get('run_content_validators', False)
),
zerver.lib.bugdown.api_arguments_table_generator.makeExtension(
base_path='templates/zerver/api/'),
zerver.lib.bugdown.api_code_examples.makeExtension(),

View File

@ -1709,6 +1709,50 @@ class BugdownErrorTests(ZulipTestCase):
with self.assertRaises(BugdownRenderingException):
bugdown_convert(msg)
def test_curl_code_block_validation(self) -> None:
processor = bugdown.fenced_code.FencedBlockPreprocessor(None)
processor.run_content_validators = True
# Simulate code formatting.
processor.format_code = lambda lang, code: lang + ':' + code # type: ignore # mypy doesn't allow monkey-patching functions
processor.placeholder = lambda s: '**' + s.strip('\n') + '**' # type: ignore # https://github.com/python/mypy/issues/708
markdown = [
'``` curl',
'curl {{ api_url }}/v1/register',
' -u BOT_EMAIL_ADDRESS:BOT_API_KEY',
' -d "queue_id=1375801870:2942"',
'```',
]
with self.assertRaises(BugdownRenderingException):
processor.run(markdown)
def test_curl_code_block_without_validation(self) -> None:
processor = bugdown.fenced_code.FencedBlockPreprocessor(None)
# Simulate code formatting.
processor.format_code = lambda lang, code: lang + ':' + code # type: ignore # mypy doesn't allow monkey-patching functions
processor.placeholder = lambda s: '**' + s.strip('\n') + '**' # type: ignore # https://github.com/python/mypy/issues/708
markdown = [
'``` curl',
'curl {{ api_url }}/v1/register',
' -u BOT_EMAIL_ADDRESS:BOT_API_KEY',
' -d "queue_id=1375801870:2942"',
'```',
]
expected = [
'',
'**curl:curl {{ api_url }}/v1/register',
' -u BOT_EMAIL_ADDRESS:BOT_API_KEY',
' -d "queue_id=1375801870:2942"**',
'',
''
]
result = processor.run(markdown)
self.assertEqual(result, expected)
class BugdownAvatarTestCase(ZulipTestCase):
def test_possible_avatar_emails(self) -> None:

View File

@ -89,6 +89,22 @@ class DocPageTest(ZulipTestCase):
if not doc_html_str:
self.assert_in_success_response(['<meta name="robots" content="noindex,nofollow">'], result)
@slow("Tests dozens of endpoints")
def test_api_doc_endpoints(self) -> None:
current_dir = os.path.dirname(os.path.abspath(__file__))
api_docs_dir = os.path.join(current_dir, '..', '..', 'templates/zerver/api/')
files = os.listdir(api_docs_dir)
def _filter_func(fp: str) -> bool:
ignored_files = ['sidebar_index.md', 'index.md', 'missing.md']
return fp.endswith('.md') and fp not in ignored_files
files = list(filter(_filter_func, files))
for f in files:
endpoint = '/api/{}'.format(os.path.splitext(f)[0])
self._test(endpoint, '', doc_html_str=True)
@slow("Tests dozens of endpoints, including generating lots of emails")
def test_doc_endpoints(self) -> None:
self._test('/api/', 'The Zulip API')

View File

@ -124,6 +124,7 @@ class MarkdownDirectoryView(ApiURLView):
# An "article" might require the api_uri_context to be rendered
api_uri_context = {} # type: Dict[str, Any]
add_api_uri_context(api_uri_context, self.request)
api_uri_context["run_content_validators"] = True
context["api_uri_context"] = api_uri_context
return context