1 # ============LICENSE_START=======================================================
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
10 # http://www.apache.org/licenses/LICENSE-2.0
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=========================================================
19 """Code for http interface"""
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
33 _log = get_logger("http")
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"
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}
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')
64 components_get = _api.model('Component List', {'components': fields.List(fields.Nested(component_fields_get))})
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)')
70 component_post = _api.model('Component post', {'componentUrl': fields.String(required=True, description='. . . . Url to the Component Specification')})
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}
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')
91 dataformats_get = _api.model('Data Format List', {'dataFormats': fields.List(fields.Nested(dataformat_fields_get))})
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)')
97 dataformat_post = _api.model('Data Format post', {'dataFormatUrl': fields.String(required=True, description='. . . . Url to the Data Format Specification')})
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')
105 error_message = _api.model('Error message', {'message': fields.String(description='. . . . .Details about the unsuccessful API request')})
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")
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)
127 only_published = False
129 args = parser_components.parse_args()
131 mockCat = MockCatalog()
132 comps = mockCat.list_components(latest=only_latest, only_published=only_published)
134 def format_record_component(obj):
136 if type(v) == datetime:
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:])
146 return dict([(to_camel_case(k), format_value(v)) \
147 for k,v in obj.items()])
149 def add_self_url(comp):
150 comp["componentUrl"] = fields.Url("resource_component", absolute=True) \
151 .output(None, {"component_id": comp["id"]})
154 def add_status(comp):
155 # "whenRevoked" and "whenPublished" are used to get status
156 comp["status"] = util.get_status_string_camel(comp)
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
165 comps = [ add_self_url(add_status(format_record_component(comp)))
166 for comp in comps if should_keep(comp) ]
168 return { "components": comps }, 200
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)
181 http_body = request.get_json()
183 user = http_body['owner']
184 spec = http_body['spec']
186 name = spec['self']['name']
187 version = spec['self']['version']
189 raise DcaeException("(Component) Spec needs to have a 'self' section with 'name' and 'version'")
191 mockCat = MockCatalog()
192 ''' Pass False to do an add vs update '''
193 mockCat.add_component(user, spec, False)
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}
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)
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):
225 mockCat = MockCatalog()
226 comp = mockCat.get_component_by_id(component_id)
227 status = util.get_status_string(comp)
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"]})
243 except MissingEntry as e:
244 abort(code=404, message=e)
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):
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)
265 except MissingEntry as e:
266 abort(code=404, message=e)
267 except (FrozenEntry, CatalogError, DcaeException) as e:
268 abort(code=400, message=e)
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):
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))
291 mockCat = MockCatalog()
292 comp = mockCat.get_component_by_id(component_id)
293 comp_name = comp['name']
294 comp_version = comp['version']
296 mockCat.publish_component(user, comp_name, comp_version)
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)
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')
320 only_published = False
322 mockCat = MockCatalog()
323 formats = mockCat.list_formats(latest=only_latest, only_published=only_published)
325 def format_record_dataformat(obj):
328 if type(v) == datetime:
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:])
339 return dict([(to_camel_case(k), format_value(v)) \
340 for k,v in obj.items()])
342 formats = [ format_record_dataformat(format) for format in formats ]
344 def add_self_url(format):
345 format["dataFormatUrl"] = fields.Url("resource_format", absolute=True) \
346 .output(None, {"dataformat_id": format["id"]})
349 formats = [ add_self_url(format) for format in formats ]
351 def add_status(format):
352 # "whenRevoked" and "whenPublished" are used to get status
353 format["status"] = util.get_status_string_camel(format)
357 formats = [ add_status(format) for format in formats ]
359 return { "dataFormats": formats }, 200
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)
372 http_body = request.get_json()
373 user = http_body['owner']
374 spec = http_body['spec']
376 name = spec['self']['name']
377 version = spec['self']['version']
379 raise DcaeException("(Data Format) Spec needs to have a 'self' section with 'name' and 'version'")
381 mockCat = MockCatalog()
382 ''' Pass False to do an add vs update '''
383 mockCat.add_format(spec, user, False)
385 dataformat_id = mockCat.get_dataformat_id(name, version)
386 dataformatUrl = fields.Url("resource_format", absolute=True) \
387 .output(None, {"dataformat_id": dataformat_id})
389 resp = {"dataFormatUrl": dataformatUrl}
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)
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):
415 mockCat = MockCatalog()
416 format = mockCat.get_dataformat_by_id(dataformat_id)
417 status = util.get_status_string(format)
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"]})
432 except MissingEntry as e:
433 abort(code=404, message=e)
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):
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)
454 except MissingEntry as e:
455 abort(code=404, message=e)
456 except (CatalogError, FrozenEntry, DcaeException) as e:
457 abort(code=400, message=e)
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):
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))
480 mockCat = MockCatalog()
481 dataformat = mockCat.get_dataformat_by_id(dataformat_id)
482 dataformat_name = dataformat['name']
483 dataformat_version = dataformat['version']
485 mockCat.publish_format(user, dataformat_name, dataformat_version)
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)
497 def start_http_server(catalog, debug=True):
501 _app.run(host="0.0.0.0", port=8080, debug=False)