Browse Source

Support for additionals fields for query/path params

Guénaël Muller 5 years ago
parent
commit
1bcfa41f05
4 changed files with 373 additions and 14 deletions
  1. 43 0
      additionals_fields.md
  2. 110 12
      hapic/doc.py
  3. 6 2
      tests/func/fake_api/common.py
  4. 214 0
      tests/func/test_doc.py

+ 43 - 0
additionals_fields.md View File

@@ -0,0 +1,43 @@
1
+## Addtionals fields supported
2
+
3
+Using marshmallow schema, you can add some extra field in field who are
4
+automatically added into field description in schema.
5
+```
6
+        class MySchema(marshmallow.Schema):
7
+            category = marshmallow.fields.String(
8
+                required=True,
9
+                description='a description',
10
+                example='00010',
11
+                format='binary',
12
+                enum=['01000', '11111'],
13
+                maxLength=5,
14
+                minLength=5,
15
+            )
16
+```
17
+
18
+Not all field are fully supported now by Hapic.
19
+
20
+## Supported Additional Fields in Hapic for query/path/body :
21
+
22
+General types:
23
+
24
+- format
25
+- description
26
+- enum
27
+- example (example is converted at the end of description for query/path)
28
+
29
+Number type:
30
+
31
+- maximum
32
+- exclusiveMaximum
33
+- minimum
34
+- exclusiveMinimum
35
+- multipleOf
36
+
37
+String type:
38
+
39
+- maxLength
40
+- minLength
41
+
42
+Theses field are related to OpenApiv2 spec, see this :
43
+https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#definitionsObject

+ 110 - 12
hapic/doc.py View File

@@ -15,6 +15,41 @@ from hapic.decorator import DecoratedController
15 15
 from hapic.description import ControllerDescription
16 16
 
17 17
 
18
+FIELDS_PARAMS_GENERIC_ACCEPTED = [
19
+    'type',
20
+    'format',
21
+    'required',
22
+    'description',
23
+    'enum',
24
+]
25
+
26
+FIELDS_TYPE_ARRAY = ['array']
27
+FIELDS_PARAMS_ARRAY_ACCEPTED = [
28
+    'items',
29
+    'collectionFormat',
30
+    'pattern',
31
+    'maxitems',
32
+    'minitems',
33
+    'uniqueitems',
34
+]
35
+
36
+FIELDS_TYPE_STRING = ['string']
37
+FIELDS_PARAMS_STRING_ACCEPTED = [
38
+    'maxLength',
39
+    'minLength',
40
+    'pattern',
41
+]
42
+
43
+FIELDS_TYPE_NUMERIC = ['number', 'integer']
44
+FIELDS_PARAMS_NUMERIC_ACCEPTED = [
45
+    'maximum',
46
+    'exclusiveMaximum',
47
+    'minimum',
48
+    'exclusiveMinimum',
49
+    'multipleOf',
50
+]
51
+
52
+
18 53
 def generate_schema_ref(spec:APISpec, schema: marshmallow.Schema) -> str:
19 54
     schema_class = spec.schema_class_resolver(
20 55
         spec,
@@ -33,6 +68,63 @@ def generate_schema_ref(spec:APISpec, schema: marshmallow.Schema) -> str:
33 68
     return ref
34 69
 
35 70
 
71
+def field_accepted_param(type: str, param_name:str) -> bool:
72
+    return (
73
+        param_name in FIELDS_PARAMS_GENERIC_ACCEPTED
74
+        or (type in FIELDS_TYPE_STRING
75
+            and param_name in FIELDS_PARAMS_STRING_ACCEPTED)
76
+        or (type in FIELDS_TYPE_ARRAY
77
+            and param_name in FIELDS_PARAMS_ARRAY_ACCEPTED)
78
+        or (type in FIELDS_TYPE_NUMERIC
79
+            and param_name in FIELDS_PARAMS_NUMERIC_ACCEPTED)
80
+    )
81
+
82
+
83
+def generate_fields_description(
84
+    schema,
85
+    in_: str,
86
+    name: str,
87
+    required: bool,
88
+    type: str=None,
89
+) -> dict:
90
+    """
91
+    Generate field OpenApiDescription for
92
+    both query and path params
93
+    :param schema: field schema
94
+    :param in_: in field
95
+    :param name: field name
96
+    :param required: required field
97
+    :param type: type field
98
+    :return: File description for OpenApi
99
+    """
100
+    description = {}
101
+    # INFO - G.M - 01-06-2018 - get field
102
+    # type to know which params are accepted
103
+    if not type and 'type' in schema:
104
+        type = schema['type']
105
+    assert type
106
+
107
+    for param_name, value in schema.items():
108
+        if field_accepted_param(type, param_name):
109
+            description[param_name] = value
110
+    description['type'] = type
111
+    description['in'] = in_
112
+    description['name'] = name
113
+    description['required'] = required
114
+
115
+
116
+    # INFO - G.M - 01-06-2018 - example is not allowed in query/path params,
117
+    # in OpenApi2, remove it and set it as string in field description.
118
+    if 'example' in schema:
119
+        if 'description' not in description:
120
+            description['description'] = ""
121
+        description['description'] = '{description}\n\n*example value: {example}*'.format(  # nopep8
122
+            description=description['description'],
123
+            example=schema['example']
124
+        )
125
+    return description
126
+
127
+
36 128
 def bottle_generate_operations(
37 129
     spec,
38 130
     route: RouteRepresentation,
@@ -90,12 +182,14 @@ def bottle_generate_operations(
90 182
         # TODO: look schema2parameters ?
91 183
         jsonschema = schema2jsonschema(schema_class, spec=spec)
92 184
         for name, schema in jsonschema.get('properties', {}).items():
93
-            method_operations.setdefault('parameters', []).append({
94
-                'in': 'path',
95
-                'name': name,
96
-                'required': name in jsonschema.get('required', []),
97
-                'type': schema['type']
98
-            })
185
+            method_operations.setdefault('parameters', []).append(
186
+                generate_fields_description(
187
+                    schema=schema,
188
+                    in_='path',
189
+                    name=name,
190
+                    required=name in jsonschema.get('required', []),
191
+                )
192
+            )
99 193
 
100 194
     if description.input_query:
101 195
         schema_class = spec.schema_class_resolver(
@@ -104,16 +198,20 @@ def bottle_generate_operations(
104 198
         )
105 199
         jsonschema = schema2jsonschema(schema_class, spec=spec)
106 200
         for name, schema in jsonschema.get('properties', {}).items():
107
-            method_operations.setdefault('parameters', []).append({
108
-                'in': 'query',
109
-                'name': name,
110
-                'required': name in jsonschema.get('required', []),
111
-                'type': schema['type']
112
-            })
201
+            method_operations.setdefault('parameters', []).append(
202
+                generate_fields_description(
203
+                    schema=schema,
204
+                    in_='query',
205
+                    name=name,
206
+                    required=name in jsonschema.get('required', []),
207
+                )
208
+            )
113 209
 
114 210
     if description.input_files:
115 211
         method_operations.setdefault('consumes', []).append('multipart/form-data')
116 212
         for field_name, field in description.input_files.wrapper.processor.schema.fields.items():
213
+            # TODO - G.M - 01-06-2018 - Check if other fields can be used
214
+            # see generate_fields_description
117 215
             method_operations.setdefault('parameters', []).append({
118 216
                 'in': 'formData',
119 217
                 'name': field_name,

+ 6 - 2
tests/func/fake_api/common.py View File

@@ -81,7 +81,9 @@ SWAGGER_DOC_API = {
81 81
       ('/users/{id}', {
82 82
          'delete': {
83 83
            'description': 'delete user',
84
-           'parameters': [{'in': 'path',
84
+           'parameters': [{'format': 'int32',
85
+                           'minimum': 1,
86
+                           'in': 'path',
85 87
                            'name': 'id',
86 88
                            'required': True,
87 89
                            'type': 'integer'}],
@@ -90,7 +92,9 @@ SWAGGER_DOC_API = {
90 92
                'schema': {'$ref': '#/definitions/NoContentSchema'}}}},
91 93
          'get': {
92 94
              'description': 'Obtain one user',
93
-             'parameters': [{'in': 'path',
95
+             'parameters': [{'format': 'int32',
96
+                             'in': 'path',
97
+                             'minimum': 1,
94 98
                              'name': 'id',
95 99
                              'required': True,
96 100
                              'type': 'integer'}],

+ 214 - 0
tests/func/test_doc.py View File

@@ -372,3 +372,217 @@ class TestDocGeneration(Base):
372 372
             'items': {'$ref': '#/definitions/{}'.format(schema_name)},
373 373
             'type': 'array'
374 374
         }
375
+
376
+    def test_func_schema_in_doc__ok__additionals_fields__query__string(self):
377
+        hapic = Hapic()
378
+        # TODO BS 20171113: Make this test non-bottle
379
+        app = bottle.Bottle()
380
+        hapic.set_context(MyContext(app=app))
381
+
382
+        class MySchema(marshmallow.Schema):
383
+            category = marshmallow.fields.String(
384
+                required=True,
385
+                description='a description',
386
+                example='00010',
387
+                format='binary',
388
+                enum=['01000', '11111'],
389
+                maxLength=5,
390
+                minLength=5,
391
+                # Theses none string specific parameters should disappear
392
+                # in query/path
393
+                maximum=400,
394
+                # exclusiveMaximun=False,
395
+                # minimum=0,
396
+                # exclusiveMinimum=True,
397
+                # multipleOf=1,
398
+            )
399
+
400
+        @hapic.with_api_doc()
401
+        @hapic.input_query(MySchema())
402
+        def my_controller():
403
+            return
404
+
405
+        app.route('/paper', method='POST', callback=my_controller)
406
+        doc = hapic.generate_doc()
407
+        assert doc.get('paths').get('/paper').get('post').get('parameters')[0]
408
+        field = doc.get('paths').get('/paper').get('post').get('parameters')[0]
409
+        assert field['description'] == 'a description\n\n*example value: 00010*'
410
+        # INFO - G.M - 01-06-2018 - Field example not allowed here,
411
+        # added in description instead
412
+        assert 'example' not in field
413
+        assert field['format'] == 'binary'
414
+        assert field['in'] == 'query'
415
+        assert field['type'] == 'string'
416
+        assert field['maxLength'] == 5
417
+        assert field['minLength'] == 5
418
+        assert field['required'] == True
419
+        assert field['enum'] == ['01000', '11111']
420
+        assert 'maximum' not in field
421
+
422
+    def test_func_schema_in_doc__ok__additionals_fields__path__string(self):
423
+        hapic = Hapic()
424
+        # TODO BS 20171113: Make this test non-bottle
425
+        app = bottle.Bottle()
426
+        hapic.set_context(MyContext(app=app))
427
+
428
+        class MySchema(marshmallow.Schema):
429
+            category = marshmallow.fields.String(
430
+                required=True,
431
+                description='a description',
432
+                example='00010',
433
+                format='binary',
434
+                enum=['01000', '11111'],
435
+                maxLength=5,
436
+                minLength=5,
437
+                # Theses none string specific parameters should disappear
438
+                # in query/path
439
+                maximum=400,
440
+                # exclusiveMaximun=False,
441
+                # minimum=0,
442
+                # exclusiveMinimum=True,
443
+                # multipleOf=1,
444
+            )
445
+
446
+        @hapic.with_api_doc()
447
+        @hapic.input_path(MySchema())
448
+        def my_controller():
449
+            return
450
+
451
+        app.route('/paper', method='POST', callback=my_controller)
452
+        doc = hapic.generate_doc()
453
+        assert doc.get('paths').get('/paper').get('post').get('parameters')[0]
454
+        field = doc.get('paths').get('/paper').get('post').get('parameters')[0]
455
+        assert field['description'] == 'a description\n\n*example value: 00010*'
456
+        # INFO - G.M - 01-06-2018 - Field example not allowed here,
457
+        # added in description instead
458
+        assert 'example' not in field
459
+        assert field['format'] == 'binary'
460
+        assert field['in'] == 'path'
461
+        assert field['type'] == 'string'
462
+        assert field['maxLength'] == 5
463
+        assert field['minLength'] == 5
464
+        assert field['required'] == True
465
+        assert field['enum'] == ['01000', '11111']
466
+        assert 'maximum' not in field
467
+
468
+    def test_func_schema_in_doc__ok__additionals_fields__path__number(self):
469
+        hapic = Hapic()
470
+        # TODO BS 20171113: Make this test non-bottle
471
+        app = bottle.Bottle()
472
+        hapic.set_context(MyContext(app=app))
473
+
474
+        class MySchema(marshmallow.Schema):
475
+            category = marshmallow.fields.Integer(
476
+                required=True,
477
+                description='a number',
478
+                example='12',
479
+                format='int64',
480
+                enum=[4, 6],
481
+                # Theses none string specific parameters should disappear
482
+                # in query/path
483
+                maximum=14,
484
+                exclusiveMaximun=False,
485
+                minimum=0,
486
+                exclusiveMinimum=True,
487
+                multipleOf=2,
488
+            )
489
+
490
+        @hapic.with_api_doc()
491
+        @hapic.input_path(MySchema())
492
+        def my_controller():
493
+            return
494
+
495
+        app.route('/paper', method='POST', callback=my_controller)
496
+        doc = hapic.generate_doc()
497
+        assert doc.get('paths').get('/paper').get('post').get('parameters')[0]
498
+        field = doc.get('paths').get('/paper').get('post').get('parameters')[0]
499
+        assert field['description'] == 'a number\n\n*example value: 12*'
500
+        # INFO - G.M - 01-06-2018 - Field example not allowed here,
501
+        # added in description instead
502
+        assert 'example' not in field
503
+        assert field['format'] == 'int64'
504
+        assert field['in'] == 'path'
505
+        assert field['type'] == 'integer'
506
+        assert field['maximum'] == 14
507
+        assert field['minimum'] == 0
508
+        assert field['exclusiveMinimum'] == True
509
+        assert field['required'] == True
510
+        assert field['enum'] == [4, 6]
511
+        assert field['multipleOf'] == 2
512
+
513
+    def test_func_schema_in_doc__ok__additionals_fields__body__number(self):
514
+        hapic = Hapic()
515
+        # TODO BS 20171113: Make this test non-bottle
516
+        app = bottle.Bottle()
517
+        hapic.set_context(MyContext(app=app))
518
+
519
+        class MySchema(marshmallow.Schema):
520
+            category = marshmallow.fields.Integer(
521
+                required=True,
522
+                description='a number',
523
+                example='12',
524
+                format='int64',
525
+                enum=[4, 6],
526
+                # Theses none string specific parameters should disappear
527
+                # in query/path
528
+                maximum=14,
529
+                exclusiveMaximun=False,
530
+                minimum=0,
531
+                exclusiveMinimum=True,
532
+                multipleOf=2,
533
+            )
534
+
535
+        @hapic.with_api_doc()
536
+        @hapic.input_body(MySchema())
537
+        def my_controller():
538
+            return
539
+
540
+        app.route('/paper', method='POST', callback=my_controller)
541
+        doc = hapic.generate_doc()
542
+
543
+        schema_field = doc.get('definitions', {}).get('MySchema', {}).get('properties', {}).get('category', {})  # nopep8
544
+        assert schema_field
545
+        assert schema_field['description'] == 'a number'
546
+        assert schema_field['example'] == '12'
547
+        assert schema_field['format'] == 'int64'
548
+        assert schema_field['type'] == 'integer'
549
+        assert schema_field['maximum'] == 14
550
+        assert schema_field['minimum'] == 0
551
+        assert schema_field['exclusiveMinimum'] == True
552
+        assert schema_field['enum'] == [4, 6]
553
+        assert schema_field['multipleOf'] == 2
554
+
555
+    def test_func_schema_in_doc__ok__additionals_fields__body__string(self):
556
+        hapic = Hapic()
557
+        # TODO BS 20171113: Make this test non-bottle
558
+        app = bottle.Bottle()
559
+        hapic.set_context(MyContext(app=app))
560
+
561
+        class MySchema(marshmallow.Schema):
562
+            category = marshmallow.fields.String(
563
+                required=True,
564
+                description='a description',
565
+                example='00010',
566
+                format='binary',
567
+                enum=['01000', '11111'],
568
+                maxLength=5,
569
+                minLength=5,
570
+            )
571
+
572
+        @hapic.with_api_doc()
573
+        @hapic.input_body(MySchema())
574
+        def my_controller():
575
+            return
576
+
577
+        app.route('/paper', method='POST', callback=my_controller)
578
+        doc = hapic.generate_doc()
579
+
580
+        schema_field = doc.get('definitions', {}).get('MySchema', {}).get('properties', {}).get('category', {})  # nopep8
581
+        assert schema_field
582
+        assert schema_field['description'] == 'a description'
583
+        assert schema_field['example'] == '00010'
584
+        assert schema_field['format'] == 'binary'
585
+        assert schema_field['type'] == 'string'
586
+        assert schema_field['maxLength'] == 5
587
+        assert schema_field['minLength'] == 5
588
+        assert schema_field['enum'] == ['01000', '11111']