From fc6724530b8de55b1d36be815f0414d953f39f86 Mon Sep 17 00:00:00 2001 From: Max Goodman Date: Sat, 9 Mar 2013 01:36:55 -0800 Subject: [PATCH] RESTful API for multis. --- r2/r2/config/routing.py | 4 + r2/r2/config/templates.py | 1 + r2/r2/controllers/__init__.py | 1 + r2/r2/controllers/api_docs.py | 5 + r2/r2/controllers/multi.py | 154 +++++++++++++++++++++++++++++++ r2/r2/lib/errors.py | 5 + r2/r2/lib/jsontemplates.py | 18 ++++ r2/r2/lib/validator/validator.py | 69 ++++++++++++++ 8 files changed, 257 insertions(+) create mode 100644 r2/r2/controllers/multi.py diff --git a/r2/r2/config/routing.py b/r2/r2/config/routing.py index f75e0dd8a..377f6e998 100644 --- a/r2/r2/config/routing.py +++ b/r2/r2/config/routing.py @@ -309,6 +309,10 @@ def make_map(): action='relnote') mc('/api/:action', controller='api') + mc("/api/multi/mine", controller="multiapi", action="my_multis") + mc("/api/multi/*path/r/:sr_name", controller="multiapi", action="multi_subreddit") + mc("/api/multi/*path", controller="multiapi", action="multi") + mc("/api/v1/:action", controller="oauth2frontend", requirements=dict(action="authorize")) mc("/api/v1/:action", controller="oauth2access", diff --git a/r2/r2/config/templates.py b/r2/r2/config/templates.py index 7239d3f5e..1fd0d3b92 100644 --- a/r2/r2/config/templates.py +++ b/r2/r2/config/templates.py @@ -39,6 +39,7 @@ api('promotedlink', PromotedLinkJsonTemplate) api('comment', CommentJsonTemplate) api('message', MessageJsonTemplate) api('subreddit', SubredditJsonTemplate) +api('labeledmulti', LabeledMultiJsonTemplate) api('morerecursion', MoreCommentJsonTemplate) api('morechildren', MoreCommentJsonTemplate) api('reddit', RedditJsonTemplate) diff --git a/r2/r2/controllers/__init__.py b/r2/r2/controllers/__init__.py index 6d79d1f07..9bbec2dd2 100644 --- a/r2/r2/controllers/__init__.py +++ b/r2/r2/controllers/__init__.py @@ -78,6 +78,7 @@ def load_controllers(): from api import ApiminimalController from api_docs import ApidocsController from apiv1 import APIv1Controller + from multi import MultiApiController from oauth2 import OAuth2FrontendController from oauth2 import OAuth2AccessController from redirect import RedirectController diff --git a/r2/r2/controllers/api_docs.py b/r2/r2/controllers/api_docs.py index 68b093fe0..20456d948 100644 --- a/r2/r2/controllers/api_docs.py +++ b/r2/r2/controllers/api_docs.py @@ -66,6 +66,9 @@ section_info = { 'subreddits': { 'title': _('subreddits'), }, + 'multis': { + 'title': _('multis'), + }, 'users': { 'title': _('users'), }, @@ -172,6 +175,7 @@ class ApidocsController(RedditController): from r2.controllers.captcha import CaptchaController from r2.controllers.front import FrontController from r2.controllers.wiki import WikiApiController + from r2.controllers.multi import MultiApiController from r2.controllers import listingcontroller api_controllers = [ @@ -179,6 +183,7 @@ class ApidocsController(RedditController): (ApiController, '/api'), (ApiminimalController, '/api'), (WikiApiController, '/api/wiki'), + (MultiApiController, '/api/multi'), (CaptchaController, ''), (FrontController, '') ] diff --git a/r2/r2/controllers/multi.py b/r2/r2/controllers/multi.py new file mode 100644 index 000000000..b75c5ed00 --- /dev/null +++ b/r2/r2/controllers/multi.py @@ -0,0 +1,154 @@ +# The contents of this file are subject to the Common Public Attribution +# License Version 1.0. (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# http://code.reddit.com/LICENSE. The License is based on the Mozilla Public +# License Version 1.1, but Sections 14 and 15 have been added to cover use of +# software over a computer network and provide for limited attribution for the +# Original Developer. In addition, Exhibit A has been modified to be consistent +# with Exhibit B. +# +# Software distributed under the License is distributed on an "AS IS" basis, +# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for +# the specific language governing rights and limitations under the License. +# +# The Original Code is reddit. +# +# The Original Developer is the Initial Developer. The Initial Developer of +# the Original Code is reddit Inc. +# +# All portions of the code written by reddit are Copyright (c) 2006-2012 reddit +# Inc. All Rights Reserved. +############################################################################### + +from pylons import c, request + +from r2.config.extensions import set_extension +from r2.controllers.api_docs import api_doc, api_section +from r2.controllers.reddit_base import RedditController +from r2.controllers.oauth2 import ( + OAuth2ResourceController, + require_oauth2_scope, +) +from r2.models.subreddit import ( + Subreddit, + LabeledMulti, + TooManySubredditsException, +) +from r2.lib.db import tdb_cassandra +from r2.lib.wrapped import Wrapped +from r2.lib.validator import ( + validate, + VUser, + VModhash, + VSRByName, + VJSON, + VMultiPath, + VMultiByPath, +) +from r2.lib.pages.things import wrap_things +from r2.lib.errors import errors, reddit_http_error, RedditError +from r2.lib.base import abort + + +class MultiApiController(RedditController, OAuth2ResourceController): + def pre(self): + set_extension(request.environ, "json") + self.check_for_bearer_token() + RedditController.pre(self) + + def on_validation_error(self, error): + abort(reddit_http_error( + code=error.code, + error_name=error.name, + explanation=error.message, + fields=error.fields, + )) + + @require_oauth2_scope("read") + @api_doc(api_section.multis) + @validate(VUser()) + def GET_my_multis(self): + """Fetch a list of multis belonging to the current user.""" + multis = LabeledMulti.by_owner(c.user) + wrapped = wrap_things(*multis) + resp = [w.render() for w in wrapped] + return self.api_wrapper(resp) + + @require_oauth2_scope("read") + @api_doc( + api_section.multis, + uri="/api/multi/{multipath}", + ) + @validate(multi=VMultiByPath("path", require_view=True)) + def GET_multi(self, multi): + """Fetch a multi's data and subreddit list by name.""" + resp = wrap_things(multi)[0].render() + return self.api_wrapper(resp) + + @require_oauth2_scope("subscribe") + @api_doc(api_section.multis, extends=GET_multi) + @validate( + VUser(), + VModhash(), + info=VMultiPath("path"), + data=VJSON("model"), + ) + def PUT_multi(self, info, data): + """Create or update a multi.""" + if info['username'] != c.user.name: + raise RedditError('BAD_MULTI_NAME', code=400, fields="path") + + try: + multi = LabeledMulti._byID(info['path']) + except tdb_cassandra.NotFound: + multi = LabeledMulti.create(info['path'], c.user) + + if 'visibility' in data: + if data['visibility'] not in ('private', 'public'): + raise RedditError('INVALID_OPTION', code=400, fields="data") + multi.visibility = data['visibility'] + multi._commit() + + return self.GET_multi(path=info['path']) + + @require_oauth2_scope("subscribe") + @api_doc(api_section.multis, extends=GET_multi) + @validate( + VUser(), + VModhash(), + multi=VMultiByPath("path", require_edit=True), + ) + def DELETE_multi(self, multi): + """Delete a multi.""" + multi.delete() + + @require_oauth2_scope("subscribe") + @api_doc( + api_section.multis, + uri="/api/multi/{multipath}/r/{srname}", + ) + @validate( + VUser(), + VModhash(), + multi=VMultiByPath("path", require_edit=True), + sr=VSRByName('sr_name'), + ) + def PUT_multi_subreddit(self, multi, sr): + """Add a subreddit to a multi.""" + + try: + multi.add_srs({sr._id: {}}) + except TooManySubredditsException as e: + raise RedditError('MULTI_TOO_MANY_SUBREDDITS', code=409) + + @require_oauth2_scope("subscribe") + @api_doc(api_section.multis, extends=PUT_multi_subreddit) + @validate( + VUser(), + VModhash(), + multi=VMultiByPath("path", require_edit=True), + sr=VSRByName('sr_name'), + ) + def DELETE_multi_subreddit(self, multi, sr): + """Remove a subreddit from a multi.""" + multi.del_srs(sr._id) diff --git a/r2/r2/lib/errors.py b/r2/r2/lib/errors.py index c2f2bcda9..8360e2e95 100644 --- a/r2/r2/lib/errors.py +++ b/r2/r2/lib/errors.py @@ -121,6 +121,11 @@ error_list = dict(( ('BAD_JSONP_CALLBACK', _('that jsonp callback contains invalid characters')), ('INVALID_PERMISSION_TYPE', _("permissions don't apply to that type of user")), ('INVALID_PERMISSIONS', _('invalid permissions string')), + ('BAD_MULTI_NAME', _('that name isn\'t going to work')), + ('MULTI_NOT_FOUND', _('that multireddit doesn\'t exist')), + ('MULTI_CANNOT_EDIT', _('you can\'t change that multireddit')), + ('MULTI_TOO_MANY_SUBREDDITS', _('no more space for subreddits in that multireddit')), + ('BAD_JSON', _('unable to parse JSON data')), )) errors = Storage([(e, e) for e in error_list.keys()]) diff --git a/r2/r2/lib/jsontemplates.py b/r2/r2/lib/jsontemplates.py index ff040c6de..8ef0fea43 100755 --- a/r2/r2/lib/jsontemplates.py +++ b/r2/r2/lib/jsontemplates.py @@ -250,6 +250,24 @@ class SubredditJsonTemplate(ThingJsonTemplate): else: return ThingJsonTemplate.thing_attr(self, thing, attr) +class LabeledMultiJsonTemplate(ThingJsonTemplate): + _data_attrs_ = ThingJsonTemplate.data_attrs( + path="path", + name="name", + subreddits="srs", + visibility="visibility", + ) + del _data_attrs_["id"] + + def kind(self, wrapped): + return "LabeledMulti" + + def thing_attr(self, thing, attr): + if attr == "srs": + return [{"name": sr.name} for sr in thing.srs] + else: + return ThingJsonTemplate.thing_attr(self, thing, attr) + class IdentityJsonTemplate(ThingJsonTemplate): _data_attrs_ = ThingJsonTemplate.data_attrs(name = "name", link_karma = "safe_karma", diff --git a/r2/r2/lib/validator/validator.py b/r2/r2/lib/validator/validator.py index ef73d152f..86db69452 100644 --- a/r2/r2/lib/validator/validator.py +++ b/r2/r2/lib/validator/validator.py @@ -38,6 +38,7 @@ from r2.lib.permissions import ModeratorPermissionSet from r2.models import * from r2.lib.authorize import Address, CreditCard from r2.lib.utils import constant_time_compare +from r2.lib.require import require, require_split, RequirementException from r2.lib.errors import errors, RedditError, UserRequiredException from r2.lib.errors import VerifiedUserRequiredException @@ -2165,3 +2166,71 @@ class VPermissions(Validator): self.set_error(errors.INVALID_PERMISSIONS, field=self.param[1]) return (None, None) return type, perm_set + + +class VJSON(VRequired): + def __init__(self, item, *args, **kwargs): + VRequired.__init__(self, item, errors.BAD_JSON, *args, **kwargs) + + def run(self, json_str): + if not json_str: + return self.error() + else: + try: + return json.loads(json_str) + except ValueError: + return self.error() + + def param_docs(self): + return { + self.param: "JSON data", + } + + +class VMultiPath(Validator): + @classmethod + def normalize(self, path): + if path[0] != '/': + path = '/' + path + path = path.lower().rstrip('/') + return path + + def run(self, path): + try: + require(path) + path = self.normalize(path) + require(path.startswith('/user/')) + user, username, m, name = require_split(path, 5, sep='/')[1:] + require(m == 'm') + username = chkuser(username) + require(username) + require(subreddit_rx.match(name)) + return {'path': path, 'username': username, 'name': name} + except RequirementException: + self.set_error('BAD_MULTI_NAME', code=400) + + def param_docs(self): + return { + self.param: "multireddit url path", + } + + +class VMultiByPath(Validator): + def __init__(self, param, require_view=True, require_edit=False): + Validator.__init__(self, param) + self.require_view = require_view + self.require_edit = require_edit + + def run(self, path): + path = VMultiPath.normalize(path) + multi = LabeledMulti._byID(path) + if not multi or (self.require_view and not multi.can_view(c.user)): + return self.set_error('MULTI_NOT_FOUND', code=404) + if self.require_edit and not multi.can_edit(c.user): + return self.set_error('MULTI_CANNOT_EDIT', code=403) + return multi + + def param_docs(self): + return { + self.param: "multireddit url path", + }