diff --git a/.gitignore b/.gitignore index deed335..7c38908 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,159 @@ -node_modules/ -dist/ +# Created by https://www.gitignore.io/api/webstorm,node + +### WebStorm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm + +*.iml + +## Directory-based project format: +.idea/ +# if you remove the above rule, at least ignore the following: + +# User-specific stuff: +# .idea/workspace.xml +# .idea/tasks.xml +# .idea/dictionaries +# .idea/shelf + +# Sensitive or high-churn files: +# .idea/dataSources.ids +# .idea/dataSources.xml +# .idea/sqlDataSources.xml +# .idea/dynamic.xml +# .idea/uiDesigner.xml + +# Gradle: +# .idea/gradle.xml +# .idea/libraries + +# Mongo Explorer plugin: +# .idea/mongoSettings.xml + +## File-based project format: +*.ipr +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + + +### Node ### +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git +node_modules + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + + +### PhpStorm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm + +*.iml + +## Directory-based project format: +.idea/ +# if you remove the above rule, at least ignore the following: + +# User-specific stuff: +# .idea/workspace.xml +# .idea/tasks.xml +# .idea/dictionaries +# .idea/shelf + +# Sensitive or high-churn files: +# .idea/dataSources.ids +# .idea/dataSources.xml +# .idea/sqlDataSources.xml +# .idea/dynamic.xml +# .idea/uiDesigner.xml + +# Gradle: +# .idea/gradle.xml +# .idea/libraries + +# Mongo Explorer plugin: +# .idea/mongoSettings.xml + +## File-based project format: +*.ipr +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + + +### SublimeText ### +# cache files for sublime text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# workspace files are user-specific +*.sublime-workspace + +# project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using SublimeText +# *.sublime-project + +# sftp configuration file +sftp-config.json + .env +dist/ diff --git a/.travis.yml b/.travis.yml index a9ba019..87d4306 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,3 +6,5 @@ deploy: api_key: secure: szhg+pTuFpxSAZEtNbGIue+ITt+F1QDfOIaK9iw4QHGKcrFrgQkXGDADSgHot4P01kQ6KpuOBw1Ryx3ce2ithebhl55zdaMXQfkKlh9vGSEO05R0yyY7SM6UUJWGebXIzZFf8G/vTbJh+jdyeBuvJ0uiDErIrKNctA5E5XFbXKWTSo3n5EDYYGEx1E89IkZX+Txs98xTfLdIZkQYG6JSF08KF6asPAbpBNXSyo8zGtXPEuB3t3pccPaHwnzSnVS4Ljhujtl6H4DmY+AnkCJpW4Tb0HAnqBX+kGnSoYyv3fG80qABkE1tUvjNzGInc0BMLFI6LrhUrLRRf8Spd4LR//tn6Wy2xLm0tsmLWHz+bE9WyopooLyWamUQRW1YUXdfRiw5CM1q5TTWii/cvdWzuuX1XBXM4FKX5LdON+hbdg/gciLQZMVyzllM7+58SNdyNiYQXObi7ioWVV6UUwv/+P++F2CwDncnjRXCUmx5ZRe8T9UnmTa5xg+YsyKcEHKz1ms9TZoQxKro4YJAxLRsn4gQd9+Sbe1WZeiQfv/JSV98VJujiCbVO8iSWMAW7Le7NxTy/Gih2u3Z13LteLSz4KF5LPAoSLO/2KDTEsZwl9uyjblGi2sVIbvkTd0P0SnAz03k73YG4qXN4R7/h3RnIl1OCk8eRGAF6eSCFpXQtjQ= app: graphqlhub +env: + - TWITTER_CONSUMER_KEY=dummy_consumer_key TWITTER_CONSUMER_SECRET=dummy_consumer_secret diff --git a/README.md b/README.md index 65462fe..565db57 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ This powers the server behind [GraphQLHub](http://www.graphqlhub.com/). It's bas - [Hacker News](schemas/hn.js) - [Reddit](schemas/reddit.js) - [GitHub](schemas/github.js) +- [Twitter](schemas/twitter.js) - [GraphQLHub](schemas/graphqlhub.js), which contains all the other schemas ## TODO diff --git a/apis/__tests__/twitter-test.js b/apis/__tests__/twitter-test.js new file mode 100644 index 0000000..546ebca --- /dev/null +++ b/apis/__tests__/twitter-test.js @@ -0,0 +1,10 @@ +import test from 'tape'; +import * as Twitter from '../twitter'; + +test('Twitter API', (t) => { + t.ok(Twitter.getUser, 'getUser should exist'); + t.ok(Twitter.getTweet, 'getTweet should exist'); + t.ok(Twitter.getTweets, 'getTweets should exist'); + t.ok(Twitter.getRetweets, 'getRetweets should exist'); + t.end(); +}); diff --git a/apis/twitter.js b/apis/twitter.js new file mode 100644 index 0000000..fb99f39 --- /dev/null +++ b/apis/twitter.js @@ -0,0 +1,39 @@ +import Twit from 'twit'; +import _ from 'lodash'; + +const { + TWITTER_CONSUMER_KEY, + TWITTER_CONSUMER_SECRET +} = process.env; + +const Twitter = new Twit({ + consumer_key : TWITTER_CONSUMER_KEY, + consumer_secret : TWITTER_CONSUMER_SECRET, + app_only_auth : true +}); + +export const getUser = (identifier, identity) => __getPromise('users/show', { [identifier]: identity }); +export const getTweets = (user_id, count) => __getPromise('statuses/user_timeline', { user_id, count }); +export const getTweet = (id) => __getPromise('statuses/show', { id }); +export const getRetweets = (id, count) => __getPromise('statuses/retweets', { id, count }); +export const searchFor = (queryParams) => __getPromise("search/tweets", queryParams, 'statuses'); + +const __getPromise = (endpoint, parameters, resultPath = null) => { + + return new Promise((resolve, reject) => { + + Twitter.get( + endpoint, + parameters, + (error, result) => { + + if (error) { + reject(error); + } + else { + resolve( resultPath !== null ? _.get(result, resultPath) : result ); + } + } + ) + }); +}; diff --git a/package.json b/package.json index 8e10ae6..d5d60ad 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "lodash": "3.10.1", "node-fetch": "1.3.2", "qs": "5.1.0", - "react": "0.13.3" + "react": "0.13.3", + "twit": "2.1.1" }, "devDependencies": { "glob": "6.0.2", diff --git a/public/index.html b/public/index.html index e0c3a68..e8cb2d7 100644 --- a/public/index.html +++ b/public/index.html @@ -25,6 +25,7 @@

GraphQLHub APIs:

  • Explore Hacker News
  • Explore Reddit
  • Explore GitHub
  • +
  • Explore Twitter
  • Explore a Relay-compatible Key-Value store
  • Follow @GraphQLHub for more updates. This page intentionally unstyled, for now.

    diff --git a/schemas/github.js b/schemas/github.js index 45e846d..24e1442 100644 --- a/schemas/github.js +++ b/schemas/github.js @@ -192,5 +192,5 @@ export const Schema = { resolve() { return {}; } - }, + } }; diff --git a/schemas/graphqlhub.js b/schemas/graphqlhub.js index 3c6b94a..5961319 100644 --- a/schemas/graphqlhub.js +++ b/schemas/graphqlhub.js @@ -9,12 +9,14 @@ import { Schema as HN } from './hn'; import { Schema as REDDIT } from './reddit'; import { Schema as KEYVALUE } from './keyvalue'; import { Schema as GITHUB } from './github'; +import { Schema as TWITTER } from './twitter'; let schemas = { hn : HN, reddit : REDDIT, keyValue : KEYVALUE, github : GITHUB, + twitter: TWITTER }; let FIELDS = { diff --git a/schemas/twitter.js b/schemas/twitter.js new file mode 100644 index 0000000..68947da --- /dev/null +++ b/schemas/twitter.js @@ -0,0 +1,186 @@ +import _ from 'lodash'; +import * as twitter from '../apis/twitter'; + +import { + GraphQLSchema, + GraphQLObjectType, + GraphQLID, + GraphQLString, + GraphQLNonNull, + GraphQLInt, + GraphQLList, + GraphQLScalarType, + GraphQLEnumType +} from 'graphql'; + +import { GraphQLError } from 'graphql/error'; +import { Kind } from 'graphql/language'; + +let UserType = new GraphQLObjectType({ + name : 'TwitterUser', + description : 'Twitter user', + fields : () => ({ + created_at : { type: GraphQLString }, + description : { type: GraphQLString }, + id : { type: GraphQLID }, // GraphQLInt would return null + screen_name : { type: GraphQLString }, + name : { type: GraphQLString }, + profile_image_url : { type: GraphQLString }, + url : { type: GraphQLString }, + tweets_count : { + type : GraphQLInt, + resolve : ({ statuses_count }) => statuses_count + }, + followers_count : { type: GraphQLInt }, + tweets : { + type : new GraphQLList(TweetType), + description : 'Get a list of tweets for current user', + args : { + limit: { + type : GraphQLInt, + defaultValue : 10 + } + }, + // user args + resolve: ({ id: user_id }, { limit }) => twitter.getTweets(user_id, limit) + } + }) + +}); + +let TweetType = new GraphQLObjectType({ + name : 'Tweet', + description : 'A tweet object', + fields : () => ({ + id : { type: GraphQLID }, + created_at : { type: GraphQLString }, + text : { type: GraphQLString }, + retweet_count : { type: GraphQLInt }, + user : { type: UserType }, + retweets : { + type : new GraphQLList(RetweetType), + description : 'Get a list of retweets', + args : { + limit: { + type : GraphQLInt, + defaultValue : 5 + } + }, + // passing integer 'id' here doesn't work surprisingly, had to use 'id_str' + resolve: ({ id_str: tweetId }, { limit }) => twitter.getRetweets(tweetId, limit) + } + }) +}); + +let RetweetType = new GraphQLObjectType({ + name : 'Retweet', + description : 'Retweet of a tweet', + fields : () => ({ + id : { type: GraphQLID }, + created_at : { type: GraphQLString }, + in_reply_to_tweet_id : { + type : GraphQLString, + resolve : ({ in_reply_to_status_id }) => in_reply_to_status_id + }, + in_reply_to_user_id : { type: GraphQLInt }, + in_reply_to_screen_name : { type: GraphQLString }, + retweeted_status : { type: TweetType }, + user : { type: UserType } + }) +}); + +let userIdentityType = new GraphQLScalarType({ + name : 'UserIdentity', + description : 'Parse user provided identity', + serialize : value => value, + parseValue : value => value, + parseLiteral : ast => { + + if (ast.kind !== Kind.STRING && ast.kind !== Kind.INT) { + throw new GraphQLError("Query error: Can only parse Integer and String but got a: " + ast.kind, [ast]); + } + + return ast.value; + } +}); + +let userIdentifierType = new GraphQLEnumType({ + name : 'UserIdentifier', + description : 'Either user unique ID, or screen name', + values : { + 'id' : { value: 'user_id' }, + 'name' : { value: 'screen_name' } + } +}); + +let searchReponseType = new GraphQLEnumType({ + name : 'SearchReponse', + description : 'Type of search response.', + values: { + mixed : { value: 'mixed' }, + recent : { value: 'recent' }, + popular : { value: 'popular' } + } +}); + +let twitterType = new GraphQLObjectType({ + name : 'TwitterAPI', + description : 'The Twitter API', + fields : { + user : { + type : UserType, + args : { + identifier: { + description : 'Either user_id or screen_name', + type : new GraphQLNonNull(userIdentifierType) + }, + identity: { + description : 'User ID (Integer) or Screen name (String) to identify user', + type : new GraphQLNonNull(userIdentityType) + }, + }, + resolve: (_, { identifier, identity }) => twitter.getUser(identifier, identity) + }, + tweet: { + type : TweetType, + args : { + id : { + type : new GraphQLNonNull(GraphQLString), + description : 'Unique ID of tweet' + } + }, + resolve: (_, { id: tweetId }) => twitter.getTweet(tweetId) + }, + search: { + type : new GraphQLList(TweetType), + description : "Returns a collection of relevant Tweets matching a specified query.", + args: { + q: { + type : new GraphQLNonNull(GraphQLString), + description : "A UTF-8, URL-encoded search query of 500 characters maximum, including operators. Queries may additionally be limited by complexity." + }, + count: { + type : GraphQLInt, + description : "The number of tweets to return per page, up to a maximum of 100. This was formerly the “rpp” parameter in the old Search API." + }, + result_type: { + type: searchReponseType, + description: `Specifies what type of search results you would prefer to receive. Valid values include: + * mixed: Include both popular and real time results in the response. + * recent: return only the most recent results in the response + * popular: return only the most popular results in the response.` + } + }, + resolve: (_, searchArgs) => twitter.searchFor(searchArgs) + } + } +}); + +export const Schema = { + query: { + type: twitterType, + resolve() { + return {}; + } + } +}; diff --git a/server.js b/server.js index aef09c9..681a198 100644 --- a/server.js +++ b/server.js @@ -44,6 +44,7 @@ let SHORTCUTS = { hn : '/playground?query=%23%20Hit%20the%20Play%20button%20above!%0A%23%20Hit%20"Docs"%20on%20the%20right%20to%20explore%20the%20API%0A%0A%7B%0A%20%20graphQLHub%0A%20%20hn%20%7B%0A%20%20%20%20topStories(limit%3A%201)%20%7B%0A%20%20%20%20%20%20title%0A%20%20%20%20%20%20url%0A%20%20%20%20%20%20timeISO%0A%20%20%20%20%20%20by%20%7B%0A%20%20%20%20%20%20%20%20id%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20kids(limit%3A%201)%20%7B%0A%20%20%20%20%20%20%20%20timeISO%0A%20%20%20%20%20%20%20%20by%20%7B%0A%20%20%20%20%20%20%20%20%20%20id%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20text%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D', keyvalue : '/playground?query=%23%20Hit%20the%20Play%20button%20above!%0A%23%20Hit%20"Docs"%20on%20the%20right%20to%20explore%20the%20API%0A%0A%0A%23mutation%20GraphQLHubMutationAPI%20%7B%0A%23%20keyValue_setValue(input%3A%20%7B%0A%23%20%20%20clientMutationId%3A%20"browser"%2C%20id%3A%20"someKey"%2C%20value%3A%20"some%20value"%20%0A%23%20%20%7D)%20%7B%0A%23%20%20%20item%20%7B%0A%23%20%20%20%20value%0A%23%20%20%20%20id%0A%23%20%20%20%7D%0A%23%20%20%20clientMutationId%0A%23%20%7D%0A%23%7D%0A%0Aquery%20GraphQLHubAPI%20%7B%0A%20%20keyValue%20%7B%0A%20%20%20%20getValue(id%3A%20"initialKey")%20%7B%0A%20%20%20%20%20%20id%0A%20%20%20%20%20%20value%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A', github : '/playground?query=%23%20Hit%20the%20Play%20button%20above!%0A%23%20Hit%20"Docs"%20on%20the%20right%20to%20explore%20the%20API%0A%0A%7B%0A%20%20graphQLHub%0A%20%20github%20%7B%0A%20%20%20%20user(username%3A%20"clayallsopp")%20%7B%0A%20%20%20%20%20%20login%0A%20%20%20%20%20%20avatar_url%0A%20%20%20%20%7D%0A%20%20%20%20repo(ownerUsername%3A%20"clayallsopp"%2C%20name%3A%20"graphqlhub")%20%7B%0A%20%20%20%20%20%20commits%20%7B%0A%20%20%20%20%20%20%20%20sha%0A%20%20%20%20%20%20%20%20message%0A%20%20%20%20%20%20%20%20author%20%7B%0A%20%20%20%20%20%20%20%20%20%20...%20on%20GithubUser%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20login%0A%20%20%20%20%20%20%20%20%20%20%20%20id%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20...%20on%20GithubCommitAuthor%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20email%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D', + twitter : '/playground?query=%23%20Hit%20the%20Play%20button%20above!%0A%23%20Hit%20"Docs"%20on%20the%20right%20to%20explore%20the%20API%0A%0A%7B%0A%20%20graphQLHub%0A%20%20twitter%20%7B%0A%20%20%20%20user%20(identifier%3A%20name%2C%20identity%3A%20"clayallsopp")%20%7B%0A%20%20%20%20%20%20created_at%0A%20%20%20%20%20%20description%0A%20%20%20%20%20%20id%0A%20%20%20%20%20%20screen_name%0A%20%20%20%20%20%20name%0A%20%20%20%20%20%20profile_image_url%0A%20%20%20%20%20%20url%0A%20%20%20%20%20%20tweets_count%0A%20%20%20%20%20%20followers_count%0A%20%20%20%20%20%20tweets(limit%3A%201)%20%7B%0A%20%20%20%20%20%20%20%20text%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20tweet(id%3A%20"687433440774459392")%20%7B%0A%20%20%20%20%20%20text%2C%0A%20%20%20%20%20%20retweets(limit%3A%202)%20%7B%0A%20%20%20%20%20%20%20%20id%2C%0A%20%20%20%20%20%20%20%20retweeted_status%20%7B%0A%20%20%20%20%20%20%20%20%20%20id%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20user%20%7B%0A%20%20%20%20%20%20%20%20%20%20screen_name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20search(q%3A%20"Javascript"%2C%20count%3A%201%2C%20result_type%3A%20mixed)%20%7B%0A%20%20%20%20%20%20user%20%7B%0A%20%20%20%20%20%20%20%20screen_name%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20id%0A%20%20%20%20%20%20text%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D' }; Object.keys(SHORTCUTS).forEach((shortcut) => { app.get(`/playground/${shortcut}`, (req, res) => {