Add API v2 which will return a list of modules on build submissions based on module stream expansion

This commit is contained in:
mprahl
2018-03-19 20:44:45 -04:00
parent 41814b42de
commit 5b278211e6
5 changed files with 95 additions and 48 deletions

View File

@@ -62,6 +62,7 @@ try:
version = pkg_resources.get_distribution('module-build-service').version
except pkg_resources.DistributionNotFound:
version = 'unknown'
api_version = 2
app = Flask(__name__)
app.wsgi_app = ReverseProxy(app.wsgi_app)

View File

@@ -553,17 +553,18 @@ class ModuleBuild(MBSBase):
})
return json
def extended_json(self, show_state_url=False):
def extended_json(self, show_state_url=False, api_version=1):
"""
:kwarg show_state_url: this will determine if `get_url_for` should be run to determine
what the `state_url` is. This should be set to `False` when extended_json is called from
the backend because it forces an app context to be created, which causes issues with
SQLAlchemy sessions.
:kwarg api_version: the API version to use when building the state URL
"""
json = self.json()
state_url = None
if show_state_url:
state_url = get_url_for('module_build', id=self.id)
state_url = get_url_for('module_build', api_version=api_version, id=self.id)
json.update({
'component_builds': [build.id for build in self.component_builds],
'build_context': self.build_context,
@@ -722,17 +723,18 @@ class ComponentBuild(MBSBase):
return retval
def extended_json(self, show_state_url=False):
def extended_json(self, show_state_url=False, api_version=1):
"""
:kwarg show_state_url: this will determine if `get_url_for` should be run to determine
what the `state_url` is. This should be set to `False` when extended_json is called from
the backend because it forces an app context to be created, which causes issues with
SQLAlchemy sessions.
:kwarg api_version: the API version to use when building the state URL
"""
json = self.json()
state_url = None
if show_state_url:
state_url = get_url_for('component_build', id=self.id)
state_url = get_url_for('component_build', api_version=api_version, id=self.id)
json.update({
'state_trace': [{'time': _utc_datetime_to_iso(record.state_time),
'state': record.state,

View File

@@ -38,9 +38,9 @@ from flask import request, url_for, Response
from datetime import datetime
from sqlalchemy.sql.sqltypes import Boolean as sqlalchemy_boolean
from module_build_service import log, models, Modulemd
from module_build_service import log, models, Modulemd, api_version
from module_build_service.errors import (ValidationError, UnprocessableEntity,
ProgrammingError)
ProgrammingError, NotFound)
from module_build_service import conf, db
from module_build_service.errors import (Forbidden, Conflict)
import module_build_service.messaging
@@ -359,11 +359,12 @@ def start_next_batch_build(config, module, session, builder, components=None):
config, module, session, builder, unbuilt_components_after_reuse)
def pagination_metadata(p_query, request_args):
def pagination_metadata(p_query, api_version, request_args):
"""
Returns a dictionary containing metadata about the paginated query.
This must be run as part of a Flask request.
:param p_query: flask_sqlalchemy.Pagination object
:param api_version: an int of the API version
:param request_args: a dictionary of the arguments that were part of the
Flask request
:return: a dictionary containing metadata about the paginated query
@@ -386,21 +387,21 @@ def pagination_metadata(p_query, request_args):
'prev': None,
'next': None,
'total': p_query.total,
'first': url_for(request.endpoint, page=1, per_page=p_query.per_page,
_external=True, **request_args_wo_page),
'last': url_for(request.endpoint, page=p_query.pages,
'first': url_for(request.endpoint, api_version=api_version, page=1,
per_page=p_query.per_page, _external=True, **request_args_wo_page),
'last': url_for(request.endpoint, api_version=api_version, page=p_query.pages,
per_page=p_query.per_page, _external=True,
**request_args_wo_page)
}
if p_query.has_prev:
pagination_data['prev'] = url_for(request.endpoint, page=p_query.prev_num,
per_page=p_query.per_page, _external=True,
**request_args_wo_page)
pagination_data['prev'] = url_for(request.endpoint, api_version=api_version,
page=p_query.prev_num, per_page=p_query.per_page,
_external=True, **request_args_wo_page)
if p_query.has_next:
pagination_data['next'] = url_for(request.endpoint, page=p_query.next_num,
per_page=p_query.per_page, _external=True,
**request_args_wo_page)
pagination_data['next'] = url_for(request.endpoint, api_version=api_version,
page=p_query.next_num, per_page=p_query.per_page,
_external=True, **request_args_wo_page)
return pagination_data
@@ -1105,6 +1106,7 @@ def submit_module_build(username, url, mmd, scm, optional_params=None):
validate_mmd(mmd)
mmds = generate_expanded_mmds(db.session, mmd)
modules = []
for mmd in mmds:
log.debug('Checking whether module build already exists: %s.',
@@ -1159,9 +1161,10 @@ def submit_module_build(username, url, mmd, scm, optional_params=None):
db.session.add(module)
db.session.commit()
modules.append(module)
log.info("%s submitted build of %s, stream=%s, version=%s, context=%s", username,
mmd.get_name(), mmd.get_stream(), mmd.get_version(), mmd.get_context())
return module
return modules
def scm_url_schemes(terse=False):
@@ -1798,6 +1801,21 @@ def cors_header(allow='*'):
return decorator
def validate_api_version():
"""
A decorator that validates the requested API version on a route
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
req_api_version = kwargs.get('api_version', 1)
if req_api_version > api_version or req_api_version < 1:
raise NotFound('The requested API version is not available')
return func(*args, **kwargs)
return wrapper
return decorator
def import_mmd(session, mmd):
"""
Imports new module build defined by `mmd` to MBS database using `session`.

View File

@@ -32,55 +32,55 @@ from flask import request, jsonify, url_for
from flask.views import MethodView
from builtins import str
from module_build_service import app, conf, log, models, db, version
from module_build_service import app, conf, log, models, db, version, api_version as max_api_version
from module_build_service.utils import (
pagination_metadata, filter_module_builds, filter_component_builds,
submit_module_build_from_scm, submit_module_build_from_yaml,
get_scm_url_re, cors_header)
get_scm_url_re, cors_header, validate_api_version)
from module_build_service.errors import (
ValidationError, Forbidden, NotFound, ProgrammingError)
api_v1 = {
api_routes = {
'module_builds': {
'url': '/module-build-service/1/module-builds/',
'url': '/module-build-service/<int:api_version>/module-builds/',
'options': {
'methods': ['POST'],
}
},
'module_builds_list': {
'url': '/module-build-service/1/module-builds/',
'url': '/module-build-service/<int:api_version>/module-builds/',
'options': {
'defaults': {'id': None},
'methods': ['GET'],
}
},
'module_build': {
'url': '/module-build-service/1/module-builds/<int:id>',
'url': '/module-build-service/<int:api_version>/module-builds/<int:id>',
'options': {
'methods': ['GET', 'PATCH'],
}
},
'component_builds_list': {
'url': '/module-build-service/1/component-builds/',
'url': '/module-build-service/<int:api_version>/component-builds/',
'options': {
'defaults': {'id': None},
'methods': ['GET'],
}
},
'component_build': {
'url': '/module-build-service/1/component-builds/<int:id>',
'url': '/module-build-service/<int:api_version>/component-builds/<int:id>',
'options': {
'methods': ['GET'],
}
},
'about': {
'url': '/module-build-service/1/about/',
'url': '/module-build-service/<int:api_version>/about/',
'options': {
'methods': ['GET']
}
},
'rebuild_strategies_list': {
'url': '/module-build-service/1/rebuild-strategies/',
'url': '/module-build-service/<int:api_version>/rebuild-strategies/',
'options': {
'methods': ['GET']
}
@@ -92,13 +92,14 @@ class AbstractQueryableBuildAPI(MethodView):
""" An abstract class, housing some common functionality. """
@cors_header()
def get(self, id):
@validate_api_version()
def get(self, api_version, id):
id_flag = request.args.get('id')
if id_flag:
endpoint = request.endpoint.split('s_list')[0]
raise ValidationError(
'The "id" query option is invalid. Did you mean to go to "{0}"?'.format(
url_for(endpoint, id=id_flag)))
url_for(endpoint, api_version=api_version, id=id_flag)))
verbose_flag = request.args.get('verbose', 'false').lower()
short_flag = request.args.get('short', 'false').lower()
json_func_kwargs = {}
@@ -107,14 +108,14 @@ class AbstractQueryableBuildAPI(MethodView):
if id is None:
# Lists all tracked builds
p_query = self.query_filter(request)
json_data = {
'meta': pagination_metadata(p_query, request.args)
'meta': pagination_metadata(p_query, api_version, request.args)
}
if verbose_flag == 'true' or verbose_flag == '1':
json_func_name = 'extended_json'
json_func_kwargs['show_state_url'] = True
json_func_kwargs['api_version'] = api_version
elif short_flag == 'true' or short_flag == '1':
if hasattr(p_query.items[0], 'short_json'):
json_func_name = 'short_json'
@@ -129,6 +130,7 @@ class AbstractQueryableBuildAPI(MethodView):
if verbose_flag == 'true' or verbose_flag == '1':
json_func_name = 'extended_json'
json_func_kwargs['show_state_url'] = True
json_func_kwargs['api_version'] = api_version
elif short_flag == 'true' or short_flag == '1':
if getattr(instance, 'short_json', None):
json_func_name = 'short_json'
@@ -149,8 +151,8 @@ class ModuleBuildAPI(AbstractQueryableBuildAPI):
model = models.ModuleBuild
# Additional POST and DELETE handlers for modules follow.
def post(self):
@validate_api_version()
def post(self, api_version):
if "multipart/form-data" in request.headers.get("Content-Type", ""):
handler = YAMLFileHandler(request)
else:
@@ -164,10 +166,16 @@ class ModuleBuildAPI(AbstractQueryableBuildAPI):
handler.username, conf.allowed_groups, handler.groups))
handler.validate()
module = handler.post()
return jsonify(module.extended_json(True)), 201
modules = handler.post()
if api_version == 1:
# Only show the first module build for backwards-compatibility
rv = modules[0].extended_json(True, api_version)
else:
rv = [module.extended_json(True, api_version) for module in modules]
return jsonify(rv), 201
def patch(self, id):
@validate_api_version()
def patch(self, api_version, id):
username, groups = module_build_service.auth.get_user(request)
try:
@@ -213,13 +221,14 @@ class ModuleBuildAPI(AbstractQueryableBuildAPI):
db.session.add(module)
db.session.commit()
return jsonify(module.extended_json(True)), 200
return jsonify(module.extended_json(True, api_version)), 200
class AboutAPI(MethodView):
@cors_header()
def get(self):
json = {'version': version}
@validate_api_version()
def get(self, api_version):
json = {'version': version, 'api_version': max_api_version}
config_items = ['auth_method']
for item in config_items:
config_item = getattr(conf, item)
@@ -233,7 +242,8 @@ class AboutAPI(MethodView):
class RebuildStrategies(MethodView):
@cors_header()
def get(self):
@validate_api_version()
def get(self, api_version):
items = []
# Sort the items list by name
for strategy in sorted(models.ModuleBuild.rebuild_strategies.keys()):
@@ -357,13 +367,13 @@ class YAMLFileHandler(BaseHandler):
optional_params=self.optional_params)
def register_api_v1():
""" Registers version 1 of MBS API. """
def register_api():
""" Registers the MBS API. """
module_view = ModuleBuildAPI.as_view('module_builds')
component_view = ComponentBuildAPI.as_view('component_builds')
about_view = AboutAPI.as_view('about')
rebuild_strategies_view = RebuildStrategies.as_view('rebuild_strategies')
for key, val in api_v1.items():
for key, val in api_routes.items():
if key.startswith('component_build'):
app.add_url_rule(val['url'],
endpoint=key,
@@ -388,4 +398,4 @@ def register_api_v1():
raise NotImplementedError("Unhandled api key.")
register_api_v1()
register_api()

View File

@@ -29,6 +29,7 @@ from shutil import copyfile
from os import path, mkdir
from os.path import dirname
import hashlib
import pytest
from tests import app, init_data
from module_build_service.errors import UnprocessableEntity
@@ -145,6 +146,14 @@ class TestViews:
assert data['rebuild_strategy'] == 'changed-and-after'
assert data['version'] == '2'
@pytest.mark.parametrize('api_version', [0, 99])
def test_query_builds_invalid_api_version(self, api_version):
rv = self.client.get('/module-build-service/{0}/module-builds/'.format(api_version))
data = json.loads(rv.data)
assert data['error'] == 'Not Found'
assert data['message'] == 'The requested API version is not available'
assert data['status'] == 404
def test_query_build_short(self):
rv = self.client.get('/module-build-service/1/module-builds/2?short=True')
data = json.loads(rv.data)
@@ -504,17 +513,24 @@ class TestViews:
assert data['error'] == 'Bad Request'
assert data['message'] == 'An invalid order_by or order_desc_by key was supplied'
@pytest.mark.parametrize('api_version', [1, 2])
@patch('module_build_service.auth.get_user', return_value=user)
@patch('module_build_service.scm.SCM')
def test_submit_build(self, mocked_scm, mocked_get_user):
def test_submit_build(self, mocked_scm, mocked_get_user, api_version):
FakeSCM(mocked_scm, 'testmodule', 'testmodule.yaml',
'620ec77321b2ea7b0d67d82992dda3e1d67055b4')
rv = self.client.post('/module-build-service/1/module-builds/', data=json.dumps(
post_url = '/module-build-service/{0}/module-builds/'.format(api_version)
rv = self.client.post(post_url, data=json.dumps(
{'branch': 'master', 'scmurl': 'git://pkgs.stg.fedoraproject.org/modules/'
'testmodule.git?#68931c90de214d9d13feefbd35246a81b6cb8d49'}))
data = json.loads(rv.data)
if api_version >= 2:
assert isinstance(data, list)
assert len(data) == 1
data = data[0]
assert 'component_builds' in data, data
assert data['component_builds'] == []
assert data['name'] == 'testmodule'
@@ -529,7 +545,7 @@ class TestViews:
assert data['id'] == 8
assert data['rebuild_strategy'] == 'changed-and-after'
assert data['state_name'] == 'init'
assert data['state_url'] == '/module-build-service/1/module-builds/8'
assert data['state_url'] == '/module-build-service/{0}/module-builds/8'.format(api_version)
assert len(data['state_trace']) == 1
assert data['state_trace'][0]['state'] == 0
assert data['tasks'] == {}
@@ -912,7 +928,7 @@ class TestViews:
rv = self.client.get('/module-build-service/1/about/')
data = json.loads(rv.data)
assert rv.status_code == 200
assert data == {'auth_method': 'kerberos', 'version': version}
assert data == {'auth_method': 'kerberos', 'api_version': 2, 'version': version}
def test_rebuild_strategy_api(self):
rv = self.client.get('/module-build-service/1/rebuild-strategies/')