├── .gitignore ├── .npmignore ├── src ├── apiBase │ ├── Events.es6.js │ ├── Record.es6.js │ ├── errors │ │ ├── NoModelError.js │ │ ├── NotImplementedError.js │ │ ├── ValidationError.js │ │ ├── BadCaptchaError.es6.js │ │ ├── FakeError.es6.js │ │ └── ResponseError.js │ ├── APIResponsePaging.es6.js │ ├── apiRequest.js │ ├── APIResponse.es6.js │ ├── APIRequestUtils.es6.js │ └── Model.es6.js ├── models │ ├── award.es6.js │ ├── multi.es6.js │ ├── promocampaign.es6.js │ ├── multisubscription.es6.js │ ├── trophy.es6.js │ ├── block.es6.js │ ├── wikiRevision.es6.js │ ├── wikiPageListing.es6.js │ ├── wikiPage.es6.js │ ├── vote.es6.js │ ├── BlockedUser.es6.js │ ├── stylesheet.es6.js │ ├── account.es6.js │ ├── subscription.es6.js │ ├── wikiPageSettings.es6.js │ ├── message.es6.js │ ├── report.es6.js │ └── base.es6.js ├── lib │ ├── isThingID.es6.js │ ├── markdown.es6.js │ ├── unredditifyLink.es6.js │ └── commentTreeUtils.es6.js ├── collections │ ├── HiddenPostsAndComments.es6.js │ ├── SavedPostsAndComments.es6.js │ ├── PostsFromSubreddit.es6.js │ ├── CommentsPage.es6.js │ ├── SubredditLists.js │ ├── SearchQuery.es6.js │ └── Listing.es6.js ├── apis │ ├── SavedEndpoint.es6.js │ ├── HiddenEndpoint.es6.js │ ├── SubredditAutocomplete.es6.js │ ├── reports.es6.js │ ├── trophies.es6.js │ ├── captcha.es6.js │ ├── multisubscriptions.es6.js │ ├── subscriptions.es6.js │ ├── VoteEndpoint.es6.js │ ├── RecommendedSubreddits.es6.js │ ├── PreferencesEndpoint.es6.js │ ├── SimilarPosts.es6.js │ ├── SubredditsByPost.es6.js │ ├── SubredditsToPostsByPost.es6.js │ ├── stylesheets.es6.js │ ├── accounts.es6.js │ ├── wikis.es6.js │ ├── activities.es6.js │ ├── modListing.es6.js │ ├── EditUserTextEndpoint.js │ ├── subredditRelationships.es6.js │ ├── SearchEndpoint.js │ ├── SavedAndHiddenCommon.es6.js │ ├── BaseContentEndpoint.js │ ├── PostsEndpoint.js │ ├── MessagesEndpoint.es6.js │ ├── CommentsEndpoint.es6.js │ ├── SubredditEndpoint.es6.js │ ├── multis.es6.js │ ├── modTools.es6.js │ └── SubredditRulesEndpoint.es6.js ├── models2 │ ├── mockgenerators │ │ ├── mockLink.es6.js │ │ └── mockHTML.es6.js │ ├── mixins │ │ ├── replyable.js │ │ ├── mixin.js │ │ └── votable.js │ ├── Wiki.es6.js │ ├── MessageModel.es6.js │ ├── SubredditRule.es6.js │ ├── thingTypes.es6.js │ ├── RedditModel.es6.js │ ├── Account.es6.js │ ├── CommentModel.es6.js │ ├── Preferences.es6.js │ ├── Subreddit.es6.js │ └── PostModel.es6.js └── index.es6.js ├── .travis.yml ├── run.js ├── blueprints.config.js ├── LICENSE ├── package.json ├── .eslintrc ├── repl └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build.js 3 | npm-debug* 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | blueprints.config.js 3 | npm-debug* 4 | 5 | .eslintrc 6 | .gitignore 7 | .npmignore 8 | .travis.yml 9 | .git 10 | -------------------------------------------------------------------------------- /src/apiBase/Events.es6.js: -------------------------------------------------------------------------------- 1 | export default { 2 | request: 'request', 3 | response: 'response', 4 | error: 'error', 5 | result: 'result', 6 | }; 7 | -------------------------------------------------------------------------------- /src/models/award.es6.js: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | 3 | class Award extends Base { 4 | _type = 'Award'; 5 | } 6 | 7 | export default Award; 8 | -------------------------------------------------------------------------------- /src/models/multi.es6.js: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | 3 | class Multi extends Base { 4 | _type = 'Multi'; 5 | } 6 | 7 | export default Multi; 8 | -------------------------------------------------------------------------------- /src/lib/isThingID.es6.js: -------------------------------------------------------------------------------- 1 | const THING_ID_REGEX = new RegExp('^t\\d_[0-9a-z]+', 'i'); 2 | 3 | export default function isThingID(val) { 4 | return THING_ID_REGEX.test(val); 5 | } 6 | -------------------------------------------------------------------------------- /src/models/promocampaign.es6.js: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | 3 | class PromoCampaign extends Base { 4 | _type = 'PromoCampaign'; 5 | } 6 | 7 | export default PromoCampaign; 8 | -------------------------------------------------------------------------------- /src/lib/markdown.es6.js: -------------------------------------------------------------------------------- 1 | export default function process(text) { 2 | if (!text) return text; 3 | 4 | text = text.replace(/ { 4 | return `user/${query.user}/saved.json`; 5 | }; 6 | 7 | export default SavedOrHiddenEndpoint( 8 | getPath, 9 | 'api/unsave', 10 | 'api/save', 11 | ); 12 | -------------------------------------------------------------------------------- /src/apis/HiddenEndpoint.es6.js: -------------------------------------------------------------------------------- 1 | import SavedOrHiddenEndpoint from './SavedAndHiddenCommon'; 2 | 3 | const getPath = (query) => { 4 | return `user/${query.user}/hidden.json`; 5 | }; 6 | 7 | export default SavedOrHiddenEndpoint( 8 | getPath, 9 | 'api/unhide', 10 | 'api/hide', 11 | ); 12 | -------------------------------------------------------------------------------- /src/apiBase/errors/NoModelError.js: -------------------------------------------------------------------------------- 1 | import FakeError from './FakeError'; 2 | 3 | export default class NoModelError extends FakeError { 4 | constructor (endpoint) { 5 | super(`No model given for api endpoint ${endpoint}`); 6 | 7 | this.name = 'NoModelError'; 8 | this.status = 400; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/models2/mockgenerators/mockLink.es6.js: -------------------------------------------------------------------------------- 1 | export default function mockLink() { 2 | const seed = Math.toFixed(Math.random() * 10); 3 | if (seed <= 3) { return 'https://www.reddit.com/r/theonion'; } 4 | if (seed <= 6) { return 'https://www.reddit.com/r/nothteonion'; } 5 | return 'https://www.theonion.com'; 6 | } 7 | -------------------------------------------------------------------------------- /src/models2/mockgenerators/mockHTML.es6.js: -------------------------------------------------------------------------------- 1 | export default function mockHTML() { 2 | // more randomization here at somepoint 3 | /* eslint-disable max-len */ 4 | return '

This is a header or something

TM
reactjs subreddit'; 5 | /* eslint-enable */ 6 | } 7 | -------------------------------------------------------------------------------- /src/models/wikiRevision.es6.js: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | 3 | class WikiRevision extends Base { 4 | _type = 'WikiRevision'; 5 | 6 | constructor(props) { 7 | if (props.author && props.author.data) { 8 | props.author = props.author.data; 9 | } 10 | 11 | super(props); 12 | } 13 | } 14 | 15 | export default WikiRevision; 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "4" 5 | - "5" 6 | 7 | script: 8 | # - npm run lint 9 | - npm run test 10 | 11 | branches: 12 | except: 13 | - staging 14 | 15 | env: 16 | - CXX=g++-4.8 17 | 18 | addons: 19 | apt: 20 | sources: 21 | - ubuntu-toolchain-r-test 22 | packages: 23 | - g++-4.8 24 | -------------------------------------------------------------------------------- /src/apiBase/errors/NotImplementedError.js: -------------------------------------------------------------------------------- 1 | import FakeError from './FakeError'; 2 | 3 | export default class NotImplementedError extends FakeError { 4 | constructor (method, endpoint) { 5 | super(`Method ${method} not implemented for api endpoint ${endpoint}`); 6 | 7 | this.name = 'NotImplementedError'; 8 | this.status = 405; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/apis/SubredditAutocomplete.es6.js: -------------------------------------------------------------------------------- 1 | import { runForm } from '../apiBase/APIRequestUtils'; 2 | 3 | const PATH = '/api/search_reddit_names.json' 4 | 5 | export default { 6 | get(apiOptions, searchTerm, over18) { 7 | const query = { query: searchTerm, include_over_18: over18 }; 8 | return runForm(apiOptions, 'post', PATH, query); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/models/wikiPageListing.es6.js: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | 3 | class WikiPageListing extends Base { 4 | _type = 'WikiPageListing'; 5 | 6 | constructor(props) { 7 | props.pages = props.data.slice(); 8 | delete props.data; 9 | delete props.kind; 10 | 11 | super(props); 12 | } 13 | } 14 | 15 | export default WikiPageListing; 16 | -------------------------------------------------------------------------------- /src/models2/mixins/replyable.js: -------------------------------------------------------------------------------- 1 | import mixin from './mixin'; 2 | import comment from '../../apis/CommentsEndpoint'; 3 | 4 | export function reply (apiOptions, text) { 5 | const oldModel = this; 6 | 7 | return comment.post(apiOptions, { 8 | thingId: this.uuid, 9 | text, 10 | }); 11 | }; 12 | 13 | export default (cls) => mixin(cls, { reply }); 14 | -------------------------------------------------------------------------------- /src/models/wikiPage.es6.js: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | 3 | class WikiPage extends Base { 4 | _type = 'WikiPage'; 5 | 6 | constructor(props) { 7 | delete props.content_html; 8 | 9 | if (props.revision_by) { 10 | props.revision_by = props.revision_by.data; 11 | } 12 | 13 | super(props); 14 | } 15 | } 16 | 17 | export default WikiPage; 18 | -------------------------------------------------------------------------------- /src/models2/mixins/mixin.js: -------------------------------------------------------------------------------- 1 | export default (cls, fns) => { 2 | Object.keys(fns).map(k => { 3 | cls.prototype[k] = fns[k]; 4 | }); 5 | 6 | const oldConstructor = cls.constructor; 7 | cls.constructor = function () { 8 | Object.keys(fns).map(k => { 9 | this[k] = this[k].bind(this); 10 | }); 11 | 12 | oldConstructor(...arguments); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/models/vote.es6.js: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | 3 | class Vote extends Base { 4 | _type = 'Vote'; 5 | 6 | validators () { 7 | const direction = this.directionValidator.bind(this); 8 | 9 | return { 10 | direction, 11 | }; 12 | } 13 | 14 | directionValidator (v) { 15 | return ([-1,0,1].indexOf(v) > -1); 16 | } 17 | } 18 | 19 | export default Vote; 20 | -------------------------------------------------------------------------------- /src/models/BlockedUser.es6.js: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | 3 | export default class BlockedUser extends Base { 4 | _type = 'BlockedUser'; 5 | 6 | 7 | validators() { 8 | const date = Base.validators.integer; 9 | const id = Base.validators.thingId; 10 | const name =Base.validators.string; 11 | 12 | return { 13 | date, 14 | id, 15 | name 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/models/stylesheet.es6.js: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | 3 | // decode websafe_json encoding 4 | function unsafeJson(text) { 5 | return text.replace(/>/g, '>') 6 | .replace(/</g, '<') 7 | .replace(/&/g, '&'); 8 | } 9 | 10 | class Stylesheet extends Base { 11 | _type = 'Stylesheet'; 12 | 13 | get stylesheet () { 14 | return unsafeJson(this.get('stylesheet')); 15 | } 16 | } 17 | 18 | export default Stylesheet; 19 | -------------------------------------------------------------------------------- /run.js: -------------------------------------------------------------------------------- 1 | require('babel-polyfill'); 2 | 3 | require('babel-register')({ 4 | ignore: false, 5 | only: /.+(?:(?:\.es6\.js)|(?:.jsx))$/, 6 | extensions: ['.js', '.es6.js'], 7 | sourceMap: true, 8 | presets: [ 9 | 'es2015', 10 | ], 11 | plugins: [ 12 | 'transform-object-rest-spread', 13 | 'transform-async-to-generator', 14 | 'transform-class-properties', 15 | 'syntax-trailing-function-commas', 16 | ], 17 | }); 18 | 19 | module.exports = require('./src/index').default; 20 | -------------------------------------------------------------------------------- /src/apiBase/errors/ValidationError.js: -------------------------------------------------------------------------------- 1 | import FakeError from './FakeError'; 2 | 3 | const msgText = (api, errors) => `${api} had errors in ${errors.join(',')}`; 4 | 5 | export default class ValidationError extends FakeError { 6 | constructor (apiName, errors, status) { 7 | const message = errors && errors.length ? 8 | msgText(apiName, errors) : `Validation error in '${apiName}'`; 9 | super(message); 10 | this.name = 'ValidationError'; 11 | this.errors = errors; 12 | this.status = status; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/apiBase/errors/BadCaptchaError.es6.js: -------------------------------------------------------------------------------- 1 | import FakeError from './FakeError'; 2 | 3 | const INCORRECT_CAPTCHA = 'Incorrect captcha provided.'; 4 | const NO_CAPTCHA = 'No captcha provided.'; 5 | 6 | export default class BadCaptchaError extends FakeError { 7 | constructor(captcha, newCaptcha, errors) { 8 | const message = captcha ? INCORRECT_CAPTCHA : NO_CAPTCHA; 9 | super(message); 10 | this.name = 'BadCaptchaError'; 11 | this.captcha = captcha; 12 | this.newCaptcha = newCaptcha; 13 | this.errors = errors; 14 | this.status = 200; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/models/account.es6.js: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | import { USER_TYPE } from '../models2/thingTypes'; 3 | 4 | export default class Account extends Base { 5 | _type = 'Account'; 6 | 7 | validators () { 8 | const thingId = this.thingIdValidator.bind(this); 9 | 10 | return { 11 | thingId, 12 | }; 13 | } 14 | 15 | uuid(props) { 16 | if (Base.validators.thingId(props.id)) { 17 | return props.id; 18 | } 19 | 20 | return `${USER_TYPE}_${props.id}`; 21 | } 22 | 23 | thingIdValidator () { 24 | const thingId = this.get('thingId'); 25 | return Base.validators.thingId(thingId); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/collections/SavedPostsAndComments.es6.js: -------------------------------------------------------------------------------- 1 | import Listing from './Listing'; 2 | import SavedEndpoint from '../apis/SavedEndpoint'; 3 | 4 | export default class SavedPostsAndComments extends Listing { 5 | static endpoint = SavedEndpoint; 6 | 7 | static fetch(apiOptions, userOrOptions, options={}) { 8 | if (typeof userOrOptions === 'string') { 9 | options.user = userOrOptions; 10 | } else { 11 | options = userOrOptions || {}; 12 | } 13 | 14 | return super.fetch(apiOptions, options); 15 | } 16 | 17 | get postsAndComments() { 18 | return this.apiResponse.results.map(this.apiResponse.getModelFromRecord); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/models/subscription.es6.js: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | 3 | const _subscriptionAllowedActions = { 4 | 'sub': true, 5 | 'unsub': true, 6 | }; 7 | 8 | class Subscription extends Base { 9 | _type = 'Subscription'; 10 | 11 | validators () { 12 | const action = this.actionValidator.bind(this); 13 | const sr = this.srValidator.bind(this); 14 | 15 | return { 16 | action, 17 | sr, 18 | }; 19 | } 20 | 21 | actionValidator (val) { 22 | return _subscriptionAllowedActions[val]; 23 | } 24 | 25 | srValidator (val) { 26 | return Base.validators.string(val); 27 | } 28 | } 29 | 30 | export default Subscription; 31 | -------------------------------------------------------------------------------- /src/apis/reports.es6.js: -------------------------------------------------------------------------------- 1 | import BaseEndpoint from '../apiBase/BaseEndpoint'; 2 | import Report from '../models/report'; 3 | 4 | export default class ReportsEndpoint extends BaseEndpoint { 5 | model = Report; 6 | 7 | move = this.notImplemented('move'); 8 | copy = this.notImplemented('copy'); 9 | get = this.notImplemented('get'); 10 | put = this.notImplemented('put'); 11 | patch = this.notImplemented('patch'); 12 | del = this.notImplemented('del'); 13 | 14 | path() { 15 | return 'api/report'; 16 | } 17 | 18 | post(data) { 19 | return super.post({ 20 | ...data, 21 | reason: 'other', 22 | api_type: 'json', 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /blueprints.config.js: -------------------------------------------------------------------------------- 1 | module.exports = [{ 2 | name: 'apiClient', 3 | webpack: { 4 | entry: { 5 | build: './src/index.es6.js', 6 | }, 7 | output: { 8 | library: '[name].js', 9 | libraryTarget: 'umd', 10 | }, 11 | resolve: { 12 | generator: 'npm-and-modules', 13 | paths: [''], 14 | extensions: ['', '.js', '.jsx', '.es6.js', '.json'], 15 | }, 16 | loaders: [ 17 | 'esnextreact', 18 | 'json', 19 | ], 20 | plugins: [ 21 | 'production-loaders', 22 | 'set-node-env', 23 | 'abort-if-errors', 24 | 'minify-and-treeshake', 25 | ], 26 | externals: 'node-modules', 27 | }, 28 | }]; 29 | -------------------------------------------------------------------------------- /src/collections/PostsFromSubreddit.es6.js: -------------------------------------------------------------------------------- 1 | import Listing from './Listing'; 2 | import PostsEndpoint from '../apis/PostsEndpoint'; 3 | 4 | export default class PostsFromSubreddit extends Listing { 5 | static endpoint = PostsEndpoint; 6 | 7 | static fetch(apiOptions, subredditNameOrOptions, options={}) { 8 | if (typeof subredditNameOrOptions === 'string') { 9 | options.subredditName = subredditNameOrOptions; 10 | } else { 11 | options = subredditNameOrOptions || {}; 12 | } 13 | 14 | return super.fetch(apiOptions, options); 15 | } 16 | 17 | get posts() { 18 | return this.apiResponse.results.map(this.apiResponse.getModelFromRecord); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/apis/trophies.es6.js: -------------------------------------------------------------------------------- 1 | import BaseEndpoint from '../apiBase/BaseEndpoint'; 2 | 3 | import Trophy from '../models/trophy'; 4 | 5 | export default class TrophiesEndpoint extends BaseEndpoint { 6 | move = this.notImplemented('move'); 7 | copy = this.notImplemented('copy'); 8 | put = this.notImplemented('put'); 9 | patch = this.notImplemented('patch'); 10 | post = this.notImplemented('post'); 11 | del = this.notImplemented('del'); 12 | 13 | path (method, query={}) { 14 | return `api/v1/user/${query.user}/trophies.json`; 15 | } 16 | 17 | formatBody (res) { 18 | const { body } = res; 19 | 20 | if (body) { 21 | return new Trophy(body.data).toJSON(); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/models/wikiPageSettings.es6.js: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | 3 | class WikiPageSettings extends Base { 4 | _type = 'WikiPageSettings'; 5 | 6 | constructor(props) { 7 | props.pageEditorsList = props.editors.map(function (item) { 8 | return item.data; 9 | }); 10 | 11 | delete props.editors; 12 | 13 | props.listedInPagesIndex = props.listed; 14 | delete props.listed; 15 | 16 | props.editingPermissionLevel = WikiPageSettings._permissionLevels[props.permlevel]; 17 | delete props.permlevel; 18 | 19 | super(props); 20 | } 21 | 22 | static _permissionLevels = { 23 | 0: 'use wiki settings', 24 | 1: 'only approved editors', 25 | 2: 'only Mods', 26 | }; 27 | } 28 | 29 | export default WikiPageSettings; 30 | -------------------------------------------------------------------------------- /src/apis/captcha.es6.js: -------------------------------------------------------------------------------- 1 | import BaseEndpoint from '../apiBase/BaseEndpoint'; 2 | import { has } from 'lodash/object'; 3 | 4 | export default class CaptchaEndpoint extends BaseEndpoint { 5 | move = this.notImplemented('move'); 6 | copy = this.notImplemented('copy'); 7 | put = this.notImplemented('put'); 8 | patch = this.notImplemented('patch'); 9 | del = this.notImplemented('del'); 10 | 11 | path(method) { 12 | return `api/${method === 'post' ? 'new_captcha' : 'needs_captcha'}`; 13 | } 14 | 15 | formatBody(res) { 16 | const { body } = res; 17 | 18 | if (has(body, 'json.errors.0')) { 19 | return body.json.errors; 20 | } else if (has(body, 'json.data.iden')) { 21 | return body.json.data; 22 | } 23 | 24 | return body; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/models2/Wiki.es6.js: -------------------------------------------------------------------------------- 1 | import RedditModel from './RedditModel'; 2 | import Record from '../apiBase/Record'; 3 | 4 | import { WIKI } from './thingTypes'; 5 | 6 | const T = RedditModel.Types; 7 | 8 | export default class Wiki extends RedditModel { 9 | static type = WIKI; 10 | 11 | static PROPERTIES = { 12 | contentHTML: T.string, 13 | contentMD: T.string, 14 | path: T.string, 15 | mayRevise: T.bool, 16 | revisionBy: T.nop, 17 | revisionDate: T.number, 18 | } 19 | 20 | static API_ALIASES = { 21 | content_html: 'contentHTML', 22 | content_md: 'contentMD', 23 | may_revise: 'mayRevise', 24 | revision_by: 'revisionBy', 25 | revision_date: 'revisionDate', 26 | } 27 | 28 | makeUUID(data) { 29 | return data.path; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/apis/multisubscriptions.es6.js: -------------------------------------------------------------------------------- 1 | import BaseEndpoint from '../apiBase/BaseEndpoint'; 2 | import MultiSubscription from '../models/multi'; 3 | 4 | import Multis from './multis'; 5 | 6 | export default class MultiSubscriptionsEndpoint extends BaseEndpoint { 7 | model = MultiSubscription; 8 | 9 | move = this.notImplemented('move'); 10 | copy = this.notImplemented('copy'); 11 | get = this.notImplemented('get'); 12 | post = this.notImplemented('post'); 13 | patch = this.notImplemented('patch'); 14 | 15 | path (method, query) { 16 | const id = Multis.buildId(query); 17 | return `api/multi/${id}/r/${query.subreddit}`; 18 | } 19 | 20 | formatData (data, method) { 21 | if (method === 'put') { 22 | return { 23 | model: JSON.stringify({ name: data.subreddit }), 24 | }; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/apis/subscriptions.es6.js: -------------------------------------------------------------------------------- 1 | import { runQuery, validateData } from '../apiBase/APIRequestUtils'; 2 | 3 | const path = 'api/subscribe'; 4 | 5 | const validator = data => !!data.subreddit; 6 | 7 | const post = (apiOptions, data) => { 8 | validateData(data, 'post', 'subscriptions', validator); 9 | 10 | const postData = { 11 | sr: data.subreddit, 12 | action: 'sub', 13 | api_type: 'json', 14 | }; 15 | 16 | return runQuery(apiOptions, 'post', path, postData, data); 17 | } 18 | 19 | const del = (apiOptions, data) => { 20 | validateData(data, 'del', 'subscriptions', validator); 21 | 22 | const postData = { 23 | sr: data.subreddit, 24 | action: 'unsub', 25 | api_type: 'json', 26 | }; 27 | 28 | return runQuery(apiOptions, 'post', path, postData, data); 29 | } 30 | 31 | export default { 32 | post, 33 | del, 34 | }; 35 | -------------------------------------------------------------------------------- /src/apis/VoteEndpoint.es6.js: -------------------------------------------------------------------------------- 1 | import { runForm, validateData } from '../apiBase/APIRequestUtils'; 2 | 3 | const path = 'api/vote'; 4 | 5 | const validator = data => ( 6 | !!data.thingId && typeof data.direction === 'number' 7 | ); 8 | 9 | const post = (apiOptions, data) => { 10 | validateData(data, 'post', 'votes', validator); 11 | 12 | const postData = { 13 | id: data.thingId, 14 | dir: data.direction, 15 | api_type: 'json', 16 | }; 17 | 18 | return runForm(apiOptions, 'post', path, postData); 19 | } 20 | 21 | const del = (apiOptions, data) => { 22 | validateData(data, 'del', 'votes', validator); 23 | 24 | const postData = { 25 | id: data.thingId, 26 | dir: 0, 27 | api_type: 'json', 28 | }; 29 | 30 | return runForm(apiOptions, 'post', path, postData); 31 | } 32 | 33 | export default { 34 | post, 35 | del, 36 | }; 37 | -------------------------------------------------------------------------------- /src/apis/RecommendedSubreddits.es6.js: -------------------------------------------------------------------------------- 1 | import { isEmpty } from 'lodash/lang'; 2 | 3 | import apiRequest from '../apiBase/apiRequest'; 4 | import Subreddit from '../models2/Subreddit'; 5 | 6 | const parseBody = (apiResponse) => { 7 | const { body } = apiResponse.response; 8 | 9 | if (body.data && Array.isArray(body.data.children)) { 10 | body.data.children.forEach(c => apiResponse.addResult(Subreddit.fromJSON(c.data))); 11 | // sometimes, we get back empty object and 200 for invalid sorts like 12 | // `mine` when logged out 13 | } else if (!isEmpty(body)) { 14 | apiResponse.addResult(Subreddit.fromJSON(body.data || body)); 15 | } 16 | 17 | return apiResponse; 18 | }; 19 | 20 | const get = (apiOptions, query) => { 21 | return apiRequest(apiOptions, 'GET', 'api/similar_subreddits.json', { query }) 22 | .then(parseBody); 23 | }; 24 | 25 | export default { get } 26 | -------------------------------------------------------------------------------- /src/apis/PreferencesEndpoint.es6.js: -------------------------------------------------------------------------------- 1 | import { runQuery, runJson } from '../apiBase/APIRequestUtils'; 2 | 3 | import Preferences from '../models2/Preferences'; 4 | 5 | const PREFS_URL = '/api/v1/me/prefs'; 6 | 7 | // We opt-out of using the automatic parsing from `runQuery` and `runJson`, 8 | // because the preferences object doesn't really make sense in the normalized 9 | // response model. 10 | 11 | export default { 12 | get: async (apiOptions) => { 13 | const responseBody = await runQuery(apiOptions, 'get', PREFS_URL, {}, {}); 14 | return Preferences.fromJSON(responseBody || {}); 15 | }, 16 | 17 | patch: async (apiOptions, changes) => { 18 | const data = { 19 | ...changes, 20 | api_type: 'json', 21 | }; 22 | 23 | const responseBody = await runJson(apiOptions, 'patch', PREFS_URL, data); 24 | return Preferences.fromJSON(responseBody || {}); 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /src/apis/SimilarPosts.es6.js: -------------------------------------------------------------------------------- 1 | import { isEmpty } from 'lodash/lang'; 2 | 3 | import apiRequest from '../apiBase/apiRequest'; 4 | import PostModel from '../models2/PostModel'; 5 | 6 | const parseBody = (apiResponse) => { 7 | const { body } = apiResponse.response; 8 | 9 | if (body.data && Array.isArray(body.data.children)) { 10 | body.data.children.forEach(c => apiResponse.addResult(PostModel.fromJSON(c.data))); 11 | // sometimes, we get back empty object and 200 for invalid sorts like 12 | // `mine` when logged out 13 | } else if (!isEmpty(body)) { 14 | apiResponse.addResult(PostModel.fromJSON(body.data || body)); 15 | } 16 | 17 | return apiResponse; 18 | }; 19 | 20 | const get = (apiOptions, query) => { 21 | return apiRequest(apiOptions, 'GET', 'api/similar_links.json', { 'query': {...query, raw_json: 1} }) 22 | .then(parseBody); 23 | }; 24 | 25 | export default { get } 26 | -------------------------------------------------------------------------------- /src/apis/SubredditsByPost.es6.js: -------------------------------------------------------------------------------- 1 | import { isEmpty } from 'lodash/lang'; 2 | 3 | import apiRequest from '../apiBase/apiRequest'; 4 | import Subreddit from '../models2/Subreddit'; 5 | 6 | const parseBody = (apiResponse) => { 7 | const { body } = apiResponse.response; 8 | 9 | if (body.data && Array.isArray(body.data.children)) { 10 | body.data.children.forEach(c => apiResponse.addResult(Subreddit.fromJSON(c.data))); 11 | // sometimes, we get back empty object and 200 for invalid sorts like 12 | // `mine` when logged out 13 | } else if (!isEmpty(body)) { 14 | apiResponse.addResult(Subreddit.fromJSON(body.data || body)); 15 | } 16 | 17 | return apiResponse; 18 | }; 19 | 20 | const get = (apiOptions, query) => { 21 | return apiRequest(apiOptions, 'GET', 'api/subreddits_by_link.json', { 'query': {...query, raw_json: 1} }) 22 | .then(parseBody); 23 | }; 24 | 25 | export default { get } 26 | -------------------------------------------------------------------------------- /src/apis/SubredditsToPostsByPost.es6.js: -------------------------------------------------------------------------------- 1 | import { isEmpty } from 'lodash/lang'; 2 | 3 | import apiRequest from '../apiBase/apiRequest'; 4 | import PostModel from '../models2/PostModel'; 5 | 6 | const parseBody = (apiResponse) => { 7 | const { body } = apiResponse.response; 8 | 9 | if (body.data && Array.isArray(body.data.children)) { 10 | body.data.children.forEach(c => apiResponse.addResult(PostModel.fromJSON(c.data))); 11 | // sometimes, we get back empty object and 200 for invalid sorts like 12 | // `mine` when logged out 13 | } else if (!isEmpty(body)) { 14 | apiResponse.addResult(PostModel.fromJSON(body.data || body)); 15 | } 16 | 17 | return apiResponse; 18 | }; 19 | 20 | const get = (apiOptions, query) => { 21 | return apiRequest(apiOptions, 'GET', 'api/subreddits_to_links_by_link.json', { 'query': {...query, raw_json: 1} }) 22 | .then(parseBody); 23 | }; 24 | 25 | export default { get } 26 | -------------------------------------------------------------------------------- /src/apis/stylesheets.es6.js: -------------------------------------------------------------------------------- 1 | import BaseEndpoint from '../apiBase/BaseEndpoint'; 2 | import Stylesheet from '../models/stylesheet'; 3 | 4 | export default class StylesheetsEndpoint extends BaseEndpoint { 5 | move = this.notImplemented('move'); 6 | copy = this.notImplemented('copy'); 7 | put = this.notImplemented('put'); 8 | patch = this.notImplemented('patch'); 9 | post = this.notImplemented('post'); 10 | del = this.notImplemented('del'); 11 | 12 | path (method, query={}) { 13 | if (query.subredditName) { 14 | return `r/${query.subredditName}/about/stylesheet.json`; 15 | } 16 | 17 | return 'api/subreddit_stylesheet.json'; 18 | } 19 | 20 | formatBody (res) { 21 | const { body } = res; 22 | const { data } = body; 23 | 24 | if (data && data.images && data.stylesheet) { 25 | return new Stylesheet(data).toJSON(); 26 | } else { 27 | return {}; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/apis/accounts.es6.js: -------------------------------------------------------------------------------- 1 | import apiRequest from '../apiBase/apiRequest'; 2 | import Account from '../models2/Account'; 3 | 4 | const getPath = (query) => { 5 | if (query.loggedOut) { 6 | return 'api/me.json'; 7 | } else if (query.user === 'me') { 8 | return 'api/v1/me'; 9 | } 10 | 11 | return `user/${query.user}/about.json`; 12 | }; 13 | 14 | const parseGetBody = apiResponse => { 15 | const { body } = apiResponse.response; 16 | 17 | if (body) { 18 | const data = { 19 | name: 'me', // me is reserved, this should only stay me in the logged out case 20 | loid: body.loid, 21 | loid_created: body.loid_created, 22 | ...(body.data || body), 23 | }; 24 | 25 | apiResponse.addResult(Account.fromJSON(data)); 26 | } 27 | 28 | return apiResponse; 29 | }; 30 | 31 | export default { 32 | get(apiOptions, query) { 33 | const path = getPath(query); 34 | 35 | return apiRequest(apiOptions, 'GET', path, { query }).then(parseGetBody); 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /src/models/message.es6.js: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | 3 | class Message extends Base { 4 | _type = 'Message'; 5 | constructor(props) { 6 | if (props.replies === '') { 7 | props.replies = []; 8 | } 9 | 10 | super(props); 11 | } 12 | 13 | validators () { 14 | const text = this.textValidator.bind(this); 15 | const subject = this.subjectValidator.bind(this); 16 | const to = this.toValidator.bind(this); 17 | 18 | return { 19 | text, 20 | subject, 21 | to, 22 | }; 23 | } 24 | 25 | textValidator () { 26 | return Base.validators.minLength(this.get('text'), 1) && 27 | Base.validators.maxLength(this.get('text'), 10000); 28 | } 29 | 30 | subjectValidator () { 31 | return Base.validators.minLength(this.get('subject'), 1) && 32 | Base.validators.maxLength(this.get('subject'), 100); 33 | } 34 | 35 | toValidator () { 36 | return Base.validators.minLength(this.get('to'), 1); 37 | } 38 | } 39 | 40 | export default Message; 41 | -------------------------------------------------------------------------------- /src/apis/wikis.es6.js: -------------------------------------------------------------------------------- 1 | import { runQuery } from '../apiBase/APIRequestUtils'; 2 | import Wiki from '../models2/Wiki'; 3 | 4 | const getPath = (query) => { 5 | const { subredditName } = query; 6 | let { path } = query; 7 | 8 | // Default to the index 9 | if (!path) { 10 | path = 'index'; 11 | } 12 | 13 | // Strip trailing slash from the path 14 | path = path.endsWith('/') ? path.slice(0, -1) : path; 15 | 16 | if (subredditName) { 17 | return `r/${subredditName}/wiki/${path}`; 18 | } else { 19 | return `wiki/${path}`; 20 | } 21 | }; 22 | 23 | const parseGetBody = path => (res, apiResponse) => { 24 | const { body } = res; 25 | if (body) { 26 | const data = { 27 | path, 28 | ...(body.data || body), 29 | }; 30 | 31 | apiResponse.addResult(Wiki.fromJSON(data)); 32 | } 33 | }; 34 | 35 | export default { 36 | get(apiOptions, query) { 37 | const path = getPath(query); 38 | const url = `${path}.json`; 39 | 40 | return runQuery(apiOptions, 'get', url, {}, query, parseGetBody(path)); 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /src/apis/activities.es6.js: -------------------------------------------------------------------------------- 1 | import { runQuery } from '../apiBase/APIRequestUtils'; 2 | 3 | import CommentModel from '../models2/CommentModel'; 4 | import PostModel from '../models2/PostModel'; 5 | 6 | const CONSTRUCTORS = { 7 | t1: CommentModel, 8 | t3: PostModel, 9 | }; 10 | 11 | const getPath = query => (`user/${query.user}/${query.activity}.json`); 12 | 13 | const formatQuery = query => ({ 14 | ...query, 15 | feature: 'link_preview', 16 | sr_detail: 'true', 17 | }); 18 | 19 | const parseBody = (res, apiResponse) => { 20 | const { body } = res; 21 | 22 | if (body) { 23 | const activities = body.data.children; 24 | 25 | activities.forEach(function(a) { 26 | const constructor = CONSTRUCTORS[a.kind]; 27 | apiResponse.addResult(constructor.fromJSON(a.data)); 28 | }); 29 | } 30 | }; 31 | 32 | export default { 33 | get(apiOptions, query) { 34 | const path = getPath(query); 35 | const formattedQuery = formatQuery(query); 36 | 37 | return runQuery(apiOptions, 'get', path, formattedQuery, query, parseBody); 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /src/collections/CommentsPage.es6.js: -------------------------------------------------------------------------------- 1 | import Listing from './Listing'; 2 | import CommentsEndpoint from '../apis/CommentsEndpoint'; 3 | import NotImplementedError from '../apiBase/errors/NotImplementedError'; 4 | 5 | export default class CommentsPage extends Listing { 6 | static endpoint = CommentsEndpoint 7 | 8 | static fetch(apiOptions, id) { 9 | if (typeof id === 'string') { 10 | id = { id }; 11 | } 12 | 13 | return super.fetch(apiOptions, id); 14 | } 15 | 16 | static fetchMoreChildren(apiOptions, comment) { 17 | return super.fetch(apiOptions, { ids: comment.children }); 18 | } 19 | 20 | get topLevelComments() { 21 | return this.apiResponse.results.map(this.apiResponse.getModelFromRecord); 22 | } 23 | 24 | replies(comment) { 25 | return comment.replies.map(this.apiResponse.getModelFromRecord); 26 | } 27 | 28 | async nextResponse() { 29 | throw new NotImplementedError('comments collection pageing not supported yet'); 30 | } 31 | 32 | async prevResponse() { 33 | throw new NotImplementedError('comments collection pageing not supported yet'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/models2/MessageModel.es6.js: -------------------------------------------------------------------------------- 1 | import RedditModel from './RedditModel'; 2 | 3 | const T = RedditModel.Types; 4 | 5 | export default class Message extends RedditModel { 6 | static PROPERTIES = { 7 | id: T.string, 8 | author: T.string, 9 | name: T.string, 10 | bodyHTML: T.string, 11 | isComment: T.bool, 12 | firstMessage: T.string, 13 | firstMessageName: T.string, 14 | createdUTC: T.number, 15 | subreddit: T.string, 16 | parentId: T.string, 17 | replies: T.arrayOf(T.string), 18 | distinguished: T.string, 19 | subject: T.string, 20 | 21 | // derived 22 | cleanPermalink: T.link, 23 | }; 24 | 25 | static API_ALIASES = { 26 | was_comment: 'isComment', 27 | first_message: 'firstMessage', 28 | first_message_name: 'firstMessageName', 29 | created_utc: 'createdUTC', 30 | body_html: 'bodyHTML', 31 | parent_id: 'parentId', 32 | }; 33 | 34 | static DERIVED_PROPERTIES = { 35 | cleanPermalink(data) { 36 | const { id } = data; 37 | if (!id) { 38 | return null; 39 | } 40 | return `/message/messages/${id}`; 41 | }, 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Jack Lawson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/apiBase/errors/FakeError.es6.js: -------------------------------------------------------------------------------- 1 | import omit from 'lodash/omit'; 2 | 3 | export default class FakeError { 4 | constructor (message) { 5 | Object.defineProperty(this, 'message', { value: message }); 6 | 7 | if (Error.hasOwnProperty('captureStackTrace')) { 8 | Error.captureStackTrace(this, this.constructor); 9 | } else { 10 | Object.defineProperty(this, 'stack', { value: (new Error()).stack }); 11 | } 12 | } 13 | 14 | /** 15 | * Use this when you want to merge properties from an object onto 16 | * an instance of FakeError. In other FakeError subclasses we used to 17 | * write things like `Object.assign(fakeErrorInstance, errorObject)`. 18 | * This code breaks because `errorObject`, an instnace of the Error class, 19 | * can have a property called `message` or `stack` that we assign 20 | * as read-only properties in the FakeError constructor. 21 | * 22 | * @param {Object} - the object we want to copy properties from 23 | * @returns {undefined} - used for the side-effect of copying key/values from input 24 | */ 25 | safeAssignProps(obj) { 26 | Object.assign(this, omit(obj, ['message', 'stack'])); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/models2/mixins/votable.js: -------------------------------------------------------------------------------- 1 | import mixin from './mixin'; 2 | import votes from '../../apis/VoteEndpoint'; 3 | 4 | export function upvote (apiOptions) { 5 | // If already upvoted, cancel out the upvote. 6 | return this._vote(apiOptions, 1); 7 | } 8 | 9 | export function downvote (apiOptions) { 10 | // If already downvoted, cancel out the upvote. 11 | return this._vote(apiOptions, -1); 12 | } 13 | 14 | export function _vote (apiOptions, direction) { 15 | const oldModel = this; 16 | 17 | const undoingVote = direction === this.likes; 18 | const newLikes = undoingVote ? 0 : direction; 19 | const newScore = undoingVote 20 | ? this.score - direction 21 | : this.score - this.likes + direction; 22 | 23 | const stub = this.stub({ 24 | likes: newLikes, 25 | score: newScore, 26 | }, async (resolve, reject) => { 27 | try { 28 | const endpoint = direction === 0 ? votes.del : votes.post; 29 | await endpoint(apiOptions, { thingId: oldModel.name, direction }); 30 | return stub; 31 | } catch (e) { 32 | throw oldModel; 33 | } 34 | }); 35 | 36 | return stub; 37 | } 38 | 39 | export default (cls) => mixin(cls, { upvote, downvote, _vote }); 40 | -------------------------------------------------------------------------------- /src/apis/modListing.es6.js: -------------------------------------------------------------------------------- 1 | import BaseContentEndpoint from './BaseContentEndpoint'; 2 | import { has } from 'lodash/object'; 3 | 4 | import PostModel from '../models2/PostModel'; 5 | import Comment from '../models2/Comment'; 6 | 7 | export default class ModListingEndpoint extends BaseContentEndpoint { 8 | move = this.notImplemented('move'); 9 | copy = this.notImplemented('copy'); 10 | put = this.notImplemented('put'); 11 | patch = this.notImplemented('patch'); 12 | del = this.notImplemented('del'); 13 | post = this.notImplemented('post'); 14 | 15 | path(method, query={}) { 16 | const { subreddit, modPath } = query; 17 | return `r/${subreddit}/about/${modPath}.json`; 18 | } 19 | 20 | formatQuery (options) { 21 | options.sr_detail = 'true'; 22 | 23 | return options; 24 | } 25 | 26 | parseBody(res, apiResponse) { 27 | const { body } = res; 28 | 29 | if (has(body, 'data.children.0')) { 30 | body.data.children.forEach(c => { 31 | if (c.kind == 't3') { 32 | apiResponse.addResult(PostModel.fromJSON(c.data)); 33 | } else if (c.kind === 't1') { 34 | apiResponse.addResult(Comment.fromJSON(c.data)); 35 | } 36 | }); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/apiBase/APIResponsePaging.es6.js: -------------------------------------------------------------------------------- 1 | import { last } from 'lodash/array'; 2 | import { MergedApiReponse } from './APIResponse'; 3 | 4 | export const withQueryAndResult = (response, fn) => { 5 | let query; 6 | let results; 7 | 8 | if (response instanceof MergedApiReponse) { 9 | query = response.lastQuery; 10 | results = response.lastResponse.results; 11 | } else { 12 | query = response.query; 13 | results = response.results; 14 | } 15 | 16 | return fn(query, results); 17 | }; 18 | 19 | export const afterResponse = (response) => withQueryAndResult(response, (query, results) => { 20 | const limit = query.limit || 25; 21 | return results.length >= limit ? last(results).paginationId : null; 22 | }); 23 | 24 | export const beforeResponse = response => withQueryAndResult(response, (query, results) => { 25 | return query.after ? results[0].paginationId : null; 26 | }); 27 | 28 | export const fetchAll = async (fetchFunction, apiOptions, initialParams, afterFn=afterResponse) => { 29 | let params = { ...initialParams }; 30 | let response = await fetchFunction(apiOptions, params); 31 | 32 | let after = afterFn(response); 33 | while (after) { 34 | params = { ...params, after }; 35 | response = response.appendResponse(await fetchFunction(apiOptions, params)); 36 | after = afterResponse(response); 37 | } 38 | 39 | return response; 40 | }; 41 | -------------------------------------------------------------------------------- /src/apis/EditUserTextEndpoint.js: -------------------------------------------------------------------------------- 1 | import apiRequest from '../apiBase/apiRequest'; 2 | import NoModelError from '../apiBase/errors/NoModelError'; 3 | import ValidationError from '../apiBase/errors/ValidationError'; 4 | 5 | import PostModel from '../models2/PostModel'; 6 | import CommentModel from '../models2/CommentModel'; 7 | 8 | const TYPE_MAP = { 9 | t1: CommentModel, 10 | t3: PostModel, 11 | }; 12 | 13 | const ENDPOINT = '/api/editusertext'; 14 | 15 | export default { 16 | post(apiOptions, data={}) { 17 | const { thingId, text } = data; 18 | if (!thingId || !text) { 19 | throw new NoModelError(ENDPOINT); 20 | } 21 | 22 | const options = { 23 | type: 'form', 24 | query: { 25 | raw_json: 1, // make sure html back from the server is un-escaped 26 | }, 27 | body: { 28 | api_type: 'json', 29 | text, 30 | thing_id: thingId, 31 | }, 32 | }; 33 | 34 | return apiRequest(apiOptions, 'POST', ENDPOINT, options) 35 | .then(apiResponse => { 36 | const { body: { json }} = apiResponse.response; 37 | if (json.errors.length) { 38 | throw new ValidationError(ENDPOINT, json.errors, apiResponse.response.status); 39 | } 40 | 41 | const thing = json.data.things[0]; 42 | return TYPE_MAP[thing.kind].fromJSON(thing.data); 43 | }); 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /src/apis/subredditRelationships.es6.js: -------------------------------------------------------------------------------- 1 | import BaseContentEndpoint from './BaseContentEndpoint'; 2 | 3 | const MODACTIONS = { 4 | contributor: 'friend', 5 | leaveContrib: 'leavecontributor', 6 | moderator_invite: 'friend', 7 | leaveMod: 'leavemoderator', 8 | acceptModInvite: 'accept_moderator_invite', 9 | banned: 'friend', 10 | muted: 'friend', 11 | wikibanned: 'friend', 12 | wikicontributor: 'friend', 13 | }; 14 | 15 | export default class SubredditRelationshipsEndpoint extends BaseContentEndpoint { 16 | move = this.notImplemented('move'); 17 | copy = this.notImplemented('copy'); 18 | put = this.notImplemented('put'); 19 | patch = this.notImplemented('patch'); 20 | 21 | path(method, query={}) { 22 | const { subreddit, type, filter } = query; 23 | if (method === 'get') { 24 | return path = `r/${subreddit}/about/${filter}`; 25 | } 26 | 27 | const sub = subreddit ? `r/${subreddit}/` : ''; 28 | let path = MODACTIONS[type]; 29 | 30 | if (method === 'del' && path === 'friend') { 31 | path = 'unfriend'; 32 | } 33 | 34 | return `${sub}api/${path}`; 35 | } 36 | 37 | get(data) { 38 | data.count = 25; 39 | return super.get(data); 40 | } 41 | 42 | post(data) { 43 | data.api_type = 'json'; 44 | return super.post(data); 45 | } 46 | 47 | del(data) { 48 | data._method = 'post'; 49 | return super.del(data); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/models2/SubredditRule.es6.js: -------------------------------------------------------------------------------- 1 | import RedditModel from './RedditModel'; 2 | import { SUBREDDIT_RULE } from './thingTypes'; 3 | 4 | const T = RedditModel.Types; 5 | 6 | export default class SubredditRule extends RedditModel { 7 | static type = SUBREDDIT_RULE; 8 | 9 | /** 10 | * Valid types for rule targets. 11 | * @enum 12 | */ 13 | static RULE_TARGET = { 14 | ALL: 'all', 15 | POST: 'link', 16 | COMMENT: 'comment', 17 | }; 18 | 19 | static PROPERTIES = { 20 | createdUTC: T.number, 21 | description: T.string, 22 | descriptionHTML: T.string, 23 | kind: T.string, 24 | priority: T.number, 25 | shortName: T.string, 26 | violationReason: T.string, 27 | 28 | // The `subredditName` property is not returned from the API directly. It is 29 | // mixed into the response data by `SubredditRulesEndpoint.get` in order 30 | // to enable making unique UUIDs. 31 | subredditName: T.string, 32 | }; 33 | 34 | static API_ALIASES = { 35 | short_name: 'shortName', 36 | created_utc: 'createdUTC', 37 | description_html: 'descriptionHTML', 38 | violation_reason: 'violationReason', 39 | }; 40 | 41 | makeUUID(data) { 42 | // The actual rules model in r2 doesn't have a proper unique key, but 43 | // the `created_utc` timestamp should work since it shouldn't change. 44 | return `${data.subredditName}/${data.created_utc || data.createdUTC}`; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/models/report.es6.js: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | 3 | class Report extends Base { 4 | _type = 'Report'; 5 | 6 | validators () { 7 | const reason = this.reasonValidator.bind(this); 8 | const otherReason = this.otherReasonValidator.bind(this); 9 | const thingId = this.thingIdValidator.bind(this); 10 | 11 | this.validators = { 12 | reason, 13 | otherReason, 14 | thingId, 15 | }; 16 | } 17 | 18 | reasonValidator () { 19 | if (this.get('other_reason') && !(this.get('reason') === 'other')) { 20 | return false; 21 | } 22 | 23 | const reasonValid = Base.validators.minLength(this.get('reason'), 1) && 24 | Base.validators.maxLength(this.get('reason'), 100); 25 | 26 | const otherReasonValid = !this.get('other_reason') || ( 27 | Base.validators.minLength(this.get('other_reason'), 1) && 28 | Base.validators.maxLength(this.get('other_reason'), 100) 29 | ); 30 | 31 | return reasonValid && otherReasonValid; 32 | } 33 | 34 | otherReasonValidator () { 35 | if (this.get('reason') !== 'other') { 36 | if (this.get('other_reason')) { 37 | return false; 38 | } 39 | } else if (!this.get('reason')) { 40 | return false; 41 | } 42 | 43 | return Base.validators.maxLength(this.get('thing_id'), 100); 44 | } 45 | 46 | thingIdValidator () { 47 | return Base.validators.minLength(this.get('thing_id'), 6) && 48 | Base.validators.maxLength(this.get('thing_id'), 10); 49 | } 50 | } 51 | 52 | export default Report; 53 | -------------------------------------------------------------------------------- /src/models2/thingTypes.es6.js: -------------------------------------------------------------------------------- 1 | export const COMMENT = 'comment'; 2 | export const COMMENT_TYPE = 't1'; 3 | export const COMMENT_LOAD_MORE = 'comment_load_more'; 4 | 5 | export const ACCOUNT = 'account'; 6 | export const ACCOUNT_TYPE = 't2'; 7 | 8 | export const POST = 'post'; 9 | export const POST_TYPE = 't3'; 10 | 11 | export const MESSAGE = 'message'; 12 | export const MESSAGE_TYPE = 't4'; 13 | 14 | export const SUBREDDIT = 'subreddit'; 15 | export const SUBREDDIT_TYPE = 't5'; 16 | 17 | export const TROPHIE = 'trophie'; 18 | export const TROPHIE_TYPE = 't6'; 19 | 20 | export const PROMOCAMPAIGN = 'promocampaign'; 21 | export const PROMOCAMPAIGN_TYPE = 't8'; 22 | 23 | // Honorary things 24 | export const WIKI = 'wiki'; 25 | export const WIKI_TYPE = 'wiki'; 26 | 27 | export const SUBREDDIT_RULE = 'subreddit_rule'; 28 | export const SUBREDDIT_RULE_TYPE = 'subreddit_rule'; 29 | 30 | const type_pairs = [ 31 | [COMMENT, COMMENT_TYPE], 32 | [ACCOUNT, ACCOUNT_TYPE], 33 | [POST, POST_TYPE], 34 | [MESSAGE, MESSAGE_TYPE], 35 | [SUBREDDIT, SUBREDDIT_TYPE], 36 | [TROPHIE, TROPHIE_TYPE], 37 | [PROMOCAMPAIGN, PROMOCAMPAIGN_TYPE], 38 | [WIKI, WIKI_TYPE], 39 | [SUBREDDIT_RULE, SUBREDDIT_RULE_TYPE], 40 | ]; 41 | 42 | export const TYPES = type_pairs.reduce((table, pair) => { 43 | table[pair[1]] = pair[0]; 44 | return table; 45 | }, {}); 46 | 47 | export const TYPE_TO_THING_TYPE = type_pairs.reduce((table, pair)=> { 48 | table[pair[0]] = pair[1]; 49 | return table; 50 | }, {}); 51 | 52 | export function thingType(id) { 53 | return TYPES[id.substring(0, 2)]; 54 | } 55 | -------------------------------------------------------------------------------- /src/collections/SubredditLists.js: -------------------------------------------------------------------------------- 1 | import { fetchAll, afterResponse, beforeResponse } from '../apiBase/APIResponsePaging'; 2 | import Listing from './Listing'; 3 | import SubredditEndpoint from '../apis/SubredditEndpoint'; 4 | 5 | export class SubredditList extends Listing { 6 | static sortFromOptions = () => {} 7 | static sort = ''; 8 | static limit = 100; 9 | static endpoint = SubredditEndpoint; 10 | 11 | static baseOptions(apiOptions) { 12 | return { 13 | sort: this.sortFromOptions(apiOptions) || this.sort, 14 | limit: this.limit, 15 | sr_detail: true, 16 | }; 17 | } 18 | 19 | static async fetch(apiOptions, all=true) { 20 | if (all) { 21 | const { get } = SubredditEndpoint; 22 | const allMergedSubreddits = await fetchAll(get, apiOptions, 23 | this.baseOptions(apiOptions)); 24 | 25 | return new this(allMergedSubreddits); 26 | } 27 | 28 | const firstPage = await this.getResponse(apiOptions); 29 | return new this(firstPage); 30 | } 31 | 32 | get subreddits() { 33 | return this.apiResponse.results.map(this.apiResponse.getModelFromRecord); 34 | } 35 | } 36 | 37 | export class SubscribedSubreddits extends SubredditList { 38 | static sortFromOptions = (apiOptions) => { 39 | if (apiOptions.token) { 40 | return 'mine/subscriber'; 41 | } 42 | 43 | return 'default'; 44 | } 45 | } 46 | 47 | export class ModeratingSubreddits extends SubredditList { 48 | static sort = 'mine/moderator'; 49 | } 50 | 51 | export class ContributingSubreddits extends SubredditList { 52 | static sort = 'mine/contributor'; 53 | } 54 | -------------------------------------------------------------------------------- /src/models2/RedditModel.es6.js: -------------------------------------------------------------------------------- 1 | import Model from '../apiBase/Model'; 2 | 3 | import { TYPES, thingType } from './thingTypes'; 4 | 5 | import isThingID from '../lib/isThingID'; 6 | import process from '../lib/markdown'; 7 | import unredditifyLink from '../lib/unredditifyLink'; 8 | 9 | import mockHTML from './mockgenerators/mockHTML'; 10 | import mockLink from './mockgenerators/mockLink'; 11 | 12 | // TYPES I'd like to add 13 | // mod: (type) => type 14 | // useage: bannedBy: T.mod(T.string), 15 | // purpose: Just to document that a field is only going to be there as a moderator 16 | // 17 | // record: val => val instanceOf Record ? val : new Record() 18 | // usage: replies: T.arrayOf(T.record) 19 | // purpose: Enforce that model relations are defined as records 20 | // 21 | // model: ModelClass => val => ModelClass.fromJSON(val) 22 | // usage: srDetail: T.model(SubredditDetailModel) 23 | // purpose: express nested model parsing for one off nested parts of your model 24 | 25 | export default class RedditModel extends Model { 26 | 27 | static Types = { 28 | ...Model.Types, 29 | html: val => Model.Types.string(val), 30 | link: val => unredditifyLink(Model.Types.string(val)), 31 | }; 32 | 33 | static MockTypes = { 34 | ...Model.MockTypes, 35 | html: mockHTML, 36 | link: mockLink, 37 | }; 38 | 39 | makeUUID(data) { 40 | if (isThingID(data.name)) { return data.name; } 41 | if (isThingID(data.id)) { return data.id; } 42 | return super.makeUUID(data); 43 | } 44 | 45 | getType(data, uuid) { 46 | return super.getType(data, uuid) || TYPES[data.kind] || thingType(uuid) || 'Unknown'; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/apiBase/apiRequest.js: -------------------------------------------------------------------------------- 1 | import superagent from 'superagent'; 2 | 3 | import { APIResponse } from './APIResponse'; 4 | import ResponseError from './errors/ResponseError'; 5 | 6 | 7 | /* A thin helper function around our apis. 8 | * 9 | * @param {Object} apiOptions - fields required to hit our api 10 | * @param {String} method - the http method upper or lowercased, e.g. 'GET' 11 | * @param {String} path - the endpoint path 12 | * @param {Object} options - use to set query params, a body, or request type 13 | */ 14 | export default (apiOptions, method, path, options={}) => { 15 | const { query={}, body={}, type=null } = options; 16 | const { origin, appName, env, token, headers={}, queryParams={}, } = apiOptions; 17 | 18 | const _method = method.toLowerCase(); 19 | const _headers = token 20 | ? { ...headers, Authorization: `Bearer ${token}` } 21 | : headers; 22 | const _query = { 23 | ...queryParams, 24 | ...query, 25 | app: `${appName}-${env}`, 26 | }; 27 | 28 | const _path = path.startsWith('/') ? path : `/${path}`; 29 | const endpoint = `${origin}${_path}`; 30 | const request = superagent[_method](endpoint).set(_headers).query(_query); 31 | 32 | if (type) { 33 | request.type(type); 34 | } 35 | 36 | if (_method === 'post') { 37 | request.send(body); 38 | } 39 | 40 | return new Promise((resolve, reject) => { 41 | request.end((err, res) => { 42 | if (!err) { 43 | resolve(new APIResponse(res)); 44 | } else { 45 | if (err && err.timeout) { 46 | err.status = 504; 47 | } 48 | 49 | reject(new ResponseError(err, _path)); 50 | } 51 | }); 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /src/models2/Account.es6.js: -------------------------------------------------------------------------------- 1 | import RedditModel from './RedditModel'; 2 | 3 | import { ACCOUNT } from './thingTypes'; 4 | 5 | const T = RedditModel.Types; 6 | 7 | export default class Subreddit extends RedditModel { 8 | static type = ACCOUNT; 9 | 10 | static PROPERTIES = { 11 | commentKarma: T.number, 12 | createdUTC: T.number, 13 | features: T.nop, 14 | goldCreddits: T.number, 15 | goldExpiration: T.number, 16 | hasMail: T.bool, 17 | hasModMail: T.bool, 18 | hasVerifiedEmail: T.bool, 19 | hideFromRobots: T.bool, 20 | id: T.string, 21 | inBeta: T.bool, 22 | inboxCount: T.number, 23 | isEmployee: T.bool, 24 | isGold: T.bool, 25 | isMod: T.bool, 26 | isSuspended: T.bool, 27 | linkKarma: T.number, 28 | loid: T.string, 29 | loidCreated: T.number, 30 | name: T.string, 31 | oauthClientId: T.string, 32 | over18: T.bool, 33 | suspensionExpirationUTC: T.number, 34 | } 35 | 36 | static API_ALIASES = { 37 | comment_karm: 'commentKarma', 38 | created_utc: 'createdUTC', 39 | gold_creddits: 'goldCreddits', 40 | gold_expiration: 'goldExpiration', 41 | has_mail: 'hasMail', 42 | has_mod_mail: 'hasModMail', 43 | has_verified_email: 'hasVerifiedEmail', 44 | hide_from_robots: 'hideFromRobots', 45 | in_beta: 'inBeta', 46 | is_employee: 'isEmployee', 47 | is_gold: 'isGold', 48 | is_mod: 'isMod', 49 | is_suspended: 'isSuspended', 50 | link_karma: 'linkKarma', 51 | loid_created: 'loidCreated', 52 | oauth_client_id: 'oauthClientId', 53 | over_18: 'over18', 54 | suspension_expiration_utc: 'suspensionExpirationUTC', 55 | } 56 | 57 | makeUUID(data) { 58 | return data.name; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@r/api-client", 3 | "version": "3.37.0", 4 | "description": "A wrapper for Reddit's API", 5 | "main": "build.js", 6 | "scripts": { 7 | "watch": "blueprints -w", 8 | "build": "blueprints", 9 | "prepublish": "npm run build", 10 | "lint": "eslint ./src", 11 | "repl": "./repl" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/reddit/node-api-client" 16 | }, 17 | "keywords": [ 18 | "reddit", 19 | "api" 20 | ], 21 | "author": "reddit.com", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/reddit/node-api-client/issues" 25 | }, 26 | "peerDependencies": { 27 | "lodash": "4.x", 28 | "superagent": "1.x" 29 | }, 30 | "devDependencies": { 31 | "@r/build": "~0.10.1", 32 | "babel-core": "^6.8.0", 33 | "babel-eslint": "^6.0.4", 34 | "babel-plugin-syntax-trailing-function-commas": "^6.8.0", 35 | "babel-plugin-transform-async-to-generator": "^6.8.0", 36 | "babel-plugin-transform-class-properties": "^6.8.0", 37 | "babel-plugin-transform-object-rest-spread": "^6.8.0", 38 | "babel-plugin-transform-react-constant-elements": "^6.8.0", 39 | "babel-plugin-transform-react-inline-elements": "^6.8.0", 40 | "babel-polyfill": "6.8.0", 41 | "babel-preset-es2015": "^6.5.0", 42 | "babel-preset-react": "^6.5.0", 43 | "babel-register": "6.5.1", 44 | "chai": "3.5.0", 45 | "eslint": "^2.9.0", 46 | "eslint-plugin-babel": "2.1.1", 47 | "eslint-plugin-react": "3.16.1", 48 | "lodash": "4.x", 49 | "mocha": "2.4.5", 50 | "sinon": "1.17.4", 51 | "sinon-chai": "2.8.0", 52 | "superagent": "1.x", 53 | "webpack": "2.1.0-beta.7" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/apis/SearchEndpoint.js: -------------------------------------------------------------------------------- 1 | import { runQuery } from '../apiBase/APIRequestUtils'; 2 | 3 | import PostModel from '../models2/PostModel'; 4 | import Subreddit from '../models2/Subreddit'; 5 | import { POST_TYPE } from '../models2/thingTypes'; 6 | 7 | const getPath = (query) => { 8 | let path = ''; 9 | 10 | if (query.subreddit) { 11 | path = `r/${query.subreddit}/`; 12 | } 13 | 14 | return `${path}search.json`; 15 | }; 16 | 17 | const formatQuery = (query) => { 18 | if (query.subreddit) { 19 | query.restrict_sr = 'on'; 20 | delete query.subreddit; 21 | } 22 | 23 | return query; 24 | }; 25 | 26 | const listsFromResponse = (res) => { 27 | const { body } = res; 28 | if (!body) { return []; } 29 | 30 | // If only one type is returned body will be an object; 31 | return Array.isArray(body) ? body : [body]; 32 | }; 33 | 34 | const parseBody = (res, apiResponse) => { 35 | const lists = listsFromResponse(res); 36 | 37 | lists.forEach((listing) => { 38 | if (listing.data.children.length) { 39 | if (listing.data.children[0].kind === POST_TYPE) { 40 | listing.data.children.forEach((link) => { 41 | apiResponse.addResult(PostModel.fromJSON(link.data)); 42 | }); 43 | 44 | apiResponse.meta.after = listing.data.after; 45 | apiResponse.meta.before = listing.data.before; 46 | } else { 47 | listing.data.children.forEach((subreddit) => { 48 | apiResponse.addResult(Subreddit.fromJSON(subreddit.data)); 49 | }); 50 | } 51 | } 52 | }); 53 | }; 54 | 55 | export default { 56 | get(apiOptions, query) { 57 | const path = getPath(query); 58 | const apiQuery = formatQuery({ ...query }); 59 | 60 | return runQuery(apiOptions, 'get', path, apiQuery, query, parseBody); 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | 4 | "root": true, 5 | 6 | "env": { 7 | // I write for browser 8 | "browser": true, 9 | // in CommonJS 10 | "node": true 11 | }, 12 | 13 | "extends": "eslint:recommended", 14 | 15 | // plugins let use use custom rules below 16 | "plugins": [ 17 | "babel", 18 | "react" 19 | ], 20 | 21 | "ecmaFeatures": { 22 | "jsx": true, 23 | "es6": true, 24 | }, 25 | 26 | "rules": { 27 | // 0 = off 28 | // 1 = warn 29 | // 2 = error 30 | 31 | // React specifc rules 32 | "react/jsx-boolean-value": 0, 33 | "react/jsx-closing-bracket-location": 1, 34 | "react/jsx-curly-spacing": [2, "always"], 35 | "react/jsx-indent-props": [1, 2], 36 | "react/jsx-no-undef": 1, 37 | "react/jsx-uses-react": 1, 38 | "react/jsx-uses-vars": 1, 39 | "react/wrap-multilines": 1, 40 | "react/react-in-jsx-scope": 1, 41 | "react/prefer-es6-class": 1, 42 | // no binding functions in render for perf 43 | "react/jsx-no-bind": 1, 44 | 45 | // handle async/await functions correctly 46 | // "babel/generator-star-spacing": 1, 47 | // handle object spread 48 | "babel/object-shorthand": 1, 49 | 50 | "indent": [2, 2, {"SwitchCase": 1}], 51 | "max-len": [1, 100, 2, {"ignoreComments": true}], 52 | "no-unused-vars": 1, 53 | "no-console": 0, 54 | // semicolons 55 | "semi": [2, "always"], 56 | "brace-style": [2, "1tbs", { "allowSingleLine": true }], 57 | "comma-dangle": [2, "always-multiline"], 58 | "consistent-return": 0, 59 | "no-underscore-dangle": 0, 60 | "quotes": [2, "single"], 61 | "keyword-spacing": [2], 62 | "space-before-blocks": [2, "always"], 63 | "space-before-function-parentheses": [0, "never"], 64 | "space-in-brackets": [0, "never"], 65 | "space-in-parens": [2, "never"], 66 | "space-unary-ops": [1, { "words": true, "nonwords": false }], 67 | "strict": [2, "never"], 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/apiBase/errors/ResponseError.js: -------------------------------------------------------------------------------- 1 | import FakeError from './FakeError'; 2 | 3 | export class DisconnectedError extends FakeError { 4 | constructor(error, url) { 5 | super(`URL ${url} not reachable. You are probably disconnected from the internet.`); 6 | this.safeAssignProps(error); 7 | } 8 | } 9 | 10 | const codeMap = { 11 | ECONNREFUSED: DisconnectedError, 12 | ENOTFOUND: DisconnectedError, 13 | }; 14 | 15 | export default class ResponseError extends FakeError { 16 | constructor (error, url) { 17 | // Make sure an error and url were actually passed in 18 | if (!error) { throw new Error('No error passed to ResponseError'); } 19 | if (!url) { throw new Error('No url passed to ResponseError'); } 20 | 21 | // HACK: technically, we should be able to skip right to the check for 22 | // `if (error.code && error.syscall) { ... }`, but there's a bug in babel 23 | // preventing us from doing so. Babel wants to make sure `super` is called 24 | // before we exit this constructor. This check is technically unneeded, because 25 | // we're returning a new instance of a separate class -- and aborting init 26 | // of this class. To workaround this, call super ahead of time 27 | // so babel's check passes. 28 | // 29 | // NOTE: If you're looking through compiled code, this fixes a bug where 30 | // babel added a call to `_possibleConstructorReturn` that was passed a var 31 | // named `_this2` which was declared but isn't initialized until `super` runs 32 | super(`Status ${error.status} returned from API request to ${url}`); 33 | this.safeAssignProps(error); 34 | this.name = 'ResponseError'; 35 | 36 | // Check if it's a disconnection error or something else weird 37 | if (error.code && error.syscall) { 38 | return ResponseError.getSystemLevelError(error, url); 39 | } 40 | } 41 | 42 | static getSystemLevelError (error, url) { 43 | const E = codeMap[error.code] || Error; 44 | return new E(error, url); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/apis/SavedAndHiddenCommon.es6.js: -------------------------------------------------------------------------------- 1 | import { runQuery, validateData } from '../apiBase/APIRequestUtils'; 2 | 3 | import { has, omit } from 'lodash/object'; 4 | 5 | import CommentModel from '../models2/CommentModel'; 6 | import PostModel from '../models2/PostModel'; 7 | 8 | const CONSTRUCTORS = { 9 | t1: CommentModel, 10 | t3: PostModel, 11 | }; 12 | 13 | const parseBody = (res, apiResponse) => { 14 | const { body } = res; 15 | if (!has(body, 'data.children')) { 16 | return; 17 | } 18 | 19 | const things = body.data.children; 20 | 21 | things.forEach(function(t) { 22 | apiResponse.addResult(CONSTRUCTORS[t.kind].fromJSON(t.data)); 23 | }); 24 | }; 25 | 26 | const formatQuery = (query) => { 27 | return omit(query, 'user'); 28 | }; 29 | 30 | const validator = (data) => { 31 | return !!data.id; 32 | }; 33 | 34 | const dataFromQuery = (data) => { 35 | return { 36 | id: data.id, 37 | category: data.category, 38 | }; 39 | }; 40 | 41 | const get = (apiOptions, query, path) => { 42 | const apiQuery = formatQuery(query); 43 | 44 | return runQuery(apiOptions, 'get', path, apiQuery, query, parseBody); 45 | }; 46 | 47 | const del = (apiOptions, query, path) => { 48 | validateData(query, 'del', 'saved', validator); 49 | const postData = dataFromQuery(query); 50 | 51 | return runQuery(apiOptions, 'post', path, postData, query, parseBody); 52 | }; 53 | 54 | const post = (apiOptions, query, path) => { 55 | validateData(query, 'post', 'saved', validator); 56 | const postData = formatQuery(query); 57 | 58 | return runQuery(apiOptions, 'post', path, postData, query, parseBody); 59 | }; 60 | 61 | export default (getPathFn, delPath, postPath) => { 62 | return { 63 | get(apiOptions, query) { 64 | const path = getPathFn(query); 65 | return get(apiOptions, query, path); 66 | }, 67 | 68 | del(apiOptions, query) { 69 | return del(apiOptions, query, delPath); 70 | }, 71 | 72 | post(apiOptions, query) { 73 | return post(apiOptions, query, postPath); 74 | }, 75 | }; 76 | }; 77 | -------------------------------------------------------------------------------- /src/apis/BaseContentEndpoint.js: -------------------------------------------------------------------------------- 1 | import { pick } from 'lodash/object'; 2 | import NoModelError from '../apiBase/errors/NoModelError'; 3 | 4 | const MOD_ACTION_MAP = { 5 | approved: (t, data) => { 6 | return [ 7 | t ? 'api/approve' : 'api/remove', 8 | pick(data, ['id', 'spam']), 9 | ]; 10 | }, 11 | removed: (t, data) => { 12 | return [ 13 | t ? 'api/remove' : 'api/approve', 14 | pick(data, ['id', 'spam']), 15 | ]; 16 | }, 17 | distinguished: (_, data) => { 18 | return [ 19 | 'api/distinguish', 20 | { 21 | id: data.id, 22 | how: data.distinguished, 23 | }, 24 | ]; 25 | }, 26 | ignoreReports: (_, data) => { 27 | return [ 28 | 'api/ignore_reports', 29 | { 30 | id: data.id, 31 | spam: data.isSpam, 32 | }, 33 | ]; 34 | }, 35 | }; 36 | 37 | export const formatBaseContentQuery = (query, method) => { 38 | if (method !== 'patch') { 39 | query.feature = 'link_preview'; 40 | query.sr_detail = 'true'; 41 | } 42 | 43 | if (method === 'del') { 44 | query._method = 'post'; 45 | } 46 | 47 | return query; 48 | }; 49 | 50 | export const patchPath = () => { 51 | return 'api/editusertext'; 52 | }; 53 | 54 | export const deletePath = () => { 55 | return 'api/del'; 56 | }; 57 | 58 | export const patch = (apiOptions, data) => { 59 | if (!data) { 60 | throw new NoModelError('/api/editusertext'); 61 | } 62 | 63 | const promises = []; 64 | 65 | Object.keys(data).map(k => { 66 | const prop = MOD_ACTION_MAP[k]; 67 | const val = data[k]; 68 | 69 | if (prop) { 70 | const [api, json] = prop(val, data); 71 | promises.push(new Promise((r, x) => { 72 | this.rawSend('post', api, json, (err, res, req) => { 73 | if (err || !res.ok) { 74 | x(err || res); 75 | } 76 | 77 | r(res, req); 78 | }); 79 | })); 80 | } 81 | }); 82 | 83 | if (data.text) { 84 | const json = { 85 | api_type: 'json', 86 | thing_id: data.id, 87 | text: data.text, 88 | _method: 'post', 89 | }; 90 | 91 | promises.push(this.save('patch', json)); 92 | } 93 | 94 | return Promise.all(promises); 95 | }; 96 | -------------------------------------------------------------------------------- /src/lib/commentTreeUtils.es6.js: -------------------------------------------------------------------------------- 1 | import { COMMENT_LOAD_MORE } from '../models2/thingTypes'; 2 | 3 | // All of these function rely on mutation, either for building the tree, 4 | // or for performance reasons (things like building dictionaryies), use/edit carefully 5 | 6 | export function treeifyComments(comments=[]) { 7 | const commentDict = {}; 8 | comments.forEach(c => { 9 | commentDict[c.name] = c; 10 | }); 11 | 12 | const topLevelComments = []; 13 | 14 | // build the tree. this relies on references, so mutability is important here 15 | comments.forEach(c => { 16 | const parent = commentDict[c.parent_id]; 17 | if (!parent) { 18 | topLevelComments.push(c); 19 | return; 20 | } 21 | 22 | if (!parent.replies) { parent.replies = []; } 23 | parent.replies.push(c); 24 | }); 25 | 26 | return topLevelComments; 27 | } 28 | 29 | export function parseCommentData(data) { 30 | if (data.kind === 'more') { 31 | return { 32 | type: COMMENT_LOAD_MORE, 33 | children: data.data.children, 34 | count: data.data.count, 35 | parent_id: data.data.parent_id 36 | } 37 | } 38 | 39 | const comment = data.data; 40 | 41 | if (comment.replies) { 42 | comment.replies = comment.replies.data.children.map(parseCommentData); 43 | } else { 44 | comment.replies = []; 45 | } 46 | 47 | return comment; 48 | } 49 | 50 | const COMMENT_DEFAULTS = { 51 | numReplies: 0, 52 | loadMoreIds: [], 53 | loadMore: false, 54 | }; 55 | 56 | export function normalizeCommentReplies(comments, isTopLevel, visitComment) { 57 | return comments.map(comment => { 58 | if (comment.type === COMMENT_LOAD_MORE) { return; } 59 | 60 | // assign some helpful keys and their defaults to the comment 61 | Object.assign(comment, COMMENT_DEFAULTS); 62 | 63 | // Filter out if a comment is a "load more" type, set a property on the 64 | // parent comment, and then nuke the fake "reply" 65 | const loadMoreIdx = comment.replies.findIndex(c => c.type === COMMENT_LOAD_MORE); 66 | if (loadMoreIdx > -1) { 67 | const loadMoreStub = comment.replies[loadMoreIdx]; 68 | 69 | comment.numReplies = loadMoreStub.count; 70 | comment.loadMoreIds = loadMoreStub.children; 71 | comment.loadMore = true; 72 | comment.replies = comment.replies.slice(0, loadMoreIdx); 73 | } 74 | 75 | comment.replies = normalizeCommentReplies(comment.replies, false, visitComment); 76 | 77 | return visitComment(comment, isTopLevel); 78 | }).filter(c => c); 79 | } 80 | -------------------------------------------------------------------------------- /src/collections/SearchQuery.es6.js: -------------------------------------------------------------------------------- 1 | import Listing from './Listing'; 2 | import SearchEndpoint from '../apis/SearchEndpoint'; 3 | 4 | import { last } from 'lodash/array'; 5 | 6 | import { withQueryAndResult } from '../apiBase/APIResponsePaging'; 7 | import { POST, SUBREDDIT } from '../models2/thingTypes'; 8 | const RESERVED_FOR_SUBBREDITS = 3; // api reserves 3 slots for subreddit results 9 | 10 | export default class SearchQuery extends Listing { 11 | static endpoint = SearchEndpoint; 12 | 13 | static fetch(apiOptions, queryOrOptions, options={}) { 14 | if (typeof queryOrOptions === 'string') { 15 | options.q = queryOrOptions; 16 | } else { 17 | options = { ...options, ...queryOrOptions }; 18 | } 19 | 20 | return super.fetch(apiOptions, options); 21 | } 22 | 23 | static fetchPostsAndComments(apiOptions, queryOrOptions, options={}) { 24 | options = { 25 | ...options, 26 | include_facets: 'off', 27 | type: [ 'sr', 'link' ], 28 | sort: 'relevance', 29 | t: 'all', 30 | }; 31 | 32 | return this.fetch(apiOptions, queryOrOptions, options); 33 | } 34 | 35 | static fetchPosts(apiOptions, queryOrOptions, options={}) { 36 | options = { 37 | ...options, 38 | include_facets: 'off', 39 | type: [ 'link' ], 40 | sort: 'relevance', 41 | t: 'all', 42 | }; 43 | 44 | return this.fetch(apiOptions, queryOrOptions, options); 45 | } 46 | 47 | static fetchSubreddits(apiOptions, queryOrOptions, options={}) { 48 | options = { 49 | ...options, 50 | include_facets: 'off', 51 | type: [ 'sr' ], 52 | sort: 'relevance', 53 | t: 'all', 54 | }; 55 | 56 | return this.fetch(apiOptions, queryOrOptions, options); 57 | } 58 | 59 | expectedNumberOfPosts(query) { 60 | return (query.limit || 25) - RESERVED_FOR_SUBBREDITS; 61 | } 62 | 63 | get afterId() { 64 | return withQueryAndResult(this.apiResponse, (query, results) => { 65 | const limit = this.expectedNumberOfPosts(query); 66 | const posts = results.filter(record => record.type === POST); 67 | return posts.length >= limit ? last(posts).uuid : null; 68 | }); 69 | } 70 | 71 | get posts() { 72 | return this.apiResponse.results 73 | .filter(record => record.type === POST) 74 | .map(this.apiResponse.getModelFromRecord); 75 | } 76 | 77 | get subreddits() { 78 | return this.apiResponse.results 79 | .filter(record => record.type === SUBREDDIT) 80 | .map(this.apiResponse.getModelFromRecord); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/models/base.es6.js: -------------------------------------------------------------------------------- 1 | const THING_ID_REGEX = new RegExp('^t\\d_[0-9a-z]+', 'i'); 2 | 3 | export default class Base { 4 | _type = 'Base'; 5 | 6 | constructor (props={}) { 7 | this.props = {}; 8 | 9 | for (let p in props) { 10 | this.props[p] = props[p]; 11 | } 12 | } 13 | 14 | validators () { 15 | return; 16 | } 17 | 18 | get (name) { 19 | return this.props[name]; 20 | } 21 | 22 | set (name, value) { 23 | if (typeof name === 'object') { 24 | Object.assign(this.props, name); 25 | } else { 26 | this.props[name] = value; 27 | } 28 | } 29 | 30 | validate (keys) { 31 | const validators = this.validators(); 32 | 33 | if (!validators) { 34 | return true; 35 | } 36 | 37 | let invalid = []; 38 | let p; 39 | 40 | for (p in this.props) { 41 | // Optionally, send in an array of keys to validate 42 | if (!keys || keys.includes(p)) { 43 | if (validators[p] && !validators[p](this.props[p])) { 44 | invalid.push(p); 45 | } 46 | } 47 | } 48 | 49 | if (invalid.length === 0) { 50 | return true; 51 | } 52 | 53 | return invalid; 54 | } 55 | 56 | uuid(props) { 57 | if (Base.validators.thingId(props.name)) { 58 | return props.name; 59 | } else if (Base.validators.thingId(props.id)) { 60 | return props.id; 61 | } 62 | } 63 | 64 | toJSON (formatter=this.noopFormat) { 65 | let props = this.props; 66 | props._type = this._type; 67 | props.uuid = this.uuid(props); 68 | 69 | if (formatter && typeof formatter === 'function') { 70 | return formatter(props); 71 | } 72 | 73 | return props; 74 | } 75 | 76 | static validators = { 77 | integer: function(i) { 78 | return i === parseInt(i); 79 | }, 80 | 81 | string: function(s) { 82 | return s === s.toString(); 83 | }, 84 | 85 | min: function (i, min) { 86 | return i >= min; 87 | }, 88 | 89 | max: function (i, max) { 90 | return i <= max; 91 | }, 92 | 93 | maxLength: function (s, l) { 94 | return Base.validators.max(s.length, l); 95 | }, 96 | 97 | minLength: function (s, l) { 98 | return Base.validators.min(s.length, l); 99 | }, 100 | 101 | regex: function(s, expr) { 102 | return expr.test(s); 103 | }, 104 | 105 | thingId: function(id) { 106 | return id == null || Base.validators.regex(id, THING_ID_REGEX); 107 | }, 108 | }; 109 | } 110 | -------------------------------------------------------------------------------- /src/collections/Listing.es6.js: -------------------------------------------------------------------------------- 1 | import { afterResponse, beforeResponse } from '../apiBase/APIResponsePaging'; 2 | import { omit } from 'lodash/object'; 3 | 4 | const identity = (id) => id; 5 | 6 | // Base class for paged collections 7 | // TODO: rethink base options a bit, whould base options just really make everytyhing? 8 | // think more about next page and etc, it should be easy to do paged requests 9 | // in the very first fetch call 10 | export default class Listing { 11 | static baseOptions() { return {}; } 12 | 13 | static endpoint = { get() {} }; 14 | 15 | static async getResponse(apiOptions, options={}) { 16 | const res = await this.endpoint.get(apiOptions, { 17 | ...this.baseOptions(), 18 | ...options, 19 | }); 20 | 21 | return res; 22 | } 23 | 24 | static async fetch(apiOptions, options={}) { 25 | return new this(await this.getResponse(apiOptions, options)); 26 | } 27 | 28 | constructor(apiResponse) { 29 | this.apiResponse = apiResponse; 30 | this.nextResponse = this.nextResponse.bind(this); 31 | this.prevResponse = this.prevResponse.bind(this); 32 | } 33 | 34 | afterId(apiResponse) { 35 | return afterResponse(apiResponse); 36 | } 37 | 38 | hasNextPage() { 39 | return !!this.afterId; 40 | } 41 | 42 | prevId(apiResponse) { 43 | return beforeResponse(apiResponse); 44 | } 45 | 46 | hasPreviousPage() { 47 | return !!this.prevId; 48 | } 49 | 50 | async nextResponse(apiOptions) { 51 | const after = this.afterId(this.apiResponse); 52 | if (!after) { return ; } 53 | const options = omit({ ...this.apiResponse.query, after}, 'before'); 54 | return await this.constructor.getResponse(apiOptions, options); 55 | } 56 | 57 | async prevResponse(apiOptions) { 58 | const before = this.prevId(this.apiResponse); 59 | if (!before) { return; } 60 | const options = omit({ ...this.apiResponse.query, before}, 'after'); 61 | return await this.constructor.getResponse(apiOptions, options); 62 | } 63 | 64 | async fetchAndMakeInstance(fetchMethod, apiOptions, reduceResponse) { 65 | const response = await fetchMethod(apiOptions); 66 | if (response) { 67 | return new this.constructor(reduceResponse(response)); 68 | } 69 | } 70 | 71 | async nextPage(apiOptions) { 72 | return this.fetchAndMakeInstance(this.nextResponse, apiOptions, identity); 73 | } 74 | 75 | async withNextPage(apiOptions) { 76 | const { nextResponse, apiResponse } = this; 77 | return this.fetchAndMakeInstance(nextResponse, apiOptions, apiResponse.appendResponse); 78 | } 79 | 80 | async prevPage(apiOptions) { 81 | return this.fetchAndMakeInstance(this.prevResponse, apiOptions, identity); 82 | } 83 | 84 | async withPrevPage(apiOptions) { 85 | return this.fetchAndMakeInstance(this.prevResponse, apiOptions, (prevResponse) => { 86 | return prevResponse.appendResponse(this.apiResponse); 87 | }); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/apis/PostsEndpoint.js: -------------------------------------------------------------------------------- 1 | import some from 'lodash/some'; 2 | 3 | import apiRequest from '../apiBase/apiRequest'; 4 | import BadCaptchaError from '../apiBase/errors/BadCaptchaError'; 5 | import ValidationError from '../apiBase/errors/ValidationError'; 6 | import PostModel from '../models2/PostModel'; 7 | import { formatBaseContentQuery } from './BaseContentEndpoint'; 8 | 9 | 10 | const BAD_CAPTCHA = 'BAD_CAPTCHA'; 11 | 12 | const getPath = (query) => { 13 | if (query.user) { 14 | return `user/${query.user}/submitted.json`; 15 | } else if (query.id) { 16 | return `by_id/${query.id}.json`; 17 | } else if (query.ids) { 18 | return `by_id/${query.ids.join(',')}.json`; 19 | } else if (query.subredditName) { 20 | if (query.sort) { 21 | return `r/${query.subredditName}/${query.sort}.json`; 22 | } 23 | return `r/${query.subredditName}.json`; 24 | } else if (query.multi) { 25 | return `user/${query.multiUser}/m/${query.multi}.json`; 26 | } 27 | 28 | return `${query.sort || 'hot'}.json`; 29 | }; 30 | 31 | const formatQuery = (query, method) => { 32 | formatBaseContentQuery(query, method); 33 | 34 | if (method !== 'patch') { 35 | query.feature = 'link_preview'; 36 | query.sr_detail = 'true'; 37 | } 38 | 39 | if (method === 'del') { 40 | query._method = 'post'; 41 | } 42 | 43 | return query; 44 | }; 45 | 46 | const formatPostData = (data)=> { 47 | const postData = { 48 | api_type: 'json', 49 | thing_id: data.thingId, 50 | title: data.title, 51 | kind: data.kind, 52 | sendreplies: data.sendreplies, 53 | sr: data.sr, 54 | iden: data.iden, 55 | 'g-recaptcha-response': data.gRecaptchaResponse, 56 | resubmit: data.resubmit, 57 | }; 58 | 59 | if (data.text) { 60 | postData.text = data.text; 61 | } else if (data.url) { 62 | postData.url = data.url; 63 | } 64 | 65 | return postData; 66 | }; 67 | 68 | const handleGet = apiResponse => { 69 | const { body: { data } } = apiResponse.response; 70 | 71 | if (data && data.children && data.children[0]) { 72 | if (data.children.length === 1) { 73 | apiResponse.addResult(PostModel.fromJSON(data.children[0].data)); 74 | } else { 75 | data.children.forEach(c => apiResponse.addResult(PostModel.fromJSON(c.data))); 76 | } 77 | } 78 | 79 | return apiResponse; 80 | }; 81 | 82 | export default { 83 | get(apiOptions, _query) { 84 | const path = getPath(_query); 85 | const query = formatQuery({ raw_json: 1, ..._query }, 'get'); 86 | 87 | return apiRequest(apiOptions, 'GET', path, { query }).then(handleGet); 88 | }, 89 | 90 | post(apiOptions, data) { 91 | const path = 'api/submit'; 92 | const query = formatPostData(data); 93 | 94 | return apiRequest(apiOptions, 'POST', path, { query }) 95 | .then(apiResponse => { 96 | const { body: { json } } = apiResponse.response; 97 | if (json.errors.length && some(json.errors, e => e[0] === BAD_CAPTCHA)) { 98 | throw new BadCaptchaError(data.gRecaptchaResponse, json.captcha, json.errors); 99 | } else if (json.errors.length) { 100 | throw new ValidationError(path, json.errors, 200); 101 | } else { 102 | return apiResponse.response.body; 103 | } 104 | }); 105 | }, 106 | }; 107 | -------------------------------------------------------------------------------- /src/apis/MessagesEndpoint.es6.js: -------------------------------------------------------------------------------- 1 | import { has } from 'lodash/object'; 2 | 3 | import apiRequest from '../apiBase/apiRequest'; 4 | import ValidationError from '../apiBase/errors/ValidationError'; 5 | 6 | import Comment from '../models2/CommentModel'; 7 | import Post from '../models2/PostModel'; 8 | import Message from '../models2/MessageModel'; 9 | 10 | const TYPE_MAP = { 11 | 't1': Comment, 12 | 't3': Post, 13 | 't4': Message, 14 | }; 15 | 16 | const parseGetBody = apiResponse => { 17 | const { body } = apiResponse.response; 18 | body.data.children.forEach(c => { 19 | const replies = []; 20 | if (c.data.replies) { 21 | c.data.replies.data.children.forEach(r => { 22 | apiResponse.addModel(TYPE_MAP[r.kind].fromJSON(r.data)); 23 | replies.push(`${r.kind}_${r.data.id}`); 24 | }); 25 | } 26 | 27 | c.data.replies = replies; 28 | apiResponse.addResult(TYPE_MAP[c.kind].fromJSON(c.data)); 29 | }); 30 | 31 | const { before, after } = body.data; 32 | apiResponse.meta = { before, after }; 33 | 34 | return apiResponse; 35 | }; 36 | 37 | const parsePostBody = apiResponse => { 38 | const { body } = apiResponse.response; 39 | if (has(body, 'json.errors') && body.json.errors.length) { 40 | // There's a problem -- return the errors 41 | const reqType = (apiResponse.request.url.indexOf('compose') !== -1) ? 'compose' : 'reply'; 42 | throw new ValidationError(`Message ${reqType}`, body.json.errors, body.status); 43 | } else if (has(body, 'json.data.things.0.data')) { 44 | // We're replying to an existing thread -- the API gives us 45 | // that message, so we return the model. 46 | const message = body.json.data.things[0]; 47 | const model = TYPE_MAP[message.kind].fromJSON(message.data); 48 | return model; 49 | } 50 | 51 | // New message thread -- API tells us nothing, so we return null 52 | return null; 53 | }; 54 | 55 | const getPath = (data={}) => { 56 | const { subreddit, type, thread } = data; 57 | const sub = subreddit ? `r/${subreddit}/` : ''; 58 | const id = thread ? `/${thread}` : ''; 59 | return `${sub}message/${type}${id}.json`; 60 | }; 61 | 62 | const postPath = (body={}) => { 63 | const { thingId } = body; 64 | if (!thingId) { 65 | return 'api/compose'; 66 | } 67 | 68 | // `api/comment` is intentional; message replies are treated as comments. 69 | return 'api/comment'; 70 | }; 71 | 72 | const translateData = data => { 73 | const ret = { 74 | api_type: 'json', 75 | raw_json: 1, 76 | text: data.body, 77 | }; 78 | 79 | if (data.thingId) { 80 | ret.thing_id = data.thingId; 81 | } 82 | if (data.to) { 83 | ret.to = data.to; 84 | } 85 | if (data.subreddit) { 86 | ret.from_sr = data.subreddit; 87 | } 88 | if (data.subject) { 89 | ret.subject = data.subject; 90 | } 91 | 92 | return ret; 93 | }; 94 | 95 | export default { 96 | get(apiOptions, data) { 97 | const path = getPath(data); 98 | const query = { ...data.query, raw_json: 1 }; 99 | 100 | return apiRequest(apiOptions, 'GET', path, { query }).then(parseGetBody); 101 | }, 102 | 103 | post(apiOptions, data) { 104 | const path = postPath(data); 105 | const body = translateData(data); 106 | 107 | return apiRequest(apiOptions, 'POST', path, { body, type: 'form' }).then(parsePostBody); 108 | }, 109 | }; 110 | -------------------------------------------------------------------------------- /src/models2/CommentModel.es6.js: -------------------------------------------------------------------------------- 1 | import RedditModel from './RedditModel'; 2 | import Record from '../apiBase/Record'; 3 | import { COMMENT, COMMENT_LOAD_MORE } from './thingTypes'; 4 | 5 | import votable from './mixins/votable'; 6 | import replyable from './mixins/replyable'; 7 | 8 | const T = RedditModel.Types; 9 | 10 | export default class CommentModel extends RedditModel { 11 | static type = COMMENT; 12 | 13 | static PROPERTIES = { 14 | archived: T.bool, 15 | author: T.string, 16 | authorFlairCSSClass: T.string, 17 | authorFlairText: T.string, 18 | children: T.nop, 19 | controversiality: T.number, 20 | distinguished: T.string, 21 | downs: T.number, 22 | edited: T.bool, 23 | gilded: T.number, 24 | id: T.string, 25 | likes: T.likes, 26 | name: T.string, 27 | replies: T.array, 28 | numReplies: T.number, 29 | loadMore: T.bool, 30 | loadMoreIds: T.arrayOf(T.string), 31 | saved: T.bool, 32 | score: T.number, 33 | stickied: T.bool, 34 | subreddit: T.string, 35 | ups: T.number, 36 | removed: T.bool, 37 | approved: T.bool, 38 | spam: T.bool, 39 | 40 | // aliases 41 | approvedBy: T.string, 42 | bannedBy: T.string, 43 | bodyHTML: T.html, 44 | bodyMD: T.html, 45 | createdUTC: T.number, 46 | linkId: T.string, 47 | linkTitle: T.string, 48 | modReports: T.array, 49 | numReports: T.number, 50 | parentId: T.string, 51 | reportReasons: T.array, 52 | scoreHidden: T.bool, 53 | subredditId: T.string, 54 | userReports: T.array, 55 | 56 | // derived 57 | cleanPermalink: T.link, 58 | canContinueThread: T.bool, 59 | }; 60 | 61 | static API_ALIASES = { 62 | approved_by: 'approvedBy', 63 | author_flair_css_class: 'authorFlairCSSClass', 64 | author_flair_text: 'authorFlairText', 65 | banned_by: 'bannedBy', 66 | body_html: 'bodyHTML', 67 | body: 'bodyMD', 68 | created_utc: 'createdUTC', 69 | link_id: 'linkId', 70 | link_title: 'linkTitle', 71 | mod_reports: 'modReports', 72 | num_reports: 'numReports', 73 | parent_id: 'parentId', 74 | report_reasons: 'reportReasons', 75 | score_hidden: 'scoreHidden', 76 | subreddit_id: 'subredditId', 77 | user_reports: 'userReports', 78 | }; 79 | 80 | static DERIVED_PROPERTIES = { 81 | cleanPermalink(data) { 82 | // if we are re-instantiating for a stub (read when we vote or reply) 83 | // re-use the cleanPermalink we parsed before. 84 | if (data.cleanPermalink) { return data.cleanPermalink; } 85 | 86 | const { subreddit, link_id, id, context } = data; 87 | 88 | if (context) { return context; } 89 | 90 | return `/r/${subreddit}/comments/${link_id.substr(3)}/comment/${id}`; 91 | }, 92 | 93 | canContinueThread(data) { 94 | // We derive this property to make the logic for rendering loadMore and 95 | // continue thread more explicit 96 | return data.loadMore && data.loadMoreIds.length === 0; 97 | }, 98 | }; 99 | 100 | makeUUID(data) { 101 | if (data.name === 't1__' && data.parent_id) { 102 | // This is a stub for load more, parentId is needed to fetch more 103 | return data.parent_id; 104 | } 105 | 106 | return data.name; 107 | } 108 | 109 | toRecord() { 110 | if (this.uuid === this.name) { 111 | return super.toRecord(); 112 | } 113 | 114 | // otherwise its a load more stub for super nested comments 115 | return new Record(COMMENT_LOAD_MORE, this.parentId); 116 | } 117 | } 118 | 119 | votable(CommentModel); 120 | replyable(CommentModel); 121 | -------------------------------------------------------------------------------- /src/apis/CommentsEndpoint.es6.js: -------------------------------------------------------------------------------- 1 | import apiRequest from '../apiBase/apiRequest'; 2 | import { formatBaseContentQuery } from './BaseContentEndpoint'; 3 | 4 | import { has } from 'lodash/object'; 5 | 6 | import CommentModel from '../models2/CommentModel'; 7 | import PostModel from '../models2/PostModel'; 8 | 9 | import { 10 | treeifyComments, 11 | parseCommentData, 12 | normalizeCommentReplies, 13 | } from '../lib/commentTreeUtils'; 14 | 15 | const formatQuery = (query, method) => { 16 | formatBaseContentQuery(query, method); 17 | 18 | if (query.commentIds) { 19 | query.children = query.commentIds.join(','); 20 | query.api_type = 'json'; 21 | query.link_id = query.linkId; 22 | 23 | delete query.commentIds; 24 | delete query.linkId; 25 | } else if (has(query, 'query.comment')) { 26 | query.comment = query.query.comment; 27 | query.context = 1; 28 | } 29 | 30 | return query; 31 | }; 32 | 33 | const getPath = (query) => { 34 | if (query.user) { 35 | return `user/${query.user}/comments.json`; 36 | } else if (query.commentIds) { 37 | return `api/morechildren.json`; 38 | } else { 39 | return `comments/${(query.id || query.linkId).replace(/^t3_/, '')}.json`; 40 | } 41 | }; 42 | 43 | const parseGetBody = (apiResponse, hasChildren) => { 44 | const { body } = apiResponse.response; 45 | let comments = []; 46 | 47 | if (Array.isArray(body)) { 48 | // The first part of the response is a link 49 | const linkData = body[0].data; 50 | if (linkData && linkData.children && linkData.children.length) { 51 | linkData.children.forEach(link => { 52 | apiResponse.addModel(PostModel.fromJSON(link.data)); 53 | }); 54 | } 55 | 56 | comments = body[1].data.children.map(parseCommentData); 57 | } else if (body.json && body.json.data) { 58 | 59 | const { things } = body.json.data; 60 | comments = treeifyComments(things.map(parseCommentData)); 61 | } 62 | 63 | normalizeCommentReplies(comments, true, (commentJSON, isTopLevel) => { 64 | // parsing is done bottom up, comment models are immutable 65 | // but they'll rely on the records 66 | const comment = CommentModel.fromJSON(commentJSON); 67 | 68 | if (isTopLevel) { 69 | apiResponse.addResult(comment); 70 | } else { 71 | apiResponse.addModel(comment); 72 | } 73 | 74 | // this sets replies to be records for consistency 75 | return comment.toRecord(); 76 | }); 77 | 78 | return apiResponse; 79 | }; 80 | 81 | const parsePostBody = apiResponse => { 82 | const { body } = apiResponse.response; 83 | 84 | if (has(body, 'json.data.things.0.data')) { 85 | const comment = body.json.data.things[0].data; 86 | apiResponse.addResult(CommentModel.fromJSON(comment)); 87 | } 88 | 89 | return apiResponse; 90 | }; 91 | 92 | export default { 93 | get(apiOptions, _query) { 94 | const hasChildren = !!_query.children; 95 | const path = getPath(_query); 96 | const query = formatQuery({ raw_json: 1, ..._query }); 97 | 98 | return apiRequest(apiOptions, 'GET', path, { query }) 99 | .then(apiResponse => parseGetBody(apiResponse, hasChildren)); 100 | }, 101 | 102 | post(apiOptions, data) { 103 | const path = 'api/comment'; 104 | const body = { 105 | api_type: 'json', 106 | thing_id: data.thingId, 107 | text: data.text, 108 | raw_json: 1, 109 | }; 110 | 111 | return apiRequest(apiOptions, 'POST', path, { body, type: 'form' }).then(parsePostBody); 112 | }, 113 | 114 | del(apiOptions, id) { 115 | const body = { id }; 116 | return apiRequest(apiOptions, 'POST', 'api/del', { body, type: 'form' }); 117 | }, 118 | }; 119 | -------------------------------------------------------------------------------- /src/apis/SubredditEndpoint.es6.js: -------------------------------------------------------------------------------- 1 | import { runQuery, validateData } from '../apiBase/APIRequestUtils'; 2 | 3 | import { pick } from 'lodash/object'; 4 | import { isEmpty } from 'lodash/lang'; 5 | 6 | import Subreddit from '../models2/Subreddit'; 7 | 8 | const DEFAULT_SUBREDDIT_OPTIONS = { 9 | allow_top: true, 10 | collapse_deleted_comments: false, 11 | comment_score_hide_mins: 0, 12 | description: '', 13 | exclude_banned_modqueue: false, 14 | 'header-title': '', 15 | hide_ads: false, 16 | lang: 'en', 17 | link_type: 'any', 18 | name: '', 19 | over_18: false, 20 | public_description: '', 21 | public_traffic: false, 22 | show_media: true, 23 | spam_comments: 'low', 24 | spam_links: 'high', 25 | spam_selfposts: 'high', 26 | sr: '', 27 | submission_type: '', 28 | submit_link_label: '', 29 | submit_text: '', 30 | submit_text_label: '', 31 | suggested_comment_sort: 'confidence', 32 | title: '', 33 | type: 'public', 34 | wiki_edit_age: 0, 35 | wiki_edit_karma: 100, 36 | wikimode: 'disabled', 37 | }; 38 | 39 | const requestPath = 'api/site_admin'; 40 | 41 | const getPath = (query) => { 42 | if (query.id && query.view === 'mod') { 43 | return `r/${query.id}/about/edit.json`; 44 | } 45 | 46 | if (query.id) { 47 | return `r/${query.id}/about.json`; 48 | } 49 | 50 | return `subreddits/${query.sort || 'default'}.json`; 51 | }; 52 | 53 | const formatQuery = (query, method) => { 54 | if (method !== 'get') { 55 | query.api_type = 'json'; 56 | } 57 | 58 | return query; 59 | }; 60 | 61 | const parseBody = (res, apiResponse) => { 62 | const { body } = res; 63 | 64 | if (body.data && Array.isArray(body.data.children)) { 65 | body.data.children.forEach(c => apiResponse.addResult(Subreddit.fromJSON(c.data))); 66 | // sometimes, we get back empty object and 200 for invalid sorts like 67 | // `mine` when logged out 68 | } else if (!isEmpty(body)) { 69 | apiResponse.addResult(Subreddit.fromJSON(body.data || body)); 70 | } 71 | }; 72 | 73 | const get = (apiOptions, query) => { 74 | const path = getPath(query); 75 | const apiQuery = formatQuery({ ...query }); 76 | 77 | return runQuery(apiOptions, 'get', path, apiQuery, query, parseBody); 78 | }; 79 | 80 | const patch = (apiOptions, data) => { 81 | // If the data doesn't have all of the keys, get the full subreddit data 82 | // and then merge in the changes and submit _that_. The API requires the 83 | // full object be sent. 84 | if (Object.keys(data).sort() !== Subreddit.fields) { 85 | return new Promise((resolve, reject) => { 86 | get(apiOptions, { 87 | id: data.id, 88 | view: 'mod', 89 | }).then(function(apiResponse) { 90 | if (!apiResponse.results.length === 1) { reject(); } 91 | const sub = apiResponse.getModelFromRecord(apiResponse.results[0]); 92 | 93 | const postData = pick({ 94 | ...DEFAULT_SUBREDDIT_OPTIONS, 95 | ...sub, 96 | ...data, 97 | sr: sub.name, 98 | }, Object.keys(DEFAULT_SUBREDDIT_OPTIONS)); 99 | 100 | return post(apiOptions, postData); 101 | }, reject); 102 | }); 103 | } 104 | 105 | return post(apiOptions, data); 106 | }; 107 | 108 | const post = (apiOptions, data) => { 109 | const postData = pick({ 110 | ...DEFAULT_SUBREDDIT_OPTIONS, 111 | ...data, 112 | }, Object.keys(DEFAULT_SUBREDDIT_OPTIONS)); 113 | 114 | return runQuery(apiOptions, 'post', requestPath, postData, data, parseBody); 115 | }; 116 | 117 | const put = (apiOptions, data) => { 118 | const modifiedData = { ...data, name: data.id }; 119 | return post(apiOptions, modifiedData); 120 | }; 121 | 122 | export default { 123 | get, 124 | patch, 125 | post, 126 | put, 127 | }; 128 | -------------------------------------------------------------------------------- /repl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const EventEmitter = require('events').EventEmitter; 6 | 7 | const repl = require('repl'); 8 | const q = require('querystring'); 9 | 10 | const packageInfo = require('./package.json'); 11 | console.log(`Starting snoode v${packageInfo.version} repl`); 12 | console.log('Enter `help()` for information.'); 13 | 14 | require('babel-polyfill'); 15 | const optionsWithAuth = require('./build').optionsWithAuth; 16 | const APIOptions = Object.assign({}, 17 | optionsWithAuth(process.env.TOKEN), 18 | { eventEmitter: new EventEmitter() } 19 | ); 20 | 21 | const local = repl.start('$> '); 22 | 23 | function help () { 24 | const help = [ 25 | 'To try out the API, use the available instance of `api` to make requests.', 26 | ' For example, try writing: `SubredditEndpoint.get(APIOptions, { id: "coffee" })`', 27 | '\n', 28 | 'The latest API error can be accessed at `error`. Requests and responses', 29 | 'can be accessed at `request` and `response`.', 30 | 'The latest API result can be accessed at `apiResponse`', 31 | '\n', 32 | 'Also try out using Models directly, they"re all in scope', 33 | ' e.g. try typing `SubscribedSubreddits.fetch(APIOptions)` (assuming you have your token)', 34 | ' property configured', 35 | ' then type `apiResponse.results` and', 36 | ' `apiResponse.getModelFromRecord(apiResponse.results[0])`', 37 | ' if you want access to the SubredditList model that"s created in this example,', 38 | ' you"ll need to append a .then(function(res) { this.res = res; })', 39 | ' and then res will be available as a toplevel variable', 40 | ].join('\n'); 41 | 42 | console.log(help); 43 | } 44 | 45 | APIOptions.eventEmitter.on('response', function printResponse (req, res) { 46 | console.log(`${req.method} ${req.url} returned ${res.statusCode}`); 47 | local.context.request = res; 48 | local.context.response = res; 49 | }); 50 | 51 | APIOptions.eventEmitter.on('result', function (apiResponse) { 52 | local.context.apiResponse = apiResponse; 53 | }); 54 | 55 | APIOptions.eventEmitter.on('error', function printResponse (e, req) { 56 | console.log(`${req.method} ${req.url} returned error:`); 57 | console.log(e.toString()); 58 | local.context.error = e; 59 | }); 60 | 61 | APIOptions.eventEmitter.on('request', function printRequest (req) { 62 | console.log(`Initiating ${req.method} to ${req.url}`); 63 | console.log(`query/data: ${JSON.stringify(req.query, null, 2)}`); 64 | }); 65 | 66 | local.context.findLoadMoreStub = function findLoadMoreStub(r) { 67 | // r is a result 68 | r.results.forEach(topLevelRecord => { 69 | local.context.checkRecordForLoadMore(r, topLevelRecord); 70 | }); 71 | }; 72 | 73 | local.context.checkRecordForLoadMore = function checkRecordForLoadMore(r, record) { 74 | const comment = r.getModelFromRecord(record); 75 | if (!comment) { return; } 76 | comment.replies.forEach(replyRecord => { 77 | if (replyRecord.type === 'comment') { 78 | local.context.checkRecordForLoadMore(r, replyRecord); 79 | return; 80 | } 81 | 82 | console.log('load more?', replyRecord); 83 | }); 84 | }; 85 | 86 | function exportToContext(submoduleName) { 87 | const submodule = require('./build')[submoduleName]; 88 | local.context[submoduleName] = submodule; 89 | 90 | Object.keys(submodule).forEach(function(subSubmoduleName) { 91 | local.context[subSubmoduleName] = submodule[subSubmoduleName]; 92 | }); 93 | } 94 | 95 | local.context.APIOptions = APIOptions; 96 | 97 | exportToContext('endpoints'); 98 | exportToContext('models'); 99 | exportToContext('collections'); 100 | exportToContext('APIResponses'); 101 | exportToContext('APIResponsePaging'); 102 | 103 | local.context.help = help; 104 | -------------------------------------------------------------------------------- /src/apis/multis.es6.js: -------------------------------------------------------------------------------- 1 | import BaseEndpoint from '../apiBase/BaseEndpoint'; 2 | import Multi from '../models/multi'; 3 | 4 | const ID_REGEX = /^user\/[^\/]+\/m\/[^\/]+$/; 5 | 6 | export default class MultisEndpoint extends BaseEndpoint { 7 | static mapSubreddits (subs) { 8 | return subs.map(s => s.name); 9 | } 10 | 11 | static formatData (data={}, method) { 12 | if (method === 'post' || method === 'put') { 13 | return { 14 | model: JSON.stringify({ 15 | description_md: data.description, 16 | display_name: data.displayName, 17 | icon_name: data.iconName, 18 | key_color: data.keyColor, 19 | visibility: data.visibility, 20 | weighting_scheme: data.weightingScheme, 21 | subreddits: data.subreddits ? data.subreddits.map(s => ({ name: s })) : undefined, 22 | }), 23 | name: data.name, 24 | }; 25 | } 26 | return data; 27 | } 28 | 29 | static buildId({ username, name, id }) { 30 | if (username && name) { 31 | return `user/${username}/m/${name}`; 32 | } 33 | 34 | if (id) { return id; } 35 | } 36 | 37 | model = Multi; 38 | 39 | path (method, query={}) { 40 | let id = MultisEndpoint.buildId(query); 41 | 42 | switch (method) { 43 | case 'get': 44 | if (query.username) { 45 | if (query.username === 'me') { 46 | return 'api/multi/mine'; 47 | } else if (id) { 48 | return `api/multi/${id}`; 49 | } 50 | 51 | return `api/multi/user/${query.username}`; 52 | } 53 | 54 | return 'api/multi'; 55 | case 'put': 56 | case 'patch': 57 | case 'post': 58 | case 'del': 59 | return `api/multi/${id}`; 60 | case 'copy': 61 | return 'api/multi/copy'; 62 | case 'move': 63 | return 'api/multi/rename'; 64 | } 65 | } 66 | 67 | formatQuery (query, method) { 68 | if (method === 'get') { 69 | if (query.user) { 70 | query.username = query.user; 71 | delete query.user; 72 | } 73 | } 74 | 75 | return query; 76 | } 77 | 78 | formatBody (res) { 79 | const { body } = res; 80 | 81 | if (body && Array.isArray(body)) { 82 | return body.map(m => { 83 | const multi = m.data; 84 | 85 | multi.subreddits = MultisEndpoint.mapSubreddits(multi.subreddits); 86 | return new Multi(multi).toJSON(); 87 | }); 88 | } else if (body) { 89 | const multi = body.data; 90 | multi.subreddits = MultisEndpoint.mapSubreddits(multi.subreddits); 91 | return new Multi(multi); 92 | } 93 | } 94 | 95 | formatData (data) { 96 | return MultisEndpoint.formatData(data); 97 | } 98 | 99 | copy (fromId, data) { 100 | if (!ID_REGEX.exec(fromId)) { 101 | throw new Error('ID did not match `user/{username}/m/{multiname}` format.'); 102 | } 103 | 104 | data = { 105 | from: fromId, 106 | to: data.id, 107 | _method: 'post', 108 | }; 109 | 110 | if (data && data.displayName) { 111 | data.display_name = data.displayName; 112 | } 113 | 114 | return this.save('copy', data); 115 | } 116 | 117 | move (fromId, toId, data) { 118 | if (!ID_REGEX.exec(fromId) || !ID_REGEX.exec(toId)) { 119 | throw new Error('ID did not match `user/{username}/m/{multiname}` format.'); 120 | } 121 | 122 | const moveData = { 123 | _method: 'post', 124 | from: fromId, 125 | to: toId, 126 | }; 127 | 128 | if (data && data.displayName) { 129 | moveData.display_name = data.displayName; 130 | } 131 | 132 | return this.save('move', moveData); 133 | } 134 | 135 | formatBody (res) { 136 | const { body } = res; 137 | if (body) { 138 | return new Multi(body.data || body).toJSON(); 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/models2/Preferences.es6.js: -------------------------------------------------------------------------------- 1 | import RedditModel from './RedditModel'; 2 | const T = RedditModel.Types; 3 | 4 | export default class PreferencesModel extends RedditModel { 5 | static type = 'preferences'; 6 | 7 | static PROPERTIES = { 8 | affiliateLinks: T.bool, 9 | allowClicktracking: T.bool, 10 | beta: T.bool, 11 | clickgadget: T.bool, 12 | collapseReadMessages: T.bool, 13 | compress: T.bool, 14 | credditAutorenew: T.bool, 15 | defaultCommentSort: T.string, // It would be nice to have T.oneOf like react here 16 | // as it's one of 'confidence', 'old', 'top', 'qa', 'controversial', 'new', 17 | defaultThemeSr: T.string, 18 | domainDetails: T.bool, 19 | emailMessages: T.bool, 20 | enableDefaultThemes: T.bool, 21 | hideAds: T.bool, 22 | hideDowns: T.bool, 23 | hideFromRobots: T.bool, 24 | hideLocationbar: T.bool, 25 | hideUps: T.bool, 26 | highlightControversial: T.bool, 27 | highlightNewComments: T.bool, 28 | ignoreSuggestedSort: T.bool, 29 | labelNsfw: T.bool, 30 | lang: T.string, 31 | legacySearch: T.bool, 32 | markMessagesRead: T.bool, 33 | media: T.string, // Another case for T.oneOf, 34 | // 'on', 'off', 'subreddit' 35 | minCommentScore: T.number, // T.number should maybe have a T.range version 36 | // -- this can only be between -100 and 100 37 | minLinkScore: T.number, // same as above 38 | monitorMentions: T.bool, 39 | newWindow: T.bool, 40 | noProfanity: T.bool, 41 | numComments: T.number, // in range 1 and 500 42 | numsites: T.number, // in range 1 and 500 43 | organic: T.bool, 44 | otherTheme: T.string, // subreddit name 45 | over18: T.bool, 46 | privateFeeds: T.bool, 47 | publicVotes: T.bool, 48 | research: T.bool, 49 | showFlair: T.bool, 50 | showGoldExpiration: T.bool, 51 | showLinkFlair: T.bool, 52 | showPromote: T.bool, 53 | showStylesheets: T.bool, 54 | showTrending: T.bool, 55 | storeVisits: T.bool, 56 | themeSelector: T.string, // subreddit name 57 | threadedMessages: T.bool, 58 | threadedModmail: T.bool, 59 | useGlobalDefaults: T.bool, 60 | }; 61 | 62 | static API_ALIASES = { 63 | affiliate_links: 'affiliateLinks', 64 | allow_clicktracking: 'allowClicktracking', 65 | beta: 'beta', 66 | clickgadget: 'clickgadget', 67 | collapse_read_messages: 'collapseReadMessages', 68 | compress: 'compress', 69 | creddit_autorenew: 'credditAutorenew', 70 | default_comment_sort: 'defaultCommentSort', 71 | default_theme_sr: 'defaultThemeSr', 72 | domain_details: 'domainDetails', 73 | email_messages: 'emailMessages', 74 | enable_default_themes: 'enableDefaultThemes', 75 | hide_ads: 'hideAds', 76 | hide_downs: 'hideDowns', 77 | hide_from_robots: 'hideFromRobots', 78 | hide_locationbar: 'hideLocationbar', 79 | hide_ups: 'hideUps', 80 | highlight_controversial: 'highlightControversial', 81 | highlight_new_comments: 'highlightNewComments', 82 | ignore_suggested_sort: 'ignoreSuggestedSort', 83 | label_nsfw: 'labelNsfw', 84 | lang: 'lang', 85 | legacy_search: 'legacySearch', 86 | mark_messages_read: 'markMessagesRead', 87 | media: 'media', 88 | min_comment_score: 'minCommentScore', 89 | min_link_score: 'minLinkScore', 90 | monitor_mentions: 'monitorMentions', 91 | newwindow: 'newWindow', 92 | no_profanity: 'noProfanity', 93 | num_comments: 'numComments', 94 | numsites: 'numsites', 95 | organic: 'organic', 96 | other_theme: 'otherTheme', 97 | over_18: 'over18', 98 | private_feeds: 'privateFeeds', 99 | public_votes: 'publicVotes', 100 | research: 'research', 101 | show_flair: 'showFlair', 102 | show_gold_expiration: 'showGoldExpiration', 103 | show_link_flair: 'showLinkFlair', 104 | show_promote: 'showPromote', 105 | show_stylesheets: 'showStylesheets', 106 | show_trending: 'showTrending', 107 | store_visits: 'storeVisits', 108 | themeSelector: 'themeSelector', 109 | threaded_messages: 'threadedMessages', 110 | threaded_modmail: 'threadedModmail', 111 | use_global_defaults: 'useGlobalDefaults', 112 | }; 113 | 114 | makeUUID(data) { 115 | return 'preferences'; // there's only one preferences object for a user 116 | // so the id is constant. Probably shouldn't use this. 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/apiBase/APIResponse.es6.js: -------------------------------------------------------------------------------- 1 | import { forEach } from 'lodash/collection'; 2 | import { last } from 'lodash/array'; 3 | 4 | import { 5 | TYPES, 6 | thingType, 7 | COMMENT, 8 | ACCOUNT, 9 | POST, 10 | MESSAGE, 11 | SUBREDDIT, 12 | SUBREDDIT_RULE, 13 | WIKI, 14 | } from '../models2/thingTypes'; 15 | 16 | export class APIResponseBase { 17 | constructor() { 18 | this.results = []; 19 | 20 | this.posts = {}; 21 | this.comments = {}; 22 | this.accounts = {}; 23 | this.messages = {}; 24 | this.subreddits = {}; 25 | this.subreddit_rules = {}; 26 | this.wikis = {}; 27 | 28 | this.typeToTable = { 29 | [COMMENT]: this.comments, 30 | [POST]: this.posts, 31 | [ACCOUNT]: this.accounts, 32 | [MESSAGE]: this.messages, 33 | [SUBREDDIT]: this.subreddits, 34 | [SUBREDDIT_RULE]: this.subreddit_rules, 35 | [WIKI]: this.wikis, 36 | }; 37 | 38 | this.addResult = this.addResult.bind(this); 39 | this.addModel = this.addModel.bind(this); 40 | this.makeRecord = this.makeRecord.bind(this); 41 | this.addToTable = this.addToTable.bind(this); 42 | this.getModelFromRecord = this.getModelFromRecord.bind(this); 43 | this.appendResponse = this.appendResponse.bind(this); 44 | } 45 | 46 | addResult(model) { 47 | if (!model) { return this; } 48 | const record = this.makeRecord(model); 49 | if (record) { 50 | this.results.push(record); 51 | this.addToTable(record, model); 52 | } 53 | 54 | return this; 55 | } 56 | 57 | addModel(model) { 58 | if (!model) { return this; } 59 | const record = this.makeRecord(model); 60 | if (record) { 61 | this.addToTable(record, model); 62 | } 63 | 64 | return this; 65 | } 66 | 67 | makeRecord(model) { 68 | if (model.toRecord) { return model.toRecord(); } 69 | const { uuid } = model; 70 | if (!uuid) { return; } 71 | 72 | const type = TYPES[model.kind] || thingType(uuid); 73 | if (!type) { return; } 74 | return { type, uuid }; 75 | } 76 | 77 | addToTable(record, model) { 78 | const table = this.typeToTable[record.type]; 79 | if (table) { table[record.uuid] = model; } 80 | return this; 81 | } 82 | 83 | getModelFromRecord(record) { 84 | const table = this.typeToTable[record.type]; 85 | if (table) { return table[record.uuid]; } 86 | } 87 | 88 | appendResponse() { throw new Error('Not implemented in base class'); } 89 | } 90 | 91 | export class APIResponse extends APIResponseBase { 92 | constructor(response, meta={}, query={}) { 93 | super(); 94 | this.request = response.req; 95 | this.response = response; 96 | 97 | // Left for backwards compatibility, you can use request and response directly 98 | this.meta = meta; 99 | this.query = query; 100 | } 101 | 102 | appendResponse(nextResponse) { 103 | return new MergedApiReponse([this, nextResponse]); 104 | } 105 | } 106 | 107 | export class MergedApiReponse extends APIResponseBase { 108 | constructor(apiResponses) { 109 | super(); 110 | this.metas = apiResponses.map(response => response.meta); 111 | this.querys = apiResponses.map(response => response.query); 112 | 113 | this.apiResponses = apiResponses; 114 | 115 | const seenResults = new Set(); 116 | 117 | const tableKeys = [ 118 | COMMENT, 119 | ACCOUNT, 120 | POST, 121 | MESSAGE, 122 | SUBREDDIT, 123 | ]; 124 | 125 | forEach(apiResponses, (apiResponse) => { 126 | forEach(apiResponse.results, (record) => { 127 | if (!seenResults.has(record.uuid)) { 128 | seenResults.add(record.uuid); 129 | this.results.push(record); 130 | } 131 | }); 132 | 133 | forEach(tableKeys, (tableKey) => { 134 | const table = this.typeToTable[tableKey]; 135 | Object.assign(table, apiResponse.typeToTable[tableKey]); 136 | }); 137 | }); 138 | } 139 | 140 | get lastResponse() { 141 | return last(this.apiResponses); 142 | } 143 | 144 | get lastQuery() { 145 | return last(this.querys); 146 | } 147 | 148 | get lastMeta() { 149 | return last(this.meta); 150 | } 151 | 152 | get query() { // shorthand convenience 153 | return this.latQuery; 154 | } 155 | 156 | appendResponse(response) { 157 | const newReponses = this.apiResponses.slice(); 158 | newReponses.push(response); 159 | 160 | return new MergedApiReponse(newReponses); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/apis/modTools.es6.js: -------------------------------------------------------------------------------- 1 | import apiRequest from '../apiBase/apiRequest'; 2 | 3 | /** 4 | * Valid distinguish types. 5 | * Note that the API endpoint used to distinguish posts and comments accepts 6 | * 'yes' instead of 'moderator' and 'no' instead of ''. See #distinguish 7 | * @enum 8 | */ 9 | const DISTINGUISH_TYPES = { 10 | NONE: '', 11 | MODERATOR: 'moderator', 12 | ADMIN: 'admin', 13 | }; 14 | 15 | const remove = (apiOptions, fullname, spam) => { 16 | // Remove a link, comment, or modmail message. 17 | const body = { 18 | id: fullname, 19 | spam: spam 20 | }; 21 | 22 | return apiRequest(apiOptions, 'POST', 'api/remove', { body, type: 'form' }); 23 | } 24 | 25 | const approve = (apiOptions, fullname) => { 26 | // Approve a link or comment 27 | const body = { id: fullname }; 28 | return apiRequest(apiOptions, 'POST', 'api/approve', { body, type: 'form' }); 29 | } 30 | 31 | /** 32 | * Distinguish a link or comment 33 | * @function 34 | * @param {Object} apiOptions 35 | * @param {string} fullname The fullname of the target comment 36 | * @param {DISTINGUISH_TYPES} distinguishType What type of distinguish is being set 37 | * @param {?bool} [_sticky] For internal use by #setStickyComment 38 | */ 39 | const distinguish = (apiOptions, fullname, distinguishType, _sticky=null) => { 40 | const distinguishTypeMap = { 41 | [DISTINGUISH_TYPES.MODERATOR]: 'yes', 42 | [DISTINGUISH_TYPES.NONE]: 'no', 43 | }; 44 | 45 | const body = { 46 | id: fullname, 47 | }; 48 | const how = distinguishTypeMap[distinguishType] || distinguishType; 49 | 50 | if (_sticky !== null) { 51 | body.sticky = _sticky; 52 | } 53 | 54 | return apiRequest(apiOptions, 'POST', `api/distinguish/${how}`, { body, type: 'form' }); 55 | } 56 | 57 | const markNSFW = (apiOptions, id) => { 58 | // Mark a link as NSFW 59 | const body = { id }; 60 | return apiRequest(apiOptions, 'POST', 'api/marknsfw', { body, type: 'form' }); 61 | } 62 | 63 | const unmarkNSFW = (apiOptions, id) => { 64 | // Unmark a link as NSFW 65 | const body = { id }; 66 | return apiRequest(apiOptions, 'POST', 'api/unmarknsfw', { body, type: 'form' }); 67 | } 68 | 69 | const lock = (apiOptions, id) => { 70 | // Lock a link 71 | const body = { id }; 72 | return apiRequest(apiOptions, 'POST', 'api/lock', { body, type: 'form' }); 73 | } 74 | 75 | const unlock = (apiOptions, id) => { 76 | // Unlock a link 77 | const body = { id }; 78 | return apiRequest(apiOptions, 'POST', 'api/unlock', { body, type: 'form' }); 79 | } 80 | 81 | const spoiler = (apiOptions, id) => { 82 | // Spoiler a post 83 | const body = { id }; 84 | return apiRequest(apiOptions, 'POST', 'api/spoiler', { body, type: 'form' }); 85 | } 86 | 87 | const unspoiler = (apiOptions, id) => { 88 | // Unspoiler a post 89 | const body = { id }; 90 | return apiRequest(apiOptions, 'POST', 'api/unspoiler', { body, type: 'form' }); 91 | } 92 | 93 | /** 94 | * Set or unset a stickied post (AKA an "Annoucement"). 95 | * See also: https://www.reddit.com/dev/api#POST_api_set_subreddit_sticky 96 | * @function 97 | * @param {Object} apiOptions 98 | * @param {string} fullname The fullname of the target post 99 | * @param {boolean} isStickied Whether to sticky or unsticky the post 100 | * @param {?number} [stickyNum] Allows for specifying the "slot" to sticky the post 101 | * into, or for specifying which post to unsticky. 102 | */ 103 | const setSubredditSticky = (apiOptions, fullname, isStickied, stickyNum=null) => { 104 | const body = { 105 | id: fullname, 106 | state: isStickied, 107 | }; 108 | 109 | if (stickyNum) { 110 | body.num = stickyNum; 111 | } 112 | 113 | return apiRequest(apiOptions, 'POST', 'api/set_subreddit_sticky', { body, type: 'form' }); 114 | } 115 | 116 | /** 117 | * Sticky or unsticky a comment. 118 | * Sticky comments are a special case of distinguished comments, and are done 119 | * through the same API endpoint (api/distinguish). That endpoint also handles 120 | * distinguishing posts, but it does *not* handle sticky posts. To avoid 121 | * confusion, we'll keep sticky comments separated here. 122 | * @function 123 | * @param {Object} apiOptions 124 | * @param {string} fullname The fullname of the target comment 125 | * @param {boolean} isStickied Whether to sticky or unsticky the comment 126 | */ 127 | const setStickyComment = (apiOptions, fullname, isStickied) => { 128 | const distinguishType = isStickied ? DISTINGUISH_TYPES.MODERATOR : DISTINGUISH_TYPES.NONE; 129 | return distinguish(apiOptions, fullname, distinguishType, isStickied); 130 | }; 131 | 132 | export default { 133 | remove, 134 | approve, 135 | distinguish, 136 | markNSFW, 137 | unmarkNSFW, 138 | lock, 139 | unlock, 140 | spoiler, 141 | unspoiler, 142 | setStickyComment, 143 | setSubredditSticky, 144 | DISTINGUISH_TYPES, 145 | }; 146 | -------------------------------------------------------------------------------- /src/apis/SubredditRulesEndpoint.es6.js: -------------------------------------------------------------------------------- 1 | import apiRequest from '../apiBase/apiRequest'; 2 | import SubredditRule from '../models2/SubredditRule'; 3 | 4 | const ADD_RULE_PATH = 'api/add_subreddit_rule'; 5 | const REMOVE_RULE_PATH = 'api/remove_subreddit_rule'; 6 | const UPDATE_RULE_PATH = 'api/update_subreddit_rule'; 7 | 8 | export default { 9 | /** 10 | * Get the rules for the given subreddit. 11 | * Models are added to the APIResponse object, and can be retrieved using 12 | * its `getModelFromRecord` method. E.g. 13 | * 14 | * const res = await SubredditRulesEndpoint.get(apiOptions, { id: 'beta' }); 15 | * const rules = res.results.map(r => res.getModelFromRecord(r)); 16 | * 17 | * @function 18 | * @param {Object} apiOptions 19 | * @param {string} subredditName The name of a subreddit 20 | * @returns {Promise} A promise resolving with the ApiResponse 21 | */ 22 | async get(apiOptions, subredditName) { 23 | const path = `r/${subredditName}/about/rules.json`; 24 | const query = { 25 | raw_json: 1, 26 | }; 27 | 28 | const apiResponse = await apiRequest(apiOptions, 'GET', path, { query }); 29 | const { rules } = apiResponse.response.body; 30 | 31 | if (!(rules && rules.length)) { return apiResponse; } 32 | 33 | rules.forEach(rule => { 34 | // The SubredditRule model expects the rule to contain the name of the 35 | // subreddit to which the rule belongs in order to make UUIDs. That is 36 | // not actually returned from the API, though, so add it now. 37 | rule.subredditName = subredditName; 38 | apiResponse.addResult(SubredditRule.fromJSON(rule)) 39 | }); 40 | 41 | return apiResponse; 42 | }, 43 | 44 | /** 45 | * Create a new subreddit rule. 46 | * 47 | * @function 48 | * @param {Object} apiOptions 49 | * @param {string} subredditName The name of a subreddit 50 | * @param {Object} data 51 | * @param {string} data.description Markdown formatted description of the rule 52 | * @param {SubredditRule~RULE_TARGET} data.kind The types of things the rule applies to 53 | * @param {string} data.shortName A short, plaintext title for the rule 54 | * @param {?string} data.violationReason A short, plaintext string to use for reporting 55 | * a violation of the rule. If omitted, the shortName will be used. 56 | */ 57 | async post(apiOptions, subredditName, data) { 58 | const path = ADD_RULE_PATH; 59 | const body = { 60 | api_type: 'json', 61 | raw_json: 1, 62 | r: subredditName, 63 | description: data.description, 64 | kind: data.kind, 65 | short_name: data.shortName, 66 | }; 67 | 68 | if (data.violationReason) { 69 | body.violation_reason = data.violation_reason; 70 | } 71 | 72 | return apiRequest(apiOptions, 'POST', path, { body, type: 'form' }); 73 | }, 74 | 75 | /** 76 | * Update a subreddit rule. 77 | * @function 78 | * @param {Object} apiOptions 79 | * @param {string} subredditName The name of a subreddit 80 | * @param {string} shortName The target rule's current shortName 81 | * @param {Object} data 82 | * @param {string} data.description Markdown formatted description of the rule 83 | * @param {SubredditRule~RULE_TARGET} data.kind The types of things the rule applies to 84 | * @param {string} data.shortName A short, plaintext title for the rule 85 | */ 86 | async put(apiOptions, subredditName, shortName, data) { 87 | const path = UPDATE_RULE_PATH; 88 | const body = { 89 | api_type: 'json', 90 | raw_json: 1, 91 | r: subredditName, 92 | old_short_name: shortName, 93 | description: data.description, 94 | kind: data.kind, 95 | short_name: data.shortName, 96 | }; 97 | 98 | // Reddit's API will return the value of short_name if violation_reason doesn't exist. 99 | // To support pulling down a rule, editing an unrelated field (e.g. description) and 100 | // putting it back to the API w/o side effects, we should treat violationReason as empty 101 | // if it is identical to shortName. It's necessary to use the old value of shortName 102 | // here so that we don't keep it as the violationReason if only the shortName changes. 103 | if (data.violationReason && data.violationReason !== shortName) { 104 | data.violation_reason = data.violationReason; 105 | } 106 | 107 | return apiRequest(apiOptions, 'POST', path, { body, type: 'form' }); 108 | }, 109 | 110 | /** 111 | * Delete a subreddit rule. 112 | * @function 113 | * @param {Object} apiOptions 114 | * @param {string} subredditName The name of a subreddit 115 | * @param {string} shortName The target rule's current shortName 116 | */ 117 | async del(apiOptions, subredditName, shortName) { 118 | const path = REMOVE_RULE_PATH; 119 | const body = { 120 | api_type: 'json', 121 | raw_json: 1, 122 | r: subredditName, 123 | short_name: shortName, 124 | }; 125 | 126 | return apiRequest(apiOptions, 'POST', path, { body, type: 'form' }); 127 | }, 128 | }; 129 | -------------------------------------------------------------------------------- /src/models2/Subreddit.es6.js: -------------------------------------------------------------------------------- 1 | import RedditModel from './RedditModel'; 2 | import { SUBREDDIT } from './thingTypes'; 3 | import subscriptions from '../apis/subscriptions'; 4 | 5 | 6 | const T = RedditModel.Types; 7 | 8 | // If the data doesn't have all of the keys, get the full subreddit data 9 | // and then merge in the changes and submit _that_. The API requires the 10 | // full object be sent. 11 | // Whoever uses this new model for posting should confirm that 12 | // this is the full list of edit fields, you may just be able to 13 | // say something like 14 | const EDIT_FIELDS = [ 15 | 'default_set', 16 | 'subreddit_id', 17 | 'domain', 18 | 'show_media', 19 | 'wiki_edit_age', 20 | 'submit_text', 21 | 'spam_links', 22 | 'title', 23 | 'collapse_deleted_comments', 24 | 'wikimode', 25 | 'over_18', 26 | 'related_subreddits', 27 | 'suggested_comment_sort', 28 | 'description', 29 | 'submit_link_label', 30 | 'spam_comments', 31 | 'spam_selfposts', 32 | 'submit_text_label', 33 | 'key_color', 34 | 'language', 35 | 'wiki_edit_karma', 36 | 'hide_ads', 37 | 'header_hover_text', 38 | 'public_traffic', 39 | 'public_description', 40 | 'comment_score_hide_mins', 41 | 'subreddit_type', 42 | 'exclude_banned_modqueue', 43 | 'submission_type', 44 | ].sort(); 45 | 46 | export default class Subreddit extends RedditModel { 47 | static type = SUBREDDIT; 48 | 49 | static fields = EDIT_FIELDS; 50 | 51 | static PROPERTIES = { 52 | accountsActive: T.number, 53 | advertiserCategory: T.string, 54 | bannerImage: T.string, 55 | bannerSize: T.arrayOf(T.number), 56 | collapseDeletedComments: T.bool, 57 | commentScoreHideMins: T.number, 58 | createdUTC: T.number, 59 | description: T.string, 60 | descriptionHTML: T.string, 61 | displayName: T.string, 62 | headerImage: T.string, 63 | headerSize: T.arrayOf(T.number), 64 | headerTitle: T.string, 65 | hideAds: T.bool, 66 | iconImage: T.string, 67 | iconSize: T.arrayOf(T.number), 68 | id: T.string, 69 | keyColor: T.string, 70 | lang: T.string, 71 | name: T.string, 72 | over18: T.bool, 73 | publicDescription: T.string, 74 | publicTraffic: T.nop, 75 | quarantine: T.bool, 76 | relatedSubreddits: T.array, 77 | spoilersEnabled: T.bool, 78 | submissionType: T.string, 79 | submitLinkLabel: T.string, 80 | submitText: T.string, 81 | submitTextLabel: T.string, 82 | subredditType: T.string, 83 | subscribers: T.number, 84 | suggestedCommentSort: T.string, 85 | title: T.string, 86 | url: T.string, 87 | userIsBanned: T.bool, 88 | userIsContributor: T.bool, 89 | userIsModerator: T.bool, 90 | userIsMuted: T.bool, 91 | userIsSubscriber: T.bool, 92 | userSrThemeEnabled: T.bool, 93 | wikiEnabled: T.bool, 94 | }; 95 | 96 | static API_ALIASES = { 97 | accounts_active: 'accountsActive', 98 | advertiser_category: 'advertiserCategory', 99 | banner_img: 'bannerImage', 100 | banner_size: 'bannerSize', 101 | collapse_deleted_comments: 'collapseDeletedComments', 102 | comment_score_hide_mins: 'commentScoreHideMins', 103 | created_utc: 'createdUTC', 104 | description_html: 'descriptionHTML', 105 | display_name: 'displayName', 106 | header_img: 'headerImage', 107 | header_size: 'headerSize', 108 | header_title: 'headerTitle', 109 | hide_ads: 'hideAds', 110 | icon_img: 'iconImage', 111 | icon_size: 'iconSize', 112 | key_color: 'keyColor', 113 | public_description: 'publicDescription', 114 | public_traffic: 'publicTraffic', 115 | related_subreddits: 'relatedSubreddits', 116 | spoilers_enabled: 'spoilersEnabled', 117 | submission_type: 'submissionType', 118 | submit_link_label: 'submitLinkLabel', 119 | submit_text_label: 'submitTextLabel', 120 | submit_text: 'submitText', 121 | subreddit_type: 'subredditType', 122 | user_is_banned: 'userIsBanned', 123 | user_is_contributor: 'userIsContributor', 124 | user_is_moderator: 'userIsModerator', 125 | user_is_muted: 'userIsMuted', 126 | user_is_subscriber: 'userIsSubscriber', 127 | user_sr_theme_enabled: 'userSrThemeEnabled', 128 | wiki_enabled: 'wikiEnabled', 129 | }; 130 | 131 | static cleanName = (name) => { 132 | if (!name) { return name; } 133 | return name.replace(/^\/?r\//, '').replace(/\/?$/, '').toLowerCase(); 134 | }; 135 | 136 | // we want to be able to lookup subreddits by name. This way when you have a 137 | // a permalink url with the subredddit name or someone types in a subreddit name 138 | // in the goto field we can look-up the subreddit in our cache without converting 139 | // the name to a thing_id. 140 | makeUUID(data) { 141 | const { url } = data; 142 | return Subreddit.cleanName(url); 143 | } 144 | 145 | makePaginationId(data) { 146 | return data.name; // this is the thing fullname 147 | } 148 | 149 | toggleSubscribed(apiOptions) { 150 | const { userIsSubscriber } = this; 151 | const toggled = !userIsSubscriber; 152 | const oldModel = this; 153 | 154 | const stub = this.stub('userIsSubscriber', toggled, async () => { 155 | try { 156 | const data = { subreddit: oldModel.name }; 157 | const endpoint = toggled ? subscriptions.post : subscriptions.del; 158 | await endpoint(apiOptions, data); 159 | return stub; 160 | } catch (e) { 161 | throw oldModel; 162 | } 163 | }); 164 | 165 | return stub; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/models2/PostModel.es6.js: -------------------------------------------------------------------------------- 1 | import RedditModel from './RedditModel'; 2 | import { POST } from './thingTypes'; 3 | import votable from './mixins/votable'; 4 | import replyable from './mixins/replyable'; 5 | 6 | const T = RedditModel.Types; 7 | 8 | const IGNORED_THUMBNAILS = new Set(['default', 'image', 'self', 'nsfw', 'spoiler']); 9 | const cleanThumbnail = thumbnail => { 10 | return IGNORED_THUMBNAILS.has(thumbnail) ? '' : thumbnail; 11 | }; 12 | 13 | export default class PostModel extends RedditModel { 14 | static type = POST; 15 | 16 | static PROPERTIES = { 17 | adserverImpPixel: T.string, 18 | archived: T.bool, 19 | author: T.string, 20 | cleanPermalink: T.link, 21 | cleanUrl: T.link, 22 | distinguished: T.string, 23 | domain: T.string, 24 | downs: T.number, 25 | gilded: T.number, 26 | hidden: T.bool, 27 | id: T.string, 28 | impPixel: T.string, 29 | likes: T.likes, 30 | locked: T.bool, 31 | malink: T.link, 32 | media: T.nop, 33 | name: T.string, 34 | over18: T.bool, 35 | postHint: T.string, 36 | promoted: T.bool, 37 | quarantine: T.bool, 38 | saved: T.bool, 39 | score: T.number, 40 | spoiler: T.bool, 41 | stickied: T.bool, 42 | subreddit: T.string, 43 | subredditDetail: T.nop, 44 | subredditId: T.string, 45 | thumbnail: T.string, 46 | title: T.string, 47 | ups: T.number, 48 | removed: T.bool, 49 | approved: T.bool, 50 | spam: T.bool, 51 | 52 | // aliases 53 | approvedBy: T.string, 54 | authorFlairCSSClass: T.string, 55 | authorFlairText: T.string, 56 | bannedBy: T.string, 57 | createdUTC: T.number, 58 | disableComments: T.bool, 59 | hideScore: T.bool, 60 | isSelf: T.bool, 61 | isBlankAd: T.bool, 62 | linkFlairCSSClass: T.string, 63 | linkFlairText: T.string, 64 | mediaOembed: T.nop, 65 | modReports: T.array, 66 | numComments: T.number, 67 | originalLink: T.string, 68 | outboundLink: T.nop, 69 | promotedBy: T.string, 70 | promotedDisplayName: T.string, 71 | promotedUrl: T.string, 72 | secureMedia: T.nop, 73 | selfTextHTML: T.string, // html version for display 74 | selfTextMD: T.string, // markdown version for editing 75 | sendReplies: T.bool, 76 | suggestedSort: T.string, 77 | thirdPartyTracking: T.string, 78 | thirdPartyTracking2: T.string, 79 | userReports: T.array, 80 | 81 | // derived 82 | expandable: T.bool, 83 | expandedContent: T.html, 84 | preview: T.nop, // it's in data as well but we want to transform it 85 | }; 86 | 87 | static API_ALIASES = { 88 | adserver_imp_pixel: 'adserverImpPixel', 89 | approved_by: 'approvedBy', 90 | author_flair_css_class: 'authorFlairCSSClass', 91 | author_flair_text: 'authorFlairText', 92 | banned_by: 'bannedBy', 93 | created_utc: 'createdUTC', 94 | disable_comments: 'disableComments', 95 | hide_score: 'hideScore', 96 | imp_pixel: 'impPixel', 97 | is_self: 'isSelf', 98 | is_blank_ad: 'isBlankAd', 99 | link_flair_css_class: 'linkFlairCSSClass', 100 | link_flair_text: 'linkFlairText', 101 | media_oembed: 'mediaOembed', 102 | mod_reports: 'modReports', 103 | num_comments: 'numComments', 104 | original_link: 'originalLink', 105 | over_18: 'over18', 106 | outbound_link: 'outboundLink', 107 | permalink: 'cleanPermalink', 108 | promoted_by: 'promotedBy', 109 | promoted_display_name: 'promotedDisplayName', 110 | promoted_url: 'promotedUrl', 111 | post_hint: 'postHint', 112 | secure_media: 'secureMedia', 113 | selftext: 'selfTextMD', 114 | selftext_html: 'selfTextHTML', 115 | suggested_sort: 'suggestedSort', 116 | sr_detail: 'subredditDetail', 117 | subreddit_id: 'subredditId', 118 | sendreplies: 'sendReplies', 119 | third_party_tracking: 'thirdPartyTracking', 120 | third_party_tracking_2: 'thirdPartyTracking2', 121 | url: 'cleanUrl', 122 | user_reports: 'userReports', 123 | }; 124 | 125 | // Note: derived properties operate on the json passed to 126 | // Post.fromJson(). If the model is being updated with `.set` (voting uses this) 127 | // or it's being re-instanced after serializing on the server, we 128 | // will have the derived property, but we might not have all of the original 129 | // json the api returns. To handle this, we re-use the computed props when necessary. 130 | static DERIVED_PROPERTIES = { 131 | expandable(data) { 132 | if (data.expandable) { 133 | return data.expandable; 134 | } 135 | 136 | // If it has secure_media, or media, or selftext, it has expandable. 137 | return !!( 138 | (data.secure_media && data.secure_media.content) || 139 | (data.media_embed && data.media_embed.content) || 140 | (data.selftext_html) 141 | ); 142 | }, 143 | 144 | expandedContent(data) { 145 | if (data.expandedContent) { 146 | return data.expandedContent; 147 | } 148 | 149 | let content; 150 | 151 | content = ( 152 | (data.secure_media_embed && data.secure_media_embed.content) || 153 | (data.media_embed && data.media_embed.content) 154 | ); 155 | 156 | if (!content && data.selftext_html) { 157 | content = data.selftext_html; 158 | } 159 | 160 | return content; 161 | }, 162 | 163 | preview(data) { 164 | if (!data.promoted || data.preview) { 165 | return data.preview; 166 | } 167 | 168 | // we build fake preview data for ads and normal thumbnails 169 | const resolutions = []; 170 | 171 | if (data.mobile_ad_url) { 172 | resolutions.push({ 173 | url: data.mobile_ad_url, 174 | height: 628, 175 | width: 1200, 176 | }); 177 | } 178 | 179 | const thumbnail = cleanThumbnail(data.thumbnail); 180 | if (thumbnail) { 181 | resolutions.push({ 182 | url: thumbnail, 183 | height: 140, 184 | width: 140, 185 | }); 186 | } 187 | 188 | return { 189 | images: [{ 190 | resolutions, 191 | }], 192 | }; 193 | }, 194 | 195 | thumbnail(data) { 196 | return cleanThumbnail(data.thumbnail); 197 | }, 198 | }; 199 | } 200 | 201 | votable(PostModel); 202 | replyable(PostModel); 203 | -------------------------------------------------------------------------------- /src/apiBase/APIRequestUtils.es6.js: -------------------------------------------------------------------------------- 1 | import superagent from 'superagent'; 2 | 3 | import Events from './Events'; 4 | import { APIResponse } from './APIResponse'; 5 | import NoModelError from './errors/NoModelError'; 6 | import ResponseError from './errors/ResponseError'; 7 | import ValidationError from './errors/ValidationError'; 8 | 9 | 10 | const EventEmitterShim = { 11 | emit: () => {}, 12 | on: () => {}, 13 | off: () => {}, 14 | }; 15 | 16 | const DefaultOptions = { 17 | userAgent: 'snoodev3', 18 | origin: 'https://www.reddit.com', 19 | appName: 'node-api-client-v3', 20 | env: 'develop', 21 | token: '', 22 | timeout: 5000, 23 | eventEmitter: EventEmitterShim, 24 | }; 25 | 26 | export const makeOptions = (overrides={}) => { 27 | return { 28 | ...DefaultOptions, 29 | ...overrides, 30 | }; 31 | }; 32 | 33 | const getEmitter = (apiOptions) => { 34 | return (apiOptions.eventEmitter || EventEmitterShim); 35 | }; 36 | 37 | const requestAuthHeader = (apiOptions) => { 38 | const token = apiOptions.token; 39 | if (!token) { return {}; } 40 | return { Authorization: `Bearer ${token}` }; 41 | }; 42 | 43 | const requestHeaders = (apiOptions) => { 44 | const authHeaders = requestAuthHeader(apiOptions); 45 | return { 46 | ...(apiOptions.headers || {}), 47 | ...authHeaders, 48 | }; 49 | }; 50 | 51 | const requestPath = (apiOptions, path) => { 52 | let slash = '/'; 53 | 54 | if (path.indexOf('/') === 0) { 55 | slash = ''; 56 | } 57 | 58 | return `${apiOptions.origin}${slash}${path}`; 59 | }; 60 | 61 | const appParameter = (apiOptions) => { 62 | return `${apiOptions.appName}-${apiOptions.env}`; 63 | }; 64 | 65 | // DEPRECATED: use apiRequest instead 66 | export const rawSend = (apiOptions, method, path, data, type, cb) => { 67 | const origin = apiOptions.origin; 68 | const url = requestPath(apiOptions, path); 69 | 70 | const fakeReq = { 71 | origin, 72 | path, 73 | url, 74 | method, 75 | query: { ...data}, 76 | }; 77 | 78 | getEmitter(apiOptions).emit(Events.request, fakeReq); 79 | let s = superagent[method](url); 80 | s.set(requestHeaders(apiOptions)); 81 | 82 | if (type === 'query') { 83 | data.app = appParameter(apiOptions); 84 | s.query({ 85 | ...(apiOptions.queryParams || {}), 86 | ...data, 87 | app: appParameter(apiOptions), 88 | }); 89 | 90 | if (s.redirects) { 91 | s.redirects(0); 92 | } 93 | } else { 94 | s.query({ app: appParameter(apiOptions) }); 95 | s.type(type); 96 | s.send(data); 97 | } 98 | 99 | s.end((err, res) => { 100 | // handle super agent inconsistencies 101 | const req = res ? res.request : fakeReq; 102 | cb(err, res, req); 103 | }); 104 | }; 105 | 106 | export const validateData = (data, method, apiName, validator) => { 107 | if (!(data && validator)) { throw new ValidationError(apiName, undefined); } 108 | if (!validator(data)) { throw new ValidationError(apiName, data); } 109 | }; 110 | 111 | 112 | // DEPRECATED: use apiRequest instead 113 | export const runJson = (apiOptions, method, path, data, parseBody, parseMeta) => { 114 | if (!(apiOptions && method && path && data)) { throw new NoModelError(); } 115 | 116 | return new Promise((resolve, reject) => { 117 | rawSend(apiOptions, method, path, data, 'json', (err, res, req) => { 118 | handle(apiOptions, resolve, reject, err, res, req, method, path, data, 119 | parseBody, parseMeta); 120 | }); 121 | }); 122 | } 123 | 124 | // DEPRECATED: use apiRequest instead 125 | export const runForm = (apiOptions, method, path, data, parseBody, parseMeta) => { 126 | if (!(apiOptions && method && path && data)) { throw new NoModelError(); } 127 | 128 | return new Promise((resolve, reject) => { 129 | rawSend(apiOptions, method, path, data, 'form', (err, res, req) => { 130 | handle(apiOptions, resolve, reject, err, res, req, method, path, data, 131 | parseBody, parseMeta); 132 | }); 133 | }); 134 | }; 135 | 136 | // DEPRECATED: use apiRequest instead 137 | export const runQuery = (apiOptions, method, path, query, rawQuery, parseBody, parseMeta) => { 138 | if (!(apiOptions && method && path && query && rawQuery)) { throw new NoModelError(); } 139 | 140 | if (method === 'get') { 141 | query.raw_json = 1; 142 | } 143 | 144 | return new Promise((resolve, reject) => { 145 | rawSend(apiOptions, method, path, query, 'query', (err, res, req) => { 146 | handle(apiOptions, resolve, reject, err, res, req, method, path, rawQuery, 147 | parseBody, parseMeta); 148 | }); 149 | }); 150 | }; 151 | 152 | const normalizeRequest = (res, req) => { 153 | if (res && !req) { 154 | return res.request || res.req; 155 | } 156 | 157 | return req; 158 | }; 159 | 160 | const handle = (apiOptions, resolve, reject, err, res, req, method, path, query, 161 | parseBody, parseMeta) => { 162 | 163 | req = normalizeRequest(res, req); 164 | 165 | if (handleRequestIfFailed(apiOptions, err, res, req, method, path, reject)) { 166 | return; 167 | } 168 | 169 | getEmitter(apiOptions).emit(Events.response, req, res); 170 | 171 | const apiResponse = tryParseResponse(reject, res, req, method, path, query, parseBody, parseMeta); 172 | 173 | getEmitter(apiOptions).emit(Events.result, apiResponse); 174 | resolve(apiResponse); 175 | }; 176 | 177 | const handleRequestIfFailed = (apiOptions, err, res, req, method, path, reject) => { 178 | if ((!err && !res) || (res && res.ok)) { return; } 179 | 180 | if (err) { 181 | getEmitter(apiOptions).emit(Events.error, err, req); 182 | 183 | if (err && err.timeout) { 184 | err.status = 504; 185 | } 186 | 187 | return reject(new ResponseError(err, path)); 188 | } 189 | 190 | // otherwise there's res and res.ok === false 191 | return reject(new ResponseError(res, path)); 192 | }; 193 | 194 | const tryParseResponse = (reject, res, req, method, path, query, parseBody, parseMeta) => { 195 | try { 196 | return makeApiResponse(res, req, method, query, parseBody, parseMeta); 197 | } catch (e) { 198 | console.trace(e); 199 | reject(new ResponseError(e, path)); 200 | } 201 | }; 202 | 203 | const makeApiResponse = (res, req, method, query, parseBody, parseMeta) => { 204 | if (!parseBody) { return res.body; } 205 | const meta = parseMeta ? parseMeta(res, req, method) : res.headers; 206 | const apiResponse = new APIResponse(res, meta, query); 207 | parseBody(res, apiResponse, req, method); 208 | return apiResponse; 209 | }; 210 | -------------------------------------------------------------------------------- /src/index.es6.js: -------------------------------------------------------------------------------- 1 | import { makeOptions, rawSend } from './apiBase/APIRequestUtils'; 2 | 3 | // import captcha from './apis/captcha'; 4 | // import modListing from './apis/modListing'; 5 | // import multis from './apis/multis'; 6 | // import multiSubscriptions from './apis/multiSubscriptions'; 7 | // import reports from './apis/reports'; 8 | // import stylesheets from './apis/stylesheets'; 9 | // import subredditRelationships from './apis/subredditRelationships'; 10 | // import trophies from './apis/trophies'; 11 | // import votes from './apis/votes'; 12 | // import wiki from './apis/wiki'; 13 | 14 | // CommentsEndpoint must be imported first followed by PostsEndpoint. 15 | // This is because the PostsEndpoint requires the PostModel which uses the replyable 16 | // mixin which requires the CommentsEndpoint. If they're imported out of order 17 | // endpoints that rely on both Comments and Posts will break in suspicous ways :( 18 | import CommentsEndpoint from './apis/CommentsEndpoint'; 19 | import PostsEndpoint from './apis/PostsEndpoint'; 20 | 21 | import AccountsEndpoint from './apis/accounts'; 22 | import ActivitiesEndpoint from './apis/activities'; 23 | import EditUserTextEndpoint from './apis/EditUserTextEndpoint'; 24 | import HiddenEndpoint from './apis/HiddenEndpoint'; 25 | import Modtools from './apis/modTools'; 26 | import PreferencesEndpoint from './apis/PreferencesEndpoint'; 27 | import RecommendedSubreddits from './apis/RecommendedSubreddits'; 28 | import SavedEndpoint from './apis/SavedEndpoint'; 29 | import SearchEndpoint from './apis/SearchEndpoint'; 30 | import SimilarPosts from './apis/SimilarPosts'; 31 | import SubredditAutocomplete from './apis/SubredditAutocomplete'; 32 | import subscriptions from './apis/subscriptions'; 33 | import SubredditEndpoint from './apis/SubredditEndpoint'; 34 | import SubredditsByPost from './apis/SubredditsByPost'; 35 | import SubredditRulesEndpoint from './apis/SubredditRulesEndpoint'; 36 | import SubredditsToPostsByPost from './apis/SubredditsToPostsByPost'; 37 | import WikisEndpoint from './apis/wikis'; 38 | import MessagesEndpoint from './apis/MessagesEndpoint'; 39 | 40 | import { APIResponse, MergedApiReponse } from './apiBase/APIResponse'; 41 | import Model from './apiBase/Model'; 42 | import Record from './apiBase/Record'; 43 | import * as ModelTypes from './models2/thingTypes'; 44 | import apiRequest from './apiBase/apiRequest'; 45 | 46 | import { 47 | withQueryAndResult, 48 | afterResponse, 49 | beforeResponse, 50 | fetchAll, 51 | } from './apiBase/APIResponsePaging'; 52 | 53 | export const APIResponses = { 54 | APIResponse, 55 | MergedApiReponse, 56 | }; 57 | 58 | export const APIResponsePaging = { 59 | withQueryAndResult, 60 | afterResponse, 61 | beforeResponse, 62 | fetchAll, 63 | }; 64 | 65 | export const endpoints = { 66 | // captcha, 67 | // modListing, 68 | // multis, 69 | // multiSubscriptions, 70 | // reports, 71 | // stylesheets, 72 | // subredditRelationships, 73 | // subscriptions, 74 | // trophies, 75 | // votes, 76 | // wiki, 77 | AccountsEndpoint, 78 | ActivitiesEndpoint, 79 | EditUserTextEndpoint, 80 | CommentsEndpoint, 81 | HiddenEndpoint, 82 | Modtools, 83 | PostsEndpoint, 84 | PreferencesEndpoint, 85 | RecommendedSubreddits, 86 | SavedEndpoint, 87 | SearchEndpoint, 88 | SimilarPosts, 89 | subscriptions, 90 | SubredditAutocomplete, 91 | SubredditsByPost, 92 | SubredditRulesEndpoint, 93 | SubredditsToPostsByPost, 94 | SubredditEndpoint, 95 | WikisEndpoint, 96 | MessagesEndpoint, 97 | }; 98 | 99 | import NoModelError from './apiBase/errors/NoModelError'; 100 | import ResponseError from './apiBase/errors/ResponseError'; 101 | import { DisconnectedError } from './apiBase/errors/ResponseError'; 102 | import ValidationError from './apiBase/errors/ValidationError'; 103 | import BadCaptchaError from './apiBase/errors/BadCaptchaError'; 104 | import NotImplementedError from './apiBase/errors/NotImplementedError'; 105 | 106 | export const errors = { 107 | NoModelError, 108 | ValidationError, 109 | ResponseError, 110 | DisconnectedError, 111 | NotImplementedError, 112 | BadCaptchaError, 113 | }; 114 | 115 | // import Award from './models/award'; 116 | // import Base from './models/base'; 117 | // import Block from './models/block'; 118 | // import BlockedUser from './models/BlockedUser'; 119 | // import Message from './models/message'; 120 | // import PromoCampaign from './models/promocampaign'; 121 | // import Subscription from './models/subscription'; 122 | // import Vote from './models/vote'; 123 | // import Report from './models/report'; 124 | // import WikiPage from './models/wikiPage'; 125 | // import WikiRevision from './models/wikiRevision'; 126 | // import WikiPageListing from './models/wikiPageListing'; 127 | // import WikiPageSettings from './models/wikiPageSettings'; 128 | 129 | // new models 130 | import Account from './models2/Account'; 131 | import CommentModel from './models2/CommentModel'; 132 | import PostModel from './models2/PostModel'; 133 | import Preferences from './models2/Preferences'; 134 | import Subreddit from './models2/Subreddit'; 135 | import SubredditRule from './models2/SubredditRule'; 136 | import Wiki from './models2/Wiki'; 137 | 138 | import { 139 | SubscribedSubreddits, 140 | ModeratingSubreddits, 141 | ContributingSubreddits, 142 | } from './collections/SubredditLists'; 143 | 144 | import CommentsPage from './collections/CommentsPage'; 145 | import HiddenPostsAndComments from './collections/HiddenPostsAndComments'; 146 | import PostsFromSubreddit from './collections/PostsFromSubreddit'; 147 | import SavedPostsAndComments from './collections/SavedPostsAndComments'; 148 | import SearchQuery from './collections/SearchQuery'; 149 | 150 | export const models = { 151 | // Award, 152 | // Base, 153 | // Block, 154 | // BlockedUser, 155 | // Message, 156 | // PromoCampaign, 157 | // Subreddit, 158 | // Subscription, 159 | // Vote, 160 | // Report, 161 | // WikiPage, 162 | // WikiRevision, 163 | // WikiPageListing, 164 | // WikiPageSettings, 165 | Model, 166 | ModelTypes, 167 | Record, 168 | 169 | Account, 170 | CommentModel, 171 | PostModel, 172 | Preferences, 173 | Subreddit, 174 | SubredditRule, 175 | 176 | Wiki, 177 | }; 178 | 179 | export const collections = { 180 | CommentsPage, 181 | ContributingSubreddits, 182 | HiddenPostsAndComments, 183 | ModeratingSubreddits, 184 | PostsFromSubreddit, 185 | SavedPostsAndComments, 186 | SearchQuery, 187 | SubscribedSubreddits, 188 | }; 189 | 190 | const DEFAULT_API_ORIGIN = 'https://www.reddit.com'; 191 | const AUTHED_API_ORIGIN = 'https://oauth.reddit.com'; 192 | 193 | // Webpack 2 has an export bug where a library's export object does not state 194 | // that it is an es6 module. Without this tag defined on the exports object, 195 | // Webpack does not import the library correctly. 196 | export const __esModule = true; 197 | 198 | const DefaultOptions = { 199 | origin: DEFAULT_API_ORIGIN, 200 | userAgent: 'snoodev3', 201 | appName: 'snoodev3', 202 | env: process.env.NODE_ENV || 'dev', 203 | }; 204 | 205 | export default makeOptions(DefaultOptions); 206 | 207 | export const requestUtils = { 208 | rawSend, 209 | apiRequest, 210 | }; 211 | 212 | export const optionsWithAuth = token => { 213 | return { 214 | ...DefaultOptions, 215 | token, 216 | origin: token ? AUTHED_API_ORIGIN : DEFAULT_API_ORIGIN, 217 | }; 218 | }; 219 | -------------------------------------------------------------------------------- /src/apiBase/Model.es6.js: -------------------------------------------------------------------------------- 1 | import Record from './Record'; 2 | 3 | const fakeUUID = () => (Math.random() * 16).toFixed(); 4 | 5 | // Model class that handles parsing, serializing, and pseudo-validation. 6 | // Provides a mechanism for creating stubs (which will represent incremental UI updates) 7 | // and fulfill themselves to the proper result of api calls 8 | // 9 | // An example class will look like 10 | // 11 | // const T = Model.Types 12 | // class Post extends Model { 13 | // static type = LINK; 14 | // 15 | // static API_ALIASES = { 16 | // body_html: 'bodyHTML, 17 | // score_hidden: 'scoreHidden', 18 | // } 19 | // 20 | // static PROPERTIES = { 21 | // id: T.string, 22 | // author: T.string, 23 | // bodyHTML: T.html, 24 | // replies: T.array, 25 | // links: T.arrayOf(T.link) 26 | // cleanURL: T.link 27 | // } 28 | // } 29 | // 30 | export default class Model { 31 | static fromJSON(obj) { 32 | return new this(obj); 33 | } 34 | 35 | // put value transformers here. They'll take input and pseudo-validate it and 36 | // transform it. You'll put thme in your subclasses PROPERITES dictionary. 37 | static Types = { 38 | string: val => val ? String(val) : '', 39 | number: val => val === undefined ? 0 : Number(val), 40 | array: val => Array.isArray(val) ? val : [], 41 | arrayOf: (type=Model.Types.nop) => val => Model.Types.array(val).map(type), 42 | bool: val => Boolean(val), 43 | likes: val => { 44 | // coming from our api, these are booleans or null. Coming from 45 | // our stub method, these are actual integers 46 | switch (val) { 47 | case true: return 1; 48 | case false: return -1; 49 | case null: return 0; 50 | default: return val; 51 | } 52 | }, 53 | 54 | nop: val => val, 55 | 56 | /* examples of more semantic types you can build 57 | // some more semantic types that apply transformations 58 | html: val => process(Model.Types.string(val)), 59 | link: val => unredditifyLink(Model.Types.string(val)), 60 | */ 61 | }; 62 | 63 | static MockTypes = { 64 | string: () => Math.random().toString(36).substring(Math.floor(Math.random() * 10) + 5), 65 | number: () => Math.floor(Math.random() * 100), 66 | array: () => Array.apply(null, Array(Math.floor(Math.random() * 10))), 67 | bool: () => Math.floor(Math.random() * 10) < 5, 68 | likes: () => Math.round((Math.random() * (1 - -1) + -1)), 69 | nop: () => null, 70 | } 71 | 72 | static Mock() { 73 | const data = Object.keys(this.PROPERTIES).reduce((prev, cur) => ({ 74 | ...prev, 75 | [cur]: this.MOCKS[cur] ? this.MOCKS[cur]() : null, 76 | }), {}); 77 | 78 | return new this(data); 79 | } 80 | 81 | static API_ALIASES = {}; 82 | static PROPERTIES = {}; 83 | static MOCKS = {}; 84 | static DERIVED_PROPERTIES = {}; 85 | 86 | constructor(data, SUPER_SECRET_SHOULD_FREEZE_FLAG_THAT_ONLY_STUBS_CAN_USE) { 87 | const { API_ALIASES, PROPERTIES, DERIVED_PROPERTIES } = this.constructor; 88 | 89 | // Please note: the use of for loops and adding properties directly 90 | // and then freezing (versus using defineProperty with writeable false) 91 | // is very intentional. Because performance. Please consult schwers or frontend-platform 92 | // before modifying 93 | 94 | const dataKeys = Object.keys(data); 95 | for (let i = 0; i < dataKeys.length; i++) { 96 | const key = dataKeys[i]; 97 | if (DERIVED_PROPERTIES[key]) { // skip if there's a dervied key of the same name 98 | continue; 99 | } 100 | 101 | let keyName = API_ALIASES[key]; 102 | if (!keyName) { keyName = key; } 103 | 104 | const typeFn = PROPERTIES[keyName]; 105 | if (typeFn) { 106 | this[keyName] = typeFn(data[key]); 107 | } 108 | } 109 | 110 | for (let propName in PROPERTIES) { 111 | if (this[propName] === undefined) { 112 | this[propName] = PROPERTIES[propName](); 113 | } 114 | } 115 | 116 | const derivedKeys = Object.keys(DERIVED_PROPERTIES); 117 | for (let i = 0; i < derivedKeys.length; i++) { 118 | const derivedKey = derivedKeys[i]; 119 | const derviceFn = DERIVED_PROPERTIES[derivedKey]; 120 | const typeFn = PROPERTIES[derivedKey]; 121 | 122 | if (derviceFn && typeFn) { 123 | this[derivedKey] = typeFn(derviceFn(data)); 124 | } 125 | } 126 | 127 | this.uuid = this.makeUUID(data); 128 | this.paginationId = this.makePaginationId(data); 129 | this.type = this.getType(data, this.uuid); 130 | 131 | if (!SUPER_SECRET_SHOULD_FREEZE_FLAG_THAT_ONLY_STUBS_CAN_USE) { 132 | Object.freeze(this); 133 | } 134 | } 135 | 136 | _diff(keyOrObject, value) { 137 | return typeof keyOrObject === 'object' 138 | ? keyOrObject 139 | : { [keyOrObject]: value }; 140 | } 141 | 142 | set(keyOrObject, value) { 143 | return new this.constructor({...this.toJSON(), ...this._diff(keyOrObject, value)}); 144 | } 145 | 146 | // .stub() is for encoding optimistic updates and other transient states 147 | // while waiting for async actions. 148 | // 149 | // A reddit-example is voting. `link.upvote()` needs to handle 150 | // a few edgecases like: 'you already upvoted, let's toggle your vote', 151 | // 'you downvoted, so the score increase is really +2 for ui (instead of +1)', 152 | // and 'we need to add +1 to the score'. 153 | // It also needs to handle failure cases like 'that upvote failed, undo everything'. 154 | // 155 | // Stubs provide a way of encoding an optimistic ui update that includes 156 | // all of these cases, that use javascript promises to encode the completion 157 | // and final state of this. 158 | // 159 | // With stubs, `.upvote()` can return a stub object so that you can: 160 | // ```javascript 161 | // /* upvoteLink is a dispatch thunk */ 162 | // const upvoteLink = link => (dispatch, getState) => () => { 163 | // const stub = link.upvote(); 164 | // dispatch(newLinkData(stub)); 165 | // 166 | // stub.reject(error => { 167 | // dispatch(failedToUpvote(link)); 168 | // // Undo the optimistic ui update. Note: .upvote can choose to 169 | // // catch the reject and pass the old version back in Promise.resolve() 170 | // disaptch(newLinkData(link)) 171 | // }); 172 | // 173 | // return stub.then(finalLink => dispatch(newLinkData(finalLink)); 174 | // }; 175 | // ``` 176 | stub(keyOrObject, valueOrPromise, promise) { 177 | if (!promise) { 178 | promise = valueOrPromise; 179 | } 180 | 181 | const next = { ...this.toJSON(), ...this._diff(keyOrObject, valueOrPromise) }; 182 | const stub = new this.constructor(next, true); 183 | stub.promise = promise; 184 | Object.freeze(stub); // super important, don't break the super secret flag 185 | return stub; 186 | } 187 | 188 | makeUUID(data) { 189 | if (data.uuid) { return data.uuid; } 190 | if (data.id) { return data.id; } 191 | console.warn('generating fake uuid'); 192 | return fakeUUID(); 193 | } 194 | 195 | makePaginationId(data) { 196 | return this.uuid || this.makeUUID(data); 197 | } 198 | 199 | getType(/* data, uuid */) { 200 | return this.constructor.type; 201 | } 202 | 203 | toRecord() { 204 | return new Record(this.type, this.uuid, this.paginationId); 205 | } 206 | 207 | toJSON() { 208 | const obj = {}; 209 | Object.keys(this).forEach(key => { 210 | if (this.constructor.PROPERTIES[key]) { 211 | obj[key] = this[key]; 212 | } 213 | }); 214 | 215 | obj.__type = this.type; 216 | return obj; 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | @r/api-client 2 | ====== 3 | 4 | [![Build Status](https://travis-ci.org/reddit/node-api-client.svg?branch=3X)](https://travis-ci.org/reddit/node-api-client) 5 | 6 | A reddit API library for node and browsers. 7 | 8 | ```javascript 9 | // Require snoode. 10 | import APIOptions from '@r/api-client'; 11 | import { collections } from '@r/api-client'; 12 | const { PostsFromSubreddit } = collections; 13 | 14 | import { each } from 'lodash/collection'; 15 | 16 | let frontpage = await PostsFromSubreddit.fetch(APIOptions, 'highqualitygifs') 17 | each(Array(10), async () => { 18 | frontpage = await frontpage.withNextPage(APIOptions); 19 | }); 20 | 21 | frontpage.posts; // ~275 glorious gifs 22 | 23 | // Example with auth. 24 | // Pass in an oauth token and new origin to `withConfig`, which returns 25 | // a new instance that inherits the config from the existing api instance 26 | // merged with the new config. 27 | import { optionsWithAuth, collections } from '@r/api-client'; 28 | const { SavedPostsAndComments } = collections; 29 | 30 | const myOauthToken = 'abcdef1234567890'; 31 | const authedOptions = optionsWithAuth(myOauthToken); 32 | 33 | const dankestMemes = await SavedPostsAndComments.fetch(authedOptions, 'my-user-name'); 34 | console.log(dankestMems.postsAndComments); 35 | ``` 36 | 37 | ### API endpoints 38 | At its core, the api is made up of ApiEndpoints, APIResponses, Models, and Records. ApiEndpoints all use functions from [APIRequestUtils](/apiBase/APIRequestUtils.es6.js). It provides an easy way to build out a idealized restful wrapper for the api. Currently instances of the api endpoints are bound to API class (exported default when you import from the api client module). Using it looks like 39 | 40 | ```javascript 41 | // Require snoode. 42 | import APIOptions from '@r/api-client'; 43 | import { endpoints } from '@r/api-client'; 44 | const { PostsEndpoint, CommentsEndpoint } = endpoints; 45 | 46 | // Example call to get links for /r/homebrewing. 47 | PostsEndpoint.get(APIOptions, { 48 | subreddit: 'homebrewing', 49 | }).then(res => { 50 | console.log(res.results); 51 | console.log(res.getModelFromRecord(res.results[0])); 52 | }); 53 | 54 | // Example call to get all comments for this particular listing. 55 | CommentsEndpoint.get(APIOptions, { 56 | linkId: 't3_ib4bk' 57 | }).then(res => { 58 | console.log(res.results); 59 | console.log(res.getModelFromRecord(res.results[0])); 60 | console.log(res.comments); 61 | console.log(res.links); 62 | }); 63 | ``` 64 | 65 | ##### NEW from 3.5.X+: The apiclient endpoint and collection signatures have now changed to take an APIOptions object instead of an API instance. Please note that while the imports are little verbose now, there will be a subsequent minor version change which will allow piecemeal importing of only the code you want from the api. This will make imports cleaner and your payload smaller. 66 | 67 | ### Models and Records 68 | A [Record](/apiBase/Record.es6.js) is essentially a tuple of `(, { 156 | if (!data.preview) { 157 | // build a preview image based on media_oembed or the thumbanil 158 | return ... 159 | } 160 | 161 | return data.preview; 162 | }, 163 | }; 164 | } 165 | ``` 166 | 167 | ### APIResponses 168 | [APIResponse.es6.js](/apiBase/APIResponse.es6.js) defines the primary classes used to interact with responses from the api. (NOTE: we're still transitioning all the endpoints, but lots of them work). APIResponse embodies our opinionated view on managing your API data. Responses are normalized, and accessed in terms of records. 169 | 170 | ```javascript 171 | import APIOptions from '@r/api-client'; 172 | import { endpoints } from '@r/api-client' 173 | const { PostsEndpoint } = endpoints; 174 | 175 | const postsResponse = await PostsEndpoint.get(APIOptions, { subredditName: 'reactjs'}); 176 | postsResponse.results; // an array of 177 | postsResponse.links; // a dictionary of : 178 | ``` 179 | 180 | For cases where you want pagination, there are helpers provided by 181 | #### [APIResponsePaging.es6.js](/apiBase/APIResponsePaging.es6.js); 182 | ```javascript 183 | import { APIResponsePaging } from '@r/api-client'; 184 | const { afterResponse } = APIResponsePaging; 185 | 186 | // get the id of the last link in an api reponse, only if there's more data 187 | // to fetch 188 | afterResponse(PostsEndpoint.get(APIOptions, { subredditName: 'reactjs' })) 189 | ``` 190 | 191 | #### [MergedResponses](/apiBase/APIResponse.es6.js) handle casses where you have paginated data. Once you've fetched the next page, you can merge it with the first page to have one response represent your entire list of data. 192 | 193 | ```javascript 194 | import APIOptions from '@r/api-client'; 195 | import { endpoints } from '@r/api-client' 196 | const { PostsEndpoint } = endpoints; 197 | 198 | import { APIResponsePaging } from '@r/api-client'; 199 | const { afterResponse } = APIResponsePaging; 200 | 201 | const options = { subredditName: 'reactjs' }; 202 | 203 | const firstPage = await PostsEndpoint.get(APIOptions, options); 204 | const after = afterResponse(firstPage); 205 | const withNextPage = firstPage.appendResponse(await PostsEndpoint.get(APIOptions, { ...options, after }); 206 | ``` 207 | 208 | Note: instances of `MergedResponses` Dont' have `.query` and `.meta` instance variables, instead they have `.querys` and `.metas` that are lists of those from their merged responses. Merging is simple loop that when given a list of responses, takes all of the top level results (with duplicates removed) and updates the tables (e.g. `apiResponse.links`) to use the latest version of the response object. This is useful for cases like paging through subreddits and the posts near page boundaries get listed twice, but you want the most up to date score, number of comments, etc` 209 | 210 | ### Smart Models 211 | This directory contains models that are built to easy interacting with the api. Models will have methods like `subreddit.subscribe()` or `comment.upvote()`. Implementation wise they'll extend models and add various static and instance methods. 212 | 213 | ### Collections 214 | Collections are used to simplyify fetching groups of things. For now all collections subclass [Listing](/collections/Listing.es6.js) has numersous helper methods for pagingation (`.withNextPage()`, `.withPreviousPage()`). Here's some documentation on the various subclasses 215 | 216 | #### [SubredditLists](/collections/SubredditLists) 217 | 218 | ```javascript 219 | import { optionsWithAuth } from '@r/api-client'; 220 | import { collections } from '@r/api-client'; 221 | const { SubscribedSubreddits, ModeratingSubreddits } = collections; 222 | const authedOptions = optionsWithAuth('123-xgy-secret'); 223 | 224 | const subscribedSubreddits = await SubscribedSubreddits.fetch(authedOptions); 225 | console.log(subscribedSubreddits.subreddits.map(subreddit => subreddit.url)); 226 | 227 | const moderatedSubreddits = await ModeratingSubreddits.fetch(authedOptions); 228 | console.log(moderatedSubreddits.subreddits.map(subreddit => subreddit.url)); 229 | ``` 230 | 231 | In these examples `.fetch(api)` handles fetching all the pages by default. This is pending feedback. 232 | 233 | #### [PostsFromSubreddit](/collections/PostsFromSubreddit.es6.js) 234 | 235 | For example, you can fetch all the posts in a subreddit like so: 236 | ```javascript 237 | import APIOptions from '@r/api-client'; 238 | import { collections } from '@r/api-client'; 239 | const { PostsFromSubreddit } = collections; 240 | 241 | const frontpagePopular = await PostsFromSubreddit.fetch(APIOptions, 'all') 242 | console.log(frontpagePopular.posts.map(post => post.title); 243 | const nextPage = await frontpagePopular.nextPage(APIOptions) 244 | ``` 245 | 246 | These endpoints are designed to take options like paging. This makes it easy to do things like continue a infinite scroll after page reloads. 247 | ```javascript 248 | import APIOptions from '@r/api-client'; 249 | import { collections } from '@r/api-client'; 250 | const { PostsFromSubreddit } = collections; 251 | 252 | import { last } from 'lodash/array'; 253 | import { each } from 'lodash/collection'; 254 | 255 | let frontpage = await PostsFromSubreddit.fetch(APIOptions, 'all') // blank fetches frontpage; 256 | each(Array(10), async () => { 257 | frontpage = await frontpage.withNextPage(APIOptions); 258 | }); 259 | 260 | const after = last(frontpage.apiResponse.results).uuid; 261 | const pageAfter = await PostsFromSubreddit.fetch(APIOptions, 'all', { after }) 262 | ``` 263 | 264 | There are lots of other endpoints you can use too. Just note in the future you'll most likely pass an object with your api options instead of an api instance. This makes more sense in a redux world, and will allow us to build the api into modules which can be imported piecemeal, which could drastically reduce payload size. 265 | 266 | #### [SavedPostsAndComments](/collections/SavedPostsAndComments.es6.js) 267 | ```javascript 268 | import { optionsWithAuth } from '@r/api-client'; 269 | import { collections } from '@r/api-client'; 270 | const { SavedPostsAndComments } = collections; 271 | 272 | const authedOptions = optionsWithAuth('123-xgy-secret'); 273 | 274 | const savedThings = await SavedPostsAndComments.fetch(authedOptions, 'my-user-name'); 275 | savedThings.postsAndComments; 276 | const savedWithNextPage = await savedThings.withNextPage(authedOptions); 277 | ``` 278 | 279 | #### [HiddenPostsAndComments](/collections/HiddenPostsAndComments.es6.js) 280 | ```javascript 281 | import { optionsWithAuth } from '@r/api-client'; 282 | import { collections } from '@r/api-client'; 283 | const { HiddenPostsAndComments } = collections; 284 | 285 | const authedOptions = optionsWithAuth('123-xgy-secret'); 286 | 287 | const lessThanDankMemes = await HiddenPostsAndComments.fetch(authedOptions, 'my-user-name'); 288 | lessThanDankMemes.postsAndComments; 289 | ``` 290 | 291 | #### [CommentsPage](/collections/CommentsPage.es6.js) 292 | ```javascript 293 | import APIOptions from '@r/api-client'; 294 | import { collections } from '@r/api-client'; 295 | const { PostsFromSubreddit } = collections; 296 | 297 | const askRedditPosts = await PostsFromSubreddit.fetch(APIOptions, 'askreddit'); 298 | const post = askRedditPosts.apiResponse.uuid; 299 | const commentsPage = await CommentsPage.fetch(api, post); 300 | ``` 301 | 302 | #### [SearchQuery](/collections/SearchQuery.es6.js) 303 | ```javascript 304 | import APIOptions from '@r/api-client'; 305 | import { collections } from '@r/api-client'; 306 | const { SearchQuery } = collections; 307 | 308 | const searchResults = await SearchQuery.fetchPostsAndSubreddits(APIOptions, 'high quality gifs'); 309 | searchResults.posts; 310 | searchResults.subreddits; 311 | ``` 312 | 313 | ### Development / Testing 314 | 315 | If you `chmod +x ./repl`, you can start up a repl for testing (and for general 316 | use!) An api instance is created in the global scope (`api`), from which you 317 | can call any of the API methods. Use `help` in the repl to learn more. 318 | 319 | If you want to use your account to see thigns like your subscriptions, saved, 320 | etc: ./repl will use the environment variable 'TOKEN' if supplied. 321 | 322 | ```bash 323 | export TOKEN='my-super-secret-secure-oauth-token' 324 | ./repl 325 | ``` 326 | ```javascript 327 | api.saved.get({ user: 'my-user-name' }).then(console.log) 328 | ``` 329 | 330 | 331 | Mancy [Not recommended, this is outdated and won't be updated for now] 332 | ----- 333 | 334 | If you install [Mancy](https://github.com/princejwesley/Mancy) you can have a nicer version of using the repl. To set it up, open mancy, and go to `Preferences`. Under `Add node modules path` add your local install of Snoode. Then under `Startup script` add `mancyStart.js`. You can edit `mancyStart.js` to include your token and you can then either use `api` or `authed` as you'd expect. Mancy supports lots of inspecting and autocomplete tools out of the box. 335 | 336 | Example `mancyStart.js`: 337 | ``` 338 | var api = require('mancyLoader.js') 339 | var authed = api.withConfig({ 340 | token: "", 341 | origin: "https://oauth.reddit.com"}) 342 | ``` 343 | 344 | 345 | Caveats 346 | ------ 347 | 348 | We write ES6/7 and compile via Reddit's build system [@r/build](https://www.github.com/reddit/node-build). We output a built file that expects a polyfilled es6/7 environment, with a lodash and superagent as peer depedencies. In your project you'll have to include those as depedencies and `import 'babel-polyfill'` or `require('babel-polyfill')` before using the api. 349 | --------------------------------------------------------------------------------