Skip to content

Commit

Permalink
frontend: OpenAPI first steps
Browse files Browse the repository at this point in the history
See fedora-copr#1865

This is still miles from being finished. I tried probably all
OpenAPI libraries that are available and decided to go with
flask-restx. It is already in Fedora, Packit team uses it as well, and
it is malleable enough to be comfortable used within our codebase.

I intentionally defined all data types and descriptions in `schema.py`
and in the way that everything is defined separately in its own
variable. We should IMHO try to compose things as much as possible to
avoid copy-pasting.

Remains to be done:

- Only a fraction of API endpoints are migrated under flask-restx, we
  need to finish the rest of them
- There is a potential to generate WTForms forms or SQLAlchemy models
  from the API schema or the other way around.
- We should share descriptions between API fields and web UI as much
  as possible
- Packit team uses does something like this, we should do as well
  @koji_builds_ns.response(HTTPStatus.OK, "OK, koji build group details follow")
  Error responses should be documented the same way.
  • Loading branch information
FrostyX committed Apr 28, 2023
1 parent 421d78b commit df523a1
Show file tree
Hide file tree
Showing 10 changed files with 815 additions and 74 deletions.
2 changes: 2 additions & 0 deletions frontend/copr-frontend.spec
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion frontend/coprs_frontend/coprs/__init__.py
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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

Expand Down
15 changes: 14 additions & 1 deletion frontend/coprs_frontend/coprs/error_handlers.py
Expand Up @@ -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 {
Expand All @@ -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 {}
16 changes: 10 additions & 6 deletions frontend/coprs_frontend/coprs/templates/api.html
Expand Up @@ -59,12 +59,16 @@ <h2>API Token</h2>
<p style="font-style:italic">You need to be logged in to see your API token.</p>
{% endif %}

<h2>The API</h2>
<h2>Documentation</h2>

<ul>
<li><a href="/api_3/docs">API documentation</a></li>
<li>
<a href="https://python-copr.readthedocs.io/en/latest/index.html">
Python client documentation
</a>
</li>
</ul>

<p> Api documentation is available at:
<a href="https://python-copr.readthedocs.io/en/latest/index.html">
https://python-copr.readthedocs.io/en/latest/index.html
</a>
</p>
</div>
{% endblock %}
25 changes: 25 additions & 0 deletions frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py
Expand Up @@ -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,
Expand All @@ -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 - <https://python-copr.readthedocs.io>",
doc="/docs",
)


# HTTP methods
GET = ["GET"]
POST = ["POST"]
Expand Down
40 changes: 34 additions & 6 deletions 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

Expand All @@ -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,
Expand Down Expand Up @@ -76,11 +91,24 @@ def render_build(build):
return flask.jsonify(to_dict(build))


@apiv3_ns.route("/build/<int:build_id>/", methods=GET)
@apiv3_ns.route("/build/<int:build_id>", methods=GET)
def get_build(build_id):
build = ComplexLogic.get_build_safe(build_id)
return render_build(build)
@apiv3_builds_ns.route("/<int:build_id>")
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)
Expand Down
13 changes: 9 additions & 4 deletions 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
Expand Down Expand Up @@ -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")
Expand Down

0 comments on commit df523a1

Please sign in to comment.