diff --git a/frontend/copr-frontend.spec b/frontend/copr-frontend.spec index ed6b5a3de..0d45a65a1 100644 --- a/frontend/copr-frontend.spec +++ b/frontend/copr-frontend.spec @@ -94,6 +94,7 @@ BuildRequires: python3-flask-openid BuildRequires: python3-flask-sqlalchemy BuildRequires: python3-flask-whooshee BuildRequires: python3-flask-wtf +BuildRequires: python3-flask-restx BuildRequires: python3-gobject BuildRequires: python3-html2text BuildRequires: python3-html5-parser @@ -152,6 +153,7 @@ Requires: python3-flask-sqlalchemy Requires: python3-flask-whooshee Requires: python3-flask-wtf Requires: python3-flask-wtf +Requires: python3-flask-restx Requires: python3-gobject Requires: python3-html2text Requires: python3-html5-parser diff --git a/frontend/coprs_frontend/coprs/__init__.py b/frontend/coprs_frontend/coprs/__init__.py index 67f103e18..b0052fb9f 100644 --- a/frontend/coprs_frontend/coprs/__init__.py +++ b/frontend/coprs_frontend/coprs/__init__.py @@ -103,6 +103,22 @@ def setup_profiler(flask_app, enabled): app.request_class = get_request_class(app) +# Tell flask-restx to not append generated suggestions at +# the end of 404 error messages +app.config["ERROR_404_HELP"] = False + +# Don't display X-Fields inputs in Swagger +app.config["RESTX_MASK_SWAGGER"] = False + +# There are some models, that are not used yet. They might still be helpful +# for the users. +app.config["RESTX_INCLUDE_ALL_MODELS"] = True + +# By default flask-restx expects "message" field in non-successful requests +# but we named the field "error" instead +app.config["ERROR_INCLUDE_MESSAGE"] = False + + from coprs.views import admin_ns from coprs.views.admin_ns import admin_general from coprs.views import api_ns @@ -152,7 +168,7 @@ def setup_profiler(flask_app, enabled): NonAdminCannotDisableAutoPrunning, ) from coprs.views.explore_ns import explore_ns -from coprs.error_handlers import get_error_handler +from coprs.error_handlers import get_error_handler, RestXErrorHandler import coprs.context_processors with app.app_context(): @@ -193,6 +209,16 @@ def handle_exceptions(error): return error_handler.handle_error(error) +@apiv3_ns.api.errorhandler(Exception) +def handle_exceptions_api(error): + """ + Whenever an exception is raised within an API endpoint which is managed by + flask-restx, we end up here. + """ + handler = RestXErrorHandler() + return handler.handle_error(error) + + app.jinja_env.trim_blocks = True app.jinja_env.lstrip_blocks = True diff --git a/frontend/coprs_frontend/coprs/error_handlers.py b/frontend/coprs_frontend/coprs/error_handlers.py index a7ef938ac..2d12c68aa 100644 --- a/frontend/coprs_frontend/coprs/error_handlers.py +++ b/frontend/coprs_frontend/coprs/error_handlers.py @@ -126,7 +126,7 @@ def render(message, code): class APIErrorHandler(BaseErrorHandler): """ - Handle exceptions raised from API (v3) + Handle exceptions raised from API (v3) - routes without flask-restx """ def override_message(self, message, error): return { @@ -137,3 +137,16 @@ def override_message(self, message, error): @staticmethod def render(message, code): return flask.jsonify(error=message) + + +class RestXErrorHandler(APIErrorHandler): + """ + Handle exceptions raised from API (v3) - routes that use flask-restx + """ + @staticmethod + def render(message, code): + return {"error": message} + + def handle_error(self, error): + body, code, headers = super().handle_error(error) + return body, code, headers or {} diff --git a/frontend/coprs_frontend/coprs/templates/api.html b/frontend/coprs_frontend/coprs/templates/api.html index 09b81b8d4..baf84732a 100644 --- a/frontend/coprs_frontend/coprs/templates/api.html +++ b/frontend/coprs_frontend/coprs/templates/api.html @@ -59,12 +59,16 @@

API Token

You need to be logged in to see your API token.

{% endif %} -

The API

+

Documentation

+ + -

Api documentation is available at: - - https://python-copr.readthedocs.io/en/latest/index.html - -

{% endblock %} diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py index 2ddb54d57..d15e9f307 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py @@ -7,6 +7,7 @@ from werkzeug.datastructures import ImmutableMultiDict, MultiDict from werkzeug.exceptions import HTTPException, NotFound, GatewayTimeout from sqlalchemy.orm.attributes import InstrumentedAttribute +from flask_restx import Api, Namespace, Resource from coprs import app from coprs.exceptions import ( AccessRestricted, @@ -23,6 +24,30 @@ apiv3_ns = flask.Blueprint("apiv3_ns", __name__, url_prefix="/api_3") +# Somewhere between flask-restx 1.0.3 and 1.1.0 this change was introduced: +# > Initializing the Api object always registers the root endpoint / even if +# > the Swagger UI path is changed. If you wish to use the root endpoint / +# > for other purposes, you must register it before initializing the Api object. +# TODO That needs to get fixed and then we can move this route to apiv3_general +# See https://github.com/python-restx/flask-restx/issues/452 +@apiv3_ns.route("/") +def home(): + """ + APIv3 homepage + Return generic information about Copr API + """ + return flask.jsonify({"version": 3}) + + +api = Api( + app=apiv3_ns, + version="v3", + title="Copr API", + description="See python client - ", + doc="/docs", +) + + # HTTP methods GET = ["GET"] POST = ["POST"] diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_builds.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_builds.py index deb53e9f3..a192212a3 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_builds.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_builds.py @@ -1,15 +1,24 @@ +# All documentation is to be written on method-level because then it is +# recognized by flask-restx and rendered in Swagger +# pylint: disable=missing-class-docstring + import os import flask from sqlalchemy.orm import joinedload from werkzeug.datastructures import MultiDict from werkzeug.utils import secure_filename +from flask_restx import Namespace, Resource from copr_common.enums import StatusEnum from coprs import db, forms, models from coprs.exceptions import (BadRequest, AccessRestricted) from coprs.views.misc import api_login_required -from coprs.views.apiv3_ns import apiv3_ns, rename_fields_helper +from coprs.views.apiv3_ns import apiv3_ns, api, rename_fields_helper +from coprs.views.apiv3_ns.schema import ( + build_model, + get_build_params, +) from coprs.logic.complex_logic import ComplexLogic from coprs.logic.builds_logic import BuildsLogic @@ -28,6 +37,12 @@ from .json2form import get_form_compatible_data + + +apiv3_builds_ns = Namespace("build", description="Builds") +api.add_namespace(apiv3_builds_ns) + + def to_dict(build): return { "id": build.id, @@ -76,11 +91,24 @@ def render_build(build): return flask.jsonify(to_dict(build)) -@apiv3_ns.route("/build//", methods=GET) -@apiv3_ns.route("/build/", methods=GET) -def get_build(build_id): - build = ComplexLogic.get_build_safe(build_id) - return render_build(build) +@apiv3_builds_ns.route("/") +class GetBuild(Resource): + + @apiv3_builds_ns.doc(params=get_build_params) + @apiv3_builds_ns.marshal_with(build_model) + def get(self, build_id): + """ + Get a build + Get details for a single Copr build. + """ + build = ComplexLogic.get_build_safe(build_id) + result = to_dict(build) + + # Workaround - `marshal_with` needs the input `build_id` to be present + # in the returned dict. Don't worry, it won't get to the end user, it + # will be stripped away. + result["build_id"] = result["id"] + return result @apiv3_ns.route("/build/list/", methods=GET) diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_general.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_general.py index fa57fa44a..9c301ad22 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_general.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_general.py @@ -1,11 +1,16 @@ +# All documentation is to be written on method-level because then it is +# recognized by flask-restx and rendered in Swagger +# pylint: disable=missing-class-docstring + import os import re from fnmatch import fnmatch import flask +from flask_restx import Namespace from coprs import app, oid, db -from coprs.views.apiv3_ns import apiv3_ns +from coprs.views.apiv3_ns import apiv3_ns, api from coprs.exceptions import AccessRestricted from coprs.views.misc import api_login_required from coprs.auth import UserAuth @@ -53,9 +58,9 @@ def krb_straighten_username(krb_remote_user): return username -@apiv3_ns.route("/") -def home(): - return flask.jsonify({"version": 3}) +apiv3_general_ns = Namespace("home", path="/", + description="APIv3 general endpoints") +api.add_namespace(apiv3_general_ns) @apiv3_ns.route("/auth-check") diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_packages.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_packages.py index 1c93b135f..fbda9221b 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_packages.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_packages.py @@ -1,4 +1,9 @@ +# All documentation is to be written on method-level because then it is +# recognized by flask-restx and rendered in Swagger +# pylint: disable=missing-class-docstring + import flask +from flask_restx import Namespace, Resource from coprs.exceptions import ( BadRequest, @@ -12,7 +17,15 @@ ) from coprs.views.misc import api_login_required from coprs import db, models, forms, helpers -from coprs.views.apiv3_ns import apiv3_ns, rename_fields_helper +from coprs.views.apiv3_ns import apiv3_ns, api, rename_fields_helper +from coprs.views.apiv3_ns.schema import ( + package_model, + add_package_params, + edit_package_params, + get_package_parser, + add_package_parser, + edit_package_parser, +) from coprs.logic.packages_logic import PackagesLogic # @TODO if we need to do this on several places, we should figure a better way to do it @@ -25,6 +38,10 @@ MAX_PACKAGES_WITHOUT_PAGINATION = 10000 +apiv3_packages_ns = Namespace("package", description="Packages") +api.add_namespace(apiv3_packages_ns) + + def to_dict(package, with_latest_build=False, with_latest_succeeded_build=False): source_dict = package.source_json_dict if "srpm_build_method" in source_dict: @@ -91,19 +108,29 @@ def get_arg_to_bool(argument): return False -@apiv3_ns.route("/package", methods=GET) -@query_params() -def get_package(ownername, projectname, packagename, - with_latest_build=False, with_latest_succeeded_build=False): - with_latest_build = get_arg_to_bool(with_latest_build) - with_latest_succeeded_build = get_arg_to_bool(with_latest_succeeded_build) +@apiv3_packages_ns.route("/") +class GetPackage(Resource): + parser = get_package_parser() - copr = get_copr(ownername, projectname) - try: - package = PackagesLogic.get(copr.id, packagename)[0] - except IndexError: - raise ObjectNotFound("No package with name {name} in copr {copr}".format(name=packagename, copr=copr.name)) - return flask.jsonify(to_dict(package, with_latest_build, with_latest_succeeded_build)) + @apiv3_packages_ns.expect(parser) + @apiv3_packages_ns.marshal_with(package_model) + def get(self): + """ + Get a package + Get a single package from a Copr project. + """ + args = self.parser.parse_args() + with_latest_build = args.with_latest_build + with_latest_succeeded_build = args.with_latest_succeeded_build + + copr = get_copr(args.ownername, args.projectname) + try: + package = PackagesLogic.get(copr.id, args.packagename)[0] + except IndexError as ex: + msg = ("No package with name {name} in copr {copr}" + .format(name=args.packagename, copr=copr.name)) + raise ObjectNotFound(msg) from ex + return to_dict(package, with_latest_build, with_latest_succeeded_build) @apiv3_ns.route("/package/list", methods=GET) @@ -111,6 +138,10 @@ def get_package(ownername, projectname, packagename, @query_params() def get_package_list(ownername, projectname, with_latest_build=False, with_latest_succeeded_build=False, **kwargs): + """ + Get a list of packages + Get a list of packages from a Copr project + """ with_latest_build = get_arg_to_bool(with_latest_build) with_latest_succeeded_build = get_arg_to_bool(with_latest_succeeded_build) @@ -138,30 +169,58 @@ def get_package_list(ownername, projectname, with_latest_build=False, return flask.jsonify(items=items, meta=paginator.meta) -@apiv3_ns.route("/package/add////", methods=POST) -@api_login_required -def package_add(ownername, projectname, package_name, source_type_text): - copr = get_copr(ownername, projectname) - data = rename_fields(get_form_compatible_data(preserve=["python_versions"])) - process_package_add_or_edit(copr, source_type_text, data=data) - package = PackagesLogic.get(copr.id, package_name).first() - return flask.jsonify(to_dict(package)) - - -@apiv3_ns.route("/package/edit////", methods=PUT) -@api_login_required -def package_edit(ownername, projectname, package_name, source_type_text=None): - copr = get_copr(ownername, projectname) - data = rename_fields(get_form_compatible_data(preserve=["python_versions"])) - try: - package = PackagesLogic.get(copr.id, package_name)[0] - source_type_text = source_type_text or package.source_type_text - except IndexError: - raise ObjectNotFound("Package {name} does not exists in copr {copr}." - .format(name=package_name, copr=copr.full_name)) - - process_package_add_or_edit(copr, source_type_text, package=package, data=data) - return flask.jsonify(to_dict(package)) +@apiv3_packages_ns.route("/add////") +class PackageAdd(Resource): + parser = add_package_parser() + + @api_login_required + @apiv3_packages_ns.doc(params=add_package_params) + @apiv3_packages_ns.expect(parser) + @apiv3_packages_ns.marshal_with(package_model) + def post(self, ownername, projectname, package_name, source_type_text): + """ + Create a package + Create a new package inside a specified Copr project. + + See what fields are required for which source types: + https://python-copr.readthedocs.io/en/latest/client_v3/package_source_types.html + """ + copr = get_copr(ownername, projectname) + data = rename_fields(get_form_compatible_data(preserve=["python_versions"])) + process_package_add_or_edit(copr, source_type_text, data=data) + package = PackagesLogic.get(copr.id, package_name).first() + return to_dict(package) + + +@apiv3_packages_ns.route("/edit////") +@apiv3_packages_ns.route("/edit////") +class PackageEdit(Resource): + parser = edit_package_parser() + + @api_login_required + @apiv3_packages_ns.doc(params=edit_package_params) + @apiv3_packages_ns.expect(parser) + @apiv3_packages_ns.marshal_with(package_model) + def post(self, ownername, projectname, package_name, source_type_text=None): + """ + Edit a package + Edit an existing package within a Copr project. + + See what fields are required for which source types: + https://python-copr.readthedocs.io/en/latest/client_v3/package_source_types.html + """ + copr = get_copr(ownername, projectname) + data = rename_fields(get_form_compatible_data(preserve=["python_versions"])) + try: + package = PackagesLogic.get(copr.id, package_name)[0] + source_type_text = source_type_text or package.source_type_text + except IndexError as ex: + msg = ("Package {name} does not exists in copr {copr}." + .format(name=package_name, copr=copr.full_name)) + raise ObjectNotFound(msg) from ex + + process_package_add_or_edit(copr, source_type_text, package=package, data=data) + return to_dict(package) @apiv3_ns.route("/package/reset", methods=PUT) diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_project_chroots.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_project_chroots.py index d6a6f1c68..0fe3f9ab1 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_project_chroots.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_project_chroots.py @@ -1,15 +1,23 @@ +# All documentation is to be written on method-level because then it is +# recognized by flask-restx and rendered in Swagger +# pylint: disable=missing-class-docstring + import flask +from flask_restx import Namespace, Resource from coprs.views.misc import api_login_required -from coprs.views.apiv3_ns import apiv3_ns, rename_fields_helper +from coprs.views.apiv3_ns import apiv3_ns, api, rename_fields_helper +from coprs.views.apiv3_ns.schema import ( + project_chroot_model, + project_chroot_build_config_model, + project_chroot_parser, +) from coprs.logic.complex_logic import ComplexLogic, BuildConfigLogic from coprs.exceptions import ObjectNotFound, InvalidForm from coprs import db, forms from coprs.logic.coprs_logic import CoprChrootsLogic from . import ( - query_params, get_copr, file_upload, - GET, PUT, str_to_list, reset_to_defaults, @@ -17,6 +25,11 @@ from .json2form import get_form_compatible_data +apiv3_project_chroots_ns = \ + Namespace("project-chroot", description="Project chroots") +api.add_namespace(apiv3_project_chroots_ns) + + def to_dict(project_chroot): return { "mock_chroot": project_chroot.mock_chroot.name, @@ -40,7 +53,7 @@ def to_build_config_dict(project_chroot): "repos": config["repos"], "additional_repos": BuildConfigLogic.generate_additional_repos(project_chroot), "additional_packages": (project_chroot.buildroot_pkgs or "").split(), - "additional_modules": project_chroot.module_toggle, + "additional_modules": str_to_list(project_chroot.module_toggle), "enable_net": project_chroot.copr.enable_net, "with_opts": str_to_list(project_chroot.with_opts), "without_opts": str_to_list(project_chroot.without_opts), @@ -59,23 +72,41 @@ def rename_fields(input_dict): "additional_modules": "module_toggle", }) -@apiv3_ns.route("/project-chroot", methods=GET) -@query_params() -def get_project_chroot(ownername, projectname, chrootname): - copr = get_copr(ownername, projectname) - chroot = ComplexLogic.get_copr_chroot_safe(copr, chrootname) - return flask.jsonify(to_dict(chroot)) +@apiv3_project_chroots_ns.route("/") +class ProjectChroot(Resource): + parser = project_chroot_parser() -@apiv3_ns.route("/project-chroot/build-config", methods=GET) -@query_params() -def get_build_config(ownername, projectname, chrootname): - copr = get_copr(ownername, projectname) - chroot = ComplexLogic.get_copr_chroot_safe(copr, chrootname) - if not chroot: - raise ObjectNotFound('Chroot not found.') - config = to_build_config_dict(chroot) - return flask.jsonify(config) + @apiv3_project_chroots_ns.expect(parser) + @apiv3_project_chroots_ns.marshal_with(project_chroot_model) + def get(self): + """ + Get a project chroot + Get settings for a single project chroot. + """ + args = self.parser.parse_args() + copr = get_copr(args.ownername, args.projectname) + chroot = ComplexLogic.get_copr_chroot_safe(copr, args.chrootname) + return to_dict(chroot) + + +@apiv3_project_chroots_ns.route("/build-config") +class BuildConfig(Resource): + parser = project_chroot_parser() + + @apiv3_project_chroots_ns.expect(parser) + @apiv3_project_chroots_ns.marshal_with(project_chroot_build_config_model) + def get(self): + """ + Get a build config + Generate a build config based on a project chroot settings. + """ + args = self.parser.parse_args() + copr = get_copr(args.ownername, args.projectname) + chroot = ComplexLogic.get_copr_chroot_safe(copr, args.chrootname) + if not chroot: + raise ObjectNotFound('Chroot not found.') + return to_build_config_dict(chroot) @apiv3_ns.route("/project-chroot/edit///", methods=PUT) diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/schema.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema.py new file mode 100644 index 000000000..58375a5b7 --- /dev/null +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema.py @@ -0,0 +1,548 @@ +""" +Sometime in the future, we can maybe drop this whole file and generate schemas +from SQLAlchemy models: +https://github.com/python-restx/flask-restx/pull/493/files + +Things used for the output: + +- *_schema - describes our output schemas +- *_field - a schema is a dict of named fields +- *_model - basically a pair schema and its name + + +Things used for parsing the input: + +- *_parser - for documenting query parameters in URL and + parsing POST values in input JSON +- *_arg - a parser is composed from arguments +- *_params - for documenting path parameters in URL because parser + can't be properly used for them [1] + +[1] https://github.com/noirbizarre/flask-restplus/issues/146#issuecomment-212968591 +""" + + +from flask_restx.reqparse import Argument, RequestParser +from flask_restx.fields import String, List, Integer, Boolean, Nested, Url, Raw +from flask_restx.inputs import boolean +from coprs.views.apiv3_ns import api + + +id_field = Integer( + description="Numeric ID", + example=123, +) + +mock_chroot_field = String( + description="Mock chroot", + example="fedora-rawhide-x86_64", +) + +ownername_field = String( + description="User or group name", + example="@copr", +) + +projectname_field = String( + description="Name of the project", + example="copr-dev", +) + +project_dirname_field = String( + description="", + example="copr-dev:pr:123", +) + +packagename_field = String( + description="Name of the package", + example="copr-cli", +) + +comps_name_field = String( + description="Name of the comps.xml file", +) + +additional_repos_field = List( + String, + description="Additional repos to be used for builds in this chroot", +) + +additional_packages_field = List( + String, + description="Additional packages to be always present in minimal buildroot", +) + +additional_modules_field = List( + String, + description=("List of modules that will be enabled " + "or disabled in the given chroot"), + example=["module1:stream", "!module2:stream"], +) + +with_opts_field = List( + String, + description="Mock --with option", +) + +without_opts_field = List( + String, + description="Mock --without option", +) + +delete_after_days_field = Integer( + description="The project will be automatically deleted after this many days", + example=30, +) + +isolation_field = String( + description=("Mock isolation feature setup. Possible values " + "are 'default', 'simple', 'nspawn'."), + example="nspawn", +) + +enable_net_field = Boolean( + description="Enable internet access during builds", +) + +source_type_field = String( + description=("See https://python-copr.readthedocs.io" + "/en/latest/client_v3/package_source_types.html"), + example="scm", +) + +scm_type_field = String( + default="Possible values are 'git', 'svn'", + example="git", +) + +source_build_method_field = String( + description="https://docs.pagure.org/copr.copr/user_documentation.html#scm", + example="tito", +) + +pypi_package_name_field = String( + description="Package name in the Python Package Index.", + example="copr", +) + +pypi_package_version_field = String( + description="PyPI package version", + example="1.128pre", +) + +# TODO We are copy-pasting descriptions from web UI to this file. This field +# is an ideal candidate for figuring out how to share the descriptions +pypi_spec_generator_field = String( + description=("Tool for generating specfile from a PyPI package. " + "The options are full-featured pyp2rpm with cross " + "distribution support, and pyp2spec that is being actively " + "developed and considered to be the future."), + example="pyp2spec", +) + +pypi_spec_template_field = String( + description=("Name of the spec template. " + "This option is limited to pyp2rpm spec generator."), + example="default", +) + +pypi_versions_field = List( + String, # We currently return string but should this be number? + description=("For what python versions to build. " + "This option is limited to pyp2rpm spec generator."), + example=["3", "2"], +) + +auto_rebuild_field = Boolean( + description="Auto-rebuild the package? (i.e. every commit or new tag)", +) + +clone_url_field = String( + description="URL to your Git or SVN repository", + example="https://github.com/fedora-copr/copr.git", +) + +committish_field = String( + description="Specific branch, tag, or commit that you want to build", + example="main", +) + +subdirectory_field = String( + description="Subdirectory where source files and .spec are located", + example="cli", +) + +spec_field = String( + description="Path to your .spec file under the specified subdirectory", + example="copr-cli.spec", +) + +chroots_field = List( + String, + description="List of chroot names", + example=["fedora-37-x86_64", "fedora-rawhide-x86_64"], +) + +submitted_on_field = Integer( + description="Timestamp when the build was submitted", + example=1677695304, +) + +started_on_field = Integer( + description="Timestamp when the build started", + example=1677695545, +) + +ended_on_field = Integer( + description="Timestamp when the build ended", + example=1677695963, +) + +is_background_field = Boolean( + description="The build is marked as a background job", +) + +submitter_field = String( + description="Username of the person who submitted this build", + example="frostyx", +) + +state_field = String( + description="", + example="succeeded", +) + +repo_url_field = Url( + description="See REPO OPTIONS in `man 5 dnf.conf`", + example="https://download.copr.fedorainfracloud.org/results/@copr/copr-dev/fedora-$releasever-$basearch/", +) + +max_builds_field = Integer( + description=("Keep only the specified number of the newest-by-id builds " + "(garbage collector is run daily)"), + example=10, +) + +source_package_url_field = String( + description="URL for downloading the SRPM package" +) + +source_package_version_field = String( + description="Package version", + example="1.105-1.git.53.319c6de", +) + +gem_name_field = String( + description="Gem name from RubyGems.org", + example="hello", +) + +custom_script_field = String( + description="Script code to produce a SRPM package", + example="#! /bin/sh -x", +) + +custom_builddeps_field = String( + description="URL to additional yum repos, which can be used during build.", + example="copr://@copr/copr", +) + +custom_resultdir_field = String( + description="Directory where SCRIPT generates sources", + example="./_build", +) + +custom_chroot_field = String( + description="What chroot to run the script in", + example="fedora-latest-x86_64", +) + +limit_field = Integer( + description="Limit", + example=20, +) + +offset_field = Integer( + description="Offset", + example=0, +) + +order_field = String( + description="Order by", + example="id", +) + +order_type_field = String( + description="Order type", + example="DESC", +) + +pagination_schema = { + "limit_field": limit_field, + "offset_field": offset_field, + "order_field": order_field, + "order_type_field": order_type_field, +} + +pagination_model = api.model("Pagination", pagination_schema) + +project_chroot_schema = { + "mock_chroot": mock_chroot_field, + "ownername": ownername_field, + "projectname": projectname_field, + "comps_name": comps_name_field, + "additional_repos": additional_repos_field, + "additional_packages": additional_packages_field, + "additional_modules": additional_modules_field, + "with_opts": with_opts_field, + "without_opts": without_opts_field, + "delete_after_days": delete_after_days_field, + "isolation": isolation_field, +} + +project_chroot_model = api.model("ProjectChroot", project_chroot_schema) + +repo_schema = { + "baseurl": String, + "id": String(example="copr_base"), + "name": String(example="Copr repository"), +} + +repo_model = api.model("Repo", repo_schema) + +project_chroot_build_config_schema = { + "chroot": mock_chroot_field, + "repos": List(Nested(repo_model)), + "additional_repos": additional_repos_field, + "additional_packages": additional_packages_field, + "additional_modules": additional_modules_field, + "enable_net": enable_net_field, + "with_opts": with_opts_field, + "without_opts": without_opts_field, + "isolation": isolation_field, +} + +project_chroot_build_config_model = \ + api.model("ProjectChrootBuildConfig", project_chroot_build_config_schema) + +source_dict_scm_schema = { + "clone_url": clone_url_field, + "committish": committish_field, + "source_build_method": source_build_method_field, + "spec": spec_field, + "subdirectory": subdirectory_field, + "type": scm_type_field, +} + +source_dict_scm_model = api.model("SourceDictSCM", source_dict_scm_schema) + +source_dict_pypi_schema = { + "pypi_package_name": pypi_package_name_field, + "pypi_package_version": pypi_package_version_field, + "spec_generator": pypi_spec_generator_field, + "spec_template": pypi_spec_template_field, + "python_versions": pypi_versions_field, +} + +source_dict_pypi_model = api.model("SourceDictPyPI", source_dict_pypi_schema) + +source_package_schema = { + "name": packagename_field, + "url": source_package_url_field, + "version": source_package_version_field, +} + +source_package_model = api.model("SourcePackage", source_package_schema) + +build_schema = { + "chroots": chroots_field, + "ended_on": ended_on_field, + "id": id_field, + "is_background": is_background_field, + "ownername": ownername_field, + "project_dirname": project_dirname_field, + "projectname": projectname_field, + "repo_url": repo_url_field, + "source_package": Nested(source_package_model), + "started_on": started_on_field, + "state": state_field, + "submitted_on": submitted_on_field, + "submitter": submitter_field, +} + +build_model = api.model("Build", build_schema) + +package_builds_schema = { + "latest": Nested(build_model, allow_null=True), + "latest_succeeded": Nested(build_model, allow_null=True), +} + +package_builds_model = api.model("PackageBuilds", package_builds_schema) + +# TODO We use this schema for both GetPackage and PackageEdit. The `builds` +# field is returned for both but only in case of GetPackage it can contain +# results. How should we document this? +package_schema = { + "id": id_field, + "name": packagename_field, + "projectname": projectname_field, + "ownername": ownername_field, + "source_type": source_type_field, + # TODO Somehow a Polymorh should be used here for `source_dict_scm_model`, + # `source_dict_pypi_model`, etc. I don't know how, so leaving an + # undocumented value for the time being. + "source_dict": Raw, + "auto_rebuild": auto_rebuild_field, + "builds": Nested(package_builds_model), +} + +package_model = api.model("Package", package_schema) + + +def clone(field): + """ + Return a copy of a field + """ + kwargs = field.__dict__.copy() + return field.__class__(**kwargs) + + +add_package_params = { + "ownername": ownername_field.description, + "projectname": projectname_field.description, + "package_name": packagename_field.description, + "source_type_text": source_type_field.description, +} + +edit_package_params = { + **add_package_params, + "source_type_text": source_type_field.description, +} + +get_build_params = { + "build_id": id_field.description, +} + +def to_arg_type(field): + """ + Take a field on the input, find out its type and convert it to a type that + can be used with `RequestParser`. + """ + types = { + Integer: int, + String: str, + Boolean: boolean, + List: list, + } + for key, value in types.items(): + if isinstance(field, key): + return value + raise RuntimeError("Unknown field type: {0}" + .format(field.__class__.__name__)) + + +def field2arg(name, field, **kwargs): + """ + Take a field on the input and create an `Argument` for `RequestParser` + based on it. + """ + return Argument( + name, + type=to_arg_type(field), + help=field.description, + **kwargs, + ) + + +def merge_parsers(a, b): + """ + Take two `RequestParser` instances and create a new one, combining all of + their arguments. + """ + parser = RequestParser() + for arg in a.args + b.args: + parser.add_argument(arg) + return parser + + +def get_package_parser(): + # pylint: disable=missing-function-docstring + parser = RequestParser() + parser.add_argument(field2arg("ownername", ownername_field, required=True)) + parser.add_argument(field2arg("projectname", projectname_field, required=True)) + parser.add_argument(field2arg("packagename", packagename_field, required=True)) + + parser.add_argument( + "with_latest_build", type=boolean, required=False, default=False, + help=( + "The result will contain 'builds' dictionary with the latest " + "submitted build of this particular package within the project")) + + parser.add_argument( + "with_latest_succeeded_build", type=boolean, required=False, default=False, + help=( + "The result will contain 'builds' dictionary with the latest " + "successful build of this particular package within the project.")) + + return parser + + +def add_package_parser(): + # pylint: disable=missing-function-docstring + args = [ + # SCM + field2arg("clone_url", clone_url_field), + field2arg("committish", committish_field), + field2arg("subdirectory", subdirectory_field), + field2arg("spec", spec_field), + field2arg("scm_type", scm_type_field), + + # Rubygems + field2arg("gem_name", gem_name_field), + + # PyPI + field2arg("pypi_package_name", pypi_package_name_field), + field2arg("pypi_package_version", pypi_package_version_field), + field2arg("spec_generator", pypi_spec_generator_field), + field2arg("spec_template", pypi_spec_template_field), + field2arg("python_versions", pypi_versions_field), + + # Custom + field2arg("script", custom_script_field), + field2arg("builddeps", custom_builddeps_field), + field2arg("resultdir", custom_resultdir_field), + field2arg("chroot", custom_chroot_field), + + + field2arg("packagename", packagename_field), + field2arg("source_build_method", source_build_method_field), + field2arg("max_builds", max_builds_field), + field2arg("webhook_rebuild", auto_rebuild_field), + ] + parser = RequestParser() + for arg in args: + arg.location = "json" + parser.add_argument(arg) + return parser + + +def edit_package_parser(): + # pylint: disable=missing-function-docstring + parser = add_package_parser().copy() + for arg in parser.args: + arg.required = False + return parser + + +def project_chroot_parser(): + # pylint: disable=missing-function-docstring + parser = RequestParser() + args = [ + field2arg("ownername", ownername_field), + field2arg("projectname", projectname_field), + field2arg("chrootname", mock_chroot_field), + ] + for arg in args: + arg.required = True + parser.add_argument(arg) + return parser