Skip to content

Commit a3d4ba7

Browse files
committed
frontend: migrate API projects namespace to flask-restx
1 parent fb58083 commit a3d4ba7

File tree

11 files changed

+1702
-876
lines changed

11 files changed

+1702
-876
lines changed

frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py

Lines changed: 168 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,11 @@
55
import inspect
66
from functools import wraps
77
from werkzeug.datastructures import ImmutableMultiDict, MultiDict
8-
from werkzeug.exceptions import HTTPException, NotFound, GatewayTimeout
98
from sqlalchemy.orm.attributes import InstrumentedAttribute
10-
from flask_restx import Api, Namespace, Resource
11-
from coprs import app
9+
from flask_restx import Api, Namespace
1210
from coprs.exceptions import (
1311
AccessRestricted,
14-
ActionInProgressException,
1512
CoprHttpException,
16-
InsufficientStorage,
17-
ObjectNotFound,
1813
BadRequest,
1914
)
2015
from coprs.logic.complex_logic import ComplexLogic
@@ -51,48 +46,68 @@ def home():
5146
# HTTP methods
5247
GET = ["GET"]
5348
POST = ["POST"]
49+
# TODO: POST != PUT nor DELETE, we should use at least use these methods according
50+
# conventions -> POST to create new element, PUT to update element, DELETE to delete
51+
# https://www.ibm.com/docs/en/urbancode-release/6.1.1?topic=reference-rest-api-conventions
52+
# fix python-copr firstly please, then put warning header to deprecated methods
5453
PUT = ["POST", "PUT"]
5554
DELETE = ["POST", "DELETE"]
5655

5756

57+
def _convert_query_params(endpoint_method, params_to_not_look_for, **kwargs):
58+
sig = inspect.signature(endpoint_method)
59+
params = list(set(sig.parameters) - params_to_not_look_for)
60+
for arg in params:
61+
if arg not in flask.request.args:
62+
# If parameter is present in the URL path, we can use its
63+
# value instead of failing that it is missing in query
64+
# parameters, e.g. let's have a view decorated with these
65+
# two routes:
66+
# @foo_ns.route("/foo/bar/<int:build>/<chroot>")
67+
# @foo_ns.route("/foo/bar") accepting ?build=X&chroot=Y
68+
# @query_params()
69+
# Then we need the following condition to get the first
70+
# route working
71+
if arg in flask.request.view_args:
72+
continue
73+
74+
# If parameter has a default value, it is not required
75+
default_parameter_value = sig.parameters[arg].default
76+
if default_parameter_value != sig.parameters[arg].empty:
77+
kwargs[arg] = default_parameter_value
78+
continue
79+
80+
raise BadRequest("Missing argument {}".format(arg))
81+
82+
kwargs[arg] = flask.request.args.get(arg)
83+
return kwargs
84+
85+
5886
def query_params():
87+
params_to_not_look_for = {"args", "kwargs"}
88+
5989
def query_params_decorator(f):
6090
@wraps(f)
6191
def query_params_wrapper(*args, **kwargs):
62-
sig = inspect.signature(f)
63-
params = [x for x in sig.parameters]
64-
params = list(set(params) - {"args", "kwargs"})
65-
for arg in params:
66-
if arg not in flask.request.args:
67-
# If parameter is present in the URL path, we can use its
68-
# value instead of failing that it is missing in query
69-
# parameters, e.g. let's have a view decorated with these
70-
# two routes:
71-
# @foo_ns.route("/foo/bar/<int:build>/<chroot>")
72-
# @foo_ns.route("/foo/bar") accepting ?build=X&chroot=Y
73-
# @query_params()
74-
# Then we need the following condition to get the first
75-
# route working
76-
if arg in flask.request.view_args:
77-
continue
78-
79-
# If parameter has a default value, it is not required
80-
if sig.parameters[arg].default == sig.parameters[arg].empty:
81-
raise BadRequest("Missing argument {}".format(arg))
82-
kwargs[arg] = flask.request.args.get(arg)
92+
kwargs = _convert_query_params(f, params_to_not_look_for, **kwargs)
8393
return f(*args, **kwargs)
8494
return query_params_wrapper
8595
return query_params_decorator
8696

8797

98+
def _shared_pagination_wrapper(**kwargs):
99+
form = PaginationForm(flask.request.args)
100+
if not form.validate():
101+
raise CoprHttpException(form.errors)
102+
kwargs.update(form.data)
103+
return kwargs
104+
105+
88106
def pagination():
89107
def pagination_decorator(f):
90108
@wraps(f)
91109
def pagination_wrapper(*args, **kwargs):
92-
form = PaginationForm(flask.request.args)
93-
if not form.validate():
94-
raise CoprHttpException(form.errors)
95-
kwargs.update(form.data)
110+
kwargs = _shared_pagination_wrapper(**kwargs)
96111
return f(*args, **kwargs)
97112
return pagination_wrapper
98113
return pagination_decorator
@@ -232,19 +247,24 @@ def get(self):
232247
return objects[self.offset : limit]
233248

234249

250+
def _check_if_user_can_edit_copr(ownername, projectname):
251+
copr = get_copr(ownername, projectname)
252+
if not flask.g.user.can_edit(copr):
253+
raise AccessRestricted(
254+
"User '{0}' can not see permissions for project '{1}' " \
255+
"(missing admin rights)".format(
256+
flask.g.user.name,
257+
'/'.join([ownername, projectname])
258+
)
259+
)
260+
return copr
261+
262+
235263
def editable_copr(f):
236264
@wraps(f)
237-
def wrapper(ownername, projectname, **kwargs):
238-
copr = get_copr(ownername, projectname)
239-
if not flask.g.user.can_edit(copr):
240-
raise AccessRestricted(
241-
"User '{0}' can not see permissions for project '{1}' "\
242-
"(missing admin rights)".format(
243-
flask.g.user.name,
244-
'/'.join([ownername, projectname])
245-
)
246-
)
247-
return f(copr, **kwargs)
265+
def wrapper(ownername, projectname):
266+
copr = _check_if_user_can_edit_copr(ownername, projectname)
267+
return f(copr)
248268
return wrapper
249269

250270

@@ -374,3 +394,109 @@ def rename_fields_helper(input_dict, replace):
374394
for value in values:
375395
output.add(new_key, value)
376396
return output
397+
398+
399+
# Flask-restx specific helpers/decorators - don't use them with regular Flask API!
400+
# TODO: delete/unify decorators for regular Flask and Flask-restx API once migration
401+
# is done
402+
403+
404+
def query_to_parameters(endpoint_method):
405+
"""
406+
Decorator passing query parameters to http method parameters
407+
408+
Returns:
409+
Endpoint that has its query parameters can be used as parameters in http method
410+
"""
411+
params_to_not_look_for = {"self", "args", "kwargs"}
412+
413+
@wraps(endpoint_method)
414+
def convert_query_parameters_of_endpoint_method(self, *args, **kwargs):
415+
kwargs = _convert_query_params(endpoint_method, params_to_not_look_for, **kwargs)
416+
return endpoint_method(self, *args, **kwargs)
417+
return convert_query_parameters_of_endpoint_method
418+
419+
420+
def deprecated_route_method(ns: Namespace, msg):
421+
"""
422+
Decorator that display a deprecation warning in headers and docs.
423+
424+
Usage:
425+
class Endpoint(Resource):
426+
...
427+
@deprecated_route_method(foo_ns, "Message e.g. what to use instead")
428+
...
429+
def get():
430+
return {"scary": "BOO!"}
431+
432+
Args:
433+
ns: flask-restx Namespace
434+
msg: Deprecation warning message.
435+
"""
436+
def decorate_endpoint_method(endpoint_method):
437+
# render deprecation in API docs
438+
ns.deprecated(endpoint_method)
439+
440+
@wraps(endpoint_method)
441+
def warn_user_in_headers(self, *args, **kwargs):
442+
custom_header = {"Warning": f"This method is deprecated: {msg}"}
443+
resp = endpoint_method(self, *args, **kwargs)
444+
if not isinstance(resp, tuple):
445+
# only resp body as dict was passed
446+
return resp, custom_header
447+
448+
for part_of_resp in resp[1:]:
449+
if isinstance(part_of_resp, dict):
450+
part_of_resp |= custom_header
451+
return resp
452+
453+
return resp + (custom_header,)
454+
455+
return warn_user_in_headers
456+
return decorate_endpoint_method
457+
458+
459+
def deprecated_route_method_type(ns: Namespace, deprecated_method_type: str, use_instead: str):
460+
"""
461+
Calls deprecated_route decorator with specific message about deprecated method.
462+
463+
Usage:
464+
class Endpoint(Resource):
465+
...
466+
@deprecated_route_method_type(foo_ns, "POST", "PUT")
467+
...
468+
def get():
469+
return {"scary": "BOO!"}
470+
471+
Args:
472+
ns: flask-restx Namespace
473+
deprecated_method_type: method enum e.g. POST
474+
use_instead: method user should use instead
475+
"""
476+
def call_deprecated_endpoint_method(endpoint_method):
477+
msg = f"Use {use_instead} method instead of {deprecated_method_type}"
478+
return deprecated_route_method(ns, msg)(endpoint_method)
479+
return call_deprecated_endpoint_method
480+
481+
482+
def restx_editable_copr(endpoint_method):
483+
"""
484+
Raises an exception if user don't have permissions for editing Copr repo.
485+
"""
486+
@wraps(endpoint_method)
487+
def editable_copr_getter(self, ownername, projectname):
488+
copr = _check_if_user_can_edit_copr(ownername, projectname)
489+
return endpoint_method(self, copr)
490+
return editable_copr_getter
491+
492+
493+
def restx_pagination(endpoint_method):
494+
"""
495+
Validates pagination arguments and converts pagination parameters from query to
496+
kwargs.
497+
"""
498+
@wraps(endpoint_method)
499+
def create_pagination(self, *args, **kwargs):
500+
kwargs = _shared_pagination_wrapper(**kwargs)
501+
return endpoint_method(self, *args, **kwargs)
502+
return create_pagination

frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_builds.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,8 @@
1515
from coprs.exceptions import (BadRequest, AccessRestricted)
1616
from coprs.views.misc import api_login_required
1717
from coprs.views.apiv3_ns import apiv3_ns, api, rename_fields_helper
18-
from coprs.views.apiv3_ns.schema import (
19-
build_model,
20-
get_build_params,
21-
)
18+
from coprs.views.apiv3_ns.schema.schemas import build_model
19+
from coprs.views.apiv3_ns.schema.docs import get_build_docs
2220
from coprs.logic.complex_logic import ComplexLogic
2321
from coprs.logic.builds_logic import BuildsLogic
2422
from coprs.logic.coprs_logic import CoprDirsLogic
@@ -38,8 +36,6 @@
3836
from .json2form import get_form_compatible_data
3937

4038

41-
42-
4339
apiv3_builds_ns = Namespace("build", description="Builds")
4440
api.add_namespace(apiv3_builds_ns)
4541

@@ -95,7 +91,7 @@ def render_build(build):
9591
@apiv3_builds_ns.route("/<int:build_id>")
9692
class GetBuild(Resource):
9793

98-
@apiv3_builds_ns.doc(params=get_build_params)
94+
@apiv3_builds_ns.doc(params=get_build_docs)
9995
@apiv3_builds_ns.marshal_with(build_model)
10096
def get(self, build_id):
10197
"""

frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_packages.py

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,16 @@
1515
UnknownSourceTypeException,
1616
InvalidForm,
1717
)
18-
from coprs.views.misc import api_login_required
18+
from coprs.views.misc import api_login_required, restx_api_login_required
1919
from coprs import db, models, forms, helpers
20-
from coprs.views.apiv3_ns import apiv3_ns, api, rename_fields_helper
21-
from coprs.views.apiv3_ns.schema import (
20+
from coprs.views.apiv3_ns import apiv3_ns, api, rename_fields_helper, query_to_parameters
21+
from coprs.views.apiv3_ns.schema.schemas import (
2222
package_model,
23-
add_package_params,
24-
edit_package_params,
25-
get_package_parser,
26-
add_package_parser,
27-
edit_package_parser,
23+
package_get_params,
24+
package_add_input_model,
25+
package_edit_input_model,
2826
)
27+
from coprs.views.apiv3_ns.schema.docs import add_package_docs, edit_package_docs
2928
from coprs.logic.packages_logic import PackagesLogic
3029

3130
# @TODO if we need to do this on several places, we should figure a better way to do it
@@ -110,25 +109,21 @@ def get_arg_to_bool(argument):
110109

111110
@apiv3_packages_ns.route("/")
112111
class GetPackage(Resource):
113-
parser = get_package_parser()
114-
115-
@apiv3_packages_ns.expect(parser)
112+
@query_to_parameters
113+
@apiv3_packages_ns.doc(params=package_get_params)
116114
@apiv3_packages_ns.marshal_with(package_model)
117-
def get(self):
115+
def get(self, ownername, projectname, packagename, with_latest_build=False,
116+
with_latest_succeeded_build=False):
118117
"""
119118
Get a package
120119
Get a single package from a Copr project.
121120
"""
122-
args = self.parser.parse_args()
123-
with_latest_build = args.with_latest_build
124-
with_latest_succeeded_build = args.with_latest_succeeded_build
125-
126-
copr = get_copr(args.ownername, args.projectname)
121+
copr = get_copr(ownername, projectname)
127122
try:
128-
package = PackagesLogic.get(copr.id, args.packagename)[0]
123+
package = PackagesLogic.get(copr.id, packagename)[0]
129124
except IndexError as ex:
130125
msg = ("No package with name {name} in copr {copr}"
131-
.format(name=args.packagename, copr=copr.name))
126+
.format(name=packagename, copr=copr.name))
132127
raise ObjectNotFound(msg) from ex
133128
return to_dict(package, with_latest_build, with_latest_succeeded_build)
134129

@@ -171,11 +166,9 @@ def get_package_list(ownername, projectname, with_latest_build=False,
171166

172167
@apiv3_packages_ns.route("/add/<ownername>/<projectname>/<package_name>/<source_type_text>")
173168
class PackageAdd(Resource):
174-
parser = add_package_parser()
175-
176-
@api_login_required
177-
@apiv3_packages_ns.doc(params=add_package_params)
178-
@apiv3_packages_ns.expect(parser)
169+
@restx_api_login_required
170+
@apiv3_packages_ns.doc(params=add_package_docs)
171+
@apiv3_packages_ns.expect(package_add_input_model)
179172
@apiv3_packages_ns.marshal_with(package_model)
180173
def post(self, ownername, projectname, package_name, source_type_text):
181174
"""
@@ -195,11 +188,9 @@ def post(self, ownername, projectname, package_name, source_type_text):
195188
@apiv3_packages_ns.route("/edit/<ownername>/<projectname>/<package_name>/")
196189
@apiv3_packages_ns.route("/edit/<ownername>/<projectname>/<package_name>/<source_type_text>")
197190
class PackageEdit(Resource):
198-
parser = edit_package_parser()
199-
200-
@api_login_required
201-
@apiv3_packages_ns.doc(params=edit_package_params)
202-
@apiv3_packages_ns.expect(parser)
191+
@restx_api_login_required
192+
@apiv3_packages_ns.doc(params=edit_package_docs)
193+
@apiv3_packages_ns.expect(package_edit_input_model)
203194
@apiv3_packages_ns.marshal_with(package_model)
204195
def post(self, ownername, projectname, package_name, source_type_text=None):
205196
"""

0 commit comments

Comments
 (0)