792cd7f5fe1a632d279bef7189e72e5da1c7d478
[dcaegen2/platform.git] / mod / onboardingapi / dcae_cli / http.py
1 # ============LICENSE_START=======================================================
2 # org.onap.dcae
3 # ================================================================================
4 # Copyright (c) 2019 AT&T Intellectual Property. All rights reserved.
5 # ================================================================================
6 # Licensed under the Apache License, Version 2.0 (the "License");
7 # you may not use this file except in compliance with the License.
8 # You may obtain a copy of the License at
9 #
10 #      http://www.apache.org/licenses/LICENSE-2.0
11 #
12 # Unless required by applicable law or agreed to in writing, software
13 # distributed under the License is distributed on an "AS IS" BASIS,
14 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 # See the License for the specific language governing permissions and
16 # limitations under the License.
17 # ============LICENSE_END=========================================================
18
19 """Code for http interface"""
20
21 import json
22 from datetime import datetime
23 from flask import Flask, request
24 from flask_restplus import Api, Resource, fields, abort
25 from dcae_cli._version import __version__
26 from dcae_cli.commands import util
27 from dcae_cli.util.logger import get_logger
28 from dcae_cli.util.exc import DcaeException
29 from dcae_cli.util import config as cli_config
30 from dcae_cli.catalog.exc import MissingEntry, CatalogError, DuplicateEntry, FrozenEntry, ForbiddenRequest
31 from dcae_cli.catalog.mock.catalog import MockCatalog
32
33 _log = get_logger("http")
34
35 _app = Flask(__name__)
36 # Try to bundle as many errors together
37 # https://flask-restplus.readthedocs.io/en/stable/parsing.html#error-handling
38 _app.config['BUNDLE_ERRORS'] = True
39 _api = Api(_app, version=__version__, title="DCAE Onboarding HTTP API", description=""
40            , contact="mhwangatresearch.att.com", default_mediatype="application/json"
41            , prefix="/onboarding", doc="/onboarding", default="onboarding"
42        )
43
44 compSpecPath = cli_config.get_server_url() + cli_config.get_path_component_spec()
45 component_fields_request = _api.schema_model('Component Spec',
46                         {'properties': {'owner': {'type': 'string'},
47                                          'spec': {'type': 'object', \
48                                                   'description': 'The Component Spec schema is here -> ' + compSpecPath}
49                                        }
50                         })
51
52 component_fields_get = _api.model('component fields', {
53                         'id':            fields.String(required=True, description='. . . . ID of the component'),
54                         'name':          fields.String(required=True, description='. . . . Name of the component'),
55                         'version':       fields.String(required=True, description='. . . . Version of the component'),
56                         'owner':         fields.String(required=True, description='. . . . ID of who added the component'),
57                         'whenAdded':     fields.DateTime(required=True, dt_format='iso8601', description='. . . . When component was added to the Catalog'),
58                         'modified':      fields.DateTime(required=True, dt_format='iso8601', description='. . . . When component was last modified'),
59                         'status':        fields.String(required=True, description='. . . . Status of the component'),
60                         'description':   fields.String(required=True, description='. . . . Description of the component'),
61                         'componentType': fields.String(required=True, description='. . . . only "docker"'),
62                         'componentUrl':  fields.String(required=True, description='. . . . Url to the Component Specification')
63                         })
64 components_get = _api.model('Component List', {'components': fields.List(fields.Nested(component_fields_get))})
65
66 component_fields_by_id = _api.inherit('component fields by id', component_fields_get, {
67                         'spec': fields.Raw(required=True, description='The Component Specification (json)')
68                         })
69
70 component_post = _api.model('Component post', {'componentUrl': fields.String(required=True, description='. . . . Url to the Component Specification')})
71
72 dataformatPath = cli_config.get_server_url() + cli_config.get_path_data_format()
73 dataformat_fields_request = _api.schema_model('Data Format Spec',
74                         {'properties': {'owner': {'type': 'string'},
75                                          'spec': {'type': 'object', \
76                                                   'description': 'The Data Format Spec schema is here -> ' + dataformatPath}
77                                        }
78                         })
79
80 dataformat_fields_get = _api.model('dataformat fields', {
81                         'id':            fields.String(required=True, description='. . . . ID of the data format'),
82                         'name':          fields.String(required=True, description='. . . . Name of the data format'),
83                         'version':       fields.String(required=True, description='. . . . Version of the data format'),
84                         'owner':         fields.String(required=True, description='. . . . ID of who added the data format'),
85                         'whenAdded':     fields.DateTime(required=True, dt_format='iso8601', description='. . . . When data format was added to the Catalog'),
86                         'modified':      fields.DateTime(required=True, dt_format='iso8601', description='. . . . When data format was last modified'),
87                         'status':        fields.String(required=True, description='. . . . Status of the data format'),
88                         'description':   fields.String(required=True, description='. . . . Description of the data format'),
89                         'dataFormatUrl': fields.String(required=True, description='. . . . Url to the Data Format Specification')
90                         })
91 dataformats_get = _api.model('Data Format List', {'dataFormats': fields.List(fields.Nested(dataformat_fields_get))})
92
93 dataformat_fields_by_id = _api.inherit('dataformat fields by id', dataformat_fields_get, {
94                         'spec': fields.Raw(required=True, description='The Data Format Specification (json)')
95                         })
96
97 dataformat_post = _api.model('Data Format post', {'dataFormatUrl': fields.String(required=True, description='. . . . Url to the Data Format Specification')})
98
99
100 patch_fields = _api.model('Patch Spec', {'owner':  fields.String(required=True, description='User ID'),
101                                          'status': fields.String(required=True, enum=['published', 'revoked'], \
102                                                        description='. . . . .[published] is the only status change supported right now')
103                         } )
104
105 error_message = _api.model('Error message', {'message': fields.String(description='. . . . .Details about the unsuccessful API request')})
106
107
108 parser_components = _api.parser()
109 parser_components.add_argument("name", type=str, trim=True,
110         location="args", help="Name of component to filter for")
111 parser_components.add_argument("version", type=str, trim=True,
112         location="args", help="Version of component to filter for")
113
114 ################
115 ## Component  ##
116 ################
117 @_api.route("/components", endpoint="resource_components")
118 class Components(Resource):
119     """Component resource"""
120     @_api.doc("get_components", description="Get list of Components in the catalog")
121     @_api.marshal_with(components_get)
122     @_api.response(200, 'Success, Components retrieved')
123     @_api.response(500, 'Internal Server Error')
124     @_api.expect(parser_components)
125     def get(self):
126         only_latest = False
127         only_published = False
128
129         args = parser_components.parse_args()
130
131         mockCat = MockCatalog()
132         comps = mockCat.list_components(latest=only_latest, only_published=only_published)
133
134         def format_record_component(obj):
135             def format_value(v):
136                 if type(v) == datetime:
137                     return v.isoformat()
138                 else:
139                     return v
140             def to_camel_case(snake_str):
141                 components = snake_str.split('_')
142                 # We capitalize the first letter of each component except the first one
143                 # with the 'title' method and join them together.
144                 return components[0] + ''.join(x.title() for x in components[1:])
145
146             return dict([(to_camel_case(k), format_value(v)) \
147                             for k,v in obj.items()])
148
149         def add_self_url(comp):
150             comp["componentUrl"] = fields.Url("resource_component", absolute=True) \
151                                        .output(None, {"component_id": comp["id"]})
152             return comp
153
154         def add_status(comp):
155             # "whenRevoked" and "whenPublished" are used to get status 
156             comp["status"] = util.get_status_string_camel(comp)
157             return comp
158
159         def should_keep(comp):
160             """Takes args to be used to filter the list of components"""
161             ok_name = args["name"] == None or args["name"] == comp["name"]
162             ok_version = args["version"] == None or args["version"] == comp["version"]
163             return ok_name and ok_version
164
165         comps = [ add_self_url(add_status(format_record_component(comp)))
166                 for comp in comps if should_keep(comp) ]
167
168         return  { "components": comps }, 200
169
170
171     @_api.doc("post_component", description="Add a Component to the Catalog", body=component_fields_request)
172     @_api.marshal_with(component_post)
173     @_api.response(200, 'Success, Component added')
174     @_api.response(400, 'Bad Request', model=error_message)
175     @_api.response(409, 'Component already exists', model=error_message)
176     @_api.response(500, 'Internal Server Error')
177     @_api.expect(component_fields_request)
178     def post(self):
179         resp = None
180         try:
181             http_body = request.get_json()
182
183             user = http_body['owner']
184             spec = http_body['spec']
185             try:
186                 name    = spec['self']['name']
187                 version = spec['self']['version']
188             except Exception:
189                 raise DcaeException("(Component) Spec needs to have a 'self' section with 'name' and 'version'")
190                 
191             mockCat = MockCatalog()
192             ''' Pass False to do an add vs update '''
193             mockCat.add_component(user, spec, False)
194
195             component_id = mockCat.get_component_id(name, version)
196             componentUrl = fields.Url("resource_component", absolute=True) \
197                                .output(None, {"component_id": component_id})
198             resp = {"componentUrl": componentUrl}
199
200         except KeyError as e:
201             abort(code=400, message="Request field missing: {}".format(e))
202         except DuplicateEntry as e:
203             resp = e.message.replace("name:version", name + ":" + version)
204             # We abort flask_restplus so our error message will override "marshal_with()" in response body
205             abort(code=409, message=resp)
206         except (CatalogError, DcaeException) as e:
207             abort(code=400, message=e)
208
209         return resp, 200
210
211
212 ######################
213 ## Component by ID  ##
214 ######################
215 @_api.route("/components/<string:component_id>", endpoint="resource_component")
216 class Component(Resource):
217     @_api.doc("get_component", description="Get a Component")
218     @_api.marshal_with(component_fields_by_id)
219     @_api.response(200, 'Success, Component retrieved')
220     @_api.response(404, 'Component not found in Catalog', model=error_message)
221     @_api.response(500, 'Internal Server Error')
222     def get(self, component_id):
223         resp = None
224         try:
225             mockCat = MockCatalog()
226             comp = mockCat.get_component_by_id(component_id)
227             status = util.get_status_string(comp)
228                         
229             resp = { "id":             comp["id"]
230                     , "name":          comp['name']
231                     , "version":       comp['version']
232                     , "whenAdded":     comp['when_added'].isoformat()
233                     , "modified":      comp["modified"].isoformat()
234                     , "owner":         comp["owner"]
235                     , "description":   comp['description']
236                     , "componentType": comp['component_type']
237                     , "spec":          json.loads(comp["spec"])
238                     , "componentUrl":  fields.Url("resource_component", absolute=True)
239                                           .output(None, {"component_id": comp["id"]})
240                     , "status":        status
241                     }
242
243         except MissingEntry as e:
244             abort(code=404, message=e)
245
246         return resp, 200
247
248
249     @_api.doc("put_component", description="Replace a Component Spec in the Catalog", body=component_fields_request)
250     @_api.response(200, 'Success, Component replaced')
251     @_api.response(400, 'Bad Request', model=error_message)
252     @_api.response(404, 'Component not found in Catalog', model=error_message)
253     @_api.response(500, 'Internal Server Error')
254     @_api.expect(component_fields_request)
255     def put(self, component_id):
256         resp = None
257         try:
258             http_body = request.get_json()
259             user = http_body['owner']
260             spec = http_body['spec']
261             mockCat = MockCatalog()
262             ''' Pass True to do an update vs add '''
263             mockCat.add_component(user, spec, True)
264
265         except MissingEntry as e:
266             abort(code=404, message=e)
267         except (FrozenEntry, CatalogError, DcaeException) as e:
268             abort(code=400, message=e)
269
270         return resp, 200
271     
272
273     @_api.doc("patch_component", description="Update a Component's status in the Catalog", body=patch_fields)
274     @_api.response(200, 'Success, Component status updated')
275     @_api.response(400, 'Bad Request', model=error_message)
276     @_api.response(403, 'Forbidden Request', model=error_message)    
277     @_api.response(404, 'Component not found in Catalog', model=error_message)
278     @_api.response(500, 'Internal Server Error')
279     @_api.expect(patch_fields)
280     def patch(self, component_id):
281         resp = None
282         try:
283             http_body = request.get_json()
284             user  = http_body['owner']
285             field = http_body['status']
286             if field not in ['published', 'revoked']:
287                 raise DcaeException("Unknown status in request: '{}'".format(field))
288             if field == 'revoked':
289                 raise DcaeException("This status is not supported yet: '{}'".format(field))
290             
291             mockCat = MockCatalog()
292             comp         = mockCat.get_component_by_id(component_id)
293             comp_name    = comp['name']
294             comp_version = comp['version']
295
296             mockCat.publish_component(user, comp_name, comp_version)
297
298         except MissingEntry as e:
299             abort(code=404, message=e)
300         except ForbiddenRequest as e:
301             abort(code=403, message=e)
302         except (CatalogError, DcaeException) as e:
303             abort(code=400, message=e)
304
305         return resp, 200
306     
307     
308 ###################
309 ##  Data Format  ##
310 ###################
311 @_api.route("/dataformats", endpoint="resource_formats")
312 class DataFormats(Resource):
313     """Data Format resource"""
314     @_api.doc("get_dataformats", description="Get list of Data Formats in the catalog")
315     @_api.marshal_with(dataformats_get)
316     @_api.response(200, 'Success, Data Formats retrieved')
317     @_api.response(500, 'Internal Server Error')
318     def get(self):
319         only_latest = False
320         only_published = False
321
322         mockCat = MockCatalog()
323         formats = mockCat.list_formats(latest=only_latest, only_published=only_published)
324
325         def format_record_dataformat(obj):
326
327             def format_value(v):
328                 if type(v) == datetime:
329                     return v.isoformat()
330                 else:
331                     return v
332
333             def to_camel_case(snake_str):
334                 components = snake_str.split('_')
335                 # We capitalize the first letter of each component except the first one
336                 # with the 'title' method and join them together.
337                 return components[0] + ''.join(x.title() for x in components[1:])
338
339             return dict([(to_camel_case(k), format_value(v)) \
340                             for k,v in obj.items()])
341
342         formats = [ format_record_dataformat(format) for format in formats ]
343
344         def add_self_url(format):
345             format["dataFormatUrl"] = fields.Url("resource_format", absolute=True) \
346                                        .output(None, {"dataformat_id": format["id"]})
347             return format
348
349         formats = [ add_self_url(format) for format in formats ]
350
351         def add_status(format):
352             # "whenRevoked" and "whenPublished" are used to get status 
353             format["status"] = util.get_status_string_camel(format)
354
355             return format
356
357         formats = [ add_status(format) for format in formats ]
358
359         return  { "dataFormats": formats }, 200
360     
361     
362     @_api.doc("post_dataformat", description="Add a Data Format to the Catalog", body=dataformat_fields_request)
363     @_api.marshal_with(dataformat_post)
364     @_api.response(200, 'Success, Data Format added')
365     @_api.response(400, 'Bad Request', model=error_message)
366     @_api.response(409, 'Data Format already exists', model=error_message)
367     @_api.response(500, 'Internal Server Error')
368     @_api.expect(dataformat_fields_request)
369     def post(self):
370         resp = None
371         try:
372             http_body = request.get_json()
373             user = http_body['owner']
374             spec = http_body['spec']
375             try:
376                 name    = spec['self']['name']
377                 version = spec['self']['version']
378             except Exception:
379                 raise DcaeException("(Data Format) Spec needs to have a 'self' section with 'name' and 'version'")
380             
381             mockCat = MockCatalog()
382             ''' Pass False to do an add vs update '''
383             mockCat.add_format(spec, user, False)
384
385             dataformat_id = mockCat.get_dataformat_id(name, version)
386             dataformatUrl = fields.Url("resource_format", absolute=True) \
387                                 .output(None, {"dataformat_id": dataformat_id})
388
389             resp = {"dataFormatUrl": dataformatUrl}
390
391         except KeyError as e:
392             abort(code=400, message="Request field missing: {}".format(e))
393         except DuplicateEntry as e:
394             resp = e.message.replace("name:version", name + ":" + version)
395             abort(code=409, message=resp)
396         except (CatalogError, DcaeException) as e:
397             abort(code=400, message=e)
398             
399         return resp, 200
400
401
402 #########################
403 ##  Data Format by ID  ##
404 #########################
405 @_api.route("/dataformats/<string:dataformat_id>", endpoint="resource_format")
406 class DataFormat(Resource):
407     @_api.doc("get_dataformat", description="Get a Data Format")
408     @_api.marshal_with(dataformat_fields_by_id)
409     @_api.response(200, 'Success, Data Format retrieved')
410     @_api.response(404, 'Data Format not found in Catalog', model=error_message)
411     @_api.response(500, 'Internal Server Error')
412     def get(self, dataformat_id):
413         resp = None
414         try:
415             mockCat = MockCatalog()
416             format = mockCat.get_dataformat_by_id(dataformat_id)
417             status = util.get_status_string(format)
418
419             resp = { "id": format["id"]
420                     , "name":          format['name']
421                     , "version":       format['version']
422                     , "whenAdded":     format["when_added"].isoformat()
423                     , "modified":      format["modified"].isoformat()
424                     , "owner":         format["owner"]
425                     , "description":   format["description"]
426                     , "spec":          json.loads(format["spec"])
427                     , "dataFormatUrl": fields.Url("resource_format", absolute=True)
428                                         .output(None, {"dataformat_id": format["id"]})
429                     , "status":   status
430                     }
431
432         except MissingEntry as e:
433             abort(code=404, message=e)
434
435         return resp, 200
436     
437     
438     @_api.doc("put_dataformat", description="Replace a Data Format Spec in the Catalog", body=dataformat_fields_request)
439     @_api.response(200, 'Success, Data Format added')
440     @_api.response(400, 'Bad Request', model=error_message)
441     @_api.response(404, 'Data Format not found in Catalog', model=error_message)
442     @_api.response(500, 'Internal Server Error')
443     @_api.expect(dataformat_fields_request)
444     def put(self, dataformat_id):
445         resp = None
446         try:
447             http_body = request.get_json()
448             user = http_body['owner']
449             spec = http_body['spec']
450             mockCat = MockCatalog()
451             ''' Pass True to do an update vs add '''
452             mockCat.add_format(spec, user, True)
453
454         except MissingEntry as e:
455             abort(code=404, message=e)
456         except (CatalogError, FrozenEntry, DcaeException) as e:
457             abort(code=400, message=e)
458
459         return resp, 200
460
461
462     @_api.doc("patch_dataformat", description="Update a Data Format's status in the Catalog", body=patch_fields)
463     @_api.response(200, 'Success, Data Format status updated')
464     @_api.response(400, 'Bad Request', model=error_message)
465     @_api.response(403, 'Forbidden Request', model=error_message)
466     @_api.response(404, 'Data Format not found in Catalog', model=error_message)
467     @_api.response(500, 'Internal Server Error')
468     @_api.expect(patch_fields)
469     def patch(self, dataformat_id):
470         resp = None
471         try:
472             http_body = request.get_json()
473             user  = http_body['owner']
474             field = http_body['status']
475             if field not in ['published', 'revoked']:
476                 raise DcaeException("Unknown status in request: '{}'".format(field))
477             if field == 'revoked':
478                 raise DcaeException("This status is not supported yet: '{}'".format(field))
479             
480             mockCat = MockCatalog()
481             dataformat         = mockCat.get_dataformat_by_id(dataformat_id)
482             dataformat_name    = dataformat['name']
483             dataformat_version = dataformat['version']
484
485             mockCat.publish_format(user, dataformat_name, dataformat_version)
486
487         except MissingEntry as e:
488             abort(code=404, message=e)
489         except ForbiddenRequest as e:
490             abort(code=403, message=e)
491         except (CatalogError, DcaeException) as e:
492             abort(code=400, message=e)
493
494         return resp, 200
495
496
497 def start_http_server(catalog, debug=True):
498     if debug:
499         _app.run(debug=True)
500     else:
501         _app.run(host="0.0.0.0", port=80, debug=False)