Skip to content

Commit

Permalink
frontend: migrate monitor, modules, mock_chroots, webhooks enpoints t…
Browse files Browse the repository at this point in the history
…o restx
  • Loading branch information
nikromen committed Feb 11, 2024
1 parent 43dba77 commit 53907e4
Show file tree
Hide file tree
Showing 8 changed files with 271 additions and 120 deletions.
5 changes: 2 additions & 3 deletions frontend/coprs_frontend/coprs/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
import random
import string
import json
from os.path import normpath
import posixpath
import re
from os.path import normpath
from urllib.parse import urlparse, parse_qs, urlunparse, urlencode

import html5_parser
Expand Down Expand Up @@ -829,7 +829,6 @@ def db_column_length(column):
return getattr(column, "property").columns[0].type.length


@flask.stream_with_context
def streamed_json(stream, start_string=None, stop_string=None):
"""
Flask response generator for JSON structures (arrays only for now)
Expand Down Expand Up @@ -867,7 +866,7 @@ def _batched_stream(count=100):

def _response():
return app.response_class(
_batched_stream(),
flask.stream_with_context(_batched_stream()),
mimetype="application/json",
)

Expand Down
20 changes: 17 additions & 3 deletions frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,16 @@ def pagination_wrapper(*args, **kwargs):
return pagination_decorator


def _shared_file_upload_wrapper():
data = json.loads(flask.request.files["json"].read()) or {}
flask.request.form = ImmutableMultiDict(list(data.items()))

def file_upload():
def file_upload_decorator(f):
@wraps(f)
def file_upload_wrapper(*args, **kwargs):
if "json" in flask.request.files:
data = json.loads(flask.request.files["json"].read()) or {}
tuples = [(k, v) for k, v in data.items()]
flask.request.form = ImmutableMultiDict(tuples)
_shared_file_upload_wrapper()
return f(*args, **kwargs)
return file_upload_wrapper
return file_upload_decorator
Expand Down Expand Up @@ -506,3 +508,15 @@ def create_pagination(self, *args, **kwargs):
kwargs = _shared_pagination_wrapper(**kwargs)
return endpoint_method(self, *args, **kwargs)
return create_pagination


def restx_file_upload(endpoint_method):
"""
Allow uploading a file to a form via endpoint by using this function as an endpoint decorator.
"""
@wraps(endpoint_method)
def inner(self, *args, **kwargs):
if "json" in flask.request.files:
_shared_file_upload_wrapper()
return endpoint_method(self, *args, **kwargs)
return inner
43 changes: 31 additions & 12 deletions frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_mock_chroots.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,36 @@
import flask
# pylint: disable=missing-class-docstring


from http import HTTPStatus

from flask_restx import Namespace, Resource
from html2text import html2text
from coprs.views.apiv3_ns import apiv3_ns

from coprs.views.apiv3_ns import api
from coprs.logic.coprs_logic import MockChrootsLogic


@apiv3_ns.route("/mock-chroots/list")
def list_chroots():
chroots = MockChrootsLogic.active_names_with_comments()
response = {}
for chroot, comment in chroots:
if comment:
response[chroot] = html2text(comment).strip("\n")
else:
response[chroot] = ""
apiv3_mock_chroots_ns = Namespace("mock-chroots", description="Mock chroots")
api.add_namespace(apiv3_mock_chroots_ns)


@apiv3_mock_chroots_ns.route("/list")
class MockChroot(Resource):
# FIXME: we can't have proper model here, - one of REST API rules that flask-restx follows
# is to have keys in JSON constant, we don't do that here.
@apiv3_mock_chroots_ns.response(HTTPStatus.OK.value, "OK, Mock chroot data follows...")
def get(self):
"""
Get list of mock chroots
Get list of all currently active mock chroots with additional comment in format
`mock_chroot_name: additional_comment`.
"""
chroots = MockChrootsLogic.active_names_with_comments()
response = {}
for chroot, comment in chroots:
if comment:
response[chroot] = html2text(comment).strip("\n")
else:
response[chroot] = ""

return flask.jsonify(response)
return response
80 changes: 52 additions & 28 deletions frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_modules.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,67 @@
# pylint: disable=missing-class-docstring


from http import HTTPStatus

import flask
import sqlalchemy
from flask_restx import Namespace, Resource
from requests.exceptions import RequestException, InvalidSchema
from wtforms import ValidationError

from coprs import forms, db_session_scope
from coprs.views.apiv3_ns import apiv3_ns, get_copr, file_upload, POST
from coprs.views.misc import api_login_required
from coprs.views.apiv3_ns import api, get_copr, restx_file_upload
from coprs.views.apiv3_ns.schema.schemas import module_build_model, fullname_params, module_add_input_model
from coprs.views.misc import restx_api_login_required
from coprs.exceptions import DuplicateException, BadRequest, InvalidForm
from coprs.logic.modules_logic import ModuleProvider, ModuleBuildFacade


apiv3_module_ns = Namespace("module", description="Module")
api.add_namespace(apiv3_module_ns)


def to_dict(module):
return {
"nsv": module.nsv,
}


@apiv3_ns.route("/module/build/<ownername>/<projectname>", methods=POST)
@api_login_required
@file_upload()
def build_module(ownername, projectname):
copr = get_copr(ownername, projectname)
form = forms.get_module_build_form(meta={'csrf': False})
if not form.validate_on_submit():
raise InvalidForm(form)

facade = None
try:
mod_info = ModuleProvider.from_input(form.modulemd.data or form.scmurl.data)
facade = ModuleBuildFacade(flask.g.user, copr, mod_info.yaml,
mod_info.filename, form.distgit.data)
with db_session_scope():
module = facade.submit_build()
return flask.jsonify(to_dict(module))

except (ValidationError, RequestException, InvalidSchema, RuntimeError) as ex:
raise BadRequest(str(ex)) from ex

except sqlalchemy.exc.IntegrityError as err:
raise DuplicateException("Module {}-{}-{} already exists"
.format(facade.modulemd.get_module_name(),
facade.modulemd.get_stream_name(),
facade.modulemd.get_version())) from err
@apiv3_module_ns.route("/build/<ownername>/<projectname>")
class Module(Resource):
@restx_api_login_required
@restx_file_upload
@apiv3_module_ns.doc(params=fullname_params)
@apiv3_module_ns.expect(module_add_input_model)
@apiv3_module_ns.marshal_with(module_build_model)
@apiv3_module_ns.response(HTTPStatus.OK.value, "Module build successfully submitted")
@apiv3_module_ns.response(
HTTPStatus.BAD_REQUEST.value, HTTPStatus.BAD_REQUEST.description
)
def post(self, ownername, projectname):
"""
Create a module build
Create a module build for ownername/projectname project.
"""
copr = get_copr(ownername, projectname)
form = forms.get_module_build_form(meta={'csrf': False})
if not form.validate_on_submit():
raise InvalidForm(form)

facade = None
try:
mod_info = ModuleProvider.from_input(form.modulemd.data or form.scmurl.data)
facade = ModuleBuildFacade(flask.g.user, copr, mod_info.yaml,
mod_info.filename, form.distgit.data)
with db_session_scope():
module = facade.submit_build()
return to_dict(module)

except (ValidationError, RequestException, InvalidSchema, RuntimeError) as ex:
raise BadRequest(str(ex)) from ex

except sqlalchemy.exc.IntegrityError as err:
raise DuplicateException("Module {}-{}-{} already exists"
.format(facade.modulemd.get_module_name(),
facade.modulemd.get_stream_name(),
facade.modulemd.get_version())) from err
133 changes: 75 additions & 58 deletions frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,26 @@
/api_3/monitor routes
"""

# pylint: disable=missing-class-docstring


from http import HTTPStatus

import flask
from flask_restx import Namespace, Resource

from coprs.exceptions import BadRequest
from coprs.logic.builds_logic import BuildsMonitorLogic
from coprs.logic.coprs_logic import CoprDirsLogic
from coprs.views.apiv3_ns import (
apiv3_ns,
GET,
get_copr,
query_params,
streamed_json_array_response,
)

from coprs.views.apiv3_ns import api, get_copr, streamed_json_array_response, query_to_parameters
from coprs.views.apiv3_ns.schema.schemas import fullname_params, monitor_model
from coprs.measure import checkpoint


apiv3_monitor_ns = Namespace("monitor", description="Monitor")
api.add_namespace(apiv3_monitor_ns)


def monitor_generator(copr_dir, additional_fields):
"""
Continuosly fill-up the package_monitor() buffer.
Expand Down Expand Up @@ -48,54 +53,66 @@ def monitor_generator(copr_dir, additional_fields):
checkpoint("Last package queried")


@apiv3_ns.route("/monitor", methods=GET)
@query_params()
def package_monitor(ownername, projectname, project_dirname=None):
"""
For list of the project packages return list of JSON dictionaries informing
about status of the last chroot builds (status, build log, etc.).
"""
checkpoint("API3 monitor start")

additional_fields = flask.request.args.getlist("additional_fields[]")

copr = get_copr(ownername, projectname)

valid_additional_fields = [
"url_build_log",
"url_backend_log",
"url_build",
]

if additional_fields:
additional_fields = set(additional_fields)
bad_fields = []
for field in sorted(additional_fields):
if field not in valid_additional_fields:
bad_fields += [field]
if bad_fields:
raise BadRequest(
"Wrong additional_fields argument(s): " +
", ".join(bad_fields)
@apiv3_monitor_ns.route("")
class Monitor(Resource):
@query_to_parameters
@apiv3_monitor_ns.doc(params=fullname_params)
# marshalling not possible with streaming JSON like this, flask-restx tries
# to serialize it to JSON and fails or returns empty responses
# passing the documentation from marshalling just to response documentation
@apiv3_monitor_ns.response(
HTTPStatus.PARTIAL_CONTENT.value, HTTPStatus.PARTIAL_CONTENT.description, monitor_model
)
@apiv3_monitor_ns.response(
HTTPStatus.BAD_REQUEST.value, HTTPStatus.BAD_REQUEST.description
)
def get(self, ownername, projectname, project_dirname=None):
"""
Get info about builds
For list of the project packages return list of JSON dictionaries informing
about status of the last chroot builds (status, build log, etc.).
"""
checkpoint("API3 monitor start")

additional_fields = flask.request.args.getlist("additional_fields[]")

copr = get_copr(ownername, projectname)

valid_additional_fields = [
"url_build_log",
"url_backend_log",
"url_build",
]

if additional_fields:
additional_fields = set(additional_fields)
bad_fields = []
for field in sorted(additional_fields):
if field not in valid_additional_fields:
bad_fields += [field]
if bad_fields:
raise BadRequest(
"Wrong additional_fields argument(s): " +
", ".join(bad_fields)
)
else:
additional_fields = set()

if project_dirname:
copr_dir = CoprDirsLogic.get_by_copr(copr, project_dirname)
else:
copr_dir = copr.main_dir

# Preload those to avoid the error sqlalchemy.orm.exc.DetachedInstanceError
# http://sqlalche.me/e/13/bhk3
_ = copr_dir.copr.active_chroots
_ = copr_dir.copr.group

try:
return streamed_json_array_response(
monitor_generator(copr_dir, additional_fields),
"Project monitor request successful",
"packages",
)
else:
additional_fields = set()

if project_dirname:
copr_dir = CoprDirsLogic.get_by_copr(copr, project_dirname)
else:
copr_dir = copr.main_dir

# Preload those to avoid the error sqlalchemy.orm.exc.DetachedInstanceError
# http://sqlalche.me/e/13/bhk3
_ = copr_dir.copr.active_chroots
_ = copr_dir.copr.group

try:
return streamed_json_array_response(
monitor_generator(copr_dir, additional_fields),
"Project monitor request successful",
"packages",
)
finally:
checkpoint("Streaming prepared")
finally:
checkpoint("Streaming prepared")
Loading

0 comments on commit 53907e4

Please sign in to comment.