Repository: algoo/hapic

Issue:
Support for description/example/etc... from schema in query/path parameter
Hapic should create query/path parameter with description and example when available.

Discussion:

Patch:
diff --git a/additionals_fields.md b/additionals_fields.md
new file mode 100644
index 0000000..5a77965
--- /dev/null
+++ b/additionals_fields.md
@@ -0,0 +1,44 @@
+## Addtionals fields supported
+
+Using marshmallow schema, you have the possibility to add extra information in
+field in order to add them into auto-generated apispec documentation.
+
+```
+        class MySchema(marshmallow.Schema):
+            category = marshmallow.fields.String(
+                required=True,
+                description='a description',
+                example='00010',
+                format='binary',
+                enum=['01000', '11111'],
+                maxLength=5,
+                minLength=5,
+            )
+```
+
+Not all field are fully supported now by Hapic.
+
+## Supported Additional Fields in Hapic for query/path/body :
+
+General types:
+
+- format
+- description
+- enum
+- example (example is converted at the end of description for query/path)
+
+Number type:
+
+- maximum
+- exclusiveMaximum
+- minimum
+- exclusiveMinimum
+- multipleOf
+
+String type:
+
+- maxLength
+- minLength
+
+Theses field are related to OpenApiv2 spec, see this :
+https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#definitionsObject
diff --git a/hapic/doc.py b/hapic/doc.py
index 48aaad9..1c0759a 100644
--- a/hapic/doc.py
+++ b/hapic/doc.py
@@ -15,6 +15,48 @@ from hapic.decorator import DecoratedController
 from hapic.description import ControllerDescription
 
 
+FIELDS_PARAMS_GENERIC_ACCEPTED = [
+    'type',
+    'format',
+    'required',
+    'description',
+    'enum',
+]
+
+FIELDS_TYPE_ARRAY = [
+    'array',
+]
+FIELDS_PARAMS_ARRAY_ACCEPTED = [
+    'items',
+    'collectionFormat',
+    'pattern',
+    'maxitems',
+    'minitems',
+    'uniqueitems',
+]
+
+FIELDS_TYPE_STRING = [
+    'string',
+]
+FIELDS_PARAMS_STRING_ACCEPTED = [
+    'maxLength',
+    'minLength',
+    'pattern',
+]
+
+FIELDS_TYPE_NUMERIC = [
+    'number', 
+    'integer',
+]
+FIELDS_PARAMS_NUMERIC_ACCEPTED = [
+    'maximum',
+    'exclusiveMaximum',
+    'minimum',
+    'exclusiveMinimum',
+    'multipleOf',
+]
+
+
 def generate_schema_ref(spec:APISpec, schema: marshmallow.Schema) -> str:
     schema_class = spec.schema_class_resolver(
         spec,
@@ -33,6 +75,63 @@ def generate_schema_ref(spec:APISpec, schema: marshmallow.Schema) -> str:
     return ref
 
 
+def field_accepted_param(type: str, param_name:str) -> bool:
+    return (
+        param_name in FIELDS_PARAMS_GENERIC_ACCEPTED
+        or (type in FIELDS_TYPE_STRING
+            and param_name in FIELDS_PARAMS_STRING_ACCEPTED)
+        or (type in FIELDS_TYPE_ARRAY
+            and param_name in FIELDS_PARAMS_ARRAY_ACCEPTED)
+        or (type in FIELDS_TYPE_NUMERIC
+            and param_name in FIELDS_PARAMS_NUMERIC_ACCEPTED)
+    )
+
+
+def generate_fields_description(
+    schema,
+    in_: str,
+    name: str,
+    required: bool,
+    type: str=None,
+) -> dict:
+    """
+    Generate field OpenApiDescription for
+    both query and path params
+    :param schema: field schema
+    :param in_: in field
+    :param name: field name
+    :param required: required field
+    :param type: type field
+    :return: File description for OpenApi
+    """
+    description = {}
+    # INFO - G.M - 01-06-2018 - get field
+    # type to know which params are accepted
+    if not type and 'type' in schema:
+        type = schema['type']
+    assert type
+
+    for param_name, value in schema.items():
+        if field_accepted_param(type, param_name):
+            description[param_name] = value
+    description['type'] = type
+    description['in'] = in_
+    description['name'] = name
+    description['required'] = required
+
+
+    # INFO - G.M - 01-06-2018 - example is not allowed in query/path params,
+    # in OpenApi2, remove it and set it as string in field description.
+    if 'example' in schema:
+        if 'description' not in description:
+            description['description'] = ""
+        description['description'] = '{description}\n\n*example value: {example}*'.format(  # nopep8
+            description=description['description'],
+            example=schema['example']
+        )
+    return description
+
+
 def bottle_generate_operations(
     spec,
     route: RouteRepresentation,
@@ -90,12 +189,14 @@ def bottle_generate_operations(
         # TODO: look schema2parameters ?
         jsonschema = schema2jsonschema(schema_class, spec=spec)
         for name, schema in jsonschema.get('properties', {}).items():
-            method_operations.setdefault('parameters', []).append({
-                'in': 'path',
-                'name': name,
-                'required': name in jsonschema.get('required', []),
-                'type': schema['type']
-            })
+            method_operations.setdefault('parameters', []).append(
+                generate_fields_description(
+                    schema=schema,
+                    in_='path',
+                    name=name,
+                    required=name in jsonschema.get('required', []),
+                )
+            )
 
     if description.input_query:
         schema_class = spec.schema_class_resolver(
@@ -104,16 +205,20 @@ def bottle_generate_operations(
         )
         jsonschema = schema2jsonschema(schema_class, spec=spec)
         for name, schema in jsonschema.get('properties', {}).items():
-            method_operations.setdefault('parameters', []).append({
-                'in': 'query',
-                'name': name,
-                'required': name in jsonschema.get('required', []),
-                'type': schema['type']
-            })
+            method_operations.setdefault('parameters', []).append(
+                generate_fields_description(
+                    schema=schema,
+                    in_='query',
+                    name=name,
+                    required=name in jsonschema.get('required', []),
+                )
+            )
 
     if description.input_files:
         method_operations.setdefault('consumes', []).append('multipart/form-data')
         for field_name, field in description.input_files.wrapper.processor.schema.fields.items():
+            # TODO - G.M - 01-06-2018 - Check if other fields can be used
+            # see generate_fields_description
             method_operations.setdefault('parameters', []).append({
                 'in': 'formData',
                 'name': field_name,
diff --git a/setup.py b/setup.py
index 99c4f13..0819989 100644
--- a/setup.py
+++ b/setup.py
@@ -46,7 +46,6 @@ setup(
     # the version across setup.py and the project code, see
     # https://packaging.python.org/en/latest/single_source_version.html
     version=version,
-
     description='HTTP api input/output manager',
     # long_description=long_description,
     long_description='',


Test patch:
diff --git a/tests/func/fake_api/common.py b/tests/func/fake_api/common.py
index 1417c2b..b1c4024 100644
--- a/tests/func/fake_api/common.py
+++ b/tests/func/fake_api/common.py
@@ -81,7 +81,9 @@ SWAGGER_DOC_API = {
       ('/users/{id}', {
          'delete': {
            'description': 'delete user',
-           'parameters': [{'in': 'path',
+           'parameters': [{'format': 'int32',
+                           'minimum': 1,
+                           'in': 'path',
                            'name': 'id',
                            'required': True,
                            'type': 'integer'}],
@@ -90,7 +92,9 @@ SWAGGER_DOC_API = {
                'schema': {'$ref': '#/definitions/NoContentSchema'}}}},
          'get': {
              'description': 'Obtain one user',
-             'parameters': [{'in': 'path',
+             'parameters': [{'format': 'int32',
+                             'in': 'path',
+                             'minimum': 1,
                              'name': 'id',
                              'required': True,
                              'type': 'integer'}],
diff --git a/tests/func/test_doc.py b/tests/func/test_doc.py
index dfbc9b9..2680fe4 100644
--- a/tests/func/test_doc.py
+++ b/tests/func/test_doc.py
@@ -372,3 +372,217 @@ class TestDocGeneration(Base):
             'items': {'$ref': '#/definitions/{}'.format(schema_name)},
             'type': 'array'
         }
+
+    def test_func_schema_in_doc__ok__additionals_fields__query__string(self):
+        hapic = Hapic()
+        # TODO BS 20171113: Make this test non-bottle
+        app = bottle.Bottle()
+        hapic.set_context(MyContext(app=app))
+
+        class MySchema(marshmallow.Schema):
+            category = marshmallow.fields.String(
+                required=True,
+                description='a description',
+                example='00010',
+                format='binary',
+                enum=['01000', '11111'],
+                maxLength=5,
+                minLength=5,
+                # Theses none string specific parameters should disappear
+                # in query/path
+                maximum=400,
+                # exclusiveMaximun=False,
+                # minimum=0,
+                # exclusiveMinimum=True,
+                # multipleOf=1,
+            )
+
+        @hapic.with_api_doc()
+        @hapic.input_query(MySchema())
+        def my_controller():
+            return
+
+        app.route('/paper', method='POST', callback=my_controller)
+        doc = hapic.generate_doc()
+        assert doc.get('paths').get('/paper').get('post').get('parameters')[0]
+        field = doc.get('paths').get('/paper').get('post').get('parameters')[0]
+        assert field['description'] == 'a description\n\n*example value: 00010*'
+        # INFO - G.M - 01-06-2018 - Field example not allowed here,
+        # added in description instead
+        assert 'example' not in field
+        assert field['format'] == 'binary'
+        assert field['in'] == 'query'
+        assert field['type'] == 'string'
+        assert field['maxLength'] == 5
+        assert field['minLength'] == 5
+        assert field['required'] == True
+        assert field['enum'] == ['01000', '11111']
+        assert 'maximum' not in field
+
+    def test_func_schema_in_doc__ok__additionals_fields__path__string(self):
+        hapic = Hapic()
+        # TODO BS 20171113: Make this test non-bottle
+        app = bottle.Bottle()
+        hapic.set_context(MyContext(app=app))
+
+        class MySchema(marshmallow.Schema):
+            category = marshmallow.fields.String(
+                required=True,
+                description='a description',
+                example='00010',
+                format='binary',
+                enum=['01000', '11111'],
+                maxLength=5,
+                minLength=5,
+                # Theses none string specific parameters should disappear
+                # in query/path
+                maximum=400,
+                # exclusiveMaximun=False,
+                # minimum=0,
+                # exclusiveMinimum=True,
+                # multipleOf=1,
+            )
+
+        @hapic.with_api_doc()
+        @hapic.input_path(MySchema())
+        def my_controller():
+            return
+
+        app.route('/paper', method='POST', callback=my_controller)
+        doc = hapic.generate_doc()
+        assert doc.get('paths').get('/paper').get('post').get('parameters')[0]
+        field = doc.get('paths').get('/paper').get('post').get('parameters')[0]
+        assert field['description'] == 'a description\n\n*example value: 00010*'
+        # INFO - G.M - 01-06-2018 - Field example not allowed here,
+        # added in description instead
+        assert 'example' not in field
+        assert field['format'] == 'binary'
+        assert field['in'] == 'path'
+        assert field['type'] == 'string'
+        assert field['maxLength'] == 5
+        assert field['minLength'] == 5
+        assert field['required'] == True
+        assert field['enum'] == ['01000', '11111']
+        assert 'maximum' not in field
+
+    def test_func_schema_in_doc__ok__additionals_fields__path__number(self):
+        hapic = Hapic()
+        # TODO BS 20171113: Make this test non-bottle
+        app = bottle.Bottle()
+        hapic.set_context(MyContext(app=app))
+
+        class MySchema(marshmallow.Schema):
+            category = marshmallow.fields.Integer(
+                required=True,
+                description='a number',
+                example='12',
+                format='int64',
+                enum=[4, 6],
+                # Theses none string specific parameters should disappear
+                # in query/path
+                maximum=14,
+                exclusiveMaximun=False,
+                minimum=0,
+                exclusiveMinimum=True,
+                multipleOf=2,
+            )
+
+        @hapic.with_api_doc()
+        @hapic.input_path(MySchema())
+        def my_controller():
+            return
+
+        app.route('/paper', method='POST', callback=my_controller)
+        doc = hapic.generate_doc()
+        assert doc.get('paths').get('/paper').get('post').get('parameters')[0]
+        field = doc.get('paths').get('/paper').get('post').get('parameters')[0]
+        assert field['description'] == 'a number\n\n*example value: 12*'
+        # INFO - G.M - 01-06-2018 - Field example not allowed here,
+        # added in description instead
+        assert 'example' not in field
+        assert field['format'] == 'int64'
+        assert field['in'] == 'path'
+        assert field['type'] == 'integer'
+        assert field['maximum'] == 14
+        assert field['minimum'] == 0
+        assert field['exclusiveMinimum'] == True
+        assert field['required'] == True
+        assert field['enum'] == [4, 6]
+        assert field['multipleOf'] == 2
+
+    def test_func_schema_in_doc__ok__additionals_fields__body__number(self):
+        hapic = Hapic()
+        # TODO BS 20171113: Make this test non-bottle
+        app = bottle.Bottle()
+        hapic.set_context(MyContext(app=app))
+
+        class MySchema(marshmallow.Schema):
+            category = marshmallow.fields.Integer(
+                required=True,
+                description='a number',
+                example='12',
+                format='int64',
+                enum=[4, 6],
+                # Theses none string specific parameters should disappear
+                # in query/path
+                maximum=14,
+                exclusiveMaximun=False,
+                minimum=0,
+                exclusiveMinimum=True,
+                multipleOf=2,
+            )
+
+        @hapic.with_api_doc()
+        @hapic.input_body(MySchema())
+        def my_controller():
+            return
+
+        app.route('/paper', method='POST', callback=my_controller)
+        doc = hapic.generate_doc()
+
+        schema_field = doc.get('definitions', {}).get('MySchema', {}).get('properties', {}).get('category', {})  # nopep8
+        assert schema_field
+        assert schema_field['description'] == 'a number'
+        assert schema_field['example'] == '12'
+        assert schema_field['format'] == 'int64'
+        assert schema_field['type'] == 'integer'
+        assert schema_field['maximum'] == 14
+        assert schema_field['minimum'] == 0
+        assert schema_field['exclusiveMinimum'] == True
+        assert schema_field['enum'] == [4, 6]
+        assert schema_field['multipleOf'] == 2
+
+    def test_func_schema_in_doc__ok__additionals_fields__body__string(self):
+        hapic = Hapic()
+        # TODO BS 20171113: Make this test non-bottle
+        app = bottle.Bottle()
+        hapic.set_context(MyContext(app=app))
+
+        class MySchema(marshmallow.Schema):
+            category = marshmallow.fields.String(
+                required=True,
+                description='a description',
+                example='00010',
+                format='binary',
+                enum=['01000', '11111'],
+                maxLength=5,
+                minLength=5,
+            )
+
+        @hapic.with_api_doc()
+        @hapic.input_body(MySchema())
+        def my_controller():
+            return
+
+        app.route('/paper', method='POST', callback=my_controller)
+        doc = hapic.generate_doc()
+
+        schema_field = doc.get('definitions', {}).get('MySchema', {}).get('properties', {}).get('category', {})  # nopep8
+        assert schema_field
+        assert schema_field['description'] == 'a description'
+        assert schema_field['example'] == '00010'
+        assert schema_field['format'] == 'binary'
+        assert schema_field['type'] == 'string'
+        assert schema_field['maxLength'] == 5
+        assert schema_field['minLength'] == 5
+        assert schema_field['enum'] == ['01000', '11111']