From de1a44f85361be6d2f8ad5afad572b658137f8d1 Mon Sep 17 00:00:00 2001 From: jakedt Date: Mon, 10 Mar 2014 18:30:41 -0400 Subject: [PATCH] First attempt at using flask-restful and swagger api documentation. --- endpoints/api/__init__.py | 40 +++++++ endpoints/api/discovery.py | 66 +++++++++++ endpoints/{api.py => api/legacy.py} | 6 +- endpoints/api/repository.py | 171 ++++++++++++++++++++++++++++ requirements-nover.txt | 4 +- 5 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 endpoints/api/__init__.py create mode 100644 endpoints/api/discovery.py rename endpoints/{api.py => api/legacy.py} (99%) create mode 100644 endpoints/api/repository.py diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py new file mode 100644 index 000000000..b125fee94 --- /dev/null +++ b/endpoints/api/__init__.py @@ -0,0 +1,40 @@ +from flask import Blueprint +from calendar import timegm +from email.utils import formatdate +from functools import partial + + +api = Blueprint('api', __name__) + + +def truthy_bool(param): + return param not in {False, 'false', 'False', '0', 'FALSE', '', 'null'} + + +def format_date(date): + """ Output an RFC822 date format. """ + return formatdate(timegm(date.utctimetuple())) + + +def add_method_metadata(name, value): + def modifier(func): + if '__api_metadata' not in dir(func): + func.__metadata = {} + func.__metadata[name] = value + return func + return modifier + + +def method_metadata(func, name): + if '__api_metadata' in dir(func): + return func.__metadata.get(name, None) + return None + + +nickname = partial(add_method_metadata, 'nickname') + + +import endpoints.api.legacy + +import endpoints.api.repository +import endpoints.api.discovery \ No newline at end of file diff --git a/endpoints/api/discovery.py b/endpoints/api/discovery.py new file mode 100644 index 000000000..831a03835 --- /dev/null +++ b/endpoints/api/discovery.py @@ -0,0 +1,66 @@ +import re + +from flask.ext.restful import Api, Resource + +from endpoints.api import api, method_metadata, nickname +from endpoints.common import get_route_data +from app import app + + +discovery_api = Api(api) + +param_regex = re.compile(r'<([\w]+:)?([\w]+)>') + +def swagger_route_data(): + apis = [] + for rule in app.url_map.iter_rules(): + endpoint_method = app.view_functions[rule.endpoint] + + if 'view_class' in dir(endpoint_method): + operations = [] + + method_names = list(rule.methods.difference(['HEAD', 'OPTIONS'])) + for method_name in method_names: + method = getattr(endpoint_method.view_class, method_name.lower(), None) + + parameters = [] + for param in rule.arguments: + parameters.append({ + 'paramType': 'path', + 'name': param, + 'dataType': 'string', + 'description': 'Param description.', + 'required': True + }) + + if method is not None: + operations.append({ + 'method': method_name, + 'nickname': method_metadata(method, 'nickname'), + 'type': 'void', + 'parameters': parameters, + }) + + swagger_path = param_regex.sub(r'{\2}', rule.rule) + apis.append({ + 'path': swagger_path, + 'description': 'Resource description.', + 'operations': operations, + }) + + swagger_data = { + 'apiVersion': 'v1', + 'swaggerVersion': '1.2', + 'basePath': 'https://quay.io/', + 'apis': apis, + } + return swagger_data + + +class DiscoveryResource(Resource): + @nickname('discovery') + def get(self): + return swagger_route_data() + + +discovery_api.add_resource(DiscoveryResource, '/v1/discovery') \ No newline at end of file diff --git a/endpoints/api.py b/endpoints/api/legacy.py similarity index 99% rename from endpoints/api.py rename to endpoints/api/legacy.py index e49189370..b776330d5 100644 --- a/endpoints/api.py +++ b/endpoints/api/legacy.py @@ -11,6 +11,7 @@ from functools import wraps from collections import defaultdict from urllib import quote +from endpoints.api import api from data import model from data.plans import PLANS, get_plan from app import app @@ -42,9 +43,6 @@ build_logs = app.config['BUILDLOGS'] logger = logging.getLogger(__name__) -api = Blueprint('api', __name__) - - @api.before_request def csrf_protect(): if request.method != "GET" and request.method != "HEAD": @@ -1146,6 +1144,8 @@ def trigger_view(trigger): def build_status_view(build_obj, can_write=False): status = build_logs.get_status(build_obj.uuid) + logger.debug('Can write: %s job_config: %s', can_write, build_obj.job_config) + build_obj.job_config = None return { 'id': build_obj.uuid, 'phase': build_obj.phase, diff --git a/endpoints/api/repository.py b/endpoints/api/repository.py new file mode 100644 index 000000000..abd470259 --- /dev/null +++ b/endpoints/api/repository.py @@ -0,0 +1,171 @@ +import logging +import json + +from functools import wraps +from flask.ext.restful import Resource, Api, reqparse, abort, fields +from flask.ext.login import current_user + +from data import model +from endpoints.api import api, truthy_bool, format_date, nickname +from util.names import parse_namespace_repository +from auth.permissions import (ReadRepositoryPermission, + ModifyRepositoryPermission, + AdministerRepositoryPermission) + +logger = logging.getLogger(__name__) + +repo_api = Api(api) + + +def parse_repository_name(f): + @wraps(f) + def wrapper(repository, *args, **kwargs): + (namespace, repository) = parse_namespace_repository(repository) + return f(namespace, repository, *args, **kwargs) + return wrapper + + +class RepositoryParamResource(Resource): + method_decorators = [parse_repository_name] + + +def resource(*urls, **kwargs): + def wrapper(api_resource): + repo_api.add_resource(api_resource, *urls, **kwargs) + return api_resource + return wrapper + + +def require_repo_permission(permission_class, allow_public=False): + def wrapper(func): + @wraps(func) + def wrapped(self, namespace, repository, *args, **kwargs): + permission = permission_class(namespace, repository) + if (permission.can() or + (allow_public and + model.repository_is_public(namespace, repository))): + return func(self, namespace, repository, *args, **kwargs) + abort(403) + func.__required_permission = 'read' + return wrapped + return wrapper + + +require_repo_read = require_repo_permission(ReadRepositoryPermission, True) +require_repo_write = require_repo_permission(ModifyRepositoryPermission) +require_repo_admin = require_repo_permission(AdministerRepositoryPermission) + + +@resource('/v1/repository') +class RepositoryList(Resource): + + @nickname('createRepo') + def post(self): + pass + + @nickname('listRepos') + def get(self): + parser = reqparse.RequestParser() + parser.add_argument('page', type=int, help='Page number must be an int.') + parser.add_argument('limit', type=int, help='Limit must be an int.') + parser.add_argument('namespace', type=str) + parser.add_argument('public', type=truthy_bool, default=True) + parser.add_argument('private', type=truthy_bool, default=True) + parser.add_argument('sort', type=truthy_bool, default=False) + parser.add_argument('count', type=truthy_bool, default=False) + args = parser.parse_args() + + def repo_view(repo_obj): + return { + 'namespace': repo_obj.namespace, + 'name': repo_obj.name, + 'description': repo_obj.description, + 'is_public': repo_obj.visibility.name == 'public', + } + + username = None + if current_user.is_authenticated() and args['private']: + username = current_user.db_user().username + + response = {} + + repo_count = None + if args['count']: + repo_count = model.get_visible_repository_count(username, + include_public=args['public'], + namespace=args['namespace']) + response['count'] = repo_count + + repo_query = model.get_visible_repositories(username, limit=args['limit'], + page=args['page'], + include_public=args['public'], + sort=args['sort'], + namespace=args['namespace']) + + response['repositories'] = [repo_view(repo) for repo in repo_query] + + return response + +def image_view(image): + extended_props = image + if image.storage and image.storage.id: + extended_props = image.storage + + command = extended_props.command + return { + 'id': image.docker_image_id, + 'created': format_date(extended_props.created), + 'comment': extended_props.comment, + 'command': json.loads(command) if command else None, + 'ancestors': image.ancestors, + 'dbid': image.id, + 'size': extended_props.image_size, + } + +@resource('/v1/repository/') +class Repository(RepositoryParamResource): + @require_repo_read + @nickname('getRepo') + def get(self, namespace, repository): + logger.debug('Get repo: %s/%s' % (namespace, repository)) + + def tag_view(tag): + image = model.get_tag_image(namespace, repository, tag.name) + if not image: + return {} + + return { + 'name': tag.name, + 'image': image_view(image), + } + + organization = None + try: + organization = model.get_organization(namespace) + except model.InvalidOrganizationException: + pass + + is_public = model.repository_is_public(namespace, repository) + repo = model.get_repository(namespace, repository) + if repo: + tags = model.list_repository_tags(namespace, repository) + tag_dict = {tag.name: tag_view(tag) for tag in tags} + can_write = ModifyRepositoryPermission(namespace, repository).can() + can_admin = AdministerRepositoryPermission(namespace, repository).can() + active_builds = model.list_repository_builds(namespace, repository, 1, + include_inactive=False) + + return { + 'namespace': namespace, + 'name': repository, + 'description': repo.description, + 'tags': tag_dict, + 'can_write': can_write, + 'can_admin': can_admin, + 'is_public': is_public, + 'is_building': len(list(active_builds)) > 0, + 'is_organization': bool(organization), + 'status_token': repo.badge_token if not is_public else '' + } + + abort(404) # Not found diff --git a/requirements-nover.txt b/requirements-nover.txt index 969d1e563..8f8f6ff5d 100644 --- a/requirements-nover.txt +++ b/requirements-nover.txt @@ -23,4 +23,6 @@ redis hiredis git+https://github.com/dotcloud/docker-py.git loremipsum -pygithub \ No newline at end of file +pygithub +flask-restful +jsonschema