refactor: remove whole graphql implementation

This commit is contained in:
Ali BARIN
2024-09-26 13:29:46 +00:00
parent a043a044ca
commit 0b7591edce
28 changed files with 28 additions and 1181 deletions

View File

@@ -23,8 +23,6 @@
"dependencies": {
"@bull-board/express": "^3.10.1",
"@casl/ability": "^6.5.0",
"@graphql-tools/graphql-file-loader": "^7.3.4",
"@graphql-tools/load": "^7.5.2",
"@node-saml/passport-saml": "^4.0.4",
"@rudderstack/rudder-sdk-node": "^1.1.2",
"@sentry/node": "^7.42.0",
@@ -41,11 +39,7 @@
"express": "~4.18.2",
"express-async-errors": "^3.1.1",
"express-basic-auth": "^1.2.1",
"express-graphql": "^0.12.0",
"fast-xml-parser": "^4.0.11",
"graphql-middleware": "^6.1.15",
"graphql-shield": "^7.5.0",
"graphql-tools": "^8.2.0",
"handlebars": "^4.7.7",
"http-errors": "~1.6.3",
"http-proxy-agent": "^7.0.0",

View File

@@ -1,3 +0,0 @@
const mutationResolvers = {};
export default mutationResolvers;

View File

@@ -1,16 +0,0 @@
import AppAuthClient from '../../models/app-auth-client';
const deleteAppAuthClient = async (_parent, params, context) => {
context.currentUser.can('delete', 'App');
await AppAuthClient.query()
.delete()
.findOne({
id: params.input.id,
})
.throwIfNotFound();
return;
};
export default deleteAppAuthClient;

View File

@@ -1,18 +0,0 @@
const updateFlow = async (_parent, params, context) => {
context.currentUser.can('update', 'Flow');
let flow = await context.currentUser
.$relatedQuery('flows')
.findOne({
id: params.input.id,
})
.throwIfNotFound();
flow = await flow.$query().patchAndFetch({
name: params.input.name,
});
return flow;
};
export default updateFlow;

View File

@@ -1,62 +0,0 @@
import Role from '../../models/role.js';
import Permission from '../../models/permission.js';
import permissionCatalog from '../../helpers/permission-catalog.ee.js';
const updateRole = async (_parent, params, context) => {
context.currentUser.can('update', 'Role');
const { id, name, description, permissions } = params.input;
const role = await Role.query().findById(id).throwIfNotFound();
try {
const updatedRole = await Role.transaction(async (trx) => {
await role.$relatedQuery('permissions', trx).delete();
if (permissions?.length) {
const sanitizedPermissions = permissions
.filter((permission) => {
const { action, subject, conditions } = permission;
const relevantAction = permissionCatalog.actions.find(
(actionCatalogItem) => actionCatalogItem.key === action
);
const validSubject = relevantAction.subjects.includes(subject);
const validConditions = conditions.every((condition) => {
return !!permissionCatalog.conditions.find(
(conditionCatalogItem) => conditionCatalogItem.key === condition
);
});
return validSubject && validConditions;
})
.map((permission) => ({
...permission,
roleId: role.id,
}));
await Permission.query().insert(sanitizedPermissions);
}
await role.$query(trx).patch({
name,
description,
});
return await Role.query(trx)
.leftJoinRelated({
permissions: true,
})
.withGraphFetched({
permissions: true,
})
.findById(id);
});
return updatedRole;
} catch (err) {
throw new Error('The role could not be updated!');
}
};
export default updateRole;

View File

@@ -1,7 +0,0 @@
import mutationResolvers from './mutation-resolvers.js';
const resolvers = {
Mutation: mutationResolvers,
};
export default resolvers;

View File

@@ -1,324 +0,0 @@
type Query {
placeholderQuery(name: String): Boolean
}
type Mutation {
placeholderQuery(name: String): Boolean
}
type Trigger {
name: String
key: String
description: String
showWebhookUrl: Boolean
pollInterval: Int
type: String
substeps: [Substep]
}
type Action {
name: String
key: String
description: String
substeps: [Substep]
}
type Substep {
key: String
name: String
arguments: [SubstepArgument]
}
type SubstepArgument {
label: String
key: String
type: String
description: String
required: Boolean
variables: Boolean
options: [SubstepArgumentOption]
source: SubstepArgumentSource
additionalFields: SubstepArgumentAdditionalFields
dependsOn: [String]
fields: [SubstepArgument]
value: JSONObject
}
type SubstepArgumentOption {
label: String
value: JSONObject
}
type SubstepArgumentSource {
type: String
name: String
arguments: [SubstepArgumentSourceArgument]
}
type SubstepArgumentSourceArgument {
name: String
value: String
}
type SubstepArgumentAdditionalFields {
type: String
name: String
arguments: [SubstepArgumentAdditionalFieldsArgument]
}
type SubstepArgumentAdditionalFieldsArgument {
name: String
value: String
}
type App {
name: String
key: String
connectionCount: Int
flowCount: Int
iconUrl: String
docUrl: String
authDocUrl: String
primaryColor: String
supportsConnections: Boolean
auth: AppAuth
triggers: [Trigger]
actions: [Action]
connections: [Connection]
}
type AppAuth {
fields: [Field]
authenticationSteps: [AuthenticationStep]
sharedAuthenticationSteps: [AuthenticationStep]
reconnectionSteps: [ReconnectionStep]
sharedReconnectionSteps: [ReconnectionStep]
}
enum ArgumentEnumType {
integer
string
}
type AuthenticationStep {
type: String
name: String
arguments: [AuthenticationStepArgument]
}
type AuthenticationStepArgument {
name: String
value: String
type: ArgumentEnumType
properties: [AuthenticationStepProperty]
}
type AuthenticationStepProperty {
name: String
value: String
}
type Connection {
id: String
key: String
reconnectable: Boolean
appAuthClientId: String
formattedData: ConnectionData
verified: Boolean
app: App
createdAt: String
flowCount: Int
}
type ConnectionData {
screenName: String
}
type ExecutionStep {
id: String
executionId: String
stepId: String
step: Step
status: String
dataIn: JSONObject
dataOut: JSONObject
errorDetails: JSONObject
createdAt: String
updatedAt: String
}
type Field {
key: String
label: String
type: String
required: Boolean
readOnly: Boolean
value: String
placeholder: String
description: String
docUrl: String
clickToCopy: Boolean
options: [SubstepArgumentOption]
}
enum FlowStatus {
paused
published
draft
}
type Flow {
id: String
name: String
active: Boolean
steps: [Step]
createdAt: String
updatedAt: String
status: FlowStatus
}
type SamlAuthProvidersRoleMapping {
id: String
samlAuthProviderId: String
roleId: String
remoteRoleName: String
}
input UserRoleInput {
id: String
}
"""
The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
"""
scalar JSONObject
input PreviousStepInput {
id: String
}
type ReconnectionStep {
type: String
name: String
arguments: [ReconnectionStepArgument]
}
type ReconnectionStepArgument {
name: String
value: String
type: ArgumentEnumType
properties: [ReconnectionStepProperty]
}
type ReconnectionStepProperty {
name: String
value: String
}
type Step {
id: String
previousStepId: String
key: String
appKey: String
iconUrl: String
webhookUrl: String
type: StepEnumType
parameters: JSONObject
connection: Connection
flow: Flow
position: Int
status: String
executionSteps: [ExecutionStep]
}
input StepConnectionInput {
id: String
}
enum StepEnumType {
trigger
action
}
input StepFlowInput {
id: String
}
input StepInput {
id: String
previousStepId: String
key: String
appKey: String
connection: StepConnectionInput
flow: StepFlowInput
parameters: JSONObject
previousStep: PreviousStepInput
}
type User {
id: String
fullName: String
email: String
role: Role
permissions: [Permission]
createdAt: String
updatedAt: String
}
type Role {
id: String
name: String
key: String
description: String
isAdmin: Boolean
permissions: [Permission]
}
type PageInfo {
currentPage: Int!
totalPages: Int!
}
type ExecutionStepEdge {
node: ExecutionStep
}
type ExecutionStepConnection {
edges: [ExecutionStepEdge]
pageInfo: PageInfo
}
type License {
id: String
name: String
expireAt: String
verified: Boolean
}
type Permission {
id: String
action: String
subject: String
conditions: [String]
}
type Action {
label: String
key: String
subjects: [String]
}
type Condition {
key: String
label: String
}
type Subject {
label: String
key: String
}
schema {
query: Query
mutation: Mutation
}

View File

@@ -1,4 +1,3 @@
import { rule, shield } from 'graphql-shield';
import User from '../models/user.js';
import AccessToken from '../models/access-token.js';
@@ -47,19 +46,3 @@ export const authenticateUser = async (request, response, next) => {
return response.status(401).end();
}
};
const isAuthenticatedRule = rule()(isAuthenticated);
export const authenticationRules = {
Mutation: {
'*': isAuthenticatedRule,
},
};
const authenticationOptions = {
allowExternalErrors: true,
};
const authentication = shield(authenticationRules, authenticationOptions);
export default authentication;

View File

@@ -1,74 +0,0 @@
import { describe, it, expect } from 'vitest';
import { allow } from 'graphql-shield';
import { isAuthenticated, authenticationRules } from './authentication.js';
import { createUser } from '../../test/factories/user.js';
import createAuthTokenByUserId from '../helpers/create-auth-token-by-user-id.js';
describe('isAuthenticated', () => {
it('should return false if no token is provided', async () => {
const req = { headers: {} };
expect(await isAuthenticated(null, null, req)).toBe(false);
});
it('should return false if token is invalid', async () => {
const req = { headers: { authorization: 'invalidToken' } };
expect(await isAuthenticated(null, null, req)).toBe(false);
});
it('should return true if token is valid and there is a user', async () => {
const user = await createUser();
const token = await createAuthTokenByUserId(user.id);
const req = { headers: { authorization: token } };
expect(await isAuthenticated(null, null, req)).toBe(true);
});
it('should return false if token is valid and but there is no user', async () => {
const user = await createUser();
const token = await createAuthTokenByUserId(user.id);
await user.$query().delete();
const req = { headers: { authorization: token } };
expect(await isAuthenticated(null, null, req)).toBe(false);
});
});
describe('authentication rules', () => {
const getQueryAndMutationNames = (rules) => {
const queries = Object.keys(rules.Query || {});
const mutations = Object.keys(rules.Mutation || {});
return { queries, mutations };
};
const { queries, mutations } = getQueryAndMutationNames(authenticationRules);
if (queries.length) {
describe('for queries', () => {
queries.forEach((query) => {
it(`should apply correct rule for query: ${query}`, () => {
const ruleApplied = authenticationRules.Query[query];
if (query === '*') {
expect(ruleApplied.func).toBe(isAuthenticated);
} else {
expect(ruleApplied).toEqual(allow);
}
});
});
});
}
describe('for mutations', () => {
mutations.forEach((mutation) => {
it(`should apply correct rule for mutation: ${mutation}`, () => {
const ruleApplied = authenticationRules.Mutation[mutation];
if (mutation === '*') {
expect(ruleApplied.func).toBe(isAuthenticated);
} else {
expect(ruleApplied).toBe(allow);
}
});
});
});
});

View File

@@ -1,53 +0,0 @@
import path, { join } from 'path';
import { fileURLToPath } from 'url';
import { graphqlHTTP } from 'express-graphql';
import { loadSchemaSync } from '@graphql-tools/load';
import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader';
import { addResolversToSchema } from '@graphql-tools/schema';
import { applyMiddleware } from 'graphql-middleware';
import appConfig from '../config/app.js';
import logger from './logger.js';
import authentication from './authentication.js';
import * as Sentry from './sentry.ee.js';
import resolvers from '../graphql/resolvers.js';
import HttpError from '../errors/http.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const schema = loadSchemaSync(join(__dirname, '../graphql/schema.graphql'), {
loaders: [new GraphQLFileLoader()],
});
const schemaWithResolvers = addResolversToSchema({
schema,
resolvers,
});
const graphQLInstance = graphqlHTTP({
schema: applyMiddleware(
schemaWithResolvers,
authentication.generate(schemaWithResolvers)
),
graphiql: appConfig.isDev,
customFormatErrorFn: (error) => {
logger.error(error.path + ' : ' + error.message + '\n' + error.stack);
if (error.originalError instanceof HttpError) {
delete error.originalError.response;
}
Sentry.captureException(error, {
tags: { graphql: true },
extra: {
source: error.source?.body,
positions: error.positions,
path: error.path,
},
});
return error;
},
});
export default graphQLInstance;

View File

@@ -6,18 +6,8 @@ const stream = {
logger.http(message.substring(0, message.lastIndexOf('\n'))),
};
const registerGraphQLToken = () => {
morgan.token('graphql-query', (req) => {
if (req.body.query) {
return `\n GraphQL ${req.body.query}`;
}
});
};
registerGraphQLToken();
const morganMiddleware = morgan(
':method :url :status :res[content-length] - :response-time ms :graphql-query',
':method :url :status :res[content-length] - :response-time ms',
{ stream }
);

View File

@@ -17,7 +17,6 @@ export function init(app) {
integrations: [
app && new Sentry.Integrations.Http({ tracing: true }),
app && new Tracing.Integrations.Express({ app }),
app && new Tracing.Integrations.GraphQL(),
].filter(Boolean),
tracesSampleRate: 1.0,
});

View File

@@ -1,5 +1,4 @@
import { Router } from 'express';
import graphQLInstance from '../helpers/graphql-instance.js';
import webhooksRouter from './webhooks.js';
import paddleRouter from './paddle.ee.js';
import healthcheckRouter from './healthcheck.js';
@@ -23,7 +22,6 @@ import installationUsersRouter from './api/v1/installation/users.js';
const router = Router();
router.use('/graphql', graphQLInstance);
router.use('/webhooks', webhooksRouter);
router.use('/paddle', paddleRouter);
router.use('/healthcheck', healthcheckRouter);

View File

@@ -4,7 +4,6 @@
"license": "See LICENSE file",
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
"dependencies": {
"@apollo/client": "^3.6.9",
"@casl/ability": "^6.5.0",
"@casl/react": "^3.1.0",
"@dagrejs/dagre": "^1.1.2",
@@ -21,7 +20,6 @@
"@testing-library/user-event": "^12.1.10",
"clipboard-copy": "^4.0.1",
"compare-versions": "^4.1.3",
"graphql": "^15.6.0",
"lodash": "^4.17.21",
"luxon": "^2.3.1",
"mui-color-input": "^2.0.0",

View File

@@ -6,11 +6,11 @@ import Menu from '@mui/material/Menu';
import { Link } from 'react-router-dom';
import Can from 'components/Can';
import apolloClient from 'graphql/client';
import * as URLS from 'config/urls';
import useAuthentication from 'hooks/useAuthentication';
import useFormatMessage from 'hooks/useFormatMessage';
import useRevokeAccessToken from 'hooks/useRevokeAccessToken';
function AccountDropdownMenu(props) {
const formatMessage = useFormatMessage();
const authentication = useAuthentication();
@@ -23,7 +23,6 @@ function AccountDropdownMenu(props) {
await revokeAccessTokenMutation.mutateAsync();
authentication.removeToken();
await apolloClient.clearStore();
onClose();
navigate(URLS.LOGIN);
};

View File

@@ -7,7 +7,6 @@ import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import CircularProgress from '@mui/material/CircularProgress';
import { ApolloError } from '@apollo/client';
import { FieldPropType } from 'propTypes/propTypes';
import useFormatMessage from 'hooks/useFormatMessage';
@@ -89,7 +88,9 @@ function AdminApplicationAuthClientDialog(props) {
}
AdminApplicationAuthClientDialog.propTypes = {
error: PropTypes.instanceOf(ApolloError),
error: PropTypes.shape({
message: PropTypes.string,
}),
onClose: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
loading: PropTypes.bool.isRequired,

View File

@@ -1,34 +0,0 @@
import { ApolloProvider as BaseApolloProvider } from '@apollo/client';
import * as React from 'react';
import { mutateAndGetClient } from 'graphql/client';
import useAuthentication from 'hooks/useAuthentication';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
const ApolloProvider = (props) => {
const enqueueSnackbar = useEnqueueSnackbar();
const authentication = useAuthentication();
const onError = React.useCallback(
(message) => {
enqueueSnackbar(message, {
variant: 'error',
SnackbarProps: {
'data-test': 'snackbar-error',
},
});
},
[enqueueSnackbar],
);
const client = React.useMemo(() => {
return mutateAndGetClient({
onError,
token: authentication.token,
});
}, [onError, authentication]);
return <BaseApolloProvider client={client} {...props} />;
};
export default ApolloProvider;

View File

@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom';
import * as URLS from 'config/urls';
import ConfirmationDialog from 'components/ConfirmationDialog';
import apolloClient from 'graphql/client';
import useAuthentication from 'hooks/useAuthentication';
import useFormatMessage from 'hooks/useFormatMessage';
import useCurrentUser from 'hooks/useCurrentUser';
@@ -27,8 +26,6 @@ function DeleteAccountDialog(props) {
authentication.removeToken();
await apolloClient.clearStore();
navigate(URLS.LOGIN);
}, [deleteCurrentUser, authentication, navigate]);

View File

@@ -1,5 +1,4 @@
import { useEffect, useCallback, createContext, useRef, useState } from 'react';
import { useMutation } from '@apollo/client';
import { useQueryClient } from '@tanstack/react-query';
import { FlowPropType } from 'propTypes/propTypes';
import ReactFlow, { useNodesState, useEdgesState } from 'reactflow';

View File

@@ -1,6 +1,5 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation } from '@apollo/client';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import LoadingButton from '@mui/lab/LoadingButton';

View File

@@ -16,7 +16,6 @@ const computeUrl = (url, backendUrl) => {
const config = {
baseUrl: process.env.REACT_APP_BASE_URL,
graphqlUrl: computeUrl('/graphql', backendUrl),
restApiUrl: computeUrl('/api', backendUrl),
supportEmailAddress: 'support@automatisch.io',
};

View File

@@ -1,13 +0,0 @@
import { InMemoryCache } from '@apollo/client';
const cache = new InMemoryCache({
typePolicies: {
App: {
keyFields: ['key'],
},
Mutation: {
mutationType: true,
fields: {},
},
},
});
export default cache;

View File

@@ -1,31 +0,0 @@
import { ApolloClient } from '@apollo/client';
import cache from './cache';
import createLink from './link';
import appConfig from 'config/app';
const client = new ApolloClient({
cache,
link: createLink({ uri: appConfig.graphqlUrl }),
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
},
},
});
export function mutateAndGetClient(options) {
const { onError, token } = options;
const link = createLink({
uri: appConfig.graphqlUrl,
token,
onError,
});
client.setLink(link);
return client;
}
export default client;

View File

@@ -1,46 +0,0 @@
import { HttpLink, from } from '@apollo/client';
import { onError } from '@apollo/client/link/error';
import { setItem } from 'helpers/storage';
import * as URLS from 'config/urls';
const createHttpLink = (options) => {
const { uri, token } = options;
const headers = {
authorization: token,
};
return new HttpLink({ uri, headers });
};
const NOT_AUTHORISED = 'Not Authorised!';
const createErrorLink = (callback) =>
onError(({ graphQLErrors, networkError, operation }) => {
const context = operation.getContext();
const autoSnackbar = context.autoSnackbar ?? true;
if (graphQLErrors)
graphQLErrors.forEach(({ message, locations, path }) => {
if (autoSnackbar) {
callback?.(message);
}
console.error(
`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`,
);
if (message === NOT_AUTHORISED) {
setItem('token', '');
if (window.location.pathname !== URLS.LOGIN) {
window.location.href = URLS.LOGIN;
}
}
});
if (networkError) {
if (autoSnackbar) {
callback?.(networkError.toString());
}
console.error(`[Network error]: ${networkError}`);
}
});
const noop = () => {};
const createLink = (options) => {
const { uri, onError = noop, token } = options;
const httpOptions = { uri, token };
return from([createErrorLink(onError), createHttpLink(httpOptions)]);
};
export default createLink;

View File

@@ -1,32 +0,0 @@
const makeEmptyData = () => {
return {
edges: [],
pageInfo: {
currentPage: 1,
totalPages: 1,
},
};
};
function offsetLimitPagination(keyArgs = false) {
return {
keyArgs,
merge(existing, incoming, { args }) {
if (!existing) {
existing = makeEmptyData();
}
if (!incoming || incoming === null) return existing;
const existingEdges = existing?.edges || [];
const incomingEdges = incoming.edges || [];
if (args) {
const newEdges = [...existingEdges, ...incomingEdges];
return {
pageInfo: incoming.pageInfo,
edges: newEdges,
};
} else {
return existing;
}
},
};
}
export default offsetLimitPagination;

View File

@@ -1,69 +0,0 @@
import { gql } from '@apollo/client';
export const GET_DYNAMIC_FIELDS = gql`
query GetDynamicFields(
$stepId: String!
$key: String!
$parameters: JSONObject
) {
getDynamicFields(stepId: $stepId, key: $key, parameters: $parameters) {
label
key
type
required
description
variables
dependsOn
value
options {
label
value
}
source {
type
name
arguments {
name
value
}
}
additionalFields {
type
name
arguments {
name
value
}
}
fields {
label
key
type
required
description
variables
value
dependsOn
options {
label
value
}
source {
type
name
arguments {
name
value
}
}
additionalFields {
type
name
arguments {
name
value
}
}
}
}
}
`;

View File

@@ -3,7 +3,6 @@ import { Settings } from 'luxon';
import ThemeProvider from 'components/ThemeProvider';
import IntlProvider from 'components/IntlProvider';
import ApolloProvider from 'components/ApolloProvider';
import SnackbarProvider from 'components/SnackbarProvider';
import MetadataProvider from 'components/MetadataProvider';
import { AuthenticationProvider } from 'contexts/Authentication';
@@ -23,13 +22,11 @@ root.render(
<SnackbarProvider>
<AuthenticationProvider>
<QueryClientProvider>
<ApolloProvider>
<IntlProvider>
<ThemeProvider>
<MetadataProvider>{routes}</MetadataProvider>
</ThemeProvider>
</IntlProvider>
</ApolloProvider>
<IntlProvider>
<ThemeProvider>
<MetadataProvider>{routes}</MetadataProvider>
</ThemeProvider>
</IntlProvider>
</QueryClientProvider>
</AuthenticationProvider>
</SnackbarProvider>