├── .dockerignore ├── .npmignore ├── .prettierrc ├── jest.config.js ├── src ├── editor │ ├── custom.d.ts │ ├── GraphQLEditor │ │ ├── README.md │ │ ├── GraphQLEditor.tsx │ │ └── editor.css │ ├── .prettierrc │ ├── tsconfig.json │ ├── .eslintrc │ ├── index.html │ ├── icons.tsx │ ├── css │ │ ├── app.css │ │ └── codemirror.css │ ├── logo.svg │ └── index.tsx ├── default-schema.graphql ├── default-extend.graphql ├── utils.ts ├── cli.ts ├── fake_definition.ts ├── proxy.ts ├── index.ts ├── __tests__ │ └── resolvers.test.ts └── resolvers.ts ├── Dockerfile ├── tsconfig.json ├── .gitignore ├── LICENSE ├── webpack.config.js ├── package.json └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !Dockerfile 3 | !.dockerignore 4 | !dist/** 5 | !package.json 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | }; 5 | -------------------------------------------------------------------------------- /src/editor/custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css' { 2 | var content: any; 3 | export = content; 4 | } 5 | -------------------------------------------------------------------------------- /src/editor/GraphQLEditor/README.md: -------------------------------------------------------------------------------- 1 | ## GraphQL Editor 2 | 3 | This component was isolated from Graphiql Query Editor 4 | It will be published as a separate component 5 | -------------------------------------------------------------------------------- /src/editor/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "all" 8 | } 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14.3.0-alpine 2 | 3 | ENTRYPOINT ["node", "/usr/local/bin/graphql-faker"] 4 | WORKDIR /workdir 5 | 6 | EXPOSE 9002 7 | 8 | RUN yarn global add graphql-faker && \ 9 | yarn cache clean --force 10 | -------------------------------------------------------------------------------- /src/editor/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "preserveConstEnums": true, 4 | "strictNullChecks": true, 5 | "sourceMap": true, 6 | "target": "es5", 7 | "outDir": "dist", 8 | "moduleResolution": "node", 9 | "lib": ["es2017", "dom"], 10 | "jsx": "react" 11 | }, 12 | "exclude": ["node_modules", "dist"], 13 | "include": ["./custom.d.ts", "**/*.tsx", "**/*.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /src/editor/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "ecmaFeatures": { 3 | "jsx": true, 4 | "modules": true 5 | }, 6 | "extends": ["eslint:recommended"], 7 | "env": { 8 | "browser": true, 9 | "node": true 10 | }, 11 | "parser": "babel-eslint", 12 | "rules": { 13 | "quotes": [2, "single"], 14 | "strict": [2, "never"], 15 | "react/jsx-uses-react": 2, 16 | "react/jsx-uses-vars": 2, 17 | "react/react-in-jsx-scope": 2 18 | }, 19 | "plugins": ["react"] 20 | } 21 | -------------------------------------------------------------------------------- /src/editor/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | GraphQL Faker by APIs.guru 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "experimentalDecorators": true, 5 | "emitDecoratorMetadata": true, 6 | "module": "commonjs", 7 | "target": "es2017", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "outDir": "dist", 12 | "pretty": true, 13 | "moduleResolution": "node", 14 | "lib": ["es2017"] 15 | }, 16 | "compileOnSave": false, 17 | "exclude": ["src/editor", "node_modules", ".tmp", "dist", "lib"] 18 | } 19 | -------------------------------------------------------------------------------- /src/default-schema.graphql: -------------------------------------------------------------------------------- 1 | # This is sample SDL schema for GraphQL Zero. 2 | # 3 | # Press save or Cmd+Enter to apply the changes and update server. Switch to GraphiQL 4 | # on the left panel to immediately test your changes. 5 | # This tool also supports extending existing APIs. Check zero --help 6 | 7 | type Company { 8 | id: ID 9 | name: String 10 | industry: String 11 | employees: [Employee!] 12 | } 13 | 14 | type Employee { 15 | id: ID 16 | firstName: String 17 | lastName: String 18 | address: String 19 | subordinates: [Employee!] 20 | company: Company 21 | } 22 | 23 | type Query { 24 | employee(id: ID): Employee 25 | company(id: ID): Company 26 | allCompanies: [Company!] 27 | } 28 | -------------------------------------------------------------------------------- /src/default-extend.graphql: -------------------------------------------------------------------------------- 1 | # This is sample SDL schema for GraphQL Zero in extend mode. 2 | # 3 | # Press save or Cmd+Enter to apply the changes and update server. Switch to GraphiQL 4 | # on the left panel to immediately test your changes. 5 | 6 | extend type Query { 7 | pet: Pet 8 | pets(limit: Int, offset: Int, order: String, sort: String): [Pet] 9 | } 10 | 11 | type Pet { 12 | name: String 13 | image: String 14 | } 15 | 16 | enum TshirtSize { 17 | S 18 | M 19 | L 20 | } 21 | 22 | # extend type Issue { 23 | # tshirtSize: TshirtSize 24 | # } 25 | 26 | extend type Address { 27 | tshirtSize: TshirtSize 28 | } 29 | extend type Capsule { 30 | pets: [Pet!]! 31 | friend: Capsule! 32 | color: String 33 | height: Int 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | /node_modules 31 | 32 | # Optional npm cache directory 33 | .npm 34 | 35 | # Optional REPL history 36 | .node_repl_history 37 | 38 | # graphql files on the root dir 39 | /*.graphql 40 | 41 | # es5 output dir 42 | /dist/ 43 | 44 | # editor output 45 | src/editor/main.js 46 | src/editor/main.js.map 47 | src/editor/main.css 48 | src/editor/main.css.map 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 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 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | 4 | module.exports = { 5 | // mode: 'development', 6 | // devtool: 'inline-source-map', 7 | devServer: { 8 | contentBase: './src/editor', 9 | proxy: { 10 | '/graphql': 'http://localhost:9002', 11 | '/user-sdl': 'http://localhost:9002', 12 | '/voyager.worker.js': 'http://localhost:9002', 13 | }, 14 | }, 15 | entry: './src/editor/index.tsx', 16 | plugins: [new MiniCssExtractPlugin({ filename: 'main.css' })], 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.tsx?$/, 21 | use: 'ts-loader', 22 | exclude: /node_modules/, 23 | }, 24 | { 25 | test: /\.css$/, 26 | use: [ 27 | 'style-loader', 28 | MiniCssExtractPlugin.loader, 29 | { loader: 'css-loader', options: { importLoaders: 1 } }, 30 | ], 31 | }, 32 | ], 33 | }, 34 | resolve: { 35 | extensions: ['.tsx', '.ts', '.mjs', '.js'], 36 | }, 37 | output: { 38 | filename: 'main.js', 39 | path: path.resolve(__dirname, 'src/editor'), 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as chalk from 'chalk'; 2 | import * as fs from 'fs'; 3 | import fetch from 'node-fetch'; 4 | import { Headers } from 'node-fetch'; 5 | import { 6 | Source, 7 | GraphQLSchema, 8 | buildClientSchema, 9 | getIntrospectionQuery, 10 | graphql 11 | } from 'graphql'; 12 | 13 | export function existsSync(filePath: string): boolean { 14 | try { 15 | fs.statSync(filePath); 16 | } catch (err) { 17 | if (err.code == 'ENOENT') return false; 18 | } 19 | return true; 20 | } 21 | 22 | export function readSDL(filepath: string): Source { 23 | return new Source(fs.readFileSync(filepath, 'utf-8'), filepath); 24 | } 25 | 26 | export function getRemoteSchema( 27 | url: string, 28 | headers: { [name: string]: string }, 29 | ): Promise { 30 | return graphqlRequest(url, headers, getIntrospectionQuery()) 31 | .then((response) => { 32 | if (response.errors) { 33 | throw Error(JSON.stringify(response.errors, null, 2)); 34 | } 35 | return buildClientSchema(response.data); 36 | }) 37 | .catch((error) => { 38 | throw Error(`Can't get introspection from ${url}:\n${error.message}`); 39 | }); 40 | } 41 | 42 | export function graphqlRequest( 43 | url, 44 | headers, 45 | query, 46 | variables?, 47 | operationName?, 48 | ) { 49 | return fetch(url, { 50 | method: 'POST', 51 | headers: new Headers({ 52 | 'content-type': 'application/json', 53 | ...(headers || {}), 54 | }), 55 | body: JSON.stringify({ 56 | operationName, 57 | query, 58 | variables, 59 | }), 60 | }).then((responce) => { 61 | if (responce.ok) return responce.json(); 62 | return responce.text().then((body) => { 63 | throw Error(`${responce.status} ${responce.statusText}\n${body}`); 64 | }); 65 | }); 66 | } 67 | 68 | 69 | const ROOTBEER_API_BASE_URL = 'https://rootbeercomputer--api-main.modal.run' 70 | 71 | export const createMockData = async (schema: GraphQLSchema, newTypes, extendedFields) => { 72 | let introspection = null; 73 | try { 74 | introspection = await graphql({ 75 | schema, 76 | source: getIntrospectionQuery() 77 | }) 78 | } catch (error) { 79 | console.log(chalk.red(error)); 80 | process.exit(1); 81 | }; 82 | try { 83 | const response = await fetch(ROOTBEER_API_BASE_URL, { 84 | method: 'post', 85 | body: JSON.stringify({introspection, newTypes, 86 | extendedFields}, null, 2), 87 | headers: {'Content-Type': 'application/json'} 88 | }); 89 | if (!response.ok) { 90 | console.log(chalk.red("The server had an error processing your graphql schema. Please report this bug on github.")); 91 | process.exit(1); 92 | } 93 | return await response.json() 94 | } catch (error) { 95 | console.log(chalk.red(error)); 96 | process.exit(1); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rootbeer/zero", 3 | "version": "0.1.7", 4 | "description": "Mock your GraphQL API with AI generated faked data... zero config", 5 | "main": "dist/index.js", 6 | "bin": { 7 | "zero": "dist/index.js" 8 | }, 9 | "engineStrict": { 10 | "node": ">= 12.x" 11 | }, 12 | "scripts": { 13 | "test": "npm run check && npm run prettier:check", 14 | "jest": "jest", 15 | "check": "tsc --noEmit", 16 | "start": "nodemon src/index.ts", 17 | "debug": "ts-node --inspect --compilerOptions '{\"inlineSources\":true}' src/index.ts", 18 | "start:editor": "webpack-dev-server --config webpack.config.js", 19 | "build:editor": "NODE_OPTIONS='--openssl-legacy-provider' webpack -p --config webpack.config.js", 20 | "build:typescript": "tsc", 21 | "copy:graphql": "cp src/*.graphql dist/", 22 | "copy:editor": "mkdir \"dist/editor\" && cp src/editor/*.html dist/editor && cp src/editor/*.js dist/editor && cp src/editor/*.css dist/editor && cp src/editor/*.svg dist/editor", 23 | "build:all": "rm -rf dist && mkdir dist && npm run build:editor && npm run build:typescript && npm run copy:graphql && npm run copy:editor", 24 | "prettier": "prettier --ignore-path .gitignore --write --list-different .", 25 | "prettier:check": "prettier --ignore-path .gitignore --check ." 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/RootbeerComputer/zero.git" 30 | }, 31 | "author": "Rootbeer Computer Inc.", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/RootbeerComputer/zero/issues" 35 | }, 36 | "homepage": "https://github.com/RootbeerComputer/zero#readme", 37 | "devDependencies": { 38 | "@types/body-parser": "1.19.0", 39 | "@types/cors": "2.8.10", 40 | "@types/jest": "^29.5.3", 41 | "@types/node-fetch": "^2.6.4", 42 | "@types/react": "16.9.35", 43 | "@types/react-dom": "16.9.8", 44 | "@types/yargs": "15.0.5", 45 | "classnames": "2.3.1", 46 | "codemirror": "5.60.0", 47 | "codemirror-graphql": "0.12.4", 48 | "css-loader": "3.5.3", 49 | "graphiql": "0.17.5", 50 | "jest": "^29.6.1", 51 | "marked": "1.1.0", 52 | "mini-css-extract-plugin": "0.9.0", 53 | "nodemon": "2.0.7", 54 | "prettier": "2.2.1", 55 | "react": "16.13.1", 56 | "react-dom": "16.13.1", 57 | "style-loader": "1.2.1", 58 | "ts-jest": "^29.1.1", 59 | "ts-loader": "7.0.5", 60 | "ts-node": "9.1.1", 61 | "typescript": "4.4.2", 62 | "webpack": "4.43.0", 63 | "webpack-cli": "3.3.11", 64 | "webpack-dev-server": "3.11.2" 65 | }, 66 | "dependencies": { 67 | "body-parser": "1.19.0", 68 | "chalk": "4.1.0", 69 | "cors": "2.8.5", 70 | "express": "4.17.1", 71 | "express-graphql": "0.12.0", 72 | "graphql": "15.7.2", 73 | "moment": "2.29.1", 74 | "node-fetch": "2.6.1", 75 | "open": "8.0.5", 76 | "yargs": "15.3.1" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import * as yargs from 'yargs'; 2 | 3 | type Options = { 4 | fileName: string | undefined; 5 | port: number; 6 | corsOrigin: string | true; 7 | openEditor: boolean; 8 | extendURL: string | undefined; 9 | headers: { [key: string]: string }; 10 | forwardHeaders: [string]; 11 | }; 12 | 13 | function builder(cmd) { 14 | return cmd 15 | .positional('SDLFile', { 16 | describe: 17 | 'path to file with SDL. If this argument is omitted Zero uses default file name', 18 | type: 'string', 19 | nargs: 1, 20 | }) 21 | .options({ 22 | port: { 23 | alias: 'p', 24 | describe: 'HTTP Port', 25 | type: 'number', 26 | requiresArg: true, 27 | default: process.env.PORT || 9002, 28 | }, 29 | open: { 30 | alias: 'o', 31 | describe: 'Open page with SDL editor and GraphiQL in browser', 32 | type: 'boolean', 33 | }, 34 | 'cors-origin': { 35 | alias: 'co', 36 | describe: 37 | 'CORS: Specify the custom origin for the Access-Control-Allow-Origin header, by default it is the same as `Origin` header from the request', 38 | type: 'string', 39 | requiresArg: true, 40 | default: true, 41 | }, 42 | extend: { 43 | alias: 'e', 44 | describe: 'URL to existing GraphQL server to extend', 45 | type: 'string', 46 | requiresArg: true, 47 | }, 48 | header: { 49 | alias: 'H', 50 | describe: 51 | 'Specify headers to the proxied server in cURL format, e.g.: "Authorization: bearer XXXXXXXXX"', 52 | array: true, 53 | type: 'string', 54 | requiresArg: true, 55 | implies: 'extend', 56 | coerce(arr) { 57 | const headers = {}; 58 | for (const str of arr) { 59 | const [, name, value] = str.match(/(.*?):(.*)/); 60 | headers[name.toLowerCase()] = value.trim(); 61 | } 62 | return headers; 63 | }, 64 | }, 65 | 'forward-headers': { 66 | describe: 67 | 'Specify which headers should be forwarded to the proxied server', 68 | array: true, 69 | type: 'string', 70 | implies: 'extend', 71 | coerce(arr) { 72 | return arr.map((str) => str.toLowerCase()); 73 | }, 74 | }, 75 | }) 76 | .epilog(epilog) 77 | .strict(); 78 | } 79 | 80 | export function parseCLI(commandCB: (options: Options) => void) { 81 | yargs.usage('$0 [SDLFile]', '', builder, handler).help('h').alias('h', 'help') 82 | .argv; 83 | 84 | function handler(argv) { 85 | commandCB({ 86 | fileName: argv.SDLFile, 87 | port: argv.port, 88 | corsOrigin: argv['cors-origin'], 89 | openEditor: argv.open, 90 | extendURL: argv.extend, 91 | headers: argv.header || {}, 92 | forwardHeaders: argv.forwardHeaders || [], 93 | }); 94 | } 95 | } 96 | 97 | const epilog = `Examples: 98 | 99 | # Mock GraphQL API based on example SDL and open interactive editor 100 | $0 --open 101 | 102 | # Mock GraphQL API based on a local graphQL schema SDL and open interactive editor 103 | $0 schema.graphql --open 104 | 105 | # Extend real data from (knockoff) SpaceX's GraphQL API with faked data based on local extension SDL file 106 | $0 extended_schema.graphql --extend https://spacex-production.up.railway.app/ \\ 107 | --header "Authorization: bearer " 108 | 109 | # Extend real data from (knockoff) SpaceX's GraphQL API with example extended SDL 110 | $0 --extend https://spacex-production.up.railway.app`; 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | :rotating_light: Experimental :warning: [Demo Video] 3 |
4 | 5 | # GraphQL Zero 6 | 7 |
8 | 9 | [![Discord](https://img.shields.io/discord/1122949106558570648)](https://discord.gg/3ASBTJWgGS) 10 | 11 |
12 | 13 | Mock your GraphQL API with generative AI fake data... zero config. Powered by LLMs that look at your schema and populate a "parallel universe" of fake data so you can prototype and test your frontend w/o implementing the backend or manually entering a bunch of data. 14 | 15 | Use for your next prototyping session, product demo, or QA bug bash! 16 | 17 | Example schemas are provided in the `examples` folder 18 | 19 | ``` 20 | npm install -g @rootbeer/zero 21 | zero schema.graphql # replace with your schema 22 | ``` 23 | 24 | Then swap out the URL in your frontend code with 25 | 26 | ``` 27 | http://localhost:9000 28 | ``` 29 | 30 | Note: We don't support persisted queries yet 31 | 32 | To proxy to a real graphql server. Write your SDL file with extended types then use this command. 33 | 34 | ``` 35 | zero extended_schema.graphql --extend https://spacex-production.up.railway.app 36 | ``` 37 | 38 | ## Motivation 39 | 40 | We live in modern times, why poke around in a 100 tabs trying to populate data for your app? 41 | 42 | Zero is unlike anything you've seen before 43 | - It's zero config. No faker! No annotating your SDL! No directives! 44 | - It's static. static consistent data: it feels as if its real. you can play around with your app, not just stuck on one page with a chunk of lorem ipsum 45 | - :soon: It's dynamic. AI generated business logic, so you can query and MUTATE. Inspired by [Backend GPT](https://github.com/RootbeerComputer/backend-GPT) 46 | - It's incremental. Mock your entire API to completely separate from prod, or extend an existing API with proxying `zero schema.graphql --extend https://existing-server.com/graphql` 47 | 48 | Credit: This repo is a hard fork of [graphql-faker](https://github.com/graphql-kit/graphql-faker) 49 | 50 | ## Features 51 | 52 | Releasing super early and unpolished. There's a 20% chance this works with your GraphQL schema. If it fails, try an easier schema :flushed: If it still fails, make an issue! 53 | 54 | - [x] Optionally proxy an existing GraphQL API to extend it with faked data 55 | - [x] Runs as local graphql server 56 | - [x] queries (using heuristics for arguments) 57 | - [ ] persisted queries 58 | - [ ] field arguments for leaf fields (using heuristics) 59 | - [ ] custom scalars (starting with popular standards) 60 | - [ ] directives (starting with popular standards) 61 | - [ ] mutations (including file uploads and auth) 62 | - [ ] subscriptions 63 | - [x] pagination 64 | 65 | ## How it works 66 | 67 | **We don't see any sensitive data.** This makes it easy to use in high security environments (i.e. SOC2). 68 | 69 | This CLI tool sends your GraphQL schema definition language (~~including docstrings~~) to us. Our server parses the schema and runs fancy AI algorithms to create a blob of mock data, which gets sent back to the CLI tool. From then on, all queries are executed locally on your machine. We mock at the GraphQL level so it's data source agnostic and client agnostic (Apollo, iOS, Java, etc). 70 | 71 | Some have asked about a Postgres edition. Reach out if this interests you. 72 | 73 | ## Contribute 74 | 75 | All PRs will be reviewed within 24 hours! 76 | 77 | I'd especially appreciate bugfixes, examples, tests, federation support, quality of life improvements, and render/heroku/docker build things! 78 | 79 | For bigger contributions, we have a [GitHub Project](https://github.com/orgs/RootbeerComputer/projects/2) 80 | -------------------------------------------------------------------------------- /src/editor/icons.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export const EditIcon = () => ( 4 | 5 | 6 | 7 | 8 | ); 9 | 10 | export const ConsoleIcon = () => ( 11 | 18 | 22 | 26 | 27 | ); 28 | 29 | export const GithubIcon = () => ( 30 | 37 | 41 | 42 | ); 43 | 44 | export const VoyagerIcon = () => ( 45 | 46 | 50 | 51 | ); 52 | -------------------------------------------------------------------------------- /src/fake_definition.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Source, 3 | GraphQLError, 4 | GraphQLSchema, 5 | parse, 6 | // validate, 7 | extendSchema, 8 | buildASTSchema, 9 | validateSchema, 10 | isObjectType, 11 | isInterfaceType, 12 | } from 'graphql'; 13 | 14 | // FIXME 15 | import { validateSDL } from 'graphql/validation/validate'; 16 | export function buildWithFakeDefinitions( 17 | schemaSDL: Source, 18 | extensionSDL?: Source, 19 | options?: { skipValidation: boolean }, 20 | ): {schema: GraphQLSchema, newTypes: {}, extendedFields: {}} { 21 | const skipValidation = options?.skipValidation ?? false; 22 | const schemaAST = parseSDL(schemaSDL); 23 | let schema = buildASTSchema(schemaAST, {assumeValidSDL: true}); // assumeValidSDL lets us ignore custom directives 24 | 25 | const newTypes = {} 26 | const extendedFields = {} 27 | if (extensionSDL != null) { 28 | const extensionAST = parseSDL(extensionSDL); 29 | if (!skipValidation) { 30 | const errors = validateSDL(extensionAST, schema); 31 | if (errors.length !== 0) { 32 | throw new ValidationErrors(errors); 33 | } 34 | } 35 | schema = extendSchema(schema, extensionAST, { 36 | assumeValid: true, 37 | commentDescriptions: true, 38 | }); 39 | 40 | for (const type of Object.values(schema.getTypeMap())) { 41 | if (isObjectType(type) || isInterfaceType(type)) { 42 | const isNewType = type.astNode?.loc?.source === extensionSDL; 43 | // mark the types that are from the extensionSDL so we can filter them out later as needed 44 | if (isNewType) { 45 | newTypes[type.name] = true; 46 | } 47 | if (type.extensions) { 48 | (type.extensions as any)['isNewType'] = isNewType; 49 | } else { 50 | type.extensions = { isNewType }; 51 | } 52 | for (const field of Object.values(type.getFields())) { 53 | const isExtensionField = field.astNode?.loc?.source === extensionSDL; 54 | if (isNewType && !isExtensionField) { 55 | throw new Error( 56 | `Type "${type.name}" was defined in the extension SDL but field "${field.name}" was not. ` + 57 | `All fields of a new type must be defined in the extension SDL.`, 58 | ); 59 | } 60 | // mark the fields that are from the extensionSDL so we can filter them out later as needed 61 | if (isExtensionField) { 62 | if (!extendedFields[type.name]) { 63 | extendedFields[type.name] = {}; 64 | } 65 | extendedFields[type.name][field.name] = true; 66 | } 67 | if (field.extensions) { 68 | (field.extensions as any)['isExtensionField'] = isExtensionField; 69 | } else { 70 | field.extensions = { isExtensionField }; 71 | } 72 | } 73 | } 74 | } 75 | } 76 | 77 | if (!skipValidation) { 78 | const errors = validateSchema(schema); 79 | if (errors.length !== 0) { 80 | throw new ValidationErrors(errors); 81 | } 82 | } 83 | 84 | return {schema, newTypes, extendedFields}; 85 | } 86 | 87 | // FIXME: move to 'graphql-js' 88 | export class ValidationErrors extends Error { 89 | subErrors: GraphQLError[]; 90 | 91 | constructor(errors) { 92 | const message = errors.map((error) => error.message).join('\n\n'); 93 | super(message); 94 | 95 | this.subErrors = errors; 96 | this.name = this.constructor.name; 97 | 98 | if (typeof Error.captureStackTrace === 'function') { 99 | Error.captureStackTrace(this, this.constructor); 100 | } else { 101 | this.stack = new Error(message).stack; 102 | } 103 | } 104 | } 105 | 106 | function parseSDL(sdl: Source) { 107 | return parse(sdl, { 108 | allowLegacySDLEmptyFields: true, 109 | allowLegacySDLImplementsInterfaces: true, 110 | }); 111 | } 112 | -------------------------------------------------------------------------------- /src/editor/css/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: system, -apple-system, 'San Francisco', '.SFNSDisplay-Regular', 'Segoe UI', Segoe, 3 | 'Segoe WP', 'Helvetica Neue', helvetica, 'Lucida Grande', arial, sans-serif; 4 | } 5 | 6 | .faker-editor-container { 7 | display: flex; 8 | height: 100vh; 9 | } 10 | 11 | .faker-editor-container > nav { 12 | width: 60px; 13 | background-color: #59155a; 14 | } 15 | 16 | .faker-editor-container > .tabs-container { 17 | flex: 1; 18 | overflow: hidden; 19 | } 20 | 21 | .tab-content { 22 | overflow: hidden; 23 | height: 0; 24 | } 25 | 26 | .tab-content.-active { 27 | display: block; 28 | height: 100%; 29 | } 30 | 31 | nav > ul { 32 | margin: 0; 33 | list-style: none; 34 | padding: 0; 35 | display: flex; 36 | flex-direction: column; 37 | color: white; 38 | height: calc(100% - 60px); 39 | } 40 | 41 | nav li { 42 | display: inline-block; 43 | padding: 15px 14px 15px 10px; 44 | cursor: pointer; 45 | position: relative; 46 | text-align: center; 47 | } 48 | 49 | nav li.-active { 50 | background-color: rgba(255, 255, 255, 0.2); 51 | } 52 | nav li.-active::after { 53 | content: ''; 54 | display: block; 55 | position: absolute; 56 | top: 0; 57 | bottom: 0; 58 | right: 0px; 59 | border-right: 4px solid #e535ab; 60 | } 61 | 62 | nav li:hover { 63 | background: rgba(255, 255, 255, 0.1); 64 | } 65 | 66 | nav li.-unsaved::before { 67 | content: '*'; 68 | color: white; 69 | font-weight: bold; 70 | position: absolute; 71 | right: 10px; 72 | } 73 | 74 | nav li.-disabled, 75 | nav li.-disabled:hover { 76 | cursor: default; 77 | background: transparent; 78 | } 79 | 80 | nav li.-disabled path, 81 | nav li.-disabled:hover path { 82 | fill: rgba(255, 255, 255, 0.2); 83 | } 84 | 85 | nav li.-pulldown { 86 | margin-top: auto; 87 | } 88 | 89 | nav li.-link { 90 | padding: 0; 91 | } 92 | 93 | nav li.-link > a { 94 | display: inline-block; 95 | padding: 15px 14px 15px 10px; 96 | } 97 | 98 | nav > .logo { 99 | padding: 5px; 100 | } 101 | 102 | .editor-container { 103 | display: flex; 104 | flex-direction: column; 105 | } 106 | 107 | .editor-container.-active { 108 | display: flex; 109 | } 110 | .editor-container > .graphql-editor { 111 | flex: 1; 112 | overflow: hidden; 113 | } 114 | 115 | .action-panel { 116 | padding: 10px 40px; 117 | background: #f2f2f2; 118 | display: flex; 119 | align-items: center; 120 | } 121 | 122 | .material-button { 123 | position: relative; 124 | 125 | display: inline-block; 126 | min-width: 160px; 127 | text-align: center; 128 | font-size: 14px; 129 | padding: 0; 130 | 131 | overflow: hidden; 132 | 133 | border-width: 0; 134 | outline: none; 135 | border-radius: 2px; 136 | box-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); 137 | 138 | background-color: #1c5a15; 139 | color: #ecf0f1; 140 | 141 | transition: background-color 0.3s; 142 | cursor: pointer; 143 | text-transform: uppercase; 144 | user-select: none; 145 | letter-spacing: 2px; 146 | } 147 | 148 | .material-button:hover, 149 | .material-button:focus { 150 | background-color: #2c6a25; 151 | } 152 | 153 | .material-button > * { 154 | position: relative; 155 | } 156 | 157 | .material-button span { 158 | display: block; 159 | padding: 10px 24px; 160 | } 161 | 162 | .material-button:active:before { 163 | content: ''; 164 | 165 | position: absolute; 166 | top: 0; 167 | left: 0; 168 | 169 | display: block; 170 | background-color: rgba(0, 0, 0, 0.2); 171 | height: 100%; 172 | width: 100%; 173 | } 174 | 175 | .material-button.-disabled, 176 | .material-button.-disabled:hover { 177 | background-color: #5d795a; 178 | cursor: default; 179 | user-select: none; 180 | } 181 | 182 | .material-button.-disabled:active::before { 183 | display: none; 184 | } 185 | 186 | .status-bar { 187 | padding: 0 20px; 188 | overflow: hidden; 189 | text-overflow: ellipsis; 190 | } 191 | 192 | .error-message { 193 | color: red; 194 | } 195 | .status { 196 | color: #1c5a15; 197 | } 198 | 199 | .tab-content .loading-box.visible { 200 | display: none; 201 | } 202 | 203 | .tab-content .graphql-voyager > .menu-content { 204 | left: 435px; 205 | } 206 | -------------------------------------------------------------------------------- /src/proxy.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from 'http'; 2 | import { 3 | Kind, 4 | print, 5 | visit, 6 | execute, 7 | TypeInfo, 8 | isAbstractType, 9 | visitWithTypeInfo, 10 | separateOperations, 11 | ExecutionArgs, 12 | GraphQLError, 13 | } from 'graphql'; 14 | 15 | import { graphqlRequest } from './utils'; 16 | 17 | export function getProxyExecuteFn(url, headers, forwardHeaders) { 18 | //TODO: proxy extensions 19 | return (args: ExecutionArgs) => { 20 | const { schema, document, contextValue, operationName } = args; 21 | 22 | const request = contextValue as IncomingMessage; 23 | const proxyHeaders = Object.create(null); 24 | for (const name of forwardHeaders) { 25 | proxyHeaders[name] = request.headers[name]; 26 | } 27 | 28 | const strippedAST = removeUnusedVariables( 29 | stripExtensionFields(schema, document), 30 | ); 31 | 32 | const operations = separateOperations(strippedAST); 33 | const operationAST = operationName 34 | ? operations[operationName] 35 | : Object.values(operations)[0]; 36 | 37 | return graphqlRequest( 38 | url, 39 | { ...headers, ...proxyHeaders }, 40 | print(operationAST), 41 | args.variableValues, 42 | operationName, 43 | ).then((result) => proxyResponse(result, args)); 44 | }; 45 | } 46 | 47 | function proxyResponse(response, args) { 48 | const rootValue = response.data || {}; 49 | const globalErrors = []; 50 | 51 | for (const error of response.errors || []) { 52 | const { message, path, extensions } = error; 53 | const errorObj = new GraphQLError( 54 | message, 55 | undefined, 56 | undefined, 57 | undefined, 58 | path, 59 | undefined, 60 | extensions, 61 | ); 62 | 63 | if (!path) { 64 | globalErrors.push(errorObj); 65 | continue; 66 | } 67 | 68 | // Recreate root value up to a place where original error was thrown 69 | // and place error as field value. 70 | pathSet(rootValue, error.path, errorObj); 71 | } 72 | 73 | if (globalErrors.length !== 0) { 74 | return { errors: globalErrors }; 75 | } 76 | 77 | return execute({ ...args, rootValue }); 78 | } 79 | 80 | function pathSet(rootObject, path, value) { 81 | let currentObject = rootObject; 82 | 83 | const basePath = [...path]; 84 | const lastKey = basePath.pop(); 85 | for (const key of basePath) { 86 | if (currentObject[key] == null) { 87 | currentObject[key] = typeof key === 'number' ? [] : {}; 88 | } 89 | currentObject = currentObject[key]; 90 | } 91 | 92 | currentObject[lastKey] = value; 93 | } 94 | 95 | function injectTypename(node) { 96 | return { 97 | ...node, 98 | selections: [ 99 | ...node.selections, 100 | { 101 | kind: Kind.FIELD, 102 | name: { 103 | kind: Kind.NAME, 104 | value: '__typename', 105 | }, 106 | }, 107 | ], 108 | }; 109 | } 110 | 111 | function stripExtensionFields(schema, operationAST) { 112 | const typeInfo = new TypeInfo(schema); 113 | 114 | return visit( 115 | operationAST, 116 | visitWithTypeInfo(typeInfo, { 117 | [Kind.FIELD]: () => { 118 | const fieldDef = typeInfo.getFieldDef(); 119 | if ( 120 | fieldDef.name.startsWith('__') || 121 | fieldDef.extensions['isExtensionField'] === true 122 | ) { 123 | return null; 124 | } 125 | }, 126 | [Kind.SELECTION_SET]: { 127 | leave(node) { 128 | const type = typeInfo.getParentType(); 129 | if (isAbstractType(type) || node.selections.length === 0) 130 | return injectTypename(node); 131 | }, 132 | }, 133 | }), 134 | ); 135 | } 136 | 137 | function removeUnusedVariables(documentAST) { 138 | const seenVariables = Object.create(null); 139 | 140 | visit(documentAST, { 141 | [Kind.VARIABLE_DEFINITION]: () => false, 142 | [Kind.VARIABLE]: (node) => { 143 | seenVariables[node.name.value] = true; 144 | }, 145 | }); 146 | 147 | return visit(documentAST, { 148 | [Kind.VARIABLE_DEFINITION]: (node) => { 149 | if (!seenVariables[node.variable.name.value]) { 150 | return null; 151 | } 152 | }, 153 | }); 154 | } 155 | -------------------------------------------------------------------------------- /src/editor/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 54 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import * as fs from 'fs'; 4 | import * as path from 'path'; 5 | 6 | import * as express from 'express'; 7 | import * as chalk from 'chalk'; 8 | import * as open from 'open'; 9 | import * as cors from 'cors'; 10 | import * as bodyParser from 'body-parser'; 11 | import { graphqlHTTP } from 'express-graphql'; 12 | import { Source, printSchema, GraphQLSchema } from 'graphql'; // astFromValue 13 | 14 | import { parseCLI } from './cli'; 15 | import { getProxyExecuteFn } from './proxy'; 16 | import { existsSync, readSDL, getRemoteSchema, createMockData } from './utils'; 17 | import { fakeTypeResolver, fakeFieldResolver, database, partialsDatabase, unassignedPartials, unassignedFakeObjects } from './resolvers'; 18 | import { ValidationErrors, buildWithFakeDefinitions } from './fake_definition'; 19 | 20 | const log = console.log; 21 | 22 | parseCLI((options) => { 23 | const { extendURL, headers, forwardHeaders } = options; 24 | const fileName = 25 | options.fileName || 26 | (extendURL ? './schema_extension.faker.graphql' : './schema.faker.graphql'); 27 | 28 | if (!options.fileName) { 29 | log( 30 | chalk.yellow( 31 | `Default file ${chalk.magenta(fileName)} is used. ` + 32 | `Specify [file] parameter to change.`, 33 | ), 34 | ); 35 | } 36 | 37 | let userSDL = existsSync(fileName) && readSDL(fileName); 38 | 39 | if (extendURL) { 40 | // run in proxy mode 41 | getRemoteSchema(extendURL, headers) 42 | .then((schema) => { 43 | const remoteSDL = new Source( 44 | printSchema(schema), 45 | `Inrospection from "${extendURL}"`, 46 | ); 47 | 48 | if (!userSDL) { 49 | let body = fs.readFileSync( 50 | path.join(__dirname, 'default-extend.graphql'), 51 | 'utf-8', 52 | ); 53 | 54 | const rootTypeName = schema.getQueryType().name; 55 | body = body.replace('___RootTypeName___', rootTypeName); 56 | 57 | userSDL = new Source(body, fileName); 58 | } 59 | 60 | const executeFn = getProxyExecuteFn(extendURL, headers, forwardHeaders); 61 | runServer(options, userSDL, remoteSDL, executeFn); 62 | }) 63 | .catch((error) => { 64 | log(chalk.red(error.stack)); 65 | process.exit(1); 66 | }); 67 | } else { 68 | if (!userSDL) { 69 | userSDL = new Source( 70 | fs.readFileSync( 71 | path.join(__dirname, 'default-schema.graphql'), 72 | 'utf-8', 73 | ), 74 | fileName, 75 | ); 76 | } 77 | runServer(options, userSDL); 78 | } 79 | }); 80 | 81 | async function runServer( 82 | options, 83 | userSDL: Source, 84 | remoteSDL?: Source, 85 | customExecuteFn?, 86 | ) { 87 | const { port, openEditor } = options; 88 | const corsOptions = { 89 | credentials: true, 90 | origin: options.corsOrigin, 91 | }; 92 | const app = express(); 93 | 94 | let schema: GraphQLSchema; 95 | let newTypes: {}; 96 | let extendedFields: {}; 97 | try { 98 | const s = remoteSDL 99 | ? buildWithFakeDefinitions(remoteSDL, userSDL) 100 | : buildWithFakeDefinitions(userSDL); 101 | schema = s.schema; 102 | newTypes = s.newTypes; 103 | extendedFields = s.extendedFields; 104 | } catch (error) { 105 | if (error instanceof ValidationErrors) { 106 | prettyPrintValidationErrors(error); 107 | process.exit(1); 108 | } else { 109 | throw error; 110 | } 111 | } 112 | 113 | console.log("Starting to load from server") 114 | const {newDatabase, newPartialsDatabase} = await createMockData(schema, newTypes, extendedFields); 115 | Object.assign(database,newDatabase); // graphql type is the key and then value is another dict from id to object 116 | Object.assign(partialsDatabase,newPartialsDatabase); 117 | Object.assign(unassignedPartials, Object.fromEntries(Object.entries(partialsDatabase).map(([typename, object_map]) => [typename, Object.keys(object_map)]))) 118 | Object.assign(unassignedFakeObjects, Object.fromEntries(Object.entries(database).map(([typename, object_map]) => [typename, Object.keys(object_map)]))) 119 | 120 | app.options('/graphql', cors(corsOptions)); 121 | app.use( 122 | '/graphql', 123 | cors(corsOptions), 124 | graphqlHTTP(() => ({ 125 | schema, 126 | typeResolver: fakeTypeResolver, 127 | fieldResolver: fakeFieldResolver, 128 | customExecuteFn, 129 | graphiql: { headerEditorEnabled: true }, 130 | })), 131 | ); 132 | 133 | app.get('/user-sdl', (_, res) => { 134 | res.status(200).json({ 135 | userSDL: userSDL.body, 136 | remoteSDL: remoteSDL && remoteSDL.body, 137 | }); 138 | }); 139 | 140 | app.use('/user-sdl', bodyParser.text({ limit: '8mb' })); 141 | app.post('/user-sdl', (req, res) => { 142 | try { 143 | const fileName = userSDL.name; 144 | fs.writeFileSync(fileName, req.body); 145 | userSDL = new Source(req.body, fileName); 146 | // TODO: tell rootbeer to generate new mock data 147 | ({schema} = remoteSDL 148 | ? buildWithFakeDefinitions(remoteSDL, userSDL) 149 | : buildWithFakeDefinitions(userSDL)) 150 | 151 | const date = new Date().toLocaleString(); 152 | log( 153 | `${chalk.green('✚')} schema saved to ${chalk.magenta( 154 | fileName, 155 | )} on ${date}`, 156 | ); 157 | 158 | res.status(200).send('ok'); 159 | } catch (err) { 160 | res.status(500).send(err.message); 161 | } 162 | }); 163 | 164 | app.use('/editor', express.static(path.join(__dirname, 'editor'))); 165 | 166 | const server = app.listen(port); 167 | 168 | const shutdown = () => { 169 | server.close(); 170 | process.exit(0); 171 | }; 172 | 173 | process.on('SIGINT', shutdown); 174 | process.on('SIGTERM', shutdown); 175 | 176 | log(`\n${chalk.green('✔')} Your GraphQL Zero API is ready to use 🚀 177 | Here are your links: 178 | 179 | ${chalk.blue('❯')} Interactive Editor: http://localhost:${port}/editor 180 | ${chalk.blue('❯')} GraphQL API: http://localhost:${port}/graphql 181 | 182 | `); 183 | 184 | if (openEditor) { 185 | setTimeout(() => open(`http://localhost:${port}/editor`), 500); 186 | } 187 | } 188 | 189 | function prettyPrintValidationErrors(validationErrors: ValidationErrors) { 190 | const { subErrors } = validationErrors; 191 | log( 192 | chalk.red( 193 | subErrors.length > 1 194 | ? `\nYour schema constains ${subErrors.length} validation errors: \n` 195 | : `\nYour schema constains a validation error: \n`, 196 | ), 197 | ); 198 | 199 | for (const error of subErrors) { 200 | let [message, ...otherLines] = error.toString().split('\n'); 201 | log([chalk.yellow(message), ...otherLines].join('\n') + '\n\n'); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/editor/index.tsx: -------------------------------------------------------------------------------- 1 | import './css/app.css'; 2 | import './css/codemirror.css'; 3 | import './GraphQLEditor/editor.css'; 4 | import 'graphiql/graphiql.css'; 5 | 6 | import GraphiQL from 'graphiql'; 7 | import * as React from 'react'; 8 | import * as ReactDOM from 'react-dom'; 9 | import classNames from 'classnames'; 10 | 11 | import { Source, GraphQLSchema } from 'graphql'; 12 | 13 | import { buildWithFakeDefinitions } from '../fake_definition'; 14 | 15 | import GraphQLEditor from './GraphQLEditor/GraphQLEditor'; 16 | import { ConsoleIcon, EditIcon, GithubIcon, VoyagerIcon } from './icons'; 17 | 18 | type FakeEditorState = { 19 | value: string | null; 20 | cachedValue: string | null; 21 | activeTab: number; 22 | hasUnsavedChanges: boolean; 23 | error: string | null; 24 | status: string | null; 25 | schema: GraphQLSchema | null; 26 | unsavedSchema: GraphQLSchema | null; 27 | remoteSDL: string | null; 28 | }; 29 | 30 | class FakeEditor extends React.Component { 31 | constructor(props) { 32 | super(props); 33 | 34 | this.state = { 35 | value: null, 36 | cachedValue: null, 37 | activeTab: 0, 38 | hasUnsavedChanges: false, 39 | unsavedSchema: null, 40 | error: null, 41 | status: null, 42 | schema: null, 43 | remoteSDL: null, 44 | }; 45 | } 46 | 47 | componentDidMount() { 48 | this.fetcher('/user-sdl') 49 | .then((response) => response.json()) 50 | .then((SDLs) => { 51 | this.updateValue(SDLs); 52 | }); 53 | 54 | window.onbeforeunload = () => { 55 | if (this.state.hasUnsavedChanges) return 'You have unsaved changes. Exit?'; 56 | }; 57 | } 58 | 59 | fetcher(url, options = {}) { 60 | const baseUrl = '..'; 61 | return fetch(baseUrl + url, { 62 | credentials: 'include', 63 | ...options, 64 | }); 65 | } 66 | 67 | graphQLFetcher(graphQLParams) { 68 | return this.fetcher('/graphql', { 69 | method: 'post', 70 | headers: { 'Content-Type': 'application/json' }, 71 | body: JSON.stringify(graphQLParams), 72 | }).then((response) => response.json()); 73 | } 74 | 75 | updateValue({ userSDL, remoteSDL }) { 76 | this.setState({ 77 | value: userSDL, 78 | cachedValue: userSDL, 79 | remoteSDL, 80 | }); 81 | this.updateSDL(userSDL, true); 82 | } 83 | 84 | postSDL(sdl) { 85 | return this.fetcher('/user-sdl', { 86 | method: 'post', 87 | headers: { 'Content-Type': 'text/plain' }, 88 | body: sdl, 89 | }); 90 | } 91 | 92 | buildSchema(userSDL, options?) { 93 | if (this.state.remoteSDL) { 94 | return buildWithFakeDefinitions( 95 | new Source(this.state.remoteSDL), 96 | new Source(userSDL), 97 | options, 98 | ); 99 | } else { 100 | return buildWithFakeDefinitions(new Source(userSDL), options); 101 | } 102 | } 103 | 104 | updateSDL(value, noError = false) { 105 | try { 106 | const {schema} = this.buildSchema(value); 107 | this.setState((prevState) => ({ 108 | ...prevState, 109 | schema, 110 | error: null, 111 | })); 112 | return true; 113 | } catch (e) { 114 | if (noError) return; 115 | this.setState((prevState) => ({ ...prevState, error: e.message })); 116 | return false; 117 | } 118 | } 119 | 120 | setStatus(status, delay) { 121 | this.setState((prevState) => ({ ...prevState, status: status })); 122 | if (!delay) return; 123 | setTimeout(() => { 124 | this.setState((prevState) => ({ ...prevState, status: null })); 125 | }, delay); 126 | } 127 | 128 | saveUserSDL = () => { 129 | let { value, hasUnsavedChanges } = this.state; 130 | if (!hasUnsavedChanges) return; 131 | 132 | if (!this.updateSDL(value)) return; 133 | 134 | this.postSDL(value).then((res) => { 135 | if (res.ok) { 136 | this.setStatus('Saved!', 2000); 137 | return this.setState((prevState) => ({ 138 | ...prevState, 139 | cachedValue: value, 140 | hasUnsavedChanges: false, 141 | unsavedSchema: null, 142 | error: null, 143 | })); 144 | } else { 145 | res.text().then((errorMessage) => { 146 | return this.setState((prevState) => ({ 147 | ...prevState, 148 | error: errorMessage, 149 | })); 150 | }); 151 | } 152 | }); 153 | }; 154 | 155 | switchTab(tab) { 156 | this.setState((prevState) => ({ ...prevState, activeTab: tab })); 157 | } 158 | 159 | onEdit = (val) => { 160 | if (this.state.error) this.updateSDL(val); 161 | let unsavedSchema = null as GraphQLSchema | null; 162 | try { 163 | const {schema} = this.buildSchema(val, { skipValidation: true }); 164 | unsavedSchema = schema; 165 | } catch (_) {} 166 | 167 | this.setState((prevState) => ({ 168 | ...prevState, 169 | value: val, 170 | hasUnsavedChanges: val !== this.state.cachedValue, 171 | unsavedSchema, 172 | })); 173 | }; 174 | 175 | render() { 176 | let { value, activeTab, schema, hasUnsavedChanges, unsavedSchema } = this.state; 177 | if (value == null || schema == null) { 178 | return
Loading...
; 179 | } 180 | 181 | return ( 182 |
183 | 229 |
230 |
235 | 241 |
242 | 248 | Save 249 | 250 |
251 | {this.state.status} 252 | {this.state.error} 253 |
254 |
255 |
256 |
261 | this.graphQLFetcher(e)} schema={schema} /> 262 |
263 |
264 |
265 | ); 266 | } 267 | } 268 | 269 | ReactDOM.render(, document.getElementById('container')); 270 | -------------------------------------------------------------------------------- /src/editor/GraphQLEditor/GraphQLEditor.tsx: -------------------------------------------------------------------------------- 1 | import 'codemirror-graphql/hint'; 2 | import 'codemirror-graphql/info'; 3 | import 'codemirror-graphql/jump'; 4 | import 'codemirror-graphql/lint'; 5 | import 'codemirror-graphql/mode'; 6 | import 'codemirror/addon/comment/comment'; 7 | import 'codemirror/addon/edit/closebrackets'; 8 | import 'codemirror/addon/edit/matchbrackets'; 9 | import 'codemirror/addon/fold/brace-fold'; 10 | import 'codemirror/addon/fold/foldgutter'; 11 | import 'codemirror/addon/hint/show-hint'; 12 | import 'codemirror/addon/lint/lint'; 13 | import 'codemirror/keymap/sublime'; 14 | import 'codemirror/keymap/sublime'; 15 | 16 | import * as CodeMirror from 'codemirror'; 17 | 18 | import { GraphQLSchema, GraphQLList, GraphQLNonNull } from 'graphql'; 19 | import * as marked from 'marked'; 20 | import * as React from 'react'; 21 | 22 | type GraphQLEditorProps = { 23 | value: string; 24 | schema: GraphQLSchema | null; 25 | onEdit: (val: string) => void; 26 | onCommand: () => void; 27 | }; 28 | 29 | export default class GraphQLEditor extends React.Component { 30 | editor: CodeMirror; 31 | cachedValue: string; 32 | ignoreChangeEvent: boolean; 33 | _node: any; 34 | 35 | constructor(props) { 36 | super(props); 37 | this.ignoreChangeEvent = false; 38 | 39 | // Keep a cached version of the value, this cache will be updated when the 40 | // editor is updated, which can later be used to protect the editor from 41 | // unnecessary updates during the update lifecycle. 42 | this.cachedValue = props.value; 43 | } 44 | 45 | componentDidMount() { 46 | const { schema, value } = this.props; 47 | const editor = CodeMirror(this._node, { 48 | value, 49 | lineNumbers: true, 50 | tabSize: 2, 51 | mode: 'graphql', 52 | theme: 'graphiql', 53 | keyMap: 'sublime', 54 | autoCloseBrackets: true, 55 | matchBrackets: true, 56 | showCursorWhenSelecting: true, 57 | foldGutter: { 58 | minFoldSize: 4, 59 | }, 60 | lint: { 61 | schema, 62 | }, 63 | hintOptions: { 64 | schema, 65 | closeOnUnfocus: false, 66 | completeSingle: false, 67 | }, 68 | info: { 69 | schema, 70 | renderDescription: (text) => marked(text, { sanitize: true }), 71 | }, 72 | jump: { 73 | schema, 74 | }, 75 | gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], 76 | extraKeys: { 77 | 'Cmd-Space': () => editor.showHint({ completeSingle: true }), 78 | 'Ctrl-Space': () => editor.showHint({ completeSingle: true }), 79 | 'Alt-Space': () => editor.showHint({ completeSingle: true }), 80 | 'Shift-Space': () => editor.showHint({ completeSingle: true }), 81 | 'Cmd-Enter': () => { 82 | if (this.props.onCommand) { 83 | this.props.onCommand(); 84 | } 85 | }, 86 | 'Ctrl-Enter': () => { 87 | if (this.props.onCommand) { 88 | this.props.onCommand(); 89 | } 90 | }, 91 | // Editor improvements 92 | 'Ctrl-Left': 'goSubwordLeft', 93 | 'Ctrl-Right': 'goSubwordRight', 94 | 'Alt-Left': 'goGroupLeft', 95 | 'Alt-Right': 'goGroupRight', 96 | }, 97 | }); 98 | 99 | editor.on('change', this._onEdit.bind(this)); 100 | editor.on('keyup', this._onKeyUp.bind(this)); 101 | editor.on('hasCompletion', this._onHasCompletion.bind(this)); 102 | this.editor = editor; 103 | } 104 | 105 | render() { 106 | return ( 107 |
{ 110 | this._node = node; 111 | }} 112 | /> 113 | ); 114 | } 115 | 116 | componentDidUpdate(prevProps) { 117 | // Ensure the changes caused by this update are not interpretted as 118 | // user-input changes which could otherwise result in an infinite 119 | // event loop. 120 | this.ignoreChangeEvent = true; 121 | 122 | const { value, schema } = this.props; 123 | 124 | if (schema != prevProps.schema) { 125 | this.editor.options.lint.schema = schema; 126 | this.editor.options.hintOptions.schema = schema; 127 | this.editor.options.info.schema = schema; 128 | this.editor.options.jump.schema = schema; 129 | CodeMirror.signal(this.editor, 'change', this.editor); 130 | } 131 | 132 | if (value !== prevProps.value && value !== this.cachedValue) { 133 | this.cachedValue = value; 134 | this.editor.setValue(value); 135 | } 136 | 137 | this.ignoreChangeEvent = false; 138 | } 139 | 140 | componentWillUnmount() { 141 | this.editor.off('change', () => this._onEdit); 142 | this.editor.off('keyup', () => this._onKeyUp); 143 | this.editor.off('hasCompletion', this._onHasCompletion); 144 | this.editor = null; 145 | } 146 | 147 | _onKeyUp(_, event) { 148 | const code = event.keyCode; 149 | if ( 150 | (code >= 65 && code <= 90) || // letters 151 | (!event.shiftKey && code >= 48 && code <= 57) || // numbers 152 | (event.shiftKey && code === 189) || // underscore 153 | (event.shiftKey && code === 50) || // @ 154 | (event.shiftKey && code === 57) || // ( 155 | (event.shiftKey && code === 186) // : 156 | ) { 157 | this.editor.execCommand('autocomplete'); 158 | } 159 | } 160 | 161 | _onEdit() { 162 | if (!this.ignoreChangeEvent) { 163 | this.cachedValue = this.editor.getValue(); 164 | this.props.onEdit(this.cachedValue); 165 | } 166 | } 167 | 168 | /** 169 | * Render a custom UI for CodeMirror's hint which includes additional info 170 | * about the type and description for the selected context. 171 | */ 172 | _onHasCompletion(cm, data) { 173 | onHasCompletion(cm, data); 174 | } 175 | } 176 | 177 | /** 178 | * Render a custom UI for CodeMirror's hint which includes additional info 179 | * about the type and description for the selected context. 180 | */ 181 | function onHasCompletion(cm, data, onHintInformationRender?) { 182 | let information; 183 | let deprecation; 184 | 185 | // When a hint result is selected, we augment the UI with information. 186 | CodeMirror.on(data, 'select', (ctx, el) => { 187 | // Only the first time (usually when the hint UI is first displayed) 188 | // do we create the information nodes. 189 | if (!information) { 190 | const hintsUl = el.parentNode; 191 | 192 | // This "information" node will contain the additional info about the 193 | // highlighted typeahead option. 194 | information = document.createElement('div'); 195 | information.className = 'CodeMirror-hint-information'; 196 | hintsUl.appendChild(information); 197 | 198 | // This "deprecation" node will contain info about deprecated usage. 199 | deprecation = document.createElement('div'); 200 | deprecation.className = 'CodeMirror-hint-deprecation'; 201 | hintsUl.appendChild(deprecation); 202 | 203 | // When CodeMirror attempts to remove the hint UI, we detect that it was 204 | // removed and in turn remove the information nodes. 205 | let onRemoveFn; 206 | hintsUl.addEventListener( 207 | 'DOMNodeRemoved', 208 | (onRemoveFn = (event) => { 209 | if (event.target === hintsUl) { 210 | hintsUl.removeEventListener('DOMNodeRemoved', onRemoveFn); 211 | information = null; 212 | deprecation = null; 213 | onRemoveFn = null; 214 | } 215 | }), 216 | ); 217 | } 218 | 219 | // Now that the UI has been set up, add info to information. 220 | const description = ctx.description 221 | ? marked(ctx.description, { sanitize: true }) 222 | : 'Self descriptive.'; 223 | const type = ctx.type ? '' + renderType(ctx.type) + '' : ''; 224 | 225 | information.innerHTML = 226 | '
' + 227 | (description.slice(0, 3) === '

' 228 | ? '

' + type + description.slice(3) 229 | : type + description) + 230 | '

'; 231 | 232 | if (ctx.isDeprecated) { 233 | const reason = ctx.deprecationReason ? marked(ctx.deprecationReason, { sanitize: true }) : ''; 234 | deprecation.innerHTML = 'Deprecated' + reason; 235 | deprecation.style.display = 'block'; 236 | } else { 237 | deprecation.style.display = 'none'; 238 | } 239 | 240 | // Additional rendering? 241 | if (onHintInformationRender) { 242 | onHintInformationRender(information); 243 | } 244 | }); 245 | } 246 | 247 | function renderType(type) { 248 | if (type instanceof GraphQLNonNull) { 249 | return `${renderType(type.ofType)}!`; 250 | } 251 | if (type instanceof GraphQLList) { 252 | return `[${renderType(type.ofType)}]`; 253 | } 254 | return `${type.name}`; 255 | } 256 | -------------------------------------------------------------------------------- /src/editor/GraphQLEditor/editor.css: -------------------------------------------------------------------------------- 1 | .graphql-editor .CodeMirror-scroll { 2 | overflow-scrolling: touch; 3 | } 4 | 5 | .graphql-editor .CodeMirror { 6 | color: #141823; 7 | font-family: 'Consolas', 'Inconsolata', 'Droid Sans Mono', 'Monaco', monospace; 8 | font-size: 13px; 9 | height: 100%; 10 | } 11 | 12 | .graphql-editor .CodeMirror-lines { 13 | padding: 20px 0; 14 | } 15 | 16 | .CodeMirror-hint-information .content { 17 | box-orient: vertical; 18 | color: #141823; 19 | display: flex; 20 | font-family: system, -apple-system, 'San Francisco', '.SFNSDisplay-Regular', 'Segoe UI', Segoe, 21 | 'Segoe WP', 'Helvetica Neue', helvetica, 'Lucida Grande', arial, sans-serif; 22 | font-size: 13px; 23 | line-clamp: 3; 24 | line-height: 16px; 25 | max-height: 48px; 26 | overflow: hidden; 27 | text-overflow: -o-ellipsis-lastline; 28 | } 29 | 30 | .CodeMirror-hint-information .content p:first-child { 31 | margin-top: 0; 32 | } 33 | 34 | .CodeMirror-hint-information .content p:last-child { 35 | margin-bottom: 0; 36 | } 37 | 38 | .CodeMirror-hint-information .infoType { 39 | color: #ca9800; 40 | cursor: pointer; 41 | display: inline; 42 | margin-right: 0.5em; 43 | } 44 | 45 | .autoInsertedLeaf.cm-property { 46 | animation-duration: 6s; 47 | animation-name: insertionFade; 48 | border-bottom: 2px solid rgba(255, 255, 255, 0); 49 | border-radius: 2px; 50 | margin: -2px -4px -1px; 51 | padding: 2px 4px 1px; 52 | } 53 | 54 | @keyframes insertionFade { 55 | from, 56 | to { 57 | background: rgba(255, 255, 255, 0); 58 | border-color: rgba(255, 255, 255, 0); 59 | } 60 | 61 | 15%, 62 | 85% { 63 | background: #fbffc9; 64 | border-color: #f0f3c0; 65 | } 66 | } 67 | 68 | div.CodeMirror-lint-tooltip { 69 | background-color: white; 70 | border-radius: 2px; 71 | border: 0; 72 | color: #141823; 73 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45); 74 | font-family: system, -apple-system, 'San Francisco', '.SFNSDisplay-Regular', 'Segoe UI', Segoe, 75 | 'Segoe WP', 'Helvetica Neue', helvetica, 'Lucida Grande', arial, sans-serif; 76 | font-size: 13px; 77 | line-height: 16px; 78 | max-width: 430px; 79 | opacity: 0; 80 | padding: 8px 10px; 81 | transition: opacity 0.15s; 82 | white-space: pre-wrap; 83 | } 84 | 85 | div.CodeMirror-lint-tooltip > * { 86 | padding-left: 23px; 87 | } 88 | 89 | div.CodeMirror-lint-tooltip > * + * { 90 | margin-top: 12px; 91 | } 92 | 93 | /* COLORS */ 94 | 95 | .graphql-editor .CodeMirror-foldmarker { 96 | border-radius: 4px; 97 | background: #08f; 98 | background: linear-gradient(#43a8ff, #0f83e8); 99 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), inset 0 0 0 1px rgba(0, 0, 0, 0.1); 100 | color: white; 101 | font-family: arial; 102 | font-size: 12px; 103 | line-height: 0; 104 | margin: 0 3px; 105 | padding: 0px 4px 1px; 106 | text-shadow: 0 -1px rgba(0, 0, 0, 0.1); 107 | } 108 | 109 | .graphql-editor div.CodeMirror span.CodeMirror-matchingbracket { 110 | color: #555; 111 | text-decoration: underline; 112 | } 113 | 114 | .graphql-editor div.CodeMirror span.CodeMirror-nonmatchingbracket { 115 | color: #f00; 116 | } 117 | 118 | /* graphiql theme */ 119 | 120 | /* Comment */ 121 | .cm-s-graphiql .cm-comment { 122 | color: #999; 123 | } 124 | 125 | /* Punctuation */ 126 | .cm-s-graphiql .cm-punctuation { 127 | color: #555; 128 | } 129 | 130 | /* Keyword */ 131 | .cm-s-graphiql .cm-keyword { 132 | color: #b11a04; 133 | } 134 | 135 | /* OperationName, FragmentName */ 136 | .cm-s-graphiql .cm-def { 137 | color: #d2054e; 138 | } 139 | 140 | /* FieldName */ 141 | .cm-s-graphiql .cm-property { 142 | color: #1f61a0; 143 | } 144 | 145 | /* FieldAlias */ 146 | .cm-s-graphiql .cm-qualifier { 147 | color: #1c92a9; 148 | } 149 | 150 | /* ArgumentName and ObjectFieldName */ 151 | .cm-s-graphiql .cm-attribute { 152 | color: #8b2bb9; 153 | } 154 | 155 | /* Number */ 156 | .cm-s-graphiql .cm-number { 157 | color: #2882f9; 158 | } 159 | 160 | /* String */ 161 | .cm-s-graphiql .cm-string { 162 | color: #d64292; 163 | } 164 | 165 | /* Boolean */ 166 | .cm-s-graphiql .cm-builtin { 167 | color: #d47509; 168 | } 169 | 170 | /* EnumValue */ 171 | .cm-s-graphiql .cm-string-2 { 172 | color: #0b7fc7; 173 | } 174 | 175 | /* Variable */ 176 | .cm-s-graphiql .cm-variable { 177 | color: #397d13; 178 | } 179 | 180 | /* Directive */ 181 | .cm-s-graphiql .cm-meta { 182 | color: #b33086; 183 | } 184 | 185 | /* Type */ 186 | .cm-s-graphiql .cm-atom { 187 | color: #ca9800; 188 | } 189 | 190 | /** 191 | Hint styling 192 | **/ 193 | .CodeMirror-hints { 194 | background: white; 195 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45); 196 | font-family: 'Consolas', 'Inconsolata', 'Droid Sans Mono', 'Monaco', monospace; 197 | font-size: 13px; 198 | list-style: none; 199 | margin-left: -6px; 200 | margin: 0; 201 | max-height: 14.5em; 202 | overflow-y: auto; 203 | overflow: hidden; 204 | padding: 0; 205 | position: absolute; 206 | z-index: 10; 207 | } 208 | 209 | .CodeMirror-hint { 210 | border-top: solid 1px #f7f7f7; 211 | color: #141823; 212 | cursor: pointer; 213 | margin: 0; 214 | max-width: 300px; 215 | overflow: hidden; 216 | padding: 2px 6px; 217 | white-space: pre; 218 | } 219 | 220 | li.CodeMirror-hint-active { 221 | background-color: #08f; 222 | border-top-color: white; 223 | color: white; 224 | } 225 | 226 | .CodeMirror-hint-information { 227 | border-top: solid 1px #c0c0c0; 228 | max-width: 300px; 229 | padding: 4px 6px; 230 | position: relative; 231 | z-index: 1; 232 | } 233 | 234 | .CodeMirror-hint-information:first-child { 235 | border-bottom: solid 1px #c0c0c0; 236 | border-top: none; 237 | margin-bottom: -1px; 238 | } 239 | 240 | .CodeMirror-hint-deprecation { 241 | background: #fffae8; 242 | box-shadow: inset 0 1px 1px -1px #bfb063; 243 | color: #867f70; 244 | font-family: system, -apple-system, 'San Francisco', '.SFNSDisplay-Regular', 'Segoe UI', Segoe, 245 | 'Segoe WP', 'Helvetica Neue', helvetica, 'Lucida Grande', arial, sans-serif; 246 | font-size: 13px; 247 | line-height: 16px; 248 | margin-top: 4px; 249 | max-height: 80px; 250 | overflow: hidden; 251 | padding: 6px; 252 | } 253 | 254 | .CodeMirror-hint-deprecation .deprecation-label { 255 | color: #c79b2e; 256 | cursor: default; 257 | display: block; 258 | font-size: 9px; 259 | font-weight: bold; 260 | letter-spacing: 1px; 261 | line-height: 1; 262 | padding-bottom: 5px; 263 | text-transform: uppercase; 264 | user-select: none; 265 | } 266 | 267 | .CodeMirror-hint-deprecation .deprecation-label + * { 268 | margin-top: 0; 269 | } 270 | 271 | .CodeMirror-hint-deprecation :last-child { 272 | margin-bottom: 0; 273 | } 274 | 275 | /** 276 | The lint marker gutter 277 | **/ 278 | .CodeMirror-lint-markers { 279 | width: 16px; 280 | } 281 | 282 | .CodeMirror-lint-tooltip { 283 | background-color: infobackground; 284 | border-radius: 4px 4px 4px 4px; 285 | border: 1px solid black; 286 | color: infotext; 287 | font-family: monospace; 288 | font-size: 10pt; 289 | max-width: 600px; 290 | opacity: 0; 291 | overflow: hidden; 292 | padding: 2px 5px; 293 | position: fixed; 294 | transition: opacity 0.4s; 295 | white-space: pre-wrap; 296 | z-index: 100; 297 | } 298 | 299 | .CodeMirror-lint-mark-error, 300 | .CodeMirror-lint-mark-warning { 301 | background-position: left bottom; 302 | background-repeat: repeat-x; 303 | } 304 | 305 | .CodeMirror-lint-mark-error { 306 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJDw4cOCW1/KIAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAHElEQVQI12NggIL/DAz/GdA5/xkY/qPKMDAwAADLZwf5rvm+LQAAAABJRU5ErkJggg=='); 307 | } 308 | 309 | .CodeMirror-lint-mark-warning { 310 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJFhQXEbhTg7YAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAMklEQVQI12NkgIIvJ3QXMjAwdDN+OaEbysDA4MPAwNDNwMCwiOHLCd1zX07o6kBVGQEAKBANtobskNMAAAAASUVORK5CYII='); 311 | } 312 | 313 | .CodeMirror-lint-marker-error, 314 | .CodeMirror-lint-marker-warning { 315 | background-position: center center; 316 | background-repeat: no-repeat; 317 | cursor: pointer; 318 | display: inline-block; 319 | height: 16px; 320 | position: relative; 321 | vertical-align: middle; 322 | width: 16px; 323 | } 324 | 325 | .CodeMirror-lint-message-error, 326 | .CodeMirror-lint-message-warning { 327 | background-position: top left; 328 | background-repeat: no-repeat; 329 | padding-left: 18px; 330 | } 331 | 332 | .CodeMirror-lint-marker-error, 333 | .CodeMirror-lint-message-error { 334 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAHlBMVEW7AAC7AACxAAC7AAC7AAAAAAC4AAC5AAD///+7AAAUdclpAAAABnRSTlMXnORSiwCK0ZKSAAAATUlEQVR42mWPOQ7AQAgDuQLx/z8csYRmPRIFIwRGnosRrpamvkKi0FTIiMASR3hhKW+hAN6/tIWhu9PDWiTGNEkTtIOucA5Oyr9ckPgAWm0GPBog6v4AAAAASUVORK5CYII='); 335 | } 336 | 337 | .CodeMirror-lint-marker-warning, 338 | .CodeMirror-lint-message-warning { 339 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAANlBMVEX/uwDvrwD/uwD/uwD/uwD/uwD/uwD/uwD/uwD6twD/uwAAAADurwD2tQD7uAD+ugAAAAD/uwDhmeTRAAAADHRSTlMJ8mN1EYcbmiixgACm7WbuAAAAVklEQVR42n3PUQqAIBBFUU1LLc3u/jdbOJoW1P08DA9Gba8+YWJ6gNJoNYIBzAA2chBth5kLmG9YUoG0NHAUwFXwO9LuBQL1giCQb8gC9Oro2vp5rncCIY8L8uEx5ZkAAAAASUVORK5CYII='); 340 | } 341 | 342 | .CodeMirror-lint-marker-multiple { 343 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAMAAADzjKfhAAAACVBMVEUAAAAAAAC/v7914kyHAAAAAXRSTlMAQObYZgAAACNJREFUeNo1ioEJAAAIwmz/H90iFFSGJgFMe3gaLZ0od+9/AQZ0ADosbYraAAAAAElFTkSuQmCC'); 344 | background-position: right bottom; 345 | background-repeat: no-repeat; 346 | width: 100%; 347 | height: 100%; 348 | } 349 | -------------------------------------------------------------------------------- /src/editor/css/codemirror.css: -------------------------------------------------------------------------------- 1 | /* BASICS */ 2 | 3 | .CodeMirror { 4 | /* Set height, width, borders, and global font properties here */ 5 | color: black; 6 | font-family: monospace; 7 | height: 300px; 8 | } 9 | 10 | /* PADDING */ 11 | 12 | .CodeMirror-lines { 13 | padding: 4px 0; /* Vertical padding around content */ 14 | } 15 | .CodeMirror pre { 16 | padding: 0 4px; /* Horizontal padding of content */ 17 | } 18 | 19 | .CodeMirror-scrollbar-filler, 20 | .CodeMirror-gutter-filler { 21 | background-color: white; /* The little square between H and V scrollbars */ 22 | } 23 | 24 | /* GUTTER */ 25 | 26 | .CodeMirror-gutters { 27 | border-right: 1px solid #ddd; 28 | background-color: #f7f7f7; 29 | white-space: nowrap; 30 | } 31 | .CodeMirror-linenumbers { 32 | } 33 | .CodeMirror-linenumber { 34 | color: #999; 35 | min-width: 20px; 36 | padding: 0 3px 0 5px; 37 | text-align: right; 38 | white-space: nowrap; 39 | } 40 | 41 | .CodeMirror-guttermarker { 42 | color: black; 43 | } 44 | .CodeMirror-guttermarker-subtle { 45 | color: #999; 46 | } 47 | 48 | /* CURSOR */ 49 | 50 | .CodeMirror div.CodeMirror-cursor { 51 | border-left: 1px solid black; 52 | } 53 | /* Shown when moving in bi-directional text */ 54 | .CodeMirror div.CodeMirror-secondarycursor { 55 | border-left: 1px solid silver; 56 | } 57 | .CodeMirror.cm-fat-cursor div.CodeMirror-cursor { 58 | background: #7e7; 59 | border: 0; 60 | width: auto; 61 | } 62 | .CodeMirror.cm-fat-cursor div.CodeMirror-cursors { 63 | z-index: 1; 64 | } 65 | 66 | .cm-animate-fat-cursor { 67 | animation: blink 1.06s steps(1) infinite; 68 | border: 0; 69 | width: auto; 70 | } 71 | @keyframes blink { 72 | 0% { 73 | background: #7e7; 74 | } 75 | 50% { 76 | background: none; 77 | } 78 | 100% { 79 | background: #7e7; 80 | } 81 | } 82 | 83 | /* Can style cursor different in overwrite (non-insert) mode */ 84 | div.CodeMirror-overwrite div.CodeMirror-cursor { 85 | } 86 | 87 | .cm-tab { 88 | display: inline-block; 89 | text-decoration: inherit; 90 | } 91 | 92 | .CodeMirror-ruler { 93 | border-left: 1px solid #ccc; 94 | position: absolute; 95 | } 96 | 97 | /* DEFAULT THEME */ 98 | 99 | .cm-s-default .cm-keyword { 100 | color: #708; 101 | } 102 | .cm-s-default .cm-atom { 103 | color: #219; 104 | } 105 | .cm-s-default .cm-number { 106 | color: #164; 107 | } 108 | .cm-s-default .cm-def { 109 | color: #00f; 110 | } 111 | .cm-s-default .cm-variable, 112 | .cm-s-default .cm-punctuation, 113 | .cm-s-default .cm-property, 114 | .cm-s-default .cm-operator { 115 | } 116 | .cm-s-default .cm-variable-2 { 117 | color: #05a; 118 | } 119 | .cm-s-default .cm-variable-3 { 120 | color: #085; 121 | } 122 | .cm-s-default .cm-comment { 123 | color: #a50; 124 | } 125 | .cm-s-default .cm-string { 126 | color: #a11; 127 | } 128 | .cm-s-default .cm-string-2 { 129 | color: #f50; 130 | } 131 | .cm-s-default .cm-meta { 132 | color: #555; 133 | } 134 | .cm-s-default .cm-qualifier { 135 | color: #555; 136 | } 137 | .cm-s-default .cm-builtin { 138 | color: #30a; 139 | } 140 | .cm-s-default .cm-bracket { 141 | color: #997; 142 | } 143 | .cm-s-default .cm-tag { 144 | color: #170; 145 | } 146 | .cm-s-default .cm-attribute { 147 | color: #00c; 148 | } 149 | .cm-s-default .cm-header { 150 | color: blue; 151 | } 152 | .cm-s-default .cm-quote { 153 | color: #090; 154 | } 155 | .cm-s-default .cm-hr { 156 | color: #999; 157 | } 158 | .cm-s-default .cm-link { 159 | color: #00c; 160 | } 161 | 162 | .cm-negative { 163 | color: #d44; 164 | } 165 | .cm-positive { 166 | color: #292; 167 | } 168 | .cm-header, 169 | .cm-strong { 170 | font-weight: bold; 171 | } 172 | .cm-em { 173 | font-style: italic; 174 | } 175 | .cm-link { 176 | text-decoration: underline; 177 | } 178 | .cm-strikethrough { 179 | text-decoration: line-through; 180 | } 181 | 182 | .cm-s-default .cm-error { 183 | color: #f00; 184 | } 185 | .cm-invalidchar { 186 | color: #f00; 187 | } 188 | 189 | .CodeMirror-composing { 190 | border-bottom: 2px solid; 191 | } 192 | 193 | /* Default styles for common addons */ 194 | 195 | div.CodeMirror span.CodeMirror-matchingbracket { 196 | color: #0f0; 197 | } 198 | div.CodeMirror span.CodeMirror-nonmatchingbracket { 199 | color: #f22; 200 | } 201 | .CodeMirror-matchingtag { 202 | background: rgba(255, 150, 0, 0.3); 203 | } 204 | .CodeMirror-activeline-background { 205 | background: #e8f2ff; 206 | } 207 | 208 | /* STOP */ 209 | 210 | /* The rest of this file contains styles related to the mechanics of 211 | the editor. You probably shouldn't touch them. */ 212 | 213 | .CodeMirror { 214 | background: white; 215 | overflow: hidden; 216 | position: relative; 217 | } 218 | 219 | .CodeMirror-scroll { 220 | height: 100%; 221 | /* 30px is the magic margin used to hide the element's real scrollbars */ 222 | /* See overflow: hidden in .CodeMirror */ 223 | margin-bottom: -30px; 224 | margin-right: -30px; 225 | outline: none; /* Prevent dragging from highlighting the element */ 226 | overflow: scroll !important; /* Things will break if this is overridden */ 227 | padding-bottom: 30px; 228 | position: relative; 229 | } 230 | .CodeMirror-sizer { 231 | border-right: 30px solid transparent; 232 | position: relative; 233 | } 234 | 235 | /* The fake, visible scrollbars. Used to force redraw during scrolling 236 | before actual scrolling happens, thus preventing shaking and 237 | flickering artifacts. */ 238 | .CodeMirror-vscrollbar, 239 | .CodeMirror-hscrollbar, 240 | .CodeMirror-scrollbar-filler, 241 | .CodeMirror-gutter-filler { 242 | display: none; 243 | position: absolute; 244 | z-index: 6; 245 | } 246 | .CodeMirror-vscrollbar { 247 | overflow-x: hidden; 248 | overflow-y: scroll; 249 | right: 0; 250 | top: 0; 251 | } 252 | .CodeMirror-hscrollbar { 253 | bottom: 0; 254 | left: 0; 255 | overflow-x: scroll; 256 | overflow-y: hidden; 257 | } 258 | .CodeMirror-scrollbar-filler { 259 | right: 0; 260 | bottom: 0; 261 | } 262 | .CodeMirror-gutter-filler { 263 | left: 0; 264 | bottom: 0; 265 | } 266 | 267 | .CodeMirror-gutters { 268 | min-height: 100%; 269 | position: absolute; 270 | left: 0; 271 | top: 0; 272 | z-index: 3; 273 | } 274 | .CodeMirror-gutter { 275 | display: inline-block; 276 | height: 100%; 277 | margin-bottom: -30px; 278 | vertical-align: top; 279 | white-space: normal; 280 | /* Hack to make IE7 behave */ 281 | *zoom: 1; 282 | *display: inline; 283 | } 284 | .CodeMirror-gutter-wrapper { 285 | background: none !important; 286 | border: none !important; 287 | position: absolute; 288 | z-index: 4; 289 | } 290 | .CodeMirror-gutter-background { 291 | position: absolute; 292 | top: 0; 293 | bottom: 0; 294 | z-index: 4; 295 | } 296 | .CodeMirror-gutter-elt { 297 | cursor: default; 298 | position: absolute; 299 | z-index: 4; 300 | } 301 | .CodeMirror-gutter-wrapper { 302 | user-select: none; 303 | } 304 | 305 | .CodeMirror-lines { 306 | cursor: text; 307 | min-height: 1px; /* prevents collapsing before first draw */ 308 | } 309 | .CodeMirror pre { 310 | -webkit-tap-highlight-color: transparent; 311 | /* Reset some styles that the rest of the page might have set */ 312 | background: transparent; 313 | border-radius: 0; 314 | border-width: 0; 315 | color: inherit; 316 | font-family: inherit; 317 | font-size: inherit; 318 | font-variant-ligatures: none; 319 | line-height: inherit; 320 | margin: 0; 321 | overflow: visible; 322 | position: relative; 323 | white-space: pre; 324 | word-wrap: normal; 325 | z-index: 2; 326 | } 327 | .CodeMirror-wrap pre { 328 | word-wrap: break-word; 329 | white-space: pre-wrap; 330 | word-break: normal; 331 | } 332 | 333 | .CodeMirror-linebackground { 334 | position: absolute; 335 | left: 0; 336 | right: 0; 337 | top: 0; 338 | bottom: 0; 339 | z-index: 0; 340 | } 341 | 342 | .CodeMirror-linewidget { 343 | overflow: auto; 344 | position: relative; 345 | z-index: 2; 346 | } 347 | 348 | .CodeMirror-widget { 349 | } 350 | 351 | .CodeMirror-code { 352 | outline: none; 353 | } 354 | 355 | /* Force content-box sizing for the elements where we expect it */ 356 | .CodeMirror-scroll, 357 | .CodeMirror-sizer, 358 | .CodeMirror-gutter, 359 | .CodeMirror-gutters, 360 | .CodeMirror-linenumber { 361 | box-sizing: content-box; 362 | } 363 | 364 | .CodeMirror-measure { 365 | height: 0; 366 | overflow: hidden; 367 | position: absolute; 368 | visibility: hidden; 369 | width: 100%; 370 | } 371 | 372 | .CodeMirror-cursor { 373 | position: absolute; 374 | } 375 | .CodeMirror-measure pre { 376 | position: static; 377 | } 378 | 379 | div.CodeMirror-cursors { 380 | position: relative; 381 | visibility: hidden; 382 | z-index: 3; 383 | } 384 | div.CodeMirror-dragcursors { 385 | visibility: visible; 386 | } 387 | 388 | .CodeMirror-focused div.CodeMirror-cursors { 389 | visibility: visible; 390 | } 391 | 392 | .CodeMirror-selected { 393 | background: #d9d9d9; 394 | } 395 | .CodeMirror-focused .CodeMirror-selected { 396 | background: #d7d4f0; 397 | } 398 | .CodeMirror-crosshair { 399 | cursor: crosshair; 400 | } 401 | .CodeMirror-line::selection, 402 | .CodeMirror-line > span::selection, 403 | .CodeMirror-line > span > span::selection { 404 | background: #d7d4f0; 405 | } 406 | .CodeMirror-line::-moz-selection, 407 | .CodeMirror-line > span::-moz-selection, 408 | .CodeMirror-line > span > span::-moz-selection { 409 | background: #d7d4f0; 410 | } 411 | 412 | .cm-searching { 413 | background: #ffa; 414 | background: rgba(255, 255, 0, 0.4); 415 | } 416 | 417 | /* IE7 hack to prevent it from returning funny offsetTops on the spans */ 418 | .CodeMirror span { 419 | *vertical-align: text-bottom; 420 | } 421 | 422 | /* Used to force a border model for a node */ 423 | .cm-force-border { 424 | padding-right: 0.1px; 425 | } 426 | 427 | @media print { 428 | /* Hide the cursor when printing */ 429 | .CodeMirror div.CodeMirror-cursors { 430 | visibility: hidden; 431 | } 432 | } 433 | 434 | /* See issue #2901 */ 435 | .cm-tab-wrap-hack:after { 436 | content: ''; 437 | } 438 | 439 | /* Help users use markselection to safely style text background */ 440 | span.CodeMirror-selectedtext { 441 | background: none; 442 | } 443 | 444 | .CodeMirror-dialog { 445 | background: inherit; 446 | color: inherit; 447 | left: 0; 448 | right: 0; 449 | overflow: hidden; 450 | padding: 0.1em 0.8em; 451 | position: absolute; 452 | z-index: 15; 453 | } 454 | 455 | .CodeMirror-dialog-top { 456 | border-bottom: 1px solid #eee; 457 | top: 0; 458 | } 459 | 460 | .CodeMirror-dialog-bottom { 461 | border-top: 1px solid #eee; 462 | bottom: 0; 463 | } 464 | 465 | .CodeMirror-dialog input { 466 | background: transparent; 467 | border: 1px solid #d3d6db; 468 | color: inherit; 469 | font-family: monospace; 470 | outline: none; 471 | width: 20em; 472 | } 473 | 474 | .CodeMirror-dialog button { 475 | font-size: 70%; 476 | } 477 | -------------------------------------------------------------------------------- /src/__tests__/resolvers.test.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLError, graphql } from 'graphql'; 2 | import {fakeTypeResolver, fakeFieldResolver, database, queryHeuristics, unassignedFakeObjects} from '../resolvers'; 3 | import { 4 | parse, 5 | buildASTSchema, 6 | } from 'graphql'; 7 | import * as fs from 'fs'; 8 | import * as path from 'path'; 9 | 10 | const fileToSchema = (filename: string) => buildASTSchema(parse(fs.readFileSync(path.join(__dirname, filename), 'utf-8'))) 11 | const shopifySchema = fileToSchema('../../examples/shopify.graphql') 12 | const linearSchema = fileToSchema('../../examples/linear.graphql') 13 | 14 | describe('Query Params Heuristics', () => { 15 | afterEach(() => { 16 | Object.assign(database,{}); 17 | Object.assign(unassignedFakeObjects,{}); 18 | }); 19 | 20 | test('simple e2e query', () => { 21 | const schemaAST = parse(` 22 | type Query { 23 | getAllUsers(limit: Int): [User!]! 24 | } 25 | type User { 26 | id: ID! 27 | name: String! 28 | } 29 | `); 30 | const schema = buildASTSchema(schemaAST); 31 | Object.assign(database,{User: {"1": {id: "1", name: "Evan"}, "2": {id: "2", name: "Kenny"}, "3": {id: "3", name: "Bob"}}}); 32 | Object.assign(unassignedFakeObjects, Object.fromEntries(Object.entries(database).map(([typename, object_map]) => [typename, Object.keys(object_map)]))) 33 | return graphql({ 34 | schema, 35 | source: ` 36 | query { 37 | getAllUsers(limit: 2) { 38 | id 39 | name 40 | } 41 | }`, 42 | typeResolver: fakeTypeResolver, 43 | fieldResolver: fakeFieldResolver 44 | }).then((result) => { 45 | expect(result).toEqual({ 46 | data: { 47 | getAllUsers: [ 48 | {id: "1", name: "Evan"}, 49 | {id: "2", name: "Kenny"}, 50 | ] 51 | } 52 | }); 53 | }); 54 | }); 55 | 56 | test('simple limit', () => { 57 | expect(queryHeuristics([3, 4, 5], {limit: 2}, ["0", "1", "2"])).toEqual({objs: [3, 4], pageInfo: {endCursor: "1", startCursor: "0", hasPreviousPage: false, hasNextPage: true}}); 58 | expect(queryHeuristics([3, 4, 5], {limit: 3}, ["0", "1", "2"])).toEqual({objs: [3, 4, 5], pageInfo: {endCursor: "2", startCursor: "0", hasPreviousPage: false, hasNextPage: false}}); 59 | }); 60 | }); 61 | 62 | describe('shopify', () => { 63 | afterEach(() => { 64 | Object.assign(database,{}); 65 | Object.assign(unassignedFakeObjects,{}); 66 | }); 67 | 68 | test('toplevel connection', async () => { 69 | Object.assign(database,{Article: {"1": {id: "1", title: "Art of War"}, "2": {id: "2", title: "Three Body Problem"}, "3": {id: "3", title: "Wandering Earth"}}}); 70 | Object.assign(unassignedFakeObjects, Object.fromEntries(Object.entries(database).map(([typename, object_map]) => [typename, Object.keys(object_map)]))) 71 | const result = await graphql({ 72 | schema: shopifySchema, 73 | source: ` 74 | query { 75 | articles(first: 2, reverse: true) { 76 | edges { 77 | node { 78 | id 79 | title 80 | } 81 | } 82 | nodes { 83 | id 84 | title 85 | } 86 | pageInfo { 87 | endCursor 88 | startCursor 89 | } 90 | } 91 | }`, 92 | typeResolver: fakeTypeResolver, 93 | fieldResolver: fakeFieldResolver 94 | }) 95 | expect(result.data.articles.edges.map(e => e.node)).toEqual(result.data.articles.nodes); 96 | expect(result).toEqual({ 97 | data: { 98 | articles: { 99 | edges: [ 100 | {node: {id: "3", title: "Wandering Earth"}}, 101 | {node: {id: "2", title: "Three Body Problem"}}, 102 | ], 103 | nodes: [ 104 | {id: "3", title: "Wandering Earth"}, 105 | {id: "2", title: "Three Body Problem"}, 106 | ], 107 | pageInfo: { 108 | endCursor: "2", 109 | startCursor: "3" 110 | } 111 | } 112 | } 113 | }); 114 | // new args this time 115 | const result2 = await graphql({ 116 | schema: shopifySchema, 117 | source: ` 118 | query { 119 | articles(after: "1", reverse: false) { 120 | edges { 121 | node { 122 | id 123 | title 124 | } 125 | } 126 | nodes { 127 | id 128 | title 129 | } 130 | pageInfo { 131 | endCursor 132 | startCursor 133 | hasNextPage 134 | hasPreviousPage 135 | } 136 | } 137 | }`, 138 | typeResolver: fakeTypeResolver, 139 | fieldResolver: fakeFieldResolver 140 | }) 141 | expect(result2.data.articles.edges.map(e => e.node)).toEqual(result2.data.articles.nodes); 142 | expect(result2).toEqual({ 143 | data: { 144 | articles: { 145 | edges: [ 146 | {node: {id: "2", title: "Three Body Problem"}}, 147 | {node: {id: "3", title: "Wandering Earth"}}, 148 | ], 149 | nodes: [ 150 | {id: "2", title: "Three Body Problem"}, 151 | {id: "3", title: "Wandering Earth"}, 152 | ], 153 | pageInfo: { 154 | startCursor: "2", 155 | endCursor: "3", 156 | hasNextPage: false, 157 | hasPreviousPage: true 158 | } 159 | } 160 | } 161 | }); 162 | }); 163 | 164 | test('nested connection', () => { 165 | Object.assign(database,{ 166 | Product: {"1": {id: "1", title: "", images: 7}, "2": {id: "2", title: "Flamethrower", images: 8}, "3": {id: "3", title: "", images: 9}}, 167 | Image: {"4": {id: "4", width: 100}, "5": {id: "5", width: 200}, "6": {id: "6", width: 300}}, 168 | ImageConnection: { 169 | "7": {edges: [9, 10], nodes: [4, 5], pageInfo: {endCursor: "5", startCursor: "4"}}, 170 | "8": {edges: [11], nodes: [6], pageInfo: {endCursor: "6", startCursor: "6"}} 171 | }, 172 | ImageEdge: { 173 | "9": {cursor: "4", node: 4}, 174 | "10": {cursor: "5", node: 5}, 175 | "11": {cursor: "6", node: 6} 176 | } 177 | }); 178 | Object.assign(unassignedFakeObjects, Object.fromEntries(Object.entries(database).map(([typename, object_map]) => [typename, Object.keys(object_map)]))) 179 | return graphql({ 180 | schema: shopifySchema, 181 | source: ` 182 | query { 183 | product(id: "2") { 184 | title 185 | images { 186 | edges { 187 | node { 188 | id 189 | width 190 | } 191 | } 192 | nodes { 193 | id 194 | width 195 | } 196 | pageInfo { 197 | endCursor 198 | startCursor 199 | } 200 | } 201 | } 202 | }`, 203 | typeResolver: fakeTypeResolver, 204 | fieldResolver: fakeFieldResolver 205 | }).then((result) => { 206 | expect(result.data.product.images.edges.map(e => e.node)).toEqual(result.data.product.images.nodes); 207 | expect(result).toEqual({ 208 | data: { 209 | product: { 210 | title: "Flamethrower", 211 | images: { 212 | edges: [ 213 | {node: {id: "6", width: 300}}, 214 | ], 215 | nodes: [ 216 | {id: "6", width: 300}, 217 | ], 218 | pageInfo: { 219 | endCursor: "6", 220 | startCursor: "6" 221 | } 222 | } 223 | } 224 | } 225 | }); 226 | }); 227 | }); 228 | 229 | test('nonconnection basic args', () => { 230 | Object.assign(database,{ 231 | Product: { 232 | "1": {id: "1", options: [4, 5, 6], title: ""}, 233 | "2": {id: "2", options: [4, 5, 6], title: "T Shirt"}, 234 | "3": {id: "3", options: [], title: "Metal Waist Belt"} 235 | }, 236 | ProductOption: { 237 | "4": {id: "4", name: "Size", values: ["Small", "Medium", "Large"]}, 238 | "5": {id: "5", name: "Color", values: ["Red", "Green", "Blue"]}, 239 | "6": {id: "6", name: "Material", values: ["Cotton", "Synthetic", "Wool"]} 240 | }, 241 | }); 242 | Object.assign(unassignedFakeObjects, Object.fromEntries(Object.entries(database).map(([typename, object_map]) => [typename, Object.keys(object_map)]))) 243 | return graphql({ 244 | schema: shopifySchema, 245 | source: ` 246 | query { 247 | p2: product(id: "2") { 248 | title 249 | options(first: 2) { 250 | id 251 | name 252 | values 253 | } 254 | } 255 | p3: product(id: "3") { 256 | title 257 | options(first: 2) { 258 | id 259 | name 260 | values 261 | } 262 | } 263 | }`, 264 | typeResolver: fakeTypeResolver, 265 | fieldResolver: fakeFieldResolver 266 | }).then((result) => { 267 | expect(result).toEqual({ 268 | data: { 269 | p2: { 270 | title: "T Shirt", 271 | options: [ 272 | {id: "4", name: "Size", values: ["Small", "Medium", "Large"]}, 273 | {id: "5", name: "Color", values: ["Red", "Green", "Blue"]}, 274 | ] 275 | }, 276 | p3: { 277 | title: "Metal Waist Belt", 278 | options: [] 279 | } 280 | } 281 | }); 282 | }); 283 | }); 284 | 285 | test('get item on nonid field', () => { 286 | Object.assign(database,{ 287 | Collection: { 288 | "7": {id: "7", products: [1, 2, 3], title: "Belts", handle: "belts"}, 289 | "8": {id: "8", products: [4, 5, 6], title: "Shoes and Socks", handle: "shoes-and-socks"}, 290 | } 291 | }); 292 | Object.assign(unassignedFakeObjects, Object.fromEntries(Object.entries(database).map(([typename, object_map]) => [typename, Object.keys(object_map)]))) 293 | return graphql({ 294 | schema: shopifySchema, 295 | source: ` 296 | query { 297 | collection(handle: "shoes-and-socks") { 298 | title 299 | } 300 | }`, 301 | typeResolver: fakeTypeResolver, 302 | fieldResolver: fakeFieldResolver 303 | }).then((result) => { 304 | expect(result).toEqual({ 305 | data: { 306 | collection: { 307 | title: "Shoes and Socks", 308 | } 309 | } 310 | }); 311 | }); 312 | }); 313 | 314 | // test('StringConnection is a connection of scalars', () => { 315 | // Object.assign(database,{ 316 | // StringConnection: { 317 | // "7": {edges: [9, 10, 11, 12, 13], nodes: ["winter", "summer", "spring", "dance", "sports"], pageInfo: {endCursor: "5", startCursor: "4"}}, 318 | // "8": {edges: [14, 15, 16], nodes: ["shoes", "hats", "shirts"], pageInfo: {endCursor: "6", startCursor: "6"}} 319 | // }, 320 | // StringEdge: { 321 | // "9": {cursor: "0", node: "winter"}, 322 | // "10": {cursor: "1", node: "summer"}, 323 | // "11": {cursor: "2", node: "spring"}, 324 | // "12": {cursor: "3", node: "dance"}, 325 | // "13": {cursor: "4", node: "sports"}, 326 | // "14": {cursor: "5", node: "shoes"}, 327 | // "15": {cursor: "6", node: "hats"}, 328 | // "16": {cursor: "7", node: "shirts"}, 329 | // } 330 | // }); 331 | // Object.assign(unassignedFakeObjects, Object.fromEntries(Object.entries(database).map(([typename, object_map]) => [typename, Object.keys(object_map)]))) 332 | // return graphql({ 333 | // schema: shopifySchema, 334 | // source: ` 335 | // query { 336 | // productTags(first: 3) { 337 | // edges { 338 | // node 339 | // } 340 | // } 341 | // productTypes(first: 2) { 342 | // edges { 343 | // node 344 | // } 345 | // } 346 | // }`, 347 | // typeResolver: fakeTypeResolver, 348 | // fieldResolver: fakeFieldResolver 349 | // }).then((result) => { 350 | // expect(result).toEqual({ 351 | // data: { 352 | // collection: { 353 | // title: "Shoes and Socks", 354 | // } 355 | // } 356 | // }); 357 | // }); 358 | // }); 359 | 360 | test('get all api versions', () => { 361 | Object.assign(database,{ 362 | ApiVersion: { 363 | "6": {id: "6", displayName: "v3.1 Alpha", handle: "v3-01-alpha", supported: false}, 364 | "7": {id: "7", displayName: "v3.2 Alpha", handle: "v3-02-alpha", supported: false}, 365 | "8": {id: "8", displayName: "v3 RC", handle: "v3-rc", supported: true}, 366 | "9": {id: "9", displayName: "v3", handle: "v3-prod", supported: true}, 367 | "10": {id: "10", displayName: "v4 Alpha", handle: "v4-alpha", supported: false}, 368 | "11": {id: "11", displayName: "v4 RC", handle: "v4-rc", supported: true}, 369 | "12": {id: "12", displayName: "v4", handle: "v4-prod", supported: true}, 370 | } 371 | }); 372 | Object.assign(unassignedFakeObjects, Object.fromEntries(Object.entries(database).map(([typename, object_map]) => [typename, Object.keys(object_map)]))) 373 | return graphql({ 374 | schema: shopifySchema, 375 | source: ` 376 | query { 377 | publicApiVersions { 378 | displayName 379 | handle 380 | supported 381 | } 382 | }`, 383 | typeResolver: fakeTypeResolver, 384 | fieldResolver: fakeFieldResolver 385 | }).then((result) => { 386 | expect(result).toEqual({ 387 | data: { 388 | publicApiVersions: [ 389 | { 390 | displayName: "v3.1 Alpha", 391 | handle: "v3-01-alpha", 392 | supported: false 393 | }, 394 | { 395 | displayName: "v3.2 Alpha", 396 | handle: "v3-02-alpha", 397 | supported: false 398 | }, 399 | { 400 | displayName: "v3 RC", 401 | handle: "v3-rc", 402 | supported: true 403 | }, 404 | { 405 | displayName: "v3", 406 | handle: "v3-prod", 407 | supported: true 408 | }, 409 | { 410 | displayName: "v4 Alpha", 411 | handle: "v4-alpha", 412 | supported: false 413 | }, 414 | { 415 | displayName: "v4 RC", 416 | handle: "v4-rc", 417 | supported: true 418 | }, 419 | { 420 | displayName: "v4", 421 | handle: "v4-prod", 422 | supported: true 423 | } 424 | ] 425 | } 426 | }); 427 | }); 428 | }); 429 | 430 | test('err on missing fields in database blob', async () => { 431 | Object.assign(database,{ 432 | ApiVersion: { 433 | "7": {id: "7", products: [1, 2, 3], title: "Belts", handle: "belts"}, 434 | "8": {id: "8", products: [4, 5, 6], title: "Shoes and Socks", handle: "shoes-and-socks"}, 435 | } 436 | }); 437 | Object.assign(unassignedFakeObjects, Object.fromEntries(Object.entries(database).map(([typename, object_map]) => [typename, Object.keys(object_map)]))) 438 | const result1 = await graphql({ 439 | schema: shopifySchema, 440 | source: ` 441 | query { 442 | publicApiVersions { 443 | displayName 444 | handle 445 | supported 446 | } 447 | }`, 448 | typeResolver: fakeTypeResolver, 449 | fieldResolver: fakeFieldResolver 450 | }) 451 | expect(result1).toEqual({ 452 | data: null, 453 | errors: [ 454 | new GraphQLError("the field, displayName, should be in parent field since it's a leaf and non root and nonnull") 455 | ] 456 | }); 457 | const result2 = await graphql({ 458 | schema: shopifySchema, 459 | source: ` 460 | query { 461 | publicApiVersions { 462 | handle 463 | supported 464 | } 465 | }`, 466 | typeResolver: fakeTypeResolver, 467 | fieldResolver: fakeFieldResolver 468 | }) 469 | expect(result2).toEqual({ 470 | data: null, 471 | errors: [ 472 | new GraphQLError("the field, supported, should be in parent field since it's a leaf and non root and nonnull") 473 | ] 474 | }); 475 | }); 476 | }); 477 | 478 | describe('linear', () => { 479 | afterEach(() => { 480 | Object.assign(database,{}); 481 | Object.assign(unassignedFakeObjects,{}); 482 | }); 483 | 484 | test('toplevel pagination', async () => { 485 | Object.assign(database,{ProjectMilestone: {"1": {id: "1", name: "Art of War", updatedAt: "2016-03-01T13:10:20Z"}, "2": {id: "2", name: "Three Body Problem", updatedAt: "2016-02-01T13:10:20Z"}, "3": {id: "3", name: "Wandering Earth", updatedAt: "2016-01-01T13:10:20Z"}}}); 486 | Object.assign(unassignedFakeObjects, Object.fromEntries(Object.entries(database).map(([typename, object_map]) => [typename, Object.keys(object_map)]))) 487 | const result = await graphql({ 488 | schema: linearSchema, 489 | source: ` 490 | query { 491 | ProjectMilestones(first: 2, orderBy: updatedAt) { 492 | edges { 493 | node { 494 | id 495 | name 496 | } 497 | } 498 | nodes { 499 | id 500 | name 501 | } 502 | pageInfo { 503 | endCursor 504 | startCursor 505 | } 506 | } 507 | }`, 508 | typeResolver: fakeTypeResolver, 509 | fieldResolver: fakeFieldResolver 510 | }) 511 | expect(result.data.ProjectMilestones.edges.map(e => e.node)).toEqual(result.data.ProjectMilestones.nodes); 512 | expect(result).toEqual({ 513 | data: { 514 | ProjectMilestones: { 515 | edges: [ 516 | {node: {id: "3", name: "Wandering Earth"}}, 517 | {node: {id: "2", name: "Three Body Problem"}}, 518 | ], 519 | nodes: [ 520 | {id: "3", name: "Wandering Earth"}, 521 | {id: "2", name: "Three Body Problem"}, 522 | ], 523 | pageInfo: { 524 | endCursor: "2", 525 | startCursor: "3" 526 | } 527 | } 528 | } 529 | }); 530 | // new args this time 531 | const result2 = await graphql({ 532 | schema: shopifySchema, 533 | source: ` 534 | query { 535 | articles(after: "1", reverse: false) { 536 | edges { 537 | node { 538 | id 539 | title 540 | } 541 | } 542 | nodes { 543 | id 544 | title 545 | } 546 | pageInfo { 547 | endCursor 548 | startCursor 549 | hasNextPage 550 | hasPreviousPage 551 | } 552 | } 553 | }`, 554 | typeResolver: fakeTypeResolver, 555 | fieldResolver: fakeFieldResolver 556 | }) 557 | expect(result2.data.articles.edges.map(e => e.node)).toEqual(result2.data.articles.nodes); 558 | expect(result2).toEqual({ 559 | data: { 560 | articles: { 561 | edges: [ 562 | {node: {id: "2", title: "Three Body Problem"}}, 563 | {node: {id: "3", title: "Wandering Earth"}}, 564 | ], 565 | nodes: [ 566 | {id: "2", title: "Three Body Problem"}, 567 | {id: "3", title: "Wandering Earth"}, 568 | ], 569 | pageInfo: { 570 | startCursor: "2", 571 | endCursor: "3", 572 | hasNextPage: false, 573 | hasPreviousPage: true 574 | } 575 | } 576 | } 577 | }); 578 | }); 579 | 580 | 581 | test('get item on id field String instead of ID', () => { 582 | Object.assign(database,{ 583 | CustomView: { 584 | "1a": {id: "1a", organization: 4, name: "Belts"}, 585 | "2a": {id: "2a", organization: 5, name: "Shoes and Socks"}, 586 | "3a": {id: "3a", organization: 5, name: "rovers"}, 587 | }, 588 | Organization: { 589 | "4": {id: "4"}, 590 | "5": {id: "5"}, 591 | } 592 | }); 593 | Object.assign(unassignedFakeObjects, Object.fromEntries(Object.entries(database).map(([typename, object_map]) => [typename, Object.keys(object_map)]))) 594 | return graphql({ 595 | schema: linearSchema, 596 | source: ` 597 | query { 598 | customView(id: "2a") { 599 | name 600 | organization { 601 | id 602 | } 603 | } 604 | }`, 605 | typeResolver: fakeTypeResolver, 606 | fieldResolver: fakeFieldResolver 607 | }).then((result) => { 608 | expect(result).toEqual({ 609 | data: { 610 | customView: { 611 | name: "Shoes and Socks", 612 | organization: { 613 | id: "5" 614 | } 615 | } 616 | } 617 | }); 618 | }); 619 | }); 620 | }); -------------------------------------------------------------------------------- /src/resolvers.ts: -------------------------------------------------------------------------------- 1 | // import * as assert from 'assert'; 2 | import { 3 | isListType, 4 | isNonNullType, 5 | isCompositeType, 6 | isLeafType, 7 | getNullableType, 8 | GraphQLTypeResolver, 9 | GraphQLFieldResolver, 10 | defaultTypeResolver, 11 | defaultFieldResolver, 12 | isAbstractType, 13 | assertAbstractType, 14 | isObjectType, 15 | isScalarType, 16 | isEnumType, 17 | assertScalarType, 18 | assertEnumType, 19 | assertObjectType, 20 | assertCompositeType, 21 | assertListType, 22 | GraphQLCompositeType, 23 | GraphQLSchema, 24 | GraphQLObjectType, 25 | assertNonNullType, 26 | } from 'graphql'; 27 | 28 | type DatabaseType = { 29 | [key: string]: { 30 | [key: number]: any; 31 | }; 32 | } 33 | export const database: DatabaseType = {}; // Map> 34 | export const partialsDatabase: DatabaseType = {} // Map> 35 | export const unassignedPartials = {} // Map 36 | export const unassignedFakeObjects = {} // Map 37 | const assignedPartialFakeObject: {[key: string]: {[key: string]: object}} = {}; // Map 38 | const assignedReferencedFakeObject: {[key: string]: {[key: string]: object}} = {}; // Map> 39 | 40 | export const fakeTypeResolver: GraphQLTypeResolver = async ( 41 | value: object, 42 | context, 43 | info, 44 | abstractType, 45 | ) => { 46 | const defaultResolved = await defaultTypeResolver( 47 | value, 48 | context, 49 | info, 50 | abstractType, 51 | ); 52 | if (defaultResolved != null) { 53 | return defaultResolved; 54 | } 55 | 56 | if ('id' in value) { 57 | //@ts-ignore 58 | if (typeof value.id === 'number') { 59 | const possibleTypes = info.schema.getPossibleTypes(abstractType).map((type) => type.name); 60 | //@ts-ignore 61 | const resolvedType = Object.entries(database).find(([key, valueMap]) => possibleTypes.includes(key) && (value.id as number) in valueMap)?.[0]; 62 | //@ts-ignore 63 | // console.log("resolved type", resolvedType, value) 64 | return resolvedType; 65 | } 66 | else { 67 | //@ts-ignore 68 | throw new Error(`id should be a number but instead it is ${value.id}`) 69 | } 70 | } else { 71 | throw new Error(`value should have an id but instead it is ${value}`) 72 | } 73 | }; 74 | 75 | export const queryHeuristics = (data: any[], args: any, cursors: string[]) => { 76 | let ret = data; 77 | const order = args.order || args.orderBy || args.sortKey; 78 | const sort = args.sort || args.orderDirection || (args.reverse ? "desc" : "asc"); 79 | console.log("order", order, sort) 80 | const offset = args.offset || args.skip; 81 | const after = args.after; 82 | const limit = args.limit || args.first; 83 | const hasPreviousPage = offset > 0 || after !== undefined; 84 | let hasNextPage = false; 85 | if (order && sort && ret.length > 0) { 86 | const actualKey = order in ret[0] ? order : (order.toLowerCase() in ret[0] ? order.toLowerCase() : undefined); 87 | if (actualKey) { 88 | const sortedPairs = ret.map((val, idx)=>({val, cursor: cursors[idx]})).sort((a, b) => { 89 | if (sort === "asc" || sort == "ASC") { 90 | return a.val[actualKey] < b.val[actualKey] ? -1 : 1; 91 | } else if (sort === "desc" || sort === "DESC") { 92 | return b.val[actualKey] < a.val[actualKey] ? -1 : 1; 93 | } else { 94 | throw new Error("Invalid sort value. Must be 'asc' or 'desc'."); 95 | } 96 | }) 97 | for (var k = 0; k < sortedPairs.length; k++) { 98 | ret[k] = sortedPairs[k].val 99 | cursors[k] = sortedPairs[k].cursor 100 | } 101 | } else { 102 | console.log(`${order} field not in object`) 103 | } 104 | } 105 | let startCursor = cursors[0] 106 | let endCursor = cursors[cursors.length - 1] 107 | if (after && limit) { 108 | // TODO make IDs strings so we can use === 109 | const index = ret.findIndex((obj) => obj.id == after); // TODO should really be comparing to cursor not id, but we always set our cursor to ID. this is a problem though for scalars and objects w/o id 110 | if (index === -1) { 111 | throw new Error(`after cursor ${after} not found in data`) 112 | } 113 | hasNextPage = index + 1 + limit < ret.length; 114 | startCursor = cursors[index + 1]; 115 | endCursor = cursors[index + 1 + limit - 1]; 116 | ret = ret.slice(index + 1, index + 1 + limit); 117 | } else if (offset && limit) { 118 | hasNextPage = offset + limit < ret.length; 119 | startCursor = cursors[offset]; 120 | endCursor = cursors[offset + limit - 1]; 121 | ret = ret.slice(offset, offset + limit); 122 | } else if (limit) { 123 | hasNextPage = limit < ret.length; 124 | endCursor = cursors[limit - 1]; // TODO add test case for when this is keyerror 125 | ret = ret.slice(0, limit); 126 | } else if (offset) { 127 | startCursor = cursors[offset]; 128 | ret = ret.slice(offset); 129 | } else if (after) { 130 | // TODO make IDs strings so we can use === 131 | const index = ret.findIndex((obj) => obj.id == after); // TODO should really be comparing to cursor not id, but we always set our cursor to ID. this is a problem though for scalars and objects w/o id 132 | if (index === -1) { 133 | throw new Error(`after cursor ${after} not found in data`) 134 | } 135 | startCursor = cursors[index + 1]; 136 | ret = ret.slice(index + 1); 137 | } 138 | return { 139 | objs: ret, 140 | pageInfo: { 141 | startCursor, 142 | endCursor, 143 | hasNextPage, 144 | hasPreviousPage, 145 | } 146 | } 147 | } 148 | 149 | export const fakeFieldResolver: GraphQLFieldResolver = async ( 150 | source, 151 | args, 152 | context, 153 | info, 154 | ) => { 155 | const { schema, parentType, fieldName } = info; 156 | const fieldDef = parentType.getFields()[fieldName]; 157 | 158 | let resolved = await defaultFieldResolver(source, args, context, info); 159 | if (resolved === undefined && source && typeof source === 'object') { 160 | resolved = source[info.path.key]; // alias value // TODO when use info.path.key vs fieldName? What's the difference? 161 | } 162 | 163 | if (resolved instanceof Error) { 164 | return resolved; 165 | } 166 | 167 | let fieldDefUnwrappedType = fieldDef.type 168 | if (isNonNullType(fieldDefUnwrappedType)) fieldDefUnwrappedType = fieldDefUnwrappedType.ofType 169 | if (isListType(fieldDefUnwrappedType)) fieldDefUnwrappedType = fieldDefUnwrappedType.ofType; 170 | if (isNonNullType(fieldDefUnwrappedType)) fieldDefUnwrappedType = fieldDefUnwrappedType.ofType 171 | 172 | //@ts-ignore 173 | if (fieldDefUnwrappedType.name === "StringConnection" || fieldDefUnwrappedType.name === "IntConnection" || fieldDefUnwrappedType.name === "FloatConnection" || fieldDefUnwrappedType.name === "BooleanConnection" || fieldDefUnwrappedType.name === "IDConnection") { 174 | //@ts-ignore 175 | throw Error(`Sorry, we don't support querying for primitive connections yet. This is on our roadmap. ${fieldDefUnwrappedType.name} on field ${fieldName}`) 176 | } 177 | 178 | if (parentType === schema.getQueryType()) { 179 | if (resolved !== undefined) { 180 | return resolved; // its probably a object from the proxy server 181 | } 182 | let t = fieldDef.type 183 | let shouldReturnNonnull = false; 184 | if (isNonNullType(t)) t = t.ofType; shouldReturnNonnull = true; 185 | // TODO: throw error when user forgot to include a arg that the schema says they should incldue! 186 | if (!isListType(t) && !args.id) { 187 | if (t.name.endsWith("Connection")) { 188 | const underlyingTypeName = t.name.replace(/Connection$/, ""); 189 | const tempObjs = Object.values(database[underlyingTypeName]).sort((a, b) => a.id - b.id) 190 | const {objs, pageInfo} = queryHeuristics( 191 | tempObjs, 192 | args, 193 | tempObjs.map((obj) => obj.id) 194 | ); 195 | return { 196 | // todo support ai generated primitives on this top level query Connection object and its edges/pageInfo 197 | edges: objs.map((obj) => ({cursor: obj.id, node: obj})), 198 | nodes: objs, 199 | // filters: 200 | pageInfo 201 | } 202 | } else { 203 | // TODO: these are heuristics, we should ask AI to infer biz logic 204 | if (Object.keys(args).length === 0) 205 | return Object.values(database[t.name])[0] 206 | else if (Object.keys(args).length === 1) { 207 | const lookupField = Object.keys(args)[0] 208 | const o = Object.values(database[t.name]).find((obj) => obj[lookupField] === args[lookupField]) 209 | if (o === null && shouldReturnNonnull) 210 | throw new Error(`Object of type ${t.name} with field ${lookupField} equal to ${args[lookupField]} not in database`) 211 | return o 212 | } else { 213 | throw new Error("Query that returns a single object must have a id param. Its on our roadmap to infer biz logic so you don't get this error.") 214 | } 215 | } 216 | } 217 | if (isListType(t) && args.id) throw new Error("Query that returns a list should not have id param. This shouldn't be encountered. Open an issue on github.") 218 | if (isListType(t)) t = t.ofType 219 | if (isNonNullType(t)) t = t.ofType 220 | if (isAbstractType(t)) { 221 | t = assertAbstractType(t); 222 | const possibleTypes = schema.getPossibleTypes(t); 223 | if (args.id) { 224 | return getObjectFromDatabaseWithId(args.id, t, schema, source); 225 | } else { 226 | let out = [] 227 | for (const type of possibleTypes) { 228 | if (type.name in database) { 229 | out.push(...Object.values(database[type.name])) // Currently, we return all objects matching a type when you query. In the future, we'll support pagination and filtering. 230 | } else { 231 | throw new Error(`Type ${t.name} not in database`) 232 | } 233 | } 234 | const {objs} = queryHeuristics(out, args, out.map((obj) => obj.id)) 235 | return objs 236 | } 237 | } else { 238 | if (!isObjectType(t)) { 239 | if (!isScalarType(t) || !isEnumType(t)) { 240 | throw new Error(`This shouldn't happen. Open an issue.`) 241 | } else { 242 | if (isScalarType(t)) { 243 | t = assertScalarType(t) 244 | return t.parseValue(0) // need biz logic inferred by AI. Happy to merge some heuristic PRs for now. We also could have the server list some example values here 245 | } else { 246 | t = assertEnumType(t) 247 | return t.parseValue(0) // need biz logic inferred by AI. Happy to merge some heuristic PRs for now. We also could have the server list some example values here 248 | } 249 | } 250 | } 251 | t = assertObjectType(t) 252 | 253 | if (!(t.name in database)) { 254 | throw new Error(`Type ${t.name} not in database`) 255 | } 256 | if (args.id) { 257 | if (!(args.id in database[t.name]) && shouldReturnNonnull) { 258 | throw new Error(`Object with id ${args.id} not in database`) 259 | } 260 | return database[t.name][args.id] 261 | } else { 262 | const out = Object.values(database[t.name]); 263 | const {objs} = queryHeuristics(out, args, out.map((obj) => obj.id)) 264 | return objs; 265 | } 266 | } 267 | } else if (isLeafType(fieldDefUnwrappedType)) { 268 | // TODO need the above condition to trigger on list of leafs 269 | if ((fieldDef.extensions && fieldDef.extensions['isExtensionField'])) { 270 | if (resolved !== undefined) { 271 | return resolved; 272 | } 273 | if (!(source["id"] in assignedPartialFakeObject)) { // TODO is there a better way than source['ID']? 274 | //@ts-ignore 275 | assignedPartialFakeObject[source["id"]] = partialsDatabase[parentType.name][unassignedPartials[parentType.name].pop()] 276 | if (unassignedPartials[parentType.name].length === 0) { 277 | unassignedPartials[parentType.name] = Object.keys(partialsDatabase[parentType.name]) 278 | } 279 | } 280 | return assignedPartialFakeObject[source["id"]][info.path.key] 281 | } 282 | if (resolved === undefined && isNonNullType(fieldDef.type)) { 283 | throw new Error(`the field, ${fieldDef.name}, should be in parent field since it's a leaf and non root and nonnull`) 284 | } 285 | // TODO handle undefined list elements when should be [Int!]! or [Int!] 286 | if (isListType(isNonNullType(fieldDef.type) ? fieldDef.type.ofType : fieldDef.type)) { 287 | if (isNonNullType(fieldDef.type)) { 288 | if (resolved !== undefined && resolved !== null) { 289 | return resolved; 290 | } else { 291 | throw Error(`Field ${fieldDef.name} is non null but resolved is ${resolved}`) 292 | } 293 | } else { 294 | return resolved; 295 | } 296 | } else { 297 | if (isNonNullType(fieldDef.type)) { 298 | if (resolved !== undefined && resolved !== null) { 299 | return resolved; 300 | } else { 301 | throw Error(`Field ${fieldDef.name} is non null but resolved is ${resolved}`) 302 | } 303 | } else { 304 | return resolved; 305 | } 306 | } 307 | } else { // cur field is a reference to another object 308 | if (parentType === schema.getQueryType()) throw new Error("shouldn't be here") 309 | if ((fieldDef.extensions && fieldDef.extensions['isExtensionField']) && resolved === undefined) { 310 | // TODO if source[info.path.key] is assigned we should cehck that its the same as the value in unassignedFakeObject? 311 | // or nah since even though this is a non extension object, it could still be a mock 312 | // if list type then need to pick out multiple 313 | if (!(source["id"] in assignedReferencedFakeObject)) assignedReferencedFakeObject[source["id"]] = {} 314 | if (!(info.path.key in assignedReferencedFakeObject[source["id"]])) { 315 | let remoteType = fieldDef.type 316 | if (isNonNullType(remoteType)) remoteType = remoteType.ofType 317 | if (isListType(remoteType)) remoteType = remoteType.ofType 318 | if (isNonNullType(remoteType)) remoteType = remoteType.ofType 319 | remoteType = assertCompositeType(remoteType) 320 | if (isListType(isNonNullType(fieldDef.type) ? fieldDef.type.ofType : fieldDef.type)) { 321 | // pop at most 4 elements. 322 | assignedReferencedFakeObject[source["id"]][info.path.key] = unassignedFakeObjects[remoteType.name].splice(-4, 4) 323 | } else { 324 | assignedReferencedFakeObject[source["id"]][info.path.key] = unassignedFakeObjects[remoteType.name].pop() 325 | } 326 | if (unassignedFakeObjects[remoteType.name].length === 0) { 327 | unassignedFakeObjects[remoteType.name] = Object.keys(database[remoteType.name]); 328 | } 329 | } 330 | //@ts-ignore 331 | source = {...source, [info.path.key]: assignedReferencedFakeObject[source["id"]][info.path.key]} 332 | } 333 | var type = fieldDef.type 334 | if (isNonNullType(type)) { 335 | type = type.ofType; 336 | if (source[info.path.key] === null) { 337 | throw new Error("Error, source field is null but output type is non null") 338 | } 339 | } else if (source[info.path.key] === null) { 340 | return null; 341 | } 342 | if (isListType(type)) { 343 | var listElementType = assertListType(type).ofType 344 | var allowNull = true; 345 | if (isNonNullType(listElementType)) { 346 | listElementType = listElementType.ofType 347 | allowNull = false; 348 | } 349 | listElementType = assertCompositeType(listElementType) 350 | if (source[info.path.key].length === 0) { 351 | return [] 352 | } 353 | if (parentType.name.endsWith("Connection") && (info.path.key === "nodes" || info.path.key === "edges") && typeof source[info.path.key][0] === "object") { // TODO if source[info.path.key].length === 0 does this crash? 354 | return source[info.path.key] 355 | } 356 | const temp = source[info.path.key].map((id) => { 357 | if (allowNull && id === null) { 358 | return null 359 | } 360 | return getObjectFromDatabaseWithId(id, listElementType, schema, source) 361 | }) 362 | const {objs} = queryHeuristics(temp, args, temp.map((obj) => obj.id)); 363 | return objs 364 | } else { 365 | if (type.name.endsWith("Connection")) { // note: an alternative impl could be to pass down Connection args via context 366 | type = assertObjectType(type) 367 | type.getFields() 368 | const underlyingTypeName = type.name.replace(/Connection$/, ""); 369 | const underlyingType = schema.getType(underlyingTypeName) // todo assert this matches up with return type of the nodes field 370 | if (!underlyingType) throw new Error(`Type ${underlyingTypeName} not in schema`) 371 | if (!isCompositeType(underlyingType)) throw new Error(`Type ${underlyingTypeName} is not a composite type for connection ${type.name}`) 372 | const connectionObj = getObjectFromDatabaseWithId(source[info.path.key],type,schema, source); 373 | let temp = connectionObj['nodes'].map((id) => { 374 | if (allowNull && id === null) { 375 | return null 376 | } 377 | return getObjectFromDatabaseWithId(id, underlyingType, schema, source) 378 | }) 379 | const {objs, pageInfo} = queryHeuristics(temp, args, temp.map((obj) => obj.id)); 380 | const connectionObjsEdges = connectionObj['edges'].map((id) => { 381 | if (allowNull && id === null) { 382 | return null 383 | } 384 | let edgeType = (type as GraphQLObjectType).getFields()['edges'].type 385 | if (isNonNullType(edgeType)) edgeType = assertNonNullType(edgeType).ofType 386 | if (isListType(edgeType)) edgeType = assertListType(edgeType).ofType 387 | if (isNonNullType(edgeType)) edgeType = assertNonNullType(edgeType).ofType 388 | edgeType = assertObjectType(edgeType) 389 | return getObjectFromDatabaseWithId(id, edgeType, schema, source) 390 | }) 391 | return { 392 | ...connectionObj, // include this b/c there might be some ai generated primitives to fill in 393 | edges: objs.map( 394 | (obj) => ({...(connectionObjsEdges.find(e=>e.cursor == obj.id)), cursor: obj.id, node: obj})), // todo this "find" will be slow... optimize 395 | nodes: objs, 396 | // filters: 397 | pageInfo 398 | } 399 | } 400 | if (parentType.name.endsWith("Connection") && info.path.key === "pageInfo" && typeof source[info.path.key] === "object") { 401 | return source[info.path.key] 402 | } 403 | if (parentType.name.endsWith("Edge") && (info.path.key === "node") && typeof source[info.path.key] === "object") { // TODO kind of wrong since we need to enforce that parentType's parentType name ends with "Connection" 404 | // TODO also, according to spec, edge type doesn't have to end in Edge. It could be called something else 405 | return source[info.path.key] 406 | } 407 | type = assertCompositeType(type) 408 | const id = source[info.path.key]; // .id? 409 | return getObjectFromDatabaseWithId(id, type, schema, source) 410 | } 411 | } 412 | // TODO support mutations with AI 413 | const isMutation = parentType === schema.getMutationType(); 414 | const isCompositeReturn = isCompositeType(getNullableType(fieldDef.type)); 415 | if (isMutation && isCompositeReturn && isPlainObject(resolved)) { 416 | const inputArg = args['input']; 417 | return { 418 | ...(Object.keys(args).length === 1 && isPlainObject(inputArg) 419 | ? inputArg 420 | : args), 421 | ...resolved, 422 | }; 423 | } 424 | return resolved; 425 | }; 426 | 427 | function getObjectFromDatabaseWithId(id: any, t: GraphQLCompositeType, schema: GraphQLSchema, source) { 428 | // if (typeof id !== 'number') 429 | // throw new Error(`id should be a number but instead it is ${id} on the object ${JSON.stringify(source)} for fieldDef.type ${fieldDef.type}`); 430 | if (id === null || id === undefined) 431 | throw new Error(`id should not be null but instead it is ${id} on the object ${JSON.stringify(source)}`); 432 | if (typeof id === 'object') 433 | throw new Error(`id should be a number but instead it is ${id} on the object ${JSON.stringify(source)}`); 434 | // TODO does this support abstract type? 435 | let obj = null; 436 | if (isAbstractType(t)) { 437 | t = assertAbstractType(t); 438 | const possibleTypes = schema.getPossibleTypes(t); 439 | for (const p of possibleTypes) { 440 | if (!(p.name in database)) { 441 | throw new Error(`Type ${p.name} not in database`); 442 | } 443 | if (id in database[p.name]) { 444 | obj = database[p.name][id]; 445 | break; 446 | } 447 | } 448 | } else { 449 | t = assertObjectType(t); 450 | obj = database[t.name][id]; 451 | } 452 | if (obj === null || obj === undefined) 453 | throw new Error(`obj should be well defined but instead it is ${obj} on the object ${JSON.stringify(source)}`); 454 | return obj; 455 | } 456 | 457 | function isPlainObject(maybeObject) { 458 | return ( 459 | typeof maybeObject === 'object' && 460 | maybeObject !== null && 461 | !Array.isArray(maybeObject) 462 | ); 463 | } --------------------------------------------------------------------------------