mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-10-05 14:28:08 +03:00
move sample apps outside graphql-engine
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/8904 GitOrigin-RevId: d0c3000dff8615ff48f33c6a8ebf083d055ac0c3
This commit is contained in:
parent
84ff37f8fb
commit
3d533bee61
1
community/sample-apps/README.md
Normal file
1
community/sample-apps/README.md
Normal file
@ -0,0 +1 @@
|
||||
## This project has been moved to [hasura/sample-apps](https://github.com/hasura/sample-apps)
|
@ -1,4 +0,0 @@
|
||||
firebase.json
|
||||
.firebaserc
|
||||
node_modules
|
||||
config.js
|
@ -1,64 +0,0 @@
|
||||
# Firebase Auth + Hasura JWT
|
||||
|
||||
Barebones example to show how to have Firebase Auth integrated with Hasura JWT mode.
|
||||
|
||||
## Firebase Auth
|
||||
|
||||
Firebase has few ways of implementing custom JWT claims in Firebase Auth:
|
||||
|
||||
1. Have firebase generate the JWTs, then customize them from your backend using
|
||||
Firebase Admin SDK [[docs]](https://firebase.google.com/docs/auth/admin/custom-claims#defining_roles_via_an_http_request)
|
||||
2. Use Firebase cloud functions, and listen to user creation events to add
|
||||
custom claims to generated JWT [[docs]](https://firebase.google.com/docs/auth/admin/custom-claims#defining_roles_via_firebase_functions_on_user_creation)
|
||||
3. Have your own backend server, which generates custom tokens [[docs]](https://firebase.google.com/docs/auth/admin/create-custom-tokens)
|
||||
4. Have your own backend scripts (not initiated by the client) to update user custom claims [[docs]](https://firebase.google.com/docs/auth/admin/custom-claims#defining_roles_via_backend_script)
|
||||
|
||||
## Add custom claims in Firebase
|
||||
|
||||
In this example, we are choosing the option 2 from above. But this can be done via any of the above methods. [Firebase docs](https://firebase.google.com/docs/auth/admin/custom-claims) have extensive documentation on how to achieve this via different methods.
|
||||
|
||||
This example is adapted from [this guide](https://firebase.google.com/docs/auth/admin/custom-claims#defining_roles_via_firebase_functions_on_user_creation).
|
||||
|
||||
### Pre-requisites
|
||||
|
||||
This example assumes that you already have Firebase Auth setup for your app.
|
||||
|
||||
### Add the cloud function
|
||||
|
||||
Deploy the cloud function inside `functions/` folder:
|
||||
|
||||
```shell
|
||||
firebase deploy --only functions
|
||||
```
|
||||
|
||||
Customize the code to add your logic of assigning roles in the custom claims.
|
||||
|
||||
This cloud function is using the `onCreate` trigger. So whenever a user is created, this function is run.
|
||||
|
||||
### Client-side code
|
||||
|
||||
The client-side code is in `app/` folder.
|
||||
|
||||
## Configure Hasura to start in JWT mode
|
||||
|
||||
- Deploy GraphQL Engine on Hasura Cloud and setup PostgreSQL via Heroku:
|
||||
|
||||
[![Deploy to Hasura Cloud](https://graphql-engine-cdn.hasura.io/img/deploy_to_hasura.png)](https://cloud.hasura.io/signup)
|
||||
|
||||
After deploying, add the following environment variables to configure JWT mode:
|
||||
|
||||
```
|
||||
HASURA_GRAPHQL_ADMIN_SECRET : youradminsecretkey
|
||||
```
|
||||
|
||||
```
|
||||
HASURA_GRAPHQL_JWT_SECRET: {"type":"RS256", "jwk_url": "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com", "audience": "<firebase-project-id>", "issuer": "https://securetoken.google.com/<firebase-project-id>"}
|
||||
```
|
||||
|
||||
## Sending JWT to Hasura
|
||||
|
||||
Now, whenever you make a request to Hasura GraphQL engine (as an authenticated user), send the `id_token` in `Authorization` header:
|
||||
|
||||
```
|
||||
Authorization: Bearer <firebase-id-token>
|
||||
```
|
@ -1,17 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<title> Firebase Auth + Hasura JWT example </title>
|
||||
</head>
|
||||
<body>
|
||||
<h1> Firebase Auth + Hasura JWT example </h1>
|
||||
<form id="login-form">
|
||||
Email: <input id="email" type="email"/>
|
||||
Password: <input id="password" type="password" />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
<button id="get-token"> Get ID token </button>
|
||||
<div id="id-token"></div>
|
||||
<script src="https://www.gstatic.com/firebasejs/5.5.3/firebase.js"></script>
|
||||
<script src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
@ -1,60 +0,0 @@
|
||||
// Initialize Firebase
|
||||
var config = {
|
||||
apiKey: "xxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
authDomain: "<your-app>.firebaseapp.com",
|
||||
databaseURL: "https://<your-app>.firebaseio.com",
|
||||
projectId: "<your-app>",
|
||||
storageBucket: "<your-app>.appspot.com",
|
||||
messagingSenderId: "xxxxxxxxxxxx"
|
||||
};
|
||||
firebase.initializeApp(config);
|
||||
|
||||
document.getElementById('login-form').onsubmit = function(event) {
|
||||
event.preventDefault();
|
||||
let email = document.getElementById('email').value;
|
||||
let pass = document.getElementById('password').value;
|
||||
login(email, pass);
|
||||
};
|
||||
|
||||
|
||||
document.getElementById('get-token').onclick = function(event) {
|
||||
event.preventDefault();
|
||||
firebase.auth().currentUser.getIdToken(true).
|
||||
then(token => document.getElementById('id-token').innerHTML = token);
|
||||
};
|
||||
|
||||
function login(email, password) {
|
||||
firebase.auth().signInWithEmailAndPassword(email, password)
|
||||
.then(function(user) {
|
||||
console.log('login success');
|
||||
})
|
||||
.catch(function(error) {
|
||||
// Handle Errors here.
|
||||
var errorCode = error.code;
|
||||
var errorMessage = error.message;
|
||||
console.log(error);
|
||||
});
|
||||
|
||||
let callback = null;
|
||||
let metadataRef = null;
|
||||
firebase.auth().onAuthStateChanged(user => {
|
||||
// Remove previous listener.
|
||||
if (callback) {
|
||||
metadataRef.off('value', callback);
|
||||
}
|
||||
// On user login add new listener.
|
||||
if (user) {
|
||||
// Check if refresh is required.
|
||||
metadataRef = firebase.database().ref('metadata/' + user.uid + '/refreshTime');
|
||||
callback = (snapshot) => {
|
||||
// Force refresh to pick up the latest custom claims changes.
|
||||
// Note this is always triggered on first call. Further optimization could be
|
||||
// added to avoid the initial trigger when the token is issued and already contains
|
||||
// the latest claims.
|
||||
user.getIdToken(true);
|
||||
};
|
||||
// Subscribe new listener to changes on that node.
|
||||
metadataRef.on('value', callback);
|
||||
}
|
||||
});
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
const functions = require('firebase-functions');
|
||||
const admin = require('firebase-admin');
|
||||
admin.initializeApp(functions.config().firebase);
|
||||
|
||||
// On sign up.
|
||||
exports.processSignUp = functions.auth.user().onCreate(user => {
|
||||
console.log(user);
|
||||
// Check if user meets role criteria:
|
||||
// Your custom logic here: to decide what roles and other `x-hasura-*` should the user get
|
||||
let customClaims;
|
||||
if (user.email && user.email.indexOf('@hasura.io') !== -1) {
|
||||
customClaims = {
|
||||
'https://hasura.io/jwt/claims': {
|
||||
'x-hasura-default-role': 'admin',
|
||||
'x-hasura-allowed-roles': ['user', 'admin'],
|
||||
'x-hasura-user-id': user.uid
|
||||
}
|
||||
};
|
||||
}
|
||||
else {
|
||||
customClaims = {
|
||||
'https://hasura.io/jwt/claims': {
|
||||
'x-hasura-default-role': 'user',
|
||||
'x-hasura-allowed-roles': ['user'],
|
||||
'x-hasura-user-id': user.uid
|
||||
}
|
||||
};
|
||||
}
|
||||
// Set custom user claims on this newly created user.
|
||||
return admin.auth().setCustomUserClaims(user.uid, customClaims)
|
||||
.then(() => {
|
||||
// Update real-time database to notify client to force refresh.
|
||||
const metadataRef = admin.database().ref("metadata/" + user.uid);
|
||||
// Set the refresh time to the current UTC timestamp.
|
||||
// This will be captured on the client to force a token refresh.
|
||||
return metadataRef.set({refreshTime: new Date().getTime()});
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(error);
|
||||
});
|
||||
});
|
File diff suppressed because it is too large
Load Diff
@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "functions",
|
||||
"description": "Cloud Functions for Firebase",
|
||||
"scripts": {
|
||||
"serve": "firebase serve --only functions",
|
||||
"shell": "firebase functions:shell",
|
||||
"start": "npm run shell",
|
||||
"deploy": "firebase deploy --only functions",
|
||||
"logs": "firebase functions:log"
|
||||
},
|
||||
"dependencies": {
|
||||
"firebase-admin": "~6.0.0",
|
||||
"firebase-functions": "^2.0.3"
|
||||
},
|
||||
"private": true
|
||||
}
|
@ -1,49 +0,0 @@
|
||||
# gatsby-contentful-auth0
|
||||
This is the sample music playlist application demonstrating the Gatsby + Contentful Remote Join with Hasura GraphQL.
|
||||
|
||||
## Getting started
|
||||
|
||||
If you've cloned this repository, navigate into the directory and install the npm modules using this command:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
> Note: if you clone this project through the Gatsby CLI, it will install the modules for you.
|
||||
|
||||
## Auth0
|
||||
This application uses Auth0 to manage identity. Refer to the [Auth0 integration guide](https://hasura.io/docs/latest/graphql/core/guides/integrations/auth0-jwt.html) for the configuration.
|
||||
|
||||
### Modify auth config
|
||||
|
||||
Rename `.env.EXAMPLE` to `.env.development` (or `.env.production`) and replace `<value>` for `AUTH0_DOMAIN` and `AUTH0_CLIENTID` with your Auth0 domain prefix and your client ID. These can be found on your [client dashboard](https://manage.auth0.com/#/clients).
|
||||
|
||||
Replace the `<value>` for `AUTH0_CALLBACK` with the URL for your callback route. The default for development is `http://localhost:8000/callback`.
|
||||
|
||||
## Contentful
|
||||
Contentful remote schema is added as part of the migration. Configure the environment variables in Hasura GraphQL Engine server for the types to get merged.
|
||||
|
||||
- `CONTENTFUL_API_KEY`
|
||||
- `CONTENTFUL_API_ENDPOINT` which is of the format https://graphql.contentful.com/content/v1/spaces/<space-id>
|
||||
|
||||
## Migrations
|
||||
|
||||
Execute the following command inside `hasura` to apply the migrations
|
||||
|
||||
```bash
|
||||
hasura metadata apply
|
||||
hasura migrate apply
|
||||
hasura metadata reload
|
||||
```
|
||||
|
||||
This will create all the necessary tables, relationships and remote joins.
|
||||
|
||||
## Run the app
|
||||
You can start the development server with the following command:
|
||||
|
||||
```bash
|
||||
gatsby develop
|
||||
```
|
||||
|
||||
The app runs at `localhost:8000` by default.
|
||||
|
@ -1,5 +0,0 @@
|
||||
# ./.env
|
||||
# Get these values at https://manage.auth0.com and create a new file called .env.development
|
||||
AUTH0_DOMAIN=<value>
|
||||
AUTH0_CLIENTID=<value>
|
||||
AUTH0_CALLBACK=<value>
|
@ -1,71 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Typescript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.*
|
||||
!.env.EXAMPLE
|
||||
|
||||
# gatsby files
|
||||
.cache/
|
||||
public
|
||||
|
||||
# Mac files
|
||||
.DS_Store
|
||||
|
||||
# Yarn
|
||||
yarn-error.log
|
||||
.pnp/
|
||||
.pnp.js
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
@ -1,7 +0,0 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
# gatsby-contentful-auth0
|
||||
This is the sample music playlist application demonstrating the Gatsby + Contentful Remote Join with Hasura GraphQL.
|
||||
|
||||
## Getting started
|
||||
|
||||
If you've cloned this repository, navigate into the directory and install the npm modules using this command:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
> Note: if you clone this project through the Gatsby CLI, it will install the modules for you.
|
||||
|
||||
## Modify auth config
|
||||
|
||||
Rename `.env.EXAMPLE` to `.env.development` (or `.env.production`) and replace `<value>` for `AUTH0_DOMAIN` and `AUTH0_CLIENTID` with your Auth0 domain prefix and your client ID. These can be found on your [client dashboard](https://manage.auth0.com/#/clients).
|
||||
|
||||
Replace the `<value>` for `AUTH0_CALLBACK` with the URL for your callback route. The default for development is `http://localhost:8000/callback`.
|
||||
|
||||
## Run the app
|
||||
You can start the development server with the following command:
|
||||
|
||||
```bash
|
||||
gatsby develop
|
||||
```
|
||||
|
||||
The app runs at `localhost:8000` by default.
|
||||
|
@ -1,31 +0,0 @@
|
||||
import React from "react"
|
||||
import { silentAuth } from "./src/utils/auth"
|
||||
|
||||
class SessionCheck extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
loading: true,
|
||||
}
|
||||
}
|
||||
|
||||
handleCheckSession = () => {
|
||||
this.setState({ loading: false })
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
silentAuth(this.handleCheckSession)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
this.state.loading === false && (
|
||||
<React.Fragment>{this.props.children}</React.Fragment>
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const wrapRootElement = ({ element }) => {
|
||||
return <SessionCheck>{element}</SessionCheck>
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
const fetch = require(`node-fetch`)
|
||||
const { createHttpLink } = require(`apollo-link-http`)
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
{
|
||||
resolve: 'gatsby-source-graphql',
|
||||
options: {
|
||||
typeName: 'HASURA',
|
||||
fieldName: 'hasura',
|
||||
createLink: (pluginOptions) => {
|
||||
return createHttpLink({
|
||||
uri: 'http://localhost:8080/v1/graphql',
|
||||
headers: {
|
||||
},
|
||||
fetch,
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
};
|
@ -1,37 +0,0 @@
|
||||
// ./gatsby-node.js
|
||||
// Implement the Gatsby API “onCreatePage”. This is
|
||||
// called after every page is created.
|
||||
exports.onCreatePage = async ({ page, actions }) => {
|
||||
const { createPage } = actions
|
||||
|
||||
// page.matchPath is a special key that's used for matching pages
|
||||
// only on the client.
|
||||
if (page.path.match(/^\/account/)) {
|
||||
page.matchPath = "/account/*"
|
||||
|
||||
// Update the page.
|
||||
createPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
exports.onCreateWebpackConfig = ({ stage, loaders, actions }) => {
|
||||
if (stage === "build-html") {
|
||||
/*
|
||||
* During the build step, `auth0-js` will break because it relies on
|
||||
* browser-specific APIs. Fortunately, we don’t need it during the build.
|
||||
* Using Webpack’s null loader, we’re able to effectively ignore `auth0-js`
|
||||
* during the build. (See `src/utils/auth.js` to see how we prevent this
|
||||
* from breaking the app.)
|
||||
*/
|
||||
actions.setWebpackConfig({
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /auth0-js/,
|
||||
use: loaders.null(),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -1,35 +0,0 @@
|
||||
{
|
||||
"name": "gatsby-contentful-hasura",
|
||||
"private": true,
|
||||
"description": "A music playlist app built with Gatsby, Contentful and Hasura GraphQL",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "gatsby build",
|
||||
"develop": "gatsby develop",
|
||||
"format": "prettier --write src/**/*.{js,jsx}",
|
||||
"start": "npm run develop",
|
||||
"serve": "gatsby serve",
|
||||
"test": "echo \"Write tests! -> https://gatsby.dev/unit-testing\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@reach/router": "^1.2.1",
|
||||
"apollo-cache-inmemory": "^1.6.2",
|
||||
"apollo-client": "^2.6.3",
|
||||
"apollo-link-http": "^1.5.15",
|
||||
"apollo-link-ws": "^1.0.18",
|
||||
"auth0-js": "^9.10.1",
|
||||
"gatsby": "^2.3.16",
|
||||
"gatsby-source-graphql": "^2.1.3",
|
||||
"graphql": "^14.4.2",
|
||||
"graphql-tag": "^2.10.1",
|
||||
"node-fetch": "^2.6.0",
|
||||
"react": "^16.8.6",
|
||||
"react-apollo": "^2.5.8",
|
||||
"react-dom": "^16.8.6",
|
||||
"subscriptions-transport-ws": "^0.9.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^1.16.4"
|
||||
}
|
||||
}
|
@ -1,116 +0,0 @@
|
||||
import React, { useState } from "react"
|
||||
import { ApolloProvider } from 'react-apollo';
|
||||
import {Query} from 'react-apollo';
|
||||
import gql from 'graphql-tag';
|
||||
import './style.css';
|
||||
|
||||
const query = gql`
|
||||
query PlaylistQuery {
|
||||
playlist {
|
||||
name
|
||||
tracks {
|
||||
track_details {
|
||||
name
|
||||
}
|
||||
track {
|
||||
items {
|
||||
track {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const mutation = gql`
|
||||
mutation insert_playlist($name: String!) {
|
||||
insert_playlist(objects: [{
|
||||
name: $name
|
||||
}]) {
|
||||
affected_rows
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
const Playlist = ({ client }) => {
|
||||
const [showNewPlaylist, updateShowNewPlaylist] = useState(false);
|
||||
const [playlistName, setPlaylistName] = useState('');
|
||||
const createPlaylist = (playlistName) => {
|
||||
client.mutate({
|
||||
mutation: mutation,
|
||||
variables: {name: playlistName}
|
||||
}).then((data) => {
|
||||
//TODO: update cache
|
||||
})
|
||||
}
|
||||
return (
|
||||
<ApolloProvider client={client}>
|
||||
<Query query={query}>
|
||||
{({ loading, error, data, client}) => {
|
||||
if (loading) {
|
||||
return (<div>Loading...</div>);
|
||||
}
|
||||
/*
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return (<div>Error!</div>);
|
||||
}
|
||||
*/
|
||||
return (
|
||||
<div>
|
||||
<div className={'newPlaylist'}>
|
||||
<button
|
||||
className={'playlistBtn'}
|
||||
onClick={() => updateShowNewPlaylist(true)}
|
||||
>+ New Playlist</button>
|
||||
{showNewPlaylist ?
|
||||
(
|
||||
<div>
|
||||
Playlist name:
|
||||
<input type="text" onChange={(e) => setPlaylistName(e.target.value)} />
|
||||
<button onClick={() => createPlaylist(playlistName)}>Create</button>
|
||||
</div>
|
||||
)
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
<h2>My Playlist</h2>
|
||||
{data && data.playlist.length ? null : <div>No playlists available</div>}
|
||||
{data && data.playlist.map((p, i) => (
|
||||
<div key={i+p.name}>
|
||||
<b>{p.name}</b>
|
||||
{p.tracks.map((t,j) => {
|
||||
let elem = null;
|
||||
if (t.track) {
|
||||
elem = (
|
||||
<div key={t+j}>
|
||||
<div>{t.track_details.name}</div>
|
||||
<div>
|
||||
{t.track.items[0] ? (
|
||||
<audio controls>
|
||||
<source src={t.track.items[0].track.url} type="audio/mp3" />
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return elem;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
);
|
||||
}}
|
||||
</Query>
|
||||
</ApolloProvider>
|
||||
)
|
||||
};
|
||||
|
||||
export default Playlist;
|
@ -1,14 +0,0 @@
|
||||
nav > a {
|
||||
padding: 10px 5px;
|
||||
}
|
||||
.newPlaylistSection {
|
||||
padding-top: 10px;
|
||||
}
|
||||
.newPlaylist {
|
||||
padding: 10px 0px;
|
||||
}
|
||||
.playlistBtn {
|
||||
padding: 5px 10px;
|
||||
border-radius: 2px;
|
||||
border-color: lightgreen;
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
import React from "react"
|
||||
import { Router } from "@reach/router"
|
||||
import { login, logout, isAuthenticated, getProfile } from "../utils/auth"
|
||||
import { Link } from "gatsby"
|
||||
import Playlist from "../components/Playlist";
|
||||
|
||||
import ApolloClient from 'apollo-client';
|
||||
import { HttpLink } from 'apollo-link-http';
|
||||
import { InMemoryCache } from 'apollo-cache-inmemory';
|
||||
|
||||
const createApolloClient = (authToken) => {
|
||||
return new ApolloClient({
|
||||
link: new HttpLink({
|
||||
uri: 'http://localhost:8080/v1/graphql',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`
|
||||
// 'X-Hasura-Admin-Secret': 'myadminsecretkey'
|
||||
}
|
||||
}),
|
||||
cache: new InMemoryCache(),
|
||||
});
|
||||
};
|
||||
|
||||
const Home = ({ user }) => {
|
||||
return <p>Hi, {user.name ? user.name : "friend"}!</p>
|
||||
}
|
||||
|
||||
const Account = ({ data }) => {
|
||||
if (!isAuthenticated()) {
|
||||
login()
|
||||
return <p>Redirecting to login...</p>
|
||||
}
|
||||
|
||||
const user = getProfile();
|
||||
const client = createApolloClient(user.idToken);
|
||||
|
||||
return [
|
||||
<nav key="links">
|
||||
<Link to="/account">Home</Link>{" "}
|
||||
<Link to="/account/playlist">Playlist</Link>{" "}
|
||||
<a
|
||||
href="#logout"
|
||||
onClick={e => {
|
||||
logout()
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
Log Out
|
||||
</a>
|
||||
</nav>,
|
||||
<Router key="router">
|
||||
<Home path="/account" user={user} />
|
||||
<Playlist client={client} path="/account/playlist" />
|
||||
</Router>
|
||||
];
|
||||
}
|
||||
|
||||
export default Account;
|
||||
|
@ -1,10 +0,0 @@
|
||||
import React from "react"
|
||||
import { handleAuthentication } from "../utils/auth"
|
||||
|
||||
const Callback = () => {
|
||||
handleAuthentication()
|
||||
|
||||
return <p>Loading...</p>
|
||||
}
|
||||
|
||||
export default Callback
|
@ -1,9 +0,0 @@
|
||||
import React from "react"
|
||||
import { Link } from "gatsby"
|
||||
|
||||
export default () => (
|
||||
<div>
|
||||
<p>Hello Gatsby!</p>
|
||||
<Link to="/account">Go to your account</Link>
|
||||
</div>
|
||||
)
|
@ -1,84 +0,0 @@
|
||||
import auth0 from "auth0-js"
|
||||
import { navigate } from "gatsby"
|
||||
|
||||
const isBrowser = typeof window !== "undefined"
|
||||
|
||||
const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN;
|
||||
const AUTH0_CLIENTID = process.env.AUTH0_CLIENTID;
|
||||
const AUTH0_CALLBACK = process.env.AUTH0_CALLBACK;
|
||||
|
||||
const auth = isBrowser
|
||||
? new auth0.WebAuth({
|
||||
domain: AUTH0_DOMAIN,
|
||||
clientID: AUTH0_CLIENTID,
|
||||
redirectUri: AUTH0_CALLBACK,
|
||||
responseType: "token id_token",
|
||||
scope: "openid profile email",
|
||||
})
|
||||
: {}
|
||||
|
||||
const tokens = {
|
||||
accessToken: false,
|
||||
idToken: false,
|
||||
expiresAt: false,
|
||||
}
|
||||
|
||||
let user = {}
|
||||
|
||||
export const isAuthenticated = () => {
|
||||
if (!isBrowser) {
|
||||
return
|
||||
}
|
||||
|
||||
return localStorage.getItem("isLoggedIn") === "true"
|
||||
}
|
||||
|
||||
export const login = () => {
|
||||
if (!isBrowser) {
|
||||
return
|
||||
}
|
||||
|
||||
auth.authorize()
|
||||
}
|
||||
|
||||
const setSession = (cb = () => {}) => (err, authResult) => {
|
||||
if (err) {
|
||||
navigate("/")
|
||||
cb()
|
||||
return
|
||||
}
|
||||
|
||||
if (authResult && authResult.accessToken && authResult.idToken) {
|
||||
let expiresAt = authResult.expiresIn * 1000 + new Date().getTime()
|
||||
tokens.accessToken = authResult.accessToken
|
||||
tokens.idToken = authResult.idToken
|
||||
tokens.expiresAt = expiresAt
|
||||
user = authResult.idTokenPayload
|
||||
user.idToken = authResult.idToken
|
||||
localStorage.setItem("isLoggedIn", true)
|
||||
navigate("/account")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
export const silentAuth = callback => {
|
||||
if (!isAuthenticated()) return callback()
|
||||
auth.checkSession({}, setSession(callback))
|
||||
}
|
||||
|
||||
export const handleAuthentication = () => {
|
||||
if (!isBrowser) {
|
||||
return
|
||||
}
|
||||
|
||||
auth.parseHash(setSession())
|
||||
}
|
||||
|
||||
export const getProfile = () => {
|
||||
return user
|
||||
}
|
||||
|
||||
export const logout = () => {
|
||||
localStorage.setItem("isLoggedIn", false)
|
||||
auth.logout()
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 2.7 KiB |
@ -1,32 +0,0 @@
|
||||
function userSyncRule(user, context, callback) {
|
||||
const userId = user.user_id;
|
||||
const nickname = user.nickname;
|
||||
|
||||
const mutation = `mutation($userId: String!, $nickname: String) {
|
||||
insert_users(objects: [{
|
||||
id: $userId,
|
||||
name: $nickname
|
||||
}],
|
||||
on_conflict: {
|
||||
constraint: users_pkey,
|
||||
update_columns: [name]
|
||||
}) {
|
||||
affected_rows
|
||||
}
|
||||
}`;
|
||||
|
||||
request.post(
|
||||
{
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-hasura-admin-secret": configuration.ADMIN_SECRET
|
||||
},
|
||||
url: "https://<your-app-domain>/v1/graphql",
|
||||
body: JSON.stringify({ query: mutation, variables: { userId, nickname } })
|
||||
},
|
||||
function(error, response, body) {
|
||||
console.log(body);
|
||||
callback(error, user, context);
|
||||
}
|
||||
);
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
version: 3
|
||||
endpoint: http://localhost:8080/
|
||||
api_paths:
|
||||
v1_query: v1/query
|
||||
v2_query: v2/query
|
||||
v1_metadata: v1/metadata
|
||||
graphql: v1/graphql
|
||||
config: v1alpha1/config
|
||||
pg_dump: v1alpha1/pg_dump
|
||||
version: v1/version
|
||||
metadata_directory: metadata
|
||||
migrations_directory: migrations
|
||||
seeds_directory: seeds
|
||||
actions:
|
||||
kind: synchronous
|
||||
handler_webhook_baseurl: http://localhost:3000
|
||||
codegen:
|
||||
framework: ""
|
||||
output_dir: ""
|
@ -1,6 +0,0 @@
|
||||
actions: []
|
||||
custom_types:
|
||||
enums: []
|
||||
input_objects: []
|
||||
objects: []
|
||||
scalars: []
|
@ -1 +0,0 @@
|
||||
[]
|
@ -1 +0,0 @@
|
||||
[]
|
@ -1,14 +0,0 @@
|
||||
- name: default
|
||||
kind: postgres
|
||||
configuration:
|
||||
connection_info:
|
||||
database_url:
|
||||
from_env: SAMPLE_APPS_DATABASE_URL
|
||||
isolation_level: read-committed
|
||||
pool_settings:
|
||||
connection_lifetime: 600
|
||||
idle_timeout: 180
|
||||
max_connections: 50
|
||||
retries: 1
|
||||
use_prepared_statements: false
|
||||
tables: "!include default/tables/tables.yaml"
|
@ -1,11 +0,0 @@
|
||||
table:
|
||||
name: album
|
||||
schema: public
|
||||
array_relationships:
|
||||
- name: tracks
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: album_id
|
||||
table:
|
||||
name: track
|
||||
schema: public
|
@ -1,15 +0,0 @@
|
||||
table:
|
||||
name: playlist
|
||||
schema: public
|
||||
object_relationships:
|
||||
- name: user
|
||||
using:
|
||||
foreign_key_constraint_on: user_id
|
||||
array_relationships:
|
||||
- name: playlist_tracks
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: playlist_id
|
||||
table:
|
||||
name: playlist_track
|
||||
schema: public
|
@ -1,10 +0,0 @@
|
||||
table:
|
||||
name: playlist_track
|
||||
schema: public
|
||||
object_relationships:
|
||||
- name: playlist
|
||||
using:
|
||||
foreign_key_constraint_on: playlist_id
|
||||
- name: track
|
||||
using:
|
||||
foreign_key_constraint_on: track_id
|
@ -1,15 +0,0 @@
|
||||
table:
|
||||
name: track
|
||||
schema: public
|
||||
object_relationships:
|
||||
- name: album
|
||||
using:
|
||||
foreign_key_constraint_on: album_id
|
||||
array_relationships:
|
||||
- name: playlist_tracks
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: track_id
|
||||
table:
|
||||
name: playlist_track
|
||||
schema: public
|
@ -1,11 +0,0 @@
|
||||
table:
|
||||
name: users
|
||||
schema: public
|
||||
array_relationships:
|
||||
- name: playlists
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: user_id
|
||||
table:
|
||||
name: playlist
|
||||
schema: public
|
@ -1,5 +0,0 @@
|
||||
- "!include public_album.yaml"
|
||||
- "!include public_playlist.yaml"
|
||||
- "!include public_playlist_track.yaml"
|
||||
- "!include public_track.yaml"
|
||||
- "!include public_users.yaml"
|
@ -1 +0,0 @@
|
||||
[]
|
@ -1 +0,0 @@
|
||||
[]
|
@ -1 +0,0 @@
|
||||
[]
|
@ -1 +0,0 @@
|
||||
version: 3
|
@ -1,70 +0,0 @@
|
||||
CREATE TABLE public.album (
|
||||
id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
CREATE SEQUENCE public.album_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
ALTER SEQUENCE public.album_id_seq OWNED BY public.album.id;
|
||||
CREATE TABLE public.playlist (
|
||||
id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
user_id text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
CREATE SEQUENCE public.playlist_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
ALTER SEQUENCE public.playlist_id_seq OWNED BY public.playlist.id;
|
||||
CREATE TABLE public.playlist_track (
|
||||
track_id integer NOT NULL,
|
||||
playlist_id integer NOT NULL
|
||||
);
|
||||
CREATE TABLE public.track (
|
||||
id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
album_id integer NOT NULL
|
||||
);
|
||||
CREATE SEQUENCE public.track_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
ALTER SEQUENCE public.track_id_seq OWNED BY public.track.id;
|
||||
CREATE TABLE public.users (
|
||||
id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
ALTER TABLE ONLY public.album ALTER COLUMN id SET DEFAULT nextval('public.album_id_seq'::regclass);
|
||||
ALTER TABLE ONLY public.playlist ALTER COLUMN id SET DEFAULT nextval('public.playlist_id_seq'::regclass);
|
||||
ALTER TABLE ONLY public.track ALTER COLUMN id SET DEFAULT nextval('public.track_id_seq'::regclass);
|
||||
ALTER TABLE ONLY public.album
|
||||
ADD CONSTRAINT album_pkey PRIMARY KEY (id);
|
||||
ALTER TABLE ONLY public.playlist
|
||||
ADD CONSTRAINT playlist_pkey PRIMARY KEY (id);
|
||||
ALTER TABLE ONLY public.playlist_track
|
||||
ADD CONSTRAINT playlist_track_pkey PRIMARY KEY (track_id, playlist_id);
|
||||
ALTER TABLE ONLY public.track
|
||||
ADD CONSTRAINT track_pkey PRIMARY KEY (id);
|
||||
ALTER TABLE ONLY public.users
|
||||
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
|
||||
ALTER TABLE ONLY public.playlist_track
|
||||
ADD CONSTRAINT playlist_track_playlist_id_fkey FOREIGN KEY (playlist_id) REFERENCES public.playlist(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
|
||||
ALTER TABLE ONLY public.playlist_track
|
||||
ADD CONSTRAINT playlist_track_track_id_fkey FOREIGN KEY (track_id) REFERENCES public.track(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
|
||||
ALTER TABLE ONLY public.playlist
|
||||
ADD CONSTRAINT playlist_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
|
||||
ALTER TABLE ONLY public.track
|
||||
ADD CONSTRAINT track_album_id_fkey FOREIGN KEY (album_id) REFERENCES public.album(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
|
@ -1,64 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Typescript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
.cache/
|
||||
public
|
||||
yarn-error.log
|
||||
|
||||
package-lock.json
|
@ -1,213 +0,0 @@
|
||||
# gatsby-postgres-graphql
|
||||
|
||||
Boilerplate to get started with Gatsby, Hasura GraphQL engine as CMS and postgres as database using the awesome plugin [gatsby-source-graphql](https://github.com/gatsbyjs/gatsby/tree/master/packages/gatsby-source-graphql).
|
||||
|
||||
[![Edit gatsby-postgres-graphql](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/hasura/graphql-engine/tree/master/community/sample-apps/gatsby-postgres-graphql?fontsize=14)
|
||||
|
||||
![Gatsby Postgres GraphQL](./assets/gatsby-postgres-graphql.png)
|
||||
|
||||
# Tutorial
|
||||
|
||||
- Deploy GraphQL Engine on Hasura Cloud and setup PostgreSQL via Heroku:
|
||||
|
||||
[![Deploy to Hasura Cloud](https://graphql-engine-cdn.hasura.io/img/deploy_to_hasura.png)](https://cloud.hasura.io/signup)
|
||||
|
||||
- Get the Hasura app URL (say `gatsby-graphql.hasura.app`)
|
||||
|
||||
- Clone this repo:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/hasura/graphql-engine
|
||||
cd graphql-engine/community/sample-apps/gatsby-postgres-graphql
|
||||
```
|
||||
|
||||
- Create `author` table:
|
||||
|
||||
Open Hasura console: visit https://gatsby-graphql.hasura.app on a browser
|
||||
Navigate to `Data` section in the top nav bar and create a table as follows:
|
||||
|
||||
![Create author table](./assets/add_table.jpg)
|
||||
|
||||
- Insert sample data into `author` table:
|
||||
|
||||
![Insert data into author table](./assets/insert_data.jpg)
|
||||
|
||||
Verify if the row is inserted successfully
|
||||
|
||||
![Insert data into author table](./assets/browse_rows.jpg)
|
||||
|
||||
- Install npm modules:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
- Configure gatsby to use `gatsby-source-graphql` plugin and a connection GraphQL url to stitch the schema.
|
||||
|
||||
```js
|
||||
{
|
||||
plugins: [
|
||||
{
|
||||
resolve: "gatsby-source-graphql", // <- Configure plugin
|
||||
options: {
|
||||
typeName: "HASURA",
|
||||
fieldName: "hasura", // <- fieldName under which schema will be stitched
|
||||
url: process.env.GATSBY_HASURA_GRAPHQL_URL,
|
||||
refetchInterval: 10 // Refresh every 10 seconds for new data
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
- Run the app:
|
||||
|
||||
```bash
|
||||
GATSBY_HASURA_GRAPHQL_URL=https://gatsby-graphql.hasura.app/v1/graphql npm run develop
|
||||
```
|
||||
|
||||
- Test the app
|
||||
Visit [http://localhost:8000](http://localhost:8000) to view the app
|
||||
|
||||
![Demo app](./assets/test_app.jpg)
|
||||
|
||||
# Make a GraphQL query from your component using hooks
|
||||
|
||||
1. Create a component named `AuthorList.js`:
|
||||
|
||||
```js
|
||||
import React from "react";
|
||||
import { useQuery } from "@apollo/react-hooks";
|
||||
import { gql } from "apollo-boost";
|
||||
|
||||
const GET_AUTHORS = gql`
|
||||
query {
|
||||
author {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const AuthorList = () => {
|
||||
const { loading, error, data } = useQuery(GET_AUTHORS);
|
||||
|
||||
if (loading) return "loading...";
|
||||
if (error) return `error: ${error.message}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data.author.map((author, index) => (
|
||||
<div key={index}>
|
||||
<h2>{author.name}</h2>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthorList;
|
||||
export { GET_AUTHORS };
|
||||
```
|
||||
|
||||
# Make a GraphQL mutation using hooks
|
||||
|
||||
Additional packages are needed to be added to support mutations: <br/>
|
||||
`npm install @apollo/react-hooks apollo-boost isomorphic-fetch`
|
||||
|
||||
1. Create an `apollo.js` util file:
|
||||
|
||||
```js
|
||||
import ApolloClient from "apollo-boost";
|
||||
import fetch from "isomorphic-fetch";
|
||||
|
||||
export const client = new ApolloClient({
|
||||
uri: process.env.GATSBY_HASURA_GRAPHQL_URL,
|
||||
fetch
|
||||
});
|
||||
```
|
||||
|
||||
2. Create `gatsby-browser.js` and `gatsby-ssr.js`
|
||||
|
||||
```js
|
||||
import React from "react";
|
||||
import { ApolloProvider } from "@apollo/react-hooks";
|
||||
import { client } from "./src/utils/apollo";
|
||||
|
||||
export const wrapRootElement = ({ element }) => (
|
||||
<ApolloProvider client={client}>{element}</ApolloProvider>
|
||||
);
|
||||
```
|
||||
|
||||
3. Create an `AddAuthor.js` component to add mutations:
|
||||
|
||||
```js
|
||||
import React, { useState } from "react";
|
||||
import { useMutation } from "@apollo/react-hooks";
|
||||
import { gql } from "apollo-boost";
|
||||
import { GET_AUTHORS } from "./AuthorList";
|
||||
|
||||
const ADD_AUTHOR = gql`
|
||||
mutation insert_author($name: String!) {
|
||||
insert_author(objects: { name: $name }) {
|
||||
returning {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const AddAuthor = () => {
|
||||
const [author, setAuthor] = useState("");
|
||||
const [insert_author, { loading, error }] = useMutation(ADD_AUTHOR, {
|
||||
update: (cache, { data }) => {
|
||||
setAuthor("");
|
||||
const existingAuthors = cache.readQuery({
|
||||
query: GET_AUTHORS
|
||||
});
|
||||
|
||||
// Add the new author to the cache
|
||||
const newAuthor = data.insert_author.returning[0];
|
||||
cache.writeQuery({
|
||||
query: GET_AUTHORS,
|
||||
data: {author: [newAuthor, ...existingAuthors.author]}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (loading) return "loading...";
|
||||
if (error) return `error: ${error.message}`;
|
||||
|
||||
const handleSubmit = event => {
|
||||
event.preventDefault();
|
||||
insert_author({
|
||||
variables: {
|
||||
name: author
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label htmlFor="author">
|
||||
Add Author:
|
||||
<input
|
||||
name="author"
|
||||
value={author}
|
||||
onChange={event => setAuthor(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">ADD</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddAuthor;
|
||||
```
|
||||
|
||||
4. Run the app and test mutation. New data will be added to the top via a cache update.
|
||||
|
||||
# Contributing
|
||||
|
||||
Checkout the [contributing guide](../../../CONTRIBUTING.md#community-content) for more details.
|
Binary file not shown.
Before Width: | Height: | Size: 29 KiB |
Binary file not shown.
Before Width: | Height: | Size: 26 KiB |
Binary file not shown.
Before Width: | Height: | Size: 20 KiB |
Binary file not shown.
Before Width: | Height: | Size: 20 KiB |
Binary file not shown.
Before Width: | Height: | Size: 16 KiB |
@ -1 +0,0 @@
|
||||
endpoint: https://gatsby-graphql.hasura.app
|
@ -1,7 +0,0 @@
|
||||
import React from "react";
|
||||
import { ApolloProvider } from "@apollo/react-hooks";
|
||||
import { client } from "./src/utils/apollo";
|
||||
|
||||
export const wrapRootElement = ({ element }) => (
|
||||
<ApolloProvider client={client}>{element}</ApolloProvider>
|
||||
);
|
@ -1,13 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
{
|
||||
resolve: "gatsby-source-graphql",
|
||||
options: {
|
||||
typeName: "HASURA",
|
||||
fieldName: "hasura",
|
||||
url: process.env.GATSBY_HASURA_GRAPHQL_URL,
|
||||
refetchInterval: 10 // Refresh every 60 seconds for new data
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
@ -1,7 +0,0 @@
|
||||
import React from "react";
|
||||
import { ApolloProvider } from "@apollo/react-hooks";
|
||||
import { client } from "./src/utils/apollo";
|
||||
|
||||
export const wrapRootElement = ({ element }) => (
|
||||
<ApolloProvider client={client}>{element}</ApolloProvider>
|
||||
);
|
@ -1,19 +0,0 @@
|
||||
version: 3
|
||||
endpoint: http://localhost:8080/
|
||||
api_paths:
|
||||
v1_query: v1/query
|
||||
v2_query: v2/query
|
||||
v1_metadata: v1/metadata
|
||||
graphql: v1/graphql
|
||||
config: v1alpha1/config
|
||||
pg_dump: v1alpha1/pg_dump
|
||||
version: v1/version
|
||||
metadata_directory: metadata
|
||||
migrations_directory: migrations
|
||||
seeds_directory: seeds
|
||||
actions:
|
||||
kind: synchronous
|
||||
handler_webhook_baseurl: http://localhost:3000
|
||||
codegen:
|
||||
framework: ""
|
||||
output_dir: ""
|
@ -1,2 +0,0 @@
|
||||
|
||||
|
@ -1,6 +0,0 @@
|
||||
actions: []
|
||||
custom_types:
|
||||
enums: []
|
||||
input_objects: []
|
||||
objects: []
|
||||
scalars: []
|
@ -1 +0,0 @@
|
||||
[]
|
@ -1 +0,0 @@
|
||||
[]
|
@ -1,13 +0,0 @@
|
||||
- name: default
|
||||
kind: postgres
|
||||
configuration:
|
||||
connection_info:
|
||||
database_url:
|
||||
from_env: SAMPLE_APPS_DATABASE_URL
|
||||
pool_settings:
|
||||
idle_timeout: 180
|
||||
max_connections: 50
|
||||
retries: 1
|
||||
tables:
|
||||
- "!include default/tables/public_author.yaml"
|
||||
functions: []
|
@ -1,3 +0,0 @@
|
||||
table:
|
||||
name: author
|
||||
schema: public
|
@ -1 +0,0 @@
|
||||
[]
|
@ -1 +0,0 @@
|
||||
[]
|
@ -1 +0,0 @@
|
||||
version: 3
|
@ -1,15 +0,0 @@
|
||||
CREATE TABLE public.author (
|
||||
id integer NOT NULL,
|
||||
name text NOT NULL
|
||||
);
|
||||
CREATE SEQUENCE public.author_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
ALTER SEQUENCE public.author_id_seq OWNED BY public.author.id;
|
||||
ALTER TABLE ONLY public.author ALTER COLUMN id SET DEFAULT nextval('public.author_id_seq'::regclass);
|
||||
ALTER TABLE ONLY public.author
|
||||
ADD CONSTRAINT author_pkey PRIMARY KEY (id);
|
@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "gatsby-postgres-graphql",
|
||||
"description": "Gatsby simple source hasura graphql cms",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"develop": "gatsby develop",
|
||||
"build": "gatsby build",
|
||||
"serve": "gatsby serve"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hot-loader/react-dom": "^16.11.0",
|
||||
"@apollo/react-hooks": "^3.1.3",
|
||||
"apollo-boost": "^0.4.7",
|
||||
"gatsby": "^2.18.17",
|
||||
"gatsby-link": "^2.2.27",
|
||||
"gatsby-source-graphql": "^2.1.29",
|
||||
"isomorphic-fetch": "^2.2.1",
|
||||
"react": "^16.12",
|
||||
"react-dom": "^16.12"
|
||||
}
|
||||
}
|
@ -1,62 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useMutation } from "@apollo/react-hooks";
|
||||
import { gql } from "apollo-boost";
|
||||
import { GET_AUTHORS } from "./AuthorList";
|
||||
|
||||
const ADD_AUTHOR = gql`
|
||||
mutation insert_author($name: String!) {
|
||||
insert_author(objects: { name: $name }) {
|
||||
returning {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const AddAuthor = () => {
|
||||
const [author, setAuthor] = useState("");
|
||||
const [insert_author, { loading, error }] = useMutation(ADD_AUTHOR, {
|
||||
update: (cache, { data }) => {
|
||||
setAuthor("");
|
||||
const existingAuthors = cache.readQuery({
|
||||
query: GET_AUTHORS
|
||||
});
|
||||
|
||||
// Add the new author to the cache
|
||||
const newAuthor = data.insert_author.returning[0];
|
||||
cache.writeQuery({
|
||||
query: GET_AUTHORS,
|
||||
data: {author: [newAuthor, ...existingAuthors.author]}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (loading) return "loading...";
|
||||
if (error) return `error: ${error.message}`;
|
||||
|
||||
const handleSubmit = event => {
|
||||
event.preventDefault();
|
||||
insert_author({
|
||||
variables: {
|
||||
name: author
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label htmlFor="author">
|
||||
Add Author:
|
||||
<input
|
||||
name="author"
|
||||
value={author}
|
||||
onChange={event => setAuthor(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">ADD</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddAuthor;
|
@ -1,32 +0,0 @@
|
||||
import React from "react";
|
||||
import { useQuery } from "@apollo/react-hooks";
|
||||
import { gql } from "apollo-boost";
|
||||
|
||||
const GET_AUTHORS = gql`
|
||||
query {
|
||||
author {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const AuthorList = () => {
|
||||
const { loading, error, data } = useQuery(GET_AUTHORS);
|
||||
|
||||
if (loading) return "loading...";
|
||||
if (error) return `error: ${error.message}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data.author.map((author, index) => (
|
||||
<div key={index}>
|
||||
<h2>{author.name}</h2>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthorList;
|
||||
export { GET_AUTHORS };
|
@ -1,14 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import AddAuthor from "../components/AddAuthor";
|
||||
import AuthorList from "../components/AuthorList";
|
||||
|
||||
const Index = () => (
|
||||
<div>
|
||||
<h1>My Authors</h1>
|
||||
<AddAuthor />
|
||||
<AuthorList />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Index;
|
@ -1,7 +0,0 @@
|
||||
import ApolloClient from "apollo-boost";
|
||||
import fetch from "isomorphic-fetch";
|
||||
|
||||
export const client = new ApolloClient({
|
||||
uri: process.env.GATSBY_HASURA_GRAPHQL_URL,
|
||||
fetch
|
||||
});
|
@ -1,15 +0,0 @@
|
||||
# GraphQL Benchmarking
|
||||
|
||||
Uses the [Chinook sample database](https://github.com/lerocha/chinook-database). Tested on macOS Ventura 13.1.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Checkout [https://github.com/hasura/graphql-bench](https://github.com/hasura/graphql-bench) and build the docker container with `make build_local_docker_image`
|
||||
|
||||
1. Run `docker compose -f docker-compose.hasura.yml up -d` to bootstrap Postgres with Chinook.
|
||||
|
||||
1. After a few minutes check the Hasura docker logs to see if Hasura is running, which implies the database is now bootstrapped. Run `docker compose -f docker-compose.hasura.yml down`
|
||||
|
||||
1. Run the benchmarks `sh benchmark.sh`
|
||||
|
||||
1. Open the results on the GraphQL bench website [https://hasura.github.io/graphql-bench/app/web-app/](https://hasura.github.io/graphql-bench/app/web-app/)
|
@ -1,31 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
docker compose -f docker-compose.hasura.yml up -d --build
|
||||
|
||||
echo "Running Hasura Benchmark"
|
||||
|
||||
sleep 5
|
||||
|
||||
docker run --net=host -v "$PWD":/app/tmp -it \
|
||||
graphql-bench-local query \
|
||||
--config="./tmp/config.query.hasura.yaml" \
|
||||
--outfile="./tmp/report.hasura.json"
|
||||
|
||||
docker compose -f docker-compose.hasura.yml down
|
||||
|
||||
echo "Hasura Benchmark done"
|
||||
|
||||
docker compose -f docker-compose.node.yml up -d --build
|
||||
|
||||
echo "Running Nodejs Benchmark"
|
||||
|
||||
sleep 5
|
||||
|
||||
docker run --net=host -v "$PWD":/app/tmp -it \
|
||||
graphql-bench-local query \
|
||||
--config="./tmp/config.query.node.yaml" \
|
||||
--outfile="./tmp/report.nodejs.json"
|
||||
|
||||
docker compose -f docker-compose.node.yml down
|
||||
|
||||
echo "Node.js Benchmark done"
|
@ -1,64 +0,0 @@
|
||||
url: "http://host.docker.internal:8080/v1/graphql"
|
||||
# url: https://benchmark-hasura-rso3d2ja7a-uc.a.run.app/v1/graphql
|
||||
headers:
|
||||
content-type: application/json
|
||||
# "Debug" mode enables request and response logging for Autocannon and K6
|
||||
# This lets you see what is happening and confirm proper behavior.
|
||||
# This should be disabled for genuine benchmarks, and only used for debugging/visibility.
|
||||
debug: false
|
||||
queries:
|
||||
# Name: Unique name for the query
|
||||
- name: GetAllArtistsAlbumsAndTracks
|
||||
# Tools: List of benchmarking tools to run: ['autocannon', 'k6', 'wrk2']
|
||||
tools: [k6]
|
||||
execution_strategy: REQUESTS_PER_SECOND
|
||||
rps: 2000
|
||||
duration: 10s
|
||||
connections: 50
|
||||
query: |
|
||||
query GetAllArtistsAlbumsTracks_Genres {
|
||||
Artist {
|
||||
ArtistId
|
||||
Name
|
||||
Albums {
|
||||
AlbumId
|
||||
Title
|
||||
Tracks {
|
||||
TrackId
|
||||
Name
|
||||
Composer
|
||||
Genre {
|
||||
GenreId
|
||||
Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
- name: AlbumByPK
|
||||
tools: [k6]
|
||||
execution_strategy: FIXED_REQUEST_NUMBER
|
||||
requests: 10000
|
||||
query: |
|
||||
query AlbumByPK {
|
||||
Album_by_pk(AlbumId: 1) {
|
||||
AlbumId
|
||||
Title
|
||||
}
|
||||
}
|
||||
- name: AlbumByPKMultiStage
|
||||
tools: [k6]
|
||||
execution_strategy: MULTI_STAGE
|
||||
initial_rps: 0
|
||||
stages:
|
||||
- duration: 5s
|
||||
target: 100
|
||||
- duration: 5s
|
||||
target: 1000
|
||||
query: |
|
||||
query AlbumByPK {
|
||||
Album_by_pk(AlbumId: 1) {
|
||||
AlbumId
|
||||
Title
|
||||
}
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
url: "http://host.docker.internal:8080/v1/graphql"
|
||||
# url: https://benchmark-node-rso3d2ja7a-ul.a.run.app/v1/graphql
|
||||
headers:
|
||||
content-type: application/json
|
||||
# "Debug" mode enables request and response logging for Autocannon and K6
|
||||
# This lets you see what is happening and confirm proper behavior.
|
||||
# This should be disabled for genuine benchmarks, and only used for debugging/visibility.
|
||||
debug: false
|
||||
queries:
|
||||
# Name: Unique name for the query
|
||||
- name: GetAllArtistsAlbumsAndTracks
|
||||
# Tools: List of benchmarking tools to run: ['autocannon', 'k6', 'wrk2']
|
||||
tools: [k6]
|
||||
execution_strategy: REQUESTS_PER_SECOND
|
||||
rps: 2000
|
||||
duration: 10s
|
||||
connections: 10
|
||||
query: |
|
||||
query GetAllArtistsAlbumsTracks_Genres {
|
||||
Artist {
|
||||
ArtistId
|
||||
Name
|
||||
Albums {
|
||||
AlbumId
|
||||
Title
|
||||
Tracks {
|
||||
TrackId
|
||||
Name
|
||||
Composer
|
||||
Genre {
|
||||
GenreId
|
||||
Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
- name: AlbumByPK
|
||||
tools: [k6]
|
||||
execution_strategy: FIXED_REQUEST_NUMBER
|
||||
requests: 10000
|
||||
query: |
|
||||
query AlbumByPK {
|
||||
Album_by_pk(AlbumId: 1) {
|
||||
AlbumId
|
||||
Title
|
||||
}
|
||||
}
|
||||
- name: AlbumByPKMultiStage
|
||||
tools: [k6]
|
||||
execution_strategy: MULTI_STAGE
|
||||
initial_rps: 0
|
||||
stages:
|
||||
- duration: 5s
|
||||
target: 100
|
||||
- duration: 5s
|
||||
target: 1000
|
||||
query: |
|
||||
query AlbumByPK {
|
||||
Album_by_pk(AlbumId: 1) {
|
||||
AlbumId
|
||||
Title
|
||||
}
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
restart: always
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgrespassword
|
||||
graphql-engine:
|
||||
image: hasura/graphql-engine:v2.18.0.cli-migrations-v3
|
||||
volumes:
|
||||
- ./hasura/migrations:/hasura-migrations
|
||||
- ./hasura/metadata:/hasura-metadata
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- "postgres"
|
||||
restart: always
|
||||
environment:
|
||||
## postgres database to store Hasura metadata
|
||||
HASURA_GRAPHQL_METADATA_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
|
||||
## this env var can be used to add the above postgres database to Hasura as a data source. this can be removed/updated based on your needs
|
||||
PG_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
|
||||
## enable the console served by server
|
||||
HASURA_GRAPHQL_ENABLE_CONSOLE: "true" # set to "false" to disable console
|
||||
## enable debugging mode. It is recommended to disable this in production
|
||||
HASURA_GRAPHQL_DEV_MODE: "true"
|
||||
HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup, http-log, webhook-log, websocket-log, query-log
|
||||
## uncomment next line to run console offline (i.e load console assets from server instead of CDN)
|
||||
# HASURA_GRAPHQL_CONSOLE_ASSETS_DIR: /srv/console-assets
|
||||
## uncomment next line to set an admin secret
|
||||
# HASURA_GRAPHQL_ADMIN_SECRET: myadminsecretkey
|
||||
volumes:
|
||||
db_data:
|
@ -1,18 +0,0 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
restart: always
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgrespassword
|
||||
node:
|
||||
build: ./nodejs
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- "postgres"
|
||||
volumes:
|
||||
db_data:
|
@ -1,6 +0,0 @@
|
||||
version: 3
|
||||
endpoint: http://localhost:8080
|
||||
metadata_directory: metadata
|
||||
actions:
|
||||
kind: synchronous
|
||||
handler_webhook_baseurl: http://localhost:3000
|
@ -1,6 +0,0 @@
|
||||
actions: []
|
||||
custom_types:
|
||||
enums: []
|
||||
input_objects: []
|
||||
objects: []
|
||||
scalars: []
|
@ -1 +0,0 @@
|
||||
{}
|
@ -1 +0,0 @@
|
||||
[]
|
@ -1,9 +0,0 @@
|
||||
- name: default
|
||||
kind: postgres
|
||||
configuration:
|
||||
connection_info:
|
||||
database_url:
|
||||
from_env: PG_DATABASE_URL
|
||||
isolation_level: read-committed
|
||||
use_prepared_statements: false
|
||||
tables: "!include default/tables/tables.yaml"
|
@ -1,15 +0,0 @@
|
||||
table:
|
||||
name: Album
|
||||
schema: public
|
||||
object_relationships:
|
||||
- name: Artist
|
||||
using:
|
||||
foreign_key_constraint_on: ArtistId
|
||||
array_relationships:
|
||||
- name: Tracks
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: AlbumId
|
||||
table:
|
||||
name: Track
|
||||
schema: public
|
@ -1,11 +0,0 @@
|
||||
table:
|
||||
name: Artist
|
||||
schema: public
|
||||
array_relationships:
|
||||
- name: Albums
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: ArtistId
|
||||
table:
|
||||
name: Album
|
||||
schema: public
|
@ -1,15 +0,0 @@
|
||||
table:
|
||||
name: Customer
|
||||
schema: public
|
||||
object_relationships:
|
||||
- name: Employee
|
||||
using:
|
||||
foreign_key_constraint_on: SupportRepId
|
||||
array_relationships:
|
||||
- name: Invoices
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: CustomerId
|
||||
table:
|
||||
name: Invoice
|
||||
schema: public
|
@ -1,22 +0,0 @@
|
||||
table:
|
||||
name: Employee
|
||||
schema: public
|
||||
object_relationships:
|
||||
- name: Employee
|
||||
using:
|
||||
foreign_key_constraint_on: ReportsTo
|
||||
array_relationships:
|
||||
- name: Customers
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: SupportRepId
|
||||
table:
|
||||
name: Customer
|
||||
schema: public
|
||||
- name: Employees
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: ReportsTo
|
||||
table:
|
||||
name: Employee
|
||||
schema: public
|
@ -1,11 +0,0 @@
|
||||
table:
|
||||
name: Genre
|
||||
schema: public
|
||||
array_relationships:
|
||||
- name: Tracks
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: GenreId
|
||||
table:
|
||||
name: Track
|
||||
schema: public
|
@ -1,15 +0,0 @@
|
||||
table:
|
||||
name: Invoice
|
||||
schema: public
|
||||
object_relationships:
|
||||
- name: Customer
|
||||
using:
|
||||
foreign_key_constraint_on: CustomerId
|
||||
array_relationships:
|
||||
- name: InvoiceLines
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: InvoiceId
|
||||
table:
|
||||
name: InvoiceLine
|
||||
schema: public
|
@ -1,10 +0,0 @@
|
||||
table:
|
||||
name: InvoiceLine
|
||||
schema: public
|
||||
object_relationships:
|
||||
- name: Invoice
|
||||
using:
|
||||
foreign_key_constraint_on: InvoiceId
|
||||
- name: Track
|
||||
using:
|
||||
foreign_key_constraint_on: TrackId
|
@ -1,11 +0,0 @@
|
||||
table:
|
||||
name: MediaType
|
||||
schema: public
|
||||
array_relationships:
|
||||
- name: Tracks
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: MediaTypeId
|
||||
table:
|
||||
name: Track
|
||||
schema: public
|
@ -1,11 +0,0 @@
|
||||
table:
|
||||
name: Playlist
|
||||
schema: public
|
||||
array_relationships:
|
||||
- name: PlaylistTracks
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: PlaylistId
|
||||
table:
|
||||
name: PlaylistTrack
|
||||
schema: public
|
@ -1,10 +0,0 @@
|
||||
table:
|
||||
name: PlaylistTrack
|
||||
schema: public
|
||||
object_relationships:
|
||||
- name: Playlist
|
||||
using:
|
||||
foreign_key_constraint_on: PlaylistId
|
||||
- name: Track
|
||||
using:
|
||||
foreign_key_constraint_on: TrackId
|
@ -1,28 +0,0 @@
|
||||
table:
|
||||
name: Track
|
||||
schema: public
|
||||
object_relationships:
|
||||
- name: Album
|
||||
using:
|
||||
foreign_key_constraint_on: AlbumId
|
||||
- name: Genre
|
||||
using:
|
||||
foreign_key_constraint_on: GenreId
|
||||
- name: MediaType
|
||||
using:
|
||||
foreign_key_constraint_on: MediaTypeId
|
||||
array_relationships:
|
||||
- name: InvoiceLines
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: TrackId
|
||||
table:
|
||||
name: InvoiceLine
|
||||
schema: public
|
||||
- name: PlaylistTracks
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: TrackId
|
||||
table:
|
||||
name: PlaylistTrack
|
||||
schema: public
|
@ -1,11 +0,0 @@
|
||||
- "!include public_Album.yaml"
|
||||
- "!include public_Artist.yaml"
|
||||
- "!include public_Customer.yaml"
|
||||
- "!include public_Employee.yaml"
|
||||
- "!include public_Genre.yaml"
|
||||
- "!include public_Invoice.yaml"
|
||||
- "!include public_InvoiceLine.yaml"
|
||||
- "!include public_MediaType.yaml"
|
||||
- "!include public_Playlist.yaml"
|
||||
- "!include public_PlaylistTrack.yaml"
|
||||
- "!include public_Track.yaml"
|
@ -1 +0,0 @@
|
||||
disabled_for_roles: []
|
@ -1 +0,0 @@
|
||||
[]
|
@ -1 +0,0 @@
|
||||
{}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user