Compare commits

..

2 Commits

Author SHA1 Message Date
Ali BARIN
6bafdf5257 refactor(http-client): inherit interceptors from parent instance 2024-07-25 07:52:27 +00:00
Ali BARIN
ade6282013 feat(formatter/text): add regex support in replace transfomer 2024-07-25 07:52:21 +00:00
11 changed files with 174 additions and 259 deletions

View File

@@ -52,7 +52,7 @@ const appConfig = {
isDev: appEnv === 'development', isDev: appEnv === 'development',
isTest: appEnv === 'test', isTest: appEnv === 'test',
isProd: appEnv === 'production', isProd: appEnv === 'production',
version: '0.13.0', version: '0.12.0',
postgresDatabase: process.env.POSTGRES_DATABASE || 'automatisch_development', postgresDatabase: process.env.POSTGRES_DATABASE || 'automatisch_development',
postgresSchema: process.env.POSTGRES_SCHEMA || 'public', postgresSchema: process.env.POSTGRES_SCHEMA || 'public',
postgresPort: parseInt(process.env.POSTGRES_PORT || '5432'), postgresPort: parseInt(process.env.POSTGRES_PORT || '5432'),

View File

@@ -10,7 +10,7 @@ describe('GET /api/v1/automatisch/version', () => {
const expectedPayload = { const expectedPayload = {
data: { data: {
version: '0.13.0', version: '0.12.0',
}, },
meta: { meta: {
count: 1, count: 1,

View File

@@ -3,100 +3,70 @@ import { HttpsProxyAgent } from 'https-proxy-agent';
import { HttpProxyAgent } from 'http-proxy-agent'; import { HttpProxyAgent } from 'http-proxy-agent';
import appConfig from '../config/app.js'; import appConfig from '../config/app.js';
export function createInstance(customConfig = {}, { requestInterceptor, responseErrorInterceptor } = {}) { const config = axios.defaults;
const config = { const httpProxyUrl = appConfig.httpProxy;
...axios.defaults, const httpsProxyUrl = appConfig.httpsProxy;
...customConfig const supportsProxy = httpProxyUrl || httpsProxyUrl;
}; const noProxyEnv = appConfig.noProxy;
const httpProxyUrl = appConfig.httpProxy; const noProxyHosts = noProxyEnv ? noProxyEnv.split(',').map(host => host.trim()) : [];
const httpsProxyUrl = appConfig.httpsProxy;
const supportsProxy = httpProxyUrl || httpsProxyUrl;
const noProxyEnv = appConfig.noProxy;
const noProxyHosts = noProxyEnv ? noProxyEnv.split(',').map(host => host.trim()) : [];
if (supportsProxy) { if (supportsProxy) {
if (httpProxyUrl) { if (httpProxyUrl) {
config.httpAgent = new HttpProxyAgent(httpProxyUrl); config.httpAgent = new HttpProxyAgent(httpProxyUrl);
}
if (httpsProxyUrl) {
config.httpsAgent = new HttpsProxyAgent(httpsProxyUrl);
}
config.proxy = false;
} }
const instance = axios.create(config); if (httpsProxyUrl) {
config.httpsAgent = new HttpsProxyAgent(httpsProxyUrl);
function shouldSkipProxy(hostname) {
return noProxyHosts.some(noProxyHost => {
return hostname.endsWith(noProxyHost) || hostname === noProxyHost;
});
};
/**
* The interceptors are executed in the reverse order they are added.
*/
instance.interceptors.request.use(
function skipProxyIfInNoProxy(requestConfig) {
const hostname = new URL(requestConfig.baseURL).hostname;
if (supportsProxy && shouldSkipProxy(hostname)) {
requestConfig.httpAgent = undefined;
requestConfig.httpsAgent = undefined;
}
return requestConfig;
},
(error) => Promise.reject(error)
);
// not always we have custom request interceptors
if (requestInterceptor) {
instance.interceptors.request.use(
async function customInterceptor(requestConfig) {
let newRequestConfig = requestConfig;
for (const interceptor of requestInterceptor) {
newRequestConfig = await interceptor(newRequestConfig);
}
return newRequestConfig;
}
);
} }
instance.interceptors.request.use( config.proxy = false;
function removeBaseUrlForAbsoluteUrls(requestConfig) {
/**
* If the URL is an absolute URL, we remove its origin out of the URL
* and set it as baseURL. This lets us streamlines the requests made by Automatisch
* and requests made by app integrations.
*/
try {
const url = new URL(requestConfig.url);
requestConfig.baseURL = url.origin;
requestConfig.url = url.pathname + url.search;
return requestConfig;
} catch (err) {
return requestConfig;
}
},
(error) => Promise.reject(error)
);
// not always we have custom response error interceptor
if (responseErrorInterceptor) {
instance.interceptors.response.use(
(response) => response,
responseErrorInterceptor
);
}
return instance;
} }
const defaultInstance = createInstance(); const axiosWithProxyInstance = axios.create(config);
export default defaultInstance; function shouldSkipProxy(hostname) {
return noProxyHosts.some(noProxyHost => {
return hostname.endsWith(noProxyHost) || hostname === noProxyHost;
});
};
/**
* The interceptors are executed in the reverse order they are added.
*/
axiosWithProxyInstance.interceptors.request.use(
function skipProxyIfInNoProxy(requestConfig) {
const hostname = new URL(requestConfig.baseURL).hostname;
if (supportsProxy && shouldSkipProxy(hostname)) {
requestConfig.httpAgent = undefined;
requestConfig.httpsAgent = undefined;
}
return requestConfig;
},
undefined,
{ synchronous: true }
);
axiosWithProxyInstance.interceptors.request.use(
function removeBaseUrlForAbsoluteUrls(requestConfig) {
/**
* If the URL is an absolute URL, we remove its origin out of the URL
* and set it as baseURL. This lets us streamlines the requests made by Automatisch
* and requests made by app integrations.
*/
try {
const url = new URL(requestConfig.url);
requestConfig.baseURL = url.origin;
requestConfig.url = url.pathname + url.search;
return requestConfig;
} catch {
return requestConfig;
}
},
undefined,
{ synchronous: true}
);
export default axiosWithProxyInstance;

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, it, expect, vi } from 'vitest'; import { beforeEach, describe, it, expect, vi } from 'vitest';
describe('Custom default axios with proxy', () => { describe('Axios with proxy', () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
}); });
@@ -23,13 +23,7 @@ describe('Custom default axios with proxy', () => {
expect(secondRequestInterceptor.fulfilled.name).toBe('removeBaseUrlForAbsoluteUrls'); expect(secondRequestInterceptor.fulfilled.name).toBe('removeBaseUrlForAbsoluteUrls');
}); });
it('should throw with invalid url (consisting of path alone)', async () => { describe('skipProxyIfInNoProxy', () => {
const axios = (await import('./axios-with-proxy.js')).default;
await expect(() => axios('/just-a-path')).rejects.toThrowError('Invalid URL');
});
describe('with skipProxyIfInNoProxy interceptor', () => {
let appConfig, axios; let appConfig, axios;
beforeEach(async() => { beforeEach(async() => {
appConfig = (await import('../config/app.js')).default; appConfig = (await import('../config/app.js')).default;
@@ -73,7 +67,7 @@ describe('Custom default axios with proxy', () => {
}); });
}); });
describe('with removeBaseUrlForAbsoluteUrls interceptor', () => { describe('removeBaseUrlForAbsoluteUrls', () => {
let axios; let axios;
beforeEach(async() => { beforeEach(async() => {
axios = (await import('./axios-with-proxy.js')).default; axios = (await import('./axios-with-proxy.js')).default;
@@ -122,48 +116,4 @@ describe('Custom default axios with proxy', () => {
expect(interceptedRequestConfig.url).toBe('/path?query=1'); expect(interceptedRequestConfig.url).toBe('/path?query=1');
}); });
}); });
describe('with extra requestInterceptors', () => {
it('should apply extra request interceptors in the middle', async () => {
const { createInstance } = await import('./axios-with-proxy.js');
const interceptor = (config) => {
config.test = true;
return config;
}
const instance = createInstance({}, {
requestInterceptor: [
interceptor
]
});
const requestInterceptors = instance.interceptors.request.handlers;
const customInterceptor = requestInterceptors[1].fulfilled;
expect(requestInterceptors.length).toBe(3);
await expect(customInterceptor({})).resolves.toStrictEqual({ test: true });
});
it('should work with a custom interceptor setting a baseURL and a request to path', async () => {
const { createInstance } = await import('./axios-with-proxy.js');
const interceptor = (config) => {
config.baseURL = 'http://localhost';
return config;
}
const instance = createInstance({}, {
requestInterceptor: [
interceptor
]
});
try {
await instance.get('/just-a-path');
} catch (error) {
expect(error.config.baseURL).toBe('http://localhost');
expect(error.config.url).toBe('/just-a-path');
}
})
});
}); });

View File

@@ -11,7 +11,6 @@ export default function computeParameters(parameters, executionSteps) {
const computedValue = parts const computedValue = parts
.map((part) => { .map((part) => {
const isVariable = part.match(variableRegExp); const isVariable = part.match(variableRegExp);
if (isVariable) { if (isVariable) {
const stepIdAndKeyPath = part.replace(/{{step.|}}/g, ''); const stepIdAndKeyPath = part.replace(/{{step.|}}/g, '');
const [stepId, ...keyPaths] = stepIdAndKeyPath.split('.'); const [stepId, ...keyPaths] = stepIdAndKeyPath.split('.');
@@ -21,32 +20,17 @@ export default function computeParameters(parameters, executionSteps) {
}); });
const data = executionStep?.dataOut; const data = executionStep?.dataOut;
const dataValue = get(data, keyPath); const dataValue = get(data, keyPath);
// Covers both arrays and objects
if (typeof dataValue === 'object') {
return JSON.stringify(dataValue);
}
return dataValue; return dataValue;
} }
return part; return part;
}).join(''); })
.join('');
// challenge the input to see if it is stringifies object or array return {
try { ...result,
const parsedValue = JSON.parse(computedValue); [key]: computedValue,
};
return {
...result,
[key]: parsedValue,
};
} catch (error) {
return {
...result,
[key]: computedValue,
};
}
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {

View File

@@ -1,43 +1,65 @@
import HttpError from '../../errors/http.js'; import HttpError from '../../errors/http.js';
import { createInstance } from '../axios-with-proxy.js'; import axios from '../axios-with-proxy.js';
// Mutates the `toInstance` by copying the request interceptors from `fromInstance`
const copyRequestInterceptors = (fromInstance, toInstance) => {
// Copy request interceptors
fromInstance.interceptors.request.forEach(interceptor => {
toInstance.interceptors.request.use(
interceptor.fulfilled,
interceptor.rejected,
{
synchronous: interceptor.synchronous,
runWhen: interceptor.runWhen
}
);
});
}
export default function createHttpClient({ $, baseURL, beforeRequest = [] }) { export default function createHttpClient({ $, baseURL, beforeRequest = [] }) {
async function interceptResponseError(error) { const instance = axios.create({
const { config, response } = error; baseURL,
// Do not destructure `status` from `error.response` because it might not exist });
const status = response?.status;
if ( // 1. apply the beforeRequest functions from the app
// TODO: provide a `shouldRefreshToken` function in the app instance.interceptors.request.use((requestConfig) => {
(status === 401 || status === 403) && const result = beforeRequest.reduce((newConfig, beforeRequestFunc) => {
$.app.auth && return beforeRequestFunc($, newConfig);
$.app.auth.refreshToken && }, requestConfig);
!$.app.auth.isRefreshTokenRequested
) {
$.app.auth.isRefreshTokenRequested = true;
await $.app.auth.refreshToken($);
// retry the previous request before the expired token error return result;
const newResponse = await instance.request(config); });
$.app.auth.isRefreshTokenRequested = false;
return newResponse; // 2. inherit the request inceptors from the parent instance
copyRequestInterceptors(axios, instance);
instance.interceptors.response.use(
(response) => response,
async (error) => {
const { config, response } = error;
// Do not destructure `status` from `error.response` because it might not exist
const status = response?.status;
if (
// TODO: provide a `shouldRefreshToken` function in the app
(status === 401 || status === 403) &&
$.app.auth &&
$.app.auth.refreshToken &&
!$.app.auth.isRefreshTokenRequested
) {
$.app.auth.isRefreshTokenRequested = true;
await $.app.auth.refreshToken($);
// retry the previous request before the expired token error
const newResponse = await instance.request(config);
$.app.auth.isRefreshTokenRequested = false;
return newResponse;
}
throw new HttpError(error);
} }
);
throw new HttpError(error);
};
const instance = createInstance(
{
baseURL,
},
{
requestInterceptor: beforeRequest.map((originalBeforeRequest) => {
return async (requestConfig) => await originalBeforeRequest($, requestConfig);
}),
responseErrorInterceptor: interceptResponseError,
}
)
return instance; return instance;
} }

View File

@@ -63,8 +63,6 @@ export default async (flowId, request, response) => {
}); });
if (testRun) { if (testRun) {
response.status(204).end();
// in case of testing, we do not process the whole process. // in case of testing, we do not process the whole process.
continue; continue;
} }
@@ -76,12 +74,6 @@ export default async (flowId, request, response) => {
executionId, executionId,
}); });
if (actionStep.appKey === 'filter' && !actionExecutionStep.dataOut) {
response.status(422).end();
break;
}
if (actionStep.key === 'respondWith' && !response.headersSent) { if (actionStep.key === 'respondWith' && !response.headersSent) {
const { headers, statusCode, body } = actionExecutionStep.dataOut; const { headers, statusCode, body } = actionExecutionStep.dataOut;

View File

@@ -14,7 +14,7 @@ connection in Automatisch. If any of the steps are outdated, please let us know!
7. Click on the **Select all** and then click on the **Create** button. 7. Click on the **Select all** and then click on the **Create** button.
8. Now, copy your **API key secret** and paste the key into the **API Key** field in Automatisch. 8. Now, copy your **API key secret** and paste the key into the **API Key** field in Automatisch.
9. Write any screen name to be displayed in Automatisch. 9. Write any screen name to be displayed in Automatisch.
10. You can find your project ID next to your project name. Paste the id into **Project ID** field in Automatisch. 10. You can find your project ID next to your project name. Paste the id into **Project ID** field in Automatsich.
11. If you are using self-hosted Appwrite project, you can paste the instance url into **Appwrite instance URL** field in Automatisch. 11. If you are using self-hosted Appwrite project, you can paste the instace url into **Appwrite instance URL** field in Automatisch.
12. Fill the host name field with the hostname of your instance URL. It's either `cloud.appwrite.io` or hostname of your instance URL. 12. Fill the host name field with the hostname of your instance URL. It's either `cloud.appwrite.io` or hostname of your instance URL.
13. Start using Appwrite integration with Automatisch! 13. Start using Appwrite integration with Automatisch!

View File

@@ -1,7 +1,7 @@
--- ---
favicon: /favicons/appwrite.svg favicon: /favicons/appwrite.svg
items: items:
- name: New documents - name: New documets
desc: Triggers when a new document is created. desc: Triggers when a new document is created.
--- ---

View File

@@ -3,6 +3,7 @@ import * as React from 'react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { useFormContext, useWatch } from 'react-hook-form'; import { useFormContext, useWatch } from 'react-hook-form';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import Divider from '@mui/material/Divider'; import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
@@ -11,7 +12,6 @@ import AddIcon from '@mui/icons-material/Add';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import InputCreator from 'components/InputCreator'; import InputCreator from 'components/InputCreator';
import { EditorContext } from 'contexts/Editor'; import { EditorContext } from 'contexts/Editor';
import { Grid } from '@mui/material';
const createGroupItem = () => ({ const createGroupItem = () => ({
key: '', key: '',
@@ -122,7 +122,7 @@ function FilterConditions(props) {
<React.Fragment> <React.Fragment>
<Stack sx={{ width: '100%' }} direction="column" spacing={2} mt={2}> <Stack sx={{ width: '100%' }} direction="column" spacing={2} mt={2}>
{groups?.map((group, groupIndex) => ( {groups?.map((group, groupIndex) => (
<React.Fragment key={groupIndex}> <>
{groupIndex !== 0 && <Divider />} {groupIndex !== 0 && <Divider />}
<Typography variant="subtitle2" gutterBottom> <Typography variant="subtitle2" gutterBottom>
@@ -133,16 +133,19 @@ function FilterConditions(props) {
</Typography> </Typography>
{group?.and?.map((groupItem, groupItemIndex) => ( {group?.and?.map((groupItem, groupItemIndex) => (
<Grid container key={`item-${groupItem.id}`}> <Stack direction="row" spacing={2} key={`item-${groupItem.id}`}>
<Grid <Stack
container direction={{ xs: 'column', sm: 'row' }}
spacing={2} spacing={{ xs: 2 }}
item sx={{ display: 'flex', flex: 1 }}
xs={12}
sm={11}
sx={{ order: { xs: 2, sm: 1 } }}
> >
<Grid item xs={12} md={4}> <Box
sx={{
display: 'flex',
flex: '1 0 0px',
maxWidth: ['100%', '33%'],
}}
>
<InputCreator <InputCreator
schema={createStringArgument({ schema={createStringArgument({
key: `or.${groupIndex}.and.${groupItemIndex}.key`, key: `or.${groupIndex}.and.${groupItemIndex}.key`,
@@ -152,8 +155,15 @@ function FilterConditions(props) {
stepId={stepId} stepId={stepId}
disabled={editorContext.readOnly} disabled={editorContext.readOnly}
/> />
</Grid> </Box>
<Grid item xs={12} md={4}>
<Box
sx={{
display: 'flex',
flex: '1 0 0px',
maxWidth: ['100%', '33%'],
}}
>
<InputCreator <InputCreator
schema={createDropdownArgument({ schema={createDropdownArgument({
key: `or.${groupIndex}.and.${groupItemIndex}.operator`, key: `or.${groupIndex}.and.${groupItemIndex}.operator`,
@@ -164,8 +174,15 @@ function FilterConditions(props) {
stepId={stepId} stepId={stepId}
disabled={editorContext.readOnly} disabled={editorContext.readOnly}
/> />
</Grid> </Box>
<Grid item xs={12} md={4}>
<Box
sx={{
display: 'flex',
flex: '1 0 0px',
maxWidth: ['100%', '33%'],
}}
>
<InputCreator <InputCreator
schema={createStringArgument({ schema={createStringArgument({
key: `or.${groupIndex}.and.${groupItemIndex}.value`, key: `or.${groupIndex}.and.${groupItemIndex}.value`,
@@ -175,28 +192,18 @@ function FilterConditions(props) {
stepId={stepId} stepId={stepId}
disabled={editorContext.readOnly} disabled={editorContext.readOnly}
/> />
</Grid> </Box>
</Grid> </Stack>
<Grid item xs={12} sm={1} sx={{ order: { xs: 1, sm: 2 } }}>
<Stack justifyContent="center" alignItems="flex-end"> <IconButton
<IconButton size="small"
size="small" edge="start"
edge="start" onClick={() => removeGroupItem(groupIndex, groupItemIndex)}
onClick={() => sx={{ width: 61, height: 61 }}
removeGroupItem(groupIndex, groupItemIndex) >
} <RemoveIcon />
sx={{ </IconButton>
width: 40, </Stack>
height: 40,
mb: { xs: 2, sm: 0 },
}}
disabled={groups.length === 1 && group.and.length === 1}
>
<RemoveIcon />
</IconButton>
</Stack>
</Grid>
</Grid>
))} ))}
<Stack spacing={1} direction="row"> <Stack spacing={1} direction="row">
@@ -220,7 +227,7 @@ function FilterConditions(props) {
</IconButton> </IconButton>
)} )}
</Stack> </Stack>
</React.Fragment> </>
))} ))}
</Stack> </Stack>
</React.Fragment> </React.Fragment>

View File

@@ -19,13 +19,7 @@ const process = ({ data, parentKey, index, parentLabel = '' }) => {
const value = joinBy('.', parentKey, index?.toString(), name); const value = joinBy('.', parentKey, index?.toString(), name);
if (Array.isArray(sampleValue)) { if (Array.isArray(sampleValue)) {
const arrayItself = { return sampleValue.flatMap((item, index) =>
label,
value,
sampleValue: JSON.stringify(sampleValue),
};
const arrayItems = sampleValue.flatMap((item, index) =>
process({ process({
data: item, data: item,
parentKey: value, parentKey: value,
@@ -33,9 +27,6 @@ const process = ({ data, parentKey, index, parentLabel = '' }) => {
parentLabel: label, parentLabel: label,
}), }),
); );
// TODO: remove spreading
return [arrayItself, ...arrayItems];
} }
if (typeof sampleValue === 'object' && sampleValue !== null) { if (typeof sampleValue === 'object' && sampleValue !== null) {
@@ -45,7 +36,6 @@ const process = ({ data, parentKey, index, parentLabel = '' }) => {
parentLabel: label, parentLabel: label,
}); });
} }
return [ return [
{ {
label, label,