Skip to content

Commit

Permalink
Custom header support + split parser into own file
Browse files Browse the repository at this point in the history
  • Loading branch information
jkmartindale committed May 24, 2022
1 parent 992780e commit f915306
Show file tree
Hide file tree
Showing 3 changed files with 157 additions and 116 deletions.
31 changes: 26 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@ CLI tool to pull the GraphQL schema (as GraphQL SDL or JSON) from a given endpoi
This shouldn't have to be a standalone tool, but the state of GraphQL tooling is very unstable and I found myself unable to find a tool I could run just to get a remote GraphQL schema in SDL form. Every recommendation I found either:
- Only returns JSON
- No longer works or isn't maintained
- Removed the SDL download feature at some point
- Removed the schema download feature at some point
- Requires you to use their specific full-stack workflow

None of that makes sense, so I made this stupid script that does one thing and does it well. Please make it obsolete.
None of that makes sense, so I made this stupid script that does one thing and does it well enough. Please make it obsolete.

## Installation
```shell
npm install -g gqlschema
```

gqlschema is also compatible with `npx` if you prefer not to add it to your PATH.
gqlschema is also compatible with `npx` if you prefer not to add it to your `PATH`.

## Usage
By default, gqlschema outputs the SDL to stdout:
Expand All @@ -31,18 +31,38 @@ type Root {
allPeople(after: String, first: Int, before: String, last: Int): PeopleConnection
person(id: ID, personID: ID): Person
allPlanets(after: String, first: Int, before: String, last: Int): PlanetsConnection
...[truncated]
...
```
Output can also be saved to a file with the `-o FILE` option. If you specify both `--json` and `--sdl`, `FILE` will be used as a base filename and output will be saved to `FILE.json` and `FILE.graphql` accordingly.
Use the `-H HEADER` option to send headers (cookies, authorization, user agent, etc.) with the introspection query. For example, the GitHub GraphQL API requires a personal access token:
```shell
$ gqlschema https://api.github.com/graphql
Failed to connect to API endpoint.
HTTPError: Response code 401 (Unauthorized)
$ gqlschema https://api.github.com/graphql -H "Authorization: Bearer ghp_[redacted]"
directive @requiredCapabilities(requiredCapabilities: [String!]) on OBJECT | SCALAR | ARGUMENT_DEFINITION | INTERFACE | INPUT_OBJECT | FIELD_DEFINITION | ENUM | ENUM_VALUE | UNION | INPUT_FIELD_DEFINITION

"""Autogenerated input type of AbortQueuedMigrations"""
input AbortQueuedMigrationsInput {
"""The ID of the organization that is running the migrations."""
ownerId: ID!

"""A unique identifier for the client performing the mutation."""
clientMutationId: String
}
...
```
gqlschema supports introspection options provided by [GraphQL.js](https://github.com/graphql/graphql-js). These flags may not be compatible with all GraphQL servers (especially `--specified-by-url` and `--input-value-deprecation`) and could cause the introspection to fail.
### Full Usage
```
$ gqlschema --help
usage: gqlschema [-h] [-s] [-j] [-o FILE] [-N] [-D] [-R] [-S] [-I] endpoint
usage: index.js [-h] [-s] [-j] [-H HEADER] [-o FILE] [-N] [-D] [-R] [-S] [-I] endpoint

positional arguments:
endpoint GraphQL endpoint with introspection enabled
Expand All @@ -51,6 +71,7 @@ optional arguments:
-h, --help show this help message and exit
-s, --sdl download schema as GraphQL SDL (default)
-j, --json download schema as JSON
-H, --header HEADER add HTTP request header (can be specified multiple times)
-o, --output FILE output to the specified file instead of stdout

introspection options:
Expand Down
115 changes: 4 additions & 111 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,123 +1,15 @@
#!/usr/bin/env node

// Chose argparse over meow because of automatic help generation and over yargs because the help messages for positional arguments are more helpful
import { Action, ArgumentParser, HelpFormatter, Namespace } from 'argparse'
import * as fs from 'fs'
import got from 'got'
import { buildClientSchema, getIntrospectionQuery, IntrospectionQuery, printSchema } from 'graphql'
import * as path from 'path'
import { URL } from 'url'
import { parser } from './parser.js'

const CustomHelpFormatter = class extends HelpFormatter {
/**
* Expands the help position to allow argument listings to fit in one line
*/
constructor() {
super({
max_help_position: 34,
// Required if other arguments present
prog: path.basename(process.argv.slice(1)[0]),
})
}

/**
* Modified formatter that doesn't repeat the metavar in help output
*
* Before: `-s METAVAR, --long METAVAR`
* After: `-s1, --long METAVAR`
*/
_format_action_invocation(action: Action) {
if (!action.option_strings.length) {
let default_value = this._get_default_metavar_for_positional(action)
let metavar = this._metavar_formatter(action, default_value)(1)[0]
return metavar
}

// if the Optional doesn't take a value, format is:
// -s, --long
if (action.nargs === 0) {
return action.option_strings.join(', ')
}

// if the Optional takes a value, format is:
// -s, --long ARGS
let default_value = this._get_default_metavar_for_optional(action)
let args_string = this._format_args(action, default_value)
return `${action.option_strings.join(', ')} ${args_string}`
}
}

const parser = new class extends ArgumentParser {
constructor() {
super({
formatter_class: CustomHelpFormatter
})
}
/**
* Augments the standard argument parser with a couple defaults not expressible with the argparse API:
* - If `-j, --json` isn't specified, `-s, --sdl` is assumed
* - If exporting both JSON and SDL, the given filename gets `.json` and `.graphql` appended
*/
parse_args(args?: string[], ns?: Namespace | object): Namespace {
const parsed = super.parse_args(args, ns)

if (!parsed.json) {
parsed.sdl = true
}

parsed.output_json = parsed.output_sdl = parsed.output
if (parsed.output && parsed.sdl && parsed.json) {
parsed.output_json += '.json'
parsed.output_sdl += '.graphql'
}

return parsed
}
}
parser.add_argument('endpoint', {
help: 'GraphQL endpoint with introspection enabled'
})
parser.add_argument('-s', '--sdl', {
action: 'store_true',
help: 'download schema as GraphQL SDL (default)',
})
parser.add_argument('-j', '--json', {
action: 'store_true',
help: 'download schema as JSON'
})
parser.add_argument('-o', '--output', {
action: 'store',
help: 'output to the specified file instead of stdout',
metavar: 'FILE',
})
const introspectionGroup = parser.add_argument_group({ title: 'introspection options' })
introspectionGroup.add_argument('-N', '--no-descriptions', {
action: 'store_false',
help: "don't include descriptions in the introspection result",
})
introspectionGroup.add_argument('-D', '--schema-description', {
action: 'store_true',
help: 'include `description` field on schema',
dest: 'schemaDescription',
})
introspectionGroup.add_argument('-R', '--repeatable-directives', {
action: 'store_true',
help: 'include `isRepeatable` flag on directives',
dest: 'directiveIsRepeatable',
})
introspectionGroup.add_argument('-S', '--specified-by-url', {
action: 'store_true',
help: 'include `specifiedByURL` in the introspection result',
dest: 'specifiedByUrl',
})
introspectionGroup.add_argument('-I', '--input-value-deprecation', {
action: 'store_true',
help: 'query deprecation of input values',
dest: 'inputValueDeprecation',
})
let args = parser.parse_args()

async function getQueryData(endpoint: string, query: string): Promise<IntrospectionQuery> {
async function getQueryData(endpoint: string, query: string, headers?: any): Promise<IntrospectionQuery> {
try {
new URL(endpoint)
} catch (error) {
Expand All @@ -127,6 +19,7 @@ async function getQueryData(endpoint: string, query: string): Promise<Introspect

try {
const { data } = await got.post({
headers,
http2: true,
json: { query },
url: endpoint
Expand All @@ -147,7 +40,7 @@ async function outputResult(data: string, file: fs.PathLike | fs.promises.FileHa
}

// Grab the JSON first (needed to generate SDL) and write it out if requested
const introspectionData = await getQueryData(args.endpoint, getIntrospectionQuery(args))
const introspectionData = await getQueryData(args.endpoint, getIntrospectionQuery(args), args.headers)
if (args.json) {
const json = JSON.stringify(introspectionData) + '\n'
outputResult(json, args.output_json)
Expand Down
127 changes: 127 additions & 0 deletions src/parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { Action, ArgumentParser, HelpFormatter, Namespace } from 'argparse'
import * as path from 'path'

const CustomHelpFormatter = class extends HelpFormatter {
/**
* Expands the help position to allow argument listings to fit in one line
*/
constructor() {
super({
max_help_position: 34,
// Required if other arguments present
prog: path.basename(process.argv.slice(1)[0]),
})
}

/**
* Modified formatter that doesn't repeat the metavar in help output
*
* Before: `-s METAVAR, --long METAVAR`
* After: `-s1, --long METAVAR`
*/
_format_action_invocation(action: Action) {
if (!action.option_strings.length) {
let default_value = this._get_default_metavar_for_positional(action)
let metavar = this._metavar_formatter(action, default_value)(1)[0]
return metavar
}

// if the Optional doesn't take a value, format is:
// -s, --long
if (action.nargs === 0) {
return action.option_strings.join(', ')
}

// if the Optional takes a value, format is:
// -s, --long ARGS
let default_value = this._get_default_metavar_for_optional(action)
let args_string = this._format_args(action, default_value)
return `${action.option_strings.join(', ')} ${args_string}`
}
}

export const parser = new class extends ArgumentParser {
constructor() {
super({
formatter_class: CustomHelpFormatter
})
}
/**
* Modified default argument parsing with behavior not expressible with the argparse API:
* - If `-j, --json` isn't specified, `-s, --sdl` is assumed
* - If exporting both JSON and SDL, the given filename gets `.json` and `.graphql` appended
* - Conversion of the `header` array to a key-value store
*/
parse_args(args?: string[], ns?: Namespace | object): Namespace {
const parsed = super.parse_args(args, ns)

if (!parsed.json) {
parsed.sdl = true
}

parsed.output_json = parsed.output_sdl = parsed.output
if (parsed.output && parsed.sdl && parsed.json) {
parsed.output_json += '.json'
parsed.output_sdl += '.graphql'
}

if (parsed.headers) {
parsed.headers = parsed.headers.reduce((headers: any, current: string) => {
const split = current.split(':')
headers[split[0].trim()] = split[1].trim()
return headers
}, {})
} else {
parsed.headers = {}
}

return parsed
}
}
parser.add_argument('endpoint', {
help: 'GraphQL endpoint with introspection enabled',
})
parser.add_argument('-s', '--sdl', {
action: 'store_true',
help: 'download schema as GraphQL SDL (default)',
})
parser.add_argument('-j', '--json', {
action: 'store_true',
help: 'download schema as JSON',
})
parser.add_argument('-H', '--header', {
action: 'append',
dest: 'headers',
help: 'add HTTP request header (can be specified multiple times)',
metavar: 'HEADER'
})
parser.add_argument('-o', '--output', {
action: 'store',
help: 'output to the specified file instead of stdout',
metavar: 'FILE',
})
const introspectionGroup = parser.add_argument_group({ title: 'introspection options' })
introspectionGroup.add_argument('-N', '--no-descriptions', {
action: 'store_false',
help: "don't include descriptions in the introspection result",
})
introspectionGroup.add_argument('-D', '--schema-description', {
action: 'store_true',
dest: 'schemaDescription',
help: 'include `description` field on schema',
})
introspectionGroup.add_argument('-R', '--repeatable-directives', {
action: 'store_true',
dest: 'directiveIsRepeatable',
help: 'include `isRepeatable` flag on directives',
})
introspectionGroup.add_argument('-S', '--specified-by-url', {
action: 'store_true',
dest: 'specifiedByUrl',
help: 'include `specifiedByURL` in the introspection result',
})
introspectionGroup.add_argument('-I', '--input-value-deprecation', {
action: 'store_true',
dest: 'inputValueDeprecation',
help: 'query deprecation of input values',
})

0 comments on commit f915306

Please sign in to comment.