diff --git a/doc/source/usage.rst b/doc/source/usage.rst index 27b809c..82479f3 100644 --- a/doc/source/usage.rst +++ b/doc/source/usage.rst @@ -182,6 +182,114 @@ max_version a *Deprecated in $version* stanza in the html output. +rest_status_code +---------------- + +The ``rest_status_code`` stanza is how you can show what HTTP status codes your +API uses and what they indicate + +.. code-block:: rst + + .. rest_status_code:: + +This stanza should be the first element after the narrative section of the +method description. + +An example from the Designate documentation is: + +.. code-block:: rst + :emphasize-lines: 11-25 + + Create Zone + =========== + + .. rest_method:: POST /v2/zones + + Create a zone + + Response codes + -------------- + + .. rest_status_code:: success status.yaml + + - 200 + - 100 + - 201 + + + .. rest_status_code:: error status.yaml + + - 405 + - 403 + - 401 + - 400 + - 500 + - 409: duplcate_zone + +And corresponding entries in ``status.yaml``: + +.. code-block:: yaml + + 100: + default: | + An unusual code for an API + 200: + default: | + Request was successful. + 201: + default: > + Resource was created and is ready to use. The ``Location`` header + will have the URL to the new item + + 400: + default: | + Some content in the request was invalid + zone_data_error: | + Some of the data for the + 401: + default: | + User must authenticate before making a request + 403: + default: | + Policy does not allow current user to do this operation. + 405: + default: | + Method is not valid for this endpoint. Not all endpoints allow all HTTP methods. + 409: + default: | + This operation conflicted with another operation on this resource + duplcate_zone: | + There is already a zone with this name. + 500: + default: | + Something went wrong inside the service. + + +This will create a 2 tables of response codes, one for success and one for +errors. + +status file format +------------------ + +This is a simple yaml file, with a single object of status codes and the +reasons that each would be used. + +Each status code **must** have a default entry. This is used when a code is +used in a ``rest_status_code`` stanza with no value. + +There may be situations where the reason for a code may be different across +endpoints, or a different message may be appropriate. + +In this case, adding a entry at the same level as the ``default`` and +referencing that in the stanaza like so: + +.. code-block:: yaml + + - 409: duplcate_zone + +This will overide the default message with the newly defined one. + + rest_expand_all --------------- diff --git a/os_api_ref/__init__.py b/os_api_ref/__init__.py index 99d999c..a86066d 100644 --- a/os_api_ref/__init__.py +++ b/os_api_ref/__init__.py @@ -23,6 +23,9 @@ from sphinx.util.compat import Directive from sphinx.util.osutil import copyfile import yaml +from os_api_ref.http_codes import http_code +from os_api_ref.http_codes import http_code_html +from os_api_ref.http_codes import HTTPResponseCodeDirective __version__ = pbr.version.VersionInfo( 'os_api_ref').version_string() @@ -532,11 +535,14 @@ def setup(app): html=(rest_method_html, None)) app.add_node(rest_expand_all, html=(rest_expand_all_html, None)) + app.add_node(http_code, + html=(http_code_html, None)) # This specifies all our directives that we're adding app.add_directive('rest_parameters', RestParametersDirective) app.add_directive('rest_method', RestMethodDirective) app.add_directive('rest_expand_all', RestExpandAllDirective) + app.add_directive('rest_status_code', HTTPResponseCodeDirective) # The doctree-read hook is used do the slightly crazy doc # transformation that we do to get the rest_method document diff --git a/os_api_ref/http_codes.py b/os_api_ref/http_codes.py new file mode 100644 index 0000000..ad09aef --- /dev/null +++ b/os_api_ref/http_codes.py @@ -0,0 +1,232 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from docutils import nodes +from docutils.parsers.rst.directives.tables import Table +from docutils.statemachine import ViewList +from httplib import responses +import yaml + +# cache for file -> yaml so we only do the load and check of a yaml +# file once during a sphinx processing run. +HTTP_YAML_CACHE = {} + + +class HTTPResponseCodeDirective(Table): + + headers = ["Code", "Reason"] + + status_types = ("success", "error") + + # This is for HTTP response codes that OpenStack may use that are not part + # the httplib response dict. + CODES = { + 429: "Too Many Requests", + } + + required_arguments = 2 + + def __init__(self, *args, **kwargs): + self.CODES.update(responses) + super(HTTPResponseCodeDirective, self).__init__(*args, **kwargs) + + def _load_status_file(self, fpath): + global HTTP_YAML_CACHE + if fpath in HTTP_YAML_CACHE: + return HTTP_YAML_CACHE[fpath] + + # self.app.info("Fpath: %s" % fpath) + try: + with open(fpath, 'r') as stream: + lookup = yaml.load(stream) + except IOError: + self.env.warn( + self.env.docname, + "Parameters file %s not found" % fpath) + return + except yaml.YAMLError as exc: + self.app.warn(exc) + raise + + HTTP_YAML_CACHE[fpath] = lookup + return lookup + + def run(self): + self.env = self.state.document.settings.env + self.app = self.env.app + + # Make sure we have some content, which should be yaml that + # defines some parameters. + if not self.content: + error = self.state_machine.reporter.error( + 'No parameters defined', + nodes.literal_block(self.block_text, self.block_text), + line=self.lineno) + return [error] + + if not len(self.arguments) >= 2: + error = self.state_machine.reporter.error( + '%s' % self.arguments, + nodes.literal_block(self.block_text, self.block_text), + line=self.lineno) + return [error] + + _, status_defs_file = self.env.relfn2path(self.arguments.pop()) + status_type = self.arguments.pop() + + self.status_defs = self._load_status_file(status_defs_file) + + # self.app.info("%s" % str(self.status_defs)) + + if status_type not in self.status_types: + error = self.state_machine.reporter.error( + 'Type %s is not one of %s' % (status_type, self.status_types), + nodes.literal_block(self.block_text, self.block_text), + line=self.lineno) + return [error] + + self.yaml = self._load_codes() + + self.max_cols = len(self.headers) + # TODO(sdague): it would be good to dynamically set column + # widths (or basically make the colwidth thing go away + # entirely) + self.options['widths'] = (30, 70) + self.col_widths = self.get_column_widths(self.max_cols) + # Actually convert the yaml + title, messages = self.make_title() + # self.app.info("Title %s, messages %s" % (title, messages)) + table_node = self.build_table() + self.add_name(table_node) + + title_block = nodes.title( + text=status_type.capitalize()) + + section = nodes.section(ids=title_block) + section += title_block + section += table_node + + return [section] + messages + + def _load_codes(self): + content = "\n".join(self.content) + parsed = yaml.load(content) + + new_content = list() + + for item in parsed: + if isinstance(item, int): + new_content.append((item, self.status_defs[item]['default'])) + else: + try: + for code, reason in item.items(): + new_content.append( + (code, self.status_defs[code][reason]) + ) + except KeyError: + self.app.warn( + "Could not find %s for code %s" % (reason, code)) + new_content.append( + (code, self.status_defs[code]['default'])) + + return new_content + + def build_table(self): + table = nodes.table() + tgroup = nodes.tgroup(cols=len(self.headers)) + table += tgroup + + # TODO(sdague): it would be really nice to figure out how not + # to have this stanza, it kind of messes up all of the table + # formatting because it doesn't let tables just be the right + # size. + tgroup.extend( + nodes.colspec(colwidth=col_width, colname='c' + str(idx)) + for idx, col_width in enumerate(self.col_widths) + ) + + thead = nodes.thead() + tgroup += thead + + row_node = nodes.row() + thead += row_node + row_node.extend(nodes.entry(h, nodes.paragraph(text=h)) + for h in self.headers) + + tbody = nodes.tbody() + tgroup += tbody + + rows, groups = self.collect_rows() + tbody.extend(rows) + table.extend(groups) + + return table + + def add_col(self, node): + entry = nodes.entry() + entry.append(node) + return entry + + def add_desc_col(self, value): + entry = nodes.entry() + result = ViewList(value.split('\n')) + self.state.nested_parse(result, 0, entry) + return entry + + def collect_rows(self): + rows = [] + groups = [] + try: + # self.app.info("Parsed content is: %s" % self.yaml) + for code, desc in self.yaml: + + h_code = http_code() + h_code['code'] = code + h_code['title'] = self.CODES.get(code, 'Unknown') + + trow = nodes.row() + trow += self.add_col(h_code) + trow += self.add_desc_col(desc) + rows.append(trow) + except AttributeError as exc: + # if 'key' in locals(): + self.app.warn("Failure on key: %s, values: %s. %s" % + (code, desc, exc)) + # else: + # rows.append(self.show_no_yaml_error()) + return rows, groups + + +def http_code_html(self, node): + tmpl = "%(code)s - %(title)s" + self.body.append(tmpl % node) + raise nodes.SkipNode + + +class http_code(nodes.Part, nodes.Element): + """Node for http_code stanza + + Because we need to insert very specific HTML at the final stage of + processing, the http_code stanza needs a custom node type. This + lets us accumulate the relevant data into this node, during + parsing, but not turn it into known sphinx types (lists, tables, + sections). + + Then, during the final build phase we transform directly to the + html that we want. + + NOTE: this means we error trying to build latex or man pages for + these stanza types right now. This is all fixable if we add an + output formatter for this node type, but it's not yet a + priority. Contributions welcomed. + """ + pass diff --git a/os_api_ref/tests/examples/basic/index.rst b/os_api_ref/tests/examples/basic/index.rst index 529cf6a..80e717f 100644 --- a/os_api_ref/tests/examples/basic/index.rst +++ b/os_api_ref/tests/examples/basic/index.rst @@ -11,3 +11,22 @@ I am text, hear me roar! .. rest_parameters:: parameters.yaml - name: name + +Response codes +-------------- + +.. rest_status_code:: success status.yaml + + - 200 + - 100 + - 201 + + +.. rest_status_code:: error status.yaml + + - 405 + - 403 + - 401 + - 400 + - 500 + - 409: duplcate_zone diff --git a/os_api_ref/tests/examples/basic/status.yaml b/os_api_ref/tests/examples/basic/status.yaml new file mode 100644 index 0000000..d21f2c2 --- /dev/null +++ b/os_api_ref/tests/examples/basic/status.yaml @@ -0,0 +1,39 @@ +################# +# Success Codes # +################# +100: + default: | + An unusual code for an API +200: + default: | + Request was successful. +201: + default: | + Resource was created and is ready to use. + +################# +# Error Codes # +################# + +400: + default: | + Some content in the request was invalid + zone_data_error: | + Some of the data for the +401: + default: | + User must authenticate before making a request +403: + default: | + Policy does not allow current user to do this operation. +405: + default: | + Method is not valid for this endpoint. +409: + default: | + This operation conflicted with another operation on this resource + duplcate_zone: | + There is already a zone with this name. +500: + default: | + Something went wrong inside the service. diff --git a/os_api_ref/tests/test_basic_example.py b/os_api_ref/tests/test_basic_example.py index 69d6e41..7c16fd8 100644 --- a/os_api_ref/tests/test_basic_example.py +++ b/os_api_ref/tests/test_basic_example.py @@ -102,3 +102,64 @@ class TestBasicExample(base.TestCase): """ self.assertIn(table, self.content) + + def test_rest_response(self): + + success_table = """table border="1" class="docutils"> + + + + + +Code +Reason + + + +200 - OK +Request was successful. + +100 - Continue +An unusual code for an API + +201 - Created +Resource was created and is ready to use. + + + +""" + + error_table = """ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + +
CodeReason
405 - Method Not AllowedMethod is not valid for this endpoint.
403 - ForbiddenPolicy does not allow current user to do this operation.
401 - UnauthorizedUser must authenticate before making a request
400 - Bad RequestSome content in the request was invalid
500 - Internal Server ErrorSomething went wrong inside the service.
409 - ConflictThere is already a zone with this name.
+""" + self.assertIn(success_table, self.content) + self.assertIn(error_table, self.content)