├── .gitignore ├── .ruby-version ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── app ├── controllers │ ├── application_controller.rb │ └── repositories_controller.rb └── views │ ├── layouts │ └── application.html.erb │ └── repositories │ ├── _icon.html.erb │ ├── _list_item.html.erb │ ├── _navigation.html.erb │ ├── _repositories.html.erb │ ├── _star.html.erb │ ├── index.html.erb │ └── show.html.erb ├── bin ├── bundle ├── rails └── rake ├── config.ru ├── config ├── application.rb ├── boot.rb ├── environment.rb ├── environments │ └── development.rb ├── routes.rb └── secrets.yml ├── db └── schema.json ├── lib └── tasks │ └── schema.rake └── public ├── application.css ├── application.js ├── favicon.ico ├── octicons.eot ├── octicons.min.css ├── octicons.svg ├── octicons.ttf ├── octicons.woff └── octicons.woff2 /.gitignore: -------------------------------------------------------------------------------- 1 | *.rbc 2 | capybara-*.html 3 | .rspec 4 | /log 5 | /tmp 6 | /db/*.sqlite3 7 | /db/*.sqlite3-journal 8 | /public/system 9 | /coverage/ 10 | /spec/tmp 11 | *.orig 12 | rerun.txt 13 | pickle-email-*.html 14 | 15 | # Ignore Byebug command history file. 16 | .byebug_history 17 | 18 | # Ignore precompiled javascript packs 19 | /public/packs 20 | /public/packs-test 21 | /public/assets 22 | 23 | # Ignore uploaded files in development 24 | /storage/* 25 | !/storage/.keep 26 | 27 | # Editors 28 | *.sublime-workspace 29 | *.sublime-project 30 | .idea 31 | .vscode 32 | 33 | .DS_Store 34 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.4.2 -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rails", "~> 5.0.7" 4 | gem "graphql", "1.2.2" 5 | gem "graphql-client", "0.2.3" 6 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | actioncable (5.0.7) 5 | actionpack (= 5.0.7) 6 | nio4r (>= 1.2, < 3.0) 7 | websocket-driver (~> 0.6.1) 8 | actionmailer (5.0.7) 9 | actionpack (= 5.0.7) 10 | actionview (= 5.0.7) 11 | activejob (= 5.0.7) 12 | mail (~> 2.5, >= 2.5.4) 13 | rails-dom-testing (~> 2.0) 14 | actionpack (5.0.7) 15 | actionview (= 5.0.7) 16 | activesupport (= 5.0.7) 17 | rack (~> 2.0) 18 | rack-test (~> 0.6.3) 19 | rails-dom-testing (~> 2.0) 20 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 21 | actionview (5.0.7) 22 | activesupport (= 5.0.7) 23 | builder (~> 3.1) 24 | erubis (~> 2.7.0) 25 | rails-dom-testing (~> 2.0) 26 | rails-html-sanitizer (~> 1.0, >= 1.0.3) 27 | activejob (5.0.7) 28 | activesupport (= 5.0.7) 29 | globalid (>= 0.3.6) 30 | activemodel (5.0.7) 31 | activesupport (= 5.0.7) 32 | activerecord (5.0.7) 33 | activemodel (= 5.0.7) 34 | activesupport (= 5.0.7) 35 | arel (~> 7.0) 36 | activesupport (5.0.7) 37 | concurrent-ruby (~> 1.0, >= 1.0.2) 38 | i18n (>= 0.7, < 2) 39 | minitest (~> 5.1) 40 | tzinfo (~> 1.1) 41 | arel (7.1.4) 42 | builder (3.2.3) 43 | concurrent-ruby (1.0.5) 44 | crass (1.0.4) 45 | erubis (2.7.0) 46 | globalid (0.4.1) 47 | activesupport (>= 4.2.0) 48 | graphql (1.2.2) 49 | graphql-client (0.2.3) 50 | activesupport (>= 3.0, < 6.0) 51 | graphql (>= 0.19.2) 52 | i18n (1.1.1) 53 | concurrent-ruby (~> 1.0) 54 | loofah (2.2.2) 55 | crass (~> 1.0.2) 56 | nokogiri (>= 1.5.9) 57 | mail (2.7.1) 58 | mini_mime (>= 0.1.1) 59 | method_source (0.9.0) 60 | mini_mime (1.0.1) 61 | mini_portile2 (2.3.0) 62 | minitest (5.11.3) 63 | nio4r (2.3.1) 64 | nokogiri (1.8.5) 65 | mini_portile2 (~> 2.3.0) 66 | rack (2.0.5) 67 | rack-test (0.6.3) 68 | rack (>= 1.0) 69 | rails (5.0.7) 70 | actioncable (= 5.0.7) 71 | actionmailer (= 5.0.7) 72 | actionpack (= 5.0.7) 73 | actionview (= 5.0.7) 74 | activejob (= 5.0.7) 75 | activemodel (= 5.0.7) 76 | activerecord (= 5.0.7) 77 | activesupport (= 5.0.7) 78 | bundler (>= 1.3.0) 79 | railties (= 5.0.7) 80 | sprockets-rails (>= 2.0.0) 81 | rails-dom-testing (2.0.3) 82 | activesupport (>= 4.2.0) 83 | nokogiri (>= 1.6) 84 | rails-html-sanitizer (1.0.4) 85 | loofah (~> 2.2, >= 2.2.2) 86 | railties (5.0.7) 87 | actionpack (= 5.0.7) 88 | activesupport (= 5.0.7) 89 | method_source 90 | rake (>= 0.8.7) 91 | thor (>= 0.18.1, < 2.0) 92 | rake (12.3.1) 93 | sprockets (3.7.2) 94 | concurrent-ruby (~> 1.0) 95 | rack (> 1, < 3) 96 | sprockets-rails (3.2.1) 97 | actionpack (>= 4.0) 98 | activesupport (>= 4.0) 99 | sprockets (>= 3.0.0) 100 | thor (0.20.0) 101 | thread_safe (0.3.6) 102 | tzinfo (1.2.5) 103 | thread_safe (~> 0.1) 104 | websocket-driver (0.6.5) 105 | websocket-extensions (>= 0.1.0) 106 | websocket-extensions (0.1.3) 107 | 108 | PLATFORMS 109 | ruby 110 | 111 | DEPENDENCIES 112 | graphql (= 1.2.2) 113 | graphql-client (= 0.2.3) 114 | rails (~> 5.0.7) 115 | 116 | BUNDLED WITH 117 | 1.16.6 118 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 GitHub, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub GraphQL Rails example application 2 | 3 | Demonstrates how to use the [`graphql-client`](http://github.com/github/graphql-client) gem to build a simple repository listing web view against the [GitHub GraphQL API](https://developer.github.com/v4/guides/intro-to-graphql/). 4 | 5 | 6 | 7 | The application structure is setup like a typical Rails app using controllers, views and routes with one key difference, no models. This app doesn't connect directly to any database. All the data is being fetched remotely from the GitHub GraphQL API. Instead of declaring resource models, data queries are declared right along side their usage in controllers and views. This allows an efficient single request to be constructed rather than making numerous REST requests to render a single view. 8 | 9 | ## Table of Contents 10 | 11 | Jump right into the code and read the inline documentation. The following is a suggested reading order: 12 | 13 | 1. [app/controller/repositories_controller.rb](https://github.com/github/github-graphql-rails-example/blob/master/app/controllers/repositories_controller.rb) defines the top level GraphQL queries to fetch repository list and show pages. 14 | 2. [app/views/repositories/index.html.erb](https://github.com/github/github-graphql-rails-example/blob/master/app/views/repositories/index.html.erb) shows the root template's listing query and composition over subviews. 15 | 3. [app/views/repositories/_repositories.html.erb]( https://github.com/github/github-graphql-rails-example/blob/master/app/views/repositories/_repositories.html.erb) makes use of GraphQL connections to show the first couple items and a "load more" button. 16 | 4. [app/views/repositories/show.html.erb](https://github.com/github/github-graphql-rails-example/blob/master/app/views/repositories/show.html.erb) shows the root template for the repository show page. 17 | 5. [app/controller/application_controller.rb](https://github.com/github/github-graphql-rails-example/blob/master/app/controllers/application_controller.rb) defines controller helpers for executing GraphQL query requests. 18 | 6. [config/application.rb](https://github.com/github/github-graphql-rails-example/blob/master/config/application.rb) configures `GraphQL::Client` to point to the GitHub GraphQL endpoint. 19 | 20 | ## Running locally 21 | 22 | First, you'll need a [GitHub API access token](https://help.github.com/articles/creating-an-access-token-for-command-line-use) to make GraphQL API requests. This should be set as a `GITHUB_ACCESS_TOKEN` environment variable as configured in [config/secrets.yml](https://github.com/github/github-graphql-rails-example/blob/master/config/secrets.yml). 23 | 24 | ``` sh 25 | $ git clone https://github.com/github/github-graphql-rails-example 26 | $ cd github-graphql-rails-example/ 27 | $ bundle install 28 | $ GITHUB_ACCESS_TOKEN=abc123 bin/rails server 29 | ``` 30 | 31 | And visit [http://localhost:3000/](http://localhost:3000/). 32 | 33 | ## See Also 34 | 35 | * [Facebook's GraphQL homepage](http://graphql.org/) 36 | * [GitHub's GraphQL API Early Access program](https://developer.github.com/early-access/graphql) 37 | * [Ruby GraphQL Client library](https://github.com/github/graphql-client) 38 | * [Relay Modern example](https://github.com/github/github-graphql-relay-example) 39 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require_relative 'config/application' 2 | Rails.application.load_tasks 3 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | class QueryError < StandardError; end 3 | 4 | private 5 | # Public: Define request scoped helper method for making GraphQL queries. 6 | # 7 | # Examples 8 | # 9 | # data = query(ViewerQuery) 10 | # data.viewer.login #=> "josh" 11 | # 12 | # definition - A query or mutation operation GraphQL::Client::Definition. 13 | # Client.parse("query { version }") returns a definition. 14 | # variables - Optional set of variables to use during the operation. 15 | # (default: {}) 16 | # 17 | # Returns a structured query result or raises if the request failed. 18 | def query(definition, variables = {}) 19 | response = GitHub::Client.query(definition, variables: variables, context: client_context) 20 | 21 | if response.errors.any? 22 | raise QueryError.new(response.errors[:data].join(", ")) 23 | else 24 | response.data 25 | end 26 | end 27 | 28 | # Public: Useful helper method for tracking GraphQL context data to pass 29 | # along to the network adapter. 30 | def client_context 31 | # Use static access token from environment. However, here we have access 32 | # to the current request so we could configure the token to be retrieved 33 | # from a session cookie. 34 | { access_token: GitHub::Application.secrets.github_access_token } 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /app/controllers/repositories_controller.rb: -------------------------------------------------------------------------------- 1 | class RepositoriesController < ApplicationController 2 | # Define query for repository listing. 3 | # 4 | # All queries MUST be assigned to constants and therefore be statically 5 | # defined. Queries MUST NOT be generated at request time. 6 | IndexQuery = GitHub::Client.parse <<-'GRAPHQL' 7 | # All read requests are defined in a "query" operation 8 | query { 9 | # viewer is the currently authenticated User 10 | viewer { 11 | # "...FooConstant" is the fragment spread syntax to include the index 12 | # view's fragment. 13 | # 14 | # "Views::Repositories::Index::Viewer" means the fragment is defined 15 | # in app/views/repositories/index.html.erb and named Viewer. 16 | ...Views::Repositories::Index::Viewer 17 | } 18 | } 19 | GRAPHQL 20 | 21 | # GET /repositories 22 | def index 23 | # Use query helper defined in ApplicationController to execute the query. 24 | # `query` returns a GraphQL::Client::QueryResult instance with accessors 25 | # that map to the query structure. 26 | data = query IndexQuery 27 | 28 | # Render the app/views/repositories/index.html.erb template with our 29 | # current User. 30 | # 31 | # Using explicit render calls with locals is preferred to implicit render 32 | # with instance variables. 33 | render "repositories/index", locals: { 34 | viewer: data.viewer 35 | } 36 | end 37 | 38 | 39 | # Define query for "Show more repositories..." AJAX action. 40 | MoreQuery = GitHub::Client.parse <<-'GRAPHQL' 41 | # This query uses variables to accept an "after" param to load the next 42 | # 10 repositories. 43 | query($after: String!) { 44 | viewer { 45 | repositories(first: 10, after: $after) { 46 | # Instead of refetching all of the index page's data, we only need 47 | # the data for the repositories container partial. 48 | ...Views::Repositories::Repositories::RepositoryConnection 49 | } 50 | } 51 | } 52 | GRAPHQL 53 | 54 | # GET /repositories/more?after=CURSOR 55 | def more 56 | # Execute the MoreQuery passing along data from params to the query. 57 | data = query MoreQuery, after: params[:after] 58 | 59 | # Using an explicit render again, just render the repositories list partial 60 | # and return it to the client. 61 | render partial: "repositories/repositories", locals: { 62 | repositories: data.viewer.repositories 63 | } 64 | end 65 | 66 | 67 | # Define query for repository show page. 68 | ShowQuery = GitHub::Client.parse <<-'GRAPHQL' 69 | # Query is parameterized by a $id variable. 70 | query($id: ID!) { 71 | # Use global id Node lookup 72 | node(id: $id) { 73 | # Include fragment for app/views/repositories/show.html.erb 74 | ...Views::Repositories::Show::Repository 75 | } 76 | } 77 | GRAPHQL 78 | 79 | # GET /repositories/ID 80 | def show 81 | # Though we've only defined part of the ShowQuery in the controller, when 82 | # query(ShowQuery) is executed, we're sending along the query as well as 83 | # all of its fragment dependencies to the API server. 84 | # 85 | # Here's the raw query that's actually being sent. 86 | # 87 | # query RepositoriesController__ShowQuery($id: ID!) { 88 | # node(id: $id) { 89 | # ...Views__Repositories__Show__Repository 90 | # } 91 | # } 92 | # 93 | # fragment Views__Repositories__Show__Repository on Repository { 94 | # id 95 | # owner { 96 | # login 97 | # } 98 | # name 99 | # description 100 | # homepageUrl 101 | # ...Views__Repositories__Navigation__Repository 102 | # } 103 | # 104 | # fragment Views__Repositories__Navigation__Repository on Repository { 105 | # hasIssuesEnabled 106 | # } 107 | data = query ShowQuery, id: params[:id] 108 | 109 | if repository = data.node 110 | render "repositories/show", locals: { 111 | repository: repository 112 | } 113 | else 114 | # If node can't be found, 404. This may happen if the repository doesn't 115 | # exist, we don't have permission or we used a global ID that was the 116 | # wrong type. 117 | head :not_found 118 | end 119 | end 120 | 121 | StarMutation = GitHub::Client.parse <<-'GRAPHQL' 122 | mutation($id: ID!) { 123 | star(input: { starrableId: $id }) { 124 | starrable { 125 | ...Views::Repositories::Star::Repository 126 | } 127 | } 128 | } 129 | GRAPHQL 130 | 131 | def star 132 | data = query StarMutation, id: params[:id] 133 | 134 | if repository = data.star 135 | respond_to do |format| 136 | format.js { 137 | render partial: "repositories/star", locals: { repository: data.star.starrable } 138 | } 139 | 140 | format.html { 141 | redirect_to "/repositories" 142 | } 143 | end 144 | else 145 | head :not_found 146 | end 147 | end 148 | 149 | UnstarMutation = GitHub::Client.parse <<-'GRAPHQL' 150 | mutation($id: ID!) { 151 | unstar(input: { starrableId: $id }) { 152 | starrable { 153 | ...Views::Repositories::Star::Repository 154 | } 155 | } 156 | } 157 | GRAPHQL 158 | 159 | def unstar 160 | data = query UnstarMutation, id: params[:id] 161 | 162 | if repository = data.unstar 163 | respond_to do |format| 164 | format.js { 165 | render partial: "repositories/star", locals: { repository: data.unstar.starrable } 166 | } 167 | 168 | format.html { 169 | redirect_to "/repositories" 170 | } 171 | end 172 | else 173 | head :not_found 174 | end 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | GitHub 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | <%= yield %> 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /app/views/repositories/_icon.html.erb: -------------------------------------------------------------------------------- 1 | <%graphql 2 | fragment Repository on Repository { 3 | isFork 4 | isMirror 5 | isPrivate 6 | } 7 | %> 8 | <% repository = Views::Repositories::Icon::Repository.new(repository) %> 9 | 10 | <% if repository.is_fork? %> 11 | 12 | <% elsif repository.is_private? %> 13 | 14 | <% elsif repository.is_mirror? %> 15 | 16 | <% else %> 17 | 18 | <% end %> 19 | -------------------------------------------------------------------------------- /app/views/repositories/_list_item.html.erb: -------------------------------------------------------------------------------- 1 | <%graphql 2 | fragment Repository on Repository { 3 | id 4 | owner { 5 | login 6 | } 7 | name 8 | stargazers { 9 | totalCount 10 | } 11 | ...Views::Repositories::Icon::Repository 12 | ...Views::Repositories::Star::Repository 13 | } 14 | %> 15 | <% repository = Views::Repositories::ListItem::Repository.new(repository) %> 16 | 17 |
  • 18 | <%= render "repositories/star", repository: repository %> 19 | <%= render "repositories/icon", repository: repository %> 20 | 21 | 22 | <%= repository.owner.login %>/<%= repository.name %> 23 | 24 |
  • 25 | -------------------------------------------------------------------------------- /app/views/repositories/_navigation.html.erb: -------------------------------------------------------------------------------- 1 | <%graphql 2 | fragment Repository on Repository { 3 | hasIssuesEnabled 4 | issues { 5 | totalCount 6 | } 7 | pullRequests { 8 | totalCount 9 | } 10 | } 11 | %> 12 | <% repository = Views::Repositories::Navigation::Repository.new(repository) %> 13 | 14 | 51 | -------------------------------------------------------------------------------- /app/views/repositories/_repositories.html.erb: -------------------------------------------------------------------------------- 1 | <%graphql 2 | # Connection types are similar to ActiveRecord associations. Connections 3 | # always expose "edges" and "pageInfo". "edges" are the items in the 4 | # collection and "pageInfo" is pagination related metadata. 5 | fragment RepositoryConnection on RepositoryConnection { 6 | # edges returns an Array, however it doesn't return an Array of 7 | # Repositories. Instead its a RepositoryEdge that has a node that is a 8 | # Repository. Sorry, for all the indirection. 9 | edges { 10 | # node is the Repository itself 11 | node { 12 | ...Views::Repositories::ListItem::Repository 13 | } 14 | 15 | # Cursor is an opaque identifier that lets you fetch items before or 16 | # after this repository in this collection. To fetch the next 10 items, 17 | # we'll want the last item's cursor. 18 | cursor 19 | } 20 | 21 | # Pagination related metadata 22 | pageInfo { 23 | hasNextPage 24 | # hasPreviousPage can also be checked 25 | } 26 | } 27 | %> 28 | <% repositories = Views::Repositories::Repositories::RepositoryConnection.new(repositories) %> 29 | 30 | <%# 31 | We could enumerate this list by repositories.edges.each { |edge| edge.node } 32 | but each_node provides a handy enumerator to get each repository. 33 | %> 34 | <% repositories.each_node do |repository| %> 35 | <%= render "repositories/list_item", repository: repository %> 36 | <% end %> 37 | 38 | <% if repositories.page_info.has_next_page? %> 39 |
  • 40 | 41 | Show more repositories... 42 | 43 | 44 |
  • 45 | <% end %> 46 | -------------------------------------------------------------------------------- /app/views/repositories/_star.html.erb: -------------------------------------------------------------------------------- 1 | <%graphql 2 | fragment Repository on Repository { 3 | id 4 | viewerHasStarred 5 | stargazers { 6 | totalCount 7 | } 8 | } 9 | %> 10 | <% repository = Views::Repositories::Star::Repository.new(repository) %> 11 | 12 | 13 | <%= repository.stargazers.total_count %> 14 | <%= form_tag repository.viewer_has_starred? ? unstar_repository_path(repository.id) : star_repository_path(repository.id), method: :put, class: "star-form" do %> 15 | 18 | <% end %> 19 | 20 | -------------------------------------------------------------------------------- /app/views/repositories/index.html.erb: -------------------------------------------------------------------------------- 1 | <%# 2 | GraphQL fragments are defined in the templates themselves. 3 | 4 | All data being used directly in this template should also be reflected 5 | statically in the fragment. And vice versa, all fields in the query fragment 6 | should only be used directly in this template. You MUST keep static queries 7 | and runtime usage in sync. Defining queries in the same file makes 8 | this aspect easier to manage. 9 | %> 10 | <%graphql 11 | # Fragments are parts of queries, they can't be executed themselves. 12 | # This fragment is defined on the User type and is named "Viewer". You can 13 | # name the fragment whatever you'd like. Its the name that is exported as 14 | # a Ruby constant, so it must start with a capital letter. 15 | # 16 | # Its exported as "Views::Repositories::Index::Viewer". The module path to 17 | # the tempate then the name of the fragment. 18 | fragment Viewer on User { 19 | # Initially, fetch the first 10 repositories. We'll show a "load more" 20 | # button to demostrate GraphQL connection cursors. 21 | repositories(first: 10) { 22 | # The only data we're using directly in this template is the total number 23 | # of repositories. Also note that its camelcase here. 24 | totalCount 25 | 26 | # Include data dependencies of app/views/repositories/_repositories.html.erb 27 | # All renders in this template will map to a fragment spread to 28 | # statically define the view composition relationship. 29 | ...Views::Repositories::Repositories::RepositoryConnection 30 | } 31 | } 32 | %> 33 | <%# 34 | The first step of any GraphQL defined view is to cast arguments to the locally 35 | defined fragment result wrapper. In a statically typed langauge, you can think 36 | of it as passing a concrete type into a function that accepts an interface. 37 | 38 | def repositories_index(viewer: Views::Repositories::Index::Viewer) 39 | 40 | The wrapper serves two primary roles: 41 | 42 | 1. Provides Ruby friendly snake case accessors for accessing the underlying data 43 | 2. Only expose fields explicitly defined by this fragment. 44 | `viewer.repositories` is a full set of data, but only `total_count` is 45 | exposed to this template since thats all we explicitly defined. 46 | %> 47 | <% viewer = Views::Repositories::Index::Viewer.new(viewer) %> 48 | 49 |
    50 | 64 |
    65 | -------------------------------------------------------------------------------- /app/views/repositories/show.html.erb: -------------------------------------------------------------------------------- 1 | <%# 2 | GraphQL fragments are defined in the templates themselves. 3 | 4 | All data being used directly in this template should also be reflected 5 | statically in the fragment. And vice versa, all fields in the query fragment 6 | should only be used directly in this template. You MUST keep static queries 7 | and runtime usage in sync. Defining queries in the same file makes 8 | this aspect easier to manage. 9 | %> 10 | <%graphql 11 | # Fragments are parts of queries, they can't be executed themselves. 12 | # This fragment is defined on the Repository type and is coincidentally named 13 | # "Repository". You can name the fragment whatever you'd like. Its the name 14 | # that is exported as a Ruby constant, so it must start with a capital letter. 15 | # 16 | # Its exported as "Views::Repositories::Show::Repository". The module path to 17 | # the tempate then the name of the fragment. 18 | fragment Repository on Repository { 19 | # id is a GraphQL global id, not a database id 20 | # It is an opaque base64 identifier we can use to refetch the entity. 21 | id 22 | 23 | # owner is a User or Organization 24 | owner { 25 | login 26 | } 27 | 28 | # The repository's name 29 | name 30 | 31 | # Optional description text and homepage URL 32 | description 33 | homepageUrl 34 | 35 | # Include repositories/_navigation.html.erb data dependencies 36 | ...Views::Repositories::Navigation::Repository 37 | 38 | ...Views::Repositories::Star::Repository 39 | } 40 | %> 41 | <%# 42 | The first step of any GraphQL defined view is to cast arguments to the locally 43 | defined fragment result wrapper. In a statically typed langauge, you can think 44 | of it as passing a concrete type into a function that accepts an interface. 45 | 46 | def repositories_show(repository: Views::Repositories::Show::Repository) 47 | 48 | The wrapper serves two primary roles: 49 | 50 | 1. Provides Ruby friendly snake case accessors for accessing the underlying data 51 | 2. Only expose fields explicitly defined by this fragment. 52 | `repository` is a full set of data, but fields included by the 53 | ...Views::Repositories::Navigation::Repository spread are hidden. 54 | %> 55 | <% repository = Views::Repositories::Show::Repository.new(repository) %> 56 | 57 |
    58 | <%= render "repositories/navigation", repository: repository %> 59 | 60 |

    61 | <%= repository.owner.login %> 62 | / 63 | <%= repository.name %> 64 |

    65 | 66 |
    67 | <%= render "repositories/star", repository: repository %> 68 |
    69 |
    70 | 71 |
    72 | 73 |
    74 |

    <%= repository.name %>

    75 | 76 | <% if repository.description.present? %> 77 |

    <%= repository.description %>

    78 | <% end %> 79 | 80 |

    81 | <%# NOTE: homepage_url is snake case here %> 82 | <% if repository.homepage_url.present? %> 83 | Homepage 84 | <% end %> 85 | 86 | View on GitHub 87 |

    88 |
    89 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../config/application', __dir__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require_relative 'config/environment' 2 | run Rails.application 3 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails" 4 | require "action_controller/railtie" 5 | require "action_view/railtie" 6 | require "graphql/client/railtie" 7 | require "graphql/client/http" 8 | 9 | Bundler.require(*Rails.groups) 10 | 11 | module GitHub 12 | class Application < Rails::Application 13 | end 14 | 15 | HTTPAdapter = GraphQL::Client::HTTP.new("https://api.github.com/graphql") do 16 | def headers(context) 17 | unless token = context[:access_token] || Application.secrets.github_access_token 18 | # $ GITHUB_ACCESS_TOKEN=abc123 bin/rails server 19 | # https://help.github.com/articles/creating-an-access-token-for-command-line-use 20 | fail "Missing GitHub access token" 21 | end 22 | 23 | { 24 | "Authorization" => "Bearer #{token}" 25 | } 26 | end 27 | end 28 | 29 | Client = GraphQL::Client.new( 30 | schema: Application.root.join("db/schema.json").to_s, 31 | execute: HTTPAdapter 32 | ) 33 | Application.config.graphql.client = Client 34 | end 35 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 2 | require 'bundler/setup' 3 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | require_relative 'application' 2 | Rails.application.initialize! 3 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | config.cache_classes = false 3 | config.eager_load = false 4 | config.consider_all_requests_local = true 5 | 6 | config.action_controller.perform_caching = false 7 | config.cache_store = :null_store 8 | 9 | config.active_support.deprecation = :log 10 | end 11 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | resources :repositories do 3 | get "more", on: :collection 4 | put "star", on: :member 5 | put "unstar", on: :member 6 | end 7 | get "/", to: redirect("/repositories") 8 | end 9 | -------------------------------------------------------------------------------- /config/secrets.yml: -------------------------------------------------------------------------------- 1 | development: 2 | secret_key_base: f81cf4b9e7cdc87601e48bf02d34b14b5b2ef64b2c4a3d3d8fa7d15595102fee7b5d40812da93da3ef1412bb01a61f6a2aec94bf2cd95f2bf990f0fb6c115a9a 3 | github_access_token: <%= ENV["GITHUB_ACCESS_TOKEN"] %> 4 | -------------------------------------------------------------------------------- /lib/tasks/schema.rake: -------------------------------------------------------------------------------- 1 | namespace :schema do 2 | # The public schema will evolve over time, so you'll want to periodically 3 | # refetch the latest and check in the changes. 4 | # 5 | # An offline copy of the schema allows queries to be typed checked statically 6 | # before even sending a request. 7 | desc "Update GitHub GraphQL schema" 8 | task :update do 9 | GraphQL::Client.dump_schema(GitHub::HTTPAdapter, "db/schema.json") 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /public/application.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 20px; 3 | padding-bottom: 20px; 4 | } 5 | 6 | .inline-block { 7 | display: inline-block; 8 | } 9 | 10 | .header { 11 | margin-top: 0; 12 | margin-bottom: 0; 13 | } 14 | 15 | .header h3 { 16 | margin-top: 0; 17 | margin-bottom: 0; 18 | line-height: 40px; 19 | } 20 | 21 | .octicon { 22 | vertical-align: text-top; 23 | } 24 | 25 | .repositories { 26 | width: 350px; 27 | } 28 | 29 | .star-badge { 30 | float: right; 31 | color: #777; 32 | } 33 | 34 | .star-badge-header { 35 | margin-left: 10px; 36 | vertical-align: text-bottom; 37 | } 38 | 39 | .star-badge .octicon { 40 | color: #777; 41 | } 42 | 43 | .star-badge .highlight { 44 | color: #e36209; 45 | } 46 | 47 | .star-form { 48 | display: inline; 49 | } 50 | 51 | .star-form button { 52 | background: transparent; 53 | border: none; 54 | margin: 0; 55 | padding: 0; 56 | } 57 | 58 | .nav-pills .badge { 59 | background-color: #ccc; 60 | } 61 | 62 | .show-more .spinner { 63 | display: none; 64 | } 65 | 66 | .show-more.loading .spinner { 67 | display: inline-block; 68 | float: right; 69 | animation-name: spin; 70 | animation-duration: 2000ms; 71 | animation-iteration-count: infinite; 72 | animation-timing-function: linear; 73 | padding-left: 4px; 74 | } 75 | 76 | @keyframes spin { 77 | from { 78 | transform: rotate(0deg); 79 | } to { 80 | transform: rotate(360deg); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /public/application.js: -------------------------------------------------------------------------------- 1 | function loadMoreRepositories(link) { 2 | var container = link.parentElement; 3 | container.classList.add('loading'); 4 | 5 | fetch(link.href).then(function(response) { 6 | response.text().then(function(text) { 7 | container.insertAdjacentHTML('afterend', text); 8 | container.remove(); 9 | }) 10 | }) 11 | } 12 | 13 | function toggleStar(el) { 14 | fetch(el.action, { method: 'PUT', headers: { 'X-Requested-With': 'XMLHttpRequest' } }).then(function(response) { 15 | response.text().then(function(text) { 16 | // Parse text to get an actual element 17 | var div = document.createElement('div') 18 | div.innerHTML = text 19 | 20 | // Find the star container 21 | var container = el.closest('.star-badge') 22 | container.replaceWith(div.firstElementChild) 23 | }) 24 | }) 25 | } 26 | 27 | document.addEventListener('submit', function(e) { 28 | var form = e.target 29 | toggleStar(form) 30 | e.preventDefault() 31 | }) 32 | 33 | // Basic event delegation 34 | document.addEventListener('click', function(e) { 35 | var loadMoreLink = e.target.closest('.js-load-more') 36 | if (loadMoreLink) { 37 | loadMoreRepositories(loadMoreLink) 38 | e.preventDefault() 39 | } 40 | }) 41 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/github-graphql-rails-example/8d274407aba1952605649b1152cd43aa3a78e67a/public/favicon.ico -------------------------------------------------------------------------------- /public/octicons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/github-graphql-rails-example/8d274407aba1952605649b1152cd43aa3a78e67a/public/octicons.eot -------------------------------------------------------------------------------- /public/octicons.min.css: -------------------------------------------------------------------------------- 1 | @font-face{font-family:Octicons;src:url(octicons.eot?ef21c39f0ca9b1b5116e5eb7ac5eabe6);src:url(octicons.eot?#iefix) format("embedded-opentype"),url(octicons.woff2?ef21c39f0ca9b1b5116e5eb7ac5eabe6) format("woff2"),url(octicons.woff?ef21c39f0ca9b1b5116e5eb7ac5eabe6) format("woff"),url(octicons.ttf?ef21c39f0ca9b1b5116e5eb7ac5eabe6) format("truetype"),url(octicons.svg?ef21c39f0ca9b1b5116e5eb7ac5eabe6#octicons) format("svg");font-weight:400;font-style:normal}.mega-octicon,.octicon{font:normal normal normal 16px/1 Octicons;display:inline-block;text-decoration:none;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-user-select:none;-ms-user-select:none;user-select:none;speak:none}.mega-octicon{font-size:32px}.octicon-alert:before{content:"\f02d"}.octicon-arrow-down:before{content:"\f03f"}.octicon-arrow-left:before{content:"\f040"}.octicon-arrow-right:before{content:"\f03e"}.octicon-arrow-small-down:before{content:"\f0a0"}.octicon-arrow-small-left:before{content:"\f0a1"}.octicon-arrow-small-right:before{content:"\f071"}.octicon-arrow-small-up:before{content:"\f09f"}.octicon-arrow-up:before{content:"\f03d"}.octicon-beaker:before{content:"\f0dd"}.octicon-bell:before{content:"\f0de"}.octicon-bold:before{content:"\f0e2"}.octicon-book:before{content:"\f007"}.octicon-bookmark:before{content:"\f07b"}.octicon-briefcase:before{content:"\f0d3"}.octicon-broadcast:before{content:"\f048"}.octicon-browser:before{content:"\f0c5"}.octicon-bug:before{content:"\f091"}.octicon-calendar:before{content:"\f068"}.octicon-check:before{content:"\f03a"}.octicon-checklist:before{content:"\f076"}.octicon-chevron-down:before{content:"\f0a3"}.octicon-chevron-left:before{content:"\f0a4"}.octicon-chevron-right:before{content:"\f078"}.octicon-chevron-up:before{content:"\f0a2"}.octicon-circle-slash:before{content:"\f084"}.octicon-circuit-board:before{content:"\f0d6"}.octicon-clippy:before{content:"\f035"}.octicon-clock:before{content:"\f046"}.octicon-cloud-download:before{content:"\f00b"}.octicon-cloud-upload:before{content:"\f00c"}.octicon-code:before{content:"\f05f"}.octicon-comment-discussion:before{content:"\f04f"}.octicon-comment:before{content:"\f02b"}.octicon-credit-card:before{content:"\f045"}.octicon-dash:before{content:"\f0ca"}.octicon-dashboard:before{content:"\f07d"}.octicon-database:before{content:"\f096"}.octicon-desktop-download:before{content:"\f0dc"}.octicon-device-camera-video:before{content:"\f057"}.octicon-device-camera:before{content:"\f056"}.octicon-device-desktop:before{content:"\f27c"}.octicon-device-mobile:before{content:"\f038"}.octicon-diff-added:before{content:"\f06b"}.octicon-diff-ignored:before{content:"\f099"}.octicon-diff-modified:before{content:"\f06d"}.octicon-diff-removed:before{content:"\f06c"}.octicon-diff-renamed:before{content:"\f06e"}.octicon-diff:before{content:"\f04d"}.octicon-ellipses:before{content:"\f101"}.octicon-ellipsis:before{content:"\f09a"}.octicon-eye:before{content:"\f04e"}.octicon-file-binary:before{content:"\f094"}.octicon-file-code:before{content:"\f010"}.octicon-file-directory:before{content:"\f016"}.octicon-file-media:before{content:"\f012"}.octicon-file-pdf:before{content:"\f014"}.octicon-file-submodule:before{content:"\f017"}.octicon-file-symlink-directory:before{content:"\f0b1"}.octicon-file-symlink-file:before{content:"\f0b0"}.octicon-file-text:before{content:"\f011"}.octicon-file-zip:before{content:"\f013"}.octicon-file:before{content:"\f102"}.octicon-flame:before{content:"\f0d2"}.octicon-fold:before{content:"\f0cc"}.octicon-gear:before{content:"\f02f"}.octicon-gift:before{content:"\f042"}.octicon-gist-secret:before{content:"\f08c"}.octicon-gist:before{content:"\f00e"}.octicon-git-branch:before{content:"\f020"}.octicon-git-commit:before{content:"\f01f"}.octicon-git-compare:before{content:"\f0ac"}.octicon-git-merge:before{content:"\f023"}.octicon-git-pull-request:before{content:"\f009"}.octicon-globe:before{content:"\f0b6"}.octicon-grabber:before{content:"\f103"}.octicon-graph:before{content:"\f043"}.octicon-heart:before{content:"\2665"}.octicon-history:before{content:"\f07e"}.octicon-home:before{content:"\f08d"}.octicon-horizontal-rule:before{content:"\f070"}.octicon-hubot:before{content:"\f09d"}.octicon-inbox:before{content:"\f0cf"}.octicon-info:before{content:"\f059"}.octicon-issue-closed:before{content:"\f028"}.octicon-issue-opened:before{content:"\f026"}.octicon-issue-reopened:before{content:"\f027"}.octicon-italic:before{content:"\f0e4"}.octicon-jersey:before{content:"\f019"}.octicon-key:before{content:"\f049"}.octicon-keyboard:before{content:"\f00d"}.octicon-law:before{content:"\f0d8"}.octicon-light-bulb:before{content:"\f000"}.octicon-link-external:before{content:"\f07f"}.octicon-link:before{content:"\f05c"}.octicon-list-ordered:before{content:"\f062"}.octicon-list-unordered:before{content:"\f061"}.octicon-location:before{content:"\f060"}.octicon-lock:before{content:"\f06a"}.octicon-logo-gist:before{content:"\f0ad"}.octicon-logo-github:before{content:"\f092"}.octicon-mail-read:before{content:"\f03c"}.octicon-mail-reply:before{content:"\f051"}.octicon-mail:before{content:"\f03b"}.octicon-mark-github:before{content:"\f00a"}.octicon-markdown:before{content:"\f0c9"}.octicon-megaphone:before{content:"\f077"}.octicon-mention:before{content:"\f0be"}.octicon-milestone:before{content:"\f075"}.octicon-mirror:before{content:"\f024"}.octicon-mortar-board:before{content:"\f0d7"}.octicon-mute:before{content:"\f080"}.octicon-no-newline:before{content:"\f09c"}.octicon-octoface:before{content:"\f008"}.octicon-organization:before{content:"\f037"}.octicon-package:before{content:"\f0c4"}.octicon-paintcan:before{content:"\f0d1"}.octicon-pencil:before{content:"\f058"}.octicon-person:before{content:"\f018"}.octicon-pin:before{content:"\f041"}.octicon-plug:before{content:"\f0d4"}.octicon-plus-small:before{content:"\f104"}.octicon-plus:before{content:"\f05d"}.octicon-primitive-dot:before{content:"\f052"}.octicon-primitive-square:before{content:"\f053"}.octicon-pulse:before{content:"\f085"}.octicon-question:before{content:"\f02c"}.octicon-quote:before{content:"\f063"}.octicon-radio-tower:before{content:"\f030"}.octicon-reply:before{content:"\f105"}.octicon-repo-clone:before{content:"\f04c"}.octicon-repo-force-push:before{content:"\f04a"}.octicon-repo-forked:before{content:"\f002"}.octicon-repo-pull:before{content:"\f006"}.octicon-repo-push:before{content:"\f005"}.octicon-repo:before{content:"\f001"}.octicon-rocket:before{content:"\f033"}.octicon-rss:before{content:"\f034"}.octicon-ruby:before{content:"\f047"}.octicon-search:before{content:"\f02e"}.octicon-server:before{content:"\f097"}.octicon-settings:before{content:"\f07c"}.octicon-shield:before{content:"\f0e1"}.octicon-sign-in:before{content:"\f036"}.octicon-sign-out:before{content:"\f032"}.octicon-smiley:before{content:"\f0e7"}.octicon-squirrel:before{content:"\f0b2"}.octicon-star:before{content:"\f02a"}.octicon-stop:before{content:"\f08f"}.octicon-sync:before{content:"\f087"}.octicon-tag:before{content:"\f015"}.octicon-tasklist:before{content:"\f0e5"}.octicon-telescope:before{content:"\f088"}.octicon-terminal:before{content:"\f0c8"}.octicon-text-size:before{content:"\f0e3"}.octicon-three-bars:before{content:"\f05e"}.octicon-thumbsdown:before{content:"\f0db"}.octicon-thumbsup:before{content:"\f0da"}.octicon-tools:before{content:"\f031"}.octicon-trashcan:before{content:"\f0d0"}.octicon-triangle-down:before{content:"\f05b"}.octicon-triangle-left:before{content:"\f044"}.octicon-triangle-right:before{content:"\f05a"}.octicon-triangle-up:before{content:"\f0aa"}.octicon-unfold:before{content:"\f039"}.octicon-unmute:before{content:"\f0ba"}.octicon-unverified:before{content:"\f0e8"}.octicon-verified:before{content:"\f0e6"}.octicon-versions:before{content:"\f064"}.octicon-watch:before{content:"\f0e0"}.octicon-x:before{content:"\f081"}.octicon-zap:before{content:"\26a1"} -------------------------------------------------------------------------------- /public/octicons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Created by FontForge 20150913 at Mon Jul 11 12:02:11 2016 6 | By Aaron Shekey 7 | 8 | 9 | 10 | 23 | 25 | 27 | 29 | 31 | 33 | 35 | 38 | 40 | 43 | 45 | 47 | 50 | 53 | 56 | 59 | 61 | 63 | 66 | 68 | 70 | 72 | 74 | 76 | 79 | 81 | 83 | 85 | 87 | 90 | 92 | 95 | 98 | 100 | 102 | 105 | 107 | 109 | 111 | 114 | 116 | 118 | 121 | 124 | 126 | 128 | 131 | 133 | 136 | 138 | 140 | 142 | 144 | 146 | 148 | 150 | 152 | 154 | 156 | 158 | 160 | 163 | 165 | 167 | 169 | 171 | 173 | 176 | 178 | 180 | 183 | 185 | 187 | 189 | 191 | 193 | 195 | 197 | 199 | 201 | 204 | 206 | 208 | 211 | 213 | 215 | 217 | 219 | 222 | 225 | 227 | 229 | 232 | 234 | 236 | 238 | 240 | 242 | 244 | 246 | 248 | 251 | 254 | 256 | 258 | 260 | 263 | 265 | 267 | 269 | 271 | 273 | 275 | 277 | 279 | 281 | 283 | 285 | 288 | 292 | 294 | 297 | 300 | 302 | 304 | 306 | 309 | 311 | 313 | 315 | 317 | 319 | 321 | 323 | 326 | 329 | 331 | 333 | 336 | 342 | 344 | 347 | 349 | 351 | 353 | 355 | 357 | 359 | 361 | 363 | 366 | 369 | 371 | 373 | 376 | 379 | 382 | 385 | 388 | 390 | 392 | 394 | 396 | 398 | 400 | 402 | 404 | 406 | 409 | 412 | 416 | 418 | 420 | 422 | 424 | 426 | 428 | 429 | 430 | -------------------------------------------------------------------------------- /public/octicons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/github-graphql-rails-example/8d274407aba1952605649b1152cd43aa3a78e67a/public/octicons.ttf -------------------------------------------------------------------------------- /public/octicons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/github-graphql-rails-example/8d274407aba1952605649b1152cd43aa3a78e67a/public/octicons.woff -------------------------------------------------------------------------------- /public/octicons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/github-graphql-rails-example/8d274407aba1952605649b1152cd43aa3a78e67a/public/octicons.woff2 --------------------------------------------------------------------------------