Add API Platform functionality with authentication and management features
This commit is contained in:
parent
bcbf2f0958
commit
ed9b862b82
1
.cursorignore
Normal file
1
.cursorignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
191
PHASE-3-IMPLEMENTATION-SUMMARY.md
Normal file
191
PHASE-3-IMPLEMENTATION-SUMMARY.md
Normal 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!**
|
634
components/api-platform/CodeGenerationModal.vue
Normal file
634
components/api-platform/CodeGenerationModal.vue
Normal 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>
|
318
components/api-platform/CollectionsSidebar.vue
Normal file
318
components/api-platform/CollectionsSidebar.vue
Normal 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>
|
87
components/api-platform/CreateCollectionModal.vue
Normal file
87
components/api-platform/CreateCollectionModal.vue
Normal 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>
|
226
components/api-platform/EnvironmentModal.vue
Normal file
226
components/api-platform/EnvironmentModal.vue
Normal 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>
|
61
components/api-platform/EnvironmentSelector.vue
Normal file
61
components/api-platform/EnvironmentSelector.vue
Normal 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>
|
669
components/api-platform/ImportExportModal.vue
Normal file
669
components/api-platform/ImportExportModal.vue
Normal 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>
|
247
components/api-platform/RequestBuilder.vue
Normal file
247
components/api-platform/RequestBuilder.vue
Normal 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>
|
586
components/api-platform/ResponseViewer.vue
Normal file
586
components/api-platform/ResponseViewer.vue
Normal 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>
|
149
components/api-platform/SaveRequestModal.vue
Normal file
149
components/api-platform/SaveRequestModal.vue
Normal 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>
|
72
components/api-platform/tabs/AuthTab.vue
Normal file
72
components/api-platform/tabs/AuthTab.vue
Normal 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>
|
436
components/api-platform/tabs/BodyTab.vue
Normal file
436
components/api-platform/tabs/BodyTab.vue
Normal 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>
|
51
components/api-platform/tabs/HeadersTab.vue
Normal file
51
components/api-platform/tabs/HeadersTab.vue
Normal 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>
|
51
components/api-platform/tabs/ParamsTab.vue
Normal file
51
components/api-platform/tabs/ParamsTab.vue
Normal 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>
|
136
composables/useApiPlatform.js
Normal file
136
composables/useApiPlatform.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
136
composables/useApiRequest.js
Normal file
136
composables/useApiRequest.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
159
composables/useVariableSubstitution.js
Normal file
159
composables/useVariableSubstitution.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
79
docs/Current Implementation Analysis (first g.ini
Normal file
79
docs/Current Implementation Analysis (first g.ini
Normal 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
|
@ -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",
|
||||||
|
@ -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",
|
||||||
|
188
pages/api-platform/index.vue
Normal file
188
pages/api-platform/index.vue
Normal 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
88
pages/oauth/callback.vue
Normal 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>
|
@ -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[]
|
||||||
|
}
|
||||||
|
267
server/api/api-platform/send-request.post.js
Normal file
267
server/api/api-platform/send-request.post.js
Normal 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
73
yarn.lock
73
yarn.lock
@ -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"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user