From c13adf7fb358af6a1782ef125df9e6caec526b8c Mon Sep 17 00:00:00 2001 From: ruwinirathnamalala Date: Mon, 11 May 2026 18:05:40 +0530 Subject: [PATCH 1/2] Path Parameter Support for API Endpoints --- .../POST/services/requests/explain.yml | 31 ++++ .../Endpoints/Custom/index.tsx | 136 +++++++++++++++--- .../RequestVariables/VariableCell/index.tsx | 15 +- .../Endpoints/RequestVariables/index.tsx | 10 +- .../Flow/EdgeTypes/AddEndpointModal.tsx | 9 ++ GUI/src/i18n/en/common.json | 2 + GUI/src/i18n/et/common.json | 2 + .../ApiRegistryPage/ApiRegistryTable.tsx | 2 +- GUI/src/store/api-registry.store.ts | 22 ++- .../types/endpoint/endpoint-variable-data.ts | 2 + .../request-variables-row-data.ts | 1 + .../request-variables-table-columns.ts | 1 + docker-compose.yml | 2 +- 13 files changed, 206 insertions(+), 29 deletions(-) diff --git a/DSL/Ruuter/services/POST/services/requests/explain.yml b/DSL/Ruuter/services/POST/services/requests/explain.yml index a15b3cc23..d0e097cfb 100644 --- a/DSL/Ruuter/services/POST/services/requests/explain.yml +++ b/DSL/Ruuter/services/POST/services/requests/explain.yml @@ -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: @@ -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: diff --git a/GUI/src/components/ApiEndpointCard/Endpoints/Custom/index.tsx b/GUI/src/components/ApiEndpointCard/Endpoints/Custom/index.tsx index c522e902e..fc39fa047 100644 --- a/GUI/src/components/ApiEndpointCard/Endpoints/Custom/index.tsx +++ b/GUI/src/components/ApiEndpointCard/Endpoints/Custom/index.tsx @@ -89,14 +89,63 @@ const EndpointCustom: React.FC = ({ 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 = [...urlPathPart.matchAll(/(? m[1]); + const pathParams = allParams.filter((v) => pathPlaceholderNames.includes(v.name)); + const queryParams = allParams.filter((v) => !pathPlaceholderNames.includes(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 = /(?, - params: extractMapValues(def.params) as Record, + params: extractMapValues(queryOnlyParams) as Record, body: getEndpointBody(def), }; - generateJsonRequest(def) + generateJsonRequest({ ...def, url: testUrlBase, params: queryOnlyParams }) .then((content) => { const schema = JSON.stringify(content, undefined, 4); setResponseContent(schema); @@ -161,10 +210,26 @@ const EndpointCustom: React.FC = ({ 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(), @@ -173,6 +238,7 @@ const EndpointCustom: React.FC = ({ required: false, value: parsedUrl.params[key], operator: (parsedUrl.operators[key] as RequestOperator) || '=', + paramType: 'query', }); }); @@ -197,18 +263,34 @@ const EndpointCustom: React.FC = ({ 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: {}, @@ -270,19 +352,37 @@ const EndpointCustom: React.FC = ({ ); }; -function parseURL(url: string) { +function parseURLWithPlaceholders(url: string): { + url: string; + pathParams: string[]; + params: Record; + operators: Record; +} { try { - const queryString = url.split('?')[1] ?? ''; - const params: Record = {}; - const operators: Record = {}; + const [pathPart, queryPart = ''] = url.split('?'); - queryString + // Detect {name} placeholders in the path portion + const pathParams: string[] = []; + const pathPlaceholderRegex = /\{([^}]+)\}/g; + let match; + while ((match = pathPlaceholderRegex.exec(pathPart)) !== null) { + if (!pathParams.includes(match[1])) { + pathParams.push(match[1]); + } + } + + // Parse query params + const params: Record = {}; + const operators: Record = {}; + 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; @@ -290,10 +390,10 @@ function parseURL(url: string) { } }); - 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: {} }; } } diff --git a/GUI/src/components/ApiEndpointCard/Endpoints/RequestVariables/VariableCell/index.tsx b/GUI/src/components/ApiEndpointCard/Endpoints/RequestVariables/VariableCell/index.tsx index f76a4d397..12548b92f 100644 --- a/GUI/src/components/ApiEndpointCard/Endpoints/RequestVariables/VariableCell/index.tsx +++ b/GUI/src/components/ApiEndpointCard/Endpoints/RequestVariables/VariableCell/index.tsx @@ -36,11 +36,16 @@ const VariableCell: React.FC = ({ row, updateRowVariable, var placeholder={t('newService.endpoint.variable') + '..'} /> ) : ( -

- {row.original.variable} - {row.original.type && `, (${row.original.type})`} - {row.original.description && `, (${row.original.description})`} -

+
+ {}} + /> +
); }; diff --git a/GUI/src/components/ApiEndpointCard/Endpoints/RequestVariables/index.tsx b/GUI/src/components/ApiEndpointCard/Endpoints/RequestVariables/index.tsx index 7a968b7ba..028e76621 100644 --- a/GUI/src/components/ApiEndpointCard/Endpoints/RequestVariables/index.tsx +++ b/GUI/src/components/ApiEndpointCard/Endpoints/RequestVariables/index.tsx @@ -77,6 +77,7 @@ const RequestVariables: React.FC = ({ description: data.description, arrayType: data.arrayType, nestedLevel, + paramType: data.paramType, }; }; @@ -199,6 +200,7 @@ const RequestVariables: React.FC = ({ 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 @@ -253,6 +255,7 @@ const RequestVariables: React.FC = ({ value: row.value, description: row.description, operator: requestTab.tab === EndpointTab.Params ? (row.operator as RequestOperator) || '=' : undefined, + paramType: row.paramType, }; variables.push(newVariable); }); @@ -317,7 +320,11 @@ const RequestVariables: React.FC = ({ 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, @@ -327,6 +334,7 @@ const RequestVariables: React.FC = ({ value: row.value, description: row.description, operator: requestTab.tab === EndpointTab.Params ? (row.operator as RequestOperator) || '=' : undefined, + paramType: row.paramType, }; variables.push(newVariable); }); diff --git a/GUI/src/components/Flow/EdgeTypes/AddEndpointModal.tsx b/GUI/src/components/Flow/EdgeTypes/AddEndpointModal.tsx index 54daba623..754559ac3 100644 --- a/GUI/src/components/Flow/EdgeTypes/AddEndpointModal.tsx +++ b/GUI/src/components/Flow/EdgeTypes/AddEndpointModal.tsx @@ -124,6 +124,15 @@ const AddEndpointModal: React.FC = ({ 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). diff --git a/GUI/src/i18n/en/common.json b/GUI/src/i18n/en/common.json index 2b541d081..043219d1b 100644 --- a/GUI/src/i18n/en/common.json +++ b/GUI/src/i18n/en/common.json @@ -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", diff --git a/GUI/src/i18n/et/common.json b/GUI/src/i18n/et/common.json index 339858b03..11e0bd6ad 100644 --- a/GUI/src/i18n/et/common.json +++ b/GUI/src/i18n/et/common.json @@ -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", diff --git a/GUI/src/pages/ApiRegistryPage/ApiRegistryTable.tsx b/GUI/src/pages/ApiRegistryPage/ApiRegistryTable.tsx index 358b49594..aa816d63a 100644 --- a/GUI/src/pages/ApiRegistryPage/ApiRegistryTable.tsx +++ b/GUI/src/pages/ApiRegistryPage/ApiRegistryTable.tsx @@ -375,7 +375,7 @@ const ApiRegistryTable: React.FC = ({