Skip to content

Instantly share code, notes, and snippets.

@Braunson
Created February 13, 2025 23:34
Show Gist options
  • Save Braunson/4e5648afce8392312b7c047afcfa2aee to your computer and use it in GitHub Desktop.
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
<!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