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