This commit is contained in:
Benjamin P Toews 2016-10-18 09:27:31 -06:00
Коммит 80c7187622
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: E9C423BE17EFEE70
16 изменённых файлов: 758 добавлений и 0 удалений

1
.gitignore поставляемый Normal file
Просмотреть файл

@ -0,0 +1 @@
Gemfile.lock

74
CODE_OF_CONDUCT.md Normal file
Просмотреть файл

@ -0,0 +1,74 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
nationality, personal appearance, race, religion, or sexual identity and
orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at [INSERT EMAIL ADDRESS]. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/

33
CONTRIBUTING.md Normal file
Просмотреть файл

@ -0,0 +1,33 @@
## Contributing
[fork]: https://github.com/github/graphql-relay-walker/fork
[pr]: https://github.com/github/graphql-relay-walker/compare
[style]: https://github.com/styleguide/ruby
[code-of-conduct]: CODE_OF_CONDUCT.md
Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great.
Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms.
## Submitting a pull request
0. [Fork][fork] and clone the repository
0. Configure and install the dependencies: `script/bootstrap`
0. Make sure the tests pass on your machine: `rake`
0. Create a new branch: `git checkout -b my-branch-name`
0. Make your change, add tests, and make sure the tests still pass
0. Push to your fork and [submit a pull request][pr]
0. Pat your self on the back and wait for your pull request to be reviewed and merged.
Here are a few things you can do that will increase the likelihood of your pull request being accepted:
- Follow the [style guide][style].
- Write tests.
- Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests.
- Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
## Resources
- [Contributing to Open Source on GitHub](https://guides.github.com/activities/contributing-to-open-source/)
- [Using Pull Requests](https://help.github.com/articles/using-pull-requests/)
- [GitHub Help](https://help.github.com)

3
Gemfile Normal file
Просмотреть файл

@ -0,0 +1,3 @@
source "https://rubygems.org"
gemspec

21
LICENSE.md Normal file
Просмотреть файл

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2016 GitHub, inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

62
README.md Normal file
Просмотреть файл

@ -0,0 +1,62 @@
# GraphQL Relay Walker
![](https://cloud.githubusercontent.com/assets/1144197/19287829/9ce479b8-8fc0-11e6-975c-8d686e3e0783.jpg)
`GraphQL::Relay::Walker` is a Ruby library that generates queries for walking a [Relay](https://facebook.github.io/relay/docs/graphql-relay-specification.html#content) [GraphQL](http://graphql.org/) schema. Given a `GraphQL::Schema`, you can walk from a given starting point, exercising any defined connections. This can be useful for various kinds of automated testing. Check out the [GitHub Walker](./examples/github_walker) example to see it in action.
## Setup
You can install this library as a Ruby Gem:
```bash
gem install graphql-relay-walker
```
## Usage
```ruby
require "graphql/relay/walker"
id = "<some starting node id>"
query = GraphQL::Relay::Walker.query_string(client.schema)
GraphQL::Relay::Walker.walk(from_id: id) do |frame|
# The global relay id of the object we're looking at.
frame.gid
# The frame where we discovered this object's GID.
frame.parent
# Execute the query and store the result in the frame.
# The implementation here is up to you, but you should set
# `frame.result` to the Hash result of executing the query.
frame.result = execute(query, variables: {"id" => frame.gid})
# Parse the results, adding any newly discovered IDs to our queue.
frame.enqueue_found_gids
end
```
## Usage with `GraphQL::Client`
Requiring `graphql/relay/walker/client_ext` will add a `GraphQL::Client#walk` method. This simplifies things by allowing the client to build and execute the query for you.
Here's how you would walk the [SWAPI GraphQL Wrapper](https://github.com/graphql/swapi-graphql), starting from Luke Skywalker, assuming a client configuration like [this](https://github.com/github/graphql-client/blob/2761908e735e6d34bf6056d26e97de54d384aa14/README.md#configuration).
```ruby
require "graphql/relay/walker/client_ext"
skywalker_gid = "cGVvcGxlOjE="
SWAPI::Client.walk(from_id: skywalker_gid) do |frame|
# The global relay id of the object we're looking at.
frame.gid
# The frame where we discovered this object's GID.
frame.parent
# The result of executing the query for this frame's GID.
frame.result
end
```

Просмотреть файл

@ -0,0 +1,4 @@
source "https://rubygems.org"
gem "graphql-client", "~> 0.2"
gem "graphql-relay-walker", path: "../.."

Просмотреть файл

@ -0,0 +1,43 @@
# GitHub Walker
GraphQL Relay Walker example: *walk GitHub's GraphQL API*
## Usage
You can use this code as a library:
```ruby
require "github_walker"
# Walk the graph, printing each object we come across.
GitHubWalker.walk do |frame|
msg = "Found object `#{frame.gid}`"
msg += " via object `#{frame.parent.gid}`" += if frame.parent
puts message
end
```
or you can run the provided script:
```bash
# install dependencies
bundle install
# Walk the graph.
script/walk
```
either way, you need to have a GitHub API token in your environment:
```bash
export GITHUB_ACCESS_TOKEN=<my access token>
```
You can make one of these tokens [here](https://github.com/settings/tokens). Your token will need these scopes:
- `read:gpg_key`
- `read:org`
- `read:public_key`
- `read:repo_hook`
- `repo`
- `user`

Просмотреть файл

@ -0,0 +1,32 @@
require "graphql/client"
require "graphql/client/http"
require "graphql/relay/walker/client_ext"
module GitHubWalker
URL = "https://api.github.com/graphql"
TOKEN = ENV["GITHUB_ACCESS_TOKEN"]
HTTP = GraphQL::Client::HTTP.new(URL) do
def headers(context)
{"Authorization" => "Bearer #{TOKEN}"}
end
end
Schema = GraphQL::Client.load_schema(HTTP)
Client = GraphQL::Client.new(schema: Schema, execute: HTTP)
ViewerIdQuery = Client.parse <<-'GRAPHQL'
query {
viewer {
id
}
}
GRAPHQL
def self.walk(&blk)
gid = Client.query(ViewerIdQuery).data.viewer.id
Client.walk(from_id: gid, &blk)
end
end

Просмотреть файл

@ -0,0 +1,3 @@
#!/bin/sh
bundle exec ruby script/walk.rb

Просмотреть файл

@ -0,0 +1,53 @@
# NAME
# walk — Walk GitHub's GraphQL API, starting with your user.
#
# SYNOPSIS
# script/walk
#
# DESCRIPTION
# Starting from your user, walk the GitHub GraphQL API, following all
# accessible connections. A GitHub API token must be provided in the
# `GITHUB_ACCESS_TOKEN` environment variable.
#
require "json"
unless ENV["GITHUB_ACCESS_TOKEN"]
lines = File.read(__FILE__).split("\n")
help = []
help << lines.shift[2..-1] while lines.first.start_with?("#")
puts help.join("\n")
exit(1)
end
puts "Loading GitHub Schema"
$:.unshift File.expand_path('../../lib', __FILE__)
require "github_walker"
stats = {
:depth => Hash.new { |h,k| h[k] = 0 },
:total => 0
}
def frame_depth(frame, depth=1)
if frame.parent
frame_depth(frame.parent, depth + 1)
else
depth
end
end
puts "Starting walking"
GitHubWalker.walk do |frame|
stats[:total] += 1
stats[:depth][frame_depth(frame)] += 1
if stats[:total] % 100 == 0
puts JSON.pretty_generate(stats)
puts
end
if frame.context[:response].errors.any?
puts frame.context[:response].errors.values.flatten.uniq.join("\n")
end
end

Просмотреть файл

@ -0,0 +1,13 @@
Gem::Specification.new do |s|
s.name = "graphql-relay-walker"
s.version = "0.0.1"
s.licenses = ["MIT"]
s.summary = "Traverse a Relay GraphQL graph"
s.authors = ["Ben Toews"]
s.email = "opensource+graphql-relay-walker@github.com"
s.files = %w(LICENSE.md README.md CONTRIBUTING.md CODE_OF_CONDUCT.md graphql-relay-walker.gemspec)
s.files += Dir.glob("lib/**/*.rb")
s.homepage = "https://github.com/github/graphql-relay-walker"
s.add_dependency "graphql", "~> 0.19"
end

Просмотреть файл

@ -0,0 +1,32 @@
module GraphQL::Relay
module Walker
# Build a query that starts with a relay node and grabs the IDs of all its
# connections and node fields.
#
# schema - The GraphQL::Schema to build a query for.
#
# Returns a String query.
def self.query_string(schema)
QueryBuilder.new(schema).query_string
end
# Start traversing a graph, starting from the given relay node ID.
#
# from_id: - The `ID!` id to start walking from.
# &blk - A block to call with each Walker::Queue::Frame that is visited.
# This block is responsible for executing a query for the frame's
# GID, storing the results in the frame, and enqueuing further
# node IDs to visit.
#
# Returns nothing.
def self.walk(from_id:, &blk)
queue = Queue.new
queue.add_gid(from_id)
queue.each_frame(&blk)
end
end
end
require "graphql/relay/walker/queue"
require "graphql/relay/walker/query_builder"

Просмотреть файл

@ -0,0 +1,29 @@
module GraphQL::Relay::Walker
module ClientExt
# Walk this client's graph from the given GID.
#
# from_id: - The String GID to start walking from.
# &blk - A block to call with each Walker::Queue::Frame that is visited.
#
# Returns nothing.
def walk(from_id:)
query_string = GraphQL::Relay::Walker.query_string(schema)
walker_query = parse(query_string)
GraphQL::Relay::Walker.walk(from_id: from_id) do |frame|
response = query(walker_query, variables: {"id" => frame.gid})
frame.context[:response] = response
frame.result = response.data.to_h
frame.enqueue_found_gids
yield(frame) if block_given?
end
end
end
end
begin
require "graphql/relay/walker"
require "graphql/client"
GraphQL::Client.send(:include, GraphQL::Relay::Walker::ClientExt)
rescue LoadError
end

Просмотреть файл

@ -0,0 +1,247 @@
module GraphQL::Relay::Walker
class QueryBuilder
DEFAULT_ARGUMENTS = {"first" => 5}
BASE_QUERY = "query($id: ID!) { node(id: $id) {} }"
attr_reader :schema, :connection_arguments, :ast
# Initialize a new QueryBuilder.
#
# schema - The GraphQL::Schema to build a query for.
# connection_arguments: - A Hash or arguments to use for connection fields
# (optional).
#
# Returns nothing.
def initialize(schema, connection_arguments: DEFAULT_ARGUMENTS)
@schema = schema
@connection_arguments = connection_arguments
@ast = build_query
end
# Get the built query.
#
# Returns a String.
def query_string
ast.to_query_string
end
private
# Build a query for our relay schema that selects an inline fragment for
# every node type. For every inline fragment, we select the ID of every node
# field and connection.
#
# Returns a GraphQL::Language::Nodes::Document instance.
def build_query
GraphQL.parse(BASE_QUERY).tap do |d_ast|
selections = d_ast.definitions.first.selections.first.selections
node_types.each do |type|
selections << inline_fragment_ast(type)
end
selections.compact!
end
end
# Private: Make a AST of the given type.
#
# klass - The GraphQL::Language::Nodes::AbstractNode subclass
# to create.
# needs_selections: - Boolean. Will this AST be invalid if it doesn't have
# any selections?
#
# Returns a GraphQL::Language::Nodes::AbstractNode subclass instance or nil
# if the created AST was invalid for having no selections.
def make(klass, needs_selections: true)
k_ast = klass.new
yield(k_ast) if block_given?
k_ast.selections.compact!
if k_ast.selections.empty? && needs_selections
nil
else
k_ast
end
end
# Make an inline fragment AST.
#
# type - The GraphQL::ObjectType instance to make the fragment
# for.
# with_children: - Boolean. Whether to select all children of this inline
# fragment, or just it's ID.
#
# Returns a GraphQL::Language::Nodes::InlineFragment instance or nil if the
# created AST was invalid for having no selections.
def inline_fragment_ast(type, with_children: true)
make(GraphQL::Language::Nodes::InlineFragment) do |if_ast|
if_ast.type = type.name
if with_children
type.all_fields.each do |field|
if node_field?(field)
if_ast.selections << node_field_ast(field)
elsif connection_field?(field)
if_ast.selections << connection_field_ast(field)
end
end
elsif id = type.get_field("id")
if_ast.selections << field_ast(id)
end
end
end
# Make a field AST.
#
# field - The GraphQL::Field instance to make the fragment for.
# arguments - A Hash of arguments to include in the field.
# &blk - A block to call with the AST and field type before returning
# the AST.
#
# Returns a GraphQL::Language::Nodes::Field instance or nil if the created
# AST was invalid for having no selections or missing required arguments.
def field_ast(field, arguments={}, &blk)
type = field.type.unwrap
# Bail unless we have the required arguments.
return unless field.arguments.reject do |_, arg|
arg.type.valid_input?(nil)
end.all? do |name, _|
arguments.key?(name)
end
make(GraphQL::Language::Nodes::Field, needs_selections: !type.kind.scalar?) do |f_ast|
f_ast.name = field.name
f_ast.alias = random_alias unless field.name == "id"
f_ast.arguments = arguments.map do |name, value|
GraphQL::Language::Nodes::Argument.new(name: name, value: value)
end
blk.call(f_ast, type) if blk
end
end
# Make a field AST for a node field.
#
# field - The GraphQL::Field instance to make the fragment for.
#
# Returns a GraphQL::Language::Nodes::Field instance.
def node_field_ast(field)
field_ast(field) do |f_ast, type|
selections = f_ast.selections
if type.kind.object?
selections << field_ast(type.get_field("id"))
else
possible_node_types(type).each do |if_type|
selections << inline_fragment_ast(if_type, with_children: false)
end
end
end
end
# Make a field AST for an edges field.
#
# field - The GraphQL::Field instance to make the fragment for.
#
# Returns a GraphQL::Language::Nodes::Field instance.
def edges_field_ast(field)
field_ast(field) do |f_ast, type|
f_ast.selections << node_field_ast(type.get_field("node"))
end
end
# Make a field AST for a connection field.
#
# field - The GraphQL::Field instance to make the fragment for.
#
# Returns a GraphQL::Language::Nodes::Field instance or nil if the created
# AST was invalid for missing required arguments.
def connection_field_ast(field)
field_ast(field, connection_arguments) do |f_ast, type|
f_ast.selections << edges_field_ast(type.get_field("edges"))
end
end
# Is this field for a relay node?
#
# field - A GraphQL::Field instance.
#
# Returns true if the field's type includes the `Node` interface or is a
# union or interface with a possible type that includes the `Node` interface
# Returns false otherwise.
def node_field?(field)
type = field.type.unwrap
kind = type.kind
if kind.object?
node_types.include?(type)
elsif kind.interface? || kind.union?
possible_node_types(type).any?
end
end
# Is this field for a relay connection?
#
# field - A GraphQL::Field instance.
#
# Returns true if this field's type has a `edges` field whose type has a
# `node` field that is a relay node. Returns false otherwise.
def connection_field?(field)
type = field.type.unwrap
if edges_field = type.get_field("edges")
edges = edges_field.type.unwrap
if node_field = edges.get_field("node")
return node_field?(node_field)
end
end
false
end
# Get the possible types of a union or interface.
#
# type - A GraphQL::UnionType or GraphQL::InterfaceType instance.
#
# Returns an Array of GraphQL::ObjectType instances.
def possible_types(type)
if type.kind.interface?
schema.possible_types(type)
elsif type.kind.union?
type.possible_types
end
end
# Get the possible types of a union or interface that are relay nodes.
#
# type - A GraphQL::UnionType or GraphQL::InterfaceType instance.
#
# Returns an Array of GraphQL::ObjectType instances.
def possible_node_types(type)
possible_types(type) & node_types
end
# Get the types that implement the `Node` interface.
#
# Returns an Array of GraphQL::ObjectType instances.
def node_types
schema.possible_types(node_interface)
end
# Get the `Node` interface.
#
# Returns a GraphQL::InterfaceType instance.
def node_interface
schema.types["Node"]
end
# Make a random alias for a field.
#
# Returns a six character random String.
def random_alias
6.times.map { (SecureRandom.random_number(26) + 97).chr }.join
end
end
end

Просмотреть файл

@ -0,0 +1,108 @@
module GraphQL::Relay::Walker
class Queue
attr_accessor :max_size, :random_idx
# Initialize a new Queue.
#
# max_size: - The maximum size the queue can grow to. This helps when
# walking a large graph by forcing us to walk deeper.
# random_idx: - Add frames to the queue at random indicies. This helps when
# walking a large graph by forcing us to walk deeper.
#
# Returns nothing.
def initialize(max_size: nil, random_idx: false)
@max_size = max_size
@random_idx = random_idx
@queue = []
@seen = Set.new
end
# Add a frame to the queue if its GID hasn't been seen already and the queue
# hasn't exceeded its max size.
#
# frame - The Queue::Frame to add to the queue.
#
# Returns true if the frame was added, false otherwise.
def add(frame)
return false if max_size && queue.length >= max_size
return false if @seen.include?(frame.gid)
@seen.add(frame.gid)
idx = random_idx ? rand(@queue.length + 1) : @queue.length
@queue.insert(idx, frame)
true
end
# Add a GID to the queue.
#
# gid - The String GID to add to the queue.
# parent - The frame where this GID was discovered (optional).
#
# Returns true if a frame was added, false otherwise.
def add_gid(gid, parent=nil)
frame = Frame.new(self, gid, parent)
add(frame)
end
# Iterate through the queue, yielding each frame.
#
# Returns nothing.
def each_frame
while frame = @queue.shift
yield(frame)
end
end
end
class Frame
attr_reader :queue, :gid, :parent, :context
attr_accessor :result
# Initialize a new Frame.
#
# queue - The Queue that this frame belongs to.
# gid - The String GID.
# parent - The Frame where this GID was discovered.
#
# Returns nothing.
def initialize(queue, gid, parent)
@queue = queue
@gid = gid
@parent = parent
@context = {}
end
# Add each found GID to the queue.
#
# Returns nothing.
def enqueue_found_gids
found_gids.each { |gid| queue.add(child(gid)) }
end
# Make a new frame with the given GID and this frame as its parent.
#
# gid - The String GID to create the frame with.
#
# Returns a Queue::Frame instance.
def child(gid)
Frame.new(queue, gid, self)
end
# The GIDs from this frame's results.
#
# Returns an Array of GID Strings.
def found_gids(data=result)
[].tap do |ids|
case data
when Hash
ids.concat(Array(data["id"]))
ids.concat(found_gids(data.values))
when Array
data.each { |datum| ids.concat(found_gids(datum)) }
end
end
end
end
end