1
0
mirror of https://github.com/quay/quay.git synced 2026-01-26 06:21:37 +03:00
Files
quay/endpoints/api/discovery.py
Ivan Bazulic d7864ed4ee ui: Add custom TOS support (PROJQUAY-5648) (#1973)
* ui: Add custom TOS support
Enable adding of a custom TOS in the Quay footer for on-premise installations via the optional `TERMS_OF_SERVICE` config flag.
If the flag is not defined, the TOS field will not be visible on Quay rendering. Also changes the behaviour of the discovery endpoint to include custom terms of service instead of pointing to Quay.io terms of service for all installations.

* Fix formatting errors

* Fix TOS page for Quay.io deployments

* Change TOS variable name to TERMS_OF_SERVICE_URL for better clarity

* Removed reference to Quay.io from the discovery endpoint description
2023-06-19 11:44:52 -04:00

366 lines
13 KiB
Python

# TODO to extract the discovery stuff into a util at the top level and then use it both here and config_app discovery.py
"""
API discovery information.
"""
import re
import logging
import sys
from collections import OrderedDict
from flask_restful import reqparse
from app import app
from auth import scopes
from endpoints.api import (
ApiResource,
resource,
method_metadata,
nickname,
parse_args,
query_param,
)
from endpoints.decorators import anon_allowed
from util.parsing import truthy_bool
logger = logging.getLogger(__name__)
PARAM_REGEX = re.compile(r"<([^:>]+:)*([\w]+)>")
TYPE_CONVERTER = {
truthy_bool: "boolean",
str: "string",
reqparse.text_type: "string",
int: "integer",
}
PREFERRED_URL_SCHEME = app.config["PREFERRED_URL_SCHEME"]
SERVER_HOSTNAME = app.config["SERVER_HOSTNAME"]
if SERVER_HOSTNAME == "quay.io" or SERVER_HOSTNAME == "stage.quay.io":
TERMS_OF_SERVICE_URL = "https://www.openshift.com/legal/terms"
else:
TERMS_OF_SERVICE_URL = app.config["TERMS_OF_SERVICE_URL"]
CONTACT_EMAIL = app.config["MAIL_DEFAULT_SENDER"]
def fully_qualified_name(method_view_class):
return "%s.%s" % (method_view_class.__module__, method_view_class.__name__)
def swagger_route_data(include_internal=False, compact=False):
def swagger_parameter(
name, description, kind="path", param_type="string", required=True, enum=None, schema=None
):
# https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#parameterObject
parameter_info = {"name": name, "in": kind, "required": required}
if not compact:
parameter_info["description"] = description or ""
if schema:
parameter_info["schema"] = {"$ref": "#/definitions/%s" % schema}
else:
parameter_info["type"] = param_type
if enum is not None and len(list(enum)) > 0:
parameter_info["enum"] = list(enum)
return parameter_info
paths = {}
models = {}
tags = []
tags_added = set()
operationIds = set()
for rule in app.url_map.iter_rules():
endpoint_method = app.view_functions[rule.endpoint]
# Verify that we have a view class for this API method.
if not "view_class" in dir(endpoint_method):
continue
view_class = endpoint_method.view_class
# Hide the class if it is internal.
internal = method_metadata(view_class, "internal")
if not include_internal and internal:
continue
# Build the tag.
parts = fully_qualified_name(view_class).split(".")
tag_name = parts[-2]
if not tag_name in tags_added:
tags_added.add(tag_name)
tags.append(
{
"name": tag_name,
"description": (sys.modules[view_class.__module__].__doc__ or "").strip(),
}
)
# Build the Swagger data for the path.
swagger_path = PARAM_REGEX.sub(r"{\2}", rule.rule)
full_name = fully_qualified_name(view_class)
path_swagger = {"x-name": full_name, "x-path": swagger_path, "x-tag": tag_name}
if include_internal:
related_user_res = method_metadata(view_class, "related_user_resource")
if related_user_res is not None:
path_swagger["x-user-related"] = fully_qualified_name(related_user_res)
paths[swagger_path] = path_swagger
# Add any global path parameters.
param_data_map = (
view_class.__api_path_params if "__api_path_params" in dir(view_class) else {}
)
if param_data_map:
path_parameters_swagger = []
for path_parameter in param_data_map:
description = param_data_map[path_parameter].get("description")
path_parameters_swagger.append(swagger_parameter(path_parameter, description))
path_swagger["parameters"] = path_parameters_swagger
# Add the individual HTTP operations.
method_names = list(rule.methods.difference(["HEAD", "OPTIONS"]))
for method_name in method_names:
# https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#operation-object
method = getattr(view_class, method_name.lower(), None)
if method is None:
logger.debug("Unable to find method for %s in class %s", method_name, view_class)
continue
_operationId = method_metadata(method, "nickname")
if isinstance(_operationId, list):
operationId = None
for oid in _operationId:
if oid in operationIds:
continue
else:
operationId = oid
break
if operationId is None:
raise Exception("Duplicate operation Id: %s" % operationId)
else:
operationId = _operationId
operation_swagger = {
"operationId": operationId,
"parameters": [],
}
if operationId is None:
continue
if operationId in operationIds:
raise Exception("Duplicate operation Id: %s" % operationId)
operationIds.add(operationId)
if not compact:
operation_swagger.update(
{
"description": method.__doc__.strip() if method.__doc__ else "",
"tags": [tag_name],
}
)
# Mark the method as internal.
internal = method_metadata(method, "internal")
if internal is not None:
operation_swagger["x-internal"] = True
if include_internal:
requires_fresh_login = method_metadata(method, "requires_fresh_login")
if requires_fresh_login is not None:
operation_swagger["x-requires-fresh-login"] = True
# Add the path parameters.
if rule.arguments:
for path_parameter in rule.arguments:
description = param_data_map.get(path_parameter, {}).get("description")
operation_swagger["parameters"].append(
swagger_parameter(path_parameter, description)
)
# Add the query parameters.
if "__api_query_params" in dir(method):
for query_parameter_info in method.__api_query_params:
name = query_parameter_info["name"]
description = query_parameter_info["help"]
param_type = TYPE_CONVERTER[query_parameter_info["type"]]
required = query_parameter_info["required"]
operation_swagger["parameters"].append(
swagger_parameter(
name,
description,
kind="query",
param_type=param_type,
required=required,
enum=query_parameter_info["choices"],
)
)
# Add the OAuth security block.
# https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#securityRequirementObject
scope = method_metadata(method, "oauth2_scope")
if scope and not compact:
operation_swagger["security"] = [{"oauth2_implicit": [scope.scope]}]
# Add the responses block.
# https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#responsesObject
response_schema_name = method_metadata(method, "response_schema")
if not compact:
if response_schema_name:
models[response_schema_name] = view_class.schemas[response_schema_name]
models["ApiError"] = {
"type": "object",
"properties": {
"status": {
"type": "integer",
"description": "Status code of the response.",
},
"type": {
"type": "string",
"description": "Reference to the type of the error.",
},
"detail": {
"type": "string",
"description": "Details about the specific instance of the error.",
},
"title": {
"type": "string",
"description": "Unique error code to identify the type of error.",
},
"error_message": {
"type": "string",
"description": "Deprecated; alias for detail",
},
"error_type": {
"type": "string",
"description": "Deprecated; alias for detail",
},
},
"required": [
"status",
"type",
"title",
],
}
responses = {
"400": {
"description": "Bad Request",
},
"401": {
"description": "Session required",
},
"403": {
"description": "Unauthorized access",
},
"404": {
"description": "Not found",
},
}
for _, body in list(responses.items()):
body["schema"] = {"$ref": "#/definitions/ApiError"}
if method_name == "DELETE":
responses["204"] = {"description": "Deleted"}
elif method_name == "POST":
responses["201"] = {"description": "Successful creation"}
else:
responses["200"] = {"description": "Successful invocation"}
if response_schema_name:
responses["200"]["schema"] = {
"$ref": "#/definitions/%s" % response_schema_name
}
operation_swagger["responses"] = responses
# Add the request block.
request_schema_name = method_metadata(method, "request_schema")
if request_schema_name and not compact:
models[request_schema_name] = view_class.schemas[request_schema_name]
operation_swagger["parameters"].append(
swagger_parameter(
"body", "Request body contents.", kind="body", schema=request_schema_name
)
)
# Add the operation to the parent path.
if not internal or (internal and include_internal):
path_swagger[method_name.lower()] = operation_swagger
tags.sort(key=lambda t: t["name"])
paths = OrderedDict(sorted(list(paths.items()), key=lambda p: p[1]["x-tag"]))
if compact:
return {"paths": paths}
swagger_data = {
"swagger": "2.0",
"host": SERVER_HOSTNAME,
"basePath": "/",
"schemes": [PREFERRED_URL_SCHEME],
"info": {
"version": "v1",
"title": "Quay Frontend",
"description": (
"This API allows you to perform many of the operations required to work "
"with Quay repositories, users, and organizations."
),
"termsOfService": TERMS_OF_SERVICE_URL,
"contact": {"email": CONTACT_EMAIL},
},
"securityDefinitions": {
"oauth2_implicit": {
"type": "oauth2",
"flow": "implicit",
"authorizationUrl": "%s://%s/oauth/authorize"
% (PREFERRED_URL_SCHEME, SERVER_HOSTNAME),
"scopes": {
scope.scope: scope.description
for scope in list(scopes.app_scopes(app.config).values())
},
},
},
"paths": paths,
"definitions": models,
"tags": tags,
}
return swagger_data
@resource("/v1/discovery")
class DiscoveryResource(ApiResource):
"""
Ability to inspect the API for usage information and documentation.
"""
@parse_args()
@query_param("internal", "Whether to include internal APIs.", type=truthy_bool, default=False)
@nickname("discovery")
@anon_allowed
def get(self, parsed_args):
"""
List all of the API endpoints available in the swagger API format.
"""
return swagger_route_data(parsed_args["internal"])