├── .github └── workflows │ ├── go.yml │ ├── lint.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── .mise.toml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── HomebrewFormula └── gquil.rb ├── LICENSE ├── Makefile ├── NOTES.md ├── README.md ├── cmd └── gquil.go ├── examples ├── countries.graphql ├── empty.graphql ├── github.graphql ├── images │ ├── countries.png │ └── user-projects.png └── tiny.graphql ├── go.mod ├── go.sum └── pkg ├── astutil └── astutil.go ├── commands ├── cli.go ├── cli_test.go ├── common.go ├── context.go ├── generate_sdl.go ├── headers_test.go ├── helpers.go ├── introspection.go ├── json.go ├── ls.go ├── ls_directives.go ├── ls_fields.go ├── ls_types.go ├── merge.go ├── testdata │ ├── cases │ │ ├── json_everything │ │ │ ├── expected.json │ │ │ └── meta.yaml │ │ ├── ls_directives │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ ├── ls_fields │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ ├── ls_fields_include_args │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ ├── ls_fields_json │ │ │ ├── expected.json │ │ │ └── meta.yaml │ │ ├── ls_fields_named │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ ├── ls_fields_of_type │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ ├── ls_fields_on_type │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ ├── ls_fields_returning_type │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ ├── ls_types │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ ├── ls_types_from │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ ├── ls_types_from_depth │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ ├── ls_types_implements │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ ├── ls_types_include_directives │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ ├── ls_types_json │ │ │ ├── expected.json │ │ │ └── meta.yaml │ │ ├── ls_types_member_of │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ ├── ls_types_multiple_files │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ ├── ls_types_with_kind │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ ├── viz │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ ├── viz_from │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ ├── viz_interfaces_as_unions │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ │ └── viz_selfref │ │ │ ├── expected.txt │ │ │ └── meta.yaml │ ├── headers.txt │ ├── in.graphql │ ├── other.graphql │ └── selfref.graphql ├── version.go └── viz.go ├── graph ├── edge.go ├── graph.go ├── graph_test.go └── reference_set.go ├── introspection ├── client.go ├── query.go ├── spec_versions.go ├── toast.go ├── toast_test.go └── types.go └── model ├── arguments.go ├── builtins.go ├── definition.go ├── directives.go ├── enums.go ├── fields.go ├── model.go ├── model_test.go ├── name_reference.go ├── testdata └── cases │ ├── defaults │ ├── expected.json │ └── in.graphql │ ├── descriptions │ ├── expected.json │ └── in.graphql │ ├── directives │ ├── expected.json │ └── in.graphql │ ├── enums │ ├── expected.json │ └── in.graphql │ ├── fieldargs │ ├── expected.json │ └── in.graphql │ └── wrapping │ ├── expected.json │ └── in.graphql ├── type.go └── value.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.22' 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | permissions: 9 | contents: read 10 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 11 | # pull-requests: read 12 | 13 | jobs: 14 | golangci: 15 | name: lint 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-go@v5 20 | with: 21 | go-version: '1.22' 22 | - name: golangci-lint 23 | uses: golangci/golangci-lint-action@v6 24 | with: 25 | version: v1.58 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: '1.22' 20 | 21 | - name: Run GoReleaser 22 | uses: goreleaser/goreleaser-action@v5 23 | with: 24 | distribution: goreleaser 25 | version: "~> v1" 26 | args: release --clean 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | dist/ 3 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - main: ./cmd/gquil.go 9 | env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - darwin 14 | goarch: 15 | - amd64 16 | - arm64 17 | ldflags: 18 | - -X github.com/benweint/gquil/pkg/commands.version={{.Version}} 19 | 20 | archives: 21 | - format: tar.gz 22 | name_template: >- 23 | {{ .ProjectName }}_ 24 | {{- .Os }}_ 25 | {{- if eq .Arch "amd64" }}x86_64 26 | {{- else }}{{ .Arch }}{{ end }} 27 | {{- if .Arm }}v{{ .Arm }}{{ end }} 28 | 29 | changelog: 30 | sort: asc 31 | filters: 32 | exclude: 33 | - "^docs:" 34 | - "^test:" 35 | 36 | brews: 37 | - repository: 38 | owner: benweint 39 | name: gquil 40 | url_template: "https://github.com/benweint/gquil/releases/download/{{ .Tag }}/{{ .ArtifactName }}" 41 | download_strategy: CurlDownloadStrategy 42 | commit_author: 43 | name: GoReleaser Bot 44 | email: goreleaser@carlosbecker.com 45 | directory: HomebrewFormula 46 | homepage: "https://github.com/benweint/gquil" 47 | description: "Inspect, visualize, and transform GraphQL schemas on the command line." 48 | license: "MIT" 49 | skip_upload: false 50 | test: | 51 | system "#{bin}/gquil --version" 52 | -------------------------------------------------------------------------------- /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | golang = { version = "prefix:1.22" } 3 | golangci-lint = "1.59.0" 4 | 5 | [plugins] 6 | golangci-lint = 'https://github.com/hypnoglow/asdf-golangci-lint' 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | This code of conduct applies to all interactions with the `gquil` project and its 4 | maintainers, contributors, and users. 5 | 6 | ## Our Pledge 7 | 8 | We as members, contributors, and leaders pledge to make participation in our 9 | community a harassment-free experience for everyone, regardless of age, body 10 | size, visible or invisible disability, ethnicity, sex characteristics, gender 11 | identity and expression, level of experience, education, socio-economic status, 12 | nationality, personal appearance, race, caste, color, religion, or sexual 13 | identity and orientation. 14 | 15 | We pledge to act and interact in ways that contribute to an open, welcoming, 16 | diverse, inclusive, and healthy community. 17 | 18 | ## Our Standards 19 | 20 | Examples of behavior that contributes to a positive environment for our 21 | community include: 22 | 23 | * Demonstrating empathy and kindness toward other people 24 | * Being respectful of differing opinions, viewpoints, and experiences 25 | * Giving and gracefully accepting constructive feedback 26 | * Accepting responsibility and apologizing to those affected by our mistakes, 27 | and learning from the experience 28 | * Focusing on what is best not just for us as individuals, but for the overall 29 | community 30 | 31 | Examples of unacceptable behavior include: 32 | 33 | * The use of sexualized language or imagery, and sexual attention or advances of 34 | any kind 35 | * Trolling, insulting or derogatory comments, and personal or political attacks 36 | * Public or private harassment 37 | * Publishing others' private information, such as a physical or email address, 38 | without their explicit permission 39 | * Other conduct which could reasonably be considered inappropriate in a 40 | professional setting 41 | 42 | ## Enforcement Responsibilities 43 | 44 | Community leaders are responsible for clarifying and enforcing our standards of 45 | acceptable behavior and will take appropriate and fair corrective action in 46 | response to any behavior that they deem inappropriate, threatening, offensive, 47 | or harmful. 48 | 49 | Community leaders have the right and responsibility to remove, edit, or reject 50 | comments, commits, code, wiki edits, issues, and other contributions that are 51 | not aligned to this Code of Conduct, and will communicate reasons for moderation 52 | decisions when appropriate. 53 | 54 | ## Scope 55 | 56 | This Code of Conduct applies within all community spaces, and also applies when 57 | an individual is officially representing the community in public spaces. 58 | Examples of representing our community include using an official email address, 59 | posting via an official social media account, or acting as an appointed 60 | representative at an online or offline event. 61 | 62 | ## Enforcement 63 | 64 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 65 | reported to the community leaders responsible for enforcement at 66 | benweint@gmail.com. 67 | All complaints will be reviewed and investigated promptly and fairly. 68 | 69 | All community leaders are obligated to respect the privacy and security of the 70 | reporter of any incident. 71 | 72 | ## Enforcement Guidelines 73 | 74 | Community leaders will follow these Community Impact Guidelines in determining 75 | the consequences for any action they deem in violation of this Code of Conduct: 76 | 77 | ### 1. Correction 78 | 79 | **Community Impact**: Use of inappropriate language or other behavior deemed 80 | unprofessional or unwelcome in the community. 81 | 82 | **Consequence**: A private, written warning from community leaders, providing 83 | clarity around the nature of the violation and an explanation of why the 84 | behavior was inappropriate. A public apology may be requested. 85 | 86 | ### 2. Warning 87 | 88 | **Community Impact**: A violation through a single incident or series of 89 | actions. 90 | 91 | **Consequence**: A warning with consequences for continued behavior. No 92 | interaction with the people involved, including unsolicited interaction with 93 | those enforcing the Code of Conduct, for a specified period of time. This 94 | includes avoiding interactions in community spaces as well as external channels 95 | like social media. Violating these terms may lead to a temporary or permanent 96 | ban. 97 | 98 | ### 3. Temporary Ban 99 | 100 | **Community Impact**: A serious violation of community standards, including 101 | sustained inappropriate behavior. 102 | 103 | **Consequence**: A temporary ban from any sort of interaction or public 104 | communication with the community for a specified period of time. No public or 105 | private interaction with the people involved, including unsolicited interaction 106 | with those enforcing the Code of Conduct, is allowed during this period. 107 | Violating these terms may lead to a permanent ban. 108 | 109 | ### 4. Permanent Ban 110 | 111 | **Community Impact**: Demonstrating a pattern of violation of community 112 | standards, including sustained inappropriate behavior, harassment of an 113 | individual, or aggression toward or disparagement of classes of individuals. 114 | 115 | **Consequence**: A permanent ban from any sort of public interaction within the 116 | community. 117 | 118 | ## Attribution 119 | 120 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 121 | version 2.1, available at 122 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 123 | 124 | Community Impact Guidelines were inspired by 125 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 126 | 127 | For answers to common questions about this code of conduct, see the FAQ at 128 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 129 | [https://www.contributor-covenant.org/translations][translations]. 130 | 131 | [homepage]: https://www.contributor-covenant.org 132 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 133 | [Mozilla CoC]: https://github.com/mozilla/diversity 134 | [FAQ]: https://www.contributor-covenant.org/faq 135 | [translations]: https://www.contributor-covenant.org/translations 136 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `gquil` 2 | 3 | Thank you for your interest in contributing to `gquil`! 4 | 5 | This tool was borne of my frustration with the existing tooling for working with GraphQL schemas at the command line, so is currently shaped by my own personal preferences and tastes, but I want it to be generally useful for people working with GraphQL on a daily basis. 6 | 7 | ## Code of conduct 8 | 9 | This project has a [code of conduct](./CODE_OF_CONDUCT.md), please observe it. 10 | 11 | ## Providing feedback 12 | 13 | I would love to hear from you about: 14 | 15 | - Bugs you encounter while using `gquil` 16 | - Things you were confused by in the behavior of the tool or documentation 17 | - Things you wish the tool would do that it doesn't 18 | 19 | You can report any of these kinds of feedback via a GitHub [issue](https://github.com/benweint/gquil/issues). 20 | 21 | ### What to include 22 | 23 | When reporting an issue, please include: 24 | 25 | - The version of `gquil` you're using (`gquil version`) 26 | - The exact invocation of `gquil` you tried (or wanted) to run 27 | - Any input schemas necessary to reproduce the problem you're reporting 28 | 29 | ## Development 30 | 31 | ### Clone it 32 | 33 | ``` 34 | git clone git@github.com:benweint/gquil.git 35 | ``` 36 | 37 | ### Install tools 38 | 39 | `gquil` is implemented in Go, so you'll need to have a version of Go installed to build or contribute to it. It uses [`golangci-lint`](https://github.com/golangci/golangci-lint) for linting. 40 | 41 | I use [mise](https://mise.jdx.dev/) for managing my local Go & golangci-lint versions. If you do too: 42 | 43 | ``` 44 | cd gquil 45 | mise install 46 | 47 | # Check that the versions match what's in `.mise.toml` 48 | go version 49 | golangci-lint --version 50 | ``` 51 | 52 | Otherwise, check `.mise.toml` for the current Go and golangci-lint versions to use. 53 | 54 | ### Running the tests 55 | 56 | ``` 57 | go test ./... 58 | ``` 59 | 60 | ### Running the linter 61 | 62 | ``` 63 | golangci-lint run ./... 64 | ``` 65 | 66 | ### Running tests & lints together 67 | 68 | ``` 69 | make check 70 | ``` 71 | 72 | -------------------------------------------------------------------------------- /HomebrewFormula/gquil.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | # frozen_string_literal: true 3 | 4 | # This file was generated by GoReleaser. DO NOT EDIT. 5 | class Gquil < Formula 6 | desc "Inspect, visualize, and transform GraphQL schemas on the command line." 7 | homepage "https://github.com/benweint/gquil" 8 | version "0.2.3" 9 | license "MIT" 10 | 11 | on_macos do 12 | on_intel do 13 | url "https://github.com/benweint/gquil/releases/download/v0.2.3/gquil_darwin_x86_64.tar.gz", using: CurlDownloadStrategy 14 | sha256 "45aaf06cc882f1f336107d555926f7ec25e673aea10c85ab2a4364afaa01b0f5" 15 | 16 | def install 17 | bin.install "gquil" 18 | end 19 | end 20 | on_arm do 21 | url "https://github.com/benweint/gquil/releases/download/v0.2.3/gquil_darwin_arm64.tar.gz", using: CurlDownloadStrategy 22 | sha256 "559ace3f3f6bfc6ebf068168e3c9f25d0a7bae63b98570fea2413cb5c8987aa7" 23 | 24 | def install 25 | bin.install "gquil" 26 | end 27 | end 28 | end 29 | 30 | on_linux do 31 | on_intel do 32 | if Hardware::CPU.is_64_bit? 33 | url "https://github.com/benweint/gquil/releases/download/v0.2.3/gquil_linux_x86_64.tar.gz", using: CurlDownloadStrategy 34 | sha256 "66f193b1958b04d6c2545a7057b2498a0fdbdb57f620446fe28f3d83add92b79" 35 | 36 | def install 37 | bin.install "gquil" 38 | end 39 | end 40 | end 41 | on_arm do 42 | if Hardware::CPU.is_64_bit? 43 | url "https://github.com/benweint/gquil/releases/download/v0.2.3/gquil_linux_arm64.tar.gz", using: CurlDownloadStrategy 44 | sha256 "b95f2f4c45c58848f3ac58d27219570d84d835481c1b1cb3922ae1d3c26f659f" 45 | 46 | def install 47 | bin.install "gquil" 48 | end 49 | end 50 | end 51 | end 52 | 53 | test do 54 | system "#{bin}/gquil --version" 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | check: 2 | golangci-lint run ./... 3 | go test ./... 4 | -------------------------------------------------------------------------------- /NOTES.md: -------------------------------------------------------------------------------- 1 | ## Testing 2 | 3 | - [x] high level test coverage 4 | - [ ] test coverage for introspection commands (requires a running server) 5 | 6 | ## Documentation 7 | 8 | - [x] Make `gquil help ` show help instead of an error 9 | - [x] Make help text link back to a path for feedback, issues 10 | - [x] Add a CONTRIBUTING.md file with info about development, processes 11 | - [ ] Add examples to the in-tool documentation 12 | - [ ] Add a manpage 13 | 14 | ## Output / formatting tweaks 15 | 16 | - [x] Make `--json` flag format emitted JSON 17 | - [x] Add a `--version` flag 18 | 19 | ## Argument handling 20 | 21 | - [x] Make it possible to read header values from a file ala `curl -H @filename` 22 | 23 | ## Error handling 24 | 25 | - [x] Ensure that schema parse / validation errors actually point back to the source location 26 | 27 | ## Features 28 | 29 | - [x] Add a --named arg to ls fields command 30 | - [ ] Make --interfaces-as-unions work everywhere that --from does 31 | - [ ] Add a --depth-reverse flag to graph filtering options 32 | - [ ] Add a --with-directive flag to filter types, fields by directives 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `gquil` 2 | 3 | `gquil` is a tool for introspecting GraphQL schemas on the command line. See the [blog post](https://www.benweintraub.com/2024/06/13/gquil-a-cli-tool-for-graphql/) for a more narrative intro, or read on here for an overview. 4 | 5 | It is designed to help make large GraphQL schemas more easily navigable, and is intended to be used in conjunction with other CLI tools you already use. It can output information about large GraphQL schemas in several forms: 6 | 7 | - A line-delimited format for lists of fields, types, and directives (suitable for direct inspection, or use with `grep`, `sort`, `awk`, etc.) 8 | - A JSON format (suitable for processing with tools like [`jq`](https://github.com/jqlang/jq)) 9 | - GraphViz's [DOT language](https://graphviz.org/doc/info/lang.html) for visualization purposes (suitable for use with `dot`) 10 | - GraphQL SDL (suitable for feeding back into `gquil` itself, or using with other GraphQL-related tools) 11 | 12 | gquil aims to follow the [Command Line Interface Guidelines](https://clig.dev/). 13 | 14 | ## Installation 15 | 16 | Pre-built binaries for macOS and Linux are published to [GitHub releases](https://github.com/benweint/gquil/releases). 17 | 18 | You can also install via Homebrew, using the formula hosted in this repo: 19 | 20 | ``` 21 | brew tap benweint/gquil https://github.com/benweint/gquil 22 | brew install gquil 23 | ``` 24 | 25 | ## Feedback & contributions 26 | 27 | See [CONTRIBUTING.md](./CONTRIBUTING.md). 28 | 29 | ## Capabilities 30 | 31 | ### Visualizing schemas 32 | 33 | You can visualize GraphQL schemas using the `viz` subcommand, by piping the output into the `dot` command from [GraphViz](https://graphviz.org/). For example: 34 | 35 | ``` 36 | ❯ gquil viz examples/countries.graphql | dot -Tpdf >out.pdf 37 | ``` 38 | 39 | Produces output like this: 40 | 41 | ![A graph visualization of the countries.graphql example schema](./examples/images/countries.png) 42 | 43 | #### Trimming the visualization 44 | 45 | Real GraphQL schemas tend to have a lot of types with a lot of fields, which can make the resulting visualization both overwhelming and slow to render. For this reason, it can be useful to trim down the visualization using the `--from` and `--depth` options in order to denote a set of types or fields of interest that you'd like to anchor your visualization at. For example, this command trims the GitHub GraphQL API to only the fields and types reachable from within 2 hops from `User.projects`: 46 | 47 | ``` 48 | ❯ gquil viz --from User.projects --depth 3 examples/github.graphql 49 | ``` 50 | 51 | It produces: 52 | 53 | ![A graph visualization of github.graphql, trimmed to only showing 3 levels of depth from the User.projects entrypoint](./examples/images/user-projects.png) 54 | 55 | ### Listing types, fields, and directives 56 | 57 | #### Listing types 58 | 59 | You can list all types within a GraphQL schema. Note that 'types' in the context of GraphQL is overloaded, and includes object types, enums, interfaces, unions, input objects, and scalars. 60 | 61 | ``` 62 | ❯ gquil ls types examples/github.graphql 63 | INPUT_OBJECT AbortQueuedMigrationsInput 64 | OBJECT AbortQueuedMigrationsPayload 65 | INPUT_OBJECT AbortRepositoryMigrationInput 66 | OBJECT AbortRepositoryMigrationPayload 67 | INPUT_OBJECT AcceptEnterpriseAdministratorInvitationInput 68 | ... snip ... 69 | ``` 70 | 71 | You can filter types by kind, union membership, interface implementation status, and graph reachability from a given root type or field. For example, to find all types in the GitHub GraphQL API which implement `UniformResourceLocatable` and are reachable within 2 hops from the `Release` type: 72 | 73 | ``` 74 | ❯ gquil ls types --implements UniformResourceLocatable --from Release --depth 2 examples/github.graphql 75 | OBJECT Commit 76 | OBJECT Release 77 | OBJECT Repository 78 | OBJECT User 79 | ``` 80 | 81 | Applied directives are not emitted by default, but you can add them to the output with `--include-directives`: 82 | 83 | ``` 84 | ❯ gquil ls types --include-directives examples/github.graphql | grep @ 85 | INPUT_OBJECT MarkNotificationAsDoneInput @requiredCapabilities(requiredCapabilities: ["access_internal_graphql_notifications"]) 86 | OBJECT MarkNotificationAsDonePayload @requiredCapabilities(requiredCapabilities: ["access_internal_graphql_notifications"]) 87 | INPUT_OBJECT UnsubscribeFromNotificationsInput @requiredCapabilities(requiredCapabilities: ["access_internal_graphql_notifications"]) 88 | OBJECT UnsubscribeFromNotificationsPayload @requiredCapabilities(requiredCapabilities: ["access_internal_graphql_notifications"]) 89 | ``` 90 | 91 | The `--json` flag will emit a JSON representation of the selected types, including their descriptions and field lists. 92 | 93 | #### Listing fields 94 | 95 | You can also list individual fields. This will include fields on object types, interfaces, and input object types. 96 | 97 | ``` 98 | ❯ gquil ls fields --on-type Votable examples/github.graphql 99 | Votable.upvoteCount: Int! 100 | Votable.viewerCanUpvote: Boolean! 101 | Votable.viewerHasUpvoted: Boolean! 102 | ``` 103 | 104 | ### Generating GraphQL SDL from an introspection endpoint 105 | 106 | Some GraphQL servers expose an [introspection schema](https://graphql.org/learn/introspection/) for making queries about the type system supported by the server. The types used for this introspection schema are specified [here](https://spec.graphql.org/October2021/#sec-Introspection), but writing queries directly against the introspection schema is neither simple nor pleasant. 107 | 108 | `gquil` tries to help by making it easy to generate a materialized GraphSQL SDL document from an introspection endpoint, like this: 109 | 110 | ``` 111 | ❯ gquil introspection generate-sdl https://countries.trevorblades.com 112 | type Continent { 113 | code: ID! 114 | countries: [Country!]! 115 | name: String! 116 | } 117 | ... snip ... 118 | ``` 119 | 120 | This is particularly useful in combination with other the `gquil` subcommands described above. For example, to list all fields in a GraphQL API, you can combine the `introspection generate-sdl` and `ls fields` subcommands: 121 | 122 | ``` 123 | ❯ gquil introspection generate-sdl https://countries.trevorblades.com | gquil ls fields - 124 | Continent.code: ID! 125 | Continent.countries: [Country!]! 126 | Continent.name: String! 127 | Country.awsRegion: String! 128 | ... snip ... 129 | ``` 130 | 131 | #### Adding extra headers 132 | 133 | Some GraphQL APIs require authentication, usually passed via HTTP headers. You can attach additional headers to the introspection HTTP request via the `--header` flag to `generate-sdl`. 134 | 135 | ### Merging multiple GraphQL SDL files 136 | 137 | Sometimes, GraphQL schemas are split across multiple files. For this reason, most `gquil` subcommands accept any number of `.graphql` SDL files as input. However, sometimes it's useful to be able to merge together multiple GraphQL files in a normalized way. The `merge` subcommand allows you to do this: 138 | 139 | ``` 140 | ❯ gquil merge examples/github.graphql examples/tiny.graphql 141 | ``` 142 | 143 | During the merging process, this command will ensure that there are no duplicate definitions, and that the merged result is actually a valid GraphQL SDL document, producing an error if it is not. 144 | 145 | The resulting GraphQL will have types and directives sorted by their names, making the output deterministic. 146 | 147 | ## More examples 148 | 149 | These examples show some ways that you can compose `gquil` with other tools. 150 | 151 | ### Check type / name consistency for a given field name 152 | 153 | Are all fields named `user` in the GitHub GraphQL schema typed as `User`? Nope! 154 | 155 | ``` 156 | ❯ gquil ls fields --named user --json ./examples/github.graphql | jq '[.[]|.underlyingTypeName]|unique' 157 | [ 158 | "Actor", 159 | "User" 160 | ] 161 | ``` 162 | 163 | ### Diff two GraphQL schemas with `dyff` 164 | 165 | By using `gquil json` to produce a JSON representation of both the old and new schemas, you can generate a nice-looking diff report between them with [`dyff`](https://github.com/homeport/dyff): 166 | 167 | ``` 168 | ❯ dyff between --omit-header \ 169 | <(gquil json before.graphql) \ 170 | <(gquil json after.graphql) 171 | 172 | types 173 | + one list entry added: 174 | - name: Banana 175 | │ kind: OBJECT 176 | │ interfaces: 177 | │ - Edible 178 | │ fields: 179 | │ - name: calories 180 | │ │ type: 181 | │ │ │ name: Int 182 | │ │ │ kind: SCALAR 183 | │ │ typeName: Int 184 | │ │ underlyingTypeName: Int 185 | 186 | types.Edible.possibleTypeNames 187 | + one list entry added: 188 | - Banana 189 | ``` 190 | -------------------------------------------------------------------------------- /cmd/gquil.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/benweint/gquil/pkg/commands" 5 | ) 6 | 7 | func main() { 8 | commands.Main() 9 | } 10 | -------------------------------------------------------------------------------- /examples/countries.graphql: -------------------------------------------------------------------------------- 1 | type Continent { 2 | code: ID! 3 | countries: [Country!]! 4 | name: String! 5 | } 6 | input ContinentFilterInput { 7 | code: StringQueryOperatorInput 8 | } 9 | type Country { 10 | awsRegion: String! 11 | capital: String 12 | code: ID! 13 | continent: Continent! 14 | currencies: [String!]! 15 | currency: String 16 | emoji: String! 17 | emojiU: String! 18 | languages: [Language!]! 19 | name(lang: String): String! 20 | native: String! 21 | phone: String! 22 | phones: [String!]! 23 | states: [State!]! 24 | subdivisions: [Subdivision!]! 25 | } 26 | input CountryFilterInput { 27 | code: StringQueryOperatorInput 28 | continent: StringQueryOperatorInput 29 | currency: StringQueryOperatorInput 30 | name: StringQueryOperatorInput 31 | } 32 | type Language { 33 | code: ID! 34 | name: String! 35 | native: String! 36 | rtl: Boolean! 37 | } 38 | input LanguageFilterInput { 39 | code: StringQueryOperatorInput 40 | } 41 | type Query { 42 | continent(code: ID!): Continent 43 | continents(filter: ContinentFilterInput = {}): [Continent!]! 44 | countries(filter: CountryFilterInput = {}): [Country!]! 45 | country(code: ID!): Country 46 | language(code: ID!): Language 47 | languages(filter: LanguageFilterInput = {}): [Language!]! 48 | } 49 | type State { 50 | code: String 51 | country: Country! 52 | name: String! 53 | } 54 | input StringQueryOperatorInput { 55 | eq: String 56 | in: [String!] 57 | ne: String 58 | nin: [String!] 59 | regex: String 60 | } 61 | type Subdivision { 62 | code: ID! 63 | emoji: String 64 | name: String! 65 | } 66 | -------------------------------------------------------------------------------- /examples/empty.graphql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benweint/gquil/b4e496ad6f2ab9033130694a944b61a6e3cca2fe/examples/empty.graphql -------------------------------------------------------------------------------- /examples/images/countries.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benweint/gquil/b4e496ad6f2ab9033130694a944b61a6e3cca2fe/examples/images/countries.png -------------------------------------------------------------------------------- /examples/images/user-projects.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benweint/gquil/b4e496ad6f2ab9033130694a944b61a6e3cca2fe/examples/images/user-projects.png -------------------------------------------------------------------------------- /examples/tiny.graphql: -------------------------------------------------------------------------------- 1 | type A { 2 | b: B 3 | } 4 | 5 | type B { 6 | c: C 7 | } 8 | 9 | type C { 10 | d: D 11 | e: E 12 | } 13 | 14 | type D { 15 | x: String 16 | } 17 | 18 | type E { 19 | x: String 20 | } 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/benweint/gquil 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/alecthomas/kong v0.9.0 7 | github.com/stretchr/testify v1.4.0 8 | github.com/vektah/gqlparser/v2 v2.5.11 9 | ) 10 | 11 | require ( 12 | github.com/agnivade/levenshtein v1.1.1 // indirect 13 | github.com/davecgh/go-spew v1.1.1 // indirect 14 | github.com/pmezard/go-difflib v1.0.0 // indirect 15 | gopkg.in/yaml.v2 v2.4.0 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= 2 | github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= 3 | github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU= 4 | github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 5 | github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA= 6 | github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os= 7 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 8 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 9 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= 10 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= 11 | github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= 12 | github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= 17 | github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= 18 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 19 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 20 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 21 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 22 | github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= 23 | github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 24 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 25 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 26 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 27 | github.com/vektah/gqlparser/v2 v2.5.11 h1:JJxLtXIoN7+3x6MBdtIP59TP1RANnY7pXOaDnADQSf8= 28 | github.com/vektah/gqlparser/v2 v2.5.11/go.mod h1:1rCcfwB2ekJofmluGWXMSEnPMZgbxzwj6FaZ/4OT8Cc= 29 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 30 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 31 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 32 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 33 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 34 | -------------------------------------------------------------------------------- /pkg/astutil/astutil.go: -------------------------------------------------------------------------------- 1 | package astutil 2 | 3 | import ( 4 | "slices" 5 | "strings" 6 | 7 | "github.com/vektah/gqlparser/v2/ast" 8 | ) 9 | 10 | // IsBuiltinDirective returns true if the given directive name is one of the 11 | // built-in directives specified here: https://spec.graphql.org/October2021/#sec-Type-System.Directives.Built-in-Directives 12 | // 13 | // @defer is also counted as built-in, despite not appearing in the spec, because it is included in the prelude used by the 14 | // GraphQL parsing library we're using here: https://github.com/vektah/gqlparser/blob/master/validator/prelude.graphql 15 | func IsBuiltinDirective(name string) bool { 16 | builtinDirectives := []string{ 17 | "skip", 18 | "include", 19 | "deprecated", 20 | "specifiedBy", 21 | "defer", // not specified, but in the gqlparser prelude 22 | } 23 | 24 | return slices.Contains(builtinDirectives, name) 25 | } 26 | 27 | // IsBuiltinType returns true if the given type name is either a reserved name 28 | // (see https://spec.graphql.org/October2021/#sec-Names.Reserved-Names) or one of the specified 29 | // scalar types in the GraphQL spec here: https://spec.graphql.org/October2021/#sec-Scalars.Built-in-Scalars 30 | func IsBuiltinType(name string) bool { 31 | if strings.HasPrefix(name, "__") { 32 | return true 33 | } 34 | 35 | if name == "String" || name == "ID" || name == "Boolean" || name == "Int" || name == "Float" || name == "Enum" { 36 | return true 37 | } 38 | 39 | return false 40 | } 41 | 42 | // IsBuiltinField returns true if the given field name is a reserved name (begins with '__'). 43 | func IsBuiltinField(name string) bool { 44 | return strings.HasPrefix(name, "__") 45 | } 46 | 47 | // FilterBuiltins accepts and mutates an *ast.Schema to remove all built-in types and directives. 48 | func FilterBuiltins(s *ast.Schema) { 49 | s.Types = filterBuiltinTypes(s.Types) 50 | s.Directives = filterBuiltinDirectives(s.Directives) 51 | } 52 | 53 | func filterBuiltinTypes(defs map[string]*ast.Definition) map[string]*ast.Definition { 54 | result := map[string]*ast.Definition{} 55 | for _, def := range defs { 56 | if IsBuiltinType(def.Name) { 57 | continue 58 | } 59 | result[def.Name] = def 60 | } 61 | return result 62 | } 63 | 64 | func filterBuiltinDirectives(dirs map[string]*ast.DirectiveDefinition) map[string]*ast.DirectiveDefinition { 65 | result := map[string]*ast.DirectiveDefinition{} 66 | for _, d := range dirs { 67 | if IsBuiltinDirective(d.Name) { 68 | continue 69 | } 70 | result[d.Name] = d 71 | } 72 | return result 73 | } 74 | -------------------------------------------------------------------------------- /pkg/commands/cli.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/alecthomas/kong" 7 | ) 8 | 9 | type CLI struct { 10 | Ls LsCmd `cmd:"" aliases:"list" help:"List types, fields, or directives in a GraphQL SDL document."` 11 | Json JsonCmd `cmd:"" help:"Return a JSON representation of a GraphQL SDL document."` 12 | Introspection IntrospectionCmd `cmd:"" help:"Interact with a GraphQL introspection endpoint over HTTP."` 13 | Viz VizCmd `cmd:"" help:"Visualize a GraphQL schema using GraphViz."` 14 | Merge MergeCmd `cmd:"" help:"Merge multiple GraphQL SDL documents into a single one."` 15 | VersionFlag versionFlag `hidden:"" help:"Print version and exit."` 16 | Version VersionCmd `cmd:"" help:"Print the version of gquil and exit."` 17 | } 18 | 19 | const description = `Inspect, visualize, and transform GraphQL schemas. 20 | 21 | For more documentation, or to report an issue: 22 | https://github.com/benweint/gquil 23 | ` 24 | 25 | func MakeParser(opts ...kong.Option) (*kong.Kong, error) { 26 | var cli CLI 27 | 28 | defaultOptions := []kong.Option{ 29 | kong.Name("gquil"), 30 | kong.Description(description), 31 | kong.UsageOnError(), 32 | kong.ExplicitGroups(Groups), 33 | } 34 | 35 | return kong.New(&cli, append(defaultOptions, opts...)...) 36 | } 37 | 38 | // massageArgs munges the input args in order to translate: 39 | // - `gquil` -> `gquil --help` 40 | // - `gquil help` -> `quil --help` 41 | // - `gquil help ` -> `gquil --help` 42 | func massageArgs(args []string) []string { 43 | args = args[1:] 44 | 45 | if len(args) == 0 { 46 | return []string{"--help"} 47 | } 48 | 49 | if args[0] == "help" { 50 | if len(args) == 1 { 51 | return []string{"--help"} 52 | } 53 | 54 | return append(args[1:], "--help") 55 | } 56 | 57 | return args 58 | } 59 | 60 | func Main() int { 61 | parser, err := MakeParser() 62 | if err != nil { 63 | panic(err) 64 | } 65 | 66 | args := massageArgs(os.Args) 67 | ctx, err := parser.Parse(args) 68 | parser.FatalIfErrorf(err) 69 | err = ctx.Run(Context{ 70 | Stdout: os.Stdout, 71 | Stderr: os.Stderr, 72 | Stdin: os.Stdin, 73 | }) 74 | ctx.FatalIfErrorf(err) 75 | return 0 76 | } 77 | -------------------------------------------------------------------------------- /pkg/commands/cli_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "path" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "gopkg.in/yaml.v2" 11 | ) 12 | 13 | type TestCaseParams struct { 14 | Dir string 15 | Args []string `yaml:"args"` 16 | ExpectJson bool `yaml:"expectJson"` 17 | ExpectedOutput string 18 | expectedOutputPath string 19 | } 20 | 21 | func TestCli(t *testing.T) { 22 | var cases []TestCaseParams 23 | 24 | baseDir := "testdata/cases" 25 | entries, err := os.ReadDir(baseDir) 26 | assert.NoError(t, err) 27 | 28 | for _, ent := range entries { 29 | if !ent.IsDir() { 30 | continue 31 | } 32 | 33 | testCaseDir := path.Join(baseDir, ent.Name()) 34 | metaYamlPath := path.Join(testCaseDir, "meta.yaml") 35 | metaYamlRaw, err := os.ReadFile(metaYamlPath) 36 | assert.NoError(t, err) 37 | 38 | params := TestCaseParams{ 39 | Dir: testCaseDir, 40 | } 41 | err = yaml.Unmarshal(metaYamlRaw, ¶ms) 42 | assert.NoError(t, err) 43 | 44 | expectedFilename := "expected.txt" 45 | if params.ExpectJson { 46 | expectedFilename = "expected.json" 47 | } 48 | 49 | expectedOutputPath := path.Join(testCaseDir, expectedFilename) 50 | params.expectedOutputPath = expectedOutputPath 51 | expectedOutputRaw, err := os.ReadFile(expectedOutputPath) 52 | assert.NoError(t, err) 53 | params.ExpectedOutput = string(expectedOutputRaw) 54 | 55 | cases = append(cases, params) 56 | } 57 | 58 | for _, tc := range cases { 59 | t.Run(tc.Dir, func(t *testing.T) { 60 | parser, err := MakeParser() 61 | assert.NoError(t, err) 62 | 63 | ctx, err := parser.Parse(tc.Args) 64 | assert.NoError(t, err) 65 | 66 | var stdoutBuf, stderrBuf, stdinBuf bytes.Buffer 67 | 68 | err = ctx.Run(Context{ 69 | Stdout: &stdoutBuf, 70 | Stderr: &stderrBuf, 71 | Stdin: &stdinBuf, 72 | }) 73 | assert.NoError(t, err) 74 | 75 | updateExpected := os.Getenv("TEST_UPDATE_EXPECTED") 76 | if updateExpected != "" { 77 | err = os.WriteFile(tc.expectedOutputPath, stdoutBuf.Bytes(), 0655) 78 | assert.NoError(t, err) 79 | } 80 | 81 | if tc.ExpectJson { 82 | assert.JSONEq(t, tc.ExpectedOutput, stdoutBuf.String()) 83 | } else { 84 | assert.Equal(t, tc.ExpectedOutput, stdoutBuf.String()) 85 | } 86 | }) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /pkg/commands/common.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/alecthomas/kong" 5 | "github.com/benweint/gquil/pkg/graph" 6 | "github.com/benweint/gquil/pkg/model" 7 | ) 8 | 9 | var Groups = []kong.Group{ 10 | { 11 | Key: "filtering", 12 | Title: "Filtering options", 13 | }, 14 | { 15 | Key: "output", 16 | Title: "Output formatting options", 17 | }, 18 | } 19 | 20 | type InputOptions struct { 21 | // TODO: stdin support 22 | SchemaFiles []string `arg:"" name:"schemas" help:"Path to the GraphQL SDL schema file(s) to read from."` 23 | } 24 | 25 | type FilteringOptions struct { 26 | IncludeBuiltins bool `name:"include-builtins" group:"filtering" help:"Include built-in types and directives in output (omitted by default)."` 27 | } 28 | 29 | type IncludeDirectivesOption struct { 30 | IncludeDirectives bool `name:"include-directives" group:"output" help:"Include applied directives in human-readable output. Has no effect with --json."` 31 | } 32 | 33 | type OutputOptions struct { 34 | Json bool `name:"json" group:"output" help:"Output results as JSON."` 35 | } 36 | 37 | type GraphFilteringOptions struct { 38 | From []string `name:"from" group:"filtering" help:"Only include types reachable from the specified type(s) or field(s). May be specified multiple times to use multiple roots."` 39 | Depth int `name:"depth" group:"filtering" help:"When used with --from, limit the depth of traversal."` 40 | } 41 | 42 | func (o GraphFilteringOptions) filterSchema(s *model.Schema) error { 43 | if len(o.From) == 0 { 44 | return nil 45 | } 46 | 47 | roots, err := s.ResolveNames(o.From) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | g := graph.MakeGraph(s).ReachableFrom(roots, o.Depth) 53 | s.Types = g.GetDefinitions() 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /pkg/commands/context.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | type Context struct { 10 | Stdout io.Writer 11 | Stderr io.Writer 12 | Stdin io.Reader 13 | } 14 | 15 | func (c Context) Print(s string) { 16 | _, _ = c.Stdout.Write([]byte(s)) 17 | } 18 | 19 | func (c Context) Printf(fs string, args ...any) { 20 | _, _ = fmt.Fprintf(c.Stdout, fs, args...) 21 | } 22 | 23 | func (c Context) PrintJson(v any) error { 24 | out, err := json.MarshalIndent(v, "", " ") 25 | if err != nil { 26 | return err 27 | } 28 | 29 | _, err = c.Stdout.Write(out) 30 | return err 31 | } 32 | -------------------------------------------------------------------------------- /pkg/commands/generate_sdl.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "os" 8 | "strings" 9 | 10 | "github.com/benweint/gquil/pkg/astutil" 11 | "github.com/benweint/gquil/pkg/introspection" 12 | "github.com/benweint/gquil/pkg/model" 13 | "github.com/vektah/gqlparser/v2/formatter" 14 | ) 15 | 16 | type GenerateSDLCmd struct { 17 | Endpoint string `arg:"" help:"The GraphQL introspection endpoint URL to fetch from."` 18 | Headers []string `name:"header" short:"H" help:"Set custom headers on the introspection request, e.g. for authentication. Format: : . May be specified multiple times. Header values may be read from a file with the syntax @, e.g. --header @my-headers.txt."` 19 | Trace bool `name:"trace" help:"Dump the introspection HTTP request and response to stderr for debugging."` 20 | 21 | OutputOptions 22 | SpecVersionOptions 23 | FilteringOptions 24 | } 25 | 26 | func (c *GenerateSDLCmd) Help() string { 27 | return `Issues a GraphQL introspection query via an HTTP POST request to the specified endpoint, and uses the response to generate a GraphQL SDL document, which is emitted to stdout. 28 | 29 | Example: 30 | 31 | gquil introspection generate-sdl \ 32 | --header 'origin: https://docs.developer.yelp.com' \ 33 | https://api.yelp.com/v3/graphql 34 | 35 | Note that since GraphQL's introspection schema does not expose information about the application sites of most directives, the generated SDL will lack any applied directives (with the exception of @deprecated, which is exposed via the introspection system). 36 | 37 | If your GraphQL endpoint requires authentication or other special headers, you can set custom headers on the issued request using the --header flag.` 38 | } 39 | 40 | func (c *GenerateSDLCmd) Run(ctx Context) error { 41 | sv, err := introspection.ParseSpecVersion(c.SpecVersion) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | var traceOut io.Writer 47 | if c.Trace { 48 | traceOut = os.Stderr 49 | } 50 | 51 | headers, err := parseHeaders(c.Headers) 52 | if err != nil { 53 | return fmt.Errorf("failed to parse custom header: %w", err) 54 | } 55 | 56 | client := introspection.NewClient(c.Endpoint, headers, sv, traceOut) 57 | s, err := client.FetchSchemaAst() 58 | if err != nil { 59 | return err 60 | } 61 | 62 | if c.Json { 63 | m, err := model.MakeSchema(s) 64 | if err != nil { 65 | return fmt.Errorf("failed to construct model from introspection schema AST: %w", err) 66 | } 67 | 68 | if !c.IncludeBuiltins { 69 | m.FilterBuiltins() 70 | } 71 | 72 | return ctx.PrintJson(m) 73 | } else { 74 | if !c.IncludeBuiltins { 75 | astutil.FilterBuiltins(s) 76 | } 77 | 78 | f := formatter.NewFormatter(os.Stdout) 79 | f.FormatSchema(s) 80 | } 81 | 82 | return nil 83 | } 84 | 85 | func parseHeaders(raw []string) (http.Header, error) { 86 | result := http.Header{} 87 | for _, rawHeader := range raw { 88 | parsedHeaders, err := parseHeaderValue(rawHeader) 89 | if err != nil { 90 | return nil, err 91 | } 92 | for key, vals := range parsedHeaders { 93 | for _, val := range vals { 94 | result.Add(key, val) 95 | } 96 | } 97 | } 98 | return result, nil 99 | } 100 | 101 | func parseHeaderValue(raw string) (http.Header, error) { 102 | if strings.HasPrefix(raw, "@") { 103 | return parseHeadersFromFile(strings.TrimPrefix(raw, "@")) 104 | } 105 | 106 | key, val, err := parseHeaderString(raw) 107 | if err != nil { 108 | return nil, err 109 | } 110 | return http.Header{ 111 | key: []string{val}, 112 | }, nil 113 | } 114 | 115 | func parseHeadersFromFile(path string) (http.Header, error) { 116 | raw, err := os.ReadFile(path) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | result := http.Header{} 122 | lines := strings.Split(string(raw), "\n") 123 | for _, line := range lines { 124 | if line == "" { 125 | continue 126 | } 127 | key, val, err := parseHeaderString(line) 128 | if err != nil { 129 | return nil, err 130 | } 131 | result.Add(key, val) 132 | } 133 | return result, nil 134 | } 135 | 136 | func parseHeaderString(raw string) (string, string, error) { 137 | parts := strings.SplitN(raw, ":", 2) 138 | if len(parts) != 2 { 139 | return "", "", fmt.Errorf("invalid header value '%s', expected format ': '", raw) 140 | } 141 | key := parts[0] 142 | value := strings.TrimLeft(parts[1], " ") 143 | return key, value, nil 144 | } 145 | -------------------------------------------------------------------------------- /pkg/commands/headers_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestHeaderParsing(t *testing.T) { 11 | for _, testCase := range []struct { 12 | name string 13 | values []string 14 | wantError bool 15 | expected http.Header 16 | }{ 17 | { 18 | name: "single-valued header", 19 | values: []string{ 20 | "foo: bar", 21 | }, 22 | expected: http.Header{ 23 | "Foo": []string{"bar"}, 24 | }, 25 | }, 26 | { 27 | name: "multiple headers", 28 | values: []string{ 29 | "foo: bar", 30 | "baz: qux", 31 | }, 32 | expected: http.Header{ 33 | "Foo": []string{"bar"}, 34 | "Baz": []string{"qux"}, 35 | }, 36 | }, 37 | { 38 | name: "single header with multiple values", 39 | values: []string{ 40 | "foo: bar", 41 | "foo:baz", 42 | }, 43 | expected: http.Header{ 44 | "Foo": []string{"bar", "baz"}, 45 | }, 46 | }, 47 | { 48 | name: "malformed", 49 | values: []string{ 50 | "foo", 51 | }, 52 | wantError: true, 53 | }, 54 | { 55 | name: "from file", 56 | values: []string{ 57 | "@testdata/headers.txt", 58 | }, 59 | expected: http.Header{ 60 | "Foo": []string{"bar"}, 61 | "Baz": []string{"qux"}, 62 | }, 63 | }, 64 | { 65 | name: "nonexistent file", 66 | values: []string{ 67 | "@notreal.txt", 68 | }, 69 | wantError: true, 70 | }, 71 | { 72 | name: "file and inline", 73 | values: []string{ 74 | "@testdata/headers.txt", 75 | "baz: bop", 76 | "other: one", 77 | }, 78 | expected: http.Header{ 79 | "Foo": []string{"bar"}, 80 | "Baz": []string{"qux", "bop"}, 81 | "Other": []string{"one"}, 82 | }, 83 | }, 84 | } { 85 | t.Run(testCase.name, func(t *testing.T) { 86 | result, err := parseHeaders(testCase.values) 87 | if testCase.wantError { 88 | assert.Error(t, err) 89 | } else { 90 | assert.NoError(t, err) 91 | assert.Equal(t, testCase.expected, result) 92 | } 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /pkg/commands/helpers.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "sort" 9 | "strings" 10 | 11 | "github.com/benweint/gquil/pkg/model" 12 | "github.com/vektah/gqlparser/v2" 13 | "github.com/vektah/gqlparser/v2/ast" 14 | ) 15 | 16 | func loadSchemaModel(paths []string) (*model.Schema, error) { 17 | rawSchema, err := parseSchemaFromPaths(paths) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | s, err := model.MakeSchema(rawSchema) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | sort.Slice(s.Directives, func(i, j int) bool { 28 | return strings.Compare(s.Directives[i].Name, s.Directives[j].Name) < 0 29 | }) 30 | 31 | return s, nil 32 | } 33 | 34 | func parseSchemaFromPaths(paths []string) (*ast.Schema, error) { 35 | var sources []*ast.Source 36 | for _, path := range paths { 37 | var raw []byte 38 | var err error 39 | if path == "-" { 40 | path = "stdin" 41 | raw, err = io.ReadAll(os.Stdin) 42 | } else { 43 | raw, err = os.ReadFile(path) 44 | } 45 | if err != nil { 46 | return nil, fmt.Errorf("could not read source SDL from %s: %w", path, err) 47 | } 48 | 49 | source := ast.Source{ 50 | Name: path, 51 | Input: string(raw), 52 | } 53 | sources = append(sources, &source) 54 | } 55 | schema, err := gqlparser.LoadSchema(sources...) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed to parse source SDL: %w", err) 58 | } 59 | 60 | return schema, nil 61 | } 62 | 63 | func formatArgumentDefinitionList(al model.ArgumentDefinitionList) string { 64 | if len(al) == 0 { 65 | return "" 66 | } 67 | 68 | var formattedArgs []string 69 | for _, arg := range al { 70 | formatted := fmt.Sprintf("%s: %s", arg.Name, arg.Type) 71 | formattedArgs = append(formattedArgs, formatted) 72 | } 73 | return "(" + strings.Join(formattedArgs, ", ") + ")" 74 | } 75 | 76 | func formatArgumentList(al model.ArgumentList) (string, error) { 77 | if len(al) == 0 { 78 | return "", nil 79 | } 80 | 81 | var formattedArgs []string 82 | for _, arg := range al { 83 | formattedValue, err := formatValue(arg.Value) 84 | if err != nil { 85 | return "", err 86 | } 87 | formatted := fmt.Sprintf("%s: %s", arg.Name, formattedValue) 88 | formattedArgs = append(formattedArgs, formatted) 89 | } 90 | return "(" + strings.Join(formattedArgs, ", ") + ")", nil 91 | } 92 | 93 | func formatDirectiveList(dl model.DirectiveList) (string, error) { 94 | if len(dl) == 0 { 95 | return "", nil 96 | } 97 | 98 | var formattedDirectives []string 99 | for _, d := range dl { 100 | formattedArgs, err := formatArgumentList(d.Arguments) 101 | if err != nil { 102 | return "", err 103 | } 104 | formatted := fmt.Sprintf("@%s%s", d.Name, formattedArgs) 105 | formattedDirectives = append(formattedDirectives, formatted) 106 | } 107 | 108 | return " " + strings.Join(formattedDirectives, " "), nil 109 | } 110 | 111 | func formatValue(v model.Value) (string, error) { 112 | raw, err := json.Marshal(v) 113 | return string(raw), err 114 | } 115 | -------------------------------------------------------------------------------- /pkg/commands/introspection.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/benweint/gquil/pkg/introspection" 5 | ) 6 | 7 | type IntrospectionCmd struct { 8 | GenerateSDL GenerateSDLCmd `cmd:"" help:"Generate GraphQL SDL from a GraphQL introspection endpoint over HTTP(S)."` 9 | Query EmitQueryCmd `cmd:"" help:"Emit the GraphQL query used for introspection."` 10 | } 11 | 12 | type SpecVersionOptions struct { 13 | SpecVersion string `name:"spec-version" help:"GraphQL spec version to use when making the introspection query. One of june2018, october2021. You may want to use a newer spec version when interacting with servers which support it, to get newer fields (like the schema description field, or the isRepeatable field on directives, which were added in the october2021 spec)." default:"june2018"` 14 | } 15 | 16 | type EmitQueryCmd struct { 17 | Json bool `name:"json" help:"Emit the query as JSON, suitable for passing to curl or similar."` 18 | SpecVersionOptions 19 | } 20 | 21 | func (c *EmitQueryCmd) Run(ctx Context) error { 22 | sv, err := introspection.ParseSpecVersion(c.SpecVersion) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | if c.Json { 28 | q := introspection.GraphQLParams{ 29 | Query: introspection.GetQuery(sv), 30 | OperationName: "IntrospectionQuery", 31 | } 32 | return ctx.PrintJson(q) 33 | } 34 | 35 | ctx.Printf("%s\n", introspection.GetQuery(sv)) 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /pkg/commands/json.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | type JsonCmd struct { 4 | InputOptions 5 | FilteringOptions 6 | GraphFilteringOptions 7 | } 8 | 9 | func (c *JsonCmd) Help() string { 10 | return `Print a flattened JSON representation of the given GraphQL schema, suitable for processing with jq or similar. The JSON format used is inspired by but not identical to the GraphQL introspection type system. It differs mainly in that references to named types are 'flattened' into strings, rather than being represented as recursively nested objects. 11 | 12 | Unlike the introspection types in the GraphQL spec, the JSON output format includes information about the application sites of directives, under the 'directives' key. 13 | 14 | The JSON format for fields and arguments also adds several convenience fields which are useful when processing the output: 15 | 16 | * underlyingTypeName: the underlying named type of the field, after unwrapping list and non-null wrapping types. For example, a field of type '[String!]' would have an underlyingTypeName of 'String') 17 | * typeName: the type of the field, represented as a string in GraphQL SDL notation (for example: '[String!]!')` 18 | } 19 | 20 | func (c *JsonCmd) Run(ctx Context) error { 21 | s, err := loadSchemaModel(c.SchemaFiles) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | if err = c.filterSchema(s); err != nil { 27 | return err 28 | } 29 | 30 | if !c.IncludeBuiltins { 31 | s.FilterBuiltins() 32 | } 33 | 34 | return ctx.PrintJson(s) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/commands/ls.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | type LsCmd struct { 4 | Types LsTypesCmd `cmd:"" help:"List types in the given schema(s)."` 5 | Fields LsFieldsCmd `cmd:"" help:"List fields in the given schema(s)."` 6 | Directives LsDirectivesCmd `cmd:"" help:"List directive definitions in the given schema(s)."` 7 | } 8 | -------------------------------------------------------------------------------- /pkg/commands/ls_directives.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/benweint/gquil/pkg/model" 8 | ) 9 | 10 | type LsDirectivesCmd struct { 11 | InputOptions 12 | FilteringOptions 13 | OutputOptions 14 | } 15 | 16 | func (c LsDirectivesCmd) Help() string { 17 | return `List all directive definitions in the given GraphQL SDL file(s). 18 | 19 | By default, directives are emitted with their argument definitions and valid application locations, in a format that mirrors the SDL for defining them. You can emit JSON representations of them instead with the --json flag.` 20 | } 21 | 22 | func (c LsDirectivesCmd) Run(ctx Context) error { 23 | s, err := loadSchemaModel(c.SchemaFiles) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | if !c.IncludeBuiltins { 29 | s.FilterBuiltins() 30 | } 31 | 32 | if c.Json { 33 | return ctx.PrintJson(s.Directives) 34 | } 35 | 36 | for _, directive := range s.Directives { 37 | ctx.Printf("%s\n", formatDirectiveDefinition(directive)) 38 | } 39 | 40 | return nil 41 | } 42 | 43 | func formatDirectiveDefinition(d *model.DirectiveDefinition) string { 44 | var locations []string 45 | for _, kind := range d.Locations { 46 | locations = append(locations, string(kind)) 47 | } 48 | return fmt.Sprintf("@%s%s on %s", d.Name, formatArgumentDefinitionList(d.Arguments), strings.Join(locations, " | ")) 49 | } 50 | -------------------------------------------------------------------------------- /pkg/commands/ls_fields.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "slices" 5 | 6 | "github.com/benweint/gquil/pkg/model" 7 | ) 8 | 9 | type LsFieldsCmd struct { 10 | InputOptions 11 | OnType string `name:"on-type" group:"filtering" help:"Only include fields which appear on the specified type."` 12 | OfType string `name:"of-type" group:"filtering" help:"Only include fields of the specified type. List and non-null types will be treated as being of their underlying wrapped type for the purposes of this filtering."` 13 | ReturningType string `name:"returning-type" group:"filtering" help:"Only include fields which may return the specified type. Interface or union-typed fields may possibly return their implementing or member types. List and non-null fields are unwrapped for the purposes of this filtering."` 14 | Named string `name:"named" group:"filtering" help:"Only include fields with the given name (matched against the field name only, not including type name)."` 15 | IncludeArgs bool `name:"include-args" group:"output" help:"Include argument definitions in human-readable output. Has no effect with --json."` 16 | IncludeDirectivesOption 17 | OutputOptions 18 | FilteringOptions 19 | GraphFilteringOptions 20 | } 21 | 22 | func (c LsFieldsCmd) Help() string { 23 | return `Fields are identified as ., where is the host type on which they are defined, and are emitted in sorted order by these identifiers. 24 | 25 | You can use the --on-type, --of-type, --returning-type, and --named arguments to filter the set of returned fields. You can also filter by graph reachability using the --from and --depth options, see the help for these flags for details. 26 | 27 | Field arguments and directives are not included in the output by default (only names and types), but can be added with --include-args and --include-directives, respectivesly. You can also use --json for a JSON output format. The JSON output format matches the one used by the json subcommand, with the exception that field names will include the host type as a prefix (e.g. 'Query.search' instead of just 'search').` 28 | } 29 | 30 | func (c LsFieldsCmd) Run(ctx Context) error { 31 | s, err := loadSchemaModel(c.SchemaFiles) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | if err = c.filterSchema(s); err != nil { 37 | return err 38 | } 39 | 40 | if !c.IncludeBuiltins { 41 | s.FilterBuiltins() 42 | } 43 | 44 | var fields model.FieldDefinitionList 45 | for _, t := range s.Types { 46 | if c.OnType != "" && c.OnType != t.Name { 47 | continue 48 | } 49 | for _, f := range t.Fields { 50 | if c.OfType != "" && c.OfType != f.Type.Unwrap().String() { 51 | continue 52 | } 53 | if c.ReturningType != "" && !fieldMightReturn(s, f, c.ReturningType) { 54 | continue 55 | } 56 | if c.Named != "" && c.Named != f.Name { 57 | continue 58 | } 59 | f.Name = t.Name + "." + f.Name 60 | fields = append(fields, f) 61 | } 62 | } 63 | fields.Sort() 64 | 65 | if c.Json { 66 | return ctx.PrintJson(fields) 67 | } 68 | 69 | for _, f := range fields { 70 | args := "" 71 | if c.IncludeArgs { 72 | args = formatArgumentDefinitionList(f.Arguments) 73 | } 74 | directives := "" 75 | if c.IncludeDirectives { 76 | directives, err = formatDirectiveList(f.Directives) 77 | if err != nil { 78 | return err 79 | } 80 | } 81 | ctx.Printf("%s%s: %s%s\n", f.Name, args, f.Type, directives) 82 | } 83 | 84 | return nil 85 | } 86 | 87 | func fieldMightReturn(s *model.Schema, field *model.FieldDefinition, typeName string) bool { 88 | underlyingType := field.Type.Unwrap() 89 | if underlyingType.Name == typeName { 90 | return true 91 | } 92 | 93 | referencedType := s.Types[field.Type.Unwrap().Name] 94 | if referencedType != nil && slices.Contains(referencedType.PossibleTypes, typeName) { 95 | return true 96 | } 97 | 98 | return false 99 | } 100 | -------------------------------------------------------------------------------- /pkg/commands/ls_types.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "slices" 5 | "strings" 6 | 7 | "github.com/benweint/gquil/pkg/model" 8 | "github.com/vektah/gqlparser/v2/ast" 9 | ) 10 | 11 | type LsTypesCmd struct { 12 | InputOptions 13 | Kind ast.DefinitionKind `name:"kind" group:"filtering" help:"Only list types of the given kind (interface, object, union, input_object, enum, scalar)."` 14 | MemberOf string `name:"member-of" group:"filtering" help:"Only list types which are members of the given union."` 15 | Implements string `name:"implements" group:"filtering" help:"Only list types which implement the given interface."` 16 | IncludeDirectivesOption 17 | FilteringOptions 18 | OutputOptions 19 | GraphFilteringOptions 20 | } 21 | 22 | func (c LsTypesCmd) Help() string { 23 | return `Types include objects types, interfaces, unions, enums, input objects, and scalars. The default output format prepends each listed type with its kind. You can filter to a specific kind using --kind, which will cause the kind to be omitted in the output. For example: 24 | 25 | gquil ls types --kind interface examples/github.graphql 26 | 27 | You can also filter types based on their membership in a union type (--member-of), or based on whether they implement a specified interface (--implements). You can also filter by graph reachability using the --from and --depth options, see the help for these flags for details. 28 | 29 | Directives are not included in the output by default, but can be added with --include-directives. You can also use --json for a JSON output format. The JSON output format matches the one used by the json subcommand. 30 | ` 31 | } 32 | 33 | func (c LsTypesCmd) Run(ctx Context) error { 34 | s, err := loadSchemaModel(c.SchemaFiles) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | if err = c.filterSchema(s); err != nil { 40 | return err 41 | } 42 | 43 | if !c.IncludeBuiltins { 44 | s.FilterBuiltins() 45 | } 46 | 47 | var memberTypes []string 48 | if c.MemberOf != "" { 49 | for _, t := range s.Types { 50 | if t.Name == c.MemberOf { 51 | memberTypes = t.PossibleTypes 52 | } 53 | } 54 | } 55 | 56 | var types model.DefinitionList 57 | normalizedKind := ast.DefinitionKind(strings.ToUpper(string(c.Kind))) 58 | for _, t := range s.Types { 59 | if normalizedKind != "" && normalizedKind != t.Kind { 60 | continue 61 | } 62 | 63 | if c.MemberOf != "" && !slices.Contains(memberTypes, t.Name) { 64 | continue 65 | } 66 | 67 | if c.Implements != "" && !slices.Contains(t.Interfaces, c.Implements) { 68 | continue 69 | } 70 | 71 | types = append(types, t) 72 | } 73 | types.Sort() 74 | 75 | if c.Json { 76 | return ctx.PrintJson(types) 77 | } else { 78 | for _, t := range types { 79 | directives := "" 80 | if c.IncludeDirectives { 81 | directives, err = formatDirectiveList(t.Directives) 82 | if err != nil { 83 | return err 84 | } 85 | } 86 | 87 | if c.Kind != "" { 88 | ctx.Printf("%s%s\n", t.Name, directives) 89 | } else { 90 | ctx.Printf("%s %s%s\n", t.Kind, t.Name, directives) 91 | } 92 | } 93 | } 94 | 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /pkg/commands/merge.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/benweint/gquil/pkg/astutil" 5 | "github.com/vektah/gqlparser/v2/formatter" 6 | ) 7 | 8 | type MergeCmd struct { 9 | InputOptions 10 | FilteringOptions 11 | } 12 | 13 | func (c *MergeCmd) Run(ctx Context) error { 14 | s, err := parseSchemaFromPaths(c.SchemaFiles) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | if !c.IncludeBuiltins { 20 | astutil.FilterBuiltins(s) 21 | } 22 | 23 | f := formatter.NewFormatter(ctx.Stdout) 24 | f.FormatSchema(s) 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/json_everything/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { 4 | "directives": [ 5 | { 6 | "name": "key", 7 | "arguments": [ 8 | { 9 | "name": "fields", 10 | "value": [ 11 | "variety" 12 | ] 13 | } 14 | ] 15 | } 16 | ], 17 | "fields": [ 18 | { 19 | "name": "variety", 20 | "type": { 21 | "kind": "ENUM", 22 | "name": "AppleVariety" 23 | }, 24 | "typeName": "AppleVariety", 25 | "underlyingTypeName": "AppleVariety" 26 | }, 27 | { 28 | "name": "measurements", 29 | "type": { 30 | "kind": "OBJECT", 31 | "name": "Measurements" 32 | }, 33 | "typeName": "Measurements", 34 | "underlyingTypeName": "Measurements" 35 | }, 36 | { 37 | "name": "calories", 38 | "type": { 39 | "kind": "SCALAR", 40 | "name": "Int" 41 | }, 42 | "typeName": "Int", 43 | "underlyingTypeName": "Int" 44 | } 45 | ], 46 | "interfaces": [ 47 | "Edible" 48 | ], 49 | "kind": "OBJECT", 50 | "name": "Apple" 51 | }, 52 | { 53 | "enumValues": [ 54 | { 55 | "name": "FUJI" 56 | }, 57 | { 58 | "name": "COSMIC_CRISP" 59 | }, 60 | { 61 | "name": "GRANNY_SMITH" 62 | } 63 | ], 64 | "kind": "ENUM", 65 | "name": "AppleVariety" 66 | }, 67 | { 68 | "fields": [ 69 | { 70 | "name": "calories", 71 | "type": { 72 | "kind": "SCALAR", 73 | "name": "Int" 74 | }, 75 | "typeName": "Int", 76 | "underlyingTypeName": "Int" 77 | } 78 | ], 79 | "interfaces": [ 80 | "Edible" 81 | ], 82 | "kind": "OBJECT", 83 | "name": "Biscuit" 84 | }, 85 | { 86 | "fields": [ 87 | { 88 | "name": "calories", 89 | "type": { 90 | "kind": "SCALAR", 91 | "name": "Int" 92 | }, 93 | "typeName": "Int", 94 | "underlyingTypeName": "Int" 95 | } 96 | ], 97 | "kind": "INTERFACE", 98 | "name": "Edible", 99 | "possibleTypeNames": [ 100 | "Apple", 101 | "Orange", 102 | "Biscuit" 103 | ] 104 | }, 105 | { 106 | "kind": "SCALAR", 107 | "name": "FieldSet" 108 | }, 109 | { 110 | "inputFields": [ 111 | { 112 | "name": "nameLike", 113 | "type": { 114 | "kind": "SCALAR", 115 | "name": "String" 116 | }, 117 | "typeName": "String", 118 | "underlyingTypeName": "String" 119 | }, 120 | { 121 | "name": "limit", 122 | "type": { 123 | "kind": "SCALAR", 124 | "name": "Int" 125 | }, 126 | "typeName": "Int", 127 | "underlyingTypeName": "Int" 128 | } 129 | ], 130 | "kind": "INPUT_OBJECT", 131 | "name": "Filter" 132 | }, 133 | { 134 | "kind": "UNION", 135 | "name": "Fruit", 136 | "possibleTypeNames": [ 137 | "Apple", 138 | "Orange" 139 | ] 140 | }, 141 | { 142 | "fields": [ 143 | { 144 | "name": "height", 145 | "type": { 146 | "kind": "SCALAR", 147 | "name": "Int" 148 | }, 149 | "typeName": "Int", 150 | "underlyingTypeName": "Int" 151 | }, 152 | { 153 | "name": "width", 154 | "type": { 155 | "kind": "SCALAR", 156 | "name": "Int" 157 | }, 158 | "typeName": "Int", 159 | "underlyingTypeName": "Int" 160 | }, 161 | { 162 | "name": "depth", 163 | "type": { 164 | "kind": "SCALAR", 165 | "name": "Int" 166 | }, 167 | "typeName": "Int", 168 | "underlyingTypeName": "Int" 169 | } 170 | ], 171 | "kind": "OBJECT", 172 | "name": "Measurements" 173 | }, 174 | { 175 | "fields": [ 176 | { 177 | "name": "variety", 178 | "type": { 179 | "kind": "ENUM", 180 | "name": "OrangeVariety" 181 | }, 182 | "typeName": "OrangeVariety", 183 | "underlyingTypeName": "OrangeVariety" 184 | }, 185 | { 186 | "name": "calories", 187 | "type": { 188 | "kind": "SCALAR", 189 | "name": "Int" 190 | }, 191 | "typeName": "Int", 192 | "underlyingTypeName": "Int" 193 | } 194 | ], 195 | "interfaces": [ 196 | "Edible" 197 | ], 198 | "kind": "OBJECT", 199 | "name": "Orange" 200 | }, 201 | { 202 | "enumValues": [ 203 | { 204 | "name": "VALENCIA" 205 | }, 206 | { 207 | "name": "NAVEL" 208 | }, 209 | { 210 | "name": "CARA_CARA" 211 | } 212 | ], 213 | "kind": "ENUM", 214 | "name": "OrangeVariety" 215 | }, 216 | { 217 | "fields": [ 218 | { 219 | "arguments": [ 220 | { 221 | "name": "name", 222 | "type": { 223 | "kind": "SCALAR", 224 | "name": "String" 225 | }, 226 | "typeName": "String", 227 | "underlyingTypeName": "String" 228 | } 229 | ], 230 | "name": "fruit", 231 | "type": { 232 | "kind": "UNION", 233 | "name": "Fruit" 234 | }, 235 | "typeName": "Fruit", 236 | "underlyingTypeName": "Fruit" 237 | }, 238 | { 239 | "arguments": [ 240 | { 241 | "name": "name", 242 | "type": { 243 | "kind": "SCALAR", 244 | "name": "String" 245 | }, 246 | "typeName": "String", 247 | "underlyingTypeName": "String" 248 | } 249 | ], 250 | "name": "edible", 251 | "type": { 252 | "kind": "INTERFACE", 253 | "name": "Edible" 254 | }, 255 | "typeName": "Edible", 256 | "underlyingTypeName": "Edible" 257 | }, 258 | { 259 | "arguments": [ 260 | { 261 | "name": "filter", 262 | "type": { 263 | "kind": "INPUT_OBJECT", 264 | "name": "Filter" 265 | }, 266 | "typeName": "Filter", 267 | "underlyingTypeName": "Filter" 268 | } 269 | ], 270 | "name": "edibles", 271 | "type": { 272 | "kind": "NON_NULL", 273 | "ofType": { 274 | "kind": "LIST", 275 | "ofType": { 276 | "kind": "NON_NULL", 277 | "ofType": { 278 | "kind": "INTERFACE", 279 | "name": "Edible" 280 | } 281 | } 282 | } 283 | }, 284 | "typeName": "[Edible!]!", 285 | "underlyingTypeName": "Edible" 286 | } 287 | ], 288 | "kind": "OBJECT", 289 | "name": "Query" 290 | } 291 | ], 292 | "queryTypeName": "Query", 293 | "directives": [ 294 | { 295 | "description": "", 296 | "name": "key", 297 | "arguments": [ 298 | { 299 | "name": "fields", 300 | "type": { 301 | "kind": "NON_NULL", 302 | "ofType": { 303 | "kind": "SCALAR", 304 | "name": "FieldSet" 305 | } 306 | }, 307 | "typeName": "FieldSet!", 308 | "underlyingTypeName": "FieldSet" 309 | }, 310 | { 311 | "defaultValue": true, 312 | "name": "resolvable", 313 | "type": { 314 | "kind": "SCALAR", 315 | "name": "Boolean" 316 | }, 317 | "typeName": "Boolean", 318 | "underlyingTypeName": "Boolean" 319 | } 320 | ], 321 | "locations": [ 322 | "OBJECT", 323 | "INTERFACE" 324 | ], 325 | "repeatable": true 326 | } 327 | ] 328 | } -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/json_everything/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["json", "testdata/in.graphql"] 2 | expectJson: true -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_directives/expected.txt: -------------------------------------------------------------------------------- 1 | @key(fields: FieldSet!, resolvable: Boolean) on OBJECT | INTERFACE 2 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_directives/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["ls", "directives", "testdata/in.graphql"] 2 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_fields/expected.txt: -------------------------------------------------------------------------------- 1 | Apple.calories: Int 2 | Apple.measurements: Measurements 3 | Apple.variety: AppleVariety 4 | Biscuit.calories: Int 5 | Edible.calories: Int 6 | Filter.limit: Int 7 | Filter.nameLike: String 8 | Measurements.depth: Int 9 | Measurements.height: Int 10 | Measurements.width: Int 11 | Orange.calories: Int 12 | Orange.variety: OrangeVariety 13 | Query.edible: Edible 14 | Query.edibles: [Edible!]! 15 | Query.fruit: Fruit 16 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_fields/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["ls", "fields", "testdata/in.graphql"] 2 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_fields_include_args/expected.txt: -------------------------------------------------------------------------------- 1 | Query.edible(name: String): Edible 2 | Query.edibles(filter: Filter): [Edible!]! 3 | Query.fruit(name: String): Fruit 4 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_fields_include_args/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["ls", "fields", "--on-type", "Query", "--include-args", "testdata/in.graphql"] 2 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_fields_json/expected.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Apple.calories", 4 | "type": { 5 | "kind": "SCALAR", 6 | "name": "Int" 7 | }, 8 | "typeName": "Int", 9 | "underlyingTypeName": "Int" 10 | }, 11 | { 12 | "name": "Apple.measurements", 13 | "type": { 14 | "kind": "OBJECT", 15 | "name": "Measurements" 16 | }, 17 | "typeName": "Measurements", 18 | "underlyingTypeName": "Measurements" 19 | }, 20 | { 21 | "name": "Apple.variety", 22 | "type": { 23 | "kind": "ENUM", 24 | "name": "AppleVariety" 25 | }, 26 | "typeName": "AppleVariety", 27 | "underlyingTypeName": "AppleVariety" 28 | }, 29 | { 30 | "name": "Biscuit.calories", 31 | "type": { 32 | "kind": "SCALAR", 33 | "name": "Int" 34 | }, 35 | "typeName": "Int", 36 | "underlyingTypeName": "Int" 37 | }, 38 | { 39 | "name": "Edible.calories", 40 | "type": { 41 | "kind": "SCALAR", 42 | "name": "Int" 43 | }, 44 | "typeName": "Int", 45 | "underlyingTypeName": "Int" 46 | }, 47 | { 48 | "name": "Filter.limit", 49 | "type": { 50 | "kind": "SCALAR", 51 | "name": "Int" 52 | }, 53 | "typeName": "Int", 54 | "underlyingTypeName": "Int" 55 | }, 56 | { 57 | "name": "Filter.nameLike", 58 | "type": { 59 | "kind": "SCALAR", 60 | "name": "String" 61 | }, 62 | "typeName": "String", 63 | "underlyingTypeName": "String" 64 | }, 65 | { 66 | "name": "Measurements.depth", 67 | "type": { 68 | "kind": "SCALAR", 69 | "name": "Int" 70 | }, 71 | "typeName": "Int", 72 | "underlyingTypeName": "Int" 73 | }, 74 | { 75 | "name": "Measurements.height", 76 | "type": { 77 | "kind": "SCALAR", 78 | "name": "Int" 79 | }, 80 | "typeName": "Int", 81 | "underlyingTypeName": "Int" 82 | }, 83 | { 84 | "name": "Measurements.width", 85 | "type": { 86 | "kind": "SCALAR", 87 | "name": "Int" 88 | }, 89 | "typeName": "Int", 90 | "underlyingTypeName": "Int" 91 | }, 92 | { 93 | "name": "Orange.calories", 94 | "type": { 95 | "kind": "SCALAR", 96 | "name": "Int" 97 | }, 98 | "typeName": "Int", 99 | "underlyingTypeName": "Int" 100 | }, 101 | { 102 | "name": "Orange.variety", 103 | "type": { 104 | "kind": "ENUM", 105 | "name": "OrangeVariety" 106 | }, 107 | "typeName": "OrangeVariety", 108 | "underlyingTypeName": "OrangeVariety" 109 | }, 110 | { 111 | "arguments": [ 112 | { 113 | "name": "name", 114 | "type": { 115 | "kind": "SCALAR", 116 | "name": "String" 117 | }, 118 | "typeName": "String", 119 | "underlyingTypeName": "String" 120 | } 121 | ], 122 | "name": "Query.edible", 123 | "type": { 124 | "kind": "INTERFACE", 125 | "name": "Edible" 126 | }, 127 | "typeName": "Edible", 128 | "underlyingTypeName": "Edible" 129 | }, 130 | { 131 | "arguments": [ 132 | { 133 | "name": "filter", 134 | "type": { 135 | "kind": "INPUT_OBJECT", 136 | "name": "Filter" 137 | }, 138 | "typeName": "Filter", 139 | "underlyingTypeName": "Filter" 140 | } 141 | ], 142 | "name": "Query.edibles", 143 | "type": { 144 | "kind": "NON_NULL", 145 | "ofType": { 146 | "kind": "LIST", 147 | "ofType": { 148 | "kind": "NON_NULL", 149 | "ofType": { 150 | "kind": "INTERFACE", 151 | "name": "Edible" 152 | } 153 | } 154 | } 155 | }, 156 | "typeName": "[Edible!]!", 157 | "underlyingTypeName": "Edible" 158 | }, 159 | { 160 | "arguments": [ 161 | { 162 | "name": "name", 163 | "type": { 164 | "kind": "SCALAR", 165 | "name": "String" 166 | }, 167 | "typeName": "String", 168 | "underlyingTypeName": "String" 169 | } 170 | ], 171 | "name": "Query.fruit", 172 | "type": { 173 | "kind": "UNION", 174 | "name": "Fruit" 175 | }, 176 | "typeName": "Fruit", 177 | "underlyingTypeName": "Fruit" 178 | } 179 | ] -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_fields_json/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["ls", "fields", "--json", "testdata/in.graphql"] 2 | expectJson: true -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_fields_named/expected.txt: -------------------------------------------------------------------------------- 1 | Apple.calories: Int 2 | Biscuit.calories: Int 3 | Edible.calories: Int 4 | Orange.calories: Int 5 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_fields_named/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["ls", "fields", "--named", "calories", "testdata/in.graphql"] 2 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_fields_of_type/expected.txt: -------------------------------------------------------------------------------- 1 | Apple.calories: Int 2 | Biscuit.calories: Int 3 | Edible.calories: Int 4 | Filter.limit: Int 5 | Measurements.depth: Int 6 | Measurements.height: Int 7 | Measurements.width: Int 8 | Orange.calories: Int 9 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_fields_of_type/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["ls", "fields", "--of-type", "Int", "testdata/in.graphql"] -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_fields_on_type/expected.txt: -------------------------------------------------------------------------------- 1 | Query.edible: Edible 2 | Query.edibles: [Edible!]! 3 | Query.fruit: Fruit 4 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_fields_on_type/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["ls", "fields", "--on-type", "Query", "testdata/in.graphql"] 2 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_fields_returning_type/expected.txt: -------------------------------------------------------------------------------- 1 | Query.edible: Edible 2 | Query.edibles: [Edible!]! 3 | Query.fruit: Fruit 4 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_fields_returning_type/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["ls", "fields", "--returning-type", "Apple", "testdata/in.graphql"] 2 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types/expected.txt: -------------------------------------------------------------------------------- 1 | OBJECT Apple 2 | ENUM AppleVariety 3 | OBJECT Biscuit 4 | INTERFACE Edible 5 | SCALAR FieldSet 6 | INPUT_OBJECT Filter 7 | UNION Fruit 8 | OBJECT Measurements 9 | OBJECT Orange 10 | ENUM OrangeVariety 11 | OBJECT Query 12 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["ls", "types", "testdata/in.graphql"] 2 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types_from/expected.txt: -------------------------------------------------------------------------------- 1 | OBJECT Apple 2 | ENUM AppleVariety 3 | OBJECT Measurements 4 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types_from/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["ls", "types", "--from", "Apple", "testdata/in.graphql"] 2 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types_from_depth/expected.txt: -------------------------------------------------------------------------------- 1 | UNION Fruit 2 | OBJECT Query 3 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types_from_depth/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["ls", "types", "--from", "Query.fruit", "--depth", "2", "testdata/in.graphql"] 2 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types_implements/expected.txt: -------------------------------------------------------------------------------- 1 | OBJECT Apple 2 | OBJECT Biscuit 3 | OBJECT Orange 4 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types_implements/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["ls", "types", "--implements", "Edible", "testdata/in.graphql"] 2 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types_include_directives/expected.txt: -------------------------------------------------------------------------------- 1 | OBJECT Apple @key(fields: ["variety"]) 2 | ENUM AppleVariety 3 | OBJECT Biscuit 4 | INTERFACE Edible 5 | SCALAR FieldSet 6 | INPUT_OBJECT Filter 7 | UNION Fruit 8 | OBJECT Measurements 9 | OBJECT Orange 10 | ENUM OrangeVariety 11 | OBJECT Query 12 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types_include_directives/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["ls", "types", "--include-directives", "testdata/in.graphql"] -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types_json/expected.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "directives": [ 4 | { 5 | "name": "key", 6 | "arguments": [ 7 | { 8 | "name": "fields", 9 | "value": [ 10 | "variety" 11 | ] 12 | } 13 | ] 14 | } 15 | ], 16 | "fields": [ 17 | { 18 | "name": "variety", 19 | "type": { 20 | "kind": "ENUM", 21 | "name": "AppleVariety" 22 | }, 23 | "typeName": "AppleVariety", 24 | "underlyingTypeName": "AppleVariety" 25 | }, 26 | { 27 | "name": "measurements", 28 | "type": { 29 | "kind": "OBJECT", 30 | "name": "Measurements" 31 | }, 32 | "typeName": "Measurements", 33 | "underlyingTypeName": "Measurements" 34 | }, 35 | { 36 | "name": "calories", 37 | "type": { 38 | "kind": "SCALAR", 39 | "name": "Int" 40 | }, 41 | "typeName": "Int", 42 | "underlyingTypeName": "Int" 43 | } 44 | ], 45 | "interfaces": [ 46 | "Edible" 47 | ], 48 | "kind": "OBJECT", 49 | "name": "Apple" 50 | }, 51 | { 52 | "enumValues": [ 53 | { 54 | "name": "FUJI" 55 | }, 56 | { 57 | "name": "COSMIC_CRISP" 58 | }, 59 | { 60 | "name": "GRANNY_SMITH" 61 | } 62 | ], 63 | "kind": "ENUM", 64 | "name": "AppleVariety" 65 | }, 66 | { 67 | "fields": [ 68 | { 69 | "name": "calories", 70 | "type": { 71 | "kind": "SCALAR", 72 | "name": "Int" 73 | }, 74 | "typeName": "Int", 75 | "underlyingTypeName": "Int" 76 | } 77 | ], 78 | "interfaces": [ 79 | "Edible" 80 | ], 81 | "kind": "OBJECT", 82 | "name": "Biscuit" 83 | }, 84 | { 85 | "fields": [ 86 | { 87 | "name": "calories", 88 | "type": { 89 | "kind": "SCALAR", 90 | "name": "Int" 91 | }, 92 | "typeName": "Int", 93 | "underlyingTypeName": "Int" 94 | } 95 | ], 96 | "kind": "INTERFACE", 97 | "name": "Edible", 98 | "possibleTypeNames": [ 99 | "Apple", 100 | "Orange", 101 | "Biscuit" 102 | ] 103 | }, 104 | { 105 | "kind": "SCALAR", 106 | "name": "FieldSet" 107 | }, 108 | { 109 | "inputFields": [ 110 | { 111 | "name": "nameLike", 112 | "type": { 113 | "kind": "SCALAR", 114 | "name": "String" 115 | }, 116 | "typeName": "String", 117 | "underlyingTypeName": "String" 118 | }, 119 | { 120 | "name": "limit", 121 | "type": { 122 | "kind": "SCALAR", 123 | "name": "Int" 124 | }, 125 | "typeName": "Int", 126 | "underlyingTypeName": "Int" 127 | } 128 | ], 129 | "kind": "INPUT_OBJECT", 130 | "name": "Filter" 131 | }, 132 | { 133 | "kind": "UNION", 134 | "name": "Fruit", 135 | "possibleTypeNames": [ 136 | "Apple", 137 | "Orange" 138 | ] 139 | }, 140 | { 141 | "fields": [ 142 | { 143 | "name": "height", 144 | "type": { 145 | "kind": "SCALAR", 146 | "name": "Int" 147 | }, 148 | "typeName": "Int", 149 | "underlyingTypeName": "Int" 150 | }, 151 | { 152 | "name": "width", 153 | "type": { 154 | "kind": "SCALAR", 155 | "name": "Int" 156 | }, 157 | "typeName": "Int", 158 | "underlyingTypeName": "Int" 159 | }, 160 | { 161 | "name": "depth", 162 | "type": { 163 | "kind": "SCALAR", 164 | "name": "Int" 165 | }, 166 | "typeName": "Int", 167 | "underlyingTypeName": "Int" 168 | } 169 | ], 170 | "kind": "OBJECT", 171 | "name": "Measurements" 172 | }, 173 | { 174 | "fields": [ 175 | { 176 | "name": "variety", 177 | "type": { 178 | "kind": "ENUM", 179 | "name": "OrangeVariety" 180 | }, 181 | "typeName": "OrangeVariety", 182 | "underlyingTypeName": "OrangeVariety" 183 | }, 184 | { 185 | "name": "calories", 186 | "type": { 187 | "kind": "SCALAR", 188 | "name": "Int" 189 | }, 190 | "typeName": "Int", 191 | "underlyingTypeName": "Int" 192 | } 193 | ], 194 | "interfaces": [ 195 | "Edible" 196 | ], 197 | "kind": "OBJECT", 198 | "name": "Orange" 199 | }, 200 | { 201 | "enumValues": [ 202 | { 203 | "name": "VALENCIA" 204 | }, 205 | { 206 | "name": "NAVEL" 207 | }, 208 | { 209 | "name": "CARA_CARA" 210 | } 211 | ], 212 | "kind": "ENUM", 213 | "name": "OrangeVariety" 214 | }, 215 | { 216 | "fields": [ 217 | { 218 | "arguments": [ 219 | { 220 | "name": "name", 221 | "type": { 222 | "kind": "SCALAR", 223 | "name": "String" 224 | }, 225 | "typeName": "String", 226 | "underlyingTypeName": "String" 227 | } 228 | ], 229 | "name": "fruit", 230 | "type": { 231 | "kind": "UNION", 232 | "name": "Fruit" 233 | }, 234 | "typeName": "Fruit", 235 | "underlyingTypeName": "Fruit" 236 | }, 237 | { 238 | "arguments": [ 239 | { 240 | "name": "name", 241 | "type": { 242 | "kind": "SCALAR", 243 | "name": "String" 244 | }, 245 | "typeName": "String", 246 | "underlyingTypeName": "String" 247 | } 248 | ], 249 | "name": "edible", 250 | "type": { 251 | "kind": "INTERFACE", 252 | "name": "Edible" 253 | }, 254 | "typeName": "Edible", 255 | "underlyingTypeName": "Edible" 256 | }, 257 | { 258 | "arguments": [ 259 | { 260 | "name": "filter", 261 | "type": { 262 | "kind": "INPUT_OBJECT", 263 | "name": "Filter" 264 | }, 265 | "typeName": "Filter", 266 | "underlyingTypeName": "Filter" 267 | } 268 | ], 269 | "name": "edibles", 270 | "type": { 271 | "kind": "NON_NULL", 272 | "ofType": { 273 | "kind": "LIST", 274 | "ofType": { 275 | "kind": "NON_NULL", 276 | "ofType": { 277 | "kind": "INTERFACE", 278 | "name": "Edible" 279 | } 280 | } 281 | } 282 | }, 283 | "typeName": "[Edible!]!", 284 | "underlyingTypeName": "Edible" 285 | } 286 | ], 287 | "kind": "OBJECT", 288 | "name": "Query" 289 | } 290 | ] -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types_json/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["ls", "types", "--json", "testdata/in.graphql"] 2 | expectJson: true -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types_member_of/expected.txt: -------------------------------------------------------------------------------- 1 | OBJECT Apple 2 | OBJECT Orange 3 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types_member_of/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["ls", "types", "--member-of", "Fruit", "testdata/in.graphql"] 2 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types_multiple_files/expected.txt: -------------------------------------------------------------------------------- 1 | OBJECT Apple 2 | ENUM AppleVariety 3 | OBJECT Banana 4 | OBJECT Biscuit 5 | INTERFACE Edible 6 | SCALAR FieldSet 7 | INPUT_OBJECT Filter 8 | UNION Fruit 9 | OBJECT Measurements 10 | OBJECT Orange 11 | ENUM OrangeVariety 12 | OBJECT Query 13 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types_multiple_files/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["ls", "types", "testdata/in.graphql", "testdata/other.graphql"] 2 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types_with_kind/expected.txt: -------------------------------------------------------------------------------- 1 | Apple 2 | Biscuit 3 | Measurements 4 | Orange 5 | Query 6 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/ls_types_with_kind/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["ls", "types", "--kind", "object", "testdata/in.graphql"] 2 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/viz/expected.txt: -------------------------------------------------------------------------------- 1 | digraph { 2 | rankdir=LR 3 | ranksep=2 4 | node [shape=box fontname=Courier] 5 | n_Apple [shape=plain, label=< 6 | 7 | 8 | 9 | 10 | 11 |
object Apple
varietyAppleVariety
measurementsMeasurements
caloriesInt
>] 12 | n_AppleVariety [shape=plain, label=< 13 | \n \n \n
enum AppleVariety
FUJI
COSMIC_CRISP
GRANNY_SMITH
>] 14 | n_Biscuit [shape=plain, label=< 15 | 16 | 17 | 18 |
object Biscuit
caloriesInt
>] 19 | n_Edible [shape=plain, label=< 20 | 21 | 22 | 23 |
interface Edible
caloriesInt
>] 24 | n_Filter [shape=plain, label=< 25 | 26 | 27 | 28 |
input Filter
nameLikeString
limitInt
>] 29 | n_Fruit [shape=plain, label=< 30 | \n \n
union Fruit
Apple
Orange
>] 31 | n_Measurements [shape=plain, label=< 32 | 33 | 34 | 35 | 36 | 37 |
object Measurements
heightInt
widthInt
depthInt
>] 38 | n_Orange [shape=plain, label=< 39 | 40 | 41 | 42 | 43 |
object Orange
varietyOrangeVariety
caloriesInt
>] 44 | n_OrangeVariety [shape=plain, label=< 45 | \n \n \n
enum OrangeVariety
VALENCIA
NAVEL
CARA_CARA
>] 46 | n_Query [shape=plain, label=< 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |
object Query
fruitFruit
nameString
edibleEdible
nameString
edibles[Edible!]!
filterFilter
>] 56 | n_Apple:p_variety -> n_AppleVariety:main 57 | n_Apple:p_measurements -> n_Measurements:main 58 | n_Fruit:p_Apple -> n_Apple:main 59 | n_Fruit:p_Orange -> n_Orange:main 60 | n_Orange:p_variety -> n_OrangeVariety:main 61 | n_Query:p_fruit -> n_Fruit:main 62 | n_Query:p_edible -> n_Edible:main 63 | n_Query:p_edibles -> n_Edible:main 64 | n_Query:p_edibles_filter -> n_Filter:main 65 | } 66 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/viz/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["viz", "testdata/in.graphql"] -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/viz_from/expected.txt: -------------------------------------------------------------------------------- 1 | digraph { 2 | rankdir=LR 3 | ranksep=2 4 | node [shape=box fontname=Courier] 5 | n_Apple [shape=plain, label=< 6 | 7 | 8 | 9 | 10 | 11 |
object Apple
varietyAppleVariety
measurementsMeasurements
caloriesInt
>] 12 | n_AppleVariety [shape=plain, label=< 13 | \n \n \n
enum AppleVariety
FUJI
COSMIC_CRISP
GRANNY_SMITH
>] 14 | n_Fruit [shape=plain, label=< 15 | \n \n
union Fruit
Apple
Orange
>] 16 | n_Measurements [shape=plain, label=< 17 | 18 | 19 | 20 | 21 | 22 |
object Measurements
heightInt
widthInt
depthInt
>] 23 | n_Orange [shape=plain, label=< 24 | 25 | 26 | 27 | 28 |
object Orange
varietyOrangeVariety
caloriesInt
>] 29 | n_OrangeVariety [shape=plain, label=< 30 | \n \n \n
enum OrangeVariety
VALENCIA
NAVEL
CARA_CARA
>] 31 | n_Query [shape=plain, label=< 32 | 33 | 34 | 35 | 36 |
object Query
fruitFruit
nameString
>] 37 | n_Apple:p_variety -> n_AppleVariety:main 38 | n_Apple:p_measurements -> n_Measurements:main 39 | n_Fruit:p_Apple -> n_Apple:main 40 | n_Fruit:p_Orange -> n_Orange:main 41 | n_Orange:p_variety -> n_OrangeVariety:main 42 | n_Query:p_fruit -> n_Fruit:main 43 | } 44 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/viz_from/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["viz", "--from", "Query.fruit", "testdata/in.graphql"] -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/viz_interfaces_as_unions/expected.txt: -------------------------------------------------------------------------------- 1 | digraph { 2 | rankdir=LR 3 | ranksep=2 4 | node [shape=box fontname=Courier] 5 | n_Apple [shape=plain, label=< 6 | 7 | 8 | 9 | 10 | 11 |
object Apple
varietyAppleVariety
measurementsMeasurements
caloriesInt
>] 12 | n_AppleVariety [shape=plain, label=< 13 | \n \n \n
enum AppleVariety
FUJI
COSMIC_CRISP
GRANNY_SMITH
>] 14 | n_Biscuit [shape=plain, label=< 15 | 16 | 17 | 18 |
object Biscuit
caloriesInt
>] 19 | n_Edible [shape=plain, label=< 20 | \n \n \n
interface Edible
Apple
Orange
Biscuit
>] 21 | n_Filter [shape=plain, label=< 22 | 23 | 24 | 25 |
input Filter
nameLikeString
limitInt
>] 26 | n_Fruit [shape=plain, label=< 27 | \n \n
union Fruit
Apple
Orange
>] 28 | n_Measurements [shape=plain, label=< 29 | 30 | 31 | 32 | 33 | 34 |
object Measurements
heightInt
widthInt
depthInt
>] 35 | n_Orange [shape=plain, label=< 36 | 37 | 38 | 39 | 40 |
object Orange
varietyOrangeVariety
caloriesInt
>] 41 | n_OrangeVariety [shape=plain, label=< 42 | \n \n \n
enum OrangeVariety
VALENCIA
NAVEL
CARA_CARA
>] 43 | n_Query [shape=plain, label=< 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 |
object Query
fruitFruit
nameString
edibleEdible
nameString
edibles[Edible!]!
filterFilter
>] 53 | n_Apple:p_variety -> n_AppleVariety:main 54 | n_Apple:p_measurements -> n_Measurements:main 55 | n_Edible:p_Apple -> n_Apple:main 56 | n_Edible:p_Orange -> n_Orange:main 57 | n_Edible:p_Biscuit -> n_Biscuit:main 58 | n_Fruit:p_Apple -> n_Apple:main 59 | n_Fruit:p_Orange -> n_Orange:main 60 | n_Orange:p_variety -> n_OrangeVariety:main 61 | n_Query:p_fruit -> n_Fruit:main 62 | n_Query:p_edible -> n_Edible:main 63 | n_Query:p_edibles -> n_Edible:main 64 | n_Query:p_edibles_filter -> n_Filter:main 65 | } 66 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/viz_interfaces_as_unions/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["viz", "--interfaces-as-unions", "testdata/in.graphql"] -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/viz_selfref/expected.txt: -------------------------------------------------------------------------------- 1 | digraph { 2 | rankdir=LR 3 | ranksep=2 4 | node [shape=box fontname=Courier] 5 | n_Person [shape=plain, label=< 6 | 7 | 8 | 9 |
object Person
friends[Person]
>] 10 | n_Person:p_friends:e -> n_Person:main:e 11 | } 12 | -------------------------------------------------------------------------------- /pkg/commands/testdata/cases/viz_selfref/meta.yaml: -------------------------------------------------------------------------------- 1 | args: ["viz", "testdata/selfref.graphql"] 2 | -------------------------------------------------------------------------------- /pkg/commands/testdata/headers.txt: -------------------------------------------------------------------------------- 1 | foo: bar 2 | baz: qux 3 | -------------------------------------------------------------------------------- /pkg/commands/testdata/in.graphql: -------------------------------------------------------------------------------- 1 | scalar FieldSet 2 | 3 | directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE 4 | 5 | type Query { 6 | fruit(name: String): Fruit 7 | edible(name: String): Edible 8 | edibles(filter: Filter): [Edible!]! 9 | } 10 | 11 | input Filter { 12 | nameLike: String 13 | limit: Int 14 | } 15 | 16 | type Apple implements Edible @key(fields: ["variety"]) { 17 | variety: AppleVariety 18 | measurements: Measurements 19 | calories: Int 20 | } 21 | 22 | type Orange implements Edible { 23 | variety: OrangeVariety 24 | calories: Int 25 | } 26 | 27 | type Biscuit implements Edible { 28 | calories: Int 29 | } 30 | 31 | type Measurements { 32 | height: Int 33 | width: Int 34 | depth: Int 35 | } 36 | 37 | interface Edible { 38 | calories: Int 39 | } 40 | 41 | union Fruit = Apple | Orange 42 | 43 | enum AppleVariety { 44 | FUJI 45 | COSMIC_CRISP 46 | GRANNY_SMITH 47 | } 48 | 49 | enum OrangeVariety { 50 | VALENCIA 51 | NAVEL 52 | CARA_CARA 53 | } -------------------------------------------------------------------------------- /pkg/commands/testdata/other.graphql: -------------------------------------------------------------------------------- 1 | type Banana implements Edible { 2 | calories: Int 3 | } 4 | -------------------------------------------------------------------------------- /pkg/commands/testdata/selfref.graphql: -------------------------------------------------------------------------------- 1 | type Person { 2 | friends: [Person] 3 | } 4 | -------------------------------------------------------------------------------- /pkg/commands/version.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import "github.com/alecthomas/kong" 4 | 5 | // version string will be injected by automation 6 | // see .goreleaser.yaml 7 | var version string = "unknown" 8 | 9 | type versionFlag bool 10 | 11 | func (f versionFlag) BeforeReset(ctx *kong.Context) error { 12 | _, _ = ctx.Stdout.Write([]byte(version + "\n")) 13 | ctx.Kong.Exit(0) 14 | return nil 15 | } 16 | 17 | type VersionCmd struct { 18 | } 19 | 20 | func (c VersionCmd) Run(ctx Context) error { 21 | ctx.Print(version + "\n") 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /pkg/commands/viz.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/benweint/gquil/pkg/graph" 5 | ) 6 | 7 | type VizCmd struct { 8 | InputOptions 9 | FilteringOptions 10 | GraphFilteringOptions 11 | InterfacesAsUnions bool `name:"interfaces-as-unions" help:"Treat interfaces as unions rather than objects for the purposes of graph construction."` 12 | } 13 | 14 | func (c *VizCmd) Help() string { 15 | return `To render the resulting graph to a PDF, you can use the 'dot' tool that comes with GraphViz: 16 | 17 | gquil viz schema.graphql | dot -Tpdf >out.pdf 18 | 19 | For GraphQL schemas with a large number of types and fields, the resulting diagram may be very large. You can trim it down to a particular region of interest using the --from and --depth flags to indicate a starting point and maximum traversal depth to use when traversing the graph. 20 | 21 | gquil viz --from Reviews --depth 2 schema.graphql | dot -Tpdf >out.pdf 22 | 23 | GraphQL unions are represented as nodes in the graph with outbound edges to each member type. Interfaces are represented in the same way as object types by default, with one outbound edge per field, pointing to the type of that field. To instead render interfaces with one outbound edge per implementing type, you can use the --interfaces-as-unions flag.` 24 | } 25 | 26 | func (c *VizCmd) Run(ctx Context) error { 27 | s, err := loadSchemaModel(c.SchemaFiles) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | var opts []graph.GraphOption 33 | if c.InterfacesAsUnions { 34 | opts = append(opts, graph.WithInterfacesAsUnions()) 35 | } 36 | 37 | if c.IncludeBuiltins { 38 | opts = append(opts, graph.WithBuiltins(true)) 39 | } 40 | 41 | g := graph.MakeGraph(s, opts...) 42 | 43 | if len(c.From) > 0 { 44 | roots, err := s.ResolveNames(c.From) 45 | if err != nil { 46 | return err 47 | } 48 | g = g.ReachableFrom(roots, c.Depth) 49 | } 50 | 51 | ctx.Print(g.ToDot()) 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /pkg/graph/edge.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import "github.com/benweint/gquil/pkg/model" 4 | 5 | type edgeKind int 6 | 7 | const ( 8 | edgeKindField edgeKind = iota 9 | edgeKindArgument 10 | edgeKindPossibleType 11 | ) 12 | 13 | type edge struct { 14 | kind edgeKind 15 | src *model.Definition 16 | dst *model.Definition 17 | field *model.FieldDefinition // only set for fields representing eges or arguments 18 | argument *model.ArgumentDefinition // only set for fields representing arguments 19 | } 20 | -------------------------------------------------------------------------------- /pkg/graph/graph.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | 8 | "github.com/benweint/gquil/pkg/astutil" 9 | "github.com/benweint/gquil/pkg/model" 10 | "github.com/vektah/gqlparser/v2/ast" 11 | ) 12 | 13 | type Graph struct { 14 | nodes model.DefinitionMap 15 | edges map[string][]*edge 16 | interfacesAsUnions bool 17 | renderBuiltins bool 18 | } 19 | 20 | func normalizeKind(kind ast.DefinitionKind, interfacesAsUnions bool) ast.DefinitionKind { 21 | if kind == ast.Interface { 22 | if interfacesAsUnions { 23 | return ast.Union 24 | } 25 | return ast.Object 26 | } 27 | return kind 28 | } 29 | 30 | type GraphOption func(g *Graph) 31 | 32 | func WithInterfacesAsUnions() GraphOption { 33 | return func(g *Graph) { 34 | g.interfacesAsUnions = true 35 | } 36 | } 37 | 38 | func WithBuiltins(renderBuiltins bool) GraphOption { 39 | return func(g *Graph) { 40 | g.renderBuiltins = renderBuiltins 41 | } 42 | } 43 | 44 | func MakeGraph(s *model.Schema, opts ...GraphOption) *Graph { 45 | g := &Graph{ 46 | nodes: s.Types, 47 | edges: map[string][]*edge{}, 48 | } 49 | 50 | for _, opt := range opts { 51 | opt(g) 52 | } 53 | 54 | for _, t := range s.Types { 55 | var typeEdges []*edge 56 | kind := normalizeKind(t.Kind, g.interfacesAsUnions) 57 | switch kind { 58 | case ast.Object, ast.InputObject: 59 | typeEdges = g.makeFieldEdges(t) 60 | case ast.Union: 61 | typeEdges = g.makeUnionEdges(t) 62 | } 63 | g.edges[t.Name] = typeEdges 64 | } 65 | 66 | return g 67 | } 68 | 69 | func (g *Graph) makeFieldEdges(t *model.Definition) []*edge { 70 | var result []*edge 71 | for _, f := range t.Fields { 72 | fieldEdge := g.makeFieldEdge(t, f.Type.Unwrap(), f, nil) 73 | if fieldEdge == nil { 74 | continue 75 | } 76 | result = append(result, fieldEdge) 77 | for _, arg := range f.Arguments { 78 | argEdge := g.makeFieldEdge(t, arg.Type.Unwrap(), f, arg) 79 | if argEdge == nil { 80 | continue 81 | } 82 | result = append(result, argEdge) 83 | } 84 | } 85 | return result 86 | } 87 | 88 | func (g *Graph) makeUnionEdges(t *model.Definition) []*edge { 89 | var result []*edge 90 | for _, possibleType := range t.PossibleTypes { 91 | srcNode := g.nodes[t.Name] 92 | dstNode := g.nodes[possibleType] 93 | if srcNode == nil || dstNode == nil { 94 | continue 95 | } 96 | result = append(result, &edge{ 97 | src: srcNode, 98 | dst: dstNode, 99 | kind: edgeKindPossibleType, 100 | }) 101 | } 102 | return result 103 | } 104 | 105 | func (g *Graph) makeFieldEdge(src *model.Definition, targetType *model.Type, f *model.FieldDefinition, arg *model.ArgumentDefinition) *edge { 106 | kind := edgeKindField 107 | 108 | if arg != nil { 109 | targetType = arg.Type.Unwrap() 110 | kind = edgeKindArgument 111 | } 112 | 113 | srcNode := g.nodes[src.Name] 114 | dstNode := g.nodes[targetType.Name] 115 | if srcNode == nil || dstNode == nil { 116 | return nil 117 | } 118 | return &edge{ 119 | src: srcNode, 120 | dst: dstNode, 121 | kind: kind, 122 | field: f, 123 | argument: arg, 124 | } 125 | } 126 | 127 | func (g *Graph) GetDefinitions() model.DefinitionMap { 128 | return g.nodes 129 | } 130 | 131 | func (g *Graph) ReachableFrom(roots []*model.NameReference, maxDepth int) *Graph { 132 | seen := referenceSet{} 133 | 134 | var traverse func(n *model.Definition, depth int) 135 | 136 | traverseField := func(typeName string, f *model.FieldDefinition, depth int) { 137 | key := model.FieldNameReference(typeName, f.Name) 138 | if seen[key] { 139 | return 140 | } 141 | seen[key] = true 142 | 143 | if maxDepth > 0 && depth > maxDepth { 144 | return 145 | } 146 | 147 | for _, arg := range f.Arguments { 148 | argType := arg.Type.Unwrap() 149 | traverse(g.nodes[argType.Name], depth+1) 150 | } 151 | 152 | underlyingType := f.Type.Unwrap() 153 | traverse(g.nodes[underlyingType.Name], depth+1) 154 | } 155 | 156 | traverse = func(n *model.Definition, depth int) { 157 | if maxDepth > 0 && depth > maxDepth { 158 | return 159 | } 160 | key := model.TypeNameReference(n.Name) 161 | if _, ok := seen[key]; ok { 162 | return 163 | } 164 | 165 | seen[key] = true 166 | 167 | for _, e := range g.edges[n.Name] { 168 | switch e.kind { 169 | case edgeKindField, edgeKindArgument: 170 | traverseField(n.Name, e.field, depth) 171 | case edgeKindPossibleType: 172 | traverse(e.dst, depth+1) 173 | } 174 | } 175 | } 176 | 177 | for _, root := range roots { 178 | targetType := g.nodes[root.TypeName] 179 | if root.FieldName != "" { 180 | traverseField(targetType.Name, targetType.Fields.Named(root.FieldName), 1) 181 | } else { 182 | traverse(targetType, 1) 183 | } 184 | } 185 | 186 | filteredNodes := model.DefinitionMap{} 187 | for name, node := range g.nodes { 188 | if seen.includesType(name) { 189 | filteredNodes[name] = seen.filterFields(node) 190 | } 191 | } 192 | 193 | filteredEdges := map[string][]*edge{} 194 | for from, edges := range g.edges { 195 | var filtered []*edge 196 | for _, edge := range edges { 197 | _, srcPresent := filteredNodes[edge.src.Name] 198 | _, dstPresent := filteredNodes[edge.dst.Name] 199 | fieldPresent := edge.field == nil || seen.includesField(edge.src.Name, edge.field.Name) 200 | if srcPresent && dstPresent && fieldPresent { 201 | filtered = append(filtered, edge) 202 | } 203 | } 204 | if len(filtered) > 0 { 205 | filteredEdges[from] = filtered 206 | } 207 | } 208 | 209 | return &Graph{ 210 | nodes: filteredNodes, 211 | edges: filteredEdges, 212 | interfacesAsUnions: g.interfacesAsUnions, 213 | renderBuiltins: g.renderBuiltins, 214 | } 215 | } 216 | 217 | func (g *Graph) ToDot() string { 218 | nodeDefs := g.buildNodeDefs() 219 | edgeDefs := g.buildEdgeDefs() 220 | return "digraph {\n rankdir=LR\n ranksep=2\n node [shape=box fontname=Courier]\n" + strings.Join(nodeDefs, "\n") + "\n" + strings.Join(edgeDefs, "\n") + "\n}\n" 221 | } 222 | 223 | func (g *Graph) buildNodeDefs() []string { 224 | var result []string 225 | for _, name := range sortedKeys(g.nodes) { 226 | if astutil.IsBuiltinType(name) && !g.renderBuiltins { 227 | continue 228 | } 229 | node := g.nodes[name] 230 | if node.Kind == ast.Scalar { 231 | continue 232 | } 233 | nodeDef := fmt.Sprintf(" %s [shape=plain, label=<%s>]", nodeID(node), g.makeNodeLabel(node)) 234 | result = append(result, nodeDef) 235 | } 236 | return result 237 | } 238 | 239 | func (g *Graph) buildEdgeDefs() []string { 240 | var result []string 241 | for _, sourceNodeName := range sortedKeys(g.edges) { 242 | edges := g.edges[sourceNodeName] 243 | for _, edge := range edges { 244 | if edge.dst.Kind == ast.Scalar { 245 | continue 246 | } 247 | 248 | if !g.renderBuiltins { 249 | if astutil.IsBuiltinType(edge.src.Name) { 250 | continue 251 | } 252 | if edge.field != nil && astutil.IsBuiltinField(edge.field.Name) { 253 | continue 254 | } 255 | } 256 | 257 | srcPortSuffix := "" 258 | switch edge.kind { 259 | case edgeKindField: 260 | srcPortSuffix = ":" + portName(edge.field.Name) 261 | case edgeKindArgument: 262 | srcPortSuffix = ":" + portNameForArgument(edge.field.Name, edge.argument.Name) 263 | case edgeKindPossibleType: 264 | srcPortSuffix = ":" + portName(edge.dst.Name) 265 | } 266 | 267 | dstPortSuffix := ":main" 268 | 269 | // if the src and dst nodes are the same, we can avoid the edge overlapping 270 | // the field name by forcing it to the right-hand side of the node. 271 | if edge.src.Name == edge.dst.Name { 272 | srcPortSuffix += ":e" 273 | dstPortSuffix += ":e" 274 | } 275 | 276 | result = append(result, fmt.Sprintf(" %s%s -> %s%s", nodeID(edge.src), srcPortSuffix, nodeID(edge.dst), dstPortSuffix)) 277 | } 278 | } 279 | 280 | return result 281 | } 282 | 283 | func sortedKeys[T any](m map[string]T) []string { 284 | var result []string 285 | for k := range m { 286 | result = append(result, k) 287 | } 288 | sort.Strings(result) 289 | return result 290 | } 291 | 292 | func (g *Graph) makeNodeLabel(node *model.Definition) string { 293 | switch normalizeKind(node.Kind, g.interfacesAsUnions) { 294 | case ast.Object: 295 | return g.makeFieldTableNodeLabel(node) 296 | case ast.InputObject: 297 | return makeInputObjectNodeLabel(node) 298 | case ast.Enum: 299 | return makeEnumLabel(node) 300 | case ast.Union: 301 | return makePolymorphicLabel(node) 302 | default: 303 | return makeGenericNodeLabel(node) 304 | } 305 | } 306 | 307 | // From https://colorbrewer2.org/#type=qualitative&scheme=Paired&n=5 308 | func colorForKind(kind ast.DefinitionKind) string { 309 | switch kind { 310 | case ast.Object: 311 | return "#fbb4ae" 312 | case ast.Interface: 313 | return "#b3cde3" 314 | case ast.InputObject: 315 | return "#ccebc5" 316 | case ast.Enum: 317 | return "#decbe4" 318 | case ast.Union: 319 | return "#fed9a6" 320 | default: 321 | return "#ffffff" 322 | } 323 | } 324 | 325 | func nodeID(n *model.Definition) string { 326 | return "n_" + n.Name 327 | } 328 | 329 | func portName(fieldName string) string { 330 | return "p_" + fieldName 331 | } 332 | 333 | func portNameForArgument(fieldName, argName string) string { 334 | return "p_" + fieldName + "_" + argName 335 | } 336 | 337 | func makeEnumLabel(node *model.Definition) string { 338 | result := "\n" 339 | result += fmt.Sprintf(` `, colorForKind(node.Kind), node.Name) 340 | for _, val := range node.EnumValues { 341 | result += fmt.Sprintf(` \n`, val.Name) 342 | } 343 | result += "
enum %s
%s
" 344 | return result 345 | } 346 | 347 | func makePolymorphicLabel(node *model.Definition) string { 348 | result := "\n" 349 | result += fmt.Sprintf(` `, colorForKind(node.Kind), strings.ToLower(string(node.Kind)), node.Name) 350 | for _, possibleType := range node.PossibleTypes { 351 | result += fmt.Sprintf(` \n`, portName(possibleType), possibleType) 352 | } 353 | result += "
%s %s
%s
" 354 | return result 355 | } 356 | 357 | func (g *Graph) makeFieldTableNodeLabel(node *model.Definition) string { 358 | result := "\n" 359 | result += fmt.Sprintf(` `+"\n", colorForKind(node.Kind), strings.ToLower(string(node.Kind)), node.Name) 360 | for _, field := range node.Fields { 361 | if !g.renderBuiltins && astutil.IsBuiltinField(field.Name) { 362 | continue 363 | } 364 | args := field.Arguments 365 | result += fmt.Sprintf(` `+"\n", len(args)+1, field.Name, portName(field.Name), field.Type.String()) 366 | for _, arg := range args { 367 | result += fmt.Sprintf(` `+"\n", arg.Name, portNameForArgument(field.Name, arg.Name), arg.Type) 368 | } 369 | } 370 | result += "\n
%s %s
%s%s
%s%s
" 371 | return result 372 | } 373 | 374 | func makeInputObjectNodeLabel(node *model.Definition) string { 375 | result := "\n" 376 | result += fmt.Sprintf(` `+"\n", colorForKind(node.Kind), node.Name) 377 | for _, field := range node.Fields { 378 | result += fmt.Sprintf(` `+"\n", field.Name, portName(field.Name), field.Type) 379 | } 380 | result += "
input %s
%s%s
" 381 | return result 382 | } 383 | 384 | func makeGenericNodeLabel(node *model.Definition) string { 385 | return fmt.Sprintf("%s\n%s", strings.ToLower(string(node.Kind)), node.Name) 386 | } 387 | -------------------------------------------------------------------------------- /pkg/graph/graph_test.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/benweint/gquil/pkg/model" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/vektah/gqlparser/v2" 12 | "github.com/vektah/gqlparser/v2/ast" 13 | ) 14 | 15 | type edgeSpec struct { 16 | srcType string 17 | dstType string 18 | fieldName string 19 | argName string 20 | } 21 | 22 | func (es edgeSpec) String() string { 23 | return fmt.Sprintf("%s -> %s [%s].%s", es.srcType, es.dstType, es.fieldName, es.argName) 24 | } 25 | 26 | func TestReachableFrom(t *testing.T) { 27 | for _, tc := range []struct { 28 | name string 29 | schema string 30 | roots []string 31 | expectedNodes []string 32 | expectedFields []string 33 | expectedEdges []edgeSpec 34 | maxDepth int 35 | }{ 36 | { 37 | name: "single field root", 38 | schema: `type Query { 39 | alpha: Alpha 40 | beta: Beta 41 | } 42 | 43 | type Alpha { 44 | name: String 45 | } 46 | 47 | type Beta { 48 | name: String 49 | }`, 50 | roots: []string{"Query.alpha"}, 51 | expectedNodes: []string{"Alpha", "Query"}, 52 | expectedFields: []string{"Alpha.name", "Query.alpha"}, 53 | expectedEdges: []edgeSpec{ 54 | { 55 | srcType: "Query", 56 | dstType: "Alpha", 57 | fieldName: "alpha", 58 | }, 59 | }, 60 | }, 61 | { 62 | name: "multiple field roots", 63 | schema: `type Query { 64 | alpha: Alpha 65 | beta: Beta 66 | gaga: Gaga 67 | } 68 | 69 | type Alpha { 70 | name: String 71 | } 72 | 73 | type Beta { 74 | name: String 75 | } 76 | 77 | type Gaga { 78 | name: String 79 | }`, 80 | roots: []string{"Query.alpha", "Query.beta"}, 81 | expectedNodes: []string{"Alpha", "Beta", "Query"}, 82 | expectedFields: []string{"Alpha.name", "Beta.name", "Query.alpha", "Query.beta"}, 83 | expectedEdges: []edgeSpec{ 84 | { 85 | srcType: "Query", 86 | dstType: "Alpha", 87 | fieldName: "alpha", 88 | }, 89 | { 90 | srcType: "Query", 91 | dstType: "Beta", 92 | fieldName: "beta", 93 | }, 94 | }, 95 | }, 96 | { 97 | name: "root field with cycle", 98 | schema: `type Query { 99 | person(name: String): Person 100 | organization(name: String): Organization 101 | } 102 | 103 | type Person { 104 | name: String 105 | friends: [Person] 106 | } 107 | 108 | type Organization { 109 | name: String 110 | }`, 111 | roots: []string{"Person.friends"}, 112 | expectedNodes: []string{"Person"}, 113 | expectedFields: []string{"Person.friends", "Person.name"}, 114 | expectedEdges: []edgeSpec{ 115 | { 116 | srcType: "Person", 117 | dstType: "Person", 118 | fieldName: "friends", 119 | }, 120 | }, 121 | }, 122 | { 123 | name: "unions", 124 | schema: `type Query { 125 | subject(name: String): Subject 126 | events: [Event] 127 | } 128 | 129 | union Subject = Person | Organization 130 | 131 | type Person { 132 | name: String 133 | } 134 | 135 | type Organization { 136 | name: String 137 | } 138 | 139 | type Event { 140 | title: String 141 | }`, 142 | roots: []string{"Query.subject"}, 143 | expectedNodes: []string{"Organization", "Person", "Query", "Subject"}, 144 | expectedFields: []string{"Organization.name", "Person.name", "Query.subject"}, 145 | expectedEdges: []edgeSpec{ 146 | { 147 | srcType: "Query", 148 | dstType: "Subject", 149 | fieldName: "subject", 150 | }, 151 | { 152 | srcType: "Subject", 153 | dstType: "Organization", 154 | }, 155 | { 156 | srcType: "Subject", 157 | dstType: "Person", 158 | }, 159 | }, 160 | }, 161 | { 162 | name: "depth limited", 163 | schema: `type Query { 164 | persons(filter: PersonFilter): [Person] 165 | foods: [Food] 166 | } 167 | 168 | input PersonFilter { 169 | nameLike: String 170 | matchMode: MatchMode 171 | } 172 | 173 | enum MatchMode { 174 | CASE_SENSITIVE 175 | CASE_INSENSITIVE 176 | } 177 | 178 | type Person { 179 | name: String 180 | favoriteFoods: [Food] 181 | } 182 | 183 | type Food { 184 | name: String 185 | }`, 186 | roots: []string{"Query.persons"}, 187 | maxDepth: 2, 188 | expectedNodes: []string{"Person", "PersonFilter", "Query"}, 189 | expectedFields: []string{ 190 | "Person.favoriteFoods", 191 | "Person.name", 192 | "PersonFilter.matchMode", 193 | "PersonFilter.nameLike", 194 | "Query.persons", 195 | }, 196 | expectedEdges: []edgeSpec{ 197 | { 198 | srcType: "Query", 199 | dstType: "Person", 200 | fieldName: "persons", 201 | }, 202 | { 203 | srcType: "Query", 204 | dstType: "PersonFilter", 205 | fieldName: "persons", 206 | argName: "filter", 207 | }, 208 | }, 209 | }, 210 | { 211 | name: "filtered self edges", 212 | schema: `type Person { 213 | hobbies: [Hobby] 214 | friends: [Person] 215 | } 216 | 217 | type Hobby { 218 | name: String 219 | } 220 | `, 221 | roots: []string{"Person.hobbies"}, 222 | maxDepth: 2, 223 | expectedNodes: []string{"Hobby", "Person"}, 224 | expectedFields: []string{"Hobby.name", "Person.hobbies"}, 225 | expectedEdges: []edgeSpec{ 226 | { 227 | srcType: "Person", 228 | dstType: "Hobby", 229 | fieldName: "hobbies", 230 | }, 231 | }, 232 | }, 233 | } { 234 | t.Run(tc.name, func(t *testing.T) { 235 | src := ast.Source{ 236 | Name: "testcase", 237 | Input: tc.schema, 238 | } 239 | rawSchema, err := gqlparser.LoadSchema(&src) 240 | assert.NoError(t, err) 241 | 242 | s, err := model.MakeSchema(rawSchema) 243 | assert.NoError(t, err) 244 | 245 | roots, err := s.ResolveNames(tc.roots) 246 | assert.NoError(t, err) 247 | 248 | g := MakeGraph(s) 249 | trimmed := g.ReachableFrom(roots, tc.maxDepth) 250 | 251 | var actualNodes []string 252 | var actualEdges []edgeSpec 253 | var actualFields []string 254 | 255 | for _, node := range trimmed.nodes { 256 | if node.Kind == ast.Scalar { 257 | continue 258 | } 259 | actualNodes = append(actualNodes, node.Name) 260 | for _, field := range node.Fields { 261 | fieldId := node.Name + "." + field.Name 262 | actualFields = append(actualFields, fieldId) 263 | } 264 | } 265 | 266 | sort.Strings(actualNodes) 267 | sort.Strings(actualFields) 268 | 269 | assert.Equal(t, tc.expectedNodes, actualNodes) 270 | assert.Equal(t, tc.expectedFields, actualFields) 271 | 272 | for _, edges := range trimmed.edges { 273 | for _, edge := range edges { 274 | if edge.dst.Kind == ast.Scalar { 275 | continue 276 | } 277 | fieldName := "" 278 | if edge.field != nil { 279 | fieldName = edge.field.Name 280 | } 281 | argName := "" 282 | if edge.argument != nil { 283 | argName = edge.argument.Name 284 | } 285 | actualEdge := edgeSpec{ 286 | srcType: edge.src.Name, 287 | dstType: edge.dst.Name, 288 | fieldName: fieldName, 289 | argName: argName, 290 | } 291 | actualEdges = append(actualEdges, actualEdge) 292 | } 293 | } 294 | 295 | sort.Slice(actualEdges, func(i, j int) bool { 296 | return strings.Compare(actualEdges[i].String(), actualEdges[j].String()) < 0 297 | }) 298 | 299 | assert.Equal(t, tc.expectedEdges, actualEdges) 300 | }) 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /pkg/graph/reference_set.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import "github.com/benweint/gquil/pkg/model" 4 | 5 | // referenceSet captures the set of types & fields which have been encountered when traversing a GraphQL schema. 6 | type referenceSet map[model.NameReference]bool 7 | 8 | // includesType returns true if the target referenceSet includes at least one field on the given type name, 9 | // or a key representing the entire type. 10 | func (s referenceSet) includesType(name string) bool { 11 | for key := range s { 12 | if key.TypeName == name { 13 | return true 14 | } 15 | } 16 | return false 17 | } 18 | 19 | // includesField returns true if the given referenceSet includes a key representing the given field on the given type. 20 | func (s referenceSet) includesField(typeName, fieldName string) bool { 21 | return s[model.FieldNameReference(typeName, fieldName)] 22 | } 23 | 24 | // filterFields returns a copy of the given definition, where the field list has been filtered to only include 25 | // fields which were included in the referenceSet. The original def is not modified by this method. 26 | func (s referenceSet) filterFields(def *model.Definition) *model.Definition { 27 | var filteredFields []*model.FieldDefinition 28 | for _, field := range def.Fields { 29 | if s.includesField(def.Name, field.Name) { 30 | filteredFields = append(filteredFields, field) 31 | } 32 | } 33 | 34 | return &model.Definition{ 35 | Kind: def.Kind, 36 | Name: def.Name, 37 | Description: def.Description, 38 | Directives: def.Directives, 39 | Interfaces: def.Interfaces, 40 | PossibleTypes: def.PossibleTypes, 41 | EnumValues: def.EnumValues, 42 | Fields: filteredFields, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pkg/introspection/client.go: -------------------------------------------------------------------------------- 1 | package introspection 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/http/httputil" 11 | "strings" 12 | 13 | "github.com/vektah/gqlparser/v2/ast" 14 | ) 15 | 16 | // Client is a client capable of issuing an introspection query against a GraphQL server over HTTP, 17 | // and transforming the response into either an *ast.Schema. 18 | type Client struct { 19 | endpoint string 20 | headers http.Header 21 | specVersion SpecVersion 22 | traceOut io.Writer 23 | } 24 | 25 | type GraphQLParams struct { 26 | Query string `json:"query"` 27 | OperationName string `json:"operationName"` 28 | Variables map[string]any `json:"variables"` 29 | } 30 | 31 | type graphQLResponse struct { 32 | Data json.RawMessage `json:"data"` 33 | Errors []graphQLError `json:"errors"` 34 | } 35 | 36 | type graphQLError struct { 37 | Message string `json:"message"` 38 | Locations []struct { 39 | Line int `json:"line"` 40 | Column int `json:"column"` 41 | } `json:"locations"` 42 | Path []string `json:"path"` 43 | } 44 | 45 | // NewClient returns a new GraphQL introspection client. 46 | // HTTP requests issued by this client will use the given HTTP headers, in addition to some defaults. 47 | // The given SpecVersion will be used to ensure that the introspection query issued by the client is 48 | // compatible with a specific version of the GraphQL spec. 49 | // If traceOut is non-nil, the outbound request and returned response will be dumped to it for debugging 50 | // purposes. 51 | func NewClient(endpoint string, headers http.Header, specVersion SpecVersion, traceOut io.Writer) *Client { 52 | mergedHeaders := http.Header{ 53 | "content-type": []string{ 54 | "application/json", 55 | }, 56 | } 57 | 58 | for key, vals := range headers { 59 | mergedHeaders[key] = vals 60 | } 61 | 62 | return &Client{ 63 | endpoint: endpoint, 64 | headers: mergedHeaders, 65 | specVersion: specVersion, 66 | traceOut: traceOut, 67 | } 68 | } 69 | 70 | func (c *Client) FetchSchemaAst() (*ast.Schema, error) { 71 | rawSchema, err := c.fetchSchema() 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | s, err := responseToAst(rawSchema) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | return s, nil 82 | } 83 | 84 | func (c *Client) fetchSchema() (*Schema, error) { 85 | rsp, err := c.issueQuery(GetQuery(c.specVersion), nil, "IntrospectionQuery") 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | if len(rsp.Errors) != 0 { 91 | var errs []error 92 | for _, err := range rsp.Errors { 93 | forPath := "" 94 | if len(err.Path) != 0 { 95 | forPath = fmt.Sprintf(" at path %s", strings.Join(err.Path, ".")) 96 | } 97 | errs = append(errs, fmt.Errorf("error executing introspection query%s: %s", forPath, err.Message)) 98 | } 99 | return nil, errors.Join(errs...) 100 | } 101 | 102 | var parsed IntrospectionQueryResult 103 | err = json.Unmarshal(rsp.Data, &parsed) 104 | if err != nil { 105 | return nil, fmt.Errorf("failed to deserialize introspection query result: %w", err) 106 | } 107 | 108 | return &parsed.Schema, nil 109 | } 110 | 111 | func (c *Client) issueQuery(query string, vars map[string]any, operation string) (*graphQLResponse, error) { 112 | body := GraphQLParams{ 113 | Query: query, 114 | OperationName: operation, 115 | Variables: vars, 116 | } 117 | 118 | jsonBody, err := json.Marshal(body) 119 | if err != nil { 120 | return nil, fmt.Errorf("failed to serialize introspection request body: %w", err) 121 | } 122 | 123 | req, err := http.NewRequest("POST", c.endpoint, bytes.NewBuffer(jsonBody)) 124 | if err != nil { 125 | return nil, fmt.Errorf("failed to create introspection request: %w", err) 126 | } 127 | 128 | req.Header = c.headers 129 | 130 | if c.traceOut != nil { 131 | requestDump, err := httputil.DumpRequestOut(req, true) 132 | if err != nil { 133 | return nil, fmt.Errorf("failed to dump introspection HTTP request: %w", err) 134 | } 135 | fmt.Fprintf(c.traceOut, "---\nIntrospection request:\n%s\n", string(requestDump)) 136 | } 137 | 138 | client := &http.Client{} 139 | resp, err := client.Do(req) 140 | if err != nil { 141 | return nil, fmt.Errorf("failed to send introspection request to %s: %w", c.endpoint, err) 142 | } 143 | defer resp.Body.Close() 144 | 145 | if c.traceOut != nil { 146 | rspDump, err := httputil.DumpResponse(resp, true) 147 | if err != nil { 148 | return nil, fmt.Errorf("failed to dump introspection HTTP response: %w", err) 149 | } 150 | fmt.Fprintf(c.traceOut, "\n---\nIntrospection response:\n%s\n", string(rspDump)) 151 | } 152 | 153 | rspBody, err := io.ReadAll(resp.Body) 154 | if err != nil { 155 | return nil, fmt.Errorf("failed to read introspection query response: %w", err) 156 | } 157 | 158 | if resp.StatusCode != http.StatusOK { 159 | return nil, fmt.Errorf("received non-200 response to introspection query: status=%d, body=%s", resp.StatusCode, rspBody) 160 | } 161 | 162 | var graphqlResp graphQLResponse 163 | err = json.Unmarshal(rspBody, &graphqlResp) 164 | if err != nil { 165 | return nil, fmt.Errorf("failed to deserialize introspection response body: %w", err) 166 | } 167 | 168 | return &graphqlResp, nil 169 | } 170 | -------------------------------------------------------------------------------- /pkg/introspection/query.go: -------------------------------------------------------------------------------- 1 | package introspection 2 | 3 | import ( 4 | "bytes" 5 | "text/template" 6 | ) 7 | 8 | // GetQuery returns a GraphQL introspection query that is compatible with the given version 9 | // of the GraphQL spec. 10 | func GetQuery(sv SpecVersion) string { 11 | t, err := template.New("QueryTemplate").Parse(queryTemplate) 12 | if err != nil { 13 | panic(err) 14 | } 15 | 16 | var buf bytes.Buffer 17 | if err = t.Execute(&buf, sv); err != nil { 18 | panic(err) 19 | } 20 | 21 | return buf.String() 22 | } 23 | 24 | const queryTemplate = ` 25 | query IntrospectionQuery { 26 | __schema { 27 | {{ if eq .HasSchemaDescription true }}description{{end}} 28 | queryType { 29 | name 30 | } 31 | mutationType { 32 | name 33 | } 34 | subscriptionType { 35 | name 36 | } 37 | types { 38 | ...FullType 39 | } 40 | directives { 41 | name 42 | description 43 | locations 44 | args { 45 | ...InputValue 46 | } 47 | {{ if eq .HasIsRepeatable true }}isRepeatable{{end}} 48 | } 49 | } 50 | } 51 | 52 | fragment FullType on __Type { 53 | kind 54 | name 55 | description 56 | fields(includeDeprecated: true) { 57 | name 58 | description 59 | args { 60 | ...InputValue 61 | } 62 | type { 63 | ...TypeRef 64 | } 65 | isDeprecated 66 | deprecationReason 67 | } 68 | inputFields { 69 | ...InputValue 70 | } 71 | interfaces { 72 | ...TypeRef 73 | } 74 | enumValues(includeDeprecated: true) { 75 | name 76 | description 77 | isDeprecated 78 | deprecationReason 79 | } 80 | possibleTypes { 81 | ...TypeRef 82 | } 83 | {{ if eq .HasSpecifiedByURL true }}specifiedByURL{{end}} 84 | } 85 | 86 | fragment InputValue on __InputValue { 87 | name 88 | description 89 | type { 90 | ...TypeRef 91 | } 92 | defaultValue 93 | } 94 | 95 | fragment TypeRef on __Type { 96 | kind 97 | name 98 | ofType { 99 | kind 100 | name 101 | ofType { 102 | kind 103 | name 104 | ofType { 105 | kind 106 | name 107 | ofType { 108 | kind 109 | name 110 | ofType { 111 | kind 112 | name 113 | ofType { 114 | kind 115 | name 116 | ofType { 117 | kind 118 | name 119 | } 120 | } 121 | } 122 | } 123 | } 124 | } 125 | } 126 | } 127 | ` 128 | -------------------------------------------------------------------------------- /pkg/introspection/spec_versions.go: -------------------------------------------------------------------------------- 1 | package introspection 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // SpecVersion represents a version of the GraphQL specification. 9 | // Versions are listed at https://spec.graphql.org/ 10 | type SpecVersion struct { 11 | name string 12 | HasSpecifiedByURL bool 13 | HasIsRepeatable bool 14 | HasSchemaDescription bool 15 | } 16 | 17 | var specVersions = map[string]SpecVersion{ 18 | "june2018": { 19 | name: "june2018", 20 | }, 21 | "october2021": { 22 | name: "october2021", 23 | HasSpecifiedByURL: true, 24 | HasIsRepeatable: true, 25 | HasSchemaDescription: true, 26 | }, 27 | } 28 | 29 | func ParseSpecVersion(raw string) (SpecVersion, error) { 30 | sv, ok := specVersions[raw] 31 | if !ok { 32 | return SpecVersion{}, fmt.Errorf("invalid spec version '%s', known versions are %s", raw, strings.Join(knownVersions(), ", ")) 33 | } 34 | 35 | return sv, nil 36 | } 37 | 38 | func knownVersions() []string { 39 | var result []string 40 | for name := range specVersions { 41 | result = append(result, name) 42 | } 43 | return result 44 | } 45 | -------------------------------------------------------------------------------- /pkg/introspection/toast.go: -------------------------------------------------------------------------------- 1 | package introspection 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/vektah/gqlparser/v2/ast" 7 | "github.com/vektah/gqlparser/v2/parser" 8 | ) 9 | 10 | // responseToAst converts a deserialized introspection query result into an *ast.Schema, which 11 | // may then either be printed to GraphQL SDL, or converted into a model.Schema for further processing. 12 | func responseToAst(s *Schema) (*ast.Schema, error) { 13 | var defs ast.DefinitionList 14 | 15 | for _, def := range s.Types { 16 | newDef := ast.Definition{ 17 | Kind: ast.DefinitionKind(def.Kind), 18 | Description: def.Description, 19 | Name: def.Name, 20 | } 21 | 22 | if def.Kind == ObjectKind { 23 | var interfaces []string 24 | for _, iface := range def.Interfaces { 25 | interfaces = append(interfaces, iface.Name) 26 | } 27 | newDef.Interfaces = interfaces 28 | } 29 | 30 | if def.Kind == ObjectKind || def.Kind == InputObjectKind || def.Kind == InterfaceKind { 31 | var fields ast.FieldList 32 | for _, inField := range def.Fields { 33 | args, err := makeArgumentDefinitionList(inField.Args) 34 | if err != nil { 35 | return nil, err 36 | } 37 | field := ast.FieldDefinition{ 38 | Name: inField.Name, 39 | Description: inField.Description, 40 | Type: makeType(inField.Type), 41 | Arguments: args, 42 | Directives: synthesizeDeprecationDirective(inField.IsDeprecated, inField.DeprecationReason), 43 | } 44 | fields = append(fields, &field) 45 | } 46 | for _, inField := range def.InputFields { 47 | field := ast.FieldDefinition{ 48 | Name: inField.Name, 49 | Description: inField.Description, 50 | Type: makeType(inField.Type), 51 | } 52 | if inField.DefaultValue != nil { 53 | defaultValue, err := makeDefaultValue(inField.Type, *inField.DefaultValue) 54 | if err != nil { 55 | return nil, err 56 | } 57 | field.DefaultValue = defaultValue 58 | } 59 | fields = append(fields, &field) 60 | } 61 | newDef.Fields = fields 62 | } 63 | 64 | if def.Kind == UnionKind { 65 | var possibleTypes []string 66 | for _, pt := range def.PossibleTypes { 67 | possibleTypes = append(possibleTypes, pt.Name) 68 | } 69 | newDef.Types = possibleTypes 70 | } 71 | 72 | if def.Kind == EnumKind { 73 | var evs ast.EnumValueList 74 | for _, ev := range def.EnumValues { 75 | evs = append(evs, &ast.EnumValueDefinition{ 76 | Name: ev.Name, 77 | Description: ev.Description, 78 | Directives: synthesizeDeprecationDirective(ev.IsDeprecated, ev.DeprecationReason), 79 | }) 80 | } 81 | newDef.EnumValues = evs 82 | } 83 | 84 | defs = append(defs, &newDef) 85 | } 86 | 87 | typeMap := map[string]*ast.Definition{} 88 | for _, def := range defs { 89 | typeMap[def.Name] = def 90 | } 91 | 92 | directiveMap := map[string]*ast.DirectiveDefinition{} 93 | for _, dir := range s.Directives { 94 | var locations []ast.DirectiveLocation 95 | for _, loc := range dir.Locations { 96 | locations = append(locations, ast.DirectiveLocation(loc)) 97 | } 98 | args, err := makeArgumentDefinitionList(dir.Args) 99 | if err != nil { 100 | return nil, err 101 | } 102 | directiveMap[dir.Name] = &ast.DirectiveDefinition{ 103 | Name: dir.Name, 104 | Description: dir.Description, 105 | Arguments: args, 106 | Locations: locations, 107 | IsRepeatable: dir.IsRepeatable, 108 | Position: &ast.Position{ 109 | Src: &ast.Source{ 110 | BuiltIn: false, 111 | }, 112 | }, 113 | } 114 | } 115 | 116 | return &ast.Schema{ 117 | Types: typeMap, 118 | Query: typeMap[s.QueryType.Name], 119 | Mutation: typeMap[s.MutationType.Name], 120 | Subscription: typeMap[s.SubscriptionType.Name], 121 | Directives: directiveMap, 122 | }, nil 123 | } 124 | 125 | func synthesizeDeprecationDirective(deprecated bool, deprecationReason string) ast.DirectiveList { 126 | if !deprecated { 127 | return nil 128 | } 129 | 130 | return ast.DirectiveList{ 131 | &ast.Directive{ 132 | Name: "deprecated", 133 | Arguments: ast.ArgumentList{ 134 | &ast.Argument{ 135 | Name: "reason", 136 | Value: &ast.Value{ 137 | Kind: ast.StringValue, 138 | Raw: deprecationReason, 139 | }, 140 | }, 141 | }, 142 | }, 143 | } 144 | } 145 | 146 | func makeArgumentDefinitionList(in []InputValue) (ast.ArgumentDefinitionList, error) { 147 | var result ast.ArgumentDefinitionList 148 | for _, inArg := range in { 149 | arg := &ast.ArgumentDefinition{ 150 | Name: inArg.Name, 151 | Description: inArg.Description, 152 | Type: makeType(inArg.Type), 153 | } 154 | 155 | if inArg.DefaultValue != nil { 156 | var err error 157 | if arg.DefaultValue, err = makeDefaultValue(inArg.Type, *inArg.DefaultValue); err != nil { 158 | return nil, err 159 | } 160 | } 161 | 162 | result = append(result, arg) 163 | } 164 | return result, nil 165 | } 166 | 167 | func makeDefaultValue(t *Type, raw string) (*ast.Value, error) { 168 | var kind ast.ValueKind 169 | 170 | switch raw { 171 | case "null": 172 | kind = ast.NullValue 173 | case "true", "false": 174 | kind = ast.BooleanValue 175 | default: 176 | switch t.Kind { 177 | case ScalarKind: 178 | switch t.Name { 179 | case "Int": 180 | kind = ast.IntValue 181 | case "Float": 182 | kind = ast.FloatValue 183 | case "String": 184 | return makeValue(raw) 185 | default: 186 | kind = ast.StringValue 187 | } 188 | case InputObjectKind, ListKind: 189 | return makeValue(raw) 190 | case EnumKind: 191 | kind = ast.EnumValue 192 | case NonNullKind: 193 | return makeDefaultValue(t.OfType, raw) 194 | default: 195 | return nil, fmt.Errorf("unsupported type kind %s for default value '%s'", t.Kind, raw) 196 | } 197 | } 198 | 199 | return &ast.Value{ 200 | Kind: kind, 201 | Raw: raw, 202 | }, nil 203 | } 204 | 205 | // makeValue parses a raw string representing a GraphQL Value[1] 206 | // This is useful for handling the __InputValue.defaultValue field[2] in the 207 | // introspection schema. 208 | // 209 | // Since the GraphQL parser we're using doesn't support parsing a Value directly, 210 | // we have to wrap the value in a dummy query here, providing it as an argument 211 | // to a non-existent field. 212 | // 213 | // [1]: https://spec.graphql.org/October2021/#Value 214 | // [2]: https://spec.graphql.org/October2021/#sec-The-__InputValue-Type 215 | func makeValue(raw string) (*ast.Value, error) { 216 | src := &ast.Source{ 217 | Input: fmt.Sprintf("{ f(in: %s) }", raw), 218 | } 219 | doc, err := parser.ParseQuery(src) 220 | if err != nil { 221 | return nil, err 222 | } 223 | 224 | field := doc.Operations[0].SelectionSet[0].(*ast.Field) 225 | return field.Arguments[0].Value, nil 226 | } 227 | 228 | func makeType(in *Type) *ast.Type { 229 | if in.Kind == NonNullKind { 230 | wrappedType := makeType(in.OfType) 231 | wrappedType.NonNull = true 232 | return wrappedType 233 | } 234 | 235 | if in.Kind == ListKind { 236 | wrappedType := makeType(in.OfType) 237 | return &ast.Type{ 238 | Elem: wrappedType, 239 | } 240 | } 241 | 242 | return &ast.Type{ 243 | NamedType: in.Name, 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /pkg/introspection/toast_test.go: -------------------------------------------------------------------------------- 1 | package introspection 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/vektah/gqlparser/v2/formatter" 9 | ) 10 | 11 | func TestResponseToAst(t *testing.T) { 12 | for _, testCase := range []struct { 13 | name string 14 | response Schema 15 | expected string 16 | }{ 17 | { 18 | name: "compound default values", 19 | response: Schema{ 20 | Types: []Type{ 21 | { 22 | Kind: "OBJECT", 23 | Name: "Query", 24 | Description: "The root query object", 25 | Fields: []Field{ 26 | { 27 | Name: "fruits", 28 | Description: "Get fruits", 29 | Args: []InputValue{ 30 | { 31 | Name: "name", 32 | Description: "Filter returned fruits by name", 33 | Type: &Type{ 34 | Kind: ScalarKind, 35 | Name: "String", 36 | }, 37 | }, 38 | { 39 | Name: "orderBy", 40 | Type: &Type{ 41 | Kind: InputObjectKind, 42 | Name: "FruitsOrderBy", 43 | }, 44 | DefaultValue: stringp("{ direction: ASC, field: NAME }"), 45 | }, 46 | }, 47 | Type: &Type{ 48 | Kind: ListKind, 49 | OfType: &Type{ 50 | Kind: ObjectKind, 51 | Name: "Fruit", 52 | }, 53 | }, 54 | }, 55 | }, 56 | }, 57 | { 58 | Kind: "OBJECT", 59 | Name: "Fruit", 60 | Fields: []Field{ 61 | { 62 | Name: "name", 63 | Type: &Type{ 64 | Kind: ScalarKind, 65 | Name: "String", 66 | }, 67 | }, 68 | }, 69 | }, 70 | { 71 | Kind: "INPUT_OBJECT", 72 | Name: "FruitsOrderBy", 73 | InputFields: []InputValue{ 74 | { 75 | Name: "direction", 76 | Type: &Type{ 77 | Kind: EnumKind, 78 | Name: "FruitsOrderByDirection", 79 | }, 80 | }, 81 | { 82 | Name: "field", 83 | Type: &Type{ 84 | Kind: EnumKind, 85 | Name: "FruitsOrderByField", 86 | }, 87 | }, 88 | }, 89 | }, 90 | { 91 | Kind: "ENUM", 92 | Name: "FruitsOrderByDirection", 93 | Description: "Which direction to order the fruits by", 94 | EnumValues: []EnumValue{ 95 | { 96 | Name: "ASC", 97 | Description: "Ascending", 98 | }, 99 | { 100 | Name: "DESC", 101 | Description: "Descending", 102 | }, 103 | }, 104 | }, 105 | { 106 | Kind: "ENUM", 107 | Name: "FruitsOrderByField", 108 | EnumValues: []EnumValue{ 109 | { 110 | Name: "NAME", 111 | Description: "Name", 112 | }, 113 | }, 114 | }, 115 | }, 116 | }, 117 | expected: `type Fruit { 118 | name: String 119 | } 120 | input FruitsOrderBy { 121 | direction: FruitsOrderByDirection 122 | field: FruitsOrderByField 123 | } 124 | """ 125 | Which direction to order the fruits by 126 | """ 127 | enum FruitsOrderByDirection { 128 | """ 129 | Ascending 130 | """ 131 | ASC 132 | """ 133 | Descending 134 | """ 135 | DESC 136 | } 137 | enum FruitsOrderByField { 138 | """ 139 | Name 140 | """ 141 | NAME 142 | } 143 | """ 144 | The root query object 145 | """ 146 | type Query { 147 | """ 148 | Get fruits 149 | """ 150 | fruits( 151 | """ 152 | Filter returned fruits by name 153 | """ 154 | name: String 155 | orderBy: FruitsOrderBy = {direction:ASC,field:NAME}): [Fruit] 156 | } 157 | `, 158 | }, 159 | } { 160 | t.Run(testCase.name, func(t *testing.T) { 161 | s, err := responseToAst(&testCase.response) 162 | assert.NoError(t, err) 163 | 164 | var buf bytes.Buffer 165 | f := formatter.NewFormatter(&buf) 166 | f.FormatSchema(s) 167 | 168 | actual := buf.String() 169 | assert.Equal(t, testCase.expected, actual) 170 | }) 171 | } 172 | } 173 | 174 | func stringp(s string) *string { 175 | return &s 176 | } 177 | -------------------------------------------------------------------------------- /pkg/introspection/types.go: -------------------------------------------------------------------------------- 1 | package introspection 2 | 3 | // The types in this file are used to deserialize the response from a GraphQL introspection query. 4 | // As such, they directly mirror the specified introspection schema described in the GraphQL spec 5 | // here: https://spec.graphql.org/October2021/#sec-Schema-Introspection.Schema-Introspection-Schema 6 | 7 | type IntrospectionQueryResult struct { 8 | Schema Schema `json:"__schema"` 9 | } 10 | 11 | // Schema represents an instance of the __Schema introspection type: 12 | // https://spec.graphql.org/October2021/#sec-The-__Schema-Type 13 | type Schema struct { 14 | Types []Type `json:"types"` 15 | QueryType Type `json:"queryType,omitempty"` 16 | MutationType Type `json:"mutationType,omitempty"` 17 | SubscriptionType Type `json:"subscriptionType,omitempty"` 18 | Directives []Directive `json:"directives,omitempty"` 19 | } 20 | 21 | // Type represents an instance of the __Type introspection type: 22 | // https://spec.graphql.org/October2021/#sec-The-__Type-Type 23 | type Type struct { 24 | Kind TypeKind `json:"kind"` 25 | Name string `json:"name,omitempty"` 26 | Description string `json:"description,omitempty"` 27 | 28 | // OBJECT and INTERFACE only 29 | Fields []Field `json:"fields,omitempty"` 30 | 31 | // OBJECT only 32 | Interfaces []Type `json:"interfaces,omitempty"` 33 | 34 | // INTERFACE and UNION only 35 | PossibleTypes []Type `json:"possibleTypes,omitempty"` 36 | 37 | // ENUM only 38 | EnumValues []EnumValue `json:"enumValues,omitempty"` 39 | 40 | // INPUT_OBJECT only 41 | InputFields []InputValue `json:"inputFields,omitempty"` 42 | 43 | // NON_NULL and LIST only 44 | OfType *Type `json:"ofType,omitempty"` 45 | } 46 | 47 | // Directive represents an instance of the __Directive introspection type: 48 | // https://spec.graphql.org/October2021/#sec-The-__Directive-Type 49 | type Directive struct { 50 | Name string `json:"name"` 51 | Description string `json:"description,omitempty"` 52 | Locations []DirectiveLocation `json:"locations"` 53 | Args []InputValue `json:"args"` 54 | IsRepeatable bool `json:"isRepeatable"` 55 | } 56 | 57 | // Field represents an instance of the __Field introspection type: 58 | // https://spec.graphql.org/October2021/#sec-The-__Field-Type 59 | type Field struct { 60 | Name string `json:"name"` 61 | Description string `json:"description,omitempty"` 62 | Args []InputValue `json:"args,omitempty"` 63 | Type *Type `json:"type"` 64 | IsDeprecated bool `json:"isDeprecated"` 65 | DeprecationReason string `json:"deprecationReason"` 66 | } 67 | 68 | // EnumValue represents an instance of the __EnumValue introspection type: 69 | // https://spec.graphql.org/October2021/#sec-The-__EnumValue-Type 70 | type EnumValue struct { 71 | Name string `json:"name"` 72 | Description string `json:"description"` 73 | IsDeprecated bool `json:"isDeprecated"` 74 | DeprecationReason string `json:"deprecationReason"` 75 | } 76 | 77 | // InputValue represents an instance of the __InputValue introspection type: 78 | // https://spec.graphql.org/October2021/#sec-The-__InputValue-Type 79 | type InputValue struct { 80 | Name string `json:"name"` 81 | Description string `json:"description,omitempty"` 82 | Type *Type `json:"type"` 83 | DefaultValue *string `json:"defaultValue,omitempty"` 84 | } 85 | 86 | // TypeKind represents a possible value of the __TypeKind introspection enum. 87 | type TypeKind string 88 | 89 | const ( 90 | ScalarKind = TypeKind("SCALAR") 91 | ObjectKind = TypeKind("OBJECT") 92 | InterfaceKind = TypeKind("INTERFACE") 93 | UnionKind = TypeKind("UNION") 94 | EnumKind = TypeKind("ENUM") 95 | InputObjectKind = TypeKind("INPUT_OBJECT") 96 | ListKind = TypeKind("LIST") 97 | NonNullKind = TypeKind("NON_NULL") 98 | ) 99 | 100 | // DirectiveLocation represents a possible value of the __DirectiveLocation introspection enum. 101 | type DirectiveLocation string 102 | -------------------------------------------------------------------------------- /pkg/model/arguments.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/vektah/gqlparser/v2/ast" 7 | ) 8 | 9 | // Based on the __InputValue introspection type. 10 | type ArgumentDefinition struct { 11 | Name string 12 | Description string 13 | DefaultValue Value 14 | Type *Type 15 | Directives DirectiveList 16 | } 17 | 18 | type ArgumentDefinitionList []*ArgumentDefinition 19 | 20 | type ArgumentList []*Argument 21 | 22 | type Argument struct { 23 | Name string `json:"name"` 24 | Value Value `json:"value"` 25 | } 26 | 27 | func (a *ArgumentDefinition) MarshalJSON() ([]byte, error) { 28 | m := map[string]any{ 29 | "name": a.Name, 30 | "type": a.Type, 31 | "typeName": a.Type.String(), 32 | "underlyingTypeName": a.Type.Unwrap().Name, 33 | } 34 | 35 | if a.Description != "" { 36 | m["description"] = a.Description 37 | } 38 | 39 | if a.DefaultValue != nil { 40 | m["defaultValue"] = a.DefaultValue 41 | } 42 | 43 | if len(a.Directives) != 0 { 44 | m["directives"] = a.Directives 45 | } 46 | 47 | return json.Marshal(m) 48 | } 49 | 50 | func makeArgumentDefinitionList(in ast.ArgumentDefinitionList) (ArgumentDefinitionList, error) { 51 | var result ArgumentDefinitionList 52 | for _, a := range in { 53 | argDef, err := makeArgumentDefinition(a.Name, a.Description, a.Type, a.Directives, a.DefaultValue) 54 | if err != nil { 55 | return nil, err 56 | } 57 | result = append(result, argDef) 58 | } 59 | return result, nil 60 | } 61 | 62 | func makeArgumentDefinition(name, description string, inType *ast.Type, inDirectives ast.DirectiveList, inDefaultValue *ast.Value) (*ArgumentDefinition, error) { 63 | defaultValue, err := makeValue(inDefaultValue) 64 | if err != nil { 65 | return nil, err 66 | } 67 | directives, err := makeDirectiveList(inDirectives) 68 | if err != nil { 69 | return nil, err 70 | } 71 | return &ArgumentDefinition{ 72 | Name: name, 73 | Description: description, 74 | Type: makeType(inType), 75 | DefaultValue: defaultValue, 76 | Directives: directives, 77 | }, nil 78 | } 79 | 80 | func makeArgumentList(in ast.ArgumentList) (ArgumentList, error) { 81 | var out ArgumentList 82 | for _, a := range in { 83 | val, err := makeValue(a.Value) 84 | if err != nil { 85 | return nil, err 86 | } 87 | out = append(out, &Argument{ 88 | Name: a.Name, 89 | Value: val, 90 | }) 91 | } 92 | return out, nil 93 | } 94 | -------------------------------------------------------------------------------- /pkg/model/builtins.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/benweint/gquil/pkg/astutil" 5 | ) 6 | 7 | func filterBuiltinTypesAndFields(defs DefinitionMap) DefinitionMap { 8 | result := DefinitionMap{} 9 | for _, def := range defs { 10 | if astutil.IsBuiltinType(def.Name) { 11 | continue 12 | } 13 | def.Fields = filterBuiltinFields(def.Fields) 14 | result[def.Name] = def 15 | } 16 | return result 17 | } 18 | 19 | func filterBuiltinDirectives(dirs DirectiveDefinitionList) DirectiveDefinitionList { 20 | var result DirectiveDefinitionList 21 | for _, d := range dirs { 22 | if astutil.IsBuiltinDirective(d.Name) { 23 | continue 24 | } 25 | result = append(result, d) 26 | } 27 | return result 28 | } 29 | 30 | func filterBuiltinFields(defs FieldDefinitionList) FieldDefinitionList { 31 | var result FieldDefinitionList 32 | for _, fd := range defs { 33 | if astutil.IsBuiltinField(fd.Name) { 34 | continue 35 | } 36 | result = append(result, fd) 37 | } 38 | return result 39 | } 40 | -------------------------------------------------------------------------------- /pkg/model/definition.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "slices" 7 | "strings" 8 | 9 | "github.com/vektah/gqlparser/v2/ast" 10 | ) 11 | 12 | // Based on the __Type introspection type: https://spec.graphql.org/October2021/#sec-The-__Type-Type 13 | type Definition struct { 14 | Kind ast.DefinitionKind 15 | Name string 16 | Description string 17 | Directives DirectiveList 18 | 19 | // only set for interfaces, objects, input objects 20 | Fields FieldDefinitionList 21 | 22 | // only set for interfaces 23 | Interfaces []string 24 | 25 | // only set for interfaces & unions 26 | PossibleTypes []string 27 | 28 | // only set for enums 29 | EnumValues EnumValueList 30 | } 31 | 32 | func (d *Definition) String() string { 33 | return fmt.Sprintf("Def{name=%s, kind=%s}", d.Name, d.Kind) 34 | } 35 | 36 | func (d *Definition) MarshalJSON() ([]byte, error) { 37 | m := map[string]any{ 38 | "kind": d.Kind, 39 | "name": d.Name, 40 | } 41 | 42 | if d.Description != "" { 43 | m["description"] = d.Description 44 | } 45 | 46 | if len(d.Directives) > 0 { 47 | m["directives"] = d.Directives 48 | } 49 | 50 | if len(d.Fields) > 0 { 51 | fieldsKeyName := "fields" 52 | if d.Kind == ast.InputObject { 53 | fieldsKeyName = "inputFields" 54 | } 55 | m[fieldsKeyName] = d.Fields 56 | } 57 | 58 | if len(d.Interfaces) > 0 { 59 | m["interfaces"] = d.Interfaces 60 | } 61 | 62 | if len(d.PossibleTypes) > 0 { 63 | m["possibleTypeNames"] = d.PossibleTypes 64 | } 65 | 66 | if len(d.EnumValues) > 0 { 67 | m["enumValues"] = d.EnumValues 68 | } 69 | 70 | return json.Marshal(m) 71 | } 72 | 73 | type DefinitionList []*Definition 74 | 75 | func (d DefinitionList) Sort() { 76 | slices.SortFunc[DefinitionList, *Definition](d, func(a, b *Definition) int { 77 | return strings.Compare(a.Name, b.Name) 78 | }) 79 | } 80 | 81 | func (d DefinitionList) ToMap() DefinitionMap { 82 | result := DefinitionMap{} 83 | for _, def := range d { 84 | result[def.Name] = def 85 | } 86 | return result 87 | } 88 | 89 | type DefinitionMap map[string]*Definition 90 | 91 | func (d DefinitionMap) ToSortedList() DefinitionList { 92 | var result DefinitionList 93 | for _, def := range d { 94 | result = append(result, def) 95 | } 96 | result.Sort() 97 | return result 98 | } 99 | 100 | func MakeDefinitionMap(in DefinitionList) DefinitionMap { 101 | result := DefinitionMap{} 102 | for _, def := range in { 103 | result[def.Name] = def 104 | } 105 | return result 106 | } 107 | 108 | func makeDefinition(in *ast.Definition) (*Definition, error) { 109 | def := &Definition{ 110 | Kind: in.Kind, 111 | Name: in.Name, 112 | Description: in.Description, 113 | Interfaces: in.Interfaces, 114 | PossibleTypes: in.Types, 115 | } 116 | 117 | if in.Kind == ast.Object || in.Kind == ast.Interface || in.Kind == ast.InputObject { 118 | fields, err := makeFieldDefinitionList(in.Fields) 119 | if err != nil { 120 | return nil, err 121 | } 122 | def.Fields = fields 123 | } 124 | 125 | directives, err := makeDirectiveList(in.Directives) 126 | if err != nil { 127 | return nil, err 128 | } 129 | def.Directives = directives 130 | 131 | enumValues, err := makeEnumValueList(in.EnumValues) 132 | if err != nil { 133 | return nil, err 134 | } 135 | def.EnumValues = enumValues 136 | 137 | return def, nil 138 | } 139 | 140 | func maybeTypeName(in *ast.Definition) string { 141 | if in == nil { 142 | return "" 143 | } 144 | return in.Name 145 | } 146 | 147 | func resolveTypeKinds(typesByName DefinitionMap, t *Type) error { 148 | if t.OfType != nil { 149 | return resolveTypeKinds(typesByName, t.OfType) 150 | } else { 151 | referencedType, ok := typesByName[t.Name] 152 | if !ok { 153 | return fmt.Errorf("could not resolve type named '%s'", t.Name) 154 | } 155 | t.Kind = TypeKind(referencedType.Kind) 156 | } 157 | return nil 158 | } 159 | -------------------------------------------------------------------------------- /pkg/model/directives.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "github.com/vektah/gqlparser/v2/ast" 4 | 5 | // Directive represents a specific application site / instantiation of a directive. 6 | // 7 | // Note that this type does *not* appear in the GraphQL introspection schema, which lacks information 8 | // about most directive application sites (with the exception of @deprecated, which is special-cased). 9 | type Directive struct { 10 | Name string `json:"name"` 11 | Arguments ArgumentList `json:"arguments,omitempty"` 12 | } 13 | 14 | // DirectiveList represents a list of directives all applied at the same application site. 15 | type DirectiveList []*Directive 16 | 17 | // DirectiveDefinition represents the definition of a directive. 18 | // Based on the __Directive introspection type defined here: https://spec.graphql.org/October2021/#sec-The-__Directive-Type 19 | type DirectiveDefinition struct { 20 | Description string `json:"description"` 21 | Name string `json:"name"` 22 | Arguments ArgumentDefinitionList `json:"arguments,omitempty"` 23 | Locations []ast.DirectiveLocation `json:"locations"` 24 | IsRepeatable bool `json:"repeatable"` 25 | } 26 | 27 | // DirectiveDefinitionList represents a list of directive definitions. 28 | type DirectiveDefinitionList []*DirectiveDefinition 29 | 30 | func makeDirectiveDefinition(in *ast.DirectiveDefinition) (*DirectiveDefinition, error) { 31 | args, err := makeArgumentDefinitionList(in.Arguments) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return &DirectiveDefinition{ 37 | Name: in.Name, 38 | Description: in.Description, 39 | Arguments: args, 40 | Locations: in.Locations, 41 | IsRepeatable: in.IsRepeatable, 42 | }, nil 43 | } 44 | 45 | func makeDirectiveList(in ast.DirectiveList) (DirectiveList, error) { 46 | var out DirectiveList 47 | for _, d := range in { 48 | args, err := makeArgumentList(d.Arguments) 49 | if err != nil { 50 | return nil, err 51 | } 52 | out = append(out, &Directive{ 53 | Name: d.Name, 54 | Arguments: args, 55 | }) 56 | } 57 | return out, nil 58 | } 59 | -------------------------------------------------------------------------------- /pkg/model/enums.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "github.com/vektah/gqlparser/v2/ast" 4 | 5 | // EnumValueDefinition represents a single possible value for a GraphQL enum. 6 | // Based on the __EnumValue introspection type specified here: https://spec.graphql.org/October2021/#sec-The-__EnumValue-Type 7 | // Note that the isDeprecated and deprecationReason fields are replaced by the more generic 'directives' field. 8 | type EnumValueDefinition struct { 9 | Description string `json:"description,omitempty"` 10 | Name string `json:"name"` 11 | Directives DirectiveList `json:"directives,omitempty"` 12 | } 13 | 14 | // EnumValueList represents a set of possible enum values for a single enum. 15 | type EnumValueList []*EnumValueDefinition 16 | 17 | func makeEnumValueList(in ast.EnumValueList) (EnumValueList, error) { 18 | var result EnumValueList 19 | for _, ev := range in { 20 | val, err := makeEnumValue(ev) 21 | if err != nil { 22 | return nil, err 23 | } 24 | result = append(result, val) 25 | } 26 | return result, nil 27 | } 28 | 29 | func makeEnumValue(in *ast.EnumValueDefinition) (*EnumValueDefinition, error) { 30 | directives, err := makeDirectiveList(in.Directives) 31 | if err != nil { 32 | return nil, err 33 | } 34 | return &EnumValueDefinition{ 35 | Name: in.Name, 36 | Description: in.Description, 37 | Directives: directives, 38 | }, nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/model/fields.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/json" 5 | "slices" 6 | "strings" 7 | 8 | "github.com/vektah/gqlparser/v2/ast" 9 | ) 10 | 11 | // Based on the __Field and __InputValue introspection types: https://spec.graphql.org/October2021/#sec-The-__Field-Type 12 | // 13 | // Notable differences from the spec: 14 | // - __Field and __InputValue are represented here by a single merged type, where some fields are left blank when not 15 | // applicable. 16 | // - The directives field represents information about directives attached to a given field or input value, which is 17 | // not present in the introspection schema. 18 | // - As a result of the above, the deprecated and deprecationReason fields are omitted, since they would 19 | // duplicate the content of the more generic directives field. 20 | type FieldDefinition struct { 21 | Name string `json:"name"` 22 | Description string `json:"description,omitempty"` 23 | Type *Type `json:"type"` 24 | Arguments ArgumentDefinitionList `json:"arguments,omitempty"` // only for fields 25 | DefaultValue Value `json:"defaultValue,omitempty"` // only for input values 26 | Directives DirectiveList `json:"directives,omitempty"` 27 | } 28 | 29 | // FieldDefinitionList represents a set of fields definitions on the same object, interface, or input type. 30 | type FieldDefinitionList []*FieldDefinition 31 | 32 | func (fdl FieldDefinitionList) Sort() { 33 | slices.SortFunc[FieldDefinitionList, *FieldDefinition](fdl, func(a, b *FieldDefinition) int { 34 | return strings.Compare(a.Name, b.Name) 35 | }) 36 | } 37 | 38 | func (fdl FieldDefinitionList) Named(name string) *FieldDefinition { 39 | for _, field := range fdl { 40 | if field.Name == name { 41 | return field 42 | } 43 | } 44 | return nil 45 | } 46 | 47 | func (fd *FieldDefinition) MarshalJSON() ([]byte, error) { 48 | m := map[string]any{ 49 | "name": fd.Name, 50 | "type": fd.Type, 51 | "typeName": fd.Type.String(), 52 | "underlyingTypeName": fd.Type.Unwrap().Name, 53 | } 54 | 55 | if fd.Description != "" { 56 | m["description"] = fd.Description 57 | } 58 | 59 | if len(fd.Arguments) != 0 { 60 | m["arguments"] = fd.Arguments 61 | } 62 | 63 | // TODO: zerovalues 64 | if fd.DefaultValue != nil { 65 | m["defaultValue"] = fd.DefaultValue 66 | } 67 | 68 | if len(fd.Directives) != 0 { 69 | m["directives"] = fd.Directives 70 | } 71 | 72 | return json.Marshal(m) 73 | } 74 | 75 | func makeFieldDefinitionList(in ast.FieldList) (FieldDefinitionList, error) { 76 | var result FieldDefinitionList 77 | for _, f := range in { 78 | defaultValue, err := makeValue(f.DefaultValue) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | args, err := makeArgumentDefinitionList(f.Arguments) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | directives, err := makeDirectiveList(f.Directives) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | result = append(result, &FieldDefinition{ 94 | Name: f.Name, 95 | Description: f.Description, 96 | Type: makeType(f.Type), 97 | Arguments: args, 98 | Directives: directives, 99 | DefaultValue: defaultValue, 100 | }) 101 | } 102 | return result, nil 103 | } 104 | -------------------------------------------------------------------------------- /pkg/model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/vektah/gqlparser/v2/ast" 9 | ) 10 | 11 | // Based on the __Schema introspection type: https://spec.graphql.org/October2021/#sec-The-__Schema-Type 12 | // 13 | // The QueryType, MutationType, and SubscriptionType fields have been suffixed with 'Name' and 14 | // are represented as strings referring to named types, rather than nested objects. 15 | type Schema struct { 16 | Description string 17 | Types DefinitionMap 18 | QueryTypeName string 19 | MutationTypeName string 20 | SubscriptionTypeName string 21 | Directives DirectiveDefinitionList 22 | } 23 | 24 | func (s *Schema) FilterBuiltins() { 25 | s.Types = filterBuiltinTypesAndFields(s.Types) 26 | s.Directives = filterBuiltinDirectives(s.Directives) 27 | } 28 | 29 | func (s *Schema) MarshalJSON() ([]byte, error) { 30 | m := map[string]any{ 31 | "types": s.Types.ToSortedList(), 32 | } 33 | 34 | if s.Description != "" { 35 | m["description"] = s.Description 36 | } 37 | 38 | if s.QueryTypeName != "" { 39 | m["queryTypeName"] = s.QueryTypeName 40 | } 41 | 42 | if s.MutationTypeName != "" { 43 | m["mutationTypeName"] = s.MutationTypeName 44 | } 45 | 46 | if s.SubscriptionTypeName != "" { 47 | m["subscriptionTypeName"] = s.SubscriptionTypeName 48 | } 49 | 50 | if len(s.Directives) > 0 { 51 | m["directives"] = s.Directives 52 | } 53 | 54 | return json.Marshal(m) 55 | } 56 | 57 | func (s *Schema) ResolveNames(names []string) ([]*NameReference, error) { 58 | var roots []*NameReference 59 | var badNames []string 60 | for _, rootName := range names { 61 | root := s.resolveName(rootName) 62 | if root == nil { 63 | badNames = append(badNames, rootName) 64 | } else { 65 | roots = append(roots, root) 66 | } 67 | } 68 | 69 | if len(badNames) > 0 { 70 | return nil, fmt.Errorf("unknown name(s): %s", strings.Join(badNames, ", ")) 71 | } 72 | 73 | return roots, nil 74 | } 75 | 76 | func (s *Schema) resolveName(name string) *NameReference { 77 | parts := strings.SplitN(name, ".", 2) 78 | typePart := parts[0] 79 | for _, def := range s.Types { 80 | if def.Name == typePart { 81 | if len(parts) == 1 { 82 | return &NameReference{ 83 | TypeName: def.Name, 84 | } 85 | } 86 | 87 | fieldPart := parts[1] 88 | for _, field := range def.Fields { 89 | if field.Name == fieldPart { 90 | return &NameReference{ 91 | TypeName: def.Name, 92 | FieldName: field.Name, 93 | } 94 | } 95 | } 96 | } 97 | } 98 | 99 | return nil 100 | } 101 | 102 | // MakeSchema constructs and returns a Schema from the given ast.Schema. 103 | // The provided ast.Schema must be 'complete' in the sense that it must contain type definitions 104 | // for all types used in the schema, including built-in types like String, Int, etc. 105 | func MakeSchema(in *ast.Schema) (*Schema, error) { 106 | typesByName := DefinitionMap{} 107 | for _, def := range in.Types { 108 | t, err := makeDefinition(def) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | if t.Kind == ast.Interface { 114 | var possibleTypes []string 115 | for _, possibleType := range in.PossibleTypes[def.Name] { 116 | possibleTypes = append(possibleTypes, possibleType.Name) 117 | } 118 | t.PossibleTypes = possibleTypes 119 | } 120 | 121 | typesByName[t.Name] = t 122 | } 123 | 124 | var directives DirectiveDefinitionList 125 | for _, dd := range in.Directives { 126 | def, err := makeDirectiveDefinition(dd) 127 | if err != nil { 128 | return nil, err 129 | } 130 | directives = append(directives, def) 131 | } 132 | 133 | // Resolve type kinds for named types by looking them up in typesByName 134 | for _, t := range typesByName { 135 | for _, f := range t.Fields { 136 | if err := resolveTypeKinds(typesByName, f.Type); err != nil { 137 | return nil, err 138 | } 139 | for _, a := range f.Arguments { 140 | if err := resolveTypeKinds(typesByName, a.Type); err != nil { 141 | return nil, err 142 | } 143 | } 144 | } 145 | } 146 | 147 | for _, d := range directives { 148 | for _, arg := range d.Arguments { 149 | if err := resolveTypeKinds(typesByName, arg.Type); err != nil { 150 | return nil, err 151 | } 152 | } 153 | } 154 | 155 | return &Schema{ 156 | Description: in.Description, 157 | Types: typesByName, 158 | QueryTypeName: maybeTypeName(in.Query), 159 | MutationTypeName: maybeTypeName(in.Mutation), 160 | SubscriptionTypeName: maybeTypeName(in.Subscription), 161 | Directives: directives, 162 | }, nil 163 | } 164 | -------------------------------------------------------------------------------- /pkg/model/model_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/vektah/gqlparser/v2" 11 | "github.com/vektah/gqlparser/v2/ast" 12 | ) 13 | 14 | func TestJSONSerialization(t *testing.T) { 15 | root := "testdata/cases" 16 | entries, err := os.ReadDir(root) 17 | assert.NoError(t, err) 18 | 19 | for _, ent := range entries { 20 | if !ent.IsDir() { 21 | continue 22 | } 23 | 24 | testDir := path.Join(root, ent.Name()) 25 | tc := testCase{dir: testDir} 26 | t.Run(ent.Name(), tc.run) 27 | } 28 | } 29 | 30 | type testCase struct { 31 | dir string 32 | } 33 | 34 | func (tc *testCase) run(t *testing.T) { 35 | inputPath := path.Join(tc.dir, "in.graphql") 36 | expectedPath := path.Join(tc.dir, "expected.json") 37 | 38 | rawInput, err := os.ReadFile(inputPath) 39 | assert.NoError(t, err) 40 | 41 | src := ast.Source{ 42 | Name: "input", 43 | Input: string(rawInput), 44 | } 45 | s, err := gqlparser.LoadSchema(&src) 46 | assert.NoError(t, err) 47 | 48 | ss, err := MakeSchema(s) 49 | assert.NoError(t, err) 50 | 51 | ss.Types = filterBuiltinTypesAndFields(ss.Types) 52 | ss.Directives = filterBuiltinDirectives(ss.Directives) 53 | 54 | actual, err := json.MarshalIndent(ss, "", " ") 55 | assert.NoError(t, err) 56 | 57 | updateExpected := os.Getenv("TEST_UPDATE_EXPECTED") != "" 58 | if updateExpected { 59 | err = os.WriteFile(expectedPath, actual, 0644) 60 | assert.NoError(t, err) 61 | } 62 | 63 | expected, err := os.ReadFile(expectedPath) 64 | assert.NoError(t, err) 65 | 66 | assert.JSONEq(t, string(expected), string(actual)) 67 | } 68 | -------------------------------------------------------------------------------- /pkg/model/name_reference.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type NameReference struct { 4 | TypeName string 5 | FieldName string 6 | } 7 | 8 | func TypeNameReference(name string) NameReference { 9 | return NameReference{ 10 | TypeName: name, 11 | } 12 | } 13 | 14 | func FieldNameReference(typeName, fieldName string) NameReference { 15 | return NameReference{ 16 | TypeName: typeName, 17 | FieldName: fieldName, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pkg/model/testdata/cases/defaults/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "queryTypeName": "Query", 3 | "types": [ 4 | { 5 | "inputFields": [ 6 | { 7 | "name": "substring", 8 | "type": { 9 | "kind": "SCALAR", 10 | "name": "String" 11 | }, 12 | "typeName": "String", 13 | "underlyingTypeName": "String" 14 | }, 15 | { 16 | "name": "regex", 17 | "type": { 18 | "kind": "SCALAR", 19 | "name": "String" 20 | }, 21 | "typeName": "String", 22 | "underlyingTypeName": "String" 23 | } 24 | ], 25 | "kind": "INPUT_OBJECT", 26 | "name": "Match" 27 | }, 28 | { 29 | "fields": [ 30 | { 31 | "arguments": [ 32 | { 33 | "defaultValue": { 34 | "limit": 10, 35 | "match": { 36 | "substring": "foo" 37 | } 38 | }, 39 | "name": "q", 40 | "type": { 41 | "kind": "INPUT_OBJECT", 42 | "name": "SearchQuery" 43 | }, 44 | "typeName": "SearchQuery", 45 | "underlyingTypeName": "SearchQuery" 46 | } 47 | ], 48 | "name": "search", 49 | "type": { 50 | "kind": "LIST", 51 | "ofType": { 52 | "kind": "OBJECT", 53 | "name": "Result" 54 | } 55 | }, 56 | "typeName": "[Result]", 57 | "underlyingTypeName": "Result" 58 | } 59 | ], 60 | "kind": "OBJECT", 61 | "name": "Query" 62 | }, 63 | { 64 | "fields": [ 65 | { 66 | "name": "title", 67 | "type": { 68 | "kind": "SCALAR", 69 | "name": "String" 70 | }, 71 | "typeName": "String", 72 | "underlyingTypeName": "String" 73 | } 74 | ], 75 | "kind": "OBJECT", 76 | "name": "Result" 77 | }, 78 | { 79 | "inputFields": [ 80 | { 81 | "name": "match", 82 | "type": { 83 | "kind": "INPUT_OBJECT", 84 | "name": "Match" 85 | }, 86 | "typeName": "Match", 87 | "underlyingTypeName": "Match" 88 | }, 89 | { 90 | "name": "limit", 91 | "type": { 92 | "kind": "SCALAR", 93 | "name": "Int" 94 | }, 95 | "typeName": "Int", 96 | "underlyingTypeName": "Int" 97 | } 98 | ], 99 | "kind": "INPUT_OBJECT", 100 | "name": "SearchQuery" 101 | } 102 | ] 103 | } -------------------------------------------------------------------------------- /pkg/model/testdata/cases/defaults/in.graphql: -------------------------------------------------------------------------------- 1 | input SearchQuery { 2 | match: Match 3 | limit: Int 4 | } 5 | 6 | input Match { 7 | substring: String 8 | regex: String 9 | } 10 | 11 | type Query { 12 | search(q: SearchQuery = {match: {substring: "foo"}, limit: 10}): [Result] 13 | } 14 | 15 | type Result { 16 | title: String 17 | } -------------------------------------------------------------------------------- /pkg/model/testdata/cases/descriptions/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { 4 | "fields": [ 5 | { 6 | "name": "foo", 7 | "type": { 8 | "kind": "SCALAR", 9 | "name": "String" 10 | }, 11 | "typeName": "String", 12 | "underlyingTypeName": "String" 13 | } 14 | ], 15 | "kind": "OBJECT", 16 | "name": "Bar" 17 | }, 18 | { 19 | "enumValues": [ 20 | { 21 | "description": "This one has a description", 22 | "name": "CHOCOLATE" 23 | }, 24 | { 25 | "name": "VANILLA" 26 | }, 27 | { 28 | "name": "STRAWBERRY" 29 | } 30 | ], 31 | "kind": "ENUM", 32 | "name": "Flavor" 33 | }, 34 | { 35 | "description": "This type has a description", 36 | "fields": [ 37 | { 38 | "description": "This field has a description", 39 | "name": "withDescription", 40 | "type": { 41 | "kind": "SCALAR", 42 | "name": "String" 43 | }, 44 | "typeName": "String", 45 | "underlyingTypeName": "String" 46 | }, 47 | { 48 | "name": "noDescription", 49 | "type": { 50 | "kind": "SCALAR", 51 | "name": "String" 52 | }, 53 | "typeName": "String", 54 | "underlyingTypeName": "String" 55 | }, 56 | { 57 | "arguments": [ 58 | { 59 | "description": "Description for first arg", 60 | "name": "firstArg", 61 | "type": { 62 | "kind": "SCALAR", 63 | "name": "Int" 64 | }, 65 | "typeName": "Int", 66 | "underlyingTypeName": "Int" 67 | } 68 | ], 69 | "name": "argDesc", 70 | "type": { 71 | "kind": "SCALAR", 72 | "name": "String" 73 | }, 74 | "typeName": "String", 75 | "underlyingTypeName": "String" 76 | } 77 | ], 78 | "kind": "OBJECT", 79 | "name": "Foo" 80 | }, 81 | { 82 | "description": "This union has a description", 83 | "kind": "UNION", 84 | "name": "Things", 85 | "possibleTypeNames": [ 86 | "Foo", 87 | "Bar" 88 | ] 89 | } 90 | ] 91 | } -------------------------------------------------------------------------------- /pkg/model/testdata/cases/descriptions/in.graphql: -------------------------------------------------------------------------------- 1 | "This type has a description" 2 | type Foo { 3 | "This field has a description" 4 | withDescription: String 5 | 6 | noDescription: String 7 | 8 | argDesc( 9 | "Description for first arg" 10 | firstArg: Int 11 | ): String 12 | } 13 | 14 | type Bar { 15 | foo: String 16 | } 17 | 18 | "This union has a description" 19 | union Things = Foo | Bar 20 | 21 | enum Flavor { 22 | "This one has a description" 23 | CHOCOLATE 24 | VANILLA 25 | STRAWBERRY 26 | } -------------------------------------------------------------------------------- /pkg/model/testdata/cases/directives/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "directives": [ 3 | { 4 | "arguments": [ 5 | { 6 | "defaultValue": "because i said so", 7 | "name": "reason", 8 | "type": { 9 | "kind": "SCALAR", 10 | "name": "String" 11 | }, 12 | "typeName": "String", 13 | "underlyingTypeName": "String" 14 | } 15 | ], 16 | "description": "", 17 | "locations": [ 18 | "OBJECT", 19 | "FIELD_DEFINITION" 20 | ], 21 | "name": "fancy", 22 | "repeatable": false 23 | } 24 | ], 25 | "types": [ 26 | { 27 | "directives": [ 28 | { 29 | "arguments": [ 30 | { 31 | "name": "reason", 32 | "value": "it just is" 33 | } 34 | ], 35 | "name": "fancy" 36 | } 37 | ], 38 | "fields": [ 39 | { 40 | "directives": [ 41 | { 42 | "name": "fancy" 43 | } 44 | ], 45 | "name": "name", 46 | "type": { 47 | "kind": "SCALAR", 48 | "name": "String" 49 | }, 50 | "typeName": "String", 51 | "underlyingTypeName": "String" 52 | } 53 | ], 54 | "kind": "OBJECT", 55 | "name": "Foo" 56 | } 57 | ] 58 | } -------------------------------------------------------------------------------- /pkg/model/testdata/cases/directives/in.graphql: -------------------------------------------------------------------------------- 1 | directive @fancy(reason: String = "because i said so") on OBJECT | FIELD_DEFINITION 2 | 3 | type Foo @fancy(reason: "it just is") { 4 | name: String @fancy 5 | } -------------------------------------------------------------------------------- /pkg/model/testdata/cases/enums/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { 4 | "fields": [ 5 | { 6 | "arguments": [ 7 | { 8 | "name": "order", 9 | "type": { 10 | "kind": "NON_NULL", 11 | "ofType": { 12 | "kind": "ENUM", 13 | "name": "Order" 14 | } 15 | }, 16 | "typeName": "Order!", 17 | "underlyingTypeName": "Order" 18 | } 19 | ], 20 | "name": "names", 21 | "type": { 22 | "kind": "LIST", 23 | "ofType": { 24 | "kind": "NON_NULL", 25 | "ofType": { 26 | "kind": "SCALAR", 27 | "name": "String" 28 | } 29 | } 30 | }, 31 | "typeName": "[String!]", 32 | "underlyingTypeName": "String" 33 | } 34 | ], 35 | "kind": "OBJECT", 36 | "name": "Foo" 37 | }, 38 | { 39 | "enumValues": [ 40 | { 41 | "name": "ASCENDING" 42 | }, 43 | { 44 | "name": "DESCENDING" 45 | } 46 | ], 47 | "kind": "ENUM", 48 | "name": "Order" 49 | } 50 | ] 51 | } -------------------------------------------------------------------------------- /pkg/model/testdata/cases/enums/in.graphql: -------------------------------------------------------------------------------- 1 | enum Order { 2 | ASCENDING 3 | DESCENDING 4 | } 5 | 6 | type Foo { 7 | names(order: Order!): [String!] 8 | } -------------------------------------------------------------------------------- /pkg/model/testdata/cases/fieldargs/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { 4 | "inputFields": [ 5 | { 6 | "name": "prefix", 7 | "type": { 8 | "kind": "SCALAR", 9 | "name": "String" 10 | }, 11 | "typeName": "String", 12 | "underlyingTypeName": "String" 13 | }, 14 | { 15 | "name": "suffix", 16 | "type": { 17 | "kind": "SCALAR", 18 | "name": "String" 19 | }, 20 | "typeName": "String", 21 | "underlyingTypeName": "String" 22 | } 23 | ], 24 | "kind": "INPUT_OBJECT", 25 | "name": "Filter" 26 | }, 27 | { 28 | "fields": [ 29 | { 30 | "arguments": [ 31 | { 32 | "name": "first", 33 | "type": { 34 | "kind": "SCALAR", 35 | "name": "Int" 36 | }, 37 | "typeName": "Int", 38 | "underlyingTypeName": "Int" 39 | }, 40 | { 41 | "name": "filter", 42 | "type": { 43 | "kind": "INPUT_OBJECT", 44 | "name": "Filter" 45 | }, 46 | "typeName": "Filter", 47 | "underlyingTypeName": "Filter" 48 | } 49 | ], 50 | "name": "people", 51 | "type": { 52 | "kind": "OBJECT", 53 | "name": "Person" 54 | }, 55 | "typeName": "Person", 56 | "underlyingTypeName": "Person" 57 | } 58 | ], 59 | "kind": "OBJECT", 60 | "name": "Foo" 61 | }, 62 | { 63 | "fields": [ 64 | { 65 | "name": "name", 66 | "type": { 67 | "kind": "SCALAR", 68 | "name": "String" 69 | }, 70 | "typeName": "String", 71 | "underlyingTypeName": "String" 72 | } 73 | ], 74 | "kind": "OBJECT", 75 | "name": "Person" 76 | } 77 | ] 78 | } -------------------------------------------------------------------------------- /pkg/model/testdata/cases/fieldargs/in.graphql: -------------------------------------------------------------------------------- 1 | type Foo { 2 | people(first: Int, filter: Filter): Person 3 | } 4 | 5 | type Person { 6 | name: String 7 | } 8 | 9 | input Filter { 10 | prefix: String 11 | suffix: String 12 | } 13 | -------------------------------------------------------------------------------- /pkg/model/testdata/cases/wrapping/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { 4 | "fields": [ 5 | { 6 | "name": "name", 7 | "type": { 8 | "kind": "SCALAR", 9 | "name": "String" 10 | }, 11 | "typeName": "String", 12 | "underlyingTypeName": "String" 13 | }, 14 | { 15 | "name": "nameNotNull", 16 | "type": { 17 | "kind": "NON_NULL", 18 | "ofType": { 19 | "kind": "SCALAR", 20 | "name": "String" 21 | } 22 | }, 23 | "typeName": "String!", 24 | "underlyingTypeName": "String" 25 | }, 26 | { 27 | "name": "names", 28 | "type": { 29 | "kind": "LIST", 30 | "ofType": { 31 | "kind": "SCALAR", 32 | "name": "String" 33 | } 34 | }, 35 | "typeName": "[String]", 36 | "underlyingTypeName": "String" 37 | }, 38 | { 39 | "name": "namesNotNull", 40 | "type": { 41 | "kind": "NON_NULL", 42 | "ofType": { 43 | "kind": "LIST", 44 | "ofType": { 45 | "kind": "SCALAR", 46 | "name": "String" 47 | } 48 | } 49 | }, 50 | "typeName": "[String]!", 51 | "underlyingTypeName": "String" 52 | }, 53 | { 54 | "name": "namesNotNullElementsNotNull", 55 | "type": { 56 | "kind": "NON_NULL", 57 | "ofType": { 58 | "kind": "LIST", 59 | "ofType": { 60 | "kind": "NON_NULL", 61 | "ofType": { 62 | "kind": "SCALAR", 63 | "name": "String" 64 | } 65 | } 66 | } 67 | }, 68 | "typeName": "[String!]!", 69 | "underlyingTypeName": "String" 70 | } 71 | ], 72 | "kind": "OBJECT", 73 | "name": "Foo" 74 | } 75 | ] 76 | } -------------------------------------------------------------------------------- /pkg/model/testdata/cases/wrapping/in.graphql: -------------------------------------------------------------------------------- 1 | type Foo { 2 | name: String 3 | nameNotNull: String! 4 | names: [String] 5 | namesNotNull: [String]! 6 | namesNotNullElementsNotNull: [String!]! 7 | } 8 | -------------------------------------------------------------------------------- /pkg/model/type.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/vektah/gqlparser/v2/ast" 7 | ) 8 | 9 | type TypeKind string 10 | 11 | // See __TypeKind in the spec: 12 | // https://spec.graphql.org/October2021/#sel-GAJXNFAD7EAADxFAB45Y 13 | const ( 14 | UnknownKind = TypeKind("") 15 | ScalarKind = TypeKind("SCALAR") 16 | ObjectKind = TypeKind("OBJECT") 17 | InterfaceKind = TypeKind("INTERFACE") 18 | UnionKind = TypeKind("UNION") 19 | EnumKind = TypeKind("ENUM") 20 | InputKind = TypeKind("INPUT_OBJECT") 21 | ListKind = TypeKind("LIST") 22 | NonNullKind = TypeKind("NON_NULL") 23 | ) 24 | 25 | type Type struct { 26 | Kind TypeKind `json:"kind"` 27 | Name string `json:"name,omitempty"` 28 | OfType *Type `json:"ofType,omitempty"` 29 | } 30 | 31 | func (t *Type) Unwrap() *Type { 32 | if t.OfType != nil { 33 | return t.OfType.Unwrap() 34 | } 35 | 36 | return t 37 | } 38 | 39 | func (t *Type) String() string { 40 | switch t.Kind { 41 | case NonNullKind: 42 | return fmt.Sprintf("%s!", t.OfType.String()) 43 | case ListKind: 44 | return fmt.Sprintf("[%s]", t.OfType.String()) 45 | default: 46 | return t.Name 47 | } 48 | } 49 | 50 | func makeType(in *ast.Type) *Type { 51 | if in == nil { 52 | return nil 53 | } 54 | 55 | if in.NonNull { 56 | return &Type{ 57 | Kind: NonNullKind, 58 | OfType: makeType(&ast.Type{ 59 | NamedType: in.NamedType, 60 | Elem: in.Elem, 61 | }), 62 | } 63 | } 64 | 65 | if in.Elem != nil { 66 | return &Type{ 67 | Kind: ListKind, 68 | OfType: makeType(in.Elem), 69 | } 70 | } 71 | 72 | return &Type{ 73 | Kind: UnknownKind, 74 | Name: in.NamedType, 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /pkg/model/value.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/vektah/gqlparser/v2/ast" 9 | ) 10 | 11 | type Value any 12 | 13 | func makeValue(in *ast.Value) (Value, error) { 14 | if in == nil { 15 | return nil, nil 16 | } 17 | 18 | switch in.Kind { 19 | case ast.IntValue: 20 | return strconv.ParseInt(in.Raw, 10, 64) 21 | case ast.FloatValue: 22 | return strconv.ParseFloat(in.Raw, 32) 23 | case ast.StringValue: 24 | return in.Raw, nil 25 | case ast.BooleanValue: 26 | return strconv.ParseBool(in.Raw) 27 | case ast.NullValue: 28 | return json.Marshal(nil) 29 | case ast.EnumValue: 30 | return json.Marshal(in.Raw) 31 | case ast.ListValue: 32 | var l []any 33 | for _, cv := range in.Children { 34 | entry, err := makeValue(cv.Value) 35 | if err != nil { 36 | return nil, err 37 | } 38 | l = append(l, entry) 39 | } 40 | return l, nil 41 | case ast.ObjectValue: 42 | m := map[string]any{} 43 | for _, cv := range in.Children { 44 | val, err := makeValue(cv.Value) 45 | if err != nil { 46 | return nil, err 47 | } 48 | m[cv.Name] = val 49 | } 50 | return m, nil 51 | default: 52 | return nil, fmt.Errorf("unsupported kind: %v", in.Kind) 53 | } 54 | } 55 | --------------------------------------------------------------------------------