Browse Source

Merge pull request #68 from algoo/develop

Bastien Sevajol 6 years ago
parent
commit
fc6544e571
No account linked to committer's email
7 changed files with 384 additions and 18 deletions
  1. 44 0
      additionals_fields.md
  2. 1 1
      hapic/decorator.py
  3. 117 12
      hapic/doc.py
  4. 2 2
      hapic/ext/flask/context.py
  5. 0 1
      setup.py
  6. 6 2
      tests/func/fake_api/common.py
  7. 214 0
      tests/func/test_doc.py

+ 44 - 0
additionals_fields.md View File

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

+ 1 - 1
hapic/decorator.py View File

437
                 )
437
                 )
438
 
438
 
439
             error_response = self.context.get_response(
439
             error_response = self.context.get_response(
440
-                json.dumps(response_content),
440
+                json.dumps(dumped),
441
                 self.http_code,
441
                 self.http_code,
442
             )
442
             )
443
             return error_response
443
             return error_response

+ 117 - 12
hapic/doc.py View File

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

+ 2 - 2
hapic/ext/flask/context.py View File

74
         )
74
         )
75
 
75
 
76
         # Check error
76
         # Check error
77
-        dumped = self.default_error_builder.dump(error).data
77
+        dumped = self.default_error_builder.dump(error_content).data
78
         unmarshall = self.default_error_builder.load(dumped)
78
         unmarshall = self.default_error_builder.load(dumped)
79
 
79
 
80
         if unmarshall.errors:
80
         if unmarshall.errors:
85
             )
85
             )
86
         from flask import Response
86
         from flask import Response
87
         return Response(
87
         return Response(
88
-            response=json.dumps(error_content),
88
+            response=json.dumps(dumped),
89
             mimetype='application/json',
89
             mimetype='application/json',
90
             status=int(http_code),
90
             status=int(http_code),
91
         )
91
         )

+ 0 - 1
setup.py View File

46
     # the version across setup.py and the project code, see
46
     # the version across setup.py and the project code, see
47
     # https://packaging.python.org/en/latest/single_source_version.html
47
     # https://packaging.python.org/en/latest/single_source_version.html
48
     version=version,
48
     version=version,
49
-
50
     description='HTTP api input/output manager',
49
     description='HTTP api input/output manager',
51
     # long_description=long_description,
50
     # long_description=long_description,
52
     long_description='',
51
     long_description='',

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

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

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

372
             'items': {'$ref': '#/definitions/{}'.format(schema_name)},
372
             'items': {'$ref': '#/definitions/{}'.format(schema_name)},
373
             'type': 'array'
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']