Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions DSL/Ruuter/services/POST/services/requests/explain.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ request_explain_get:
headers: $! current_request.headers !
query: $! current_request.params !
result: res
next: check_get_redirect

check_get_redirect:
switch:
- condition: ${res.response.statusCodeValue == 301 || res.response.statusCodeValue == 302}
next: retry_get_with_slash
next: assign_result

retry_get_with_slash:
call: http.get
args:
url: ${current_request.url + '/'}
headers: $! current_request.headers !
query: $! current_request.params !
result: res
next: assign_result

request_explain_post:
Expand All @@ -59,6 +74,22 @@ request_explain_post:
query: $! current_request.params !
body: $! current_request.body !
result: res
next: check_post_redirect

check_post_redirect:
switch:
- condition: ${res.response.statusCodeValue == 301 || res.response.statusCodeValue == 302}
next: retry_post_with_slash
next: assign_result

retry_post_with_slash:
call: http.post
args:
url: ${current_request.url + '/'}
headers: $! current_request.headers !
query: $! current_request.params !
body: $! current_request.body !
result: res
next: assign_result

assign_result:
Expand Down
138 changes: 120 additions & 18 deletions GUI/src/components/ApiEndpointCard/Endpoints/Custom/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,65 @@ const EndpointCustom: React.FC<EndpointCustomProps> = ({
const handleJsonRequestClick = () => {
setIsTesting(true);
const def = endpoint.definitions[0];

// Build the test URL by resolving {param} placeholders.
// PATH params must have values; any remaining unresolved {name} blocks the test.
// Derive path param names directly from {name} placeholders in the URL path so that
// endpoints loaded from the DB (where paramType may not be persisted) work correctly.
const allParams = def.params?.variables ?? [];
const urlPathPart = (def.url ?? '').split('?')[0];
const pathPlaceholderNames = new Set(
[...urlPathPart.matchAll(/(?<!\$)\{(\w+)\}/g)].map((m) => m[1]),
);
const pathParams = allParams.filter((v) => pathPlaceholderNames.has(v.name));
const queryParams = allParams.filter((v) => !pathPlaceholderNames.has(v.name));

// Validate path params have values
for (const param of pathParams) {
if (!param.value?.trim()) {
useToastStore.getState().error({ title: t('newService.endpoint.missingPathParam', { name: param.name }) });
setIsTesting(false);
return;
}
}

// Validate named query params have values (dynamic placeholders must be filled)
for (const param of queryParams) {
if (param.name && !param.value?.trim()) {
useToastStore.getState().error({ title: t('newService.endpoint.missingPathParam', { name: param.name }) });
setIsTesting(false);
return;
}
}

// Replace {name} placeholders in the URL path
let testUrl = def.url ?? '';
for (const param of pathParams) {
testUrl = testUrl.split(`{${param.name}}`).join(encodeURIComponent(param.value ?? ''));
}

// Strip query string — query params are sent separately via the params map
const testUrlBase = testUrl.includes('?') ? testUrl.split('?')[0] : testUrl;

// Check for any remaining unresolved {name} in the path (excluding ${var} runtime vars)
const unresolvedRegex = /(?<!\$)\{(\w+)\}/;
const remaining = unresolvedRegex.exec(testUrlBase);
if (remaining) {
useToastStore.getState().error({ title: t('newService.endpoint.unresolvedPlaceholder', { name: remaining[1] }) });
setIsTesting(false);
return;
}

const queryOnlyParams = def.params ? { ...def.params, variables: queryParams } : undefined;

const request = {
url: def.url,
url: testUrlBase,
method: def.methodType,
headers: extractMapValues(def.headers) as Record<string, string>,
params: extractMapValues(def.params) as Record<string, string>,
params: extractMapValues(queryOnlyParams) as Record<string, string>,
body: getEndpointBody(def),
};
generateJsonRequest(def)
generateJsonRequest({ ...def, url: testUrlBase, params: queryOnlyParams })
.then((content) => {
const schema = JSON.stringify(content, undefined, 4);
setResponseContent(schema);
Expand Down Expand Up @@ -161,10 +212,26 @@ const EndpointCustom: React.FC<EndpointCustomProps> = ({
label=""
defaultValue={endpoint.definitions[0]?.url ?? ''}
onChange={(event) => {
const parsedUrl = parseURL(event.target.value);
const parsedUrl = parseURLWithPlaceholders(event.target.value);
endpoint.definitions[0].url = parsedUrl.url;

const existingVars = endpoint.definitions[0].params?.variables ?? [];
const parameters: EndpointVariableData[] = [];

// Add path params first (preserve existing values entered by the user)
parsedUrl.pathParams.forEach((name) => {
const existing = existingVars.find((v) => v.name === name && v.paramType === 'path');
parameters.push({
id: existing?.id ?? uuid(),
name,
type: 'custom',
required: false,
value: existing?.value ?? '',
paramType: 'path',
});
});

// Add query params
Object.keys(parsedUrl.params).forEach((key) => {
parameters.push({
id: uuid(),
Expand All @@ -173,6 +240,7 @@ const EndpointCustom: React.FC<EndpointCustomProps> = ({
required: false,
value: parsedUrl.params[key],
operator: (parsedUrl.operators[key] as RequestOperator) || '=',
paramType: 'query',
});
});

Expand All @@ -197,18 +265,34 @@ const EndpointCustom: React.FC<EndpointCustomProps> = ({
setRequestTab={setRequestTab}
onMandatoryViolationChange={onMandatoryViolationChange}
onParametersChange={(parameters) => {
const url = new URL(endpoint.definitions[0].url ?? '');
const baseUrl = `${url.origin}${url.pathname}`;
// Preserve the path template (including {name} placeholders) from stored URL.
// Use string split instead of URL constructor to avoid encoding {braces}.
const storedUrl = endpoint.definitions[0].url ?? '';
let pathTemplate = storedUrl.includes('?') ? storedUrl.split('?')[0] : storedUrl;

// When a path param is renamed, update the {oldName} placeholder in the URL path.
const oldPathParams = endpoint.definitions[0].params?.variables?.filter((p) => p.paramType === 'path') ?? [];
const newPathParams = parameters.filter((p) => p.paramType === 'path');
for (const newParam of newPathParams) {
const oldParam = oldPathParams.find((p) => p.id === newParam.id);
if (oldParam && oldParam.name !== newParam.name && newParam.name) {
pathTemplate = pathTemplate.split(`{${oldParam.name}}`).join(`{${newParam.name}}`);
}
}

// Rebuild query string from non-path params.
// Empty values restore the {paramName} placeholder so the URL stays as a template.
// Renaming a query param key here updates the corresponding key in the URL.
const queryString = parameters
.filter((param) => param.value && param.name)
.filter((param) => param.paramType !== 'path' && param.name)
.map((param) => {
const operator = param.operator ? param.operator : '=';
return `${param.name}${operator}${encodeURIComponent(param.value ?? '')}`;
const operator = param.operator ?? '=';
const val = param.value?.trim() ? encodeURIComponent(param.value) : `{${param.name}}`;
return `${param.name}${operator}${val}`;
})
.join('&');

endpoint.definitions[0].url = queryString ? `${baseUrl}?${queryString}` : baseUrl;
endpoint.definitions[0].url = queryString ? `${pathTemplate}?${queryString}` : pathTemplate;
endpoint.definitions[0].params = {
variables: parameters,
rawData: {},
Expand Down Expand Up @@ -270,30 +354,48 @@ const EndpointCustom: React.FC<EndpointCustomProps> = ({
);
};

function parseURL(url: string) {
function parseURLWithPlaceholders(url: string): {
url: string;
pathParams: string[];
params: Record<string, string>;
operators: Record<string, string>;
} {
try {
const queryString = url.split('?')[1] ?? '';
const params: Record<string, any> = {};
const operators: Record<string, string> = {};
const [pathPart, queryPart = ''] = url.split('?');

queryString
// Detect {name} placeholders in the path portion
const pathParams: string[] = [];
const pathPlaceholderRegex = /\{(\w+)\}/g;
let match;
while ((match = pathPlaceholderRegex.exec(pathPart)) !== null) {
if (!pathParams.includes(match[1])) {
pathParams.push(match[1]);
}
}

// Parse query params
const params: Record<string, string> = {};
const operators: Record<string, string> = {};
queryPart
.split('&')
.filter(Boolean)
.forEach((segment) => {
const { index, token } = findOperators(segment);
const name = decodeURIComponent(index === -1 ? segment : segment.slice(0, index));
const value = decodeURIComponent(index === -1 ? '' : segment.slice(index + token.length));
const rawValue = decodeURIComponent(index === -1 ? '' : segment.slice(index + token.length));
// If the value is itself a {placeholder}, treat it as empty (dynamic param — user fills in Params tab)
const value = /^\{[^}]+\}$/.test(rawValue) ? '' : rawValue;
const operator = index === -1 ? '=' : token;
if (name) {
params[name] = value;
operators[name] = operator;
}
});

return { url, params, operators };
return { url, pathParams, params, operators };
} catch (e) {
console.error('Invalid URL format:', e);
return { url, params: {}, operators: {} };
return { url, pathParams: [], params: {}, operators: {} };
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,16 @@ const VariableCell: React.FC<VariableCellProps> = ({ row, updateRowVariable, var
placeholder={t('newService.endpoint.variable') + '..'}
/>
) : (
<p style={{ paddingLeft: 40 * row.original.nestedLevel }}>
{row.original.variable}
{row.original.type && `, (${row.original.type})`}
{row.original.description && `, (${row.original.description})`}
</p>
<div style={{ paddingLeft: 40 * row.original.nestedLevel }}>
<FormInput
style={{ borderRadius: '4px', backgroundColor: '#f5f5f7', cursor: 'default' }}
name={`endpoint-variable-readonly-${row.id}`}
label=""
value={row.original.variable ?? ''}
readOnly
onChange={() => {}}
/>
</div>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const RequestVariables: React.FC<RequestVariablesProps> = ({
description: data.description,
arrayType: data.arrayType,
nestedLevel,
paramType: data.paramType,
};
};

Expand Down Expand Up @@ -199,6 +200,7 @@ const RequestVariables: React.FC<RequestVariablesProps> = ({
value: row.value!,
description: row.description,
operator: row.operator as RequestOperator,
paramType: row.paramType,
})) ?? [];
onParametersChange(params);
// All named params need description; mandatory params also need a value
Expand Down Expand Up @@ -253,6 +255,7 @@ const RequestVariables: React.FC<RequestVariablesProps> = ({
value: row.value,
description: row.description,
operator: requestTab.tab === EndpointTab.Params ? (row.operator as RequestOperator) || '=' : undefined,
paramType: row.paramType,
};
variables.push(newVariable);
});
Expand Down Expand Up @@ -317,7 +320,11 @@ const RequestVariables: React.FC<RequestVariablesProps> = ({

const variables: EndpointVariableData[] = [];
newData.forEach((row) => {
if (!row.value || !row.variable) return;
// Always include path params (value may be empty while user fills it in);
// for regular params, require both name and value.
const isPathParam = row.paramType === 'path';
if (!isPathParam && (!row.value || !row.variable)) return;
if (isPathParam && !row.variable) return;

const newVariable: EndpointVariableData = {
id: row.endpointVariableId ?? row.id,
Expand All @@ -327,6 +334,7 @@ const RequestVariables: React.FC<RequestVariablesProps> = ({
value: row.value,
description: row.description,
operator: requestTab.tab === EndpointTab.Params ? (row.operator as RequestOperator) || '=' : undefined,
paramType: row.paramType,
};
variables.push(newVariable);
});
Expand Down
9 changes: 9 additions & 0 deletions GUI/src/components/Flow/EdgeTypes/AddEndpointModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,15 @@ const AddEndpointModal: React.FC<AddEndpointModalProps> = ({
setIsSaving(false);
return;
}
// Block save if any PATH parameter has no value
const missingPathParam = allParams.find((p) => p.paramType === 'path' && !p.value?.trim());
if (missingPathParam) {
useToastStore
.getState()
.error({ title: t('newService.endpoint.missingPathParam', { name: missingPathParam.name }) });
setIsSaving(false);
return;
}

// Resolve serviceId: service-flow gets it from the store; registry preserves the
// endpoint's own serviceId (edit) or uses empty string (create).
Expand Down
4 changes: 2 additions & 2 deletions GUI/src/components/Flow/EdgeTypes/ApiElementsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import AddEndpointModal from 'components/Flow/EdgeTypes/AddEndpointModal';
import { EndpointTooltipContent } from 'pages/ApiRegistryPage/ApiRegistryTable';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import './ApiElementsPanel.scss';
import {
MdArrowDownward,
MdArrowUpward,
Expand All @@ -21,6 +19,8 @@ import {
MdOutlineWest,
MdUnfoldMore,
} from 'react-icons/md';
import { useParams } from 'react-router-dom';
import './ApiElementsPanel.scss';
import useApiRegistryStore from 'store/api-registry.store';
import useToastStore from 'store/toasts.store';
import { Step, StepType } from 'types';
Expand Down
2 changes: 2 additions & 0 deletions GUI/src/i18n/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@
"mandatoryNo": "No",
"mandatoryNameValueRequired": "Name and value are mandatory",
"nameTypeDescriptionRequired": "Name, type and description are required for all parameters",
"missingPathParam": "Missing value for '{{name}}'",
"unresolvedPlaceholder": "Unresolved placeholder '{{name}}'",
"paramTypeTooltip": "Type defines what data a parameter accepts.\n\nSTRING (default) – text (e.g. \"Tallinn\", \"ABC123\")\nNUMBER – numbers (e.g. 10, 10.5)\nBOOLEAN – true or false\nDATE – date or datetime (e.g. 2025-01-01, ISO 8601)",
"response": "Response",
"testUrlFirst": "Execute Test URL before saving",
Expand Down
2 changes: 2 additions & 0 deletions GUI/src/i18n/et/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@
"mandatoryNo": "Ei",
"mandatoryNameValueRequired": "Nimi ja väärtus on kohustuslikud",
"nameTypeDescriptionRequired": "Nimi, tüüp ja kirjeldus on kõigi parameetrite jaoks kohustuslikud",
"missingPathParam": "Puudub väärtus parameetrile '{{name}}'",
"unresolvedPlaceholder": "Lahendamata kohatäitja '{{name}}'",
"paramTypeTooltip": "Tüüp määrab, millist andmetüüpi parameeter aktsepteerib.\n\nSTRING (vaikimisi) – tekst (nt \"Tallinn\", \"ABC123\")\nNUMBER – arvud (nt 10, 10.5)\nBOOLEAN – true või false\nDATE – kuupäev või kuupäev-kellaaeg (nt 2025-01-01, ISO 8601)",
"response": "Vastus",
"testUrlFirst": "Enne salvestamist käivita Testi URLi",
Expand Down
2 changes: 1 addition & 1 deletion GUI/src/pages/ApiRegistryPage/ApiRegistryTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ const ApiRegistryTable: React.FC<ApiRegistryTableProps> = ({
<li key={i} className={i === pagination.pageIndex ? 'active' : ''}>
<button
type="button"
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
style={{ cursor: 'pointer', border: 'none', padding: 0 }}
onClick={() => setPagination({ ...pagination, pageIndex: i })}
>
{i + 1}
Expand Down
Loading