diff --git a/assets/scss/_styles_project.scss b/assets/scss/_styles_project.scss index c686003729..f8e04e6e55 100644 --- a/assets/scss/_styles_project.scss +++ b/assets/scss/_styles_project.scss @@ -11,6 +11,7 @@ @import "_videos_project.scss"; @import "subscription.scss"; @import "rest-api-reference.scss"; +@import "rest-api-interactive.scss"; @import "_video-landing_project.scss"; @import "elements_project"; @import "summary.scss"; diff --git a/assets/scss/rest-api-interactive.scss b/assets/scss/rest-api-interactive.scss new file mode 100644 index 0000000000..8d3b571683 --- /dev/null +++ b/assets/scss/rest-api-interactive.scss @@ -0,0 +1,440 @@ +/** + * REST API Interactive Components Styling + * Handles UI for request builder and response display + */ + +/* Execute Button */ +.rest-api-execute-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 0.375rem; + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 6px -1px rgba(102, 126, 234, 0.3); +} + +.rest-api-execute-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 12px -1px rgba(102, 126, 234, 0.4); +} + +.rest-api-execute-button:active:not(:disabled) { + transform: translateY(0); +} + +.rest-api-execute-button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.rest-api-execute-button.is-loading { + position: relative; + color: transparent; +} + +.rest-api-execute-button.is-loading::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + top: 50%; + left: 50%; + margin-left: -8px; + margin-top: -8px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: white; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Server Selection */ +.rest-api-server-selector { + margin: 1rem 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.rest-api-server-selector label { + font-weight: 600; + font-size: 0.9rem; + color: #374151; +} + +.rest-api-server-select { + padding: 0.5rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + background-color: white; + font-size: 0.9rem; + cursor: pointer; + transition: border-color 0.2s ease; +} + +.rest-api-server-select:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +/* URL Preview */ +.rest-api-url-preview { + margin: 1rem 0; + padding: 0.75rem 1rem; + background-color: #f3f4f6; + border-left: 4px solid #667eea; + border-radius: 0.375rem; + font-family: 'Monaco', 'Courier New', monospace; + font-size: 0.85rem; + word-break: break-all; + color: #374151; +} + +/* Parameter Inputs */ +.rest-api-parameter-group { + margin: 1rem 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.rest-api-parameter-input { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.rest-api-parameter-input label { + font-weight: 600; + font-size: 0.85rem; + color: #374151; +} + +.rest-api-parameter-input label .rest-api-parameter-required { + color: #dc2626; + margin-left: 0.25rem; +} + +.rest-api-parameter-input input, +.rest-api-parameter-input textarea { + padding: 0.5rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + font-size: 0.9rem; + font-family: inherit; + transition: border-color 0.2s ease; +} + +.rest-api-parameter-input input:focus, +.rest-api-parameter-input textarea:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.rest-api-parameter-input .rest-api-parameter-description { + font-size: 0.8rem; + color: #6b7280; + margin-top: -0.25rem; +} + +/* Authentication */ +.rest-api-auth-input { + margin: 1rem 0; + padding: 1rem; + background-color: #fef3c7; + border-left: 4px solid #f59e0b; + border-radius: 0.375rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.rest-api-auth-input label { + font-weight: 600; + font-size: 0.9rem; + color: #92400e; +} + +.rest-api-auth-input input { + padding: 0.5rem 0.75rem; + border: 1px solid #fcd34d; + border-radius: 0.375rem; + font-size: 0.9rem; + background-color: rgba(255, 255, 255, 0.8); +} + +.rest-api-auth-input input:focus { + outline: none; + border-color: #f59e0b; + box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.1); +} + +.rest-api-auth-input .rest-api-auth-help { + font-size: 0.8rem; + color: #92400e; +} + +/* Response Display */ +.rest-api-response-display { + margin-top: 2rem; + padding: 1.5rem; + background-color: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + display: none; + animation: slideIn 0.3s ease; +} + +.rest-api-response-display--active { + display: block; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.rest-api-response-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 2px solid #e5e7eb; +} + +.rest-api-response-header h4 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: #111827; +} + +.rest-api-response-meta { + display: flex; + gap: 1rem; + align-items: center; + font-size: 0.9rem; +} + +.rest-api-response-status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.75rem; + border-radius: 0.375rem; + font-weight: 600; + font-family: 'Monaco', 'Courier New', monospace; + font-size: 0.85rem; +} + +.rest-api-response-status.success { + background-color: #d1fae5; + color: #065f46; +} + +.rest-api-response-status.error { + background-color: #fee2e2; + color: #991b1b; +} + +.rest-api-response-status.loading { + background-color: #dbeafe; + color: #0c4a6e; +} + +.rest-api-response-duration { + color: #6b7280; + font-size: 0.85rem; +} + +/* Response Body */ +.rest-api-response-body { + margin: 0; + padding: 1rem; + background-color: #1f2937; + border-radius: 0.375rem; + color: #e5e7eb; + font-size: 0.85rem; + font-family: 'Monaco', 'Courier New', monospace; + overflow-x: auto; + max-height: 400px; + overflow-y: auto; +} + +.rest-api-response-body code { + background: none; + color: inherit; + padding: 0; + font-family: inherit; + font-size: inherit; +} + +.rest-api-response-body.language-json { + color: #60a5fa; +} + +/* Syntax highlighting for JSON */ +.rest-api-response-body.hljs { + background: #1f2937; + padding: 1rem; +} + +/* Error Display */ +.rest-api-error-display { + margin-top: 2rem; + padding: 1.5rem; + background-color: #fef2f2; + border: 1px solid #fecaca; + border-left: 4px solid #dc2626; + border-radius: 0.5rem; + animation: slideIn 0.3s ease; +} + +.rest-api-error-display--active { + display: block; +} + +.rest-api-error-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.rest-api-error-icon { + font-size: 1.5rem; +} + +.rest-api-error-header h4 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: #991b1b; +} + +.rest-api-error-message { + margin: 0 0 0.75rem 0; + color: #7f1d1d; + font-size: 0.95rem; +} + +.rest-api-error-details { + margin: 0.75rem 0 0 0; +} + +.rest-api-error-details summary { + cursor: pointer; + font-weight: 600; + color: #991b1b; + padding: 0.5rem; + user-select: none; +} + +.rest-api-error-details summary:hover { + background-color: rgba(220, 38, 38, 0.1); + border-radius: 0.375rem; +} + +.rest-api-error-details pre { + margin: 0.75rem 0 0 0; + padding: 0.75rem; + background-color: rgba(220, 38, 38, 0.05); + border-radius: 0.375rem; + font-size: 0.8rem; + overflow-x: auto; + font-family: 'Monaco', 'Courier New', monospace; + color: #7f1d1d; +} + +/* Request Section */ +.rest-api-section--try .rest-api-execute-section { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid #e5e7eb; +} + +.rest-api-execute-section h4 { + margin: 0 0 1rem 0; + font-size: 1rem; + font-weight: 600; + color: #111827; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .rest-api-response-meta { + flex-direction: column; + gap: 0.5rem; + align-items: flex-start; + } + + .rest-api-response-body { + max-height: 300px; + font-size: 0.75rem; + } + + .rest-api-execute-button { + width: 100%; + justify-content: center; + } +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .rest-api-response-display { + background-color: #111827; + border-color: #374151; + } + + .rest-api-response-header { + border-bottom-color: #374151; + color: #f3f4f6; + } + + .rest-api-response-header h4 { + color: #f3f4f6; + } + + .rest-api-response-status { + background-color: #064e3b; + color: #d1fae5; + } + + .rest-api-url-preview { + background-color: #1f2937; + border-left-color: #60a5fa; + color: #e5e7eb; + } + + .rest-api-parameter-input input, + .rest-api-parameter-input textarea { + background-color: #1f2937; + border-color: #374151; + color: #f3f4f6; + } + + .rest-api-parameter-input label { + color: #e5e7eb; + } +} diff --git a/layouts/docs/rest-apis.html b/layouts/docs/rest-apis.html index 27518e30cb..49dae381b4 100644 --- a/layouts/docs/rest-apis.html +++ b/layouts/docs/rest-apis.html @@ -21,6 +21,8 @@

{{ .Title }}

{{ partial "page-meta-lastmod.html" . }} + + {{ partial "video-section-related.html" . -}}
{{ partial "recent-discussions.html" . -}} diff --git a/layouts/partials/rest-apis/auth-input.html b/layouts/partials/rest-apis/auth-input.html new file mode 100644 index 0000000000..4d7045547b --- /dev/null +++ b/layouts/partials/rest-apis/auth-input.html @@ -0,0 +1,26 @@ +{{/* + Authentication Input Partial + Provides bearer token input for authenticated requests + Context expects: operationId, requiresAuth +*/}} +{{ if .requiresAuth }} +
+ + +

+ Provide a security token for authentication. + + Learn how to generate tokens + +

+
+{{ end }} diff --git a/layouts/partials/rest-apis/execute-button.html b/layouts/partials/rest-apis/execute-button.html new file mode 100644 index 0000000000..2221d7335e --- /dev/null +++ b/layouts/partials/rest-apis/execute-button.html @@ -0,0 +1,15 @@ +{{/* + Execute Button Partial + Provides the button to trigger API request execution + Context expects: operationId, method, path +*/}} + diff --git a/layouts/partials/rest-apis/parameter-inputs.html b/layouts/partials/rest-apis/parameter-inputs.html new file mode 100644 index 0000000000..39b963f004 --- /dev/null +++ b/layouts/partials/rest-apis/parameter-inputs.html @@ -0,0 +1,75 @@ +{{/* + Parameter Input Partial + Renders interactive input fields for API parameters + Context expects: parameters (array), operationId +*/}} +{{ if gt (len .parameters) 0 }} + {{ $locations := slice + (dict "key" "path" "label" "Path parameters") + (dict "key" "query" "label" "Query parameters") + (dict "key" "header" "label" "Header parameters") + }} + + {{ range $locations }} + {{ $rows := where $.parameters "in" .key }} + {{ if gt (len $rows) 0 }} +
+

{{ .label }}

+ {{ range $rows }} + {{ $paramSchema := index . "schema" | default dict }} + {{ $paramType := partial "rest-apis/schema-type.html" (dict "schema" $paramSchema "schemas" $.schemas) | strings.TrimSpace }} + {{ $isRequired := .required | default false }} + {{ $paramId := printf "param-%s-%s-%s" $.operationId .in .name }} + +
+ + + {{ if eq $paramType "boolean" }} + + {{ else if eq $paramType "integer" }} + + {{ else }} + + {{ end }} + + {{ with .description }} +

{{ . | markdownify }}

+ {{ end }} +
+ {{ end }} +
+ {{ end }} + {{ end }} +{{ end }} diff --git a/layouts/partials/rest-apis/server-selector.html b/layouts/partials/rest-apis/server-selector.html new file mode 100644 index 0000000000..3ae551ae80 --- /dev/null +++ b/layouts/partials/rest-apis/server-selector.html @@ -0,0 +1,21 @@ +{{/* + Server Selector Partial + Allows users to select which API server to target + Context expects: servers, operationId +*/}} +{{ if gt (len .servers) 0 }} +
+ + +
+{{ end }} diff --git a/layouts/partials/rest-apis/url-preview.html b/layouts/partials/rest-apis/url-preview.html new file mode 100644 index 0000000000..8a7d18705b --- /dev/null +++ b/layouts/partials/rest-apis/url-preview.html @@ -0,0 +1,12 @@ +{{/* + URL Preview Partial + Shows the complete URL that will be requested + Context expects: operationId, path, servers +*/}} +
+ {{ if gt (len .servers) 0 }} + {{ index (index .servers 0) "url" }}{{ .path }} + {{ else }} + https://cloud.layer5.io{{ .path }} + {{ end }} +
diff --git a/layouts/partials/rest-apis/viewer.html b/layouts/partials/rest-apis/viewer.html index 5bac898e59..de59bce509 100644 --- a/layouts/partials/rest-apis/viewer.html +++ b/layouts/partials/rest-apis/viewer.html @@ -372,7 +372,8 @@

Select an endpoint

{{ $servers = $servers | append $defaultCloudServer }} {{ end }} -
+
{{ upper .method }} @@ -403,12 +404,32 @@

Try this endpoint

{{ range $server := $servers }} {{ $serverUrl := index $server "url" | default "" }} {{ $serverDescription := index $server "description" | default "Layer5 Cloud URL" }} -
+

{{ $serverDescription }}

{{ printf "%s%s" $serverUrl $operationPath }}
{{ end }}
+ + {{ if eq .method "get" }} +
+

Execute directly

+ + {{ partial "rest-apis/server-selector.html" (dict "servers" $servers "operationId" $operationId) }} + + {{ partial "rest-apis/url-preview.html" (dict "operationId" $operationId "path" $operationPath "servers" $servers) }} + + {{ if $authSummary }} + {{ partial "rest-apis/auth-input.html" (dict "operationId" $operationId "requiresAuth" true) }} + {{ end }} + + {{ if gt (len $parameters) 0 }} + {{ partial "rest-apis/parameter-inputs.html" (dict "operationId" $operationId "parameters" $parameters "schemas" $schemas) }} + {{ end }} + + {{ partial "rest-apis/execute-button.html" (dict "operationId" $operationId "method" .method "path" $operationPath) }} +
+ {{ end }} {{ if gt (len $parameters) 0 }} diff --git a/static/js/rest-api-executor.js b/static/js/rest-api-executor.js new file mode 100644 index 0000000000..822ffd6671 --- /dev/null +++ b/static/js/rest-api-executor.js @@ -0,0 +1,519 @@ +/** + * REST API Executor - Handles interactive API requests from documentation + * Supports GET requests with parameter substitution and response formatting + */ + +class RESTAPIExecutor { + constructor() { + this.activeRequest = null; + this.requestCache = new Map(); + this.init(); + } + + /** + * Initialize the executor by setting up event listeners + */ + init() { + this.setupExecuteButtons(); + this.setupParameterInputs(); + } + + /** + * Set up click handlers for execute buttons + */ + setupExecuteButtons() { + const buttons = document.querySelectorAll('[data-execute-request]'); + buttons.forEach(button => { + button.addEventListener('click', (e) => { + e.preventDefault(); + const operationPanel = button.closest('[data-operation-panel]'); + if (operationPanel) { + this.executeRequest(operationPanel); + } + }); + }); + } + + /** + * Set up input listeners for real-time parameter updates using event delegation + */ + setupParameterInputs() { + document.addEventListener('change', (e) => { + const panel = e.target.closest('[data-operation-panel]'); + if (panel) { + if (e.target.matches('[data-parameter-input]') || e.target.matches('[data-server-select]')) { + this.updatePreviewUrl(panel); + } + } + }); + document.addEventListener('input', (e) => { + if (e.target.matches('[data-parameter-input]')) { + const panel = e.target.closest('[data-operation-panel]'); + if (panel) this.updatePreviewUrl(panel); + } + }); + } + + /** + * Execute an API request for the given operation panel + * @param {Element} operationPanel - The operation panel element + */ + async executeRequest(operationPanel) { + if (!operationPanel) { + console.error('Operation panel not found'); + return; + } + + try { + // Get operation metadata from data attributes + const operation = this.getOperationMetadata(operationPanel); + + // Validate required parameters before executing + const validationError = this.validateRequiredParameters(operationPanel); + if (validationError) { + this.displayError(operationPanel, validationError); + return; + } + + // Build the complete URL with parameters + const url = this.buildRequestUrl(operationPanel, operation); + + // Extract headers (including auth token if provided) + const headers = this.buildRequestHeaders(operationPanel); + + // Show loading state + this.setLoadingState(operationPanel, true); + + // Abort previous request if any + if (this.activeRequest) { + this.activeRequest.abort(); + } + + // Execute the request + const controller = new AbortController(); + this.activeRequest = controller; + + const startTime = performance.now(); + const response = await fetch(url, { + method: 'GET', + headers: headers, + signal: controller.signal, + mode: 'cors', + }); + const endTime = performance.now(); + const duration = endTime - startTime; + + // Parse response + const contentType = response.headers.get('content-type'); + let responseData; + let responseText; + + if (contentType && contentType.includes('application/json')) { + responseText = await response.text(); + try { + responseData = JSON.parse(responseText); + } catch (e) { + responseData = null; + } + } else { + responseText = await response.text(); + responseData = null; + } + + // Display response + this.displayResponse(operationPanel, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + data: responseData, + text: responseText, + duration: duration, + url: url, + success: response.ok, + }); + + this.setLoadingState(operationPanel, false); + } catch (error) { + if (error.name === 'AbortError') { + this.setLoadingState(operationPanel, false); + return; + } + + try { + this.displayError(operationPanel, error); + } catch (displayError) { + console.error('Error displaying error:', displayError); + } + this.setLoadingState(operationPanel, false); + } + } + + /** + * Validate that all required parameters are provided + * @param {Element} operationPanel - The operation panel element + * @returns {Error|null} Error object if validation fails, null otherwise + */ + validateRequiredParameters(operationPanel) { + const requiredInputs = operationPanel.querySelectorAll('[data-parameter-input][required]'); + const missingParams = []; + + requiredInputs.forEach(input => { + if (!input.value || input.value.trim() === '') { + const paramName = input.dataset.parameterName; + const paramLocation = input.dataset.parameterLocation; + missingParams.push(`${paramName} (${paramLocation})`); + } + }); + + if (missingParams.length > 0) { + const error = new Error(`Required parameters are missing: ${missingParams.join(', ')}`); + error.validationError = true; + return error; + } + + return null; + } + + /** + * Get operation metadata from the panel + * @param {Element} operationPanel - The operation panel element + * @returns {Object} Operation metadata + */ + getOperationMetadata(operationPanel) { + return { + id: operationPanel.dataset.operationId, + pathId: operationPanel.dataset.pathId, + method: this.extractMethod(operationPanel), + path: this.extractPath(operationPanel), + servers: this.extractServers(operationPanel), + }; + } + + /** + * Extract HTTP method from panel + * @param {Element} operationPanel - The operation panel element + * @returns {string} HTTP method + */ + extractMethod(operationPanel) { + return operationPanel.dataset.operationMethod || 'get'; + } + + /** + * Extract path from panel + * @param {Element} operationPanel - The operation panel element + * @returns {string} API path + */ + extractPath(operationPanel) { + const pathElement = operationPanel.querySelector('.rest-api-operation__path'); + return pathElement ? pathElement.textContent.trim() : ''; + } + + /** + * Extract available servers from panel + * @param {Element} operationPanel - The operation panel element + * @returns {Array} Server URLs from the server selector dropdown + */ + extractServers(operationPanel) { + const serverSelect = operationPanel.querySelector('[data-server-select]'); + if (!serverSelect) return ['https://cloud.layer5.io']; + + const servers = []; + const options = serverSelect.querySelectorAll('option'); + options.forEach(option => { + const url = option.value; + if (url && !servers.includes(url)) { + servers.push(url); + } + }); + + return servers.length > 0 ? servers : ['https://cloud.layer5.io']; + } + + /** + * Build the complete request URL with parameters + * @param {Element} operationPanel - The operation panel element + * @param {Object} operation - Operation metadata + * @returns {string} Complete URL + */ + buildRequestUrl(operationPanel, operation) { + let path = operation.path; + let baseUrl = this.getSelectedServer(operationPanel) || operation.servers[0]; + + // Replace path parameters + const pathParams = this.getParameterValues(operationPanel, 'path'); + Object.entries(pathParams).forEach(([name, value]) => { + path = path.replace(`{${name}}`, encodeURIComponent(value)); + }); + + // Build query string + const queryParams = this.getParameterValues(operationPanel, 'query'); + const queryString = this.buildQueryString(queryParams); + + return `${baseUrl}${path}${queryString}`; + } + + /** + * Build query string from parameters + * @param {Object} params - Query parameters + * @returns {string} Query string + */ + buildQueryString(params) { + const entries = Object.entries(params) + .filter(([, value]) => value !== null && value !== '') + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`); + + return entries.length > 0 ? `?${entries.join('&')}` : ''; + } + + /** + * Get parameter values from inputs + * @param {Element} operationPanel - The operation panel element + * @param {string} location - Parameter location (path, query, or header) + * @returns {Object} Parameter values + */ + getParameterValues(operationPanel, location) { + const params = {}; + const inputs = operationPanel.querySelectorAll( + `[data-parameter-input][data-parameter-location="${location}"]` + ); + + inputs.forEach(input => { + const name = input.dataset.parameterName; + if (name) { + params[name] = input.value; + } + }); + + return params; + } + + /** + * Get the selected server URL + * @param {Element} operationPanel - The operation panel element + * @returns {string|null} Selected server URL + */ + getSelectedServer(operationPanel) { + const select = operationPanel.querySelector('[data-server-select]'); + return select ? select.value : null; + } + + /** + * Build request headers + * @param {Element} operationPanel - The operation panel element + * @returns {Object} Headers object + */ + buildRequestHeaders(operationPanel) { + const headers = { + 'Accept': 'application/json', + }; + + // Add custom headers if provided + const headerInputs = this.getParameterValues(operationPanel, 'header'); + Object.assign(headers, headerInputs); + + // Add authorization token if provided + const authInput = operationPanel.querySelector('[data-auth-token-input]'); + if (authInput && authInput.value) { + headers['Authorization'] = `Bearer ${authInput.value}`; + } + + return headers; + } + + /** + * Update the preview URL when parameters change + * @param {Element} operationPanel - Optional specific panel to update. If not provided, updates all panels. + */ + updatePreviewUrl(operationPanel) { + const panels = operationPanel ? [operationPanel] : document.querySelectorAll('[data-operation-panel]'); + panels.forEach(panel => { + const preview = panel.querySelector('[data-url-preview]'); + if (preview) { + const operation = this.getOperationMetadata(panel); + const url = this.buildRequestUrl(panel, operation); + preview.textContent = url; + } + }); + } + + /** + * Display API response in the UI + * @param {Element} operationPanel - The operation panel element + * @param {Object} response - Response data + */ + displayResponse(operationPanel, response) { + try { + let responseSection = operationPanel.querySelector('.rest-api-response-display'); + + if (!responseSection) { + responseSection = this.createResponseSection(operationPanel); + } + + // Update status + const statusElement = responseSection.querySelector('[data-response-status]'); + if (statusElement) { + statusElement.textContent = `${response.status} ${response.statusText}`; + statusElement.className = `rest-api-response-status ${response.success ? 'success' : 'error'}`; + } + + // Update duration + const durationElement = responseSection.querySelector('[data-response-duration]'); + if (durationElement) { + durationElement.textContent = `${response.duration.toFixed(0)}ms`; + } + + // Update body + const bodyElement = responseSection.querySelector('[data-response-body]'); + if (bodyElement) { + if (response.data) { + bodyElement.textContent = JSON.stringify(response.data, null, 2); + bodyElement.classList.add('language-json'); + } else if (response.text) { + bodyElement.textContent = response.text; + } else { + bodyElement.textContent = '(empty response)'; + } + + // Re-highlight if using a syntax highlighter + if (window.hljs) { + window.hljs.highlightElement(bodyElement); + } + } + + responseSection.style.display = 'block'; + } catch (err) { + console.error('Error displaying response:', err); + } + } + + /** + * Create the response display section + * @param {Element} operationPanel - The operation panel element + * @returns {Element} Response section + */ + createResponseSection(operationPanel) { + try { + const section = document.createElement('section'); + section.className = 'rest-api-response-display'; + section.innerHTML = ` +
+

Response

+
+ 200 OK + 0ms +
+
+
+ `; + + // Find insertion point (after request section or at end) + const requestSection = operationPanel.querySelector('.rest-api-section--try') || + operationPanel.lastElementChild; + if (requestSection) { + requestSection.after(section); + } else { + operationPanel.appendChild(section); + } + + return section; + } catch (err) { + console.error('Error creating response section:', err); + throw err; + } + } + + /** + * Display an error message + * @param {Element} operationPanel - The operation panel element + * @param {Error} error - The error object + */ + displayError(operationPanel, error) { + const errorMessage = error.message || 'An error occurred while executing the request'; + const detailedMessage = this.getDetailedError(error); + + const errorSection = document.createElement('div'); + errorSection.className = 'rest-api-error-display'; + errorSection.innerHTML = ` +
+ ⚠️ +

Error

+
+

${this.escapeHtml(errorMessage)}

+ ${detailedMessage ? `
+ Details +
${this.escapeHtml(detailedMessage)}
+
` : ''} + `; + + // Replace or create response section + let responseSection = operationPanel.querySelector('.rest-api-response-display'); + if (responseSection) { + responseSection.replaceWith(errorSection); + } else { + const requestSection = operationPanel.querySelector('.rest-api-section--try'); + if (requestSection) { + requestSection.after(errorSection); + } else { + operationPanel.appendChild(errorSection); + } + } + + errorSection.classList.add('rest-api-error-display--active'); + } + + /** + * Get detailed error information + * @param {Error} error - The error object + * @returns {string} Detailed error message + */ + getDetailedError(error) { + if (error instanceof TypeError && error.message.includes('Failed to fetch')) { + return 'CORS Error: The API server may not allow requests from this domain. Try using a CORS proxy or configuring CORS headers on the API server.'; + } + + if (error.response && error.response.statusText) { + return `${error.response.status} ${error.response.statusText}`; + } + + return error.stack || error.message; + } + + /** + * Set loading state + * @param {Element} operationPanel - The operation panel element + * @param {boolean} isLoading - Whether loading + */ + setLoadingState(operationPanel, isLoading) { + const button = operationPanel.querySelector('[data-execute-request]'); + if (button) { + button.disabled = isLoading; + button.classList.toggle('is-loading', isLoading); + button.textContent = isLoading ? 'Executing...' : 'Execute Request'; + } + } + + /** + * Escape HTML to prevent XSS + * @param {string} text - Text to escape + * @returns {string} Escaped text + */ + escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return text.replace(/[&<>"']/g, m => map[m]); + } +} + +// Initialize when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + window.restAPIExecutor = new RESTAPIExecutor(); + }); +} else { + window.restAPIExecutor = new RESTAPIExecutor(); +}