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",
|
||||
"description": "Urus aplikasi anda",
|
||||
"child": [
|
||||
{
|
||||
"title": "API Platform",
|
||||
"path": "/api-platform",
|
||||
"icon": "",
|
||||
"child": []
|
||||
},
|
||||
{
|
||||
"title": "Konfigurasi",
|
||||
"icon": "ic:outline-settings",
|
||||
|
@ -43,7 +43,7 @@
|
||||
"@kiwicom/eslint-config": "^12.7.3",
|
||||
"@pinia/nuxt": "^0.4.11",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@prisma/client": "^5.1.1",
|
||||
"@prisma/client": "6.8.2",
|
||||
"@shimyshack/uid": "^0.1.7",
|
||||
"@sweetalert2/theme-dark": "^5.0.14",
|
||||
"@vueup/vue-quill": "^1.0.0",
|
||||
@ -61,7 +61,7 @@
|
||||
"maska": "^1.5.0",
|
||||
"pinia": "^2.1.6",
|
||||
"prettier": "^2.8.1",
|
||||
"prisma": "^5.1.1",
|
||||
"prisma": "6.8.2",
|
||||
"sass": "^1.62.0",
|
||||
"swiper": "^8.4.4",
|
||||
"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)
|
||||
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"
|
||||
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
|
||||
|
||||
"@prisma/client@^5.1.1":
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-5.1.1.tgz#ea2b0c8599bdb3f86d92e8df46fba795a744db01"
|
||||
integrity sha512-fxcCeK5pMQGcgCqCrWsi+I2rpIbk0rAhdrN+ke7f34tIrgPwA68ensrpin+9+fZvuV2OtzHmuipwduSY6HswdA==
|
||||
"@prisma/client@6.8.2":
|
||||
version "6.8.2"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-6.8.2.tgz#f0af46643604cd38341f285483edc3c3ae7e6651"
|
||||
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:
|
||||
"@prisma/engines-version" "5.1.1-1.6a3747c37ff169c90047725a05a6ef02e32ac97e"
|
||||
jiti "2.4.2"
|
||||
|
||||
"@prisma/engines-version@5.1.1-1.6a3747c37ff169c90047725a05a6ef02e32ac97e":
|
||||
version "5.1.1-1.6a3747c37ff169c90047725a05a6ef02e32ac97e"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-5.1.1-1.6a3747c37ff169c90047725a05a6ef02e32ac97e.tgz#2e8a1f098ec09452dbe00923b24f582f95d1747c"
|
||||
integrity sha512-owZqbY/wucbr65bXJ/ljrHPgQU5xXTSkmcE/JcbqE1kusuAXV/TLN3/exmz21SZ5rJ7WDkyk70J2G/n68iogbQ==
|
||||
"@prisma/debug@6.8.2":
|
||||
version "6.8.2"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-6.8.2.tgz#59fb9e0ccb0f431fe7011c36c95f9bfcbe051749"
|
||||
integrity sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==
|
||||
|
||||
"@prisma/engines@5.1.1":
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.1.1.tgz#9c26d209f93a563e048eab63b1976f222f1707d0"
|
||||
integrity sha512-NV/4nVNWFZSJCCIA3HIFJbbDKO/NARc9ej0tX5S9k2EVbkrFJC4Xt9b0u4rNZWL4V+F5LAjvta8vzEUw0rw+HA==
|
||||
"@prisma/engines-version@6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e":
|
||||
version "6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e.tgz#fd5885f4c502721ec3f81ad13d4d896d709cc34b"
|
||||
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":
|
||||
version "5.0.0"
|
||||
@ -5672,6 +5703,11 @@ jest-worker@^26.2.1:
|
||||
merge-stream "^2.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:
|
||||
version "1.19.1"
|
||||
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"
|
||||
integrity sha512-A8JV9G2zKM8PWksT7YJcmnaWtYO6C9hSfxM/xv0RxB2aNc8rjv30WakzIw1gWyqLi2eiqquo2KmS7orxqlm+yg==
|
||||
|
||||
prisma@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.1.1.tgz#8f5c0f9467a828746cb94f846d694dc7b7481a9e"
|
||||
integrity sha512-WJFG/U7sMmcc6TjJTTifTfpI6Wjoh55xl4AzopVwAdyK68L9/ogNo8QQ2cxuUjJf/Wa82z/uhyh3wMzvRIBphg==
|
||||
prisma@6.8.2:
|
||||
version "6.8.2"
|
||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-6.8.2.tgz#5cd9e1635b6ed0e27ea3cf3ef31c648c55115a63"
|
||||
integrity sha512-JNricTXQxzDtRS7lCGGOB4g5DJ91eg3nozdubXze3LpcMl1oWwcFddrj++Up3jnRE6X/3gB/xz3V+ecBk/eEGA==
|
||||
dependencies:
|
||||
"@prisma/engines" "5.1.1"
|
||||
"@prisma/config" "6.8.2"
|
||||
"@prisma/engines" "6.8.2"
|
||||
|
||||
process-nextick-args@~2.0.0:
|
||||
version "2.0.1"
|
||||
|
Loading…
x
Reference in New Issue
Block a user