Skip to content

Commit

Permalink
GraphQL API playground (#1123)
Browse files Browse the repository at this point in the history
* Refactor API console into REST console

* feat: GraphQL console component

Adds the GraphQLConsole component, which renders the graphql-playground-react component at the route `/api_console/graphql`.
The GraphQL server endpoint must be retrieved from the Parse app instance.

* feat: Set default GraphQL playground headers

* Fix GraphQL playground style import

* fix package-lock.json

* GraphQL API Console not configured state

* Fix GraphQL console empty state horizontal alignment

* Add default toolbar into GraphQL console
  • Loading branch information
douglasmuraoka authored and davimacedo committed Jul 4, 2019
1 parent 2a7b7b0 commit d04b7f0
Show file tree
Hide file tree
Showing 9 changed files with 2,265 additions and 1,076 deletions.
2,842 changes: 1,951 additions & 891 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@
"create-react-class": "15.6.3",
"csurf": "1.10.0",
"express": "4.17.1",
"graphql": "^14.3.1",
"graphql-playground-react": "^1.7.20",
"history": "4.9.0",
"immutable": "3.8.1",
"immutable": "^4.0.0-rc.9",
"immutable-devtools": "0.1.3",
"js-beautify": "1.10.0",
"json-file-plus": "3.2.0",
Expand All @@ -59,6 +61,7 @@
"react-dnd-html5-backend": "8.0.3",
"react-dom": "16.8.6",
"react-helmet": "5.2.1",
"react-redux": "^5.1.1",
"react-router": "5.0.1",
"react-router-dom": "5.0.1"
},
Expand Down
22 changes: 20 additions & 2 deletions src/dashboard/Dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import Config from './Data/Config/Config.react';
import Explorer from './Analytics/Explorer/Explorer.react';
import FourOhFour from 'components/FourOhFour/FourOhFour.react';
import GeneralSettings from './Settings/GeneralSettings.react';
import GraphQLConsole from './Data/ApiConsole/GraphQLConsole.react';
import history from 'dashboard/history';
import HostingSettings from './Settings/HostingSettings.react';
import Icon from 'components/Icon/Icon.react';
Expand All @@ -35,6 +36,7 @@ import PushIndex from './Push/PushIndex.react';
import PushNew from './Push/PushNew.react';
import PushSettings from './Settings/PushSettings.react';
import React from 'react';
import RestConsole from './Data/ApiConsole/RestConsole.react';
import Retention from './Analytics/Retention/Retention.react';
import SchemaOverview from './Data/Browser/SchemaOverview.react';
import SecuritySettings from './Settings/SecuritySettings.react';
Expand Down Expand Up @@ -246,6 +248,22 @@ export default class Dashboard extends React.Component {
return <Browser {...props} params={ props.match.params } />
}

const ApiConsoleRoute = (props) => (
<Switch>
<Route path={ props.match.path + '/rest' } render={props => (
<ApiConsole {...props}>
<RestConsole />
</ApiConsole>
)} />
<Route path={ props.match.path + '/graphql' } render={props => (
<ApiConsole {...props}>
<GraphQLConsole />
</ApiConsole>
)} />
<Redirect from={ props.match.path } to='/apps/:appId/api_console/rest' />
</Switch>
)

const AppRoute = ({ match }) => (
<AppData params={ match.params }>
<Switch>
Expand All @@ -265,8 +283,8 @@ export default class Dashboard extends React.Component {
<Redirect from={ match.path + '/logs' } to='/apps/:appId/logs/info' />

<Route path={ match.path + '/config' } component={Config} />
<Route path={ match.path + '/api_console' } component={ApiConsole} />
<Route path={ match.path + '/migration' } component={Migration} />/>
<Route path={ match.path + '/api_console' } component={ApiConsoleRoute} />
<Route path={ match.path + '/migration' } component={Migration} />


<Redirect exact from={ match.path + '/push' } to='/apps/:appId/push/new' />
Expand Down
198 changes: 17 additions & 181 deletions src/dashboard/Data/ApiConsole/ApiConsole.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,198 +5,34 @@
* This source code is licensed under the license found in the LICENSE file in
* the root directory of this source tree.
*/
import PropTypes from 'lib/PropTypes';
import Button from 'components/Button/Button.react';
import DashboardView from 'dashboard/DashboardView.react';
import Dropdown from 'components/Dropdown/Dropdown.react';
import Field from 'components/Field/Field.react';
import Fieldset from 'components/Fieldset/Fieldset.react';
import fieldStyle from 'components/Field/Field.scss';
import FlowFooter from 'components/FlowFooter/FlowFooter.react';
import FormNote from 'components/FormNote/FormNote.react';
import generateCurl from 'dashboard/Data/ApiConsole/generateCurl';
import JsonPrinter from 'components/JsonPrinter/JsonPrinter.react';
import Label from 'components/Label/Label.react';
import Modal from 'components/Modal/Modal.react';
import Option from 'components/Dropdown/Option.react';
import Parse from 'parse';
import ParseApp from 'lib/ParseApp';
import React from 'react';
import request from 'dashboard/Data/ApiConsole/request';
import styles from 'dashboard/Data/ApiConsole/ApiConsole.scss';
import TextInput from 'components/TextInput/TextInput.react';
import Toggle from 'components/Toggle/Toggle.react';
import Toolbar from 'components/Toolbar/Toolbar.react';
import React from 'react'
import CategoryList from 'components/CategoryList/CategoryList.react'
import DashboardView from 'dashboard/DashboardView.react'

export default class ApiConsole extends DashboardView {

constructor() {
super();
this.section = 'Core';
this.subsection = 'API Console';

this.state = {
method: 'GET',
endpoint: '',
useMasterKey: false,
runAsIdentifier: '',
sessionToken: null,
parameters: '',
response: {results:[]},
fetchingUser: false,
inProgress: false,
error: false,
curlModal: false,
};
}

fetchUser() {
if (this.state.runAsIdentifier.length === 0) {
this.setState({ error: false, sessionToken: null });
return;
}
Parse.Query.or(
new Parse.Query(Parse.User).equalTo('username', this.state.runAsIdentifier ),
new Parse.Query(Parse.User).equalTo('objectId', this.state.runAsIdentifier )
).first({ useMasterKey: true }).then((found) => {
if (found) {
if (found.getSessionToken()) {
this.setState({ sessionToken: found.getSessionToken(), error: false, fetchingUser: false });
} else {
// Check the Sessions table
new Parse.Query(Parse.Session).equalTo('user', found).first({ useMasterKey: true }).then((session) => {
if (session) {
this.setState({ sessionToken: session.getSessionToken(), error: false, fetchingUser: false });
} else {
this.setState({ error: 'Unable to find any active sessions for that user.', fetchingUser: false });
}
}, () => {
this.setState({ error: 'Unable to find any active sessions for that user.', fetchingUser: false });
});
}
} else {
this.setState({ error: 'Unable to find that user.', fetchingUser: false });
}
}, () => {
this.setState({ error: 'Unable to find that user.', fetchingUser: false });
});
this.setState({ fetchingUser: true });
}

makeRequest() {
let endpoint = this.state.endpoint + (this.state.method === 'GET' ? `?${this.state.parameters}` : '');
let payload = (this.state.method === 'DELETE' || this.state.method === 'GET') ? null : this.state.parameters;
let options = {};
if (this.state.useMasterKey) {
options.useMasterKey = true;
}
if (this.state.sessionToken) {
options.sessionToken = this.state.sessionToken;
}
request(
this.context.currentApp,
this.state.method,
endpoint,
payload,
options
).then((response) => {
this.setState({ response });
document.body.scrollTop = 540;
});
}

showCurl() {
this.setState({ curlModal: true });
renderSidebar() {
const { path } = this.props.match
const current = path.substr(path.lastIndexOf('/') + 1, path.length - 1)
return (
<CategoryList current={current} linkPrefix={'api_console/'} categories={[
{ name: 'REST Console', id: 'rest' },
{ name: 'GraphQL Console', id: 'graphql' }
]} />
)
}

renderContent() {
const methodDropdown =
<Dropdown onChange={(method) => this.setState({method})} value={this.state.method}>
<Option value='GET'>GET</Option>
<Option value='POST'>POST</Option>
<Option value='PUT'>PUT</Option>
<Option value='DELETE'>DELETE</Option>
</Dropdown>

let hasError = this.state.fetchingUser ||
this.state.endpoint.length === 0 ||
(this.state.runAsIdentifier.length > 0 && !this.state.sessionToken);
let parameterPlaceholder = 'where={"username":"johndoe"}';
if (this.state.method === 'POST' || this.state.method === 'PUT') {
parameterPlaceholder = '{"name":"John"}';
}

let modal = null;
if (this.state.curlModal) {
let payload = this.state.method === 'DELETE' ? null : this.state.parameters;
let options = {};
if (this.state.useMasterKey) {
options.useMasterKey = true;
}
if (this.state.sessionToken) {
options.sessionToken = this.state.sessionToken;
}
let content = generateCurl(
this.context.currentApp,
this.state.method,
this.state.endpoint,
payload,
options
);
modal = (
<Modal
title='cURL Request'
subtitle='Use this to replicate the request'
icon='laptop-outline'
customFooter={
<div className={styles.footer}>
<Button primary={true} value='Close' onClick={() => this.setState({ curlModal: false })} />
</div>
}>
<div className={styles.curl}>{content}</div>
</Modal>
);
}

return (
<div style={{ padding: '120px 0 60px 0' }}>
<Fieldset
legend='Send a test query'
description='Try out some queries, and take a look at what they return.'>
<Field
label={<Label text='What type of request?' />}
input={methodDropdown} />
<Field
label={<Label text='Which endpoint?' description={<span>Not sure what endpoint you need?<br />Take a look at our <a href="http://docs.parseplatform.org/rest/guide/">REST API guide</a>.</span>} />}
input={<TextInput value={this.state.endpoint} monospace={true} placeholder={'classes/_User'} onChange={(endpoint) => this.setState({endpoint})} />} />
<Field
label={<Label text='Use Master Key?' description={'This will bypass any ACL/CLPs.'} />}
input={<Toggle value={this.state.useMasterKey} onChange={(useMasterKey) => this.setState({ useMasterKey })} />} />
<Field
label={<Label text='Run as...' description={'Send your query as a specific user. You can use their username or Object ID.'} />}
input={<TextInput value={this.state.runAsIdentifier} monospace={true} placeholder={'Username or ID'} onChange={(runAsIdentifier) => this.setState({runAsIdentifier})} onBlur={this.fetchUser.bind(this)} />} />
<FormNote color='red' show={!!this.state.error}>{this.state.error}</FormNote>
<Field
label={<Label text='Query parameters' description={<span>Learn more about query parameters in our <a href="http://docs.parseplatform.org/rest/guide/#queries">REST API guide</a>.</span>} />}
input={<TextInput value={this.state.parameters} monospace={true} multiline={true} placeholder={parameterPlaceholder} onChange={(parameters) => this.setState({parameters})} />} />
</Fieldset>
<Fieldset
legend='Results'
description=''>
<div className={fieldStyle.field}>
<JsonPrinter object={this.state.response} />
</div>
</Fieldset>
<Toolbar section='Core' subsection='API Console' />
<FlowFooter
primary={<Button primary={true} disabled={hasError} value='Send Query' progress={this.state.inProgress} onClick={this.makeRequest.bind(this)} />}
secondary={<Button disabled={hasError} value='Export to cURL' onClick={this.showCurl.bind(this)} />} />
{modal}
</div>
);
const child = React.Children.only(this.props.children);
return React.cloneElement(
child,
{ ...child.props }
)
}
}

ApiConsole.contextTypes = {
currentApp: PropTypes.instanceOf(ParseApp)
};
14 changes: 14 additions & 0 deletions src/dashboard/Data/ApiConsole/ApiConsole.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,17 @@
padding: 10px 0;
text-align: center;
}

.content {
position: relative;
min-height: 100vh;
padding-top: 96px;
}

.empty {
position: absolute;
top: 96px;
left: 0;
right: 0;
bottom: 0;
}
57 changes: 57 additions & 0 deletions src/dashboard/Data/ApiConsole/GraphQLConsole.react.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2016-present, Parse, LLC
* All rights reserved.
*
* This source code is licensed under the license found in the LICENSE file in
* the root directory of this source tree.
*/
import ParseApp from 'lib/ParseApp';
import PropTypes from 'lib/PropTypes';
import React, { Component } from 'react';
import { Provider } from 'react-redux'
import { Playground, store } from 'graphql-playground-react';
import EmptyState from 'components/EmptyState/EmptyState.react';
import Toolbar from 'components/Toolbar/Toolbar.react';
import styles from 'dashboard/Data/ApiConsole/ApiConsole.scss';

export default class GraphQLConsole extends Component {
render() {
const { applicationId, graphQLServerURL, masterKey } = this.context.currentApp;
let content;
if (!graphQLServerURL) {
content = (
<div className={styles.empty}>
<EmptyState
title='GraphQL API Console'
description='Please update Parse-Server to version equal or above
3.5.0 and define the "graphQLServerURL" on your app configuration
in order to use the GraphQL API Console.'
icon='info-solid' />
</div>
);
} else {
const headers = {
'X-Parse-Application-Id': applicationId,
'X-Parse-Master-Key': masterKey
}
content = (
<Provider store={store}>
<Playground endpoint={graphQLServerURL} headers={headers} />
</Provider>
);
}

return (
<>
<Toolbar section='Core' subsection='GraphQL API Console' />
<div className={styles.content}>
{content}
</div>
</>
);
}
}

GraphQLConsole.contextTypes = {
currentApp: PropTypes.instanceOf(ParseApp)
};
Loading

0 comments on commit d04b7f0

Please sign in to comment.