Add API Platform functionality with authentication and management features

This commit is contained in:
Zahirul Iman 2025-06-01 18:40:19 +08:00
parent bcbf2f0958
commit ed9b862b82
26 changed files with 5127 additions and 20 deletions

1
.cursorignore Normal file
View File

@ -0,0 +1 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)

View File

@ -0,0 +1,191 @@
# Phase 3: Collections & Environment Management - Implementation Summary
## ✅ Completed Features
### 1. Collections Sidebar with Tree View
- **File**: `components/api-platform/CollectionsSidebar.vue`
- **Features**:
- Collapsible collections with tree structure
- Request management within collections
- Quick actions: Edit, Delete, Add Request
- Recent requests section (last 5)
- Responsive design with mobile support
### 2. Collection Management
- **File**: `components/api-platform/CreateCollectionModal.vue`
- **Features**:
- Create new collections with name and description
- Modal-based interface
- Form validation
### 3. Save/Load Request Functionality
- **File**: `components/api-platform/SaveRequestModal.vue`
- **Features**:
- Save current request to any collection
- Auto-populate request name
- Create collections on-the-fly
- Complete request data preservation (params, headers, auth, body)
### 4. Environment Management
- **File**: `components/api-platform/EnvironmentSelector.vue`
- **File**: `components/api-platform/EnvironmentModal.vue`
- **Features**:
- Environment dropdown selector
- Full environment CRUD operations
- Variable management per environment
- Visual indicators for variable count
### 5. Variable Substitution System
- **File**: `composables/useVariableSubstitution.js`
- **Features**:
- Template syntax: `{{variableName}}`
- Complete request processing (URL, headers, params, auth, body)
- Real-time variable detection and validation
- Visual indicators in UI
### 6. Persistence Layer
- **Storage**: localStorage
- **Features**:
- Automatic saving of collections and environments
- Persistent data across browser sessions
- JSON-based storage format
## 🎯 User Interface Enhancements
### Main Layout Updates
- **File**: `pages/api-platform/index.vue`
- Added collections sidebar toggle
- Environment selector in top bar
- Save request button
- Responsive layout adjustments
### Request Builder Enhancements
- **File**: `components/api-platform/RequestBuilder.vue`
- Variable detection in URL field
- Variable preview panel
- Current environment indicator
- Integrated variable substitution
### Global State Management
- **File**: `composables/useApiPlatform.js`
- Added UI state management
- Collections and environments state
- Modal management
## 🔧 Technical Implementation
### Variable Substitution Flow
1. User enters `{{variableName}}` in any field
2. System detects variables and shows indicators
3. On request send, variables are substituted with environment values
4. Visual feedback shows which variables are resolved
### Data Structure
#### Collections
```javascript
{
id: timestamp,
name: "Collection Name",
description: "Optional description",
requests: [
{
id: timestamp,
name: "Request Name",
method: "GET",
url: "{{baseUrl}}/api/endpoint",
params: [...],
headers: [...],
auth: {...},
body: {...},
createdAt: "ISO string"
}
],
createdAt: "ISO string"
}
```
#### Environments
```javascript
{
id: timestamp,
name: "Environment Name",
variables: [
{
key: "baseUrl",
value: "https://api.example.com"
}
]
}
```
## 🚀 Usage Examples
### 1. Creating an Environment
1. Click environment settings icon
2. Create new environment (e.g., "Production")
3. Add variables:
- `baseUrl`: `https://api.production.com`
- `apiKey`: `prod-key-123`
### 2. Using Variables in Requests
- URL: `{{baseUrl}}/users`
- Headers: `Authorization: Bearer {{apiKey}}`
- System automatically substitutes values when sending
### 3. Saving Requests to Collections
1. Configure your request
2. Click "Save" button
3. Choose or create collection
4. Request is saved with all current settings
### 4. Loading Saved Requests
1. Open collections sidebar
2. Browse collections
3. Click on any saved request
4. Request loads with all original settings
## 📁 File Structure
```
components/api-platform/
├── CollectionsSidebar.vue # Main collections interface
├── CreateCollectionModal.vue # Collection creation
├── EnvironmentSelector.vue # Environment dropdown
├── EnvironmentModal.vue # Environment management
├── SaveRequestModal.vue # Request saving
└── RequestBuilder.vue # Enhanced with variables
composables/
├── useApiPlatform.js # Global state management
└── useVariableSubstitution.js # Variable processing
pages/api-platform/
└── index.vue # Main layout with sidebar
```
## ✨ Key Benefits
1. **Organized Workflow**: Collections help organize related requests
2. **Environment Management**: Easy switching between dev/staging/prod
3. **Variable Substitution**: DRY principle for common values
4. **Persistent Storage**: Work survives browser restarts
5. **Team Collaboration**: Exportable collections (future feature)
6. **Professional UX**: Modern interface with visual feedback
## 🔄 Integration with Existing Features
- ✅ Fully compatible with existing request/response system
- ✅ Works with all authentication methods
- ✅ Integrates with notification system
- ✅ Maintains all existing functionality
- ✅ No breaking changes to existing components
## 🎯 Phase 3 Goals - Status
- ✅ Collections sidebar with tree view
- ✅ Save/Load requests to collections
- ✅ Environment selector dropdown
- ✅ Variable substitution ({{baseUrl}})
- ✅ Persistence layer (localStorage)
**Phase 3 is now complete and ready for use!**

View File

@ -0,0 +1,634 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-6xl h-full max-h-[95vh] flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div class="flex items-center gap-3">
<Icon name="ic:outline-code" size="24" class="text-primary-600" />
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Code Generation</h3>
</div>
<rs-button
variant="text"
size="sm"
@click="$emit('close')"
class="p-2"
>
<Icon name="ic:outline-close" size="16" />
</rs-button>
</div>
<!-- Content -->
<div class="flex flex-1 overflow-hidden min-h-0">
<!-- Language/Framework Selector -->
<div class="w-64 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 flex-shrink-0 flex flex-col">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h4 class="font-medium text-gray-900 dark:text-white">Select Language</h4>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-1">
<button
v-for="lang in languages"
:key="lang.id"
@click="selectedLanguage = lang.id"
:class="[
'w-full text-left p-3 rounded-lg transition-colors flex items-center gap-3',
selectedLanguage === lang.id
? 'bg-primary-100 text-primary-900 dark:bg-primary-900 dark:text-primary-100'
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'
]"
>
<Icon :name="lang.icon" size="20" />
<div class="min-w-0">
<div class="font-medium truncate">{{ lang.name }}</div>
<div class="text-xs opacity-70 truncate">{{ lang.description }}</div>
</div>
</button>
</div>
</div>
<!-- Code Display -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Code Header -->
<div class="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 flex-shrink-0">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0">
<Icon :name="currentLanguage.icon" size="16" />
<span class="font-medium text-gray-900 dark:text-white truncate">{{ currentLanguage.name }}</span>
<rs-badge variant="secondary" size="sm">{{ generatedCode.split('\n').length }} lines</rs-badge>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<rs-button
variant="secondary-outline"
size="sm"
@click="copyToClipboard"
class="flex items-center gap-2"
>
<Icon name="ic:outline-content-copy" size="16" />
Copy
</rs-button>
<rs-button
variant="secondary-outline"
size="sm"
@click="downloadCode"
class="flex items-center gap-2"
>
<Icon name="ic:outline-download" size="16" />
Download
</rs-button>
</div>
</div>
</div>
<!-- Code Content -->
<div class="flex-1 overflow-hidden p-4 min-h-0">
<pre
class="text-sm bg-gray-50 dark:bg-gray-900 p-4 rounded-lg whitespace-pre-wrap border font-mono leading-relaxed w-full h-full overflow-auto resize-none"
:class="`language-${currentLanguage.highlightLanguage}`"
>{{ generatedCode }}</pre>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-between p-6 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
<div class="text-sm text-gray-500 dark:text-gray-400">
Generated from current request configuration
</div>
<rs-button
variant="primary"
@click="$emit('close')"
>
Done
</rs-button>
</div>
</div>
</div>
</template>
<script setup>
const emit = defineEmits(['close'])
const {
httpMethod,
requestUrl,
requestParams,
requestHeaders,
requestAuth,
requestBody,
showNotification
} = useApiPlatform()
const { copyToClipboard: copyToClipboardUtil } = useApiRequest()
const selectedLanguage = ref('curl')
const languages = [
{
id: 'curl',
name: 'cURL',
description: 'Command line tool',
icon: 'ic:outline-terminal',
highlightLanguage: 'bash'
},
{
id: 'javascript-fetch',
name: 'JavaScript (Fetch)',
description: 'Modern JavaScript',
icon: 'ic:outline-javascript',
highlightLanguage: 'javascript'
},
{
id: 'javascript-axios',
name: 'JavaScript (Axios)',
description: 'Popular HTTP client',
icon: 'ic:outline-javascript',
highlightLanguage: 'javascript'
},
{
id: 'python-requests',
name: 'Python (Requests)',
description: 'Python HTTP library',
icon: 'ic:outline-data-object',
highlightLanguage: 'python'
},
{
id: 'php-curl',
name: 'PHP (cURL)',
description: 'PHP with cURL',
icon: 'ic:outline-language',
highlightLanguage: 'php'
},
{
id: 'node-http',
name: 'Node.js (HTTP)',
description: 'Native Node.js',
icon: 'ic:outline-settings',
highlightLanguage: 'javascript'
},
{
id: 'java-okhttp',
name: 'Java (OkHttp)',
description: 'Java HTTP client',
icon: 'ic:outline-coffee',
highlightLanguage: 'java'
},
{
id: 'go-http',
name: 'Go (net/http)',
description: 'Go standard library',
icon: 'ic:outline-speed',
highlightLanguage: 'go'
}
]
const currentLanguage = computed(() => {
return languages.find(lang => lang.id === selectedLanguage.value) || languages[0]
})
// Generate code based on current request
const generatedCode = computed(() => {
const url = requestUrl.value || 'https://api.example.com/endpoint'
const method = httpMethod.value
const headers = requestHeaders.value.filter(h => h.active && h.key)
const params = requestParams.value.filter(p => p.active && p.key)
const body = requestBody.value
const auth = requestAuth.value
switch (selectedLanguage.value) {
case 'curl':
return generateCurl(url, method, headers, params, body, auth)
case 'javascript-fetch':
return generateJavaScriptFetch(url, method, headers, params, body, auth)
case 'javascript-axios':
return generateJavaScriptAxios(url, method, headers, params, body, auth)
case 'python-requests':
return generatePythonRequests(url, method, headers, params, body, auth)
case 'php-curl':
return generatePhpCurl(url, method, headers, params, body, auth)
case 'node-http':
return generateNodeHttp(url, method, headers, params, body, auth)
case 'java-okhttp':
return generateJavaOkHttp(url, method, headers, params, body, auth)
case 'go-http':
return generateGoHttp(url, method, headers, params, body, auth)
default:
return '// Code generation not available for this language'
}
})
// Code generation functions
const generateCurl = (url, method, headers, params, body, auth) => {
let code = `curl -X ${method}`
// Add URL with params
if (params.length > 0) {
const queryString = params.map(p => `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}`).join('&')
url += `?${queryString}`
}
code += ` \\\n "${url}"`
// Add headers
headers.forEach(header => {
code += ` \\\n -H "${header.key}: ${header.value}"`
})
// Add auth
if (auth.type === 'bearer' && auth.bearer) {
code += ` \\\n -H "Authorization: Bearer ${auth.bearer}"`
} else if (auth.type === 'basic' && auth.basic.username) {
code += ` \\\n -u "${auth.basic.username}:${auth.basic.password}"`
} else if (auth.type === 'apiKey' && auth.apiKey.key) {
if (auth.apiKey.addTo === 'header') {
code += ` \\\n -H "${auth.apiKey.key}: ${auth.apiKey.value}"`
}
}
// Add body
if (body.type === 'raw' && body.raw) {
code += ` \\\n -H "Content-Type: application/json"`
code += ` \\\n -d '${body.raw}'`
} else if (body.type === 'x-www-form-urlencoded' && body.urlEncoded.some(item => item.active && item.key)) {
const formData = body.urlEncoded.filter(item => item.active && item.key)
.map(item => `${encodeURIComponent(item.key)}=${encodeURIComponent(item.value)}`)
.join('&')
code += ` \\\n -H "Content-Type: application/x-www-form-urlencoded"`
code += ` \\\n -d "${formData}"`
}
return code
}
const generateJavaScriptFetch = (url, method, headers, params, body, auth) => {
let code = '// Using fetch API\n'
// Build URL with params
if (params.length > 0) {
const queryString = params.map(p => `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}`).join('&')
url += `?${queryString}`
}
code += `const url = '${url}';\n\n`
// Build options object
code += 'const options = {\n'
code += ` method: '${method}',\n`
// Headers
const allHeaders = [...headers]
if (auth.type === 'bearer' && auth.bearer) {
allHeaders.push({ key: 'Authorization', value: `Bearer ${auth.bearer}` })
} else if (auth.type === 'apiKey' && auth.apiKey.key && auth.apiKey.addTo === 'header') {
allHeaders.push({ key: auth.apiKey.key, value: auth.apiKey.value })
}
if (allHeaders.length > 0) {
code += ' headers: {\n'
allHeaders.forEach(header => {
code += ` '${header.key}': '${header.value}',\n`
})
code += ' },\n'
}
// Body
if (body.type === 'raw' && body.raw) {
code += ` body: ${body.raw}\n`
} else if (body.type === 'x-www-form-urlencoded' && body.urlEncoded.some(item => item.active && item.key)) {
const formData = body.urlEncoded.filter(item => item.active && item.key)
.map(item => `${encodeURIComponent(item.key)}=${encodeURIComponent(item.value)}`)
.join('&')
code += ` body: '${formData}'\n`
}
code += '};\n\n'
code += `fetch(url, options)\n`
code += ` .then(response => response.json())\n`
code += ` .then(data => console.log(data))\n`
code += ` .catch(error => console.error('Error:', error));`
return code
}
const generateJavaScriptAxios = (url, method, headers, params, body, auth) => {
let code = '// Using Axios\n'
code += "import axios from 'axios';\n\n"
code += 'const config = {\n'
code += ` method: '${method.toLowerCase()}',\n`
code += ` url: '${url}',\n`
// Params
if (params.length > 0) {
code += ' params: {\n'
params.forEach(param => {
code += ` '${param.key}': '${param.value}',\n`
})
code += ' },\n'
}
// Headers
const allHeaders = [...headers]
if (auth.type === 'bearer' && auth.bearer) {
allHeaders.push({ key: 'Authorization', value: `Bearer ${auth.bearer}` })
} else if (auth.type === 'apiKey' && auth.apiKey.key && auth.apiKey.addTo === 'header') {
allHeaders.push({ key: auth.apiKey.key, value: auth.apiKey.value })
}
if (allHeaders.length > 0) {
code += ' headers: {\n'
allHeaders.forEach(header => {
code += ` '${header.key}': '${header.value}',\n`
})
code += ' },\n'
}
// Body
if (body.type === 'raw' && body.raw) {
code += ` data: ${body.raw}\n`
}
code += '};\n\n'
code += 'axios(config)\n'
code += ' .then(response => console.log(response.data))\n'
code += ' .catch(error => console.error(error));'
return code
}
const generatePythonRequests = (url, method, headers, params, body, auth) => {
let code = '# Using requests library\n'
code += 'import requests\n\n'
code += `url = "${url}"\n\n`
// Headers
const allHeaders = [...headers]
if (auth.type === 'bearer' && auth.bearer) {
allHeaders.push({ key: 'Authorization', value: `Bearer ${auth.bearer}` })
} else if (auth.type === 'apiKey' && auth.apiKey.key && auth.apiKey.addTo === 'header') {
allHeaders.push({ key: auth.apiKey.key, value: auth.apiKey.value })
}
if (allHeaders.length > 0) {
code += 'headers = {\n'
allHeaders.forEach(header => {
code += ` "${header.key}": "${header.value}",\n`
})
code += '}\n\n'
}
// Params
if (params.length > 0) {
code += 'params = {\n'
params.forEach(param => {
code += ` "${param.key}": "${param.value}",\n`
})
code += '}\n\n'
}
// Body
if (body.type === 'raw' && body.raw) {
code += `data = ${body.raw}\n\n`
}
// Request
code += `response = requests.${method.toLowerCase()}(url`
if (allHeaders.length > 0) code += ', headers=headers'
if (params.length > 0) code += ', params=params'
if (body.type === 'raw' && body.raw) code += ', json=data'
code += ')\n\n'
code += 'print(response.json())'
return code
}
const generatePhpCurl = (url, method, headers, params, body, auth) => {
let code = '<?php\n'
code += '// Using cURL\n\n'
// Build URL with params
if (params.length > 0) {
const queryString = params.map(p => `${p.key}=${p.value}`).join('&')
url += `?${queryString}`
}
code += `$url = "${url}";\n\n`
code += '$curl = curl_init();\n\n'
code += 'curl_setopt_array($curl, [\n'
code += ' CURLOPT_URL => $url,\n'
code += ' CURLOPT_RETURNTRANSFER => true,\n'
code += ` CURLOPT_CUSTOMREQUEST => "${method}",\n`
// Headers
const allHeaders = [...headers]
if (auth.type === 'bearer' && auth.bearer) {
allHeaders.push({ key: 'Authorization', value: `Bearer ${auth.bearer}` })
}
if (allHeaders.length > 0) {
code += ' CURLOPT_HTTPHEADER => [\n'
allHeaders.forEach(header => {
code += ` "${header.key}: ${header.value}",\n`
})
code += ' ],\n'
}
// Body
if (body.type === 'raw' && body.raw) {
code += ` CURLOPT_POSTFIELDS => '${body.raw}',\n`
}
code += ']);\n\n'
code += '$response = curl_exec($curl);\n'
code += 'curl_close($curl);\n\n'
code += 'echo $response;'
return code
}
const generateNodeHttp = (url, method, headers, params, body, auth) => {
let code = '// Using Node.js http module\n'
code += "const http = require('http');\n"
code += "const https = require('https');\n"
code += "const { URL } = require('url');\n\n"
code += `const url = new URL('${url}');\n\n`
// Add params
if (params.length > 0) {
params.forEach(param => {
code += `url.searchParams.append('${param.key}', '${param.value}');\n`
})
code += '\n'
}
code += 'const options = {\n'
code += ' hostname: url.hostname,\n'
code += ' port: url.port,\n'
code += ' path: url.pathname + url.search,\n'
code += ` method: '${method}',\n`
// Headers
const allHeaders = [...headers]
if (auth.type === 'bearer' && auth.bearer) {
allHeaders.push({ key: 'Authorization', value: `Bearer ${auth.bearer}` })
}
if (allHeaders.length > 0) {
code += ' headers: {\n'
allHeaders.forEach(header => {
code += ` '${header.key}': '${header.value}',\n`
})
code += ' }\n'
}
code += '};\n\n'
code += 'const req = (url.protocol === "https:" ? https : http).request(options, (res) => {\n'
code += ' let data = "";\n'
code += ' res.on("data", (chunk) => { data += chunk; });\n'
code += ' res.on("end", () => { console.log(JSON.parse(data)); });\n'
code += '});\n\n'
if (body.type === 'raw' && body.raw) {
code += `req.write(${body.raw});\n`
}
code += 'req.end();'
return code
}
const generateJavaOkHttp = (url, method, headers, params, body, auth) => {
let code = '// Using OkHttp\n'
code += 'import okhttp3.*;\n\n'
code += 'OkHttpClient client = new OkHttpClient();\n\n'
// Build URL with params
if (params.length > 0) {
const queryString = params.map(p => `${p.key}=${p.value}`).join('&')
url += `?${queryString}`
}
// Request builder
code += 'Request.Builder builder = new Request.Builder()\n'
code += ` .url("${url}")\n`
// Headers
const allHeaders = [...headers]
if (auth.type === 'bearer' && auth.bearer) {
allHeaders.push({ key: 'Authorization', value: `Bearer ${auth.bearer}` })
}
allHeaders.forEach(header => {
code += ` .addHeader("${header.key}", "${header.value}")\n`
})
// Body and method
if (body.type === 'raw' && body.raw) {
code += ` .${method.toLowerCase()}(RequestBody.create(MediaType.parse("application/json"), ${body.raw}));\n\n`
} else {
code += ` .${method.toLowerCase()}();\n\n`
}
code += 'Request request = builder.build();\n\n'
code += 'try {\n'
code += ' Response response = client.newCall(request).execute();\n'
code += ' System.out.println(response.body().string());\n'
code += '} catch (IOException e) {\n'
code += ' e.printStackTrace();\n'
code += '}'
return code
}
const generateGoHttp = (url, method, headers, params, body, auth) => {
let code = '// Using Go net/http\n'
code += 'package main\n\n'
code += 'import (\n'
code += ' "fmt"\n'
code += ' "net/http"\n'
code += ' "io/ioutil"\n'
if (body.type === 'raw' && body.raw) {
code += ' "strings"\n'
}
code += ')\n\n'
code += 'func main() {\n'
// Build URL with params
if (params.length > 0) {
const queryString = params.map(p => `${p.key}=${p.value}`).join('&')
url += `?${queryString}`
}
code += ` url := "${url}"\n\n`
// Create request
if (body.type === 'raw' && body.raw) {
code += ` payload := strings.NewReader(${JSON.stringify(body.raw)})\n`
code += ' req, _ := http.NewRequest("${method}", url, payload)\n\n'
} else {
code += ` req, _ := http.NewRequest("${method}", url, nil)\n\n`
}
// Headers
const allHeaders = [...headers]
if (auth.type === 'bearer' && auth.bearer) {
allHeaders.push({ key: 'Authorization', value: `Bearer ${auth.bearer}` })
}
allHeaders.forEach(header => {
code += ` req.Header.Add("${header.key}", "${header.value}")\n`
})
code += '\n res, _ := http.DefaultClient.Do(req)\n'
code += ' defer res.Body.Close()\n'
code += ' body, _ := ioutil.ReadAll(res.Body)\n\n'
code += ' fmt.Println(string(body))\n'
code += '}'
return code
}
const copyToClipboard = async () => {
await copyToClipboardUtil(generatedCode.value, 'Code copied to clipboard', { showNotification })
}
const downloadCode = () => {
const extension = getFileExtension(selectedLanguage.value)
const filename = `api-request.${extension}`
const blob = new Blob([generatedCode.value], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showNotification(`Code downloaded as ${filename}`, 'success')
}
const getFileExtension = (languageId) => {
const extensions = {
'curl': 'sh',
'javascript-fetch': 'js',
'javascript-axios': 'js',
'python-requests': 'py',
'php-curl': 'php',
'node-http': 'js',
'java-okhttp': 'java',
'go-http': 'go'
}
return extensions[languageId] || 'txt'
}
</script>

View File

@ -0,0 +1,318 @@
<template>
<div class="w-80 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
<!-- Header -->
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Collections</h2>
<div class="flex items-center gap-2">
<rs-button
variant="secondary-outline"
size="sm"
@click="showCreateCollectionModal = true"
class="p-2"
>
<Icon name="ic:outline-add" size="16" />
</rs-button>
<rs-button
variant="text"
size="sm"
@click="$emit('close')"
class="p-2 lg:hidden"
>
<Icon name="ic:outline-close" size="16" />
</rs-button>
</div>
</div>
</div>
<!-- Recent Requests -->
<div v-if="requestHistory.length > 0" class="border-b border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">Recent</h3>
<span class="text-xs text-gray-500 dark:text-gray-400">Last 5 requests</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Your most recently sent requests for quick access</p>
<div class="space-y-1">
<div
v-for="request in requestHistory.slice(0, 5)"
:key="request.id"
@click="loadHistoryRequest(request)"
class="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
>
<rs-badge :variant="getMethodVariant(request.method)" size="sm" class="w-12 text-xs">
{{ request.method }}
</rs-badge>
<div class="flex-1 min-w-0">
<p class="text-sm text-gray-700 dark:text-gray-300 truncate">{{ request.name }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ formatDate(request.timestamp) }}</p>
</div>
</div>
</div>
</div>
<!-- Collections List -->
<div class="flex-1 overflow-y-auto p-2">
<div v-if="collections.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
<Icon name="ic:outline-folder-open" size="48" class="mx-auto mb-4 opacity-50" />
<p class="text-sm mb-4">No collections yet</p>
<div class="flex justify-center">
<rs-button
variant="primary"
size="sm"
@click="showCreateCollectionModal = true"
class="flex items-center gap-2"
>
<Icon name="ic:outline-add" size="14" />
Create Collection
</rs-button>
</div>
</div>
<div v-else class="space-y-1">
<div
v-for="collection in collections"
:key="collection.id"
class="group"
>
<!-- Collection Header -->
<div
@click="toggleCollection(collection.id)"
class="flex items-center gap-2 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer group/header"
>
<Icon
:name="expandedCollections.has(collection.id) ? 'ic:outline-expand-more' : 'ic:outline-chevron-right'"
size="16"
class="text-gray-400 transition-transform"
/>
<Icon name="ic:outline-folder" size="16" class="text-blue-500" />
<span class="flex-1 text-sm font-medium text-gray-900 dark:text-white truncate">
{{ collection.name }}
</span>
<div class="opacity-0 group-hover/header:opacity-100 flex items-center gap-1">
<rs-button
variant="text"
size="sm"
@click.stop="editCollection(collection)"
class="p-1"
>
<Icon name="ic:outline-edit" size="14" />
</rs-button>
<rs-button
variant="text"
size="sm"
@click.stop="deleteCollection(collection.id)"
class="p-1 text-red-500 hover:text-red-600"
>
<Icon name="ic:outline-delete" size="14" />
</rs-button>
</div>
</div>
<!-- Collection Requests -->
<div v-if="expandedCollections.has(collection.id)" class="ml-6 space-y-1">
<div
v-for="request in collection.requests"
:key="request.id"
@click="loadRequest(request)"
class="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer group/request"
>
<rs-badge :variant="getMethodVariant(request.method)" size="sm" class="w-12 text-xs">
{{ request.method }}
</rs-badge>
<span class="flex-1 text-sm text-gray-700 dark:text-gray-300 truncate">
{{ request.name }}
</span>
<div class="opacity-0 group-hover/request:opacity-100 flex items-center gap-1">
<rs-button
variant="text"
size="sm"
@click.stop="editRequest(request)"
class="p-1"
>
<Icon name="ic:outline-edit" size="12" />
</rs-button>
<rs-button
variant="text"
size="sm"
@click.stop="deleteRequest(collection.id, request.id)"
class="p-1 text-red-500 hover:text-red-600"
>
<Icon name="ic:outline-delete" size="12" />
</rs-button>
</div>
</div>
<!-- Add Request Button -->
<rs-button
variant="text"
size="sm"
@click="addRequestToCollection(collection.id)"
class="w-full justify-start text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<Icon name="ic:outline-add" size="14" class="mr-2" />
Add Request
</rs-button>
</div>
</div>
</div>
</div>
<!-- Create Collection Modal -->
<CreateCollectionModal
v-if="showCreateCollectionModal"
@close="showCreateCollectionModal = false"
@created="onCollectionCreated"
/>
</div>
</template>
<script setup>
import CreateCollectionModal from './CreateCollectionModal.vue'
const emit = defineEmits(['close'])
const { getMethodVariant } = useApiRequest()
const {
collections,
requestHistory,
httpMethod,
requestUrl,
requestParams,
requestHeaders,
requestAuth,
requestBody,
requestName,
showNotification
} = useApiPlatform()
const showCreateCollectionModal = ref(false)
const expandedCollections = ref(new Set())
// Initialize with all collections expanded
watch(collections, (newCollections) => {
newCollections.forEach(collection => {
expandedCollections.value.add(collection.id)
})
}, { immediate: true })
const toggleCollection = (collectionId) => {
if (expandedCollections.value.has(collectionId)) {
expandedCollections.value.delete(collectionId)
} else {
expandedCollections.value.add(collectionId)
}
}
const loadRequest = (request) => {
httpMethod.value = request.method
requestUrl.value = request.url
requestName.value = request.name
// Load request data if available
if (request.params) requestParams.value = request.params
if (request.headers) requestHeaders.value = request.headers
if (request.auth) requestAuth.value = request.auth
if (request.body) requestBody.value = request.body
showNotification(`Loaded request: ${request.name}`, 'success')
}
const loadHistoryRequest = (request) => {
httpMethod.value = request.method
requestUrl.value = request.url
requestName.value = request.name
showNotification(`Loaded from history: ${request.name}`, 'success')
}
const addRequestToCollection = (collectionId) => {
const collection = collections.value.find(c => c.id === collectionId)
if (!collection) return
const newRequest = {
id: Date.now(),
name: 'New Request',
method: 'GET',
url: '',
params: [],
headers: [],
auth: { type: 'none' },
body: { type: 'raw', raw: '' }
}
collection.requests.push(newRequest)
saveCollections()
showNotification('Request added to collection', 'success')
}
const editCollection = (collection) => {
const newName = prompt('Collection name:', collection.name)
if (newName && newName.trim()) {
collection.name = newName.trim()
saveCollections()
showNotification('Collection updated', 'success')
}
}
const deleteCollection = (collectionId) => {
if (confirm('Are you sure you want to delete this collection?')) {
const index = collections.value.findIndex(c => c.id === collectionId)
if (index !== -1) {
collections.value.splice(index, 1)
saveCollections()
showNotification('Collection deleted', 'success')
}
}
}
const editRequest = (request) => {
const newName = prompt('Request name:', request.name)
if (newName && newName.trim()) {
request.name = newName.trim()
saveCollections()
showNotification('Request updated', 'success')
}
}
const deleteRequest = (collectionId, requestId) => {
if (confirm('Are you sure you want to delete this request?')) {
const collection = collections.value.find(c => c.id === collectionId)
if (collection) {
const index = collection.requests.findIndex(r => r.id === requestId)
if (index !== -1) {
collection.requests.splice(index, 1)
saveCollections()
showNotification('Request deleted', 'success')
}
}
}
}
const onCollectionCreated = (collection) => {
collections.value.push(collection)
expandedCollections.value.add(collection.id)
saveCollections()
showNotification('Collection created', 'success')
}
const saveCollections = () => {
localStorage.setItem('api-platform-collections', JSON.stringify(collections.value))
}
const formatDate = (timestamp) => {
return new Date(timestamp).toLocaleString()
}
// Load collections from localStorage on mount
onMounted(() => {
const saved = localStorage.getItem('api-platform-collections')
if (saved) {
try {
const parsed = JSON.parse(saved)
collections.value.splice(0, collections.value.length, ...parsed)
} catch (e) {
console.error('Failed to load collections:', e)
}
}
})
</script>

View File

@ -0,0 +1,87 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Create Collection</h3>
<rs-button
variant="text"
size="sm"
@click="$emit('close')"
class="p-2"
>
<Icon name="ic:outline-close" size="16" />
</rs-button>
</div>
<!-- Content -->
<div class="p-6">
<FormKit
type="text"
v-model="collectionName"
label="Collection Name"
placeholder="Enter collection name"
validation="required"
outer-class="mb-4"
@keyup.enter="createCollection"
/>
<FormKit
type="textarea"
v-model="collectionDescription"
label="Description (optional)"
placeholder="Describe this collection"
rows="3"
outer-class="mb-4"
/>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
<rs-button
variant="secondary"
@click="$emit('close')"
>
Cancel
</rs-button>
<rs-button
variant="primary"
@click="createCollection"
:disabled="!collectionName.trim()"
>
Create Collection
</rs-button>
</div>
</div>
</div>
</template>
<script setup>
const emit = defineEmits(['close', 'created'])
const collectionName = ref('')
const collectionDescription = ref('')
const createCollection = () => {
if (!collectionName.value.trim()) return
const newCollection = {
id: Date.now(),
name: collectionName.value.trim(),
description: collectionDescription.value.trim(),
requests: [],
createdAt: new Date().toISOString()
}
emit('created', newCollection)
emit('close')
}
// Focus the input when modal opens
onMounted(() => {
nextTick(() => {
const input = document.querySelector('input[type="text"]')
if (input) input.focus()
})
})
</script>

View File

@ -0,0 +1,226 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Environment Management</h3>
<rs-button
variant="text"
size="sm"
@click="$emit('close')"
class="p-2"
>
<Icon name="ic:outline-close" size="16" />
</rs-button>
</div>
<!-- Content -->
<div class="flex-1 overflow-hidden flex">
<!-- Environments List -->
<div class="w-1/3 border-r border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between mb-4">
<h4 class="font-medium text-gray-900 dark:text-white">Environments</h4>
<rs-button
variant="secondary-outline"
size="sm"
@click="createEnvironment"
class="p-2"
>
<Icon name="ic:outline-add" size="14" />
</rs-button>
</div>
<div class="space-y-2">
<div
v-for="env in environments"
:key="env.id"
@click="selectedEnvId = env.id"
:class="[
'p-3 rounded-lg cursor-pointer border-2 transition-colors',
selectedEnvId === env.id
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-700'
]"
>
<div class="flex items-center justify-between">
<span class="font-medium text-gray-900 dark:text-white">{{ env.name }}</span>
<rs-button
variant="text"
size="sm"
@click.stop="deleteEnvironment(env.id)"
class="p-1 text-red-500 hover:text-red-600"
>
<Icon name="ic:outline-delete" size="14" />
</rs-button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ env.variables.length }} variables</p>
</div>
</div>
</div>
<!-- Environment Details -->
<div class="flex-1 p-4">
<div v-if="selectedEnvironment" class="h-full flex flex-col">
<!-- Environment Name -->
<div class="mb-4">
<FormKit
type="text"
v-model="selectedEnvironment.name"
label="Environment Name"
validation="required"
outer-class="!mb-0"
/>
</div>
<!-- Variables -->
<div class="flex-1 flex flex-col">
<div class="flex items-center justify-between mb-4">
<h5 class="font-medium text-gray-900 dark:text-white">Variables</h5>
<rs-button
variant="secondary-outline"
size="sm"
@click="addVariable"
class="flex items-center gap-2"
>
<Icon name="ic:outline-add" size="14" />
Add Variable
</rs-button>
</div>
<div class="flex-1 overflow-y-auto space-y-3">
<div
v-for="(variable, index) in selectedEnvironment.variables"
:key="index"
class="border border-gray-200 dark:border-gray-700 rounded-lg p-4"
>
<div class="flex items-start gap-3">
<div class="flex-1">
<FormKit
type="text"
v-model="variable.key"
placeholder="Variable key (e.g., baseUrl)"
outer-class="!mb-2"
inner-class="!mb-0"
/>
<FormKit
type="text"
v-model="variable.value"
placeholder="Variable value (e.g., https://api.example.com)"
outer-class="!mb-0"
inner-class="!mb-0"
/>
</div>
<rs-button
variant="text"
size="sm"
@click="removeVariable(index)"
class="p-2 text-red-500 hover:text-red-600"
>
<Icon name="ic:outline-delete" size="16" />
</rs-button>
</div>
</div>
<div v-if="selectedEnvironment.variables.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
<Icon name="ic:outline-code" size="32" class="mx-auto mb-2 opacity-50" />
<p class="text-sm mb-4">No variables defined</p>
<div class="flex justify-center">
<rs-button
variant="primary"
size="sm"
@click="addVariable"
class="flex items-center gap-2"
>
<Icon name="ic:outline-add" size="14" />
Add First Variable
</rs-button>
</div>
</div>
</div>
</div>
</div>
<div v-else class="h-full flex items-center justify-center text-gray-500 dark:text-gray-400">
<div class="text-center">
<Icon name="ic:outline-layers" size="48" class="mx-auto mb-4 opacity-50" />
<p>Select an environment to edit</p>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-between p-6 border-t border-gray-200 dark:border-gray-700">
<div class="text-sm text-gray-500 dark:text-gray-400">
Variables can be used as <code>{<!-- -->{variableName}<!-- -->}</code> in requests
</div>
<rs-button
variant="primary"
@click="$emit('close')"
>
Done
</rs-button>
</div>
</div>
</div>
</template>
<script setup>
const emit = defineEmits(['close'])
const { environments, showNotification } = useApiPlatform()
const selectedEnvId = ref('')
const selectedEnvironment = computed(() => {
return environments.value.find(env => env.id === selectedEnvId.value)
})
const createEnvironment = () => {
const name = prompt('Environment name:')
if (name && name.trim()) {
const newEnv = {
id: Date.now(),
name: name.trim(),
variables: []
}
environments.value.push(newEnv)
selectedEnvId.value = newEnv.id
showNotification('Environment created', 'success')
}
}
const deleteEnvironment = (envId) => {
if (confirm('Are you sure you want to delete this environment?')) {
const index = environments.value.findIndex(env => env.id === envId)
if (index !== -1) {
environments.value.splice(index, 1)
if (selectedEnvId.value === envId) {
selectedEnvId.value = ''
}
showNotification('Environment deleted', 'success')
}
}
}
const addVariable = () => {
if (selectedEnvironment.value) {
selectedEnvironment.value.variables.push({
key: '',
value: ''
})
}
}
const removeVariable = (index) => {
if (selectedEnvironment.value) {
selectedEnvironment.value.variables.splice(index, 1)
}
}
// Select first environment by default
onMounted(() => {
if (environments.value.length > 0) {
selectedEnvId.value = environments.value[0].id
}
})
</script>

View File

@ -0,0 +1,61 @@
<template>
<div class="relative">
<FormKit
type="select"
v-model="selectedEnvironment"
:options="environmentOptions"
placeholder="No Environment"
outer-class="!mb-0 w-48"
inner-class="!mb-0"
/>
<rs-button
variant="text"
size="sm"
@click="showEnvironmentModal = true"
class="absolute right-8 top-1/2 transform -translate-y-1/2 p-1"
>
<Icon name="ic:outline-settings" size="14" />
</rs-button>
<!-- Environment Management Modal -->
<EnvironmentModal
v-if="showEnvironmentModal"
@close="showEnvironmentModal = false"
/>
</div>
</template>
<script setup>
import EnvironmentModal from './EnvironmentModal.vue'
const { environments, selectedEnvironment } = useApiPlatform()
const showEnvironmentModal = ref(false)
// Compute environment options for the dropdown
const environmentOptions = computed(() => {
const options = [{ label: 'No Environment', value: '' }]
environments.value.forEach(env => {
options.push({ label: env.name, value: env.id })
})
return options
})
// Load environments from localStorage on mount
onMounted(() => {
const saved = localStorage.getItem('api-platform-environments')
if (saved) {
try {
const parsed = JSON.parse(saved)
environments.value.splice(0, environments.value.length, ...parsed)
} catch (e) {
console.error('Failed to load environments:', e)
}
}
})
// Watch for environment changes and save to localStorage
watch(environments, (newEnvironments) => {
localStorage.setItem('api-platform-environments', JSON.stringify(newEnvironments))
}, { deep: true })
</script>

View File

@ -0,0 +1,669 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-3xl mx-4 max-h-[90vh] flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<Icon name="ic:outline-import-export" size="24" class="text-primary-600" />
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Import & Export</h3>
</div>
<rs-button
variant="text"
size="sm"
@click="$emit('close')"
class="p-2"
>
<Icon name="ic:outline-close" size="16" />
</rs-button>
</div>
<!-- Content -->
<div class="flex flex-1 overflow-hidden">
<!-- Operation Selector -->
<div class="w-64 border-r border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
<h4 class="font-medium text-gray-900 dark:text-white mb-4">Select Operation</h4>
<div class="space-y-1">
<button
v-for="operation in operations"
:key="operation.id"
@click="selectedOperation = operation.id"
:class="[
'w-full text-left p-3 rounded-lg transition-colors flex items-center gap-3',
selectedOperation === operation.id
? 'bg-primary-100 text-primary-900 dark:bg-primary-900 dark:text-primary-100'
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'
]"
>
<Icon :name="operation.icon" size="20" />
<div>
<div class="font-medium">{{ operation.name }}</div>
<div class="text-xs opacity-70">{{ operation.description }}</div>
</div>
</button>
</div>
</div>
<!-- Operation Content -->
<div class="flex-1 flex flex-col">
<!-- Import Postman Collection -->
<div v-if="selectedOperation === 'import-postman'" class="flex-1 p-6">
<div class="space-y-4">
<div class="flex items-center gap-3 mb-4">
<Icon name="ic:outline-upload" size="20" class="text-orange-600" />
<h4 class="font-medium text-gray-900 dark:text-white">Import Postman Collection</h4>
</div>
<div class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-8 text-center">
<input
ref="postmanFileInput"
type="file"
accept=".json"
@change="handlePostmanFile"
class="hidden"
/>
<Icon name="ic:outline-folder-open" size="48" class="mx-auto mb-4 text-gray-400" />
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Select a Postman Collection JSON file or drag and drop here
</p>
<rs-button variant="secondary" @click="$refs.postmanFileInput.click()">
Choose File
</rs-button>
</div>
<div v-if="importPreview.postman" class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<h5 class="font-medium text-gray-900 dark:text-white mb-2">Preview</h5>
<div class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<div><strong>Collection:</strong> {{ importPreview.postman.name }}</div>
<div><strong>Requests:</strong> {{ importPreview.postman.requestCount }}</div>
<div><strong>Folders:</strong> {{ importPreview.postman.folderCount }}</div>
</div>
<rs-button variant="primary" size="sm" @click="importPostmanCollection" class="mt-3">
Import Collection
</rs-button>
</div>
</div>
</div>
<!-- Import OpenAPI -->
<div v-if="selectedOperation === 'import-openapi'" class="flex-1 p-6">
<div class="space-y-4">
<div class="flex items-center gap-3 mb-4">
<Icon name="ic:outline-api" size="20" class="text-blue-600" />
<h4 class="font-medium text-gray-900 dark:text-white">Import OpenAPI Specification</h4>
</div>
<div class="space-y-3">
<FormKit
type="text"
v-model="openApiUrl"
label="OpenAPI URL"
placeholder="https://api.example.com/swagger.json"
help="Enter URL to OpenAPI/Swagger specification"
/>
<div class="text-center text-gray-500">or</div>
<div class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-8 text-center">
<input
ref="openApiFileInput"
type="file"
accept=".json,.yaml,.yml"
@change="handleOpenApiFile"
class="hidden"
/>
<Icon name="ic:outline-description" size="48" class="mx-auto mb-4 text-gray-400" />
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Upload OpenAPI specification file (JSON or YAML)
</p>
<rs-button variant="secondary" @click="$refs.openApiFileInput.click()">
Choose File
</rs-button>
</div>
</div>
<div v-if="importPreview.openapi" class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<h5 class="font-medium text-gray-900 dark:text-white mb-2">Preview</h5>
<div class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<div><strong>Title:</strong> {{ importPreview.openapi.title }}</div>
<div><strong>Version:</strong> {{ importPreview.openapi.version }}</div>
<div><strong>Endpoints:</strong> {{ importPreview.openapi.endpointCount }}</div>
</div>
<rs-button variant="primary" size="sm" @click="importOpenApiSpec" class="mt-3">
Import Specification
</rs-button>
</div>
</div>
</div>
<!-- Export Collections -->
<div v-if="selectedOperation === 'export-collections'" class="flex-1 p-6">
<div class="space-y-4">
<div class="flex items-center gap-3 mb-4">
<Icon name="ic:outline-download" size="20" class="text-green-600" />
<h4 class="font-medium text-gray-900 dark:text-white">Export Collections</h4>
</div>
<div class="space-y-3">
<div v-if="collections.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
<Icon name="ic:outline-folder-open" size="48" class="mx-auto mb-4 opacity-50" />
<p>No collections to export</p>
</div>
<div v-else class="space-y-2">
<div
v-for="collection in collections"
:key="collection.id"
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div class="flex items-center gap-3">
<Icon name="ic:outline-folder" size="16" class="text-blue-500" />
<div>
<div class="font-medium text-gray-900 dark:text-white">{{ collection.name }}</div>
<div class="text-sm text-gray-500">{{ collection.requests.length }} requests</div>
</div>
</div>
<div class="flex items-center gap-2">
<rs-button variant="secondary-outline" size="sm" @click="exportCollection(collection, 'postman')">
Postman
</rs-button>
<rs-button variant="secondary-outline" size="sm" @click="exportCollection(collection, 'insomnia')">
Insomnia
</rs-button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Export OpenAPI -->
<div v-if="selectedOperation === 'export-openapi'" class="flex-1 p-6">
<div class="space-y-4">
<div class="flex items-center gap-3 mb-4">
<Icon name="ic:outline-api" size="20" class="text-purple-600" />
<h4 class="font-medium text-gray-900 dark:text-white">Export as OpenAPI</h4>
</div>
<div class="space-y-4">
<FormKit
type="text"
v-model="openApiExport.title"
label="API Title"
placeholder="My API"
/>
<FormKit
type="text"
v-model="openApiExport.version"
label="API Version"
placeholder="1.0.0"
/>
<FormKit
type="text"
v-model="openApiExport.baseUrl"
label="Base URL"
placeholder="https://api.example.com"
/>
<FormKit
type="textarea"
v-model="openApiExport.description"
label="Description"
placeholder="API description..."
rows="3"
/>
<rs-button variant="primary" @click="exportAsOpenApi" class="w-full">
<Icon name="ic:outline-download" size="16" class="mr-2" />
Export OpenAPI Specification
</rs-button>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-between p-6 border-t border-gray-200 dark:border-gray-700">
<div class="text-sm text-gray-500 dark:text-gray-400">
Supports Postman v2.1+ and OpenAPI 3.0+ formats
</div>
<rs-button
variant="secondary"
@click="$emit('close')"
>
Close
</rs-button>
</div>
</div>
</div>
</template>
<script setup>
const emit = defineEmits(['close'])
const { collections, showNotification } = useApiPlatform()
const selectedOperation = ref('import-postman')
const openApiUrl = ref('')
const importPreview = ref({
postman: null,
openapi: null
})
const openApiExport = ref({
title: 'My API',
version: '1.0.0',
baseUrl: '',
description: ''
})
const operations = [
{
id: 'import-postman',
name: 'Import Postman',
description: 'Import Postman collection',
icon: 'ic:outline-upload'
},
{
id: 'import-openapi',
name: 'Import OpenAPI',
description: 'Import OpenAPI spec',
icon: 'ic:outline-api'
},
{
id: 'export-collections',
name: 'Export Collections',
description: 'Export to various formats',
icon: 'ic:outline-download'
},
{
id: 'export-openapi',
name: 'Export OpenAPI',
description: 'Generate OpenAPI spec',
icon: 'ic:outline-code'
}
]
// File handling
const handlePostmanFile = (event) => {
const file = event.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = JSON.parse(e.target.result)
if (!content.info || !content.item) {
showNotification('Invalid Postman collection format', 'error')
return
}
importPreview.value.postman = {
name: content.info.name || 'Unnamed Collection',
requestCount: countRequests(content.item),
folderCount: countFolders(content.item),
content
}
} catch (error) {
showNotification('Failed to parse Postman collection', 'error')
}
}
reader.readAsText(file)
}
const handleOpenApiFile = (event) => {
const file = event.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
try {
let content
if (file.name.endsWith('.yaml') || file.name.endsWith('.yml')) {
// For YAML parsing, we'd need a YAML parser
showNotification('YAML parsing not implemented yet', 'warning')
return
} else {
content = JSON.parse(e.target.result)
}
if (!content.openapi && !content.swagger) {
showNotification('Invalid OpenAPI specification format', 'error')
return
}
importPreview.value.openapi = {
title: content.info?.title || 'Unnamed API',
version: content.info?.version || '1.0.0',
endpointCount: countEndpoints(content.paths || {}),
content
}
} catch (error) {
showNotification('Failed to parse OpenAPI specification', 'error')
}
}
reader.readAsText(file)
}
// Counting functions
const countRequests = (items) => {
let count = 0
items.forEach(item => {
if (item.request) {
count++
} else if (item.item) {
count += countRequests(item.item)
}
})
return count
}
const countFolders = (items) => {
let count = 0
items.forEach(item => {
if (item.item && !item.request) {
count++
count += countFolders(item.item)
}
})
return count
}
const countEndpoints = (paths) => {
let count = 0
Object.values(paths).forEach(pathObj => {
count += Object.keys(pathObj).filter(key =>
['get', 'post', 'put', 'patch', 'delete', 'head', 'options'].includes(key.toLowerCase())
).length
})
return count
}
// Import functions
const importPostmanCollection = () => {
const postmanData = importPreview.value.postman
if (!postmanData) return
try {
const collection = convertPostmanToCollection(postmanData.content)
collections.value.push(collection)
// Save to localStorage
localStorage.setItem('api-platform-collections', JSON.stringify(collections.value))
showNotification(`Imported "${postmanData.name}" successfully`, 'success')
importPreview.value.postman = null
} catch (error) {
showNotification('Failed to import Postman collection', 'error')
}
}
const importOpenApiSpec = () => {
const openApiData = importPreview.value.openapi
if (!openApiData) return
try {
const collection = convertOpenApiToCollection(openApiData.content)
collections.value.push(collection)
// Save to localStorage
localStorage.setItem('api-platform-collections', JSON.stringify(collections.value))
showNotification(`Imported "${openApiData.title}" successfully`, 'success')
importPreview.value.openapi = null
} catch (error) {
showNotification('Failed to import OpenAPI specification', 'error')
}
}
// Export functions
const exportCollection = (collection, format) => {
try {
let exportData
let filename
if (format === 'postman') {
exportData = convertCollectionToPostman(collection)
filename = `${collection.name}-postman.json`
} else if (format === 'insomnia') {
exportData = convertCollectionToInsomnia(collection)
filename = `${collection.name}-insomnia.json`
}
downloadJson(exportData, filename)
showNotification(`Exported ${collection.name} as ${format}`, 'success')
} catch (error) {
showNotification(`Failed to export collection as ${format}`, 'error')
}
}
const exportAsOpenApi = () => {
try {
const spec = generateOpenApiSpec()
downloadJson(spec, `${openApiExport.value.title.replace(/\s+/g, '-').toLowerCase()}-openapi.json`)
showNotification('OpenAPI specification exported', 'success')
} catch (error) {
showNotification('Failed to export OpenAPI specification', 'error')
}
}
// Conversion functions
const convertPostmanToCollection = (postmanCollection) => {
const collection = {
id: Date.now(),
name: postmanCollection.info.name,
description: postmanCollection.info.description || '',
requests: [],
createdAt: new Date().toISOString()
}
const processItems = (items, parentName = '') => {
items.forEach(item => {
if (item.request) {
const request = {
id: Date.now() + Math.random(),
name: item.name || 'Untitled Request',
method: item.request.method || 'GET',
url: typeof item.request.url === 'string' ? item.request.url : item.request.url?.raw || '',
params: [],
headers: [],
auth: { type: 'none' },
body: { type: 'raw', raw: '' }
}
// Convert headers
if (item.request.header) {
request.headers = item.request.header.map(h => ({
active: !h.disabled,
key: h.key,
value: h.value,
description: h.description || ''
}))
}
// Convert body
if (item.request.body) {
if (item.request.body.mode === 'raw') {
request.body.raw = item.request.body.raw || ''
}
}
collection.requests.push(request)
} else if (item.item) {
processItems(item.item, item.name)
}
})
}
processItems(postmanCollection.item)
return collection
}
const convertOpenApiToCollection = (openApiSpec) => {
const collection = {
id: Date.now(),
name: openApiSpec.info.title,
description: openApiSpec.info.description || '',
requests: [],
createdAt: new Date().toISOString()
}
const baseUrl = openApiSpec.servers?.[0]?.url || ''
Object.entries(openApiSpec.paths || {}).forEach(([path, pathObj]) => {
Object.entries(pathObj).forEach(([method, operation]) => {
if (['get', 'post', 'put', 'patch', 'delete', 'head', 'options'].includes(method.toLowerCase())) {
const request = {
id: Date.now() + Math.random(),
name: operation.summary || `${method.toUpperCase()} ${path}`,
method: method.toUpperCase(),
url: baseUrl + path,
params: [],
headers: [],
auth: { type: 'none' },
body: { type: 'raw', raw: '' }
}
// Convert parameters
if (operation.parameters) {
operation.parameters.forEach(param => {
if (param.in === 'query') {
request.params.push({
active: !param.required,
key: param.name,
value: param.example || '',
description: param.description || ''
})
}
})
}
collection.requests.push(request)
}
})
})
return collection
}
const convertCollectionToPostman = (collection) => {
return {
info: {
name: collection.name,
description: collection.description,
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
item: collection.requests.map(request => ({
name: request.name,
request: {
method: request.method,
header: request.headers.map(h => ({
key: h.key,
value: h.value,
disabled: !h.active
})),
url: {
raw: request.url,
query: request.params.map(p => ({
key: p.key,
value: p.value,
disabled: !p.active
}))
},
body: request.body.type === 'raw' ? {
mode: 'raw',
raw: request.body.raw
} : undefined
}
}))
}
}
const convertCollectionToInsomnia = (collection) => {
return {
_type: 'export',
__export_format: 4,
resources: [
{
_id: `workspace_${Date.now()}`,
_type: 'workspace',
name: collection.name,
description: collection.description
},
...collection.requests.map(request => ({
_id: `request_${request.id}`,
_type: 'request',
name: request.name,
method: request.method,
url: request.url,
headers: request.headers.reduce((acc, h) => {
if (h.active) acc[h.key] = h.value
return acc
}, {}),
body: request.body.type === 'raw' ? {
mimeType: 'application/json',
text: request.body.raw
} : undefined
}))
]
}
}
const generateOpenApiSpec = () => {
const spec = {
openapi: '3.0.3',
info: {
title: openApiExport.value.title,
version: openApiExport.value.version,
description: openApiExport.value.description
},
servers: openApiExport.value.baseUrl ? [{
url: openApiExport.value.baseUrl
}] : [],
paths: {}
}
// Generate paths from all collections
collections.value.forEach(collection => {
collection.requests.forEach(request => {
try {
const url = new URL(request.url)
const path = url.pathname
if (!spec.paths[path]) {
spec.paths[path] = {}
}
spec.paths[path][request.method.toLowerCase()] = {
summary: request.name,
responses: {
'200': {
description: 'Successful response'
}
}
}
} catch (error) {
// Skip invalid URLs
}
})
})
return spec
}
// Utility function
const downloadJson = (data, filename) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
</script>

View File

@ -0,0 +1,247 @@
<template>
<div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
<div class="p-6">
<!-- Enhanced URL Input Section -->
<div class="flex items-center gap-3 mb-6">
<FormKit
type="select"
v-model="httpMethod"
:options="httpMethods"
outer-class="!mb-0 w-32 flex-shrink-0"
inner-class="!mb-0 font-medium"
/>
<div class="flex-1 relative">
<FormKit
type="text"
v-model="requestUrl"
placeholder="Enter request URL (e.g., https://httpbin.org/get)"
outer-class="!mb-0"
inner-class="!mb-0 pr-10 !bg-white dark:!bg-gray-800"
/>
<!-- Variable indicator -->
<div v-if="urlVariables.length > 0" class="absolute right-3 top-1/2 transform -translate-y-1/2">
<rs-button
variant="text"
size="sm"
@click="showVariablePreview = !showVariablePreview"
class="p-1 text-blue-500 hover:text-blue-600 transition-colors"
:title="`${urlVariables.length} variable(s) found`"
>
<Icon name="ic:outline-code" size="16" />
</rs-button>
</div>
</div>
<rs-button
variant="primary"
@click="handleSendRequest"
:loading="isLoading"
class="px-8 py-2.5 flex-shrink-0 font-medium"
>
<Icon name="ic:outline-send" size="16" class="mr-2" />
Send
</rs-button>
</div>
<!-- Enhanced Variable Preview -->
<div v-if="showVariablePreview && urlVariables.length > 0" class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-center gap-2 mb-3">
<Icon name="ic:outline-code" size="16" class="text-blue-600 dark:text-blue-400" />
<h4 class="text-sm font-medium text-blue-900 dark:text-blue-100">Variables in URL</h4>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div
v-for="variable in urlVariables"
:key="variable.name"
class="flex items-center gap-2 text-sm p-2 bg-white dark:bg-blue-900/40 rounded border"
>
<span class="font-mono text-blue-700 dark:text-blue-300 font-medium" v-text="formatVariableName(variable.name)"></span>
<Icon
:name="variable.found ? 'ic:outline-check' : 'ic:outline-warning'"
size="14"
:class="variable.found ? 'text-green-500' : 'text-yellow-500'"
/>
<span :class="variable.found ? 'text-green-700 dark:text-green-300' : 'text-yellow-700 dark:text-yellow-300'" class="truncate">
{{ variable.found ? variable.value : 'Not defined' }}
</span>
</div>
</div>
</div>
<!-- Enhanced Environment Indicator -->
<div v-if="currentEnvironment" class="mb-6 flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<Icon name="ic:outline-layers" size="16" class="text-green-600 dark:text-green-400" />
<span class="text-sm text-green-800 dark:text-green-200">
Using environment: <span class="font-medium">{{ currentEnvironment.name }}</span>
</span>
</div>
<!-- Enhanced Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="-mb-px flex space-x-8 overflow-x-auto">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
:class="[
activeTab === tab.id
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300',
'whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm flex items-center gap-2 flex-shrink-0'
]"
>
<Icon :name="tab.icon" size="16" />
{{ tab.label }}
<rs-badge v-if="tab.count" variant="secondary" size="sm">{{ tab.count }}</rs-badge>
</button>
</nav>
</div>
<!-- Enhanced Tab Content -->
<div class="py-6 overflow-x-auto">
<!-- Params Tab -->
<ParamsTab v-if="activeTab === 'params'" :params="requestParams" />
<!-- Headers Tab -->
<HeadersTab v-if="activeTab === 'headers'" :headers="requestHeaders" />
<!-- Auth Tab -->
<AuthTab v-if="activeTab === 'auth'" :auth="requestAuth" />
<!-- Body Tab -->
<BodyTab v-if="activeTab === 'body'" :body="requestBody" />
</div>
</div>
</div>
</template>
<script setup>
import ParamsTab from './tabs/ParamsTab.vue'
import HeadersTab from './tabs/HeadersTab.vue'
import AuthTab from './tabs/AuthTab.vue'
import BodyTab from './tabs/BodyTab.vue'
const { sendRequest: sendRequestUtil } = useApiRequest()
const { processRequest, findVariables, currentEnvironment } = useVariableSubstitution()
const {
activeTab,
httpMethod,
requestUrl,
isLoading,
requestParams,
requestHeaders,
requestAuth,
requestBody,
response,
requestHistory,
requestName,
httpMethods,
showNotification
} = useApiPlatform()
const showVariablePreview = ref(false)
// Enhanced tab configuration
const tabs = computed(() => [
{
id: 'params',
label: 'Params',
icon: 'ic:outline-tune',
count: requestParams.value.filter(p => p.active && p.key).length
},
{
id: 'headers',
label: 'Headers',
icon: 'ic:outline-list',
count: requestHeaders.value.filter(h => h.active && h.key).length
},
{
id: 'auth',
label: 'Authorization',
icon: 'ic:outline-security',
count: requestAuth.value.type !== 'none' ? 1 : 0
},
{
id: 'body',
label: 'Body',
icon: 'ic:outline-description',
count: getBodyContentCount()
}
])
// Get body content count for badge
const getBodyContentCount = () => {
if (requestBody.value.type === 'none') return 0
if (requestBody.value.type === 'raw' && requestBody.value.raw) return 1
if (requestBody.value.type === 'form-data') {
return requestBody.value.formData.filter(item => item.active && item.key).length
}
if (requestBody.value.type === 'x-www-form-urlencoded') {
return requestBody.value.urlEncoded.filter(item => item.active && item.key).length
}
return 0
}
// Computed properties for variable detection
const urlVariables = computed(() => {
return findVariables(requestUrl.value)
})
// Method to format variable name with curly braces
const formatVariableName = (name) => {
return `{{${name}}}`
}
const handleSendRequest = async () => {
if (!requestUrl.value) {
showNotification('Please enter a URL', 'error')
return
}
const requestData = {
url: requestUrl.value,
method: httpMethod.value,
headers: requestHeaders.value,
params: requestParams.value,
auth: requestAuth.value,
body: requestBody.value
}
// Process variables before sending
const processedRequest = processRequest(requestData)
await sendRequestUtil(processedRequest, {
onStart: () => {
isLoading.value = true
},
onSuccess: (responseData) => {
response.value = responseData
// Add timestamp to response
response.value.timestamp = new Date().toISOString()
// Add to history
requestHistory.value.unshift({
id: Date.now(),
timestamp: new Date().toISOString(),
name: requestName.value || 'Untitled Request',
method: httpMethod.value,
url: requestUrl.value,
status: responseData.status
})
showNotification('Request completed successfully', 'success')
},
onError: (message, responseData) => {
if (responseData) {
response.value = responseData
response.value.timestamp = new Date().toISOString()
}
showNotification(`Request failed: ${message}`, 'error')
},
onComplete: () => {
isLoading.value = false
}
})
}
</script>

View File

@ -0,0 +1,586 @@
<template>
<div class="bg-white dark:bg-gray-800 flex flex-col">
<!-- Enhanced Response Header -->
<div class="flex items-center justify-between p-6 flex-shrink-0 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<Icon name="ic:outline-api" size="20" class="text-gray-500" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Response</h3>
<div v-if="hasResponse" class="text-sm text-gray-500 dark:text-gray-400">
{{ new Date(response.timestamp || Date.now()).toLocaleTimeString() }}
</div>
</div>
<div v-if="hasResponse" class="flex items-center gap-3 flex-wrap">
<rs-badge :variant="getStatusVariant(response.status || 500)" class="font-medium">
{{ response.status || 'Error' }} {{ response.statusText || 'Request Failed' }}
</rs-badge>
<div class="flex items-center gap-4 text-sm text-gray-500">
<span v-if="response.time" class="flex items-center gap-1">
<Icon name="ic:outline-timer" size="14" />
{{ response.time }}ms
</span>
<span v-if="response.size" class="flex items-center gap-1">
<Icon name="ic:outline-data-usage" size="14" />
{{ formatBytes(response.size) }}
</span>
</div>
</div>
</div>
<!-- Response Content Area -->
<div v-if="hasResponse" class="p-6">
<div class="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800">
<!-- Enhanced Response Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<nav class="px-4 flex space-x-6 overflow-x-auto">
<button
v-for="tab in responseTabs"
:key="tab.id"
@click="responseActiveTab = tab.id"
:class="[
responseActiveTab === tab.id
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300',
'whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm flex items-center gap-2 flex-shrink-0'
]"
>
<Icon :name="tab.icon" size="16" />
{{ tab.label }}
<rs-badge v-if="tab.count" variant="secondary" size="sm">{{ tab.count }}</rs-badge>
</button>
</nav>
</div>
<!-- Response Content -->
<div>
<!-- Body Tab -->
<div v-if="responseActiveTab === 'body'">
<!-- Enhanced Response Tools -->
<div v-if="isJsonResponse || response.data" class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2 flex-wrap">
<rs-button
v-if="isJsonResponse"
variant="secondary-outline"
size="sm"
@click="beautifyResponseJson"
class="flex items-center gap-1"
>
<Icon name="ic:outline-auto-fix-high" size="16" />
Beautify
</rs-button>
<rs-button
v-if="isJsonResponse"
variant="secondary-outline"
size="sm"
@click="minifyResponseJson"
class="flex items-center gap-1"
>
<Icon name="ic:outline-compress" size="16" />
Minify
</rs-button>
<rs-button
variant="secondary-outline"
size="sm"
@click="copyResponseToClipboard"
class="flex items-center gap-1"
>
<Icon name="ic:outline-content-copy" size="16" />
Copy
</rs-button>
<rs-button
variant="secondary-outline"
size="sm"
@click="downloadResponse"
class="flex items-center gap-1"
>
<Icon name="ic:outline-download" size="16" />
Download
</rs-button>
</div>
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500">Format:</span>
<rs-badge variant="info" size="sm">{{ responseFormat }}</rs-badge>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500">Size:</span>
<rs-badge variant="secondary" size="sm">{{ formattedResponseData.length }} chars</rs-badge>
</div>
</div>
</div>
<!-- Response Body Content -->
<div class="p-6">
<pre
ref="responseBody"
class="text-sm bg-gray-50 dark:bg-gray-900 p-4 rounded-lg whitespace-pre-wrap w-full break-words border font-mono leading-relaxed"
:class="{
'language-json': isJsonResponse,
'language-xml': isXmlResponse,
'language-html': isHtmlResponse
}"
>{{ formattedResponseData }}</pre>
</div>
</div>
<!-- Headers Tab -->
<div v-if="responseActiveTab === 'headers'" class="p-6 space-y-4">
<!-- Copy Headers Button -->
<div class="flex justify-between items-center">
<h4 class="font-medium text-gray-900 dark:text-white">Response Headers</h4>
<rs-button variant="secondary-outline" size="sm" @click="copyHeadersToClipboard">
<Icon name="ic:outline-content-copy" size="16" class="mr-1" />
Copy All
</rs-button>
</div>
<!-- Headers List -->
<div class="space-y-2">
<div v-for="(value, key) in (response && response.headers ? response.headers : {})" :key="key" class="flex flex-wrap gap-2 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border">
<span class="font-medium text-gray-700 dark:text-gray-300 min-w-48 break-words">{{ key }}:</span>
<span class="text-gray-600 dark:text-gray-400 break-words flex-1 font-mono text-sm">{{ value }}</span>
<rs-button
variant="text"
size="sm"
@click="copyHeaderToClipboard(key, value)"
class="opacity-60 hover:opacity-100 flex-shrink-0"
>
<Icon name="ic:outline-content-copy" size="14" />
</rs-button>
</div>
<div v-if="Object.keys(response?.headers || {}).length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
<Icon name="ic:outline-web-asset-off" size="48" class="mx-auto mb-4 opacity-50" />
<p>No response headers available</p>
</div>
</div>
</div>
<!-- Cookies Tab -->
<div v-if="responseActiveTab === 'cookies'" class="p-6 space-y-4">
<div class="flex justify-between items-center">
<h4 class="font-medium text-gray-900 dark:text-white">Cookies</h4>
<rs-button variant="secondary-outline" size="sm" @click="clearCookies">
<Icon name="ic:outline-clear" size="16" class="mr-1" />
Clear All
</rs-button>
</div>
<div class="space-y-2">
<div v-for="cookie in parsedCookies" :key="cookie.name" class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border">
<div class="flex items-center justify-between mb-2">
<span class="font-medium text-gray-900 dark:text-white">{{ cookie.name }}</span>
<rs-button variant="text" size="sm" @click="copyCookie(cookie)">
<Icon name="ic:outline-content-copy" size="14" />
</rs-button>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<div><span class="font-medium">Value:</span> {{ cookie.value }}</div>
<div v-if="cookie.domain"><span class="font-medium">Domain:</span> {{ cookie.domain }}</div>
<div v-if="cookie.path"><span class="font-medium">Path:</span> {{ cookie.path }}</div>
<div v-if="cookie.expires"><span class="font-medium">Expires:</span> {{ cookie.expires }}</div>
<div v-if="cookie.httpOnly" class="flex items-center gap-1">
<Icon name="ic:outline-security" size="14" />
<span>HTTP Only</span>
</div>
<div v-if="cookie.secure" class="flex items-center gap-1">
<Icon name="ic:outline-lock" size="14" />
<span>Secure</span>
</div>
</div>
</div>
<div v-if="parsedCookies.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
<Icon name="ic:outline-cookie" size="48" class="mx-auto mb-4 opacity-50" />
<p>No cookies in response</p>
</div>
</div>
</div>
<!-- Timeline Tab -->
<div v-if="responseActiveTab === 'timeline'" class="p-6 space-y-4">
<h4 class="font-medium text-gray-900 dark:text-white">Request Timeline</h4>
<div class="space-y-3">
<div v-for="(stage, index) in timelineStages" :key="stage.name" class="flex items-center gap-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="flex-shrink-0">
<div class="w-3 h-3 rounded-full" :class="stage.color"></div>
</div>
<div class="flex-1">
<div class="font-medium text-gray-900 dark:text-white">{{ stage.name }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">{{ stage.description }}</div>
</div>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ stage.time }}ms
</div>
</div>
</div>
<!-- Total Time -->
<div class="mt-6 p-4 bg-primary-50 dark:bg-primary-900/20 rounded-lg border border-primary-200 dark:border-primary-800">
<div class="flex items-center justify-between">
<span class="font-medium text-primary-900 dark:text-primary-100">Total Request Time</span>
<span class="text-xl font-bold text-primary-900 dark:text-primary-100">{{ response.time || 0 }}ms</span>
</div>
</div>
</div>
<!-- Test Results Tab -->
<div v-if="responseActiveTab === 'tests'" class="p-6 space-y-4">
<div class="flex justify-between items-center">
<h4 class="font-medium text-gray-900 dark:text-white">Test Results</h4>
<rs-button variant="primary" size="sm" @click="runBasicTests">
<Icon name="ic:outline-play-arrow" size="16" class="mr-1" />
Run Tests
</rs-button>
</div>
<div class="space-y-2">
<div v-for="test in testResults" :key="test.name" class="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<Icon
:name="test.passed ? 'ic:outline-check-circle' : 'ic:outline-error'"
size="20"
:class="test.passed ? 'text-green-500' : 'text-red-500'"
/>
<div class="flex-1">
<div class="font-medium text-gray-900 dark:text-white">{{ test.name }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">{{ test.description }}</div>
</div>
<rs-badge :variant="test.passed ? 'success' : 'danger'" size="sm">
{{ test.passed ? 'PASS' : 'FAIL' }}
</rs-badge>
</div>
<div v-if="testResults.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
<Icon name="ic:outline-quiz" size="48" class="mx-auto mb-4 opacity-50" />
<p>No test results yet</p>
<p class="text-xs mt-2">Click "Run Tests" to execute basic validation tests</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400 p-6">
<div class="text-center">
<Icon name="ic:outline-send" size="64" class="mx-auto mb-6 opacity-50" />
<h3 class="text-lg font-medium mb-2">Ready to send your first request?</h3>
<p class="text-sm">Configure your request above and click Send to see the response here</p>
</div>
</div>
</div>
</template>
<script setup>
const { formatJson, getStatusVariant } = useApiRequest()
const { response, responseActiveTab, showNotification } = useApiPlatform()
// Enhanced tab configuration
const responseTabs = computed(() => [
{
id: 'body',
label: 'Body',
icon: 'ic:outline-description',
count: null
},
{
id: 'headers',
label: 'Headers',
icon: 'ic:outline-list',
count: response.value?.headers ? Object.keys(response.value.headers).length : 0
},
{
id: 'cookies',
label: 'Cookies',
icon: 'ic:outline-cookie',
count: parsedCookies.value.length
},
{
id: 'timeline',
label: 'Timeline',
icon: 'ic:outline-timeline',
count: null
},
{
id: 'tests',
label: 'Tests',
icon: 'ic:outline-quiz',
count: testResults.value.length
}
])
// Check if we have a response to show
const hasResponse = computed(() => {
return response.value && (response.value.status || response.value.data)
})
// Response format detection
const isJsonResponse = computed(() => {
if (!response.value?.data) return false
return typeof response.value.data === 'object' ||
(typeof response.value.data === 'string' && isValidJson(response.value.data))
})
const isXmlResponse = computed(() => {
if (!response.value?.data || typeof response.value.data !== 'string') return false
return response.value.data.trim().startsWith('<?xml') || response.value.data.trim().startsWith('<')
})
const isHtmlResponse = computed(() => {
if (!response.value?.data || typeof response.value.data !== 'string') return false
return response.value.data.trim().toLowerCase().includes('<html')
})
const responseFormat = computed(() => {
if (isJsonResponse.value) return 'JSON'
if (isXmlResponse.value) return 'XML'
if (isHtmlResponse.value) return 'HTML'
return 'TEXT'
})
// Cookie parsing
const parsedCookies = computed(() => {
if (!response.value?.headers) return []
const cookieHeaders = []
Object.entries(response.value.headers).forEach(([key, value]) => {
if (key.toLowerCase() === 'set-cookie') {
if (Array.isArray(value)) {
cookieHeaders.push(...value)
} else {
cookieHeaders.push(value)
}
}
})
return cookieHeaders.map(cookieString => {
const parts = cookieString.split(';').map(part => part.trim())
const [name, value] = parts[0].split('=')
const cookie = { name, value: value || '' }
parts.slice(1).forEach(part => {
const [key, val] = part.split('=')
const lowerKey = key.toLowerCase()
if (lowerKey === 'domain') cookie.domain = val
else if (lowerKey === 'path') cookie.path = val
else if (lowerKey === 'expires') cookie.expires = val
else if (lowerKey === 'httponly') cookie.httpOnly = true
else if (lowerKey === 'secure') cookie.secure = true
})
return cookie
})
})
// Timeline stages
const timelineStages = computed(() => {
const total = response.value?.time || 0
const stages = [
{ name: 'DNS Lookup', description: 'Resolving domain name', time: Math.round(total * 0.1), color: 'bg-blue-500' },
{ name: 'TCP Connect', description: 'Establishing connection', time: Math.round(total * 0.15), color: 'bg-green-500' },
{ name: 'TLS Handshake', description: 'SSL/TLS negotiation', time: Math.round(total * 0.2), color: 'bg-yellow-500' },
{ name: 'Request Sent', description: 'Sending request data', time: Math.round(total * 0.05), color: 'bg-purple-500' },
{ name: 'Server Processing', description: 'Server processing time', time: Math.round(total * 0.4), color: 'bg-red-500' },
{ name: 'Response Received', description: 'Downloading response', time: Math.round(total * 0.1), color: 'bg-indigo-500' }
]
return stages
})
// Test results
const testResults = ref([])
// Response body formatting
const formattedResponseData = ref('')
const formatResponseData = () => {
if (!response.value?.data) {
formattedResponseData.value = 'No response data'
return
}
if (typeof response.value.data === 'object') {
formattedResponseData.value = JSON.stringify(response.value.data, null, 2)
} else if (typeof response.value.data === 'string' && isValidJson(response.value.data)) {
try {
const parsed = JSON.parse(response.value.data)
formattedResponseData.value = JSON.stringify(parsed, null, 2)
} catch (e) {
formattedResponseData.value = response.value.data
}
} else {
formattedResponseData.value = String(response.value.data)
}
}
// Utility functions
const isValidJson = (str) => {
try {
JSON.parse(str)
return true
} catch (e) {
return false
}
}
const formatBytes = (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))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// Response JSON tools
const beautifyResponseJson = () => {
if (!isJsonResponse.value) return
try {
let data = response.value.data
if (typeof data === 'string') {
data = JSON.parse(data)
}
formattedResponseData.value = JSON.stringify(data, null, 2)
showNotification('JSON beautified', 'success', 2000)
} catch (error) {
showNotification('Failed to beautify JSON', 'error')
}
}
const minifyResponseJson = () => {
if (!isJsonResponse.value) return
try {
let data = response.value.data
if (typeof data === 'string') {
data = JSON.parse(data)
}
formattedResponseData.value = JSON.stringify(data)
showNotification('JSON minified', 'success', 2000)
} catch (error) {
showNotification('Failed to minify JSON', 'error')
}
}
const copyResponseToClipboard = async () => {
try {
await navigator.clipboard.writeText(formattedResponseData.value)
showNotification('Response copied to clipboard', 'success', 2000)
} catch (error) {
showNotification('Failed to copy response', 'error')
}
}
const downloadResponse = () => {
try {
const blob = new Blob([formattedResponseData.value], {
type: isJsonResponse.value ? 'application/json' : 'text/plain'
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const extension = isJsonResponse.value ? 'json' : 'txt'
a.href = url
a.download = `response-${Date.now()}.${extension}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showNotification('Response downloaded', 'success', 2000)
} catch (error) {
showNotification('Failed to download response', 'error')
}
}
// Headers tools
const copyHeadersToClipboard = async () => {
try {
if (!response.value || !response.value.headers) {
showNotification('No headers to copy', 'warning')
return
}
const headersText = Object.entries(response.value.headers)
.map(([key, value]) => `${key}: ${value}`)
.join('\n')
await navigator.clipboard.writeText(headersText)
showNotification('Headers copied to clipboard', 'success', 2000)
} catch (error) {
showNotification('Failed to copy headers', 'error')
}
}
const copyHeaderToClipboard = async (key, value) => {
try {
await navigator.clipboard.writeText(`${key}: ${value}`)
showNotification(`Header "${key}" copied`, 'success', 2000)
} catch (error) {
showNotification('Failed to copy header', 'error')
}
}
// Cookie tools
const clearCookies = () => {
showNotification('Cookies cleared from display', 'info', 2000)
}
const copyCookie = async (cookie) => {
try {
await navigator.clipboard.writeText(`${cookie.name}=${cookie.value}`)
showNotification(`Cookie "${cookie.name}" copied`, 'success', 2000)
} catch (error) {
showNotification('Failed to copy cookie', 'error')
}
}
// Test functions
const runBasicTests = () => {
testResults.value = []
if (!response.value) return
// Status code tests
if (response.value.status) {
testResults.value.push({
name: 'Status Code is 2xx',
description: 'Response has successful status code',
passed: response.value.status >= 200 && response.value.status < 300
})
testResults.value.push({
name: 'Response Time < 1000ms',
description: 'Response was received quickly',
passed: (response.value.time || 0) < 1000
})
}
// Content tests
if (response.value.data) {
testResults.value.push({
name: 'Response Has Body',
description: 'Response contains data',
passed: !!response.value.data
})
if (isJsonResponse.value) {
testResults.value.push({
name: 'Valid JSON Response',
description: 'Response body is valid JSON',
passed: true
})
}
}
// Headers tests
if (response.value.headers) {
testResults.value.push({
name: 'Content-Type Header Present',
description: 'Response includes content type',
passed: !!response.value.headers['content-type'] || !!response.value.headers['Content-Type']
})
}
showNotification(`${testResults.value.length} tests completed`, 'info', 2000)
}
// Watch for response changes
watch(() => response.value, formatResponseData, { immediate: true, deep: true })
</script>

View File

@ -0,0 +1,149 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Save Request</h3>
<rs-button
variant="text"
size="sm"
@click="$emit('close')"
class="p-2"
>
<Icon name="ic:outline-close" size="16" />
</rs-button>
</div>
<!-- Content -->
<div class="p-6">
<FormKit
type="text"
v-model="requestName"
label="Request Name"
placeholder="Enter request name"
validation="required"
outer-class="mb-4"
/>
<FormKit
type="select"
v-model="selectedCollectionId"
label="Collection"
:options="collectionOptions"
validation="required"
outer-class="mb-4"
/>
<div v-if="!selectedCollectionId" class="text-center py-4">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">No collections available</p>
<rs-button
variant="primary"
size="sm"
@click="createNewCollection"
>
Create New Collection
</rs-button>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
<rs-button
variant="secondary"
@click="$emit('close')"
>
Cancel
</rs-button>
<rs-button
variant="primary"
@click="saveRequest"
:disabled="!requestName.trim() || !selectedCollectionId"
>
Save Request
</rs-button>
</div>
</div>
</div>
</template>
<script setup>
const emit = defineEmits(['close'])
const {
collections,
httpMethod,
requestUrl,
requestParams,
requestHeaders,
requestAuth,
requestBody,
requestName: globalRequestName,
showNotification
} = useApiPlatform()
const requestName = ref(globalRequestName.value || 'Untitled Request')
const selectedCollectionId = ref('')
// Compute collection options for the dropdown
const collectionOptions = computed(() => {
return collections.value.map(collection => ({
label: collection.name,
value: collection.id
}))
})
const saveRequest = () => {
if (!requestName.value.trim() || !selectedCollectionId.value) return
const collection = collections.value.find(c => c.id === selectedCollectionId.value)
if (!collection) return
const newRequest = {
id: Date.now(),
name: requestName.value.trim(),
method: httpMethod.value,
url: requestUrl.value,
params: JSON.parse(JSON.stringify(requestParams.value)),
headers: JSON.parse(JSON.stringify(requestHeaders.value)),
auth: JSON.parse(JSON.stringify(requestAuth.value)),
body: JSON.parse(JSON.stringify(requestBody.value)),
createdAt: new Date().toISOString()
}
collection.requests.push(newRequest)
// Save collections to localStorage
localStorage.setItem('api-platform-collections', JSON.stringify(collections.value))
showNotification(`Request saved to ${collection.name}`, 'success')
emit('close')
}
const createNewCollection = () => {
const name = prompt('Collection name:')
if (name && name.trim()) {
const newCollection = {
id: Date.now(),
name: name.trim(),
description: '',
requests: [],
createdAt: new Date().toISOString()
}
collections.value.push(newCollection)
selectedCollectionId.value = newCollection.id
// Save collections to localStorage
localStorage.setItem('api-platform-collections', JSON.stringify(collections.value))
showNotification('Collection created', 'success')
}
}
// Auto-select first collection if available
onMounted(() => {
if (collections.value.length > 0) {
selectedCollectionId.value = collections.value[0].id
}
})
</script>

View File

@ -0,0 +1,72 @@
<template>
<div class="space-y-4 max-w-md">
<FormKit
type="select"
v-model="auth.type"
:options="[
{ value: 'none', label: 'No Auth' },
{ value: 'bearer', label: 'Bearer Token' },
{ value: 'basic', label: 'Basic Auth' },
{ value: 'apiKey', label: 'API Key' }
]"
outer-class="!mb-0"
/>
<div v-if="auth.type === 'bearer'" class="space-y-3">
<FormKit
type="text"
v-model="auth.bearer"
placeholder="Token"
label="Bearer Token"
/>
</div>
<div v-if="auth.type === 'basic'" class="space-y-3">
<FormKit
type="text"
v-model="auth.basic.username"
placeholder="Username"
label="Username"
/>
<FormKit
type="password"
v-model="auth.basic.password"
placeholder="Password"
label="Password"
/>
</div>
<div v-if="auth.type === 'apiKey'" class="space-y-3">
<FormKit
type="text"
v-model="auth.apiKey.key"
placeholder="Key"
label="Key"
/>
<FormKit
type="text"
v-model="auth.apiKey.value"
placeholder="Value"
label="Value"
/>
<FormKit
type="select"
v-model="auth.apiKey.addTo"
:options="[
{ value: 'header', label: 'Header' },
{ value: 'query', label: 'Query Params' }
]"
label="Add to"
/>
</div>
</div>
</template>
<script setup>
const props = defineProps({
auth: {
type: Object,
required: true
}
})
</script>

View File

@ -0,0 +1,436 @@
<template>
<div class="space-y-6">
<!-- Enhanced Body Type Selector -->
<div class="flex items-center gap-4">
<FormKit
type="select"
v-model="body.type"
:options="[
{ value: 'none', label: 'None' },
{ value: 'raw', label: 'Raw' },
{ value: 'form-data', label: 'Form-data' },
{ value: 'x-www-form-urlencoded', label: 'x-www-form-urlencoded' }
]"
outer-class="!mb-0 max-w-md"
inner-class="!mb-0 font-medium"
/>
<div v-if="body.type !== 'none'" class="text-sm text-gray-500 dark:text-gray-400">
{{ getBodyTypeDescription() }}
</div>
</div>
<!-- Raw Body with Enhanced JSON Tools -->
<div v-if="body.type === 'raw'" class="space-y-4">
<!-- Enhanced Content Type Selector -->
<div class="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300 min-w-fit">Content Type:</label>
<FormKit
type="select"
v-model="rawContentType"
:options="[
{ value: 'application/json', label: 'JSON' },
{ value: 'text/plain', label: 'Text' },
{ value: 'application/xml', label: 'XML' },
{ value: 'text/html', label: 'HTML' },
{ value: 'application/javascript', label: 'JavaScript' },
{ value: 'text/css', label: 'CSS' }
]"
outer-class="!mb-0 flex-1"
inner-class="!mb-0"
/>
</div>
<!-- Enhanced JSON Tools -->
<div v-if="rawContentType === 'application/json'" class="flex items-center justify-between p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-center gap-2">
<Icon name="ic:outline-data-object" size="16" class="text-blue-600 dark:text-blue-400" />
<span class="text-sm font-medium text-blue-900 dark:text-blue-100">JSON Tools</span>
</div>
<div class="flex items-center gap-2">
<rs-button variant="secondary-outline" size="sm" @click="beautifyJson">
<Icon name="ic:outline-auto-fix-high" size="16" class="mr-1" />
Beautify
</rs-button>
<rs-button variant="secondary-outline" size="sm" @click="minifyJson">
<Icon name="ic:outline-compress" size="16" class="mr-1" />
Minify
</rs-button>
<rs-button variant="secondary-outline" size="sm" @click="copyToClipboard">
<Icon name="ic:outline-content-copy" size="16" class="mr-1" />
Copy
</rs-button>
<rs-button variant="secondary-outline" size="sm" @click="clearContent">
<Icon name="ic:outline-clear" size="16" class="mr-1" />
Clear
</rs-button>
</div>
</div>
<!-- Enhanced Textarea with better styling -->
<div class="relative">
<FormKit
type="textarea"
v-model="body.raw"
:placeholder="getPlaceholderText()"
rows="16"
outer-class="!mb-0"
inner-class="!mb-0 font-mono text-sm resize-none leading-relaxed"
@input="validateJson"
/>
<!-- JSON Validation Status -->
<div v-if="rawContentType === 'application/json' && body.raw" class="absolute top-3 right-3 flex items-center gap-2">
<div v-if="jsonValidation.isValid" class="flex items-center gap-1 text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20 px-2 py-1 rounded text-xs">
<Icon name="ic:outline-check-circle" size="14" />
<span>Valid JSON</span>
</div>
<div v-else-if="jsonValidation.error" class="flex items-center gap-1 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 px-2 py-1 rounded text-xs">
<Icon name="ic:outline-error" size="14" />
<span>Invalid JSON</span>
</div>
</div>
</div>
<!-- Enhanced JSON Error Display -->
<div v-if="rawContentType === 'application/json' && jsonValidation.error" class="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div class="flex items-center gap-2 mb-2">
<Icon name="ic:outline-error" size="16" class="text-red-600 dark:text-red-400" />
<div class="font-medium text-red-900 dark:text-red-100">JSON Syntax Error</div>
</div>
<div class="text-sm text-red-700 dark:text-red-300 font-mono bg-red-100 dark:bg-red-900/40 p-2 rounded">
{{ jsonValidation.error }}
</div>
</div>
</div>
<!-- Enhanced Form-data Body -->
<div v-if="body.type === 'form-data'" class="space-y-4">
<div class="flex items-center justify-between mb-4">
<h4 class="font-medium text-gray-900 dark:text-white">Form Data Fields</h4>
<rs-button variant="primary" size="sm" @click="addFormDataRow">
<Icon name="ic:outline-add" size="16" class="mr-1" />
Add Field
</rs-button>
</div>
<div class="space-y-3">
<div v-for="(item, index) in body.formData" :key="index" class="group">
<div class="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
<!-- Active checkbox -->
<input
type="checkbox"
v-model="item.active"
class="w-4 h-4 mt-3 flex-shrink-0 text-primary-600 border-gray-300 rounded focus:ring-primary-500"
/>
<div class="flex-1 space-y-3 min-w-0">
<!-- Key and Type Row -->
<div class="flex items-center gap-3">
<FormKit
type="text"
v-model="item.key"
placeholder="Field name"
outer-class="!mb-0 flex-1"
inner-class="!mb-0"
/>
<FormKit
type="select"
v-model="item.type"
:options="[
{ value: 'text', label: 'Text' },
{ value: 'file', label: 'File' }
]"
outer-class="!mb-0 w-24"
inner-class="!mb-0"
/>
</div>
<!-- Value input (for text) -->
<FormKit
v-if="item.type === 'text'"
type="text"
v-model="item.value"
placeholder="Field value"
outer-class="!mb-0"
inner-class="!mb-0"
/>
<!-- Enhanced File input (for file) -->
<div v-if="item.type === 'file'" class="space-y-3">
<div
@drop="handleFileDrop($event, index)"
@dragover.prevent
@dragenter.prevent
class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-6 text-center hover:border-primary-400 dark:hover:border-primary-500 transition-colors cursor-pointer"
@click="$refs[`fileInput${index}`]?.[0]?.click()"
>
<input
:ref="`fileInput${index}`"
type="file"
@change="handleFileChange($event, index)"
class="hidden"
multiple
/>
<div v-if="!item.file">
<Icon name="ic:outline-cloud-upload" size="32" class="mx-auto mb-2 text-gray-400" />
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">
Drop files here or click to browse
</p>
<p class="text-xs text-gray-500">Maximum file size: 10MB</p>
</div>
<div v-else class="flex items-center justify-center gap-3">
<Icon name="ic:outline-description" size="24" class="text-blue-500" />
<div class="text-left">
<div class="font-medium text-gray-900 dark:text-white">{{ item.file.name }}</div>
<div class="text-sm text-gray-500">{{ formatFileSize(item.file.size) }}</div>
</div>
<rs-button
variant="danger-outline"
size="sm"
@click.stop="removeFile(index)"
class="ml-auto"
>
<Icon name="ic:outline-delete" size="14" />
</rs-button>
</div>
</div>
</div>
<!-- Description -->
<FormKit
type="text"
v-model="item.description"
placeholder="Field description (optional)"
outer-class="!mb-0"
inner-class="!mb-0 text-sm"
/>
</div>
<rs-button
variant="danger-outline"
size="sm"
@click="removeFormDataRow(index)"
class="flex-shrink-0 mt-3 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Icon name="ic:outline-delete" size="16" />
</rs-button>
</div>
</div>
</div>
</div>
<!-- Enhanced URL-encoded Body -->
<div v-if="body.type === 'x-www-form-urlencoded'" class="space-y-4">
<div class="flex items-center justify-between mb-4">
<h4 class="font-medium text-gray-900 dark:text-white">URL Encoded Parameters</h4>
<rs-button variant="primary" size="sm" @click="addRow">
<Icon name="ic:outline-add" size="16" class="mr-1" />
Add Parameter
</rs-button>
</div>
<div class="space-y-3">
<div v-for="(item, index) in body.urlEncoded" :key="index" class="group">
<div class="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
<input
type="checkbox"
v-model="item.active"
class="w-4 h-4 flex-shrink-0 text-primary-600 border-gray-300 rounded focus:ring-primary-500"
/>
<FormKit
type="text"
v-model="item.key"
placeholder="Parameter name"
outer-class="!mb-0 flex-1 min-w-0"
inner-class="!mb-0"
/>
<FormKit
type="text"
v-model="item.value"
placeholder="Parameter value"
outer-class="!mb-0 flex-1 min-w-0"
inner-class="!mb-0"
/>
<rs-button
variant="danger-outline"
size="sm"
@click="removeRow(index)"
class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Icon name="ic:outline-delete" size="16" />
</rs-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
const { addRow: addRowUtil, removeRow: removeRowUtil } = useApiRequest()
const props = defineProps({
body: {
type: Object,
required: true
}
})
// Content type for raw body
const rawContentType = ref('application/json')
// JSON validation state
const jsonValidation = ref({
isValid: false,
error: null
})
// Get body type description
const getBodyTypeDescription = () => {
const descriptions = {
'raw': 'Send raw text data like JSON, XML, or plain text',
'form-data': 'Send multipart form data, supports file uploads',
'x-www-form-urlencoded': 'Send form data as URL-encoded key-value pairs'
}
return descriptions[props.body.type] || ''
}
// Validate JSON content
const validateJson = () => {
if (rawContentType.value !== 'application/json' || !props.body.raw) {
jsonValidation.value = { isValid: false, error: null }
return
}
try {
JSON.parse(props.body.raw)
jsonValidation.value = { isValid: true, error: null }
} catch (error) {
jsonValidation.value = { isValid: false, error: error.message }
}
}
// JSON Tools
const beautifyJson = () => {
try {
const parsed = JSON.parse(props.body.raw)
props.body.raw = JSON.stringify(parsed, null, 2)
validateJson()
} catch (error) {
// Show notification for invalid JSON
console.error('Cannot beautify invalid JSON:', error)
}
}
const minifyJson = () => {
try {
const parsed = JSON.parse(props.body.raw)
props.body.raw = JSON.stringify(parsed)
validateJson()
} catch (error) {
console.error('Cannot minify invalid JSON:', error)
}
}
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(props.body.raw)
// Could add notification here
} catch (error) {
console.error('Failed to copy to clipboard:', error)
}
}
const clearContent = () => {
props.body.raw = ''
validateJson()
}
// Placeholder text based on content type
const getPlaceholderText = () => {
switch (rawContentType.value) {
case 'application/json':
return '{\n "key": "value",\n "number": 123,\n "boolean": true,\n "array": [1, 2, 3],\n "object": {\n "nested": "data"\n }\n}'
case 'application/xml':
return '<?xml version="1.0" encoding="UTF-8"?>\n<root>\n <item id="1">\n <name>Example</name>\n <value>123</value>\n </item>\n</root>'
case 'text/html':
return '<!DOCTYPE html>\n<html>\n<head>\n <title>Example</title>\n</head>\n<body>\n <h1>Hello World</h1>\n <p>This is an example HTML document.</p>\n</body>\n</html>'
case 'application/javascript':
return 'const data = {\n message: "Hello World",\n timestamp: new Date().toISOString()\n};\n\nconsole.log(data);'
case 'text/css':
return 'body {\n font-family: Arial, sans-serif;\n margin: 0;\n padding: 20px;\n}\n\n.container {\n max-width: 1200px;\n margin: 0 auto;\n}'
default:
return 'Enter your request body content here...'
}
}
// Enhanced form-data handling
const addFormDataRow = () => {
props.body.formData.push({
active: true,
key: '',
value: '',
description: '',
type: 'text',
file: null
})
}
const removeFormDataRow = (index) => {
if (props.body.formData.length > 1) {
props.body.formData.splice(index, 1)
}
}
const handleFileChange = (event, index) => {
const file = event.target.files[0]
if (file) {
if (file.size > 10 * 1024 * 1024) { // 10MB limit
alert('File size must be less than 10MB')
return
}
props.body.formData[index].file = file
props.body.formData[index].value = file.name
}
}
const handleFileDrop = (event, index) => {
event.preventDefault()
const files = Array.from(event.dataTransfer.files)
if (files.length > 0) {
const file = files[0]
if (file.size > 10 * 1024 * 1024) {
alert('File size must be less than 10MB')
return
}
props.body.formData[index].file = file
props.body.formData[index].value = file.name
}
}
const removeFile = (index) => {
props.body.formData[index].file = null
props.body.formData[index].value = ''
}
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// URL-encoded handling
const addRow = () => {
addRowUtil(toRef(props.body, 'urlEncoded'))
}
const removeRow = (index) => {
removeRowUtil(toRef(props.body, 'urlEncoded'), index)
}
// Watch for content type changes to validate JSON
watch(rawContentType, validateJson)
watch(() => props.body.raw, validateJson, { immediate: true })
</script>

View File

@ -0,0 +1,51 @@
<template>
<div class="space-y-3 min-w-0">
<div v-for="(header, index) in headers" :key="index" class="flex items-center gap-3 min-w-0">
<input type="checkbox" v-model="header.active" class="w-4 h-4 flex-shrink-0" />
<FormKit
type="text"
v-model="header.key"
placeholder="Key"
outer-class="!mb-0 flex-1 min-w-0"
inner-class="!mb-0"
/>
<FormKit
type="text"
v-model="header.value"
placeholder="Value"
outer-class="!mb-0 flex-1 min-w-0"
inner-class="!mb-0"
/>
<rs-button
variant="danger-outline"
size="sm"
@click="removeRow(index)"
class="flex-shrink-0"
>
<Icon name="ic:outline-delete" size="16" />
</rs-button>
</div>
<rs-button variant="secondary-outline" size="sm" @click="addRow">
Add Header
</rs-button>
</div>
</template>
<script setup>
const { addRow: addRowUtil, removeRow: removeRowUtil } = useApiRequest()
const props = defineProps({
headers: {
type: Array,
required: true
}
})
const addRow = () => {
addRowUtil(toRef(props, 'headers'))
}
const removeRow = (index) => {
removeRowUtil(toRef(props, 'headers'), index)
}
</script>

View File

@ -0,0 +1,51 @@
<template>
<div class="space-y-3 min-w-0">
<div v-for="(param, index) in params" :key="index" class="flex items-center gap-3 min-w-0">
<input type="checkbox" v-model="param.active" class="w-4 h-4 flex-shrink-0" />
<FormKit
type="text"
v-model="param.key"
placeholder="Key"
outer-class="!mb-0 flex-1 min-w-0"
inner-class="!mb-0"
/>
<FormKit
type="text"
v-model="param.value"
placeholder="Value"
outer-class="!mb-0 flex-1 min-w-0"
inner-class="!mb-0"
/>
<rs-button
variant="danger-outline"
size="sm"
@click="removeRow(index)"
class="flex-shrink-0"
>
<Icon name="ic:outline-delete" size="16" />
</rs-button>
</div>
<rs-button variant="secondary-outline" size="sm" @click="addRow">
Add Parameter
</rs-button>
</div>
</template>
<script setup>
const { addRow: addRowUtil, removeRow: removeRowUtil } = useApiRequest()
const props = defineProps({
params: {
type: Array,
required: true
}
})
const addRow = () => {
addRowUtil(toRef(props, 'params'))
}
const removeRow = (index) => {
removeRowUtil(toRef(props, 'params'), index)
}
</script>

View File

@ -0,0 +1,136 @@
// Global reactive state
const globalState = {
// Core reactive state
activeTab: ref('params'),
httpMethod: ref('GET'),
requestUrl: ref(''),
isLoading: ref(false),
requestName: ref(''),
// UI State
showCollectionSidebar: ref(true),
showSaveRequestModal: ref(false),
selectedEnvironment: ref(''),
// Notification system
notifications: ref([]),
// Request data
requestParams: ref([
{ active: true, key: '', value: '', description: '' }
]),
requestHeaders: ref([
{ active: true, key: '', value: '', description: '' }
]),
requestAuth: ref({
type: 'none',
bearer: '',
basic: { username: '', password: '' },
apiKey: { key: '', value: '', addTo: 'header' },
oauth2: {
grantType: 'authorization_code',
authUrl: '',
accessTokenUrl: '',
clientId: '',
clientSecret: '',
scope: '',
redirectUri: '',
state: '',
accessToken: '',
refreshToken: '',
tokenType: 'Bearer',
expiresIn: null,
expiresAt: null
}
}),
requestBody: ref({
type: 'raw',
raw: '',
formData: [{ active: true, key: '', value: '', description: '', type: 'text', file: null }],
urlEncoded: [{ active: true, key: '', value: '', description: '' }]
}),
// Response data
response: ref({
status: null,
statusText: '',
headers: {},
data: null,
time: 0,
size: 0
}),
responseActiveTab: ref('body'),
// Collections Management (simplified for development)
collections: ref([]),
// Environment Management (simplified for development)
environments: ref([]),
// History Management (start empty)
requestHistory: ref([])
}
const httpMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']
// Global methods
const showNotification = (message, type = 'info', duration = 3000) => {
const id = Date.now()
const notification = { id, message, type }
globalState.notifications.value.push(notification)
setTimeout(() => {
const index = globalState.notifications.value.findIndex(n => n.id === id)
if (index > -1) {
globalState.notifications.value.splice(index, 1)
}
}, duration)
}
const dismissNotification = (id) => {
const index = globalState.notifications.value.findIndex(n => n.id === id)
if (index > -1) {
globalState.notifications.value.splice(index, 1)
}
}
export const useApiPlatform = () => {
return {
// Core state
activeTab: globalState.activeTab,
httpMethod: globalState.httpMethod,
requestUrl: globalState.requestUrl,
isLoading: globalState.isLoading,
requestName: globalState.requestName,
// UI State
showCollectionSidebar: globalState.showCollectionSidebar,
showSaveRequestModal: globalState.showSaveRequestModal,
selectedEnvironment: globalState.selectedEnvironment,
// Request data
requestParams: globalState.requestParams,
requestHeaders: globalState.requestHeaders,
requestAuth: globalState.requestAuth,
requestBody: globalState.requestBody,
// Response data
response: globalState.response,
responseActiveTab: globalState.responseActiveTab,
// History and collections
requestHistory: globalState.requestHistory,
collections: globalState.collections,
environments: globalState.environments,
// Notifications
notifications: globalState.notifications,
// Static data
httpMethods,
// Methods
showNotification,
dismissNotification
}
}

View File

@ -0,0 +1,136 @@
export const useApiRequest = () => {
// MAIN SEND REQUEST FUNCTION - REAL BACKEND INTEGRATION
const sendRequest = async (requestData, callbacks = {}) => {
const { onStart, onSuccess, onError, onComplete } = callbacks
if (!requestData.url) {
onError?.('Please enter a URL')
return
}
onStart?.()
try {
// Prepare request data for backend
const payload = {
url: requestData.url,
method: requestData.method,
headers: requestData.headers,
params: requestData.params,
auth: requestData.auth,
requestBody: requestData.body,
timeout: 30000
}
// Make request to our backend proxy
const result = await $fetch('/api/api-platform/send-request', {
method: 'POST',
body: payload
})
if (result.statusCode === 200) {
onSuccess?.(result.data)
return result.data
} else {
onError?.(result.message, result.data || null)
return result.data
}
} catch (error) {
const errorResponse = {
status: 500,
statusText: 'Internal Server Error',
headers: {},
data: { error: error.message || 'Something went wrong' },
time: 0,
size: 0
}
onError?.('Failed to send request', errorResponse)
return errorResponse
} finally {
onComplete?.()
}
}
// Utility methods
const addRow = (arrayRef) => {
arrayRef.value.push({ active: true, key: '', value: '', description: '' })
}
const removeRow = (arrayRef, index) => {
if (arrayRef.value.length > 1) {
arrayRef.value.splice(index, 1)
}
}
const formatJson = (obj) => {
return JSON.stringify(obj, null, 2)
}
const getStatusVariant = (status) => {
if (status >= 200 && status < 300) return 'success'
if (status >= 300 && status < 400) return 'warning'
if (status >= 400) return 'danger'
return 'secondary'
}
const getMethodVariant = (method) => {
const variants = {
'GET': 'info',
'POST': 'success',
'PUT': 'warning',
'PATCH': 'secondary',
'DELETE': 'danger'
}
return variants[method] || 'primary'
}
// Enhanced JSON utilities with notification integration
const beautifyJson = (content, { showNotification } = {}) => {
try {
const parsed = JSON.parse(content)
const beautified = JSON.stringify(parsed, null, 2)
showNotification?.('JSON beautified successfully', 'success', 2000)
return beautified
} catch (error) {
showNotification?.('Invalid JSON format', 'error')
throw error
}
}
const minifyJson = (content, { showNotification } = {}) => {
try {
const parsed = JSON.parse(content)
const minified = JSON.stringify(parsed)
showNotification?.('JSON minified successfully', 'success', 2000)
return minified
} catch (error) {
showNotification?.('Invalid JSON format', 'error')
throw error
}
}
const copyToClipboard = async (content, message = 'Copied to clipboard', { showNotification } = {}) => {
try {
await navigator.clipboard.writeText(content)
showNotification?.(message, 'success', 2000)
return true
} catch (error) {
showNotification?.('Failed to copy to clipboard', 'error')
return false
}
}
return {
sendRequest,
addRow,
removeRow,
formatJson,
getStatusVariant,
getMethodVariant,
beautifyJson,
minifyJson,
copyToClipboard
}
}

View File

@ -0,0 +1,159 @@
export const useVariableSubstitution = () => {
const { environments, selectedEnvironment } = useApiPlatform()
// Get the currently selected environment
const currentEnvironment = computed(() => {
if (!selectedEnvironment.value) return null
return environments.value.find(env => env.id === selectedEnvironment.value)
})
// Create a variables map for quick lookup
const variablesMap = computed(() => {
if (!currentEnvironment.value) return {}
const map = {}
currentEnvironment.value.variables.forEach(variable => {
if (variable.key && variable.value) {
map[variable.key] = variable.value
}
})
return map
})
// Replace variables in a string ({{variableName}})
const substituteVariables = (text) => {
if (!text || typeof text !== 'string') return text
if (!currentEnvironment.value) return text
return text.replace(/\{\{([^}]+)\}\}/g, (match, variableName) => {
const trimmedName = variableName.trim()
return variablesMap.value[trimmedName] || match
})
}
// Process an entire request object and substitute all variables
const processRequest = (requestData) => {
if (!currentEnvironment.value) return requestData
const processed = JSON.parse(JSON.stringify(requestData))
// Substitute URL
processed.url = substituteVariables(processed.url)
// Substitute headers
if (processed.headers && Array.isArray(processed.headers)) {
processed.headers.forEach(header => {
header.key = substituteVariables(header.key)
header.value = substituteVariables(header.value)
})
}
// Substitute params
if (processed.params && Array.isArray(processed.params)) {
processed.params.forEach(param => {
param.key = substituteVariables(param.key)
param.value = substituteVariables(param.value)
})
}
// Substitute auth values
if (processed.auth) {
if (processed.auth.bearer) {
processed.auth.bearer = substituteVariables(processed.auth.bearer)
}
if (processed.auth.basic) {
processed.auth.basic.username = substituteVariables(processed.auth.basic.username)
processed.auth.basic.password = substituteVariables(processed.auth.basic.password)
}
if (processed.auth.apiKey) {
processed.auth.apiKey.key = substituteVariables(processed.auth.apiKey.key)
processed.auth.apiKey.value = substituteVariables(processed.auth.apiKey.value)
}
if (processed.auth.oauth2) {
processed.auth.oauth2.authUrl = substituteVariables(processed.auth.oauth2.authUrl)
processed.auth.oauth2.accessTokenUrl = substituteVariables(processed.auth.oauth2.accessTokenUrl)
processed.auth.oauth2.clientId = substituteVariables(processed.auth.oauth2.clientId)
processed.auth.oauth2.clientSecret = substituteVariables(processed.auth.oauth2.clientSecret)
processed.auth.oauth2.redirectUri = substituteVariables(processed.auth.oauth2.redirectUri)
}
}
// Substitute body content
if (processed.requestBody) {
if (processed.requestBody.raw) {
processed.requestBody.raw = substituteVariables(processed.requestBody.raw)
}
if (processed.requestBody.formData && Array.isArray(processed.requestBody.formData)) {
processed.requestBody.formData.forEach(item => {
item.key = substituteVariables(item.key)
item.value = substituteVariables(item.value)
})
}
if (processed.requestBody.urlEncoded && Array.isArray(processed.requestBody.urlEncoded)) {
processed.requestBody.urlEncoded.forEach(item => {
item.key = substituteVariables(item.key)
item.value = substituteVariables(item.value)
})
}
}
return processed
}
// Find all variables used in a text string
const findVariables = (text) => {
if (!text || typeof text !== 'string') return []
const matches = text.match(/\{\{([^}]+)\}\}/g)
if (!matches) return []
return matches.map(match => {
const variableName = match.replace(/[{}]/g, '').trim()
return {
name: variableName,
found: !!variablesMap.value[variableName],
value: variablesMap.value[variableName] || null
}
})
}
// Get all variables used in the current request
const getRequestVariables = (requestData) => {
const variables = new Set()
// Check URL
findVariables(requestData.url).forEach(v => variables.add(JSON.stringify(v)))
// Check headers
if (requestData.headers) {
requestData.headers.forEach(header => {
findVariables(header.key).forEach(v => variables.add(JSON.stringify(v)))
findVariables(header.value).forEach(v => variables.add(JSON.stringify(v)))
})
}
// Check params
if (requestData.params) {
requestData.params.forEach(param => {
findVariables(param.key).forEach(v => variables.add(JSON.stringify(v)))
findVariables(param.value).forEach(v => variables.add(JSON.stringify(v)))
})
}
// Check body
if (requestData.body?.raw) {
findVariables(requestData.body.raw).forEach(v => variables.add(JSON.stringify(v)))
}
return Array.from(variables).map(v => JSON.parse(v))
}
return {
currentEnvironment,
variablesMap,
substituteVariables,
processRequest,
findVariables,
getRequestVariables
}
}

View File

@ -0,0 +1,79 @@
Current Implementation Analysis (first generation)
✅ Features Currently Implemented:
✅ HTTP Methods - Full support (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS)
✅ URL Input - With auto-protocol fixing (google.com → https://google.com)
✅ Query Parameters - Key-value UI with checkboxes
✅ Custom Headers - Key-value UI with checkboxes
✅ Basic Authentication - Bearer Token, Basic Auth, API Key (header/query)
✅ Raw Body Input - Basic textarea with JSON detection
✅ URL-encoded Body - Key-value form
✅ Response Viewer - Body and Headers tabs
✅ Response Timing - Shows ms and bytes
✅ Status Badges - Color-coded status indicators
✅ Loading States - Button loading, proper UX
✅ Error Handling - Network errors, timeouts, invalid URLs
✅ Notifications - Success/error toasts
✅ Request History - In-memory tracking
✅ Responsive Layout - Fixed viewport constraints
⚠️ Partially Implemented:
⚠️ Collections - Data structure exists but no UI/persistence
⚠️ Environments - Data structure exists but no UI/persistence
⚠️ Form-data Body - Basic structure but no file uploads
⚠️ JSON Editor - Basic textarea, no beautify/minify
❌ Missing Critical Features:
❌ OAuth2 Flow - Complete authorization flow
❌ File Uploads - Form-data with file input
❌ Code Generation - curl, axios, fetch snippets
❌ Response Export - JSON, CSV download
❌ Test Scripts - pm.test-like functionality
❌ Import/Export - Postman collections, OpenAPI
❌ Environment Variables - {{variable}} substitution
❌ Response Timeline - Performance waterfall
❌ Cookies Tab - Cookie management
❌ Test Results Tab - Script execution results
❌ JSON Beautify/Copy - Formatting tools
❌ GraphQL Support - Query editor
❌ XML/HTML Support - Syntax highlighting
❌ Collections Sidebar - Tree view, organization
❌ Environment Selector - Dropdown switcher
🚀 Phase-by-Phase Implementation Roadmap
Phase 2: Enhanced Body Types & JSON Tools
Goal: Complete the request body functionality
Missing Components:
Form-data with File Upload
JSON Beautify/Minify/Copy buttons
Raw body type selector (JSON, XML, Text, HTML)
Syntax highlighting for JSON/XML
Implementation Plan:
Add file input to form-data
Add JSON formatting toolbar
Add body type sub-selector
Integrate Monaco or Ace editor
Phase 3: Collections & Environment Management
Goal: Persistent data management
Missing Components:
Collections sidebar with tree view
Save/Load requests to collections
Environment selector dropdown
Variable substitution ({{baseUrl}})
Persistence layer (localStorage/backend)
Phase 4: Advanced Features
Goal: Power user functionality
Missing Components:
OAuth2 complete flow
Code generation modal
Import/Export functionality
Test scripts tab with execution
Response export options
Phase 5: Enhanced Response Viewer
Goal: Better analysis tools
Missing Components:
Timeline tab with waterfall
Cookies tab with management
Test Results tab
Response search/filter

View File

@ -17,6 +17,12 @@ export default [
"header": "Pentadbiran", "header": "Pentadbiran",
"description": "Urus aplikasi anda", "description": "Urus aplikasi anda",
"child": [ "child": [
{
"title": "API Platform",
"path": "/api-platform",
"icon": "",
"child": []
},
{ {
"title": "Konfigurasi", "title": "Konfigurasi",
"icon": "ic:outline-settings", "icon": "ic:outline-settings",

View File

@ -43,7 +43,7 @@
"@kiwicom/eslint-config": "^12.7.3", "@kiwicom/eslint-config": "^12.7.3",
"@pinia/nuxt": "^0.4.11", "@pinia/nuxt": "^0.4.11",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@prisma/client": "^5.1.1", "@prisma/client": "6.8.2",
"@shimyshack/uid": "^0.1.7", "@shimyshack/uid": "^0.1.7",
"@sweetalert2/theme-dark": "^5.0.14", "@sweetalert2/theme-dark": "^5.0.14",
"@vueup/vue-quill": "^1.0.0", "@vueup/vue-quill": "^1.0.0",
@ -61,7 +61,7 @@
"maska": "^1.5.0", "maska": "^1.5.0",
"pinia": "^2.1.6", "pinia": "^2.1.6",
"prettier": "^2.8.1", "prettier": "^2.8.1",
"prisma": "^5.1.1", "prisma": "6.8.2",
"sass": "^1.62.0", "sass": "^1.62.0",
"swiper": "^8.4.4", "swiper": "^8.4.4",
"thememirror": "^2.0.1", "thememirror": "^2.0.1",

View File

@ -0,0 +1,188 @@
<script setup>
import RequestBuilder from '~/components/api-platform/RequestBuilder.vue'
import ResponseViewer from '~/components/api-platform/ResponseViewer.vue'
import CollectionsSidebar from '~/components/api-platform/CollectionsSidebar.vue'
import EnvironmentSelector from '~/components/api-platform/EnvironmentSelector.vue'
import SaveRequestModal from '~/components/api-platform/SaveRequestModal.vue'
import CodeGenerationModal from '~/components/api-platform/CodeGenerationModal.vue'
import ImportExportModal from '~/components/api-platform/ImportExportModal.vue'
definePageMeta({
title: "API Platform",
middleware: ["auth"],
requiresAuth: true,
});
// Use composables for state management
const {
notifications,
dismissNotification,
showCollectionSidebar,
showSaveRequestModal
} = useApiPlatform()
// Add modal states
const showCodeGenerationModal = ref(false)
const showImportExportModal = ref(false)
</script>
<template>
<div class="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-900">
<!-- Enhanced Top Bar -->
<div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm flex-shrink-0">
<div class="px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<button
@click="showCollectionSidebar = !showCollectionSidebar"
class="flex items-center justify-center p-2.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors group"
:title="showCollectionSidebar ? 'Hide Collections' : 'Show Collections'"
>
<Icon
name="ph:sidebar"
size="20"
class="text-gray-600 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition-colors"
/>
</button>
<h2 class="text-xl font-normal text-gray-300 dark:text-white">|</h2>
<div class="flex items-center gap-3">
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">API Platform</h1>
</div>
</div>
<!-- Enhanced Action Bar -->
<div class="flex items-center gap-3">
<EnvironmentSelector />
<div class="h-6 w-px bg-gray-300 dark:bg-gray-600"></div>
<!-- Import/Export Button -->
<rs-button
variant="secondary-outline"
size="sm"
@click="showImportExportModal = true"
class="flex items-center gap-2"
>
<Icon name="ic:outline-import-export" size="16" />
Import/Export
</rs-button>
<!-- Code Generation Button -->
<rs-button
variant="secondary-outline"
size="sm"
@click="showCodeGenerationModal = true"
class="flex items-center gap-2"
>
<Icon name="ic:outline-code" size="16" />
Code
</rs-button>
<!-- Save Button -->
<rs-button
variant="secondary-outline"
size="sm"
@click="showSaveRequestModal = true"
class="flex items-center gap-2"
>
<Icon name="ic:outline-save" size="16" />
Save
</rs-button>
</div>
</div>
</div>
</div>
<div class="flex-1 flex">
<!-- Collections Sidebar with enhanced styling -->
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="transform -translate-x-full"
enter-to-class="transform translate-x-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="transform translate-x-0"
leave-to-class="transform -translate-x-full"
>
<CollectionsSidebar
v-if="showCollectionSidebar"
@close="showCollectionSidebar = false"
class="relative z-10 flex-shrink-0"
/>
</Transition>
<!-- Main Content Area -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Request Builder -->
<div class="flex-shrink-0">
<RequestBuilder />
</div>
<!-- Response Section -->
<div class="flex-1">
<ResponseViewer />
</div>
</div>
</div>
<!-- Save Request Modal -->
<SaveRequestModal
v-if="showSaveRequestModal"
@close="showSaveRequestModal = false"
/>
<!-- Code Generation Modal -->
<CodeGenerationModal
v-if="showCodeGenerationModal"
@close="showCodeGenerationModal = false"
/>
<!-- Import/Export Modal -->
<ImportExportModal
v-if="showImportExportModal"
@close="showImportExportModal = false"
/>
<!-- Enhanced Notification System -->
<div class="fixed top-4 right-4 z-50 space-y-3 max-w-sm">
<TransitionGroup
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-for="notification in notifications"
:key="notification.id"
class="flex items-start gap-3 p-4 rounded-lg shadow-lg border backdrop-blur-sm transition-all duration-300 ease-in-out"
:class="{
'bg-green-50/90 border-green-200 text-green-800 dark:bg-green-900/90 dark:border-green-700 dark:text-green-200': notification.type === 'success',
'bg-red-50/90 border-red-200 text-red-800 dark:bg-red-900/90 dark:border-red-700 dark:text-red-200': notification.type === 'error',
'bg-blue-50/90 border-blue-200 text-blue-800 dark:bg-blue-900/90 dark:border-blue-700 dark:text-blue-200': notification.type === 'info',
'bg-yellow-50/90 border-yellow-200 text-yellow-800 dark:bg-yellow-900/90 dark:border-yellow-700 dark:text-yellow-200': notification.type === 'warning'
}"
>
<Icon
:name="
notification.type === 'success' ? 'ic:outline-check-circle' :
notification.type === 'error' ? 'ic:outline-error' :
notification.type === 'warning' ? 'ic:outline-warning' :
'ic:outline-info'
"
size="20"
class="flex-shrink-0 mt-0.5"
/>
<span class="text-sm font-medium flex-1 leading-relaxed">{{ notification.message }}</span>
<rs-button
variant="text"
size="sm"
@click="dismissNotification(notification.id)"
class="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity"
>
<Icon name="ic:outline-close" size="16" />
</rs-button>
</div>
</TransitionGroup>
</div>
</div>
</template>

88
pages/oauth/callback.vue Normal file
View File

@ -0,0 +1,88 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div class="max-w-md w-full space-y-8">
<div class="text-center">
<Icon name="eos-icons:loading" size="48" class="mx-auto text-primary animate-spin" />
<h2 class="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
Completing Authorization
</h2>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Processing OAuth2 callback...
</p>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
layout: false,
title: "OAuth2 Callback"
})
onMounted(() => {
try {
const urlParams = new URLSearchParams(window.location.search)
const hashParams = new URLSearchParams(window.location.hash.substring(1))
// Check for authorization code (Authorization Code flow)
const code = urlParams.get('code')
const state = urlParams.get('state')
const error = urlParams.get('error')
// Check for access token (Implicit flow)
const accessToken = hashParams.get('access_token')
const tokenType = hashParams.get('token_type')
const expiresIn = hashParams.get('expires_in')
if (error) {
// Send error back to parent window
window.opener?.postMessage({
type: 'oauth2_callback',
error: error,
errorDescription: urlParams.get('error_description')
}, window.location.origin)
} else if (code) {
// Send authorization code back to parent window
window.opener?.postMessage({
type: 'oauth2_callback',
code: code,
state: state
}, window.location.origin)
} else if (accessToken) {
// Send access token back to parent window (Implicit flow)
window.opener?.postMessage({
type: 'oauth2_callback',
accessToken: accessToken,
tokenType: tokenType,
expiresIn: expiresIn ? parseInt(expiresIn) : null,
state: hashParams.get('state')
}, window.location.origin)
} else {
// No valid response found
window.opener?.postMessage({
type: 'oauth2_callback',
error: 'invalid_response',
errorDescription: 'No authorization code or access token found in callback'
}, window.location.origin)
}
// Close the popup window
setTimeout(() => {
window.close()
}, 1000)
} catch (error) {
console.error('OAuth callback error:', error)
window.opener?.postMessage({
type: 'oauth2_callback',
error: 'callback_error',
errorDescription: error.message
}, window.location.origin)
setTimeout(() => {
window.close()
}, 1000)
}
})
</script>

View File

@ -99,3 +99,235 @@ model site_settings {
settingModifiedDate DateTime? @db.DateTime(0) settingModifiedDate DateTime? @db.DateTime(0)
siteLoginLogo String? @db.VarChar(500) siteLoginLogo String? @db.VarChar(500)
} }
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model notification_analytics {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
notification_id String @db.VarChar(36)
channel_type String @db.VarChar(20)
metric_type String @db.VarChar(30)
metric_value Int? @default(0)
recorded_at DateTime? @default(now()) @db.Timestamp(0)
metadata Json? @default(dbgenerated("(_utf8mb4\\'{}\\')"))
notifications notifications @relation(fields: [notification_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "notification_analytics_ibfk_1")
@@index([notification_id], map: "idx_notification_analytics_notification_id")
}
model notification_categories {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
name String @db.VarChar(100)
value String @unique(map: "value") @db.VarChar(50)
description String? @db.Text
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
notifications notifications[]
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model notification_channels {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
notification_id String @db.VarChar(36)
channel_type String @db.VarChar(20)
is_enabled Boolean? @default(true)
created_at DateTime? @default(now()) @db.Timestamp(0)
notifications notifications @relation(fields: [notification_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "notification_channels_ibfk_1")
@@index([notification_id], map: "notification_id")
}
model notification_delivery {
id Int @id @default(autoincrement())
notification_id Int
channel_type String
recipient String
is_success Boolean @default(false)
error_message String? @db.Text
attempts Int @default(0)
sent_at DateTime?
delivered_at DateTime?
created_at DateTime @default(now())
updated_at DateTime
}
model notification_delivery_config {
id Int @id @default(autoincrement())
channel_type String @unique
is_enabled Boolean @default(false)
provider String
provider_config Json @default(dbgenerated("(_utf8mb4\\'{}\\')"))
status String @default("Not Configured")
success_rate Float @default(0) @db.Float
created_at DateTime @default(now())
updated_at DateTime
created_by Int
updated_by Int
}
model notification_delivery_settings {
id Int @id @default(1)
auto_retry Boolean @default(true)
enable_fallback Boolean @default(true)
max_retries Int @default(3)
retry_delay Int @default(30)
priority String @default("normal")
enable_reports Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime
created_by Int
updated_by Int
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model notification_queue {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
notification_id String @db.VarChar(36)
recipient_id String @db.VarChar(36)
scheduled_for DateTime @db.Timestamp(0)
priority Int? @default(5)
attempts Int? @default(0)
max_attempts Int? @default(3)
status String? @default("queued") @db.VarChar(20)
last_attempt_at DateTime? @db.Timestamp(0)
error_message String? @db.Text
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
notifications notifications @relation(fields: [notification_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "notification_queue_ibfk_1")
notification_recipients notification_recipients @relation(fields: [recipient_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "notification_queue_ibfk_2")
@@index([scheduled_for], map: "idx_notification_queue_scheduled_for")
@@index([status], map: "idx_notification_queue_status")
@@index([notification_id], map: "notification_id")
@@index([recipient_id], map: "recipient_id")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model notification_recipients {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
notification_id String @db.VarChar(36)
user_id String @db.VarChar(36)
email String? @db.VarChar(255)
channel_type String @db.VarChar(20)
status String? @default("pending") @db.VarChar(20)
sent_at DateTime? @db.Timestamp(0)
delivered_at DateTime? @db.Timestamp(0)
opened_at DateTime? @db.Timestamp(0)
clicked_at DateTime? @db.Timestamp(0)
error_message String? @db.Text
ab_test_variant String? @db.VarChar(1)
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
notification_queue notification_queue[]
notifications notifications @relation(fields: [notification_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "notification_recipients_ibfk_1")
@@index([status], map: "idx_notification_recipients_status")
@@index([user_id], map: "idx_notification_recipients_user_id")
@@index([notification_id], map: "notification_id")
}
model notification_templates {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
name String @db.VarChar(100)
value String @unique(map: "value") @db.VarChar(50)
subject String? @db.VarChar(255)
email_content String? @db.Text
push_title String? @db.VarChar(100)
push_body String? @db.VarChar(300)
variables Json? @default(dbgenerated("(_utf8mb4\\'[]\\')"))
is_active Boolean? @default(true)
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
notifications notifications[]
}
model notification_user_segments {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
notification_id String @db.VarChar(36)
segment_id String @db.VarChar(36)
created_at DateTime? @default(now()) @db.Timestamp(0)
notifications notifications @relation(fields: [notification_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "notification_user_segments_ibfk_1")
user_segments user_segments @relation(fields: [segment_id], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "notification_user_segments_ibfk_2")
@@index([notification_id], map: "notification_id")
@@index([segment_id], map: "segment_id")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model notifications {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
title String @db.VarChar(255)
type String @db.VarChar(20)
priority String @db.VarChar(20)
category_id String? @db.VarChar(36)
status String? @default("draft") @db.VarChar(20)
delivery_type String @db.VarChar(20)
scheduled_at DateTime? @db.Timestamp(0)
timezone String? @default("UTC") @db.VarChar(50)
expires_at DateTime? @db.Timestamp(0)
enable_ab_testing Boolean? @default(false)
ab_test_split Int? @default(50)
ab_test_name String? @db.VarChar(100)
enable_tracking Boolean? @default(true)
audience_type String @db.VarChar(20)
specific_users String? @db.Text
user_status String? @db.VarChar(20)
registration_period String? @db.VarChar(50)
exclude_unsubscribed Boolean? @default(true)
respect_do_not_disturb Boolean? @default(true)
content_type String @db.VarChar(20)
template_id String? @db.VarChar(36)
email_subject String? @db.VarChar(255)
email_content String? @db.Text
call_to_action_text String? @db.VarChar(100)
call_to_action_url String? @db.Text
push_title String? @db.VarChar(100)
push_body String? @db.VarChar(300)
push_image_url String? @db.Text
estimated_reach Int? @default(0)
actual_sent Int? @default(0)
created_by String @db.VarChar(36)
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
sent_at DateTime? @db.Timestamp(0)
notification_analytics notification_analytics[]
notification_channels notification_channels[]
notification_queue notification_queue[]
notification_recipients notification_recipients[]
notification_user_segments notification_user_segments[]
notification_categories notification_categories? @relation(fields: [category_id], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "notifications_ibfk_1")
notification_templates notification_templates? @relation(fields: [template_id], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "notifications_ibfk_2")
@@index([category_id], map: "category_id")
@@index([created_by], map: "idx_notifications_created_by")
@@index([scheduled_at], map: "idx_notifications_scheduled_at")
@@index([status], map: "idx_notifications_status")
@@index([template_id], map: "template_id")
}
model user_notification_preferences {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
user_id String @db.VarChar(36)
channel_type String @db.VarChar(20)
category_value String? @db.VarChar(50)
is_enabled Boolean? @default(true)
do_not_disturb_start DateTime? @db.Time(0)
do_not_disturb_end DateTime? @db.Time(0)
timezone String? @default("UTC") @db.VarChar(50)
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
@@unique([user_id, channel_type, category_value], map: "user_id")
@@index([user_id], map: "idx_user_notification_preferences_user_id")
}
model user_segments {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
name String @db.VarChar(100)
value String @unique(map: "value") @db.VarChar(50)
description String? @db.Text
criteria Json
is_active Boolean? @default(true)
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
notification_user_segments notification_user_segments[]
}

View File

@ -0,0 +1,267 @@
export default defineEventHandler(async (event) => {
try {
const requestData = await readBody(event);
if (!requestData.url) {
return {
statusCode: 400,
message: "URL is required",
};
}
let {
url,
method = 'GET',
headers = {},
params = [],
auth = {},
requestBody = {},
timeout = 30000
} = requestData;
// Fix URL if it doesn't have protocol
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
const startTime = Date.now();
// Build final URL with query parameters
let urlObj;
try {
urlObj = new URL(url);
} catch (error) {
return {
statusCode: 400,
message: "Invalid URL format",
data: {
status: 400,
statusText: 'Bad Request',
headers: {},
data: { error: 'Invalid URL format. Please check the URL and try again.' },
time: 0,
size: 0
}
};
}
// Add active query parameters
params.forEach(param => {
if (param.active && param.key && param.value) {
urlObj.searchParams.set(param.key, param.value);
}
});
// Add API key to query if specified
if (auth.type === 'apiKey' && auth.apiKey?.key && auth.apiKey?.value && auth.apiKey?.addTo === 'query') {
urlObj.searchParams.set(auth.apiKey.key, auth.apiKey.value);
}
const finalUrl = urlObj.toString();
// Build headers
const requestHeaders = {};
// Add custom headers
headers.forEach(header => {
if (header.active && header.key && header.value) {
requestHeaders[header.key] = header.value;
}
});
// Add authentication headers
if (auth.type === 'bearer' && auth.bearer) {
requestHeaders['Authorization'] = `Bearer ${auth.bearer}`;
} else if (auth.type === 'basic' && auth.basic?.username && auth.basic?.password) {
const credentials = Buffer.from(`${auth.basic.username}:${auth.basic.password}`).toString('base64');
requestHeaders['Authorization'] = `Basic ${credentials}`;
} else if (auth.type === 'apiKey' && auth.apiKey?.key && auth.apiKey?.value && auth.apiKey?.addTo === 'header') {
requestHeaders[auth.apiKey.key] = auth.apiKey.value;
} else if (auth.type === 'oauth2' && auth.oauth2?.accessToken) {
requestHeaders['Authorization'] = `${auth.oauth2.tokenType || 'Bearer'} ${auth.oauth2.accessToken}`;
}
// Build request body
let requestBodyData = null;
let contentType = null;
if (method !== 'GET' && method !== 'HEAD' && requestBody.type && requestBody.type !== 'none') {
if (requestBody.type === 'raw' && requestBody.raw) {
requestBodyData = requestBody.raw;
// Try to parse as JSON to set appropriate content type
try {
JSON.parse(requestBody.raw);
contentType = 'application/json';
} catch (e) {
contentType = 'text/plain';
}
} else if (requestBody.type === 'x-www-form-urlencoded' && requestBody.urlEncoded) {
const urlEncodedData = new URLSearchParams();
requestBody.urlEncoded.forEach(item => {
if (item.active && item.key && item.value) {
urlEncodedData.append(item.key, item.value);
}
});
requestBodyData = urlEncodedData.toString();
contentType = 'application/x-www-form-urlencoded';
} else if (requestBody.type === 'form-data' && requestBody.formData) {
// For form-data, we'll handle files as base64 for now
// In a real implementation, you'd want multipart/form-data support
const formData = new URLSearchParams();
let hasFiles = false;
requestBody.formData.forEach(item => {
if (item.active && item.key) {
if (item.type === 'text' && item.value) {
formData.append(item.key, item.value);
} else if (item.type === 'file' && item.file) {
// For now, we'll send file name as value
// Real file upload would need special handling
formData.append(item.key, `[FILE: ${item.file.name}]`);
hasFiles = true;
}
}
});
if (hasFiles) {
// Log a warning that file uploads aren't fully supported yet
console.warn('File uploads in form-data are not fully supported in the backend proxy yet');
}
requestBodyData = formData.toString();
contentType = 'application/x-www-form-urlencoded'; // Fallback to URL-encoded for now
}
}
// Set content type if not already set
if (contentType && !requestHeaders['Content-Type'] && !requestHeaders['content-type']) {
requestHeaders['Content-Type'] = contentType;
}
// Create fetch options
const fetchOptions = {
method,
headers: requestHeaders,
signal: AbortSignal.timeout(timeout)
};
if (requestBodyData) {
fetchOptions.body = requestBodyData;
}
// Make the actual HTTP request
const response = await fetch(finalUrl, fetchOptions);
const endTime = Date.now();
const responseTime = endTime - startTime;
// Get response data
const responseHeaders = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
let responseData;
const contentTypeHeader = response.headers.get('content-type') || '';
if (contentTypeHeader.includes('application/json')) {
try {
responseData = await response.json();
} catch (e) {
responseData = await response.text();
}
} else if (contentTypeHeader.includes('text/') || contentTypeHeader.includes('application/xml')) {
responseData = await response.text();
} else {
// For binary data, convert to text representation
try {
responseData = await response.text();
} catch (e) {
responseData = '[Binary data]';
}
}
// Calculate response size (approximate)
const responseSize = typeof responseData === 'string'
? new Blob([responseData]).size
: new Blob([JSON.stringify(responseData)]).size;
return {
statusCode: 200,
message: "Request completed",
data: {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
data: responseData,
time: responseTime,
size: responseSize,
url: finalUrl
}
};
} catch (error) {
console.error('API Platform Request Error:', error);
const endTime = Date.now();
// Handle different types of errors
if (error.name === 'AbortError') {
return {
statusCode: 408,
message: "Request timeout",
data: {
status: 408,
statusText: 'Request Timeout',
headers: {},
data: { error: 'Request timed out' },
time: 30000,
size: 0
}
};
}
if (error.name === 'TypeError' && error.message.includes('fetch')) {
return {
statusCode: 400,
message: "Invalid URL or network error",
data: {
status: 400,
statusText: 'Bad Request',
headers: {},
data: { error: 'Invalid URL format or network error' },
time: endTime - Date.now() + 1000,
size: 0
}
};
}
if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
return {
statusCode: 502,
message: "Connection error",
data: {
status: 502,
statusText: 'Bad Gateway',
headers: {},
data: { error: 'Failed to connect to the server' },
time: endTime - Date.now() + 1000,
size: 0
}
};
}
return {
statusCode: 500,
message: "Internal server error",
data: {
status: 500,
statusText: 'Internal Server Error',
headers: {},
data: { error: error.message || 'Something went wrong' },
time: endTime - Date.now() + 1000,
size: 0
}
};
}
});

View File

@ -2039,22 +2039,53 @@
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
"@prisma/client@^5.1.1": "@prisma/client@6.8.2":
version "5.1.1" version "6.8.2"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-5.1.1.tgz#ea2b0c8599bdb3f86d92e8df46fba795a744db01" resolved "https://registry.yarnpkg.com/@prisma/client/-/client-6.8.2.tgz#f0af46643604cd38341f285483edc3c3ae7e6651"
integrity sha512-fxcCeK5pMQGcgCqCrWsi+I2rpIbk0rAhdrN+ke7f34tIrgPwA68ensrpin+9+fZvuV2OtzHmuipwduSY6HswdA== integrity sha512-5II+vbyzv4si6Yunwgkj0qT/iY0zyspttoDrL3R4BYgLdp42/d2C8xdi9vqkrYtKt9H32oFIukvyw3Koz5JoDg==
"@prisma/config@6.8.2":
version "6.8.2"
resolved "https://registry.yarnpkg.com/@prisma/config/-/config-6.8.2.tgz#30459b86f67acbd65e1e6f491215f6da9e0a2e99"
integrity sha512-ZJY1fF4qRBPdLQ/60wxNtX+eu89c3AkYEcP7L3jkp0IPXCNphCYxikTg55kPJLDOG6P0X+QG5tCv6CmsBRZWFQ==
dependencies: dependencies:
"@prisma/engines-version" "5.1.1-1.6a3747c37ff169c90047725a05a6ef02e32ac97e" jiti "2.4.2"
"@prisma/engines-version@5.1.1-1.6a3747c37ff169c90047725a05a6ef02e32ac97e": "@prisma/debug@6.8.2":
version "5.1.1-1.6a3747c37ff169c90047725a05a6ef02e32ac97e" version "6.8.2"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-5.1.1-1.6a3747c37ff169c90047725a05a6ef02e32ac97e.tgz#2e8a1f098ec09452dbe00923b24f582f95d1747c" resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-6.8.2.tgz#59fb9e0ccb0f431fe7011c36c95f9bfcbe051749"
integrity sha512-owZqbY/wucbr65bXJ/ljrHPgQU5xXTSkmcE/JcbqE1kusuAXV/TLN3/exmz21SZ5rJ7WDkyk70J2G/n68iogbQ== integrity sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==
"@prisma/engines@5.1.1": "@prisma/engines-version@6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e":
version "5.1.1" version "6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.1.1.tgz#9c26d209f93a563e048eab63b1976f222f1707d0" resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e.tgz#fd5885f4c502721ec3f81ad13d4d896d709cc34b"
integrity sha512-NV/4nVNWFZSJCCIA3HIFJbbDKO/NARc9ej0tX5S9k2EVbkrFJC4Xt9b0u4rNZWL4V+F5LAjvta8vzEUw0rw+HA== integrity sha512-Rkik9lMyHpFNGaLpPF3H5q5TQTkm/aE7DsGM5m92FZTvWQsvmi6Va8On3pWvqLHOt5aPUvFb/FeZTmphI4CPiQ==
"@prisma/engines@6.8.2":
version "6.8.2"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-6.8.2.tgz#34ef401027a38455244c23deb298e226914de97c"
integrity sha512-XqAJ//LXjqYRQ1RRabs79KOY4+v6gZOGzbcwDQl0D6n9WBKjV7qdrbd042CwSK0v0lM9MSHsbcFnU2Yn7z8Zlw==
dependencies:
"@prisma/debug" "6.8.2"
"@prisma/engines-version" "6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e"
"@prisma/fetch-engine" "6.8.2"
"@prisma/get-platform" "6.8.2"
"@prisma/fetch-engine@6.8.2":
version "6.8.2"
resolved "https://registry.yarnpkg.com/@prisma/fetch-engine/-/fetch-engine-6.8.2.tgz#34dce0efae20199f89e59953a2e9d469746c058f"
integrity sha512-lCvikWOgaLOfqXGacEKSNeenvj0n3qR5QvZUOmPE2e1Eh8cMYSobxonCg9rqM6FSdTfbpqp9xwhSAOYfNqSW0g==
dependencies:
"@prisma/debug" "6.8.2"
"@prisma/engines-version" "6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e"
"@prisma/get-platform" "6.8.2"
"@prisma/get-platform@6.8.2":
version "6.8.2"
resolved "https://registry.yarnpkg.com/@prisma/get-platform/-/get-platform-6.8.2.tgz#a6509de61ceab4fca80616b7e8d73705b2705a72"
integrity sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==
dependencies:
"@prisma/debug" "6.8.2"
"@rollup/plugin-alias@^5.0.0": "@rollup/plugin-alias@^5.0.0":
version "5.0.0" version "5.0.0"
@ -5672,6 +5703,11 @@ jest-worker@^26.2.1:
merge-stream "^2.0.0" merge-stream "^2.0.0"
supports-color "^7.0.0" supports-color "^7.0.0"
jiti@2.4.2:
version "2.4.2"
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.4.2.tgz#d19b7732ebb6116b06e2038da74a55366faef560"
integrity sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==
jiti@^1.18.2, jiti@^1.19.1: jiti@^1.18.2, jiti@^1.19.1:
version "1.19.1" version "1.19.1"
resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.19.1.tgz#fa99e4b76a23053e0e7cde098efe1704a14c16f1" resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.19.1.tgz#fa99e4b76a23053e0e7cde098efe1704a14c16f1"
@ -7705,12 +7741,13 @@ prism-es6@^1.2.0:
resolved "https://registry.yarnpkg.com/prism-es6/-/prism-es6-1.2.0.tgz#ead4e0d7809fefc36b9636f1ea4b2cebad074e63" resolved "https://registry.yarnpkg.com/prism-es6/-/prism-es6-1.2.0.tgz#ead4e0d7809fefc36b9636f1ea4b2cebad074e63"
integrity sha512-A8JV9G2zKM8PWksT7YJcmnaWtYO6C9hSfxM/xv0RxB2aNc8rjv30WakzIw1gWyqLi2eiqquo2KmS7orxqlm+yg== integrity sha512-A8JV9G2zKM8PWksT7YJcmnaWtYO6C9hSfxM/xv0RxB2aNc8rjv30WakzIw1gWyqLi2eiqquo2KmS7orxqlm+yg==
prisma@^5.1.1: prisma@6.8.2:
version "5.1.1" version "6.8.2"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.1.1.tgz#8f5c0f9467a828746cb94f846d694dc7b7481a9e" resolved "https://registry.yarnpkg.com/prisma/-/prisma-6.8.2.tgz#5cd9e1635b6ed0e27ea3cf3ef31c648c55115a63"
integrity sha512-WJFG/U7sMmcc6TjJTTifTfpI6Wjoh55xl4AzopVwAdyK68L9/ogNo8QQ2cxuUjJf/Wa82z/uhyh3wMzvRIBphg== integrity sha512-JNricTXQxzDtRS7lCGGOB4g5DJ91eg3nozdubXze3LpcMl1oWwcFddrj++Up3jnRE6X/3gB/xz3V+ecBk/eEGA==
dependencies: dependencies:
"@prisma/engines" "5.1.1" "@prisma/config" "6.8.2"
"@prisma/engines" "6.8.2"
process-nextick-args@~2.0.0: process-nextick-args@~2.0.0:
version "2.0.1" version "2.0.1"