mirror of
https://github.com/ToolJet/ToolJet
synced 2026-04-21 21:47:17 +00:00
* apiendpoint component used for rendering openapi spec value are persisted * Fix/stripe old component (#13737) * fix: integrated old component * removed log --------- Co-authored-by: Devanshu Gupta <devanshuguptaknp@gmail.com>
572 lines
18 KiB
JavaScript
572 lines
18 KiB
JavaScript
import React, { useEffect, useState } from 'react';
|
|
import { openapiService } from '@/_services';
|
|
import Select from '@/_ui/Select';
|
|
import { queryManagerSelectComponentStyle } from '@/_ui/Select/styles';
|
|
import DOMPurify from 'dompurify';
|
|
import { ToolTip } from '@/_components';
|
|
import CodeHinter from '@/AppBuilder/CodeEditor';
|
|
import { withTranslation } from 'react-i18next';
|
|
import { isEmpty } from 'lodash';
|
|
import PropTypes from 'prop-types';
|
|
import SolidIcons from '@/_ui/Icon/SolidIcons';
|
|
|
|
const operationColorMapping = {
|
|
get: 'azure',
|
|
post: 'green',
|
|
delete: 'red',
|
|
put: 'yellow',
|
|
};
|
|
|
|
const extractSchemaProperties = (schema) => {
|
|
if (!schema) return {};
|
|
|
|
if (schema.properties) {
|
|
return schema.properties;
|
|
}
|
|
|
|
// Handle allOf - merge all properties
|
|
if (schema.allOf) {
|
|
return schema.allOf.reduce((acc, subSchema) => {
|
|
const props = extractSchemaProperties(subSchema);
|
|
return { ...acc, ...props };
|
|
}, {});
|
|
}
|
|
|
|
if (schema.oneOf) {
|
|
return schema.oneOf.reduce((acc, subSchema) => {
|
|
const props = extractSchemaProperties(subSchema);
|
|
return { ...acc, ...props };
|
|
}, {});
|
|
}
|
|
|
|
// Handle anyOf - similar to oneOf
|
|
if (schema.anyOf) {
|
|
return schema.anyOf.reduce((acc, subSchema) => {
|
|
const props = extractSchemaProperties(subSchema);
|
|
return { ...acc, ...props };
|
|
}, {});
|
|
}
|
|
|
|
if (schema.$ref) {
|
|
console.warn('$ref found in schema, which may need to be resolved:', schema.$ref);
|
|
return {};
|
|
}
|
|
|
|
return {};
|
|
};
|
|
|
|
const ApiEndpointInput = (props) => {
|
|
const [loadingSpec, setLoadingSpec] = useState(true);
|
|
const [options, setOptions] = useState(props.options);
|
|
const [specJson, setSpecJson] = useState(null);
|
|
const [operationParams, setOperationParams] = useState({});
|
|
|
|
// Check if specUrl is an object (multiple specs) or string (single spec)
|
|
const isMultiSpec = typeof props.specUrl === 'object' && !Array.isArray(props.specUrl);
|
|
const [selectedSpecType, setSelectedSpecType] = useState(isMultiSpec ? Object.keys(props.specUrl)[0] || '' : null);
|
|
|
|
const fetchOpenApiSpec = (specUrlOrType) => {
|
|
setLoadingSpec(true);
|
|
|
|
const url = isMultiSpec ? props.specUrl[specUrlOrType] : props.specUrl;
|
|
|
|
openapiService
|
|
.fetchSpecFromUrl(url)
|
|
.then((response) => response.text())
|
|
.then((text) => {
|
|
const format = url.endsWith('.json') ? 'json' : 'yaml';
|
|
openapiService.parseOpenapiSpec(text, format).then((data) => {
|
|
setSpecJson(data);
|
|
|
|
if (isMultiSpec) {
|
|
// MODIFIED: Retain existing values instead of clearing them
|
|
const currentParams = options?.params || {
|
|
path: {},
|
|
query: {},
|
|
request: {},
|
|
};
|
|
|
|
// Keep existing values if the operation/path still exists in the new spec
|
|
let newOperation = options?.operation;
|
|
let newPath = options?.path;
|
|
let newSelectedOperation = null;
|
|
|
|
// Validate if the current operation/path exists in the new spec
|
|
if (newPath && newOperation && data?.paths?.[newPath]?.[newOperation]) {
|
|
newSelectedOperation = data.paths[newPath][newOperation];
|
|
} else {
|
|
// Only clear if the operation/path doesn't exist in the new spec
|
|
newOperation = null;
|
|
newPath = null;
|
|
}
|
|
|
|
const newOptions = {
|
|
...options,
|
|
operation: newOperation,
|
|
path: newPath,
|
|
selectedOperation: newSelectedOperation,
|
|
params: currentParams, // Retain existing params
|
|
specType: specUrlOrType,
|
|
};
|
|
|
|
setOptions(newOptions);
|
|
props.optionsChanged(newOptions);
|
|
}
|
|
|
|
setLoadingSpec(false);
|
|
});
|
|
});
|
|
};
|
|
|
|
const getOperationKey = (operation, path) => {
|
|
return `${operation}_${path}`;
|
|
};
|
|
|
|
const changeOperation = (value) => {
|
|
const operation = value.split('/', 2)[0];
|
|
const path = value.substring(value.indexOf('/'));
|
|
|
|
if (options.operation && options.path) {
|
|
const currentOperationKey = getOperationKey(options.operation, options.path);
|
|
setOperationParams((prevState) => ({
|
|
...prevState,
|
|
[currentOperationKey]: options.params,
|
|
}));
|
|
}
|
|
|
|
const newOperationKey = getOperationKey(operation, path);
|
|
const savedParams = operationParams[newOperationKey] || {
|
|
path: {},
|
|
query: {},
|
|
request: {},
|
|
};
|
|
|
|
const newOptions = {
|
|
...options,
|
|
path,
|
|
operation,
|
|
selectedOperation: specJson.paths[path][operation],
|
|
params: savedParams,
|
|
};
|
|
|
|
setOptions(newOptions);
|
|
props.optionsChanged(newOptions);
|
|
};
|
|
|
|
const changeParam = (paramType, paramName, value) => {
|
|
if (value === '') {
|
|
removeParam(paramType, paramName);
|
|
} else {
|
|
let parsedValue = value;
|
|
|
|
if (paramType === 'request') {
|
|
try {
|
|
parsedValue = JSON.parse(value);
|
|
} catch (e) {
|
|
console.error(`Invalid JSON for request param "${paramName}":`, e);
|
|
parsedValue = value;
|
|
}
|
|
}
|
|
|
|
const newOptions = {
|
|
...options,
|
|
params: {
|
|
...options.params,
|
|
[paramType]: {
|
|
...options.params[paramType],
|
|
[paramName]: parsedValue,
|
|
},
|
|
},
|
|
};
|
|
setOptions(newOptions);
|
|
props.optionsChanged(newOptions);
|
|
}
|
|
};
|
|
|
|
const removeParam = (paramType, paramName) => {
|
|
const newOptions = JSON.parse(JSON.stringify(options));
|
|
const newParams = { ...newOptions.params };
|
|
const newParamType = { ...newParams[paramType] };
|
|
|
|
delete newParamType[paramName];
|
|
|
|
newParams[paramType] = newParamType;
|
|
newOptions.params = newParams;
|
|
setOptions(newOptions);
|
|
props.optionsChanged(newOptions);
|
|
};
|
|
|
|
const renderOperationOption = (data) => {
|
|
const path = data.displayLabel || data.value.substring(data.value.indexOf('/'));
|
|
const operation = data.operation;
|
|
const summary = data.summary;
|
|
const isSelected = data.isSelected;
|
|
|
|
if (path && operation) {
|
|
return (
|
|
<div className="d-flex align-items-start">
|
|
<div className="me-2" style={{ minWidth: '60px' }}>
|
|
<span className={`badge bg-${operationColorMapping[operation]}`}>{operation.toUpperCase()}</span>
|
|
</div>
|
|
<div className="flex-grow-1">
|
|
<div>{path}</div>
|
|
{summary && !isSelected && (
|
|
<small className="text-muted d-block" style={{ fontSize: '0.875em' }}>
|
|
{summary}
|
|
</small>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
} else {
|
|
return 'Select an operation';
|
|
}
|
|
};
|
|
|
|
const categorizeOperations = (operation, path, acc, category) => {
|
|
const operationData = specJson.paths[path][operation];
|
|
const summary = operationData?.summary || '';
|
|
|
|
// Create searchable label that includes both path and summary
|
|
const searchableLabel = summary ? `${path} ${summary}` : path;
|
|
|
|
const option = {
|
|
value: `${operation}${path}`,
|
|
label: searchableLabel,
|
|
name: path,
|
|
operation: operation,
|
|
summary: summary || null,
|
|
displayLabel: path, // Keep original path for display
|
|
};
|
|
const existingCategory = acc.find((obj) => obj.label === category);
|
|
if (existingCategory) {
|
|
existingCategory.options.push(option);
|
|
} else {
|
|
acc.push({
|
|
label: category,
|
|
options: [option],
|
|
});
|
|
}
|
|
};
|
|
|
|
const computeOperationSelectionOptions = () => {
|
|
const paths = specJson?.paths;
|
|
if (isEmpty(paths)) return [];
|
|
|
|
const pathGroups = Object.keys(paths).reduce((acc, path) => {
|
|
const operations = Object.keys(paths[path]);
|
|
const category = path.split('/')[2];
|
|
operations.forEach((operation) => categorizeOperations(operation, path, acc, category));
|
|
return acc;
|
|
}, []);
|
|
|
|
return pathGroups;
|
|
};
|
|
|
|
const getRequestBodyProperties = () => {
|
|
if (!options?.selectedOperation?.requestBody?.content) {
|
|
return {};
|
|
}
|
|
|
|
const contentTypes = Object.keys(options.selectedOperation.requestBody.content);
|
|
if (contentTypes.length === 0) {
|
|
return {};
|
|
}
|
|
|
|
const contentType = contentTypes.includes('application/json') ? 'application/json' : contentTypes[0];
|
|
|
|
const schema = options.selectedOperation.requestBody.content[contentType]?.schema;
|
|
return extractSchemaProperties(schema);
|
|
};
|
|
|
|
useEffect(() => {
|
|
const queryParams = {
|
|
path: props.options?.params?.path ?? {},
|
|
query: props.options?.params?.query ?? {},
|
|
request: props.options?.params?.request ?? {},
|
|
};
|
|
setLoadingSpec(true);
|
|
setOptions({ ...props.options, params: queryParams });
|
|
|
|
if (!isMultiSpec) {
|
|
fetchOpenApiSpec();
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isMultiSpec && selectedSpecType) {
|
|
fetchOpenApiSpec(selectedSpecType);
|
|
}
|
|
}, [selectedSpecType]);
|
|
|
|
const specTypeOptions = isMultiSpec
|
|
? Object.keys(props.specUrl).map((key) => ({
|
|
value: key,
|
|
label: key,
|
|
}))
|
|
: [];
|
|
|
|
return (
|
|
<div>
|
|
{/* Render spec type dropdown only for multi-spec */}
|
|
{isMultiSpec && (
|
|
<div className="d-flex g-2 mb-3">
|
|
<div className="col-3 form-label">
|
|
<label className="form-label">{props.t('globals.specType', 'Spec Type')}</label>
|
|
</div>
|
|
<div className="col flex-grow-1">
|
|
<Select
|
|
options={specTypeOptions}
|
|
value={{ value: selectedSpecType, label: selectedSpecType }}
|
|
onChange={(val) => setSelectedSpecType(val)}
|
|
width={'100%'}
|
|
styles={queryManagerSelectComponentStyle(props.darkMode, '100%')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{loadingSpec && (
|
|
<div className="p-3">
|
|
<div className="spinner-border spinner-border-sm text-azure mx-2" role="status"></div>
|
|
{props.t('', 'Please wait while we load the OpenAPI specification.')}
|
|
</div>
|
|
)}
|
|
{options && !loadingSpec && (
|
|
<div>
|
|
<div className="d-flex g-2">
|
|
<div className="col-12 form-label">
|
|
<label className="form-label">{props.t('globals.operation', 'Operation')}</label>
|
|
</div>
|
|
<div className="col stripe-operation-options flex-grow-1" style={{ width: '90px', marginTop: 0 }}>
|
|
<Select
|
|
options={computeOperationSelectionOptions()}
|
|
value={
|
|
options?.operation && options?.path
|
|
? {
|
|
operation: options?.operation,
|
|
value: `${options?.operation}${options?.path}`,
|
|
summary: options?.selectedOperation?.summary || null,
|
|
isSelected: true,
|
|
displayLabel: options?.path,
|
|
label: options?.selectedOperation?.summary
|
|
? `${options?.path} ${options?.selectedOperation?.summary}`
|
|
: options?.path,
|
|
}
|
|
: null
|
|
}
|
|
onChange={(value) => changeOperation(value)}
|
|
width={'100%'}
|
|
useMenuPortal={true}
|
|
customOption={renderOperationOption}
|
|
styles={queryManagerSelectComponentStyle(props.darkMode, '100%')}
|
|
useCustomStyles={true}
|
|
filterOption={(option, inputValue) => {
|
|
if (!inputValue) return true;
|
|
const searchValue = inputValue.toLowerCase();
|
|
const pathMatch = option.data.displayLabel?.toLowerCase().includes(searchValue);
|
|
const summaryMatch = option.data.summary?.toLowerCase().includes(searchValue);
|
|
return pathMatch || summaryMatch;
|
|
}}
|
|
/>
|
|
{options?.selectedOperation && (
|
|
<small
|
|
style={{ marginTop: '10px' }}
|
|
className="my-2"
|
|
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(options?.selectedOperation?.description) }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{options?.selectedOperation && (
|
|
<div className={`row stripe-fields-row ${props.darkMode && 'theme-dark'}`}>
|
|
<RenderParameterFields
|
|
parameters={options?.selectedOperation?.parameters}
|
|
type="path"
|
|
label={props.t('globals.path', 'PATH')}
|
|
options={options}
|
|
changeParam={changeParam}
|
|
removeParam={removeParam}
|
|
darkMode={props.darkMode}
|
|
/>
|
|
<RenderParameterFields
|
|
parameters={options?.selectedOperation?.parameters}
|
|
type="query"
|
|
label={props.t('globals.query'.toUpperCase(), 'Query')}
|
|
options={options}
|
|
changeParam={changeParam}
|
|
removeParam={removeParam}
|
|
darkMode={props.darkMode}
|
|
/>
|
|
<RenderParameterFields
|
|
parameters={getRequestBodyProperties()}
|
|
type="request"
|
|
label={props.t('globals.requestBody', 'REQUEST BODY')}
|
|
options={options}
|
|
changeParam={changeParam}
|
|
removeParam={removeParam}
|
|
darkMode={props.darkMode}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default withTranslation()(ApiEndpointInput);
|
|
|
|
ApiEndpointInput.propTypes = {
|
|
options: PropTypes.object,
|
|
specUrl: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
|
optionsChanged: PropTypes.func,
|
|
darkMode: PropTypes.bool,
|
|
t: PropTypes.func,
|
|
};
|
|
|
|
const RenderParameterFields = ({ parameters, type, label, options, changeParam, removeParam, darkMode }) => {
|
|
let filteredParams;
|
|
if (type === 'request') {
|
|
filteredParams = Object.keys(parameters || {});
|
|
} else {
|
|
filteredParams = parameters?.filter((param) => param.in === type);
|
|
}
|
|
|
|
const paramLabelWithDescription = (param) => {
|
|
return (
|
|
<ToolTip
|
|
message={type === 'request' ? DOMPurify.sanitize(parameters[param]?.description || '') : param.description}
|
|
>
|
|
<div className="cursor-help">
|
|
<input
|
|
type="text"
|
|
value={type === 'request' ? param : param.name}
|
|
className="form-control form-control-underline"
|
|
placeholder="key"
|
|
disabled
|
|
/>
|
|
</div>
|
|
</ToolTip>
|
|
);
|
|
};
|
|
|
|
const paramLabelWithoutDescription = (param) => {
|
|
return (
|
|
<input
|
|
type="text"
|
|
value={type === 'request' ? param : param.name}
|
|
className="form-control"
|
|
placeholder="key"
|
|
disabled
|
|
/>
|
|
);
|
|
};
|
|
|
|
const paramType = (param) => {
|
|
return (
|
|
<div className="p-2 text-muted">
|
|
{type === 'query' &&
|
|
param?.schema?.anyOf &&
|
|
param?.schema?.anyOf.map((type, i) =>
|
|
i < param.schema?.anyOf.length - 1
|
|
? type.type.substring(0, 3).toUpperCase() + '|'
|
|
: type.type.substring(0, 3).toUpperCase()
|
|
)}
|
|
{(type === 'path' || (type === 'query' && !param?.schema?.anyOf)) &&
|
|
param?.schema?.type?.substring(0, 3).toUpperCase()}
|
|
{type === 'request' && parameters[param]?.type?.substring(0, 3).toUpperCase()}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const paramDetails = (param) => {
|
|
return (
|
|
<div className="col-auto d-flex field field-width-179 align-items-center">
|
|
{(type === 'request' && parameters[param]?.description) || param?.description
|
|
? paramLabelWithDescription(param)
|
|
: paramLabelWithoutDescription(param)}
|
|
{(type === 'request' ? parameters[param]?.required : param.required) && (
|
|
<span className="text-danger fw-bold">*</span>
|
|
)}
|
|
{paramType(param)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const inputField = (param) => {
|
|
return (
|
|
<CodeHinter
|
|
initialValue={(type === 'request' ? options?.params[type][param] : options?.params[type][param.name]) ?? ''}
|
|
mode="text"
|
|
placeholder={'Value'}
|
|
theme={darkMode ? 'monokai' : 'duotone-light'}
|
|
lineNumbers={false}
|
|
onChange={(value) => {
|
|
if (type === 'request') {
|
|
changeParam(type, param, value);
|
|
} else {
|
|
changeParam(type, param.name, value);
|
|
}
|
|
}}
|
|
height={'32px'}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const clearButton = (param) => {
|
|
const handleClear = () => {
|
|
if (type === 'request') {
|
|
removeParam(type, param);
|
|
} else {
|
|
removeParam(type, param.name);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<span
|
|
className="code-hinter-clear-btn"
|
|
role="button"
|
|
onClick={handleClear}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
handleClear();
|
|
}
|
|
}}
|
|
tabIndex="0"
|
|
>
|
|
<SolidIcons name="removerectangle" width="20" fill="#ACB2B9" />
|
|
</span>
|
|
);
|
|
};
|
|
|
|
return (
|
|
filteredParams?.length > 0 && (
|
|
<div className={`${type === 'request' ? 'request-body' : type}-fields d-flex`}>
|
|
<h5 className="text-heading form-label">{label}</h5>
|
|
<div className="flex-grow-1 input-group-parent-container">
|
|
{filteredParams.map((param) => (
|
|
<div className="input-group-wrapper" key={type === 'request' ? param : param.name}>
|
|
<div className="input-group">
|
|
{paramDetails(param)}
|
|
<div className="col field overflow-hidden code-hinter-borderless">{inputField(param)}</div>
|
|
{((type === 'request' && options['params'][type][param]) || options['params'][type][param?.name]) &&
|
|
clearButton(param)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
);
|
|
};
|
|
|
|
RenderParameterFields.propTypes = {
|
|
parameters: PropTypes.any,
|
|
type: PropTypes.string,
|
|
label: PropTypes.string,
|
|
options: PropTypes.object,
|
|
changeParam: PropTypes.func,
|
|
removeParam: PropTypes.func,
|
|
darkMode: PropTypes.bool,
|
|
};
|