├── .babelrc ├── .env.default ├── .eslintrc ├── .gitignore ├── .travis.yml ├── .vscode └── launch.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __mocks__ └── request-promise.js ├── __tests__ ├── __snapshots__ │ └── basic.js.snap └── basic.js ├── api ├── config.js ├── github │ ├── connector.js │ ├── connector.test.js │ ├── models.js │ └── schema.js ├── githubKeys.js ├── githubLogin.js ├── index.html ├── index.js ├── schema.js ├── server.js ├── sql │ ├── connector.js │ ├── models.js │ └── schema.js └── subscriptions.js ├── extracted_queries.json ├── knexfile.js ├── migrations └── 20160518201950_create_comments_entries_votes.js ├── package.json ├── screenshots ├── github-oath-setup.png └── github-oauth-keys.png └── seeds └── seed.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "stage-2" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.env.default: -------------------------------------------------------------------------------- 1 | GITHUB_CLIENT_ID="xxx" 2 | GITHUB_CLIENT_SECRET="xxx" 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "jest": true 5 | }, 6 | "extends": "airbnb", 7 | "parser": "babel-eslint", 8 | "rules": { 9 | "camelcase": 0, 10 | "arrow-body-style": 0, 11 | "class-methods-use-this": 0, 12 | "import/prefer-default-export": 0, 13 | "import/no-extraneous-dependencies": 0, 14 | "import/imports-first": 0, 15 | "no-use-before-define": 0, 16 | "no-underscore-dangle": 0 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dev.sqlite3 4 | test.sqlite3 5 | .env 6 | .idea/ 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | notifications: 5 | email: false 6 | script: 7 | - npm run test:setup 8 | - npm test 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Launch", 6 | "type": "node", 7 | "request": "launch", 8 | "program": "${workspaceRoot}/api/index.js", 9 | "stopOnEntry": false, 10 | "args": [ 11 | "--watch", 12 | "api", 13 | "--exec", 14 | "node_modules/.bin/babel-node" 15 | ], 16 | "cwd": "${workspaceRoot}", 17 | "preLaunchTask": null, 18 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/nodemon", 19 | "runtimeArgs": [ 20 | ], 21 | "env": { 22 | "NODE_ENV": "development" 23 | }, 24 | "console": "internalConsole", 25 | "sourceMaps": true, 26 | "outFiles": [] 27 | }, 28 | { 29 | "name": "Attach", 30 | "type": "node", 31 | "request": "attach", 32 | "port": 5858, 33 | "address": "localhost", 34 | "restart": false, 35 | "sourceMaps": false, 36 | "outFiles": [], 37 | "localRoot": "${workspaceRoot}", 38 | "remoteRoot": null 39 | }, 40 | { 41 | "name": "Attach to Process", 42 | "type": "node", 43 | "request": "attach", 44 | "processId": "${command.PickProcess}", 45 | "port": 5858, 46 | "sourceMaps": false, 47 | "outFiles": [] 48 | } 49 | ] 50 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Apollo Contributor Guide 2 | 3 | Excited about Apollo and want to make it better? We’re excited too! 4 | 5 | Apollo is a community of developers just like you, striving to create the best tools and libraries around GraphQL. We welcome anyone who wants to contribute or provide constructive feedback, no matter the age or level of experience. If you want to help but don't know where to start, let us know, and we'll find something for you. 6 | 7 | Oh, and if you haven't already, sign up for the [Apollo Slack](http://www.apollodata.com/#slack). 8 | 9 | Here are some ways to contribute to the project, from easiest to most difficult: 10 | 11 | * [Reporting bugs](#reporting-bugs) 12 | * [Improving the documentation](#improving-the-documentation) 13 | * [Responding to issues](#responding-to-issues) 14 | * [Small bug fixes](#small-bug-fixes) 15 | * [Suggesting features](#suggesting-features) 16 | * [Big pull requests](#big-prs) 17 | 18 | ## Issues 19 | 20 | ### Reporting bugs 21 | 22 | If you encounter a bug, please file an issue on GitHub via the repository of the sub-project you think contains the bug. If an issue you have is already reported, please add additional information or add a 👍 reaction to indicate your agreement. 23 | 24 | While we will try to be as helpful as we can on any issue reported, please include the following to maximize the chances of a quick fix: 25 | 26 | 1. **Intended outcome:** What you were trying to accomplish when the bug occurred, and as much code as possible related to the source of the problem. 27 | 2. **Actual outcome:** A description of what actually happened, including a screenshot or copy-paste of any related error messages, logs, or other output that might be related. Places to look for information include your browser console, server console, and network logs. Please avoid non-specific phrases like “didn’t work” or “broke”. 28 | 3. **How to reproduce the issue:** Instructions for how the issue can be reproduced by a maintainer or contributor. Be as specific as possible, and only mention what is necessary to reproduce the bug. If possible, try to isolate the exact circumstances in which the bug occurs and avoid speculation over what the cause might be. 29 | 30 | Creating a good reproduction really helps contributors investigate and resolve your issue quickly. In many cases, the act of creating a minimal reproduction illuminates that the source of the bug was somewhere outside the library in question, saving time and effort for everyone. 31 | 32 | ### Improving the documentation 33 | 34 | Improving the documentation, examples, and other open source content can be the easiest way to contribute to the library. If you see a piece of content that can be better, open a PR with an improvement, no matter how small! If you would like to suggest a big change or major rewrite, we’d love to hear your ideas but please open an issue for discussion before writing the PR. 35 | 36 | ### Responding to issues 37 | 38 | In addition to reporting issues, a great way to contribute to Apollo is to respond to other peoples' issues and try to identify the problem or help them work around it. If you’re interested in taking a more active role in this process, please go ahead and respond to issues. And don't forget to say "Hi" on Apollo Slack! 39 | 40 | ### Small bug fixes 41 | 42 | For a small bug fix change (less than 20 lines of code changed), feel free to open a pull request. We’ll try to merge it as fast as possible and ideally publish a new release on the same day. The only requirement is, make sure you also add a test that verifies the bug you are trying to fix. 43 | 44 | ### Suggesting features 45 | 46 | Most of the features in Apollo came from suggestions by you, the community! We welcome any ideas about how to make Apollo better for your use case. Unless there is overwhelming demand for a feature, it might not get implemented immediately, but please include as much information as possible that will help people have a discussion about your proposal: 47 | 48 | 1. **Use case:** What are you trying to accomplish, in specific terms? Often, there might already be a good way to do what you need and a new feature is unnecessary, but it’s hard to know without information about the specific use case. 49 | 2. **Could this be a plugin?** In many cases, a feature might be too niche to be included in the core of a library, and is better implemented as a companion package. If there isn’t a way to extend the library to do what you want, could we add additional plugin APIs? It’s important to make the case for why a feature should be part of the core functionality of the library. 50 | 3. **Is there a workaround?** Is this a more convenient way to do something that is already possible, or is there some blocker that makes a workaround unfeasible? 51 | 52 | Feature requests will be labeled as such, and we encourage using GitHub issues as a place to discuss new features and possible implementation designs. Please refrain from submitting a pull request to implement a proposed feature until there is consensus that it should be included. This way, you can avoid putting in work that can’t be merged in. 53 | 54 | Once there is a consensus on the need for a new feature, proceed as listed below under “Big PRs”. 55 | 56 | ## Big PRs 57 | 58 | This includes: 59 | 60 | - Big bug fixes 61 | - New features 62 | 63 | For significant changes to a repository, it’s important to settle on a design before starting on the implementation. This way, we can make sure that major improvements get the care and attention they deserve. Since big changes can be risky and might not always get merged, it’s good to reduce the amount of possible wasted effort by agreeing on an implementation design/plan first. 64 | 65 | 1. **Open an issue.** Open an issue about your bug or feature, as described above. 66 | 2. **Reach consensus.** Some contributors and community members should reach an agreement that this feature or bug is important, and that someone should work on implementing or fixing it. 67 | 3. **Agree on intended behavior.** On the issue, reach an agreement about the desired behavior. In the case of a bug fix, it should be clear what it means for the bug to be fixed, and in the case of a feature, it should be clear what it will be like for developers to use the new feature. 68 | 4. **Agree on implementation plan.** Write a plan for how this feature or bug fix should be implemented. What modules need to be added or rewritten? Should this be one pull request or multiple incremental improvements? Who is going to do each part? 69 | 5. **Submit PR.** In the case where multiple dependent patches need to be made to implement the change, only submit one at a time. Otherwise, the others might get stale while the first is reviewed and merged. Make sure to avoid “while we’re here” type changes - if something isn’t relevant to the improvement at hand, it should be in a separate PR; this especially includes code style changes of unrelated code. 70 | 6. **Review.** At least one core contributor should sign off on the change before it’s merged. Look at the “code review” section below to learn about factors are important in the code review. If you want to expedite the code being merged, try to review your own code first! 71 | 7. **Merge and release!** 72 | 73 | ### Code review guidelines 74 | 75 | It’s important that every piece of code in Apollo packages is reviewed by at least one core contributor familiar with that codebase. Here are some things we look for: 76 | 77 | 1. **Required CI checks pass.** This is a prerequisite for the review, and it is the PR author's responsibility. As long as the tests don’t pass, the PR won't get reviewed. 78 | 2. **Simplicity.** Is this the simplest way to achieve the intended goal? If there are too many files, redundant functions, or complex lines of code, suggest a simpler way to do the same thing. In particular, avoid implementing an overly general solution when a simple, small, and pragmatic fix will do. 79 | 3. **Testing.** Do the tests ensure this code won’t break when other stuff changes around it? When it does break, will the tests added help us identify which part of the library has the problem? Did we cover an appropriate set of edge cases? Look at the test coverage report if there is one. Are all significant code paths in the new code exercised at least once? 80 | 4. **No unnecessary or unrelated changes.** PRs shouldn’t come with random formatting changes, especially in unrelated parts of the code. If there is some refactoring that needs to be done, it should be in a separate PR from a bug fix or feature, if possible. 81 | 5. **Code has appropriate comments.** Code should be commented, or written in a clear “self-documenting” way. 82 | 6. **Idiomatic use of the language.** In TypeScript, make sure the typings are specific and correct. In ES2015, make sure to use imports rather than require and const instead of var, etc. Ideally a linter enforces a lot of this, but use your common sense and follow the style of the surrounding code. 83 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Meteor Development Group 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHunt 2 | 3 | The Apollo Server backend shared by all Apollo client example apps. 4 | 5 | Interact with the API yourself at [http://api.githunt.com/graphiql](http://api.githunt.com/graphiql). 6 | 7 | [![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](http://www.apollostack.com/#slack) 8 | [![Build Status](https://travis-ci.org/apollographql/GitHunt-API.svg?branch=master)](https://travis-ci.org/apollographql/GitHunt-API) 9 | 10 | Demonstrates: 11 | 12 | 1. GraphQL schema, resolvers, models, and connectors to read from two different data sources, GitHub REST API and SQL 13 | 2. Web server with authentication and basic authorization using Express, Passport, and Apollo Server 14 | 15 | Please submit a pull request if you see anything that can be improved! 16 | 17 | ## Running the server 18 | 19 | 1. **Install Node/npm.** Make sure you have Node.js 4 or newer installed. 20 | 2. **Clone and install dependencies.** 21 | Run the following commands: 22 | 23 | ``` 24 | git clone https://github.com/apollostack/GitHunt-API.git 25 | cd GitHunt-API 26 | npm install 27 | ``` 28 | 29 | 3. **Run migrations.** Set up the SQLite database and run migrations/seed data with the following commands: 30 | 31 | ``` 32 | npm run migrate 33 | npm run seed 34 | ``` 35 | 36 | 4. **Get GitHub API keys.** 37 | 1. Go to [OAuth applications > Developer applications](https://github.com/settings/developers) in GitHub settings 38 | 2. Click 'Register a new application' button 39 | 3. Register your application like below 40 | 4. Click 'Register application' button at the bottom. [It should look like this screenshot of the app setup page.](screenshots/github-oath-setup.png) 41 | 5. On the following page, grab the **Client ID** and **Client Secret**, as indicated in [this screenshot of the GitHub OAuth keys page.](screenshots/github-oauth-keys.png) 42 | 43 | 5. **Add Environment Variables.** Set your Client ID and Client Secret Environment variables in the terminal like this: 44 | ``` 45 | export GITHUB_CLIENT_ID="your Client ID" 46 | export GITHUB_CLIENT_SECRET="your Client Secret" 47 | ``` 48 | 49 | Or you can use `dotenv`, to do this `cp .env.default .env` and edit with your Github keys. 50 | 51 | 6. **Run the app.** 52 | 53 | ``` 54 | npm run dev 55 | ``` 56 | 57 | 7. **Open the app.** Open http://localhost:3010/ to see what to do next. 58 | -------------------------------------------------------------------------------- /__mocks__/request-promise.js: -------------------------------------------------------------------------------- 1 | let requestQueue = []; 2 | 3 | export default function rp(requestOptions) { 4 | // Ensure we expected to get more requests 5 | expect(requestQueue.length).not.toBe(0); 6 | 7 | const nextRequest = requestQueue.shift(); 8 | // Ensure this is the request we expected 9 | expect(requestOptions).toEqual(nextRequest.options); 10 | 11 | return new Promise((resolve, reject) => { 12 | if (nextRequest.result) { 13 | resolve(nextRequest.result); 14 | } else if (nextRequest.error) { 15 | reject(nextRequest.error); 16 | } else { 17 | throw new Error('Mocked request must have result or error.'); 18 | } 19 | }); 20 | } 21 | 22 | function pushMockRequest({ options, result, error }) { 23 | const defaultOptions = { 24 | json: true, 25 | headers: { 26 | 'user-agent': 'GitHunt', 27 | }, 28 | resolveWithFullResponse: true, 29 | }; 30 | const { uri, ...rest } = options; 31 | 32 | const url = `https://api.github.com${uri}`; 33 | 34 | requestQueue.push({ 35 | options: { 36 | ...defaultOptions, 37 | ...rest, 38 | uri: url, 39 | }, 40 | result, 41 | error, 42 | }); 43 | } 44 | 45 | function flushRequestQueue() { 46 | requestQueue = []; 47 | } 48 | 49 | function noRequestsLeft() { 50 | expect(requestQueue.length).toBe(0); 51 | } 52 | 53 | rp.__pushMockRequest = pushMockRequest; // eslint-disable-line no-underscore-dangle 54 | rp.__flushRequestQueue = flushRequestQueue; // eslint-disable-line no-underscore-dangle 55 | rp.__noRequestsLeft = noRequestsLeft; // eslint-disable-line no-underscore-dangle 56 | 57 | rp.actual = require.requireActual('request-promise'); 58 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/basic.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`accepts a query 1`] = ` 4 | Object { 5 | "data": Object { 6 | "feed": Array [ 7 | Object { 8 | "postedBy": Object { 9 | "login": "stubailo", 10 | }, 11 | "repository": Object { 12 | "name": "apollo-client", 13 | "owner": Object { 14 | "login": "apollographql", 15 | }, 16 | }, 17 | }, 18 | Object { 19 | "postedBy": Object { 20 | "login": "helfer", 21 | }, 22 | "repository": Object { 23 | "name": "graphql-server", 24 | "owner": Object { 25 | "login": "apollographql", 26 | }, 27 | }, 28 | }, 29 | Object { 30 | "postedBy": Object { 31 | "login": "tmeasday", 32 | }, 33 | "repository": Object { 34 | "name": "meteor", 35 | "owner": Object { 36 | "login": "meteor", 37 | }, 38 | }, 39 | }, 40 | Object { 41 | "postedBy": Object { 42 | "login": "Slava", 43 | }, 44 | "repository": Object { 45 | "name": "bootstrap", 46 | "owner": Object { 47 | "login": "twbs", 48 | }, 49 | }, 50 | }, 51 | Object { 52 | "postedBy": Object { 53 | "login": "Slava", 54 | }, 55 | "repository": Object { 56 | "name": "d3", 57 | "owner": Object { 58 | "login": "d3", 59 | }, 60 | }, 61 | }, 62 | ], 63 | }, 64 | } 65 | `; 66 | -------------------------------------------------------------------------------- /__tests__/basic.js: -------------------------------------------------------------------------------- 1 | import rp from 'request-promise'; 2 | import casual from 'casual'; 3 | 4 | import { run } from '../api/server'; 5 | 6 | const testPort = 6789; 7 | const endpointUrl = `http://localhost:${testPort}/graphql`; 8 | 9 | let server; 10 | beforeAll(() => { 11 | server = run({ PORT: testPort }); 12 | }); 13 | 14 | it('accepts a query', async () => { 15 | casual.seed(123); 16 | 17 | [ 18 | ['apollographql/apollo-client', 'stubailo'], 19 | ['apollographql/graphql-server', 'helfer'], 20 | ['meteor/meteor', 'tmeasday'], 21 | ['twbs/bootstrap', 'Slava'], 22 | ['d3/d3', 'Slava'], 23 | ].forEach(([full_name, postedBy]) => { 24 | // First, it will request the repository; 25 | rp.__pushMockRequest({ 26 | options: { 27 | uri: `/repos/${full_name}`, 28 | }, 29 | result: { 30 | headers: { 31 | etag: casual.string, 32 | }, 33 | body: { 34 | name: full_name.split('/')[1], 35 | full_name, 36 | description: casual.sentence, 37 | html_url: casual.url, 38 | stargazers_count: casual.integer(0), 39 | open_issues_count: casual.integer(0), 40 | owner: { 41 | login: full_name.split('/')[0], 42 | avatar_url: casual.url, 43 | html_url: casual.url, 44 | }, 45 | }, 46 | }, 47 | }); 48 | 49 | // Then the user who posted it 50 | rp.__pushMockRequest({ 51 | options: { 52 | uri: `/users/${postedBy}`, 53 | }, 54 | result: { 55 | headers: { 56 | etag: casual.string, 57 | }, 58 | body: { 59 | login: postedBy, 60 | }, 61 | }, 62 | }); 63 | }); 64 | 65 | const result = await fetchGraphQL(` 66 | { 67 | feed (type: NEW, limit: 5) { 68 | repository { 69 | owner { login } 70 | name 71 | } 72 | 73 | postedBy { login } 74 | } 75 | } 76 | `); 77 | 78 | expect(result).toMatchSnapshot(); 79 | }); 80 | 81 | afterAll(() => { 82 | server.close(); 83 | server = null; 84 | }); 85 | 86 | function fetchGraphQL(query, variables) { 87 | return rp.actual(endpointUrl, { 88 | method: 'post', 89 | body: { query, variables }, 90 | json: true, 91 | }); 92 | } 93 | -------------------------------------------------------------------------------- /api/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // If set to to true, GitHunt will use `extractgql` in order to 3 | // map query ids received from the client to GraphQL documents. 4 | // 5 | // Note that the same option must be enabled on the client 6 | // and the extracted_queries.json file in both the client and API server 7 | // must be the same. 8 | persistedQueries: false, 9 | }; 10 | -------------------------------------------------------------------------------- /api/github/connector.js: -------------------------------------------------------------------------------- 1 | import rp from 'request-promise'; 2 | import DataLoader from 'dataloader'; 3 | 4 | // Keys are GitHub API URLs, values are { etag, result } objects 5 | const eTagCache = {}; 6 | 7 | const GITHUB_API_ROOT = 'https://api.github.com'; 8 | 9 | export class GitHubConnector { 10 | constructor({ clientId, clientSecret } = {}) { 11 | this.clientId = clientId; 12 | this.clientSecret = clientSecret; 13 | 14 | // Allow mocking request promise for tests 15 | this.rp = rp; 16 | if (GitHubConnector.mockRequestPromise) { 17 | this.rp = GitHubConnector.mockRequestPromise; 18 | } 19 | 20 | this.loader = new DataLoader(this.fetch.bind(this), { 21 | // The GitHub API doesn't have batching, so we should send requests as 22 | // soon as we know about them 23 | batch: false, 24 | }); 25 | } 26 | fetch(urls) { 27 | const options = { 28 | json: true, 29 | resolveWithFullResponse: true, 30 | headers: { 31 | 'user-agent': 'GitHunt', 32 | }, 33 | }; 34 | 35 | if (this.clientId) { 36 | options.qs = { 37 | client_id: this.clientId, 38 | client_secret: this.clientSecret, 39 | }; 40 | } 41 | 42 | return Promise.all(urls.map((url) => { 43 | const cachedRes = eTagCache[url]; 44 | 45 | if (cachedRes && cachedRes.eTag) { 46 | options.headers['If-None-Match'] = cachedRes.eTag; 47 | } 48 | return new Promise((resolve, reject) => { 49 | this.rp({ 50 | uri: url, 51 | ...options, 52 | }).then((response) => { 53 | const body = response.body; 54 | eTagCache[url] = { 55 | result: body, 56 | eTag: response.headers.etag, 57 | }; 58 | resolve(body); 59 | }).catch((err) => { 60 | if (err.statusCode === 304) { 61 | resolve(cachedRes.result); 62 | } else { 63 | reject(err); 64 | } 65 | }); 66 | }); 67 | })); 68 | } 69 | 70 | get(path) { 71 | return this.loader.load(GITHUB_API_ROOT + path); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /api/github/connector.test.js: -------------------------------------------------------------------------------- 1 | import rp from 'request-promise'; 2 | 3 | import { GitHubConnector } from './connector'; 4 | 5 | describe('GitHub connector', () => { 6 | beforeEach(() => { 7 | rp.__flushRequestQueue(); 8 | }); 9 | 10 | afterEach(() => { 11 | rp.__noRequestsLeft(); 12 | }); 13 | 14 | it('can be constructed', () => { 15 | expect(new GitHubConnector()).toBeTruthy(); 16 | }); 17 | 18 | it('can load one endpoint', () => { 19 | const connector = new GitHubConnector(); 20 | 21 | rp.__pushMockRequest({ 22 | options: { uri: '/endpoint' }, 23 | result: { 24 | headers: {}, 25 | body: { id: 1 }, 26 | }, 27 | }); 28 | 29 | return connector.get('/endpoint').then((result) => { 30 | expect(result).toEqual({ id: 1 }); 31 | }); 32 | }); 33 | 34 | it('fetches each endpoint only once per instance', () => { 35 | const connector = new GitHubConnector(); 36 | 37 | rp.__pushMockRequest({ 38 | options: { 39 | uri: '/endpoint', 40 | }, 41 | result: { 42 | headers: {}, 43 | body: { id: 1 }, 44 | }, 45 | }); 46 | 47 | return connector.get('/endpoint') 48 | .then((result) => { 49 | expect(result).toEqual({ id: 1 }); 50 | }) 51 | .then(() => ( 52 | // This get call doesn't actually call the API - note that we only 53 | // enqueued the request mock once! 54 | connector.get('/endpoint') 55 | )) 56 | .then((result) => { 57 | expect(result).toEqual({ id: 1 }); 58 | }); 59 | }); 60 | 61 | it('passes through the API token for unauthenticated requests', () => { 62 | const connector = new GitHubConnector({ 63 | clientId: 'fake_client_id', 64 | clientSecret: 'fake_client_secret', 65 | }); 66 | 67 | rp.__pushMockRequest({ 68 | options: { 69 | uri: '/endpoint', 70 | qs: { 71 | client_id: 'fake_client_id', 72 | client_secret: 'fake_client_secret', 73 | }, 74 | }, 75 | result: { 76 | headers: {}, 77 | body: { 78 | id: 1, 79 | }, 80 | }, 81 | }); 82 | 83 | return connector.get('/endpoint').then((result) => { 84 | expect(result).toEqual({ id: 1 }); 85 | }); 86 | }); 87 | 88 | it('should correctly interpret etags from Github', () => { 89 | const connector = new GitHubConnector(); 90 | const etag = 'etag'; 91 | 92 | rp.__pushMockRequest({ 93 | options: { 94 | uri: '/endpoint', 95 | }, 96 | result: { 97 | headers: { 98 | etag, 99 | }, 100 | body: { 101 | id: 1, 102 | }, 103 | }, 104 | }); 105 | 106 | const connector2 = new GitHubConnector(); 107 | 108 | rp.__pushMockRequest({ 109 | options: { 110 | uri: '/endpoint', 111 | headers: { 112 | 'If-None-Match': etag, 113 | 'user-agent': 'GitHunt', 114 | }, 115 | }, 116 | result: { 117 | headers: {}, 118 | body: { 119 | id: 1, 120 | }, 121 | }, 122 | }); 123 | 124 | return connector.get('/endpoint') 125 | .then(() => connector2.get('/endpoint')) 126 | .then((result) => { 127 | expect(result).toEqual({ id: 1 }); 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /api/github/models.js: -------------------------------------------------------------------------------- 1 | export class Repositories { 2 | constructor({ connector }) { 3 | this.connector = connector; 4 | } 5 | 6 | getByFullName(fullName) { 7 | return this.connector.get(`/repos/${fullName}`); 8 | } 9 | } 10 | 11 | export class Users { 12 | constructor({ connector }) { 13 | this.connector = connector; 14 | } 15 | 16 | getByLogin(username) { 17 | return this.connector.get(`/users/${username}`); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /api/github/schema.js: -------------------------------------------------------------------------------- 1 | import { property } from 'lodash'; 2 | 3 | export const schema = [` 4 | # A repository object from the GitHub API. This uses the exact field names returned by the 5 | # GitHub API for simplicity, even though the convention for GraphQL is usually to camel case. 6 | type Repository { 7 | # Just the name of the repository, e.g. GitHunt-API 8 | name: String! 9 | 10 | # The full name of the repository with the username, e.g. apollostack/GitHunt-API 11 | full_name: String! 12 | 13 | # The description of the repository 14 | description: String 15 | 16 | # The link to the repository on GitHub 17 | html_url: String! 18 | 19 | # The number of people who have starred this repository on GitHub 20 | stargazers_count: Int! 21 | 22 | # The number of open issues on this repository on GitHub 23 | open_issues_count: Int 24 | 25 | # The owner of this repository on GitHub, e.g. apollostack 26 | owner: User 27 | } 28 | 29 | # A user object from the GitHub API. This uses the exact field names returned from the GitHub API. 30 | type User { 31 | # The name of the user, e.g. apollostack 32 | login: String! 33 | 34 | # The URL to a directly embeddable image for this user's avatar 35 | avatar_url: String! 36 | 37 | # The URL of this user's GitHub page 38 | html_url: String! 39 | } 40 | `]; 41 | 42 | export const resolvers = { 43 | Repository: { 44 | owner: property('owner'), 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /api/githubKeys.js: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | 3 | dotenv.config({ silent: true }); 4 | 5 | export const { 6 | GITHUB_CLIENT_ID, 7 | GITHUB_CLIENT_SECRET, 8 | } = process.env; 9 | -------------------------------------------------------------------------------- /api/githubLogin.js: -------------------------------------------------------------------------------- 1 | import session from 'express-session'; 2 | import passport from 'passport'; 3 | import { Strategy as GitHubStrategy } from 'passport-github'; 4 | import knex from './sql/connector'; 5 | 6 | import { 7 | GITHUB_CLIENT_ID, 8 | GITHUB_CLIENT_SECRET, 9 | } from './githubKeys'; 10 | 11 | const KnexSessionStore = require('connect-session-knex')(session); 12 | 13 | const store = new KnexSessionStore({ 14 | knex, 15 | }); 16 | 17 | export function setUpGitHubLogin(app) { 18 | if (!GITHUB_CLIENT_ID) { 19 | console.warn('GitHub client ID not passed; login won\'t work.'); // eslint-disable-line no-console 20 | return; 21 | } 22 | 23 | const gitHubStrategyOptions = { 24 | clientID: GITHUB_CLIENT_ID, 25 | clientSecret: GITHUB_CLIENT_SECRET, 26 | callbackURL: process.env.NODE_ENV !== 'production' ? 27 | 'http://localhost:3000/login/github/callback' : 28 | 'http://www.githunt.com/login/github/callback', 29 | }; 30 | 31 | passport.use(new GitHubStrategy(gitHubStrategyOptions, 32 | (accessToken, refreshToken, profile, cb) => { 33 | cb(null, profile); 34 | })); 35 | 36 | passport.serializeUser((user, cb) => cb(null, user)); 37 | passport.deserializeUser((obj, cb) => cb(null, obj)); 38 | 39 | app.use(session({ 40 | secret: 'your secret', 41 | resave: true, 42 | saveUninitialized: true, 43 | store, 44 | })); 45 | 46 | app.use(passport.initialize()); 47 | app.use(passport.session()); 48 | 49 | app.get('/login/github', 50 | passport.authenticate('github')); 51 | 52 | app.get('/login/github/callback', 53 | passport.authenticate('github', { failureRedirect: '/' }), 54 | (req, res) => res.redirect('/')); 55 | 56 | app.get('/logout', (req, res) => { 57 | req.logout(); 58 | res.redirect('/'); 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /api/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | GitHunt 6 | 7 | 8 |

GitHunt API server

9 |

Thanks for downloading and running our example server app! This server doesn't include any UI code.

10 |

Try one of the following options:

11 | 32 |

33 | Have any improvements in mind? File an issue or a PR about this app at 34 | apollographql/GitHunt-API. 35 |

36 | 37 | 38 | -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | import { run } from './server'; 2 | 3 | run(process.env); 4 | -------------------------------------------------------------------------------- /api/schema.js: -------------------------------------------------------------------------------- 1 | import { merge } from 'lodash'; 2 | import { makeExecutableSchema } from 'graphql-tools'; 3 | 4 | import { schema as gitHubSchema, resolvers as gitHubResolvers } from './github/schema'; 5 | import { schema as sqlSchema, resolvers as sqlResolvers } from './sql/schema'; 6 | import { pubsub } from './subscriptions'; 7 | 8 | const rootSchema = [` 9 | 10 | # A list of options for the sort order of the feed 11 | enum FeedType { 12 | # Sort by a combination of freshness and score, using Reddit's algorithm 13 | HOT 14 | 15 | # Newest entries first 16 | NEW 17 | 18 | # Highest score entries first 19 | TOP 20 | } 21 | 22 | type Query { 23 | # A feed of repository submissions 24 | feed( 25 | # The sort order for the feed 26 | type: FeedType!, 27 | 28 | # The number of items to skip, for pagination 29 | offset: Int, 30 | 31 | # The number of items to fetch starting from the offset, for pagination 32 | limit: Int 33 | ): [Entry] 34 | 35 | # A single entry 36 | entry( 37 | # The full repository name from GitHub, e.g. "apollostack/GitHunt-API" 38 | repoFullName: String! 39 | ): Entry 40 | 41 | # Return the currently logged in user, or null if nobody is logged in 42 | currentUser: User 43 | } 44 | 45 | # The type of vote to record, when submitting a vote 46 | enum VoteType { 47 | UP 48 | DOWN 49 | CANCEL 50 | } 51 | 52 | type Mutation { 53 | # Submit a new repository, returns the new submission 54 | submitRepository( 55 | # The full repository name from GitHub, e.g. "apollostack/GitHunt-API" 56 | repoFullName: String! 57 | ): Entry 58 | 59 | # Vote on a repository submission, returns the submission that was voted on 60 | vote( 61 | # The full repository name from GitHub, e.g. "apollostack/GitHunt-API" 62 | repoFullName: String!, 63 | 64 | # The type of vote - UP, DOWN, or CANCEL 65 | type: VoteType! 66 | ): Entry 67 | 68 | # Comment on a repository, returns the new comment 69 | submitComment( 70 | # The full repository name from GitHub, e.g. "apollostack/GitHunt-API" 71 | repoFullName: String!, 72 | 73 | # The text content for the new comment 74 | commentContent: String! 75 | ): Comment 76 | } 77 | 78 | type Subscription { 79 | # Subscription fires on every comment added 80 | commentAdded(repoFullName: String!): Comment 81 | } 82 | 83 | schema { 84 | query: Query 85 | mutation: Mutation 86 | subscription: Subscription 87 | } 88 | 89 | `]; 90 | 91 | const rootResolvers = { 92 | Query: { 93 | feed(root, { type, offset, limit }, context) { 94 | // Ensure API consumer can only fetch 10 items at most 95 | const protectedLimit = (limit < 1 || limit > 10) ? 10 : limit; 96 | 97 | return context.Entries.getForFeed(type, offset, protectedLimit); 98 | }, 99 | entry(root, { repoFullName }, context) { 100 | return context.Entries.getByRepoFullName(repoFullName); 101 | }, 102 | currentUser(root, args, context) { 103 | return context.user || null; 104 | }, 105 | }, 106 | Mutation: { 107 | submitRepository(root, { repoFullName }, context) { 108 | if (!context.user) { 109 | throw new Error('Must be logged in to submit a repository.'); 110 | } 111 | 112 | return Promise.resolve() 113 | .then(() => ( 114 | context.Repositories.getByFullName(repoFullName) 115 | .catch(() => { 116 | throw new Error(`Couldn't find repository named "${repoFullName}"`); 117 | }) 118 | )) 119 | .then(() => ( 120 | context.Entries.submitRepository(repoFullName, context.user.login) 121 | )) 122 | .then(() => context.Entries.getByRepoFullName(repoFullName)); 123 | }, 124 | 125 | submitComment(root, { repoFullName, commentContent }, context) { 126 | if (!context.user) { 127 | throw new Error('Must be logged in to submit a comment.'); 128 | } 129 | return Promise.resolve() 130 | .then(() => ( 131 | context.Comments.submitComment( 132 | repoFullName, 133 | context.user.login, 134 | commentContent, 135 | ) 136 | )) 137 | .then(([id]) => context.Comments.getCommentById(id)) 138 | .then((comment) => { 139 | // publish subscription notification 140 | pubsub.publish('commentAdded', comment); 141 | return comment; 142 | }); 143 | }, 144 | 145 | vote(root, { repoFullName, type }, context) { 146 | if (!context.user) { 147 | throw new Error('Must be logged in to vote.'); 148 | } 149 | 150 | const voteValue = { 151 | UP: 1, 152 | DOWN: -1, 153 | CANCEL: 0, 154 | }[type]; 155 | 156 | return context.Entries.voteForEntry( 157 | repoFullName, 158 | voteValue, 159 | context.user.login, 160 | ).then(() => ( 161 | context.Entries.getByRepoFullName(repoFullName) 162 | )); 163 | }, 164 | }, 165 | Subscription: { 166 | commentAdded(comment) { 167 | // the subscription payload is the comment. 168 | return comment; 169 | }, 170 | }, 171 | }; 172 | 173 | // Put schema together into one array of schema strings 174 | // and one map of resolvers, like makeExecutableSchema expects 175 | const schema = [...rootSchema, ...gitHubSchema, ...sqlSchema]; 176 | const resolvers = merge(rootResolvers, gitHubResolvers, sqlResolvers); 177 | 178 | const executableSchema = makeExecutableSchema({ 179 | typeDefs: schema, 180 | resolvers, 181 | }); 182 | 183 | export default executableSchema; 184 | -------------------------------------------------------------------------------- /api/server.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import express from 'express'; 3 | import cors from 'cors'; 4 | import { graphqlExpress, graphiqlExpress } from 'graphql-server-express'; 5 | import OpticsAgent from 'optics-agent'; 6 | import bodyParser from 'body-parser'; 7 | import { invert, isString } from 'lodash'; 8 | import { createServer } from 'http'; 9 | import { SubscriptionServer } from 'subscriptions-transport-ws'; 10 | 11 | import { 12 | GITHUB_CLIENT_ID, 13 | GITHUB_CLIENT_SECRET, 14 | } from './githubKeys'; 15 | 16 | import { setUpGitHubLogin } from './githubLogin'; 17 | import { GitHubConnector } from './github/connector'; 18 | import { Repositories, Users } from './github/models'; 19 | import { Entries, Comments } from './sql/models'; 20 | import { subscriptionManager } from './subscriptions'; 21 | 22 | import schema from './schema'; 23 | import queryMap from '../extracted_queries.json'; 24 | import config from './config'; 25 | 26 | const SUBSCRIPTIONS_PATH = '/subscriptions'; 27 | 28 | // Arguments usually come from env vars 29 | export function run({ 30 | OPTICS_API_KEY, 31 | PORT: portFromEnv = 3010, 32 | } = {}) { 33 | if (OPTICS_API_KEY) { 34 | OpticsAgent.instrumentSchema(schema); 35 | } 36 | 37 | let port = portFromEnv; 38 | if (isString(portFromEnv)) { 39 | port = parseInt(portFromEnv, 10); 40 | } 41 | 42 | const subscriptionsURL = process.env.NODE_ENV !== 'production' 43 | ? `ws://localhost:${port}${SUBSCRIPTIONS_PATH}` 44 | : `ws://api.githunt.com${SUBSCRIPTIONS_PATH}`; 45 | 46 | const app = express(); 47 | 48 | app.use(cors()); 49 | app.use(bodyParser.urlencoded({ extended: true })); 50 | app.use(bodyParser.json()); 51 | 52 | const invertedMap = invert(queryMap); 53 | 54 | app.use( 55 | '/graphql', 56 | (req, resp, next) => { 57 | if (config.persistedQueries) { 58 | // eslint-disable-next-line no-param-reassign 59 | req.body.query = invertedMap[req.body.id]; 60 | } 61 | next(); 62 | }, 63 | ); 64 | 65 | setUpGitHubLogin(app); 66 | 67 | if (OPTICS_API_KEY) { 68 | app.use('/graphql', OpticsAgent.middleware()); 69 | } 70 | 71 | app.use('/graphql', graphqlExpress((req) => { 72 | // Get the query, the same way express-graphql does it 73 | // https://github.com/graphql/express-graphql/blob/3fa6e68582d6d933d37fa9e841da5d2aa39261cd/src/index.js#L257 74 | const query = req.query.query || req.body.query; 75 | if (query && query.length > 2000) { 76 | // None of our app's queries are this long 77 | // Probably indicates someone trying to send an overly expensive query 78 | throw new Error('Query too large.'); 79 | } 80 | 81 | let user; 82 | if (req.user) { 83 | // We get req.user from passport-github with some pretty oddly named fields, 84 | // let's convert that to the fields in our schema, which match the GitHub 85 | // API field names. 86 | user = { 87 | login: req.user.username, 88 | html_url: req.user.profileUrl, 89 | avatar_url: req.user.photos[0].value, 90 | }; 91 | } 92 | 93 | // Initialize a new GitHub connector instance for every GraphQL request, so that API fetches 94 | // are deduplicated per-request only. 95 | const gitHubConnector = new GitHubConnector({ 96 | clientId: GITHUB_CLIENT_ID, 97 | clientSecret: GITHUB_CLIENT_SECRET, 98 | }); 99 | 100 | let opticsContext; 101 | if (OPTICS_API_KEY) { 102 | opticsContext = OpticsAgent.context(req); 103 | } 104 | 105 | return { 106 | schema, 107 | context: { 108 | user, 109 | Repositories: new Repositories({ connector: gitHubConnector }), 110 | Users: new Users({ connector: gitHubConnector }), 111 | Entries: new Entries(), 112 | Comments: new Comments(), 113 | opticsContext, 114 | }, 115 | }; 116 | })); 117 | 118 | app.use('/graphiql', graphiqlExpress({ 119 | endpointURL: '/graphql', 120 | subscriptionsEndpoint: subscriptionsURL, 121 | query: `{ 122 | feed (type: NEW, limit: 5) { 123 | repository { 124 | owner { login } 125 | name 126 | } 127 | 128 | postedBy { login } 129 | } 130 | } 131 | `, 132 | })); 133 | 134 | // Serve our helpful static landing page. Not used in production. 135 | app.get('/', (req, res) => { 136 | res.sendFile(path.join(__dirname, 'index.html')); 137 | }); 138 | 139 | const server = createServer(app); 140 | 141 | server.listen(port, () => { 142 | console.log(`API Server is now running on http://localhost:${port}`); // eslint-disable-line no-console 143 | console.log(`API Subscriptions server is now running on ws://localhost:${port}${SUBSCRIPTIONS_PATH}`); // eslint-disable-line no-console 144 | }); 145 | 146 | // eslint-disable-next-line 147 | new SubscriptionServer( 148 | { 149 | subscriptionManager, 150 | 151 | // the onSubscribe function is called for every new subscription 152 | // and we use it to set the GraphQL context for this subscription 153 | onSubscribe: (msg, params) => { 154 | const gitHubConnector = new GitHubConnector({ 155 | clientId: GITHUB_CLIENT_ID, 156 | clientSecret: GITHUB_CLIENT_SECRET, 157 | }); 158 | return Object.assign({}, params, { 159 | context: { 160 | Repositories: new Repositories({ connector: gitHubConnector }), 161 | Users: new Users({ connector: gitHubConnector }), 162 | Entries: new Entries(), 163 | Comments: new Comments(), 164 | }, 165 | }); 166 | }, 167 | }, 168 | { 169 | path: SUBSCRIPTIONS_PATH, 170 | server, 171 | }, 172 | ); 173 | 174 | return server; 175 | } 176 | -------------------------------------------------------------------------------- /api/sql/connector.js: -------------------------------------------------------------------------------- 1 | import knex from 'knex'; 2 | import knexfile from '../../knexfile'; 3 | 4 | // Eventually we want to wrap Knex to do some batching and caching, but for 5 | // now this will do since we know none of our queries need it 6 | export default knex(knexfile[process.env.NODE_ENV || 'development']); 7 | -------------------------------------------------------------------------------- /api/sql/models.js: -------------------------------------------------------------------------------- 1 | import RedditScore from 'reddit-score'; 2 | 3 | import knex from './connector'; 4 | 5 | // A utility function that makes sure we always query the same columns 6 | function addSelectToEntryQuery(query) { 7 | query.select('entries.*', knex.raw('coalesce(sum(votes.vote_value), 0) as score')) 8 | .leftJoin('votes', 'entries.id', 'votes.entry_id') 9 | .groupBy('entries.id'); 10 | } 11 | 12 | // If we don't have a score, it is NULL by default 13 | // Convert it to 0 on read. 14 | function handleNullScoreInRow({ score, ...rest }) { 15 | return { 16 | score: score || 0, 17 | ...rest, 18 | }; 19 | } 20 | 21 | // Given a Knex query promise, resolve it and then format one or more rows 22 | function formatRows(query) { 23 | return query.then((rows) => { 24 | if (rows.map) { 25 | return rows.map(handleNullScoreInRow); 26 | } 27 | return handleNullScoreInRow(rows); 28 | }); 29 | } 30 | 31 | export class Comments { 32 | getCommentById(id) { 33 | const query = knex('comments') 34 | .where({ id }); 35 | return query.then(([row]) => row); 36 | } 37 | 38 | getCommentsByRepoName(name, limit, offset) { 39 | const query = knex('comments') 40 | .where({ repository_name: name }) 41 | .orderBy('created_at', 'desc'); 42 | 43 | if (limit !== -1) { 44 | query.limit(limit).offset(offset); 45 | } 46 | 47 | return query.then(rows => (rows || [])); 48 | } 49 | 50 | getCommentCount(name) { 51 | const query = knex('comments') 52 | .where({ repository_name: name }) 53 | .count(); 54 | return query.then(rows => rows.map(row => (row['count(*)'] || row.count || '0'))); 55 | } 56 | 57 | submitComment(repoFullName, username, content) { 58 | return knex.transaction(trx => trx('comments') 59 | .insert({ 60 | content, 61 | created_at: new Date(Date.now()), 62 | repository_name: repoFullName, 63 | posted_by: username, 64 | }) 65 | .returning('id')); 66 | } 67 | } 68 | export class Entries { 69 | getForFeed(type, offset, limit) { 70 | const query = knex('entries') 71 | .modify(addSelectToEntryQuery); 72 | 73 | if (type === 'NEW') { 74 | query.orderBy('created_at', 'desc'); 75 | } else if (type === 'TOP') { 76 | query.orderBy('score', 'desc'); 77 | } else if (type === 'HOT') { 78 | query.orderBy('hot_score', 'desc'); 79 | } else { 80 | throw new Error(`Feed type ${type} not implemented.`); 81 | } 82 | 83 | if (offset > 0) { 84 | query.offset(offset); 85 | } 86 | 87 | query.limit(limit); 88 | 89 | return formatRows(query); 90 | } 91 | 92 | getByRepoFullName(name) { 93 | // No need to batch 94 | const query = knex('entries') 95 | .modify(addSelectToEntryQuery) 96 | .where({ 97 | repository_name: name, 98 | }) 99 | .first(); 100 | 101 | return formatRows(query); 102 | } 103 | 104 | voteForEntry(repoFullName, voteValue, username) { 105 | let entry_id; 106 | 107 | return Promise.resolve() 108 | 109 | // First, get the entry_id from repoFullName 110 | .then(() => ( 111 | knex('entries') 112 | .where({ 113 | repository_name: repoFullName, 114 | }) 115 | .select(['id']) 116 | .first() 117 | .then(({ id }) => { 118 | entry_id = id; 119 | }) 120 | )) 121 | // Remove any previous votes by this person 122 | .then(() => ( 123 | knex('votes') 124 | .where({ 125 | entry_id, 126 | username, 127 | }) 128 | .delete() 129 | )) 130 | // Then, insert a vote 131 | .then(() => ( 132 | knex('votes') 133 | .insert({ 134 | entry_id, 135 | username, 136 | vote_value: voteValue, 137 | }) 138 | )) 139 | // Update hot score 140 | .then(() => this.updateHotScore(repoFullName)); 141 | } 142 | 143 | updateHotScore(repoFullName) { 144 | let entryId; 145 | let createdAt; 146 | 147 | return Promise.resolve() 148 | .then(() => ( 149 | knex('entries') 150 | .where({ 151 | repository_name: repoFullName, 152 | }) 153 | .select(['id', 'created_at']) 154 | .first() 155 | .then(({ id, created_at }) => { 156 | entryId = id; 157 | createdAt = created_at; 158 | }) 159 | )) 160 | .then(() => { 161 | return knex('votes') 162 | .select(['vote_value']) 163 | .where({ 164 | entry_id: entryId, 165 | }); 166 | }) 167 | .then((results) => { 168 | function countVotes(vote) { 169 | return (count, value) => count + (value === vote ? 1 : 0); 170 | } 171 | 172 | if (results && results.map) { 173 | const votes = results.map(vote => vote.vote_value); 174 | const ups = votes.reduce(countVotes(1), 0); 175 | const downs = votes.reduce(countVotes(-1), 0); 176 | const date = createdAt instanceof Date ? createdAt : new Date(createdAt); 177 | 178 | return (new RedditScore()).hot(ups, downs, date); 179 | } 180 | 181 | return 0; 182 | }) 183 | .then(hotScore => ( 184 | knex('entries') 185 | .where('id', entryId) 186 | .update({ 187 | hot_score: hotScore, 188 | }) 189 | )); 190 | } 191 | 192 | haveVotedForEntry(repoFullName, username) { 193 | let entry_id; 194 | 195 | return Promise.resolve() 196 | 197 | // First, get the entry_id from repoFullName 198 | .then(() => ( 199 | knex('entries') 200 | .where({ 201 | repository_name: repoFullName, 202 | }) 203 | .select(['id']) 204 | .first() 205 | .then(({ id }) => { 206 | entry_id = id; 207 | }) 208 | )) 209 | 210 | .then(() => ( 211 | knex('votes') 212 | .where({ 213 | entry_id, 214 | username, 215 | }) 216 | .select(['id', 'vote_value']) 217 | .first() 218 | )) 219 | 220 | .then(vote => vote || { vote_value: 0 }); 221 | } 222 | 223 | submitRepository(repoFullName, username) { 224 | const rateLimitMs = 60 * 60 * 1000; 225 | const rateLimitThresh = 3; 226 | 227 | // Rate limiting logic 228 | return knex.transaction(trx => trx('entries') 229 | .count() 230 | .where('posted_by', '=', username) 231 | .where('created_at', '>', new Date(Date.now() - rateLimitMs)) 232 | .then((obj) => { 233 | // If the user has already submitted too many times, we don't 234 | // post the repo. 235 | const postCount = obj[0]['count(*)']; 236 | if (postCount > rateLimitThresh) { 237 | throw new Error('Too many repos submitted in the last hour!'); 238 | } else { 239 | return trx('entries') 240 | .insert({ 241 | created_at: new Date(Date.now()), 242 | updated_at: new Date(Date.now()), 243 | repository_name: repoFullName, 244 | posted_by: username, 245 | }); 246 | } 247 | })) 248 | .then(() => this.updateHotScore(repoFullName)); 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /api/sql/schema.js: -------------------------------------------------------------------------------- 1 | import { property, constant } from 'lodash'; 2 | 3 | export const schema = [` 4 | 5 | # A comment about an entry, submitted by a user 6 | type Comment { 7 | # The SQL ID of this entry 8 | id: Int! 9 | 10 | # The GitHub user who posted the comment 11 | postedBy: User! 12 | 13 | # A timestamp of when the comment was posted 14 | createdAt: Float! # Actually a date 15 | 16 | # The text of the comment 17 | content: String! 18 | 19 | # The repository which this comment is about 20 | repoName: String! 21 | } 22 | 23 | # XXX to be removed 24 | type Vote { 25 | vote_value: Int! 26 | } 27 | 28 | # Information about a GitHub repository submitted to GitHunt 29 | type Entry { 30 | # Information about the repository from GitHub 31 | repository: Repository! 32 | 33 | # The GitHub user who submitted this entry 34 | postedBy: User! 35 | 36 | # A timestamp of when the entry was submitted 37 | createdAt: Float! # Actually a date 38 | 39 | # The score of this repository, upvotes - downvotes 40 | score: Int! 41 | 42 | # The hot score of this repository 43 | hotScore: Float! 44 | 45 | # Comments posted about this repository 46 | comments(limit: Int, offset: Int): [Comment]! 47 | 48 | # The number of comments posted about this repository 49 | commentCount: Int! 50 | 51 | # The SQL ID of this entry 52 | id: Int! 53 | 54 | # XXX to be changed 55 | vote: Vote! 56 | } 57 | 58 | `]; 59 | 60 | export const resolvers = { 61 | Entry: { 62 | repository({ repository_name }, _, context) { 63 | return context.Repositories.getByFullName(repository_name); 64 | }, 65 | postedBy({ posted_by }, _, context) { 66 | return context.Users.getByLogin(posted_by); 67 | }, 68 | comments({ repository_name }, { limit = -1, offset = 0 }, context) { 69 | return context.Comments.getCommentsByRepoName(repository_name, limit, offset); 70 | }, 71 | createdAt: property('created_at'), 72 | hotScore: property('hot_score'), 73 | commentCount({ repository_name }, _, context) { 74 | return context.Comments.getCommentCount(repository_name) || constant(0); 75 | }, 76 | vote({ repository_name }, _, context) { 77 | if (!context.user) return { vote_value: 0 }; 78 | return context.Entries.haveVotedForEntry(repository_name, context.user.login); 79 | }, 80 | }, 81 | 82 | Comment: { 83 | createdAt: property('created_at'), 84 | postedBy({ posted_by }, _, context) { 85 | return context.Users.getByLogin(posted_by); 86 | }, 87 | }, 88 | }; 89 | -------------------------------------------------------------------------------- /api/subscriptions.js: -------------------------------------------------------------------------------- 1 | import { PubSub, SubscriptionManager } from 'graphql-subscriptions'; 2 | import schema from './schema'; 3 | 4 | const pubsub = new PubSub(); 5 | const subscriptionManager = new SubscriptionManager({ 6 | schema, 7 | pubsub, 8 | setupFunctions: { 9 | commentAdded: (options, args) => ({ 10 | commentAdded: comment => comment.repository_name === args.repoFullName, 11 | }), 12 | }, 13 | }); 14 | 15 | export { subscriptionManager, pubsub }; 16 | -------------------------------------------------------------------------------- /extracted_queries.json: -------------------------------------------------------------------------------- 1 | {"query Comment($repoName: String!) {\n currentUser {\n login\n html_url\n __typename\n }\n entry(repoFullName: $repoName) {\n id\n postedBy {\n login\n html_url\n __typename\n }\n createdAt\n comments {\n ...CommentsPageComment\n __typename\n }\n repository {\n full_name\n html_url\n description\n open_issues_count\n stargazers_count\n __typename\n }\n __typename\n }\n}\n\nfragment CommentsPageComment on Comment {\n id\n postedBy {\n login\n html_url\n __typename\n }\n createdAt\n content\n __typename\n}\n\nfragment CommentsPageComment on Comment {\n id\n postedBy {\n login\n html_url\n __typename\n }\n createdAt\n content\n __typename\n}\n":1,"subscription onCommentAdded($repoFullName: String!) {\n commentAdded(repoFullName: $repoFullName) {\n id\n postedBy {\n login\n html_url\n __typename\n }\n createdAt\n content\n __typename\n }\n}\n":2,"query Feed($type: FeedType!, $offset: Int, $limit: Int) {\n currentUser {\n login\n __typename\n }\n feed(type: $type, offset: $offset, limit: $limit) {\n ...FeedEntry\n __typename\n }\n}\n\nfragment FeedEntry on Entry {\n id\n commentCount\n repository {\n full_name\n html_url\n owner {\n avatar_url\n __typename\n }\n __typename\n }\n ...VoteButtons\n ...RepoInfo\n __typename\n}\n\nfragment VoteButtons on Entry {\n score\n vote {\n vote_value\n __typename\n }\n __typename\n}\n\nfragment RepoInfo on Entry {\n createdAt\n repository {\n description\n stargazers_count\n open_issues_count\n __typename\n }\n postedBy {\n html_url\n login\n __typename\n }\n __typename\n}\n":3,"query CurrentUserForLayout {\n currentUser {\n login\n avatar_url\n __typename\n }\n}\n":4,"mutation submitComment($repoFullName: String!, $commentContent: String!) {\n submitComment(repoFullName: $repoFullName, commentContent: $commentContent) {\n ...CommentsPageComment\n __typename\n }\n}\n\nfragment CommentsPageComment on Comment {\n id\n postedBy {\n login\n html_url\n __typename\n }\n createdAt\n content\n __typename\n}\n\nfragment CommentsPageComment on Comment {\n id\n postedBy {\n login\n html_url\n __typename\n }\n createdAt\n content\n __typename\n}\n":5,"mutation submitRepository($repoFullName: String!) {\n submitRepository(repoFullName: $repoFullName) {\n createdAt\n __typename\n }\n}\n":6,"mutation vote($repoFullName: String!, $type: VoteType!) {\n vote(repoFullName: $repoFullName, type: $type) {\n score\n id\n vote {\n vote_value\n __typename\n }\n __typename\n }\n}\n":7} -------------------------------------------------------------------------------- /knexfile.js: -------------------------------------------------------------------------------- 1 | // Since Knex always runs this file first, all of our seeds and migrations are babelified. 2 | require('babel-register'); 3 | 4 | const parse = require('pg-connection-string').parse; 5 | 6 | const DATABASE_URL = process.env.DATABASE_URL; 7 | 8 | module.exports = { 9 | development: { 10 | client: 'sqlite3', 11 | connection: { 12 | filename: './dev.sqlite3', 13 | }, 14 | useNullAsDefault: true, 15 | }, 16 | test: { 17 | client: 'sqlite3', 18 | connection: { 19 | filename: './test.sqlite3', 20 | }, 21 | useNullAsDefault: true, 22 | }, 23 | production: DATABASE_URL && { 24 | client: 'pg', 25 | connection: Object.assign({}, parse(DATABASE_URL), { ssl: true }), 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /migrations/20160518201950_create_comments_entries_votes.js: -------------------------------------------------------------------------------- 1 | export function up(knex, Promise) { 2 | return Promise.all([ 3 | knex.schema.createTable('comments', (table) => { 4 | table.increments(); 5 | table.timestamps(); 6 | table.string('posted_by'); 7 | table.text('content'); 8 | table.string('repository_name'); 9 | }), 10 | 11 | knex.schema.createTable('entries', (table) => { 12 | table.increments(); 13 | table.timestamps(); 14 | table.string('repository_name').unique(); 15 | table.string('posted_by'); 16 | table.float('hot_score'); 17 | }), 18 | 19 | knex.schema.createTable('votes', (table) => { 20 | table.increments(); 21 | table.timestamps(); 22 | table.integer('entry_id'); 23 | table.integer('vote_value'); 24 | table.string('username'); 25 | table.unique(['entry_id', 'username']); 26 | }), 27 | ]); 28 | } 29 | 30 | export function down(knex, Promise) { 31 | return Promise.all([ 32 | knex.schema.dropTable('comments'), 33 | knex.schema.dropTable('entries'), 34 | knex.schema.dropTable('votes'), 35 | ]); 36 | } 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "githunt-api", 3 | "version": "1.0.0", 4 | "description": "Example app for Apollo", 5 | "scripts": { 6 | "start": "babel-node api/index.js", 7 | "dev": "nodemon api/index.js --watch api --exec babel-node", 8 | "lint": "eslint api migrations seeds", 9 | "test": "jest && npm run lint", 10 | "test:watch": "jest --watch", 11 | "seed": "knex seed:run", 12 | "migrate": "knex migrate:latest", 13 | "test:setup": "rm test.sqlite3 || true; knex migrate:latest --env test && knex seed:run --env test" 14 | }, 15 | "private": true, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/apollostack/GitHunt.git" 19 | }, 20 | "author": "", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/apollostack/GitHunt/issues" 24 | }, 25 | "homepage": "https://github.com/apollostack/GitHunt#readme", 26 | "devDependencies": { 27 | "babel-eslint": "7.2.1", 28 | "babel-jest": "^19.0.0", 29 | "babel-register": "6.24.0", 30 | "casual": "^1.5.11", 31 | "eslint": "3.19.0", 32 | "eslint-config-airbnb": "14.1.0", 33 | "eslint-plugin-babel": "4.1.1", 34 | "eslint-plugin-import": "2.2.0", 35 | "eslint-plugin-jsx-a11y": "4.0.0", 36 | "eslint-plugin-react": "6.10.1", 37 | "jest": "^19.0.2", 38 | "nodemon": "1.11.0", 39 | "sqlite3": "3.1.8" 40 | }, 41 | "dependencies": { 42 | "babel-cli": "6.23.0", 43 | "babel-core": "6.24.0", 44 | "babel-preset-es2015": "6.24.0", 45 | "babel-preset-react": "6.23.0", 46 | "babel-preset-stage-2": "6.22.0", 47 | "body-parser": "1.17.1", 48 | "connect-session-knex": "1.3.4", 49 | "cors": "^2.8.3", 50 | "dataloader": "1.3.0", 51 | "dotenv": "4.0.0", 52 | "express": "4.15.2", 53 | "express-session": "1.15.2", 54 | "graphql": "^0.9.1", 55 | "graphql-server-express": "^0.7.1", 56 | "graphql-subscriptions": "^0.3.0", 57 | "graphql-tools": "^0.11.0", 58 | "knex": "0.12.9", 59 | "lodash": "4.17.4", 60 | "optics-agent": "^1.0.5", 61 | "passport": "0.3.2", 62 | "passport-github": "1.1.0", 63 | "persistgraphql": "^0.3.0", 64 | "pg": "^6.1.2", 65 | "pg-connection-string": "^0.1.3", 66 | "reddit-score": "0.0.2", 67 | "request-promise": "4.2.0", 68 | "subscriptions-transport-ws": "^0.5.4" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /screenshots/github-oath-setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidyaha/GitHunt-API/95e3e1ba045d1df94dc3f6d44e8d83ab157abf6d/screenshots/github-oath-setup.png -------------------------------------------------------------------------------- /screenshots/github-oauth-keys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidyaha/GitHunt-API/95e3e1ba045d1df94dc3f6d44e8d83ab157abf6d/screenshots/github-oauth-keys.png -------------------------------------------------------------------------------- /seeds/seed.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import RedditScore from 'reddit-score'; 3 | 4 | function countScore(score) { 5 | return (count, value) => count + (value === score ? 1 : 0); 6 | } 7 | 8 | function hot(repoVotes, date) { 9 | const redditScore = new RedditScore(); 10 | 11 | const createdAt = date instanceof Date ? date : new Date(date); 12 | 13 | const scores = _.values(repoVotes || {}); 14 | const ups = scores.reduce(countScore(1), 0); 15 | const downs = scores.reduce(countScore(-1), 0); 16 | 17 | return redditScore.hot(ups, downs, createdAt); 18 | } 19 | 20 | const repos = [ 21 | { 22 | repository_name: 'apollographql/apollo-client', 23 | posted_by: 'stubailo', 24 | }, 25 | { 26 | repository_name: 'apollographql/graphql-server', 27 | posted_by: 'helfer', 28 | }, 29 | { 30 | repository_name: 'meteor/meteor', 31 | posted_by: 'tmeasday', 32 | }, 33 | { 34 | repository_name: 'twbs/bootstrap', 35 | posted_by: 'Slava', 36 | }, 37 | { 38 | repository_name: 'd3/d3', 39 | posted_by: 'Slava', 40 | }, 41 | { 42 | repository_name: 'angular/angular.js', 43 | posted_by: 'Slava', 44 | }, 45 | { 46 | repository_name: 'facebook/react', 47 | posted_by: 'Slava', 48 | }, 49 | { 50 | repository_name: 'jquery/jquery', 51 | posted_by: 'Slava', 52 | }, 53 | { 54 | repository_name: 'airbnb/javascript', 55 | posted_by: 'Slava', 56 | }, 57 | { 58 | repository_name: 'facebook/react-native', 59 | posted_by: 'Slava', 60 | }, 61 | { 62 | repository_name: 'torvalds/linux', 63 | posted_by: 'Slava', 64 | }, 65 | { 66 | repository_name: 'daneden/animate.css', 67 | posted_by: 'Slava', 68 | }, 69 | { 70 | repository_name: 'electron/electron', 71 | posted_by: 'Slava', 72 | }, 73 | { 74 | repository_name: 'docker/docker', 75 | posted_by: 'Slava', 76 | }, 77 | ]; 78 | 79 | const repoIds = {}; 80 | 81 | const votes = { 82 | [repos[0].repository_name]: { 83 | stubailo: 1, 84 | helfer: 1, 85 | }, 86 | [repos[1].repository_name]: { 87 | helfer: 1, 88 | }, 89 | [repos[2].repository_name]: { 90 | 91 | }, 92 | }; 93 | 94 | export function seed(knex, Promise) { 95 | return Promise.all([ 96 | knex('entries').del(), 97 | knex('votes').del(), 98 | ]) 99 | 100 | // Insert some entries for the repositories 101 | .then(() => { 102 | return Promise.all(repos.map(({ repository_name, posted_by }, i) => { 103 | const createdAt = new Date(Date.now() - (i * 10000)); 104 | const repoVotes = votes[repository_name]; 105 | const hotScore = hot(repoVotes, createdAt); 106 | 107 | return knex('entries').insert({ 108 | created_at: createdAt, 109 | updated_at: createdAt, 110 | repository_name, 111 | posted_by, 112 | hot_score: hotScore, 113 | }).returning('id').then(([id]) => { 114 | repoIds[repository_name] = id; 115 | }); 116 | })); 117 | }) 118 | 119 | // Insert some votes so that we can render a sorted feed 120 | .then(() => { 121 | return Promise.all(_.toPairs(votes).map(([repoName, voteMap]) => { 122 | return Promise.all(_.toPairs(voteMap).map(([username, vote_value]) => { 123 | return knex('votes').insert({ 124 | entry_id: repoIds[repoName], 125 | vote_value, 126 | username, 127 | }); 128 | })); 129 | })); 130 | }); 131 | } 132 | --------------------------------------------------------------------------------