├── .github
├── graphql-megaera.svg
└── workflows
│ └── test.yml
├── .gitignore
├── LICENSE
├── README.md
├── package.json
├── src
├── cli.ts
├── generate.test.ts
├── generate.ts
├── index.test.ts
├── index.ts
├── utils.ts
└── visitor.ts
└── tsconfig.json
/.github/graphql-megaera.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | test:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v4
10 | - uses: actions/setup-node@v4
11 | with:
12 | node-version: 22.x
13 | - run: npm i
14 | - run: npm test
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 | /dist/
3 | package-lock.json
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Webpod
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GraphQL Megaera
2 |
3 |
4 |
5 | GraphQL to TypeScript Generator
6 |
7 |
8 |
9 | ## Example
10 |
11 |
12 |
13 | From GraphQL
14 | To TypeScript
15 |
16 |
17 |
18 |
19 | ```graphql
20 | query IssuesQuery {
21 | issues(first: 100) {
22 | totalCount
23 | nodes {
24 | createdAt
25 | closedAt
26 | closed
27 | author {
28 | login
29 | }
30 | number
31 | title
32 | labels(first: 10) {
33 | totalCount
34 | nodes {
35 | name
36 | }
37 | }
38 | body
39 | comments(first: 10) {
40 | totalCount
41 | nodes {
42 | body
43 | }
44 | }
45 | repository {
46 | owner {
47 | login
48 | }
49 | name
50 | }
51 | }
52 | pageInfo {
53 | hasNextPage
54 | endCursor
55 | }
56 | }
57 | rateLimit {
58 | limit
59 | cost
60 | remaining
61 | resetAt
62 | }
63 | }
64 | ```
65 |
66 |
67 |
68 |
69 | ```ts
70 | type IssuesQuery = () => {
71 | issues: {
72 | totalCount: number
73 | nodes: Array<{
74 | createdAt: string
75 | closedAt: string | null
76 | closed: boolean
77 | author: {
78 | login: string
79 | }
80 | number: number
81 | title: string
82 | labels: {
83 | totalCount: number
84 | nodes: Array<{
85 | name: string
86 | }>
87 | }
88 | body: string
89 | comments: {
90 | totalCount: number
91 | nodes: Array<{
92 | body: string
93 | }>
94 | }
95 | repository: {
96 | owner: {
97 | login: string
98 | }
99 | name: string
100 | }
101 | }>
102 | pageInfo: {
103 | hasNextPage: boolean
104 | endCursor: string | null
105 | }
106 | }
107 | rateLimit: {
108 | limit: number
109 | cost: number
110 | remaining: number
111 | resetAt: string
112 | }
113 | }
114 | ```
115 |
116 |
117 |
118 |
119 |
120 | ## Installation
121 |
122 | ```bash
123 | npm install megaera
124 | ```
125 |
126 | ## Usage
127 |
128 | ```bash
129 | megaera --schema=schema.graphql ./src/**/*.graphql
130 | ```
131 |
132 | Megaera will generate TypeScript code for all queries in the specified files.
133 |
134 | ## FAQ
135 |
136 |
137 | How to use Megaera?
138 |
139 | Put your queries in `.graphql` files, and run `megaera` to generate TypeScript code from them.
140 |
141 | Megaera will copy the query string to the generated TypeScript file, so you can
142 | import it in your TypeScript code.
143 |
144 | ```ts
145 | import { IssuesQuery } from './query.graphql.ts'
146 | ```
147 |
148 | The `IssuesQuery` variable is a string with the GraphQL query. You can use it
149 | directly in your code, or pass it to a function that accepts a query.
150 |
151 | Also, `IssuesQuery` carries the type of the query, so you can use it to infer
152 | the return type of the query, and the types of the input variables.
153 |
154 | ```ts
155 | type Result = ReturnType
156 | ```
157 |
158 | The type `IssuesQuery` can also be used independently:
159 |
160 | ```ts
161 | import type { IssuesQuery } from './query.graphql.ts'
162 | ```
163 |
164 |
165 |
166 |
167 | How to get the return type of a query?
168 |
169 | Megaera generates TypeScript types for queries as functions.
170 |
171 | ```ts
172 | type UserQuery = (vars: { login?: string }) => {
173 | user: {
174 | login: string
175 | avatarUrl: string
176 | name: string
177 | }
178 | }
179 | ```
180 |
181 | To get the return type of a query, use the `ReturnType` utility type:
182 |
183 | ```ts
184 | type Result = ReturnType
185 | ```
186 |
187 |
188 |
189 |
190 | How to get the types of the variables of a query?
191 |
192 | The first parameter of the query function is the variables.
193 |
194 | You can use TypeScript's `Parameters` utility type to get the types of the variables:
195 |
196 | ```ts
197 | type Variables = Parameters[0]
198 | ```
199 |
200 | Or you can use the `Variables` utility type to get the types of the variables:
201 |
202 | ```ts
203 | import { Variables } from 'megaera'
204 |
205 | type Variables = Variables
206 | ```
207 |
208 |
209 |
210 |
211 | Why query string is copied to TypeScript file as well?
212 |
213 | To make it easier to import queries in TypeScript projects. As well to connect
214 | generated output types with query source code.
215 |
216 | This allows for library authors to create a function that accepts a query, and
217 | infers the return type from the query, as well as the types of the variables.
218 |
219 | For example, wrap [Octokit](https://github.com/octokit/octokit.js) in a function
220 | that accepts a query and returns the result:
221 |
222 | ```ts
223 | import { Query, Variables } from 'megaera'
224 | import { IssuesQuery } from './query.graphql.ts'
225 |
226 | function query(query: T, variables?: Variables) {
227 | return octokit.graphql>(query, variables)
228 | }
229 |
230 | // Return type, and types of variables are inferred from the query.
231 | const { issues } = await query(IssuesQuery, { login: 'webpod' })
232 | ```
233 |
234 |
235 |
236 |
237 | Does Megaera support fragments?
238 |
239 | Yes, Megaera fully supports fragments. Fragments are generated as separate types,
240 | and can be used independently.
241 |
242 | ```graphql
243 | query IssuesQuery($login: String) {
244 | issues(login: $login) {
245 | totalCount
246 | nodes {
247 | ...Issue
248 | }
249 | }
250 | }
251 |
252 | fragment Issue on Issue {
253 | number
254 | author {
255 | login
256 | }
257 | createdAt
258 | closedAt
259 | }
260 | ```
261 |
262 | The generated TypeScript code will have a type `Issue` that can be used independently:
263 |
264 | ```ts
265 | import { Issue, IssuesQuery } from './query.graphql.ts'
266 |
267 | const firstIssue: Issue = query(IssuesQuery).issues.nodes[0]
268 | ```
269 |
270 |
271 |
272 |
273 | Can Megaera extract queries from `.ts` files?
274 |
275 | No. To simplify development of Megaera, it is only possible to extract queries
276 | from `.graphql` files.
277 |
278 | But it should be possible to create plugins for webpack, rollup, or other
279 | bundlers that can extract queries from `.ts` files. If you are interested in
280 | this, please open an issue.
281 |
282 |
283 |
284 | ## License
285 |
286 | [MIT](LICENSE)
287 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "megaera",
3 | "version": "1.0.2",
4 | "description": "GraphQL to TypeScript Generator",
5 | "type": "module",
6 | "main": "./dist/index.js",
7 | "types": "./dist/index.d.ts",
8 | "bin": {
9 | "megaera": "./dist/cli.js"
10 | },
11 | "scripts": {
12 | "fmt": "prettier --write .",
13 | "fmt:check": "prettier --check .",
14 | "build": "tsc",
15 | "test": "vitest",
16 | "prepublishOnly": "npm run build"
17 | },
18 | "dependencies": {
19 | "graphql": "^16.0.0"
20 | },
21 | "devDependencies": {
22 | "@types/node": "^20.14.10",
23 | "prettier": "^3.3.2",
24 | "typescript": "^5.5.3",
25 | "vitest": "^2.0.1"
26 | },
27 | "files": [
28 | "dist"
29 | ],
30 | "prettier": {
31 | "semi": false,
32 | "singleQuote": true,
33 | "endOfLine": "lf"
34 | },
35 | "repository": {
36 | "type": "git",
37 | "url": "git+https://github.com/webpod/graphql-megaera.git"
38 | },
39 | "keywords": [
40 | "graphql",
41 | "typescript",
42 | "generator"
43 | ],
44 | "author": "Anton Medvedev ",
45 | "license": "MIT",
46 | "homepage": "https://github.com/webpod/graphql-megaera"
47 | }
48 |
--------------------------------------------------------------------------------
/src/cli.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import process from 'node:process'
4 | import * as os from 'node:os'
5 | import * as fs from 'node:fs'
6 | import * as path from 'node:path'
7 | import { buildSchema } from 'graphql/utilities/index.js'
8 | import { Source } from 'graphql/language/index.js'
9 | import { GraphQLSchema } from 'graphql/type/index.js'
10 | import { GraphQLError } from 'graphql/error/index.js'
11 | import { styleText } from 'node:util'
12 | import { traverse } from './visitor.js'
13 | import { generate } from './generate.js'
14 | import { plural } from './utils.js'
15 |
16 | void (async function main() {
17 | let schemaFileOrUrl: string | undefined
18 | let inputFiles: string[] = []
19 |
20 | const args = process.argv.slice(2)
21 | if (args.length === 0) {
22 | usage()
23 | return
24 | }
25 | for (let i = 0; i < args.length; i++) {
26 | const arg = args[i]
27 | if (arg === '--help' || arg === '-h') {
28 | usage()
29 | return
30 | } else if (arg == '--schema') {
31 | schemaFileOrUrl = args[++i]
32 | } else if (arg.startsWith('--schema=')) {
33 | schemaFileOrUrl = arg.slice('--schema='.length)
34 | } else if (arg.startsWith('--')) {
35 | console.error(`Unknown flag: ${arg}`)
36 | process.exitCode = 1
37 | return
38 | } else {
39 | inputFiles.push(arg)
40 | }
41 | }
42 |
43 | if (schemaFileOrUrl === undefined) {
44 | console.error(
45 | `Missing schema file or URL. Use --schema= flag to specify it.`,
46 | )
47 | process.exitCode = 1
48 | return
49 | }
50 | if (inputFiles.length === 0) {
51 | console.error(
52 | `Missing input files. Specify input files ./src/**/*.graphql to generate types.`,
53 | )
54 | process.exitCode = 1
55 | return
56 | }
57 |
58 | schemaFileOrUrl = homeDirExpand(schemaFileOrUrl)
59 | inputFiles = inputFiles.map((f) => homeDirExpand(f))
60 |
61 | let schemaSource: string
62 | if (/https?:\/\//.test(schemaFileOrUrl)) {
63 | const headers = new Headers()
64 | if (process.env.GITHUB_TOKEN) {
65 | const token = process.env.GITHUB_TOKEN
66 | headers.set('Authorization', `Bearer ${token}`)
67 | }
68 | const using = headers.has('Authorization') ? ` using $GITHUB_TOKEN` : ``
69 | console.log(`Fetching schema from ${schemaFileOrUrl}${using}.`)
70 | schemaSource = await fetch(schemaFileOrUrl, { headers }).then((r) =>
71 | r.text(),
72 | )
73 | } else {
74 | schemaSource = fs.readFileSync(schemaFileOrUrl, 'utf-8')
75 | }
76 |
77 | let schema: GraphQLSchema
78 | try {
79 | schema = buildSchema(schemaSource)
80 | } catch (e) {
81 | console.error(
82 | styleText(['bgRed', 'whiteBright', 'bold'], `Failed to parse schema`),
83 | )
84 | throw e
85 | }
86 |
87 | for (let inputFile of inputFiles) {
88 | const dirName = path.dirname(inputFile)
89 | const fileName = path.basename(inputFile)
90 |
91 | console.log(`Processing ${inputFile}`)
92 |
93 | const source = new Source(fs.readFileSync(inputFile, 'utf-8'), fileName)
94 | const content = traverse(schema, source)
95 | const code = generate(content)
96 |
97 | const ops = plural(
98 | content.operations.length,
99 | '%d operation',
100 | '%d operations',
101 | )
102 | const frg = plural(content.fragments.size, '%d fragment', '%d fragments')
103 | console.log(`> ${styleText('green', 'done')} (${ops}, ${frg})`)
104 |
105 | const prefix = `// DO NOT EDIT. This is a generated file. Instead of this file, edit "${fileName}".\n\n`
106 | fs.writeFileSync(
107 | path.join(dirName, fileName + '.ts'),
108 | prefix + code,
109 | 'utf-8',
110 | )
111 | }
112 | })().catch((e) => {
113 | if (e instanceof GraphQLError) {
114 | console.error(e.toString())
115 | process.exitCode = 1
116 | } else {
117 | throw e
118 | }
119 | })
120 |
121 | function usage() {
122 | console.log(`Usage: megaera [options] `)
123 | console.log(`Options:`)
124 | console.log(` --schema= GraphQL schema file or URL`)
125 | process.exitCode = 2
126 | }
127 |
128 | function homeDirExpand(file: string) {
129 | if (file.startsWith('~')) {
130 | return path.join(os.homedir(), file.slice(1))
131 | }
132 | return file
133 | }
134 |
--------------------------------------------------------------------------------
/src/generate.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from 'vitest'
2 | import { buildSchema, typeFromAST } from 'graphql/utilities/index.js'
3 | import { generateType } from './generate.js'
4 | import { isObjectType } from 'graphql/type/index.js'
5 |
6 | const schema = buildSchema(`
7 | type User {
8 | name: String!
9 | birthday: Date!
10 | gender: Gender!
11 | favoriteBooks: [String]
12 | }
13 |
14 | type Object {
15 | name: String!
16 | age: Int
17 | }
18 |
19 | scalar Date
20 |
21 | enum Gender {
22 | MALE
23 | FEMALE
24 | }
25 | `)
26 |
27 | function getFieldType(name: string, fieldName: string) {
28 | const type = schema.getType(name)
29 | if (!isObjectType(type)) {
30 | throw new Error(`${name} is not object type.`)
31 | }
32 | const field = type.astNode?.fields?.find(
33 | (f) => f.name.value === fieldName,
34 | )?.type
35 | if (!field) {
36 | throw new Error(`Cannot find ${fieldName} field in ${name}.`)
37 | }
38 | return typeFromAST(schema, field)
39 | }
40 |
41 | test('scalar type', () => {
42 | const birthdayType = getFieldType('User', 'birthday')
43 | const code = generateType(birthdayType)
44 | expect(code).toEqual('string')
45 | })
46 |
47 | test('enum type', () => {
48 | const code = generateType(schema.getType('Gender'))
49 | expect(code).toEqual(`'MALE' | 'FEMALE'`)
50 | })
51 |
52 | test('list type', () => {
53 | const favoriteBooksType = getFieldType('User', 'favoriteBooks')
54 | const code = generateType(favoriteBooksType)
55 | expect(code).toEqual('(string | null)[] | null')
56 | })
57 |
58 | test('object type', () => {
59 | const code = generateType(schema.getType('Object'))
60 | expect(code).toEqual('{name: string, age: number | null}')
61 | })
62 |
--------------------------------------------------------------------------------
/src/generate.ts:
--------------------------------------------------------------------------------
1 | import {
2 | GraphQLOutputType,
3 | GraphQLType,
4 | isEnumType,
5 | isListType,
6 | isNonNullType,
7 | isNullableType,
8 | isScalarType,
9 | } from 'graphql/type/definition.js'
10 | import { Content, Selector, Variable } from './visitor.js'
11 | import { isObjectType } from 'graphql/type/index.js'
12 |
13 | export function generate(content: Content) {
14 | const code: string[] = []
15 | for (const f of content.fragments.values()) {
16 | code.push(`const ${f.name} = \`#graphql
17 | ${f.source}\`
18 |
19 | export type ${f.name} = ${generateSelector(f, 0, true)}
20 | `)
21 | }
22 |
23 | for (const q of content.operations) {
24 | if (!q.source) {
25 | throw new Error('Empty query source for operation ' + q.name)
26 | }
27 |
28 | let querySource = q.source
29 |
30 | for (const fName of usedFragments(q, content)) {
31 | querySource = '${' + fName + '}\n' + querySource
32 | }
33 |
34 | code.push(`export const ${q.name} = \`#graphql
35 | ${querySource}\` as string & ${q.name}
36 |
37 | export type ${q.name} = (${generateVariables(q.variables)}) => ${generateSelector(q, 0, true)}
38 | `)
39 | }
40 |
41 | return code.join('\n')
42 | }
43 |
44 | function usedFragments(q: Selector, content: Content): string[] {
45 | const fragments: string[] = []
46 | for (const field of q.fields) {
47 | if (field.isFragment) {
48 | fragments.push(field.name)
49 | const fragment = content.fragments.get(field.name)
50 | if (!fragment) {
51 | throw new Error(`Fragment ${field.name} is not defined.`)
52 | }
53 | fragments.push(...usedFragments(fragment, content))
54 | }
55 | fragments.push(...usedFragments(field, content))
56 | }
57 | for (const inlineFragment of q.inlineFragments) {
58 | fragments.push(...usedFragments(inlineFragment, content))
59 | }
60 | return fragments
61 | }
62 |
63 | function generateVariables(variables?: Variable[]) {
64 | if (!variables || variables.length === 0) {
65 | return ''
66 | }
67 | return (
68 | 'vars: { ' +
69 | variables
70 | .map((v) => {
71 | return v.name + (v.required ? '' : '?') + ': ' + generateType(v.type)
72 | })
73 | .join(', ') +
74 | ' }'
75 | )
76 | }
77 |
78 | function generateSelector(s: Selector, depth = 0, nonNull = false): string {
79 | if (s.fields.length === 0 && s.inlineFragments.length === 0) {
80 | return generateType(s.type)
81 | }
82 |
83 | const code =
84 | generateFields(s, depth) +
85 | generateFragments(s) +
86 | generateInlineFragments(s, depth - 1)
87 |
88 | if (isNonNullType(s.type)) {
89 | return wrapInArray(s.type.ofType, code)
90 | }
91 |
92 | return (
93 | wrapInArray(s.type, code) +
94 | (isNullableType(s.type) && !nonNull ? ' | null' : '')
95 | )
96 | }
97 |
98 | function wrapInArray(t: GraphQLOutputType | undefined, code: string) {
99 | return isListType(t) ? `Array<${code}>` : code
100 | }
101 |
102 | function generateFragments(s: Selector): string {
103 | let code = ''
104 | for (const fragment of s.fields) {
105 | if (!fragment.isFragment) continue
106 | code += ' & ' + fragment.name
107 | }
108 | return code
109 | }
110 |
111 | function generateFields(s: Selector, depth: number): string {
112 | const nonFragmentFields = s.fields.filter((f) => !f.isFragment)
113 | if (nonFragmentFields.length === 0) {
114 | return '{}'
115 | }
116 |
117 | const code: string[] = []
118 | code.push('{')
119 | for (const field of nonFragmentFields) {
120 | code.push(
121 | ' '.repeat(depth + 1) +
122 | field.name +
123 | ': ' +
124 | generateSelector(field, depth + 1),
125 | )
126 | }
127 | code.push(' '.repeat(depth) + '}')
128 | return code.join('\n')
129 | }
130 |
131 | function generateInlineFragments(s: Selector, depth: number) {
132 | let code = ''
133 | let nullable = false
134 | for (const fragment of s.inlineFragments) {
135 | code += ' & ' + generateSelector(fragment, depth + 1, true)
136 | nullable ||= isNullableType(fragment.type)
137 | }
138 | return code + (nullable ? ' | null' : '')
139 | }
140 |
141 | export function generateType(t?: GraphQLType, orNull = ' | null'): string {
142 | if (t === undefined) {
143 | return 'unknown'
144 | }
145 | if (isNonNullType(t)) {
146 | return generateType(t.ofType, '')
147 | }
148 | if (isListType(t)) {
149 | const subType = generateType(t.ofType)
150 | if (subType.includes(' ')) {
151 | return '(' + subType + ')[]' + orNull
152 | }
153 | return subType + '[]' + orNull
154 | }
155 | if (isEnumType(t)) {
156 | return t
157 | .getValues()
158 | .map((v) => `'${v.value}'`)
159 | .join(' | ')
160 | }
161 | if (isObjectType(t)) {
162 | const code: string[] = []
163 | for (const field of Object.values(t.getFields())) {
164 | code.push(field.name + ': ' + generateType(field.type))
165 | }
166 | return '{' + code.join(', ') + '}'
167 | }
168 | if (isScalarType(t)) {
169 | switch (t.name) {
170 | case 'String':
171 | return 'string' + orNull
172 | case 'Int':
173 | return 'number' + orNull
174 | case 'Float':
175 | return 'number' + orNull
176 | case 'Boolean':
177 | return 'boolean' + orNull
178 | default:
179 | return 'string' + orNull
180 | }
181 | }
182 | throw new Error(`Cannot generate TypeScript type from GraphQL type "${t}".`)
183 | }
184 |
--------------------------------------------------------------------------------
/src/index.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from 'vitest'
2 | import { Source } from 'graphql/language/index.js'
3 | import { buildSchema } from 'graphql/utilities/index.js'
4 | import { transpile } from './index.js'
5 |
6 | const schema = buildSchema(`
7 | schema {
8 | query: Query
9 | mutation: Mutation
10 | }
11 |
12 | type Query {
13 | User(login: String!): User
14 | Users: [User!]
15 | NonNullUsers: [User!]!
16 | }
17 |
18 | type User {
19 | name: String
20 | avatarUrl: String
21 | favoriteAnimal: Animal
22 | }
23 |
24 | type Mutation {
25 | CreateUser(name: String, avatarUrl: String!): User
26 | }
27 |
28 | union Animal = User | Dog
29 |
30 | type Dog {
31 | name: String
32 | barks: Boolean
33 | }
34 |
35 | type Cat {
36 | name: String
37 | meows: Boolean
38 | }
39 | `)
40 |
41 | test('optional variable with default value', () => {
42 | const source = new Source(`
43 | query User($login: String! = "antonmedv") {
44 | user(login: $login) {
45 | name
46 | }
47 | }
48 | `)
49 | const code = transpile(schema, source)
50 | expect(code).includes('type User = (vars: { login?: string })')
51 | })
52 |
53 | test('optional variable with nullable type', () => {
54 | const source = new Source(`
55 | query User($login: String) {
56 | user(login: $login) {
57 | name
58 | }
59 | }
60 | `)
61 | const code = transpile(schema, source)
62 | expect(code).includes('type User = (vars: { login?: string | null })')
63 | })
64 |
65 | test('multiple queries', () => {
66 | const source = new Source(`
67 | query User1 {
68 | user(login: "anton") {
69 | name
70 | }
71 | }
72 |
73 | query User2 {
74 | user(login: "anna") {
75 | name
76 | }
77 | }
78 | `)
79 | const code = transpile(schema, source)
80 | expect(code).includes('type User1')
81 | expect(code).includes('type User2')
82 | })
83 |
84 | test('mutations', () => {
85 | const source = new Source(`
86 | mutation CreateUser($name: String!, $avatarUrl: String) {
87 | createUser(name: $name, avatarUrl: $avatarUrl) {
88 | name
89 | }
90 | }
91 | `)
92 | const code = transpile(schema, source)
93 | expect(code).includes(
94 | 'type CreateUser = (vars: { name: string, avatarUrl?: string | null }) =>',
95 | )
96 | })
97 |
98 | test('fragments', () => {
99 | const source = new Source(`
100 | fragment UserName on User {
101 | name
102 | }
103 |
104 | query User {
105 | user(login: "antonmedv") {
106 | ...UserName
107 | }
108 | }
109 | `)
110 | const code = transpile(schema, source)
111 | expect(code).includes('name: string | null\n')
112 | expect(code).includes('type UserName = {\n name: string | null\n}\n')
113 | })
114 |
115 | test('fragments with variables', () => {
116 | const source = new Source(`
117 | query User($login: String!) {
118 | ...UserName
119 | }
120 |
121 | fragment UserName on Query {
122 | user(login: $login) {
123 | name
124 | }
125 | }
126 | `)
127 | const code = transpile(schema, source)
128 | expect(code).includes(
129 | 'const UserName = `#graphql\nfragment UserName on Query',
130 | )
131 | })
132 |
133 | test('inline interfaces', () => {
134 | const source = new Source(`
135 | query User {
136 | user(login: "antonmedv") {
137 | favoriteAnimal {
138 | __typename
139 | ...on Dog {
140 | name
141 | barks
142 | }
143 | ...on Cat {
144 | name
145 | meows
146 | }
147 | }
148 | }
149 | }
150 | `)
151 | const code = transpile(schema, source)
152 | expect(code).includes('__typename: unknown\n')
153 | expect(code).includes('barks: boolean | null\n')
154 | expect(code).includes('meows: boolean | null\n')
155 | })
156 |
157 | test('query with list', () => {
158 | const source = new Source(`
159 | query Nullable {
160 | Users {
161 | name
162 | }
163 | }
164 | `)
165 | const code = transpile(schema, source)
166 | expect(code).includes(`{
167 | Users: Array<{
168 | name: string | null
169 | }> | null
170 | }`)
171 | })
172 |
173 | test('query with non-null list', () => {
174 | const source = new Source(`
175 | query NonNullable {
176 | NonNullUsers {
177 | name
178 | }
179 | }
180 | `)
181 | const code = transpile(schema, source)
182 | expect(code).includes(`{
183 | NonNullUsers: Array<{
184 | name: string | null
185 | }>
186 | }`)
187 | })
188 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { Source } from 'graphql/language/index.js'
2 | import { generate } from './generate.js'
3 | import { traverse } from './visitor.js'
4 | import { GraphQLSchema } from 'graphql/type/index.js'
5 |
6 | // Transpile a GraphQL schema and source to a TypeScript file.
7 | export function transpile(schema: GraphQLSchema, source: Source) {
8 | return generate(traverse(schema, source))
9 | }
10 |
11 | // Query is a GraphQL query string with type information attached.
12 | // Parameters of the query are the variables. The return type is the
13 | // result of the query.
14 | export type Query = string & ((vars: any) => any)
15 |
16 | // Variables of a query are the first parameter of the query function.
17 | export type Variables = Parameters[0]
18 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | export function firstLetterUpper(string: string) {
2 | return string.charAt(0).toUpperCase() + string.slice(1)
3 | }
4 |
5 | export function plural(count: number, singular: string, plural: string) {
6 | return (count === 1 ? singular : plural).replace('%d', count.toString())
7 | }
8 |
--------------------------------------------------------------------------------
/src/visitor.ts:
--------------------------------------------------------------------------------
1 | import {
2 | GraphQLOutputType,
3 | GraphQLType,
4 | isNonNullType,
5 | } from 'graphql/type/definition.js'
6 | import {
7 | typeFromAST,
8 | TypeInfo,
9 | visitWithTypeInfo,
10 | } from 'graphql/utilities/index.js'
11 | import { parse, print, Source, visit } from 'graphql/language/index.js'
12 | import { GraphQLSchema } from 'graphql/type/index.js'
13 | import { GraphQLError } from 'graphql/error/index.js'
14 | import { firstLetterUpper } from './utils.js'
15 |
16 | export type Variable = {
17 | name: string
18 | type: GraphQLType | undefined
19 | required: boolean
20 | }
21 |
22 | export type Selector = {
23 | name: string
24 | type?: GraphQLOutputType
25 | fields: Selector[]
26 | inlineFragments: Selector[]
27 | variables?: Variable[]
28 | isFragment?: boolean
29 | source?: string
30 | }
31 |
32 | export type Content = {
33 | operations: Selector[]
34 | fragments: Map
35 | }
36 |
37 | export function traverse(schema: GraphQLSchema, source: Source): Content {
38 | const ast = parse(source)
39 | const typeInfo = new TypeInfo(schema)
40 |
41 | const content: Content = {
42 | operations: [],
43 | fragments: new Map(),
44 | }
45 |
46 | const stack: Selector[] = []
47 |
48 | const visitor = visitWithTypeInfo(typeInfo, {
49 | OperationDefinition: {
50 | enter: function (node) {
51 | if (node.name === undefined) {
52 | throw new GraphQLError(
53 | firstLetterUpper(node.operation) + ' name is required',
54 | node,
55 | source,
56 | )
57 | }
58 | checkUnique(node.name.value, content)
59 |
60 | const variables: Variable[] = []
61 | for (const v of node.variableDefinitions ?? []) {
62 | const type = typeFromAST(schema, v.type)
63 | variables.push({
64 | name: v.variable.name.value,
65 | type: type,
66 | required: v.defaultValue === undefined && isNonNullType(type),
67 | })
68 | }
69 |
70 | const s: Selector = {
71 | name: node.name.value,
72 | type: typeInfo.getType() ?? undefined,
73 | fields: [],
74 | inlineFragments: [],
75 | variables: variables,
76 | source: print(node),
77 | }
78 |
79 | stack.push(s)
80 | content.operations.push(s)
81 | },
82 | leave() {
83 | stack.pop()
84 | },
85 | },
86 |
87 | FragmentDefinition: {
88 | enter(node) {
89 | checkUnique(node.name.value, content)
90 |
91 | const s: Selector = {
92 | name: node.name.value,
93 | type: typeInfo.getType() ?? undefined,
94 | fields: [],
95 | inlineFragments: [],
96 | source: print(node),
97 | }
98 |
99 | stack.push(s)
100 | content.fragments.set(s.name, s)
101 | },
102 | leave() {
103 | stack.pop()
104 | },
105 | },
106 |
107 | Field: {
108 | enter(node) {
109 | const s: Selector = {
110 | name: node.alias?.value ?? node.name.value,
111 | type: typeInfo.getType() ?? undefined,
112 | fields: [],
113 | inlineFragments: [],
114 | }
115 | stack.at(-1)?.fields.push(s)
116 | stack.push(s)
117 | },
118 | leave() {
119 | stack.pop()
120 | },
121 | },
122 |
123 | FragmentSpread: {
124 | enter(node) {
125 | stack.at(-1)?.fields.push({
126 | name: node.name.value,
127 | type: typeInfo.getType() ?? undefined,
128 | isFragment: true,
129 | fields: [],
130 | inlineFragments: [],
131 | })
132 | },
133 | },
134 |
135 | InlineFragment: {
136 | enter(node) {
137 | if (!node.typeCondition) {
138 | throw new GraphQLError(
139 | 'Inline fragment must have type condition.',
140 | node,
141 | source,
142 | )
143 | }
144 | const s: Selector = {
145 | name: node.typeCondition.name.value,
146 | type: typeInfo.getType() ?? undefined,
147 | fields: [],
148 | inlineFragments: [],
149 | }
150 | stack.at(-1)?.inlineFragments.push(s)
151 | stack.push(s)
152 | },
153 | leave() {
154 | stack.pop()
155 | },
156 | },
157 | })
158 |
159 | visit(ast, visitor)
160 |
161 | return content
162 | }
163 |
164 | function checkUnique(name: string, content: Content) {
165 | if (content.operations.find((o) => o.name === name)) {
166 | throw new GraphQLError(`Operation with name "${name}" is already defined.`)
167 | }
168 | if (content.fragments.has(name)) {
169 | throw new GraphQLError(`Fragment with name "${name}" is already defined.`)
170 | }
171 | }
172 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "lib": ["ES2022", "DOM"],
5 | "moduleResolution": "NodeNext",
6 | "module": "NodeNext",
7 | "strict": true,
8 | "outDir": "./dist",
9 | "declaration": true,
10 | "allowJs": true
11 | },
12 | "include": ["./src/**/*"]
13 | }
14 |
--------------------------------------------------------------------------------