ToolJet/frontend/src/_components/ApiEndpointInput.jsx
Ganesh Kumar b8b0d57504
Fix: ApiEndpointInput component used for rendering OpenAPI spec where values are stored in cache for respective operation. (#13732)
* 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>
2025-08-08 22:28:57 +05:30

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,
};