Created
February 13, 2025 23:34
-
-
Save Braunson/4e5648afce8392312b7c047afcfa2aee to your computer and use it in GitHub Desktop.
Zero-install, zero dependencies, high-performance API testing tool using Alpine.js and Tailwind CSS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>API Request Builder</title> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/alpinejs/3.13.5/cdn.min.js" defer></script> | |
<script src="https://unpkg.com/@tailwindcss/browser@4"></script> | |
</head> | |
<body class="bg-gray-100 min-h-screen p-4"> | |
<div class="max-w-6xl mx-auto" x-data="apiBuilder()"> | |
<div class="bg-white rounded-lg shadow-lg p-6 space-y-6"> | |
<h1 class="text-2xl font-bold text-gray-800">API Request Builder</h1> | |
<!-- URL and Method --> | |
<div class="flex gap-4"> | |
<select | |
x-model="method" | |
class="px-3 py-2 border rounded-md bg-white" | |
> | |
<option>GET</option> | |
<option>POST</option> | |
<option>PUT</option> | |
<option>PATCH</option> | |
<option>DELETE</option> | |
<option>HEAD</option> | |
</select> | |
<div class="flex-1 relative"> | |
<input | |
type="text" | |
x-model="url" | |
placeholder="Enter request URL" | |
class="w-full px-3 py-2 border rounded-md" | |
> | |
<!-- Show a preview if using env variables --> | |
<div | |
x-show="url.includes('{{')" | |
class="absolute -bottom-6 left-0 text-xs text-gray-500" | |
x-text="'Preview: ' + replaceEnvironmentVariables(url)" | |
></div> | |
</div> | |
<button | |
@click="sendRequest" | |
:disabled="loading || !url" | |
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed" | |
> | |
<span x-show="!loading">Send Request</span> | |
<span x-show="loading">Sending...</span> | |
</button> | |
</div> | |
<!-- Authentication --> | |
<div class="space-y-4"> | |
<h2 class="text-lg font-medium">Authentication</h2> | |
<select | |
x-model="authType" | |
class="px-3 py-2 border rounded-md bg-white" | |
> | |
<option value="none">No Auth</option> | |
<option value="basic">Basic Auth</option> | |
<option value="bearer">Bearer Token</option> | |
<option value="apiKey">API Key</option> | |
</select> | |
<!-- Basic Auth --> | |
<div x-show="authType === 'basic'" class="space-y-2"> | |
<input | |
type="text" | |
x-model="basicAuth.username" | |
placeholder="Username" | |
class="w-full px-3 py-2 border rounded-md" | |
> | |
<input | |
type="password" | |
x-model="basicAuth.password" | |
placeholder="Password" | |
class="w-full px-3 py-2 border rounded-md" | |
> | |
</div> | |
<!-- Bearer Token --> | |
<div x-show="authType === 'bearer'"> | |
<input | |
type="text" | |
x-model="bearerToken" | |
placeholder="Bearer Token" | |
class="w-full px-3 py-2 border rounded-md" | |
> | |
</div> | |
<!-- API Key --> | |
<div x-show="authType === 'apiKey'" class="space-y-2"> | |
<input | |
type="text" | |
x-model="apiKey.key" | |
placeholder="API Key Name" | |
class="w-full px-3 py-2 border rounded-md" | |
> | |
<input | |
type="text" | |
x-model="apiKey.value" | |
placeholder="API Key Value" | |
class="w-full px-3 py-2 border rounded-md" | |
> | |
<select | |
x-model="apiKey.in" | |
class="w-full px-3 py-2 border rounded-md" | |
> | |
<option value="header">Header</option> | |
<option value="query">Query Parameter</option> | |
</select> | |
</div> | |
<h2 class="text-lg font-medium mb-2 mt-2">Environment</h2> | |
<div class="flex items-center gap-2"> | |
<select | |
x-model="currentEnv" | |
class="px-3 py-2 border rounded-md bg-white text-gray-900" | |
style="min-width: 200px;" | |
> | |
<option value="development">Development</option> | |
<option value="staging">Staging</option> | |
<option value="production">Production</option> | |
</select> | |
<button | |
@click="activeTab = 'environment'" | |
class="text-sm font-medium text-blue-500 hover:text-blue-600 flex items-center gap-1" | |
> | |
<span>Manage Variables</span> | |
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> | |
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" /> | |
</svg> | |
</button> | |
</div> | |
</div> | |
<!-- Tabs --> | |
<div class="border-b border-gray-200"> | |
<nav class="flex space-x-4"> | |
<button | |
@click="activeTab = 'params'" | |
:class="{'border-b-2 border-blue-500': activeTab === 'params'}" | |
class="px-3 py-2 text-sm font-medium" | |
> | |
Query Params | |
</button> | |
<button | |
@click="activeTab = 'headers'" | |
:class="{'border-b-2 border-blue-500': activeTab === 'headers'}" | |
class="px-3 py-2 text-sm font-medium" | |
> | |
Headers | |
</button> | |
<button | |
@click="activeTab = 'body'" | |
:class="{'border-b-2 border-blue-500': activeTab === 'body'}" | |
class="px-3 py-2 text-sm font-medium" | |
> | |
Body | |
</button> | |
<button | |
@click="activeTab = 'environment'" | |
:class="{'border-b-2 border-blue-500': activeTab === 'environment'}" | |
class="px-3 py-2 text-sm font-medium" | |
> | |
Environment | |
</button> | |
<button | |
@click="activeTab = 'curl'" | |
:class="{'border-b-2 border-blue-500': activeTab === 'curl'}" | |
class="px-3 py-2 text-sm font-medium" | |
> | |
cURL | |
</button> | |
<button | |
@click="activeTab = 'history'" | |
:class="{'border-b-2 border-blue-500': activeTab === 'history'}" | |
class="px-3 py-2 text-sm font-medium" | |
> | |
History | |
</button> | |
</nav> | |
</div> | |
<!-- Environment Variables --> | |
<div x-show="activeTab === 'environment'" class="space-y-4"> | |
<div class="flex gap-2 mb-4"> | |
<input | |
type="text" | |
x-model="newVarKey" | |
placeholder="Variable name" | |
class="flex-1 px-3 py-2 border rounded-md" | |
> | |
<input | |
type="text" | |
x-model="newVarValue" | |
placeholder="Value" | |
class="flex-1 px-3 py-2 border rounded-md" | |
> | |
<button | |
@click="addEnvironmentVariable" | |
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600" | |
> | |
Add Variable | |
</button> | |
</div> | |
<div class="space-y-2"> | |
<template x-for="(value, key) in environments[currentEnv]" :key="key"> | |
<div class="flex items-center justify-between p-2 border rounded-md"> | |
<div> | |
<span class="font-medium" x-text="key"></span> | |
<span class="text-gray-500" x-text="': ' + value"></span> | |
</div> | |
<button | |
@click="delete environments[currentEnv][key]" | |
class="text-red-500 hover:text-red-600" | |
> | |
Remove | |
</button> | |
</div> | |
</template> | |
</div> | |
<div class="mt-4 p-4 bg-gray-50 rounded-md"> | |
<h3 class="font-medium mb-2">Usage:</h3> | |
<p class="text-sm text-gray-600"> | |
Use <code class="bg-gray-200 px-1 rounded">{{variable_name}}</code> in your requests to reference environment variables. | |
</p> | |
<p class="text-sm text-gray-600 mt-1"> | |
Example: <code class="bg-gray-200 px-1 rounded">{{base_url}}/api/users</code> | |
</p> | |
</div> | |
</div> | |
<!-- Tab Content --> | |
<div class="space-y-4"> | |
<!-- Query Params --> | |
<div x-show="activeTab === 'params'"> | |
<template x-for="(param, index) in params" :key="index"> | |
<div class="flex gap-2 mb-2"> | |
<input | |
type="text" | |
x-model="param.key" | |
placeholder="Parameter name" | |
class="flex-1 px-3 py-2 border rounded-md" | |
> | |
<input | |
type="text" | |
x-model="param.value" | |
placeholder="Value" | |
class="flex-1 px-3 py-2 border rounded-md" | |
> | |
<button | |
@click="removeRow(params, index)" | |
class="px-3 py-2 text-red-500 hover:text-red-600" | |
> | |
Remove | |
</button> | |
</div> | |
</template> | |
<button | |
@click="addRow(params)" | |
class="text-sm text-blue-500 hover:text-blue-600" | |
> | |
+ Add Parameter | |
</button> | |
</div> | |
<!-- Headers --> | |
<div x-show="activeTab === 'headers'"> | |
<template x-for="(header, index) in headers" :key="index"> | |
<div class="flex gap-2 mb-2"> | |
<input | |
type="text" | |
x-model="header.key" | |
placeholder="Header name" | |
class="flex-1 px-3 py-2 border rounded-md" | |
> | |
<input | |
type="text" | |
x-model="header.value" | |
placeholder="Value" | |
class="flex-1 px-3 py-2 border rounded-md" | |
> | |
<button | |
@click="removeRow(headers, index)" | |
class="px-3 py-2 text-red-500 hover:text-red-600" | |
> | |
Remove | |
</button> | |
</div> | |
</template> | |
<button | |
@click="addRow(headers)" | |
class="text-sm text-blue-500 hover:text-blue-600" | |
> | |
+ Add Header | |
</button> | |
</div> | |
<!-- Request Body --> | |
<div x-show="activeTab === 'body'"> | |
<textarea | |
x-model="requestBody" | |
placeholder="Enter request body (JSON)" | |
class="w-full h-40 px-3 py-2 border rounded-md font-mono text-sm" | |
></textarea> | |
</div> | |
<!-- cURL Command --> | |
<div x-show="activeTab === 'curl'" class="space-y-2"> | |
<pre class="p-4 bg-gray-800 text-white rounded-md overflow-x-auto whitespace-pre-wrap"><code x-text="generateCurl()"></code></pre> | |
</div> | |
<!-- Request History --> | |
<div x-show="activeTab === 'history'"> | |
<div class="space-y-2"> | |
<template x-for="(request, index) in requestHistory" :key="index"> | |
<div class="p-3 border rounded-md hover:bg-gray-50"> | |
<div class="flex items-center justify-between"> | |
<div class="flex items-center space-x-2"> | |
<span | |
x-text="request.method" | |
class="px-2 py-1 text-xs font-medium rounded" | |
:class="{ | |
'bg-green-100 text-green-800': request.method === 'GET', | |
'bg-blue-100 text-blue-800': request.method === 'POST', | |
'bg-yellow-100 text-yellow-800': request.method === 'PUT', | |
'bg-red-100 text-red-800': request.method === 'DELETE' | |
}" | |
></span> | |
<span class="text-sm truncate" x-text="request.url"></span> | |
</div> | |
<div class="flex items-center space-x-3"> | |
<span class="text-xs text-gray-500"> | |
Req: <span x-text="request.requestSize"></span> | |
</span> | |
<span class="text-xs text-gray-500"> | |
Res: <span x-text="request.responseSize"></span> | |
</span> | |
<span | |
class="px-2 py-1 text-xs font-medium rounded" | |
:class="request.success ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'" | |
x-text="request.status" | |
></span> | |
<span class="text-sm text-gray-500" x-text="new Date(request.timestamp).toLocaleTimeString()"></span> | |
</div> | |
</div> | |
</div> | |
</template> | |
</div> | |
</div> | |
</div> | |
<!-- Response Section --> | |
<template x-if="error || response"> | |
<div class="space-y-4"> | |
<div class="flex items-center justify-between"> | |
<h2 class="text-lg font-medium text-gray-800">Response</h2> | |
<div class="flex items-center space-x-4 text-sm text-gray-500"> | |
<span>Request Size: <span x-text="requestSize"></span></span> | |
<span>Response Size: <span x-text="responseSize"></span></span> | |
<span>Response Time: <span x-text="responseTime"></span>ms</span> | |
</div> | |
</div> | |
<!-- Error Message --> | |
<div | |
x-show="error" | |
x-text="error" | |
class="p-4 bg-red-50 text-red-700 rounded-md" | |
></div> | |
<!-- Response Headers --> | |
<template x-if="responseHeaders"> | |
<div class="space-y-2"> | |
<h3 class="text-sm font-medium text-gray-700">Headers:</h3> | |
<div class="p-4 bg-gray-50 rounded-md"> | |
<pre class="text-sm"><code x-text="JSON.stringify(responseHeaders, null, 2)"></code></pre> | |
</div> | |
</div> | |
</template> | |
<!-- Response Body --> | |
<template x-if="response"> | |
<div class="space-y-2"> | |
<h3 class="text-sm font-medium text-gray-700">Body:</h3> | |
<div class="p-4 bg-gray-50 rounded-md overflow-auto"> | |
<pre class="text-sm"><code x-text="formatResponse()"></code></pre> | |
</div> | |
</div> | |
</template> | |
</div> | |
</template> | |
</div> | |
</div> | |
<script> | |
function apiBuilder() { | |
const DEFAULT_STATE = { | |
url: '', | |
method: 'GET', | |
headers: [{ key: '', value: '' }], | |
params: [{ key: '', value: '' }], | |
requestBody: '', | |
response: null, | |
responseHeaders: null, | |
loading: false, | |
error: null, | |
activeTab: 'params', | |
requestHistory: [], | |
responseTime: null, | |
authType: 'none', | |
basicAuth: { username: '', password: '' }, | |
bearerToken: '', | |
apiKey: { key: '', value: '', in: 'header' }, | |
environments: { | |
development: {}, | |
staging: {}, | |
production: {} | |
}, | |
currentEnv: 'development', | |
newVarKey: '', | |
newVarValue: '', | |
requestSize: '0 B', | |
responseSize: '0 B' | |
}; | |
// Memoize the byte size calculation | |
const memorizedFormatBytes = (() => { | |
const cache = new Map(); | |
return (bytes) => { | |
if (cache.has(bytes)) return cache.get(bytes); | |
if (bytes === 0) return '0 B'; | |
const k = 1024; | |
const sizes = ['B', 'KB', 'MB', 'GB']; | |
const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
const result = parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; | |
cache.set(bytes, result); | |
return result; | |
}; | |
})(); | |
// Calculate request size immediately without debouncing | |
function calculateRequestSize() { | |
let size = 0; | |
size += this.url?.length || 0; | |
// Headers size | |
this.headers.forEach(header => { | |
if (header.key && header.value) { | |
size += header.key.length + header.value.length; | |
} | |
}); | |
// Body size | |
if (this.requestBody) { | |
size += this.requestBody.length; | |
} | |
return size; | |
} | |
function debounce(func, wait) { | |
let timeout; | |
return function executedFunction(...args) { | |
const later = () => { | |
clearTimeout(timeout); | |
func(...args); | |
}; | |
clearTimeout(timeout); | |
timeout = setTimeout(later, wait); | |
}; | |
} | |
// Cache environment variable replacements | |
const envVarCache = new Map(); | |
function replaceEnvironmentVariables(str, env) { | |
const cacheKey = `${str}-${env}`; | |
if (envVarCache.has(cacheKey)) return envVarCache.get(cacheKey); | |
const result = str.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, key) => { | |
return this.environments[this.currentEnv][key.trim()] || match; | |
}); | |
envVarCache.set(cacheKey, result); | |
return result; | |
} | |
// Optimize request handling with async/await and better error handling | |
async function sendRequest() { | |
try { | |
this.loading = true; | |
this.error = null; | |
this.response = null; | |
this.responseHeaders = null; | |
const startTime = performance.now(); | |
const processedUrl = this.replaceEnvironmentVariables(this.url); | |
const headers = new Headers(); | |
const requestOptions = { | |
method: this.method, | |
headers, | |
}; | |
// Add headers | |
this.headers.forEach(header => { | |
if (header.key && header.value) { | |
headers.append(header.key, this.replaceEnvironmentVariables(header.value)); | |
} | |
}); | |
// Handle authentication | |
if (this.authType === 'basic') { | |
const username = this.replaceEnvironmentVariables(this.basicAuth.username); | |
const password = this.replaceEnvironmentVariables(this.basicAuth.password); | |
headers.append('Authorization', `Basic ${btoa(`${username}:${password}`)}`); | |
} else if (this.authType === 'bearer') { | |
headers.append('Authorization', `Bearer ${this.replaceEnvironmentVariables(this.bearerToken)}`); | |
} else if (this.authType === 'apiKey' && this.apiKey.in === 'header') { | |
headers.append(this.apiKey.key, this.replaceEnvironmentVariables(this.apiKey.value)); | |
} | |
// Add body if needed | |
if (!['GET', 'HEAD'].includes(this.method) && this.requestBody) { | |
requestOptions.body = this.replaceEnvironmentVariables(this.requestBody); | |
} | |
const response = await fetch(processedUrl, requestOptions); | |
// Convert headers to object | |
this.responseHeaders = Object.fromEntries([...response.headers.entries()]); | |
// Handle response | |
try { | |
this.response = await response.json(); | |
} catch { | |
this.response = await response.text(); | |
} | |
// Update metrics | |
const requestSize = this.calculateRequestSize(); | |
const responseSize = this.response ? (typeof this.response === 'string' ? this.response.length : JSON.stringify(this.response).length) : 0; | |
this.requestSize = memorizedFormatBytes(requestSize); | |
this.responseSize = memorizedFormatBytes(responseSize); | |
this.responseTime = (performance.now() - startTime).toFixed(2); | |
// Update history | |
this.updateHistory({ | |
timestamp: new Date().toISOString(), | |
method: this.method, | |
url: processedUrl, | |
status: response.status, | |
success: response.ok, | |
requestSize: this.requestSize, | |
responseSize: this.responseSize | |
}); | |
} catch (err) { | |
this.error = err.message; | |
} finally { | |
this.loading = false; | |
} | |
} | |
// Keep only last 10 history items | |
function updateHistory(entry) { | |
this.requestHistory = [entry, ...this.requestHistory.slice(0, 9)]; | |
} | |
return { | |
...DEFAULT_STATE, | |
formatBytes: memorizedFormatBytes, | |
replaceEnvironmentVariables, | |
sendRequest, | |
updateHistory, | |
calculateRequestSize, | |
// Row management | |
addRow(array) { | |
array.push({ key: '', value: '' }); | |
}, | |
removeRow(array, index) { | |
array.splice(index, 1); | |
}, | |
// Environment variables management | |
addEnvironmentVariable() { | |
if (this.newVarKey && this.newVarValue) { | |
this.environments[this.currentEnv][this.newVarKey] = this.newVarValue; | |
this.newVarKey = ''; | |
this.newVarValue = ''; | |
} | |
}, | |
// URL building | |
buildUrl() { | |
try { | |
const processedUrl = this.replaceEnvironmentVariables(this.url); | |
const baseUrl = new URL(processedUrl); | |
this.params.forEach(param => { | |
if (param.key && param.value) { | |
const processedValue = this.replaceEnvironmentVariables(param.value); | |
baseUrl.searchParams.append(param.key, processedValue); | |
} | |
}); | |
return baseUrl.toString(); | |
} catch { | |
return this.replaceEnvironmentVariables(this.url); | |
} | |
}, | |
// cURL command generation | |
generateCurl() { | |
const processedUrl = this.replaceEnvironmentVariables(this.url); | |
let curl = `curl -X ${this.method} \\\n`; | |
// Add headers | |
this.headers.forEach(header => { | |
if (header.key && header.value) { | |
const processedValue = this.replaceEnvironmentVariables(header.value); | |
// Only quote if contains spaces or special characters | |
const needsQuotes = /[\s'"()]/.test(processedValue); | |
const value = needsQuotes ? `'${processedValue}'` : processedValue; | |
curl += ` -H "${header.key}: ${value}" \\\n`; | |
} | |
}); | |
// Add authentication | |
if (this.authType === 'basic') { | |
const username = this.replaceEnvironmentVariables(this.basicAuth.username); | |
const password = this.replaceEnvironmentVariables(this.basicAuth.password); | |
// Only quote if contains spaces or special characters | |
const needsQuotes = /[\s'"()]/.test(`${username}:${password}`); | |
const auth = needsQuotes ? `'${username}:${password}'` : `${username}:${password}`; | |
curl += ` -u ${auth} \\\n`; | |
} else if (this.authType === 'bearer') { | |
const token = this.replaceEnvironmentVariables(this.bearerToken); | |
curl += ` -H "Authorization: Bearer ${token}" \\\n`; | |
} else if (this.authType === 'apiKey') { | |
const value = this.replaceEnvironmentVariables(this.apiKey.value); | |
if (this.apiKey.in === 'header') { | |
curl += ` -H "${this.apiKey.key}: ${value}" \\\n`; | |
} else { | |
const url = new URL(processedUrl); | |
url.searchParams.append(this.apiKey.key, value); | |
processedUrl = url.toString(); | |
} | |
} | |
// Add request body | |
if (['POST', 'PUT', 'PATCH'].includes(this.method) && this.requestBody) { | |
const processedBody = this.replaceEnvironmentVariables(this.requestBody); | |
// Only quote if contains spaces or special characters | |
const needsQuotes = /[\s'"()]/.test(processedBody); | |
const body = needsQuotes ? `'${processedBody}'` : processedBody; | |
curl += ` -d ${body} \\\n`; | |
} | |
// Only quote URL if it contains spaces or special characters | |
const needsQuotes = /[\s'"()]/.test(processedUrl); | |
const url = needsQuotes ? `'${processedUrl}'` : processedUrl; | |
curl += ` ${url}`; | |
return curl; | |
}, | |
// Response formatting | |
formatResponse() { | |
if (!this.response) return ''; | |
try { | |
return typeof this.response === 'string' | |
? this.response | |
: JSON.stringify(this.response, null, 2); | |
} catch { | |
return this.response; | |
} | |
} | |
}; | |
} | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment