UNPKG

@flavoai/fastfold

Version:

Zero-boilerplate backend for React apps with auto-generated CRUD and declarative security

665 lines (607 loc) 27.1 kB
export class ApiExplorer { docGenerator; constructor(docGenerator) { this.docGenerator = docGenerator; } /** * Generate HTML for the API explorer */ generateHtml() { const docs = this.docGenerator.generateDocumentation(); return ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${docs.info.title} - API Explorer!!!</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; background: #f8f9fa; } .container { max-width: 1200px; margin: 0 auto; padding: 20px; } .header { background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 30px; } .header h1 { color: #2563eb; margin-bottom: 10px; } .header p { color: #6b7280; font-size: 16px; } .base-url { background: #f3f4f6; padding: 10px 15px; border-radius: 6px; font-family: 'Monaco', 'Menlo', monospace; margin-top: 15px; font-size: 14px; } .tables-section, .endpoints-section { background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 30px; } .section-title { font-size: 24px; margin-bottom: 20px; color: #1f2937; border-bottom: 2px solid #e5e7eb; padding-bottom: 10px; } .table-card { border: 1px solid #e5e7eb; border-radius: 6px; margin-bottom: 20px; overflow: hidden; } .table-header { background: #f9fafb; padding: 15px 20px; border-bottom: 1px solid #e5e7eb; } .table-name { font-size: 18px; font-weight: 600; color: #1f2937; } .table-security { font-size: 12px; color: #6b7280; margin-top: 5px; padding: 4px 8px; background: #fef3c7; border-radius: 4px; display: inline-block; } .table-schema { padding: 20px; } .schema-field { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f3f4f6; } .field-name { font-weight: 500; color: #374151; } .field-type { color: #7c3aed; font-family: 'Monaco', 'Menlo', monospace; font-size: 13px; background: #f3f4f6; padding: 2px 6px; border-radius: 3px; } .endpoint { border: 1px solid #e5e7eb; border-radius: 6px; margin-bottom: 15px; overflow: hidden; } .endpoint-header { background: #f9fafb; padding: 15px 20px; display: flex; align-items: center; gap: 15px; } .endpoint-header-content { cursor: pointer; display: flex; align-items: center; gap: 15px; flex: 1; } .endpoint-header-content:hover { background: rgba(0,0,0,0.02); border-radius: 4px; margin: -4px; padding: 4px; } .endpoint-header .try-button { cursor: pointer; margin-top: 0; } .endpoint-header:hover { background: #f3f4f6; } .method { font-weight: 600; padding: 4px 8px; border-radius: 4px; font-size: 12px; min-width: 60px; text-align: center; } .method.GET { background: #dcfce7; color: #166534; } .method.POST { background: #fef3c7; color: #92400e; } .method.PUT { background: #dbeafe; color: #1e40af; } .method.DELETE { background: #fee2e2; color: #dc2626; } .endpoint-path { font-family: 'Monaco', 'Menlo', monospace; font-size: 14px; flex: 1; } .endpoint-description { color: #6b7280; font-size: 14px; } .endpoint-details { padding: 20px; display: none; border-top: 1px solid #e5e7eb; } .endpoint-details.active { display: block; } .detail-section { margin-bottom: 20px; } .detail-title { font-weight: 600; margin-bottom: 10px; color: #374151; } .parameter, .response { background: #f9fafb; padding: 10px; border-radius: 4px; margin-bottom: 10px; } .param-name { font-weight: 500; color: #7c3aed; } .param-type { color: #059669; font-size: 12px; } .param-required { color: #dc2626; font-size: 12px; } .code-block { background: #1f2937; color: #f9fafb; padding: 15px; border-radius: 6px; font-family: 'Monaco', 'Menlo', monospace; font-size: 13px; overflow-x: auto; margin-top: 10px; } .response-status { font-weight: 600; margin-right: 10px; } .status-200 { color: #059669; } .status-201 { color: #059669; } .status-400 { color: #d97706; } .status-403 { color: #dc2626; } .status-404 { color: #dc2626; } .status-500 { color: #dc2626; } .try-button { background: #2563eb; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-top: 10px; } .try-button:hover { background: #1d4ed8; } </style> </head> <body> <div class="container"> <div class="header"> <h1>${docs.info.title}</h1> <p>${docs.info.description}</p> <div class="base-url">Base URL: ${docs.info.baseUrl}</div> </div> <div class="tables-section"> <h2 class="section-title">📊 Data Tables</h2> ${this.generateTablesHtml(docs.tables)} </div> <div class="endpoints-section"> <h2 class="section-title">🚀 API Endpoints</h2> ${this.generateEndpointsHtml(docs.endpoints)} </div> </div> <script> // Toggle endpoint details document.querySelectorAll('.endpoint-header-content').forEach(headerContent => { headerContent.addEventListener('click', () => { const header = headerContent.parentElement; const details = header.nextElementSibling; const isActive = details.classList.contains('active'); // Close all others document.querySelectorAll('.endpoint-details').forEach(d => d.classList.remove('active')); // Toggle current if (!isActive) { details.classList.add('active'); } }); }); // Add event listeners for try buttons document.querySelectorAll('.try-button').forEach(button => { button.addEventListener('click', () => { const method = button.getAttribute('data-method'); const path = button.getAttribute('data-path'); const hasBody = button.getAttribute('data-has-body') === 'true'; const endpointId = button.getAttribute('data-endpoint-id'); tryEndpoint(method, path, hasBody, endpointId); }); }); // Table schemas for generating examples const tableSchemas = ${JSON.stringify(docs.tables)}; // Helper function to generate example request body function generateExampleRequestBody(schema) { if (!schema || Object.keys(schema).length === 0) return { "key": "value" }; const example = {}; for (const [fieldName, fieldType] of Object.entries(schema)) { // Skip auto-generated fields like id, created_at, updated_at if (fieldName === 'id' || fieldName === 'created_at' || fieldName === 'updated_at') { continue; } switch (fieldType.toLowerCase()) { case 'string': example[fieldName] = fieldName === 'email' ? 'user@example.com' : fieldName === 'name' ? 'John Doe' : fieldName === 'title' ? 'Sample Title' : \`sample_\${fieldName}\`; break; case 'number': case 'integer': example[fieldName] = fieldName === 'age' ? 25 : fieldName === 'price' ? 99.99 : 42; break; case 'boolean': example[fieldName] = fieldName === 'active' || fieldName === 'published' ? true : false; break; case 'date': case 'datetime': example[fieldName] = '2024-01-01T00:00:00.000Z'; break; case 'json': example[fieldName] = { "key": "value" }; break; case 'text': example[fieldName] = fieldName === 'content' || fieldName === 'description' ? 'This is a sample content or description text.' : \`sample_\${fieldName}_text\`; break; default: example[fieldName] = \`sample_\${fieldName}\`; } } return example; } // Helper function to get security description function getSecurityDescription(security) { if (!security) return 'No security configured'; if (security === 'Public access - no authentication required') return security; if (security === 'Admin only - requires admin role') return security; if (security === 'Owner-based access - users can only access their own records') return security; if (security === 'Team-based access - users can access records from their team') return security; if (security === 'Authenticated users only') return security; return security || 'Custom security rule'; } // Try endpoint functionality with live API calls async function tryEndpoint(method, path, hasBody = false, endpointId) { const baseUrl = '${docs.info.baseUrl}'; let fullUrl = baseUrl + path; // Create modal for input const modal = createTryModal(method, path, hasBody, endpointId); document.body.appendChild(modal); // Show modal modal.style.display = 'flex'; } const createTryModal = (method, path, hasBody, endpointId) => { const modal = document.createElement('div'); modal.style.cssText = \` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); display: none; align-items: center; justify-content: center; z-index: 1000; \`; const modalContent = document.createElement('div'); modalContent.style.cssText = \` background: white; padding: 30px; border-radius: 8px; width: 80%; max-width: 800px; max-height: 80vh; overflow-y: auto; position: relative; \`; let pathInputs = ''; const pathParams = path.match(/{([^}]+)}/g); if (pathParams) { pathInputs = pathParams.map(param => { const paramName = param.slice(1, -1); return \` <div style="margin-bottom: 15px;"> <label style="display: block; font-weight: 600; margin-bottom: 5px;"> \${paramName} (path parameter): </label> <input type="text" id="param-\${paramName}" placeholder="Enter \${paramName}" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" required> </div> \`; }).join(''); } let bodyInput = ''; if (hasBody) { // Extract table name from path (e.g., /api/blog -> blog) const tableName = path.split('/')[2]; const tableSchema = tableSchemas[tableName]?.schema || {}; const exampleBody = generateExampleRequestBody(tableSchema); bodyInput = \` <div style="margin-bottom: 15px;"> <label style="display: block; font-weight: 600; margin-bottom: 5px;">Request Body (JSON):</label> <div style="margin-bottom: 8px; font-size: 12px; color: #6b7280;"> Fields: \${Object.entries(tableSchema).map(([field, type]) => \`<strong>\${field}</strong> (\${type})\`).join(', ')} </div> <textarea id="request-body" rows="8" placeholder='\${JSON.stringify(exampleBody, null, 2)}' style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-family: monospace;">\${JSON.stringify(exampleBody, null, 2)}</textarea> </div> \`; } // Get security info for this endpoint const tableName = path.split('/')[2]; const tableInfo = tableSchemas[tableName]; const securityInfo = tableInfo ? getSecurityDescription(tableInfo.security) : 'Unknown security'; modalContent.innerHTML = \` <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;"> <div> <h3>Try \${method} \${path}</h3> <div style="font-size: 12px; color: #6b7280; margin-top: 4px;"> 🔒 Security: \${securityInfo} </div> </div> <button class="modal-close-btn" style="background: none; border: none; font-size: 24px; cursor: pointer;">&times;</button> </div> <form id="try-form"> <div style="margin-bottom: 15px;"> <label style="display: block; font-weight: 600; margin-bottom: 5px;">Authorization (optional):</label> <div style="display: flex; gap: 8px; margin-bottom: 8px;"> <input type="text" id="auth-token" placeholder="Bearer your-jwt-token-here" style="flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-family: monospace;"> <button type="button" id="generate-test-token" style="background: #10b981; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;"> Test Token </button> </div> <div style="font-size: 11px; color: #6b7280;"> For secured endpoints, provide a JWT token. Click "Test Token" for a sample token. </div> </div> \${pathInputs} \${bodyInput} <div style="margin-bottom: 20px;"> <button type="submit" style="background: #2563eb; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer;"> Send Request </button> </div> </form> <div id="response-section" style="display: none;"> <h4>Response:</h4> <div id="response-status" style="margin-bottom: 10px; font-weight: 600;"></div> <pre id="response-body" style="background: #f5f5f5; padding: 15px; border-radius: 4px; overflow-x: auto; white-space: pre-wrap; font-size: 13px;"></pre> </div> \`; modal.appendChild(modalContent); modal.className = 'modal-backdrop'; // Handle close button const closeBtn = modalContent.querySelector('.modal-close-btn'); closeBtn.addEventListener('click', () => { modal.remove(); }); // Handle test token generation const testTokenBtn = modalContent.querySelector('#generate-test-token'); const authInput = modalContent.querySelector('#auth-token'); testTokenBtn.addEventListener('click', () => { // Show instructions for getting a real token alert(\`To test authenticated endpoints: 1. First, you need to configure your Fastfold server with an auth secret 2. Create a real JWT token with your auth system 3. Or use the public 'blog' table for testing (no auth required) For owner-based security like userProfiles: - The userId in the request body must match the user ID in your JWT token - Example: if your JWT has id="user123", set userId="user123" in the request body\`); // For demonstration, show what a token would look like authInput.value = 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6InVzZXIxMjMiLCJyb2xlIjoidXNlciJ9.your-signature-here'; }); // Handle form submission const form = modalContent.querySelector('#try-form'); form.addEventListener('submit', async (e) => { e.preventDefault(); await executeRequest(method, path, hasBody, modalContent); }); return modal; } async function executeRequest(method, path, hasBody, modalContent) { const baseUrl = '${docs.info.baseUrl}'; let finalPath = path; // Replace path parameters const pathParams = path.match(/{([^}]+)}/g); if (pathParams) { pathParams.forEach(param => { const paramName = param.slice(1, -1); const input = modalContent.querySelector(\`#param-\${paramName}\`); if (input && input.value) { finalPath = finalPath.replace(param, input.value); } }); } const fullUrl = baseUrl + finalPath; const requestOptions = { method: method, headers: { 'Content-Type': 'application/json', } }; // Add authorization header if provided const authInput = modalContent.querySelector('#auth-token'); if (authInput && authInput.value.trim()) { let authValue = authInput.value.trim(); // Add 'Bearer ' prefix if not already present if (!authValue.startsWith('Bearer ')) { authValue = 'Bearer ' + authValue; } requestOptions.headers['Authorization'] = authValue; } if (hasBody) { const bodyInput = modalContent.querySelector('#request-body'); if (bodyInput && bodyInput.value.trim()) { try { requestOptions.body = bodyInput.value; } catch (e) { alert('Invalid JSON in request body'); return; } } } const responseSection = modalContent.querySelector('#response-section'); const responseStatus = modalContent.querySelector('#response-status'); const responseBody = modalContent.querySelector('#response-body'); try { responseSection.style.display = 'block'; responseStatus.textContent = 'Loading...'; responseBody.textContent = ''; const response = await fetch(fullUrl, requestOptions); const responseText = await response.text(); let responseData; try { responseData = JSON.parse(responseText); } catch (e) { responseData = responseText; } responseStatus.innerHTML = \` <span style="color: \${response.ok ? '#059669' : '#dc2626'};"> \${response.status} \${response.statusText} </span> \`; responseBody.textContent = typeof responseData === 'object' ? JSON.stringify(responseData, null, 2) : responseData; } catch (error) { responseSection.style.display = 'block'; responseStatus.innerHTML = '<span style="color: #dc2626;">Network Error</span>'; responseBody.textContent = error.message; } } </script> </body> </html>`; } /** * Generate HTML for tables section */ generateTablesHtml(tables) { return Object.entries(tables) .map(([tableName, tableInfo]) => ` <div class="table-card"> <div class="table-header"> <div class="table-name">${tableName}</div> <div class="table-security">🔒 ${tableInfo.security}</div> </div> <div class="table-schema"> ${Object.entries(tableInfo.schema) .map(([fieldName, fieldType]) => ` <div class="schema-field"> <span class="field-name">${fieldName}</span> <span class="field-type">${fieldType}</span> </div> `).join('')} </div> </div> `).join(''); } /** * Generate HTML for endpoints section */ generateEndpointsHtml(endpoints) { return endpoints .map(endpoint => ` <div class="endpoint"> <div class="endpoint-header"> <div class="endpoint-header-content"> <span class="method ${endpoint.method}">${endpoint.method}</span> <span class="endpoint-path">${endpoint.path}</span> <span class="endpoint-description">${endpoint.description}</span> </div> <div style="margin-left: auto;"> ${this.generateTryButton(endpoint)} </div> </div> <div class="endpoint-details"> ${this.generateEndpointDetailsHtml(endpoint)} </div> </div> `).join(''); } /** * Generate HTML for endpoint details */ generateEndpointDetailsHtml(endpoint) { let html = ''; // Parameters if (endpoint.parameters && endpoint.parameters.length > 0) { html += ` <div class="detail-section"> <div class="detail-title">Parameters</div> ${endpoint.parameters.map((param) => ` <div class="parameter"> <span class="param-name">${param.name}</span> <span class="param-type">(${param.type})</span> ${param.required ? '<span class="param-required">required</span>' : ''} <div>${param.description}</div> </div> `).join('')} </div> `; } // Request Body if (endpoint.requestBody) { html += ` <div class="detail-section"> <div class="detail-title">Request Body</div> <div>${endpoint.requestBody.description}</div> <div class="code-block">${JSON.stringify(endpoint.requestBody.schema.example || {}, null, 2)}</div> </div> `; } // Responses if (endpoint.responses && endpoint.responses.length > 0) { html += ` <div class="detail-section"> <div class="detail-title">Responses</div> ${endpoint.responses.map((response) => ` <div class="response"> <span class="response-status status-${response.status}">${response.status}</span> ${response.description} ${response.schema ? `<div class="code-block">${JSON.stringify(response.schema.example || response.schema, null, 2)}</div>` : ''} </div> `).join('')} </div> `; } return html; } /** * Generate Try button for an endpoint */ generateTryButton(endpoint) { const hasBody = endpoint.method === 'POST' || endpoint.method === 'PUT'; const endpointId = `${endpoint.method.toLowerCase()}-${endpoint.path.replace(/[^a-zA-Z0-9]/g, '-')}`; return ` <button class="try-button" data-method="${endpoint.method}" data-path="${endpoint.path}" data-has-body="${hasBody}" data-endpoint-id="${endpointId}"> Try it out </button> `; } } //# sourceMappingURL=explorer.js.map