chore: v2025.10.0 release
This commit is contained in:
commit
654e18fce9
261 changed files with 45922 additions and 5485 deletions
877
.github/workflows/build-hoppscotch-agent.yml
vendored
877
.github/workflows/build-hoppscotch-agent.yml
vendored
|
|
@ -1,249 +1,670 @@
|
|||
name: Build Agent Self Host - AIO
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Tag of the version to build
|
||||
required: true
|
||||
|
||||
branch:
|
||||
description: Branch to checkout
|
||||
required: true
|
||||
default: "main"
|
||||
release_notes:
|
||||
description: Release notes for the update
|
||||
required: false
|
||||
default: "PLACEHOLDER RELEASE NOTES"
|
||||
build_macos_x64:
|
||||
description: Build for macOS x64
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
build_macos_arm64:
|
||||
description: Build for macOS ARM64
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
build_linux_deb:
|
||||
description: Build Linux DEB package
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
build_linux_appimage:
|
||||
description: Build Linux AppImage
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
build_windows_installer:
|
||||
description: Build Windows MSI installer
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
build_windows_portable:
|
||||
description: Build Windows portable executable
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [macos-latest, ubuntu-22.04, windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
build-macos-x86_64:
|
||||
name: Build MacOS x86_64 (.dmg)
|
||||
runs-on: macos-latest
|
||||
if: ${{ inputs.build_macos_x64 }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout hoppscotch/hoppscotch
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: hoppscotch/hoppscotch
|
||||
ref: main
|
||||
token: ${{ secrets.CHECKOUT_GITHUB_TOKEN }}
|
||||
- name: Checkout hoppscotch/hoppscotch
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: hoppscotch/hoppscotch
|
||||
ref: ${{ inputs.branch }}
|
||||
token: ${{ secrets.HOPPSCOTCH_GITHUB_CHECKOUT_TOKEN }}
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.15.0
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
- name: Install Rust target
|
||||
timeout-minutes: 5
|
||||
run: rustup target add x86_64-apple-darwin
|
||||
- name: Install additional tools
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
mkdir __dist/
|
||||
cd __dist/
|
||||
curl -LO "https://github.com/tauri-apps/tauri/releases/download/tauri-cli-v2.0.1/cargo-tauri-x86_64-apple-darwin.zip"
|
||||
unzip cargo-tauri-x86_64-apple-darwin.zip
|
||||
chmod +x cargo-tauri
|
||||
sudo mv cargo-tauri /usr/local/bin/tauri
|
||||
- name: Import Code-Signing Certificates
|
||||
uses: apple-actions/import-codesign-certs@v3
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.HOPPSCOTCH_APPLE_CERTIFICATE }}
|
||||
p12-password: ${{ secrets.HOPPSCOTCH_APPLE_CERTIFICATE_PASSWORD }}
|
||||
keychain-password: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-x86-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Install dependencies
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
pnpm install --filter hoppscotch-agent
|
||||
- name: Build Tauri app
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.AGENT_TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.AGENT_TAURI_SIGNING_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.HOPPSCOTCH_APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.HOPPSCOTCH_APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.HOPPSCOTCH_APPLE_TEAM_ID }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.HOPPSCOTCH_APPLE_SIGNING_IDENTITY }}
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
echo "Starting x86_64 build..."
|
||||
pnpm tauri build --verbose --target x86_64-apple-darwin
|
||||
echo "Build completed"
|
||||
- name: Prepare artifacts
|
||||
run: |
|
||||
mkdir -p artifacts/{sigs,updaters,shas}
|
||||
mv packages/hoppscotch-agent/src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*_x64.dmg artifacts/Hoppscotch_Agent_mac_x64.dmg
|
||||
mv packages/hoppscotch-agent/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/updaters/Hoppscotch_Agent_mac_update_x64.tar.gz
|
||||
mv packages/hoppscotch-agent/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/*.app.tar.gz.sig artifacts/sigs/Hoppscotch_Agent_mac_update_x64.tar.gz.sig
|
||||
- name: Generate checksums
|
||||
timeout-minutes: 2
|
||||
run: |
|
||||
cd artifacts
|
||||
for file in *; do
|
||||
if [ -f "$file" ]; then
|
||||
shasum -a 256 "$file" > "shas/${file}.sha256"
|
||||
fi
|
||||
done
|
||||
cd updaters
|
||||
for file in *; do
|
||||
if [ -f "$file" ]; then
|
||||
shasum -a 256 "$file" > "../shas/${file}.sha256"
|
||||
fi
|
||||
done
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Hoppscotch_Agent-macos-x86_64
|
||||
path: artifacts/*
|
||||
build-macos-aarch64:
|
||||
name: Build MacOS ARM64 (.dmg)
|
||||
runs-on: macos-latest
|
||||
if: ${{ inputs.build_macos_arm64 }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout hoppscotch/hoppscotch
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: hoppscotch/hoppscotch
|
||||
ref: ${{ inputs.branch }}
|
||||
token: ${{ secrets.HOPPSCOTCH_GITHUB_CHECKOUT_TOKEN }}
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.15.0
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
- name: Install Rust target
|
||||
timeout-minutes: 5
|
||||
run: rustup target add aarch64-apple-darwin
|
||||
- name: Install additional tools
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
mkdir __dist/
|
||||
cd __dist/
|
||||
curl -LO "https://github.com/tauri-apps/tauri/releases/download/tauri-cli-v2.0.1/cargo-tauri-aarch64-apple-darwin.zip"
|
||||
unzip cargo-tauri-aarch64-apple-darwin.zip
|
||||
chmod +x cargo-tauri
|
||||
sudo mv cargo-tauri /usr/local/bin/tauri
|
||||
- name: Import Code-Signing Certificates
|
||||
uses: apple-actions/import-codesign-certs@v3
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.HOPPSCOTCH_APPLE_CERTIFICATE }}
|
||||
p12-password: ${{ secrets.HOPPSCOTCH_APPLE_CERTIFICATE_PASSWORD }}
|
||||
keychain-password: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-arm-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Install dependencies
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
pnpm install --filter hoppscotch-agent
|
||||
- name: Build Tauri app
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.AGENT_TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.AGENT_TAURI_SIGNING_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.HOPPSCOTCH_APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.HOPPSCOTCH_APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.HOPPSCOTCH_APPLE_TEAM_ID }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.HOPPSCOTCH_APPLE_SIGNING_IDENTITY }}
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
echo "Starting ARM64 build..."
|
||||
pnpm tauri build --verbose --target aarch64-apple-darwin
|
||||
echo "Build completed"
|
||||
- name: Prepare artifacts
|
||||
run: |
|
||||
mkdir -p artifacts/{sigs,updaters,shas}
|
||||
mv packages/hoppscotch-agent/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*_aarch64.dmg artifacts/Hoppscotch_Agent_mac_aarch64.dmg
|
||||
mv packages/hoppscotch-agent/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/updaters/Hoppscotch_Agent_mac_update_aarch64.tar.gz
|
||||
mv packages/hoppscotch-agent/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz.sig artifacts/sigs/Hoppscotch_Agent_mac_update_aarch64.tar.gz.sig
|
||||
- name: Generate checksums
|
||||
timeout-minutes: 2
|
||||
run: |
|
||||
cd artifacts
|
||||
for file in *; do
|
||||
if [ -f "$file" ]; then
|
||||
shasum -a 256 "$file" > "shas/${file}.sha256"
|
||||
fi
|
||||
done
|
||||
cd updaters
|
||||
for file in *; do
|
||||
if [ -f "$file" ]; then
|
||||
shasum -a 256 "$file" > "../shas/${file}.sha256"
|
||||
fi
|
||||
done
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Hoppscotch_Agent-macos-arm64
|
||||
path: artifacts/*
|
||||
build-linux-deb:
|
||||
name: Build Linux x86_64 (.deb)
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ inputs.build_linux_deb }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout hoppscotch/hoppscotch
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: hoppscotch/hoppscotch
|
||||
ref: ${{ inputs.branch }}
|
||||
token: ${{ secrets.HOPPSCOTCH_GITHUB_CHECKOUT_TOKEN }}
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.15.0
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
- name: Install system dependencies
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev
|
||||
- name: Install additional tools
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
curl -LO "https://github.com/tauri-apps/tauri/releases/download/tauri-cli-v2.0.1/cargo-tauri-x86_64-unknown-linux-gnu.tgz"
|
||||
tar -xzf cargo-tauri-x86_64-unknown-linux-gnu.tgz
|
||||
chmod +x cargo-tauri
|
||||
sudo mv cargo-tauri /usr/local/bin/tauri
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
curl -LO "https://github.com/thedodd/trunk/releases/download/v0.17.5/trunk-x86_64-unknown-linux-gnu.tar.gz"
|
||||
tar -xzf trunk-x86_64-unknown-linux-gnu.tar.gz
|
||||
chmod +x trunk
|
||||
sudo mv trunk /usr/local/bin/
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Install dependencies
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
pnpm install --filter hoppscotch-agent
|
||||
- name: Build Tauri app
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
pnpm tauri build --verbose -b deb -b updater
|
||||
- name: Prepare artifacts
|
||||
run: |
|
||||
mkdir -p artifacts/{sigs,shas}
|
||||
mv packages/hoppscotch-agent/src-tauri/target/release/bundle/deb/*.deb artifacts/Hoppscotch_Agent_linux_x64.deb
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd artifacts
|
||||
for file in *; do
|
||||
if [ -f "$file" ]; then
|
||||
sha256sum "$file" > "shas/${file}.sha256"
|
||||
fi
|
||||
done
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Hoppscotch_Agent-linux-deb
|
||||
path: artifacts/*
|
||||
build-linux-appimage:
|
||||
name: Build Linux x86_64 (.AppImage)
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ inputs.build_linux_appimage }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout hoppscotch/hoppscotch
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: hoppscotch/hoppscotch
|
||||
ref: ${{ inputs.branch }}
|
||||
token: ${{ secrets.HOPPSCOTCH_GITHUB_CHECKOUT_TOKEN }}
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.15.0
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
- name: Install system dependencies
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev
|
||||
- name: Install additional tools
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
curl -LO "https://github.com/tauri-apps/tauri/releases/download/tauri-cli-v2.0.1/cargo-tauri-x86_64-unknown-linux-gnu.tgz"
|
||||
tar -xzf cargo-tauri-x86_64-unknown-linux-gnu.tgz
|
||||
chmod +x cargo-tauri
|
||||
sudo mv cargo-tauri /usr/local/bin/tauri
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
|
||||
- name: Install Rust targets (Mac)
|
||||
if: matrix.platform == 'macos-latest'
|
||||
run: |
|
||||
rustup target add aarch64-apple-darwin
|
||||
rustup target add x86_64-apple-darwin
|
||||
|
||||
- name: Install additional tools (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
# Install Tauri CLI (binary)
|
||||
curl -LO "https://github.com/tauri-apps/tauri/releases/download/tauri-cli-v2.0.1/cargo-tauri-x86_64-unknown-linux-gnu.tgz"
|
||||
tar -xzf cargo-tauri-x86_64-unknown-linux-gnu.tgz
|
||||
chmod +x cargo-tauri
|
||||
sudo mv cargo-tauri /usr/local/bin/tauri
|
||||
|
||||
# Install Trunk (binary)
|
||||
curl -LO "https://github.com/thedodd/trunk/releases/download/v0.17.5/trunk-x86_64-unknown-linux-gnu.tar.gz"
|
||||
tar -xzf trunk-x86_64-unknown-linux-gnu.tar.gz
|
||||
chmod +x trunk
|
||||
sudo mv trunk /usr/local/bin/
|
||||
|
||||
- name: Install additional tools (Mac)
|
||||
if: matrix.platform == 'macos-latest'
|
||||
run: |
|
||||
# Install Tauri CLI (binary)
|
||||
mkdir __dist/
|
||||
cd __dist/
|
||||
curl -LO "https://github.com/tauri-apps/tauri/releases/download/tauri-cli-v2.0.1/cargo-tauri-aarch64-apple-darwin.zip"
|
||||
unzip cargo-tauri-aarch64-apple-darwin.zip
|
||||
chmod +x cargo-tauri
|
||||
sudo mv cargo-tauri /usr/local/bin/tauri
|
||||
|
||||
- name: Install system dependencies (Ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev
|
||||
|
||||
- name: Setting up Windows Environment and injecting before bundle command (Windows only)
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: bash
|
||||
env:
|
||||
WINDOWS_SIGN_COMMAND: trusted-signing-cli -e ${{ secrets.AZURE_ENDPOINT }} -a ${{ secrets.AZURE_CODE_SIGNING_NAME }} -c ${{ secrets.AZURE_CERT_PROFILE_NAME }} %1
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
# Inject signing command into main conf.
|
||||
cat './src-tauri/tauri.conf.json' | jq '.bundle .windows += { "signCommand": env.WINDOWS_SIGN_COMMAND}' > './src-tauri/temp.json' && mv './src-tauri/temp.json' './src-tauri/tauri.conf.json'
|
||||
# Inject signing command into portable conf.
|
||||
cat './src-tauri/tauri.portable.conf.json' | jq '.bundle .windows += { "signCommand": env.WINDOWS_SIGN_COMMAND}' > './src-tauri/temp_portable.json' && mv './src-tauri/temp_portable.json' './src-tauri/tauri.portable.conf.json'
|
||||
cargo install trusted-signing-cli@0.3.0
|
||||
|
||||
- name: Set platform-specific variables
|
||||
run: |
|
||||
if [ "${{ matrix.platform }}" = "ubuntu-22.04" ]; then
|
||||
echo "target_arch=$(rustc -Vv | grep host | awk '{print $2}')" >> $GITHUB_ENV
|
||||
echo "target_ext=" >> $GITHUB_ENV
|
||||
echo "target_os_name=linux" >> $GITHUB_ENV
|
||||
elif [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
echo "target_arch=x86_64-pc-windows-msvc" >> $GITHUB_ENV
|
||||
echo "target_ext=.exe" >> $GITHUB_ENV
|
||||
echo "target_os_name=win" >> $GITHUB_ENV
|
||||
elif [ "${{ matrix.platform }}" = "macos-latest" ]; then
|
||||
echo "target_os_name=mac" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Setup macOS code signing
|
||||
if: matrix.platform == 'macos-latest'
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
|
||||
security create-keychain -p $KEYCHAIN_PASSWORD build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain
|
||||
security import certificate.p12 -k build.keychain -P $APPLE_CERTIFICATE_PASSWORD -T /usr/bin/codesign
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEYCHAIN_PASSWORD build.keychain
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
pnpm install --filter hoppscotch-agent
|
||||
|
||||
- name: Build Tauri app (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.AGENT_TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.AGENT_TAURI_SIGNING_PASSWORD }}
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
pnpm tauri build --verbose -b deb -b appimage -b updater
|
||||
|
||||
- name: Build Tauri app (Mac)
|
||||
if: matrix.platform == 'macos-latest'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.AGENT_TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.AGENT_TAURI_SIGNING_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
pnpm tauri build --verbose --target x86_64-apple-darwin
|
||||
pnpm tauri build --verbose --target aarch64-apple-darwin
|
||||
|
||||
- name: Build Tauri app (Windows)
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: powershell
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.AGENT_TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.AGENT_TAURI_SIGNING_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
# Build the portable version first and move it.
|
||||
# This way the next build will regenerate `hoppscotch-agent.exe`.
|
||||
pnpm tauri build --verbose --config src-tauri/tauri.portable.conf.json -- --no-default-features --features portable
|
||||
Rename-Item -Path "src-tauri/target/release/hoppscotch-agent.exe" -NewName "hoppscotch-agent-portable.exe"
|
||||
|
||||
# Build the installer version.
|
||||
pnpm tauri build --verbose -b msi -b updater
|
||||
|
||||
- name: Zip portable executable (Windows)
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: powershell
|
||||
run: |
|
||||
Compress-Archive -Path "packages/hoppscotch-agent/src-tauri/target/release/hoppscotch-agent-portable.exe" -DestinationPath "packages/hoppscotch-agent/src-tauri/target/release/Hoppscotch_Agent_win_x64_portable.zip"
|
||||
|
||||
- name: Prepare artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir artifacts
|
||||
mkdir artifacts/sigs
|
||||
if [ "${{ matrix.platform }}" = "ubuntu-22.04" ]; then
|
||||
curl -LO "https://github.com/thedodd/trunk/releases/download/v0.17.5/trunk-x86_64-unknown-linux-gnu.tar.gz"
|
||||
tar -xzf trunk-x86_64-unknown-linux-gnu.tar.gz
|
||||
chmod +x trunk
|
||||
sudo mv trunk /usr/local/bin/
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Install dependencies
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
pnpm install --filter hoppscotch-agent
|
||||
- name: Build Tauri app
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
pnpm tauri build --verbose -b appimage -b updater
|
||||
- name: Prepare artifacts
|
||||
run: |
|
||||
mkdir -p artifacts/{sigs,shas}
|
||||
mv packages/hoppscotch-agent/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/Hoppscotch_Agent_linux_x64.AppImage
|
||||
mv packages/hoppscotch-agent/src-tauri/target/release/bundle/appimage/*.AppImage.sig artifacts/sigs/Hoppscotch_Agent_linux_x64.AppImage.sig
|
||||
mv packages/hoppscotch-agent/src-tauri/target/release/bundle/deb/*.deb artifacts/Hoppscotch_Agent_linux_x64.deb
|
||||
elif [ "${{ matrix.platform }}" = "macos-latest" ]; then
|
||||
mv packages/hoppscotch-agent/src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*_x64.dmg artifacts/Hoppscotch_Agent_mac_x64.dmg
|
||||
mv packages/hoppscotch-agent/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/Hoppscotch_Agent_mac_update_x64.tar.gz
|
||||
mv packages/hoppscotch-agent/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/*.app.tar.gz.sig artifacts/sigs/Hoppscotch_Agent_mac_update_x64.tar.gz.sig
|
||||
mv packages/hoppscotch-agent/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*_aarch64.dmg artifacts/Hoppscotch_Agent_mac_aarch64.dmg
|
||||
mv packages/hoppscotch-agent/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/Hoppscotch_Agent_mac_update_aarch64.tar.gz
|
||||
mv packages/hoppscotch-agent/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz.sig artifacts/sigs/Hoppscotch_Agent_mac_update_aarch64.tar.gz.sig
|
||||
elif [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd artifacts
|
||||
for file in *; do
|
||||
if [ -f "$file" ]; then
|
||||
sha256sum "$file" > "shas/${file}.sha256"
|
||||
fi
|
||||
done
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Hoppscotch_Agent-linux-appimage
|
||||
path: artifacts/*
|
||||
build-windows-installer:
|
||||
name: Build Windows x86_64 (.msi)
|
||||
runs-on: windows-latest
|
||||
if: ${{ inputs.build_windows_installer }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout hoppscotch/hoppscotch
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: hoppscotch/hoppscotch
|
||||
ref: ${{ inputs.branch }}
|
||||
token: ${{ secrets.HOPPSCOTCH_GITHUB_CHECKOUT_TOKEN }}
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.15.0
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
- name: Download trusted-signing-cli
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
Invoke-WebRequest -Uri "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe" -OutFile "trusted-signing-cli.exe"
|
||||
Move-Item -Path "trusted-signing-cli.exe" -Destination "$env:GITHUB_WORKSPACE\trusted-signing-cli.exe"
|
||||
echo "$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
- name: Setting up Windows Environment
|
||||
timeout-minutes: 20
|
||||
shell: bash
|
||||
env:
|
||||
WINDOWS_SIGN_COMMAND: trusted-signing-cli -e ${{ secrets.AZURE_ENDPOINT }} -a ${{ secrets.AZURE_CODE_SIGNING_NAME }} -c ${{ secrets.AZURE_CERT_PROFILE_NAME }} %1
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
cat './src-tauri/tauri.conf.json' | jq '.bundle .windows += { "signCommand": env.WINDOWS_SIGN_COMMAND}' > './src-tauri/temp.json' && mv './src-tauri/temp.json' './src-tauri/tauri.conf.json'
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Install dependencies
|
||||
timeout-minutes: 15
|
||||
shell: bash
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
pnpm install --filter hoppscotch-agent
|
||||
- name: Build Tauri app
|
||||
timeout-minutes: 30
|
||||
shell: powershell
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.AGENT_TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.AGENT_TAURI_SIGNING_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
pnpm tauri build --verbose -b msi -b updater
|
||||
- name: Prepare artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p artifacts/{sigs,shas}
|
||||
mv packages/hoppscotch-agent/src-tauri/target/release/bundle/msi/*_x64_en-US.msi artifacts/Hoppscotch_Agent_win_x64.msi
|
||||
mv packages/hoppscotch-agent/src-tauri/target/release/bundle/msi/*_x64_en-US.msi.sig artifacts/sigs/Hoppscotch_Agent_win_x64.msi.sig
|
||||
- name: Generate checksums
|
||||
shell: powershell
|
||||
run: |
|
||||
cd artifacts
|
||||
Get-ChildItem -File | ForEach-Object {
|
||||
$hash = Get-FileHash -Algorithm SHA256 $_.Name
|
||||
$hash.Hash + " " + $_.Name | Out-File -Encoding UTF8 "shas/$($_.Name).sha256"
|
||||
}
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Hoppscotch_Agent-windows-installer
|
||||
path: artifacts/*
|
||||
build-windows-portable:
|
||||
name: Build Windows x86_64 Portable
|
||||
runs-on: windows-latest
|
||||
if: ${{ inputs.build_windows_portable }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout hoppscotch/hoppscotch
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: hoppscotch/hoppscotch
|
||||
ref: ${{ inputs.branch }}
|
||||
token: ${{ secrets.HOPPSCOTCH_GITHUB_CHECKOUT_TOKEN }}
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.15.0
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
- name: Download trusted-signing-cli
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
Invoke-WebRequest -Uri "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe" -OutFile "trusted-signing-cli.exe"
|
||||
Move-Item -Path "trusted-signing-cli.exe" -Destination "$env:GITHUB_WORKSPACE\trusted-signing-cli.exe"
|
||||
echo "$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
- name: Setting up Windows Environment
|
||||
timeout-minutes: 20
|
||||
shell: bash
|
||||
env:
|
||||
WINDOWS_SIGN_COMMAND: trusted-signing-cli -e ${{ secrets.AZURE_ENDPOINT }} -a ${{ secrets.AZURE_CODE_SIGNING_NAME }} -c ${{ secrets.AZURE_CERT_PROFILE_NAME }} %1
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
cat './src-tauri/tauri.portable.conf.json' | jq '.bundle .windows += { "signCommand": env.WINDOWS_SIGN_COMMAND}' > './src-tauri/temp_portable.json' && mv './src-tauri/temp_portable.json' './src-tauri/tauri.portable.conf.json'
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Install dependencies
|
||||
timeout-minutes: 15
|
||||
shell: bash
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
pnpm install --filter hoppscotch-agent
|
||||
- name: Build Tauri app
|
||||
timeout-minutes: 30
|
||||
shell: powershell
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.AGENT_TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.AGENT_TAURI_SIGNING_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
pnpm tauri build --verbose --config src-tauri/tauri.portable.conf.json -- --no-default-features --features portable
|
||||
- name: Sign portable executable
|
||||
shell: powershell
|
||||
env:
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
run: |
|
||||
cd packages/hoppscotch-agent
|
||||
trusted-signing-cli -e ${{ secrets.AZURE_ENDPOINT }} -a ${{ secrets.AZURE_CODE_SIGNING_NAME }} -c ${{ secrets.AZURE_CERT_PROFILE_NAME }} "src-tauri/target/release/hoppscotch-agent.exe"
|
||||
- name: Zip portable executable
|
||||
shell: powershell
|
||||
run: |
|
||||
Compress-Archive -Path "packages/hoppscotch-agent/src-tauri/target/release/hoppscotch-agent.exe" -DestinationPath "packages/hoppscotch-agent/src-tauri/target/release/Hoppscotch_Agent_win_x64_portable.zip"
|
||||
- name: Prepare artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p artifacts/{sigs,shas}
|
||||
mv packages/hoppscotch-agent/src-tauri/target/release/Hoppscotch_Agent_win_x64_portable.zip artifacts/Hoppscotch_Agent_win_x64_portable.zip
|
||||
fi
|
||||
- name: Generate checksums
|
||||
shell: powershell
|
||||
run: |
|
||||
cd artifacts
|
||||
Get-ChildItem -File | ForEach-Object {
|
||||
$hash = Get-FileHash -Algorithm SHA256 $_.Name
|
||||
$hash.Hash + " " + $_.Name | Out-File -Encoding UTF8 "shas/$($_.Name).sha256"
|
||||
}
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Hoppscotch_Agent-windows-portable
|
||||
path: artifacts/*
|
||||
create-update-manifest:
|
||||
name: Create Update Manifest
|
||||
needs: [build-macos-x86_64, build-macos-aarch64, build-linux-deb, build-linux-appimage, build-windows-installer, build-windows-portable]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ inputs.build_macos_x64 && inputs.build_macos_arm64 && inputs.build_linux_appimage && inputs.build_windows_installer }}
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
- name: List downloaded artifacts
|
||||
run: find artifacts -type f | sort
|
||||
- name: Create update manifest
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
CURRENT_DATE=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ")
|
||||
|
||||
- name: Generate checksums (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
cd artifacts
|
||||
mkdir shas
|
||||
for file in *; do
|
||||
if [ -f "$file" ]; then
|
||||
sha256sum "$file" > "shas/${file}.sha256"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Generate checksums (Mac)
|
||||
if: matrix.platform == 'macos-latest'
|
||||
run: |
|
||||
cd artifacts
|
||||
mkdir shas
|
||||
for file in *; do
|
||||
if [ -f "$file" ]; then
|
||||
shasum -a 256 "$file" > "shas/${file}.sha256"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Hoppscotch_Agent-${{ matrix.platform }}
|
||||
path: artifacts/*
|
||||
cat > artifacts/hoppscotch-agent-update.json << EOF
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"notes": "${{ inputs.release_notes }}",
|
||||
"pub_date": "${CURRENT_DATE}",
|
||||
"platforms": {
|
||||
"darwin-x86_64": {
|
||||
"signature": "$(cat artifacts/Hoppscotch_Agent-macos-x86_64/sigs/Hoppscotch_Agent_mac_update_x64.tar.gz.sig)",
|
||||
"url": "https://github.com/hoppscotch/agent-releases/releases/download/${VERSION}/Hoppscotch_Agent_mac_update_x64.tar.gz"
|
||||
},
|
||||
"darwin-aarch64": {
|
||||
"signature": "$(cat artifacts/Hoppscotch_Agent-macos-arm64/sigs/Hoppscotch_Agent_mac_update_aarch64.tar.gz.sig)",
|
||||
"url": "https://github.com/hoppscotch/agent-releases/releases/download/${VERSION}/Hoppscotch_Agent_mac_update_aarch64.tar.gz"
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"signature": "$(cat artifacts/Hoppscotch_Agent-linux-appimage/sigs/Hoppscotch_Agent_linux_x64.AppImage.sig)",
|
||||
"url": "https://github.com/hoppscotch/agent-releases/releases/download/${VERSION}/Hoppscotch_Agent_linux_x64.AppImage"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"signature": "$(cat artifacts/Hoppscotch_Agent-windows-installer/sigs/Hoppscotch_Agent_win_x64.msi.sig)",
|
||||
"url": "https://github.com/hoppscotch/agent-releases/releases/download/${VERSION}/Hoppscotch_Agent_win_x64.msi"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
- name: Upload manifest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: update-manifest
|
||||
path: artifacts/hoppscotch-agent-update.json
|
||||
|
|
|
|||
5
.github/workflows/tests.yml
vendored
5
.github/workflows/tests.yml
vendored
|
|
@ -13,7 +13,10 @@ jobs:
|
|||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: ["lts/*"]
|
||||
# Pinned to Node.js 22 to maintain compatibility with isolated-vm v5.x
|
||||
# Node.js 24 requires isolated-vm v6+ due to V8 API changes
|
||||
# TODO: Upgrade to isolated-vm v6 and support Node.js 24 in future dependency update cycle
|
||||
node-version: ["22"]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
</b>
|
||||
<p>
|
||||
|
||||
[](CODE_OF_CONDUCT.md) [](https://hoppscotch.io) [](https://github.com/hoppscotch/hoppscotch/actions) [](https://twitter.com/share?text=%F0%9F%91%BD%20Hoppscotch%20%E2%80%A2%20Open%20source%20API%20development%20ecosystem%20-%20Helps%20you%20create%20requests%20faster,%20saving%20precious%20time%20on%20development.&url=https://hoppscotch.io&hashtags=hoppscotch&via=hoppscotch_io)
|
||||
[](CODE_OF_CONDUCT.md) [](https://hoppscotch.io) [](https://github.com/hoppscotch/hoppscotch/actions) [](https://x.com/share?text=%F0%9F%91%BD%20Hoppscotch%20%E2%80%A2%20Open%20source%20API%20development%20ecosystem%20-%20Helps%20you%20create%20requests%20faster,%20saving%20precious%20time%20on%20development.&url=https://hoppscotch.io&hashtags=hoppscotch&via=hoppscotch_io)
|
||||
|
||||
</p>
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -16,5 +16,16 @@
|
|||
}
|
||||
|
||||
:3170 {
|
||||
reverse_proxy localhost:8080
|
||||
@mock {
|
||||
header_regexp host Host ^[^.]+\.mock\..*$
|
||||
}
|
||||
|
||||
handle @mock {
|
||||
rewrite * /mock{uri}
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,18 @@
|
|||
|
||||
# Handle requests under `/backend*` path
|
||||
handle_path /backend* {
|
||||
reverse_proxy localhost:8080
|
||||
@mock {
|
||||
header_regexp host Host ^[^.]+\.mock\..*$
|
||||
}
|
||||
|
||||
handle @mock {
|
||||
rewrite * /mock{uri}
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
}
|
||||
|
||||
# Handle requests under `/desktop-app-server*` path
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@ curlCheck() {
|
|||
fi
|
||||
}
|
||||
|
||||
# Wait for initial startup period to avoid unnecessary error logs
|
||||
# Check if the container has been running for at least 15 seconds
|
||||
UPTIME=$(awk '{print int($1)}' /proc/uptime)
|
||||
if [ "$UPTIME" -lt 15 ]; then
|
||||
echo "Container still starting up (uptime: ${UPTIME}s), skipping health check..."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$ENABLE_SUBPATH_BASED_ACCESS" = "true" ]; then
|
||||
curlCheck "http://localhost:${HOPP_AIO_ALTERNATE_PORT:-80}/backend/ping" || exit 1
|
||||
else
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
|
||||
[[redirects]]
|
||||
from = "/twitter"
|
||||
to = "https://twitter.com/hoppscotch_io"
|
||||
to = "https://x.com/hoppscotch_io"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
|
|
|
|||
13
package.json
13
package.json
|
|
@ -5,7 +5,7 @@
|
|||
"author": "Hoppscotch (support@hoppscotch.io)",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@10.15.0",
|
||||
"packageManager": "pnpm@10.18.3",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"prepare": "husky",
|
||||
|
|
@ -24,14 +24,14 @@
|
|||
"./packages/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "19.8.1",
|
||||
"@commitlint/config-conventional": "19.8.1",
|
||||
"@commitlint/cli": "20.1.0",
|
||||
"@commitlint/config-conventional": "20.0.0",
|
||||
"@hoppscotch/ui": "0.2.5",
|
||||
"@types/node": "24.5.2",
|
||||
"cross-env": "10.0.0",
|
||||
"@types/node": "24.9.1",
|
||||
"cross-env": "10.1.0",
|
||||
"http-server": "14.1.1",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.2.1"
|
||||
"lint-staged": "16.2.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
|
|
@ -39,6 +39,7 @@
|
|||
"apiconnect-wsdl": "2.0.36",
|
||||
"cross-spawn": "7.0.6",
|
||||
"execa@0.10.0": "2.0.0",
|
||||
"nodemailer@<7.0.7": "7.0.7",
|
||||
"sha.js@2.4.11": "2.4.12",
|
||||
"subscriptions-transport-ws>ws": "7.5.10",
|
||||
"vue": "3.5.22",
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@
|
|||
"devDependencies": {
|
||||
"@lezer/generator": "1.8.0",
|
||||
"@rollup/plugin-typescript": "12.1.4",
|
||||
"mocha": "11.7.2",
|
||||
"rollup": "4.52.2",
|
||||
"typescript": "5.9.2"
|
||||
"mocha": "11.7.4",
|
||||
"rollup": "4.52.5",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "hoppscotch-agent",
|
||||
"private": true,
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
@ -23,12 +23,12 @@
|
|||
"@iconify-json/lucide": "1.2.68",
|
||||
"@tauri-apps/cli": "^2.0.3",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/node": "24.3.0",
|
||||
"@types/node": "24.9.1",
|
||||
"@vitejs/plugin-vue": "5.1.4",
|
||||
"autoprefixer": "10.4.21",
|
||||
"postcss": "8.5.6",
|
||||
"tailwindcss": "3.4.16",
|
||||
"typescript": "5.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"unplugin-icons": "22.2.0",
|
||||
"unplugin-vue-components": "29.0.0",
|
||||
"vite": "6.3.6",
|
||||
|
|
|
|||
2260
packages/hoppscotch-agent/src-tauri/Cargo.lock
generated
2260
packages/hoppscotch-agent/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "hoppscotch-agent"
|
||||
version = "0.1.14"
|
||||
version = "0.1.15"
|
||||
description = "A cross-platform HTTP request agent for Hoppscotch for advanced request handling including custom headers, certificates, proxies, and local system integration."
|
||||
authors = ["AndrewBastin", "CuriousCorrelation"]
|
||||
edition = "2021"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
|
||||
"productName": "Hoppscotch Agent",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"identifier": "io.hoppscotch.agent",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
|
||||
"productName": "Hoppscotch Agent Portable",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"identifier": "io.hoppscotch.agent",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
|
|
|||
|
|
@ -4,5 +4,16 @@
|
|||
}
|
||||
|
||||
:80 :3170 {
|
||||
reverse_proxy localhost:8080
|
||||
@mock {
|
||||
header_regexp host Host ^[^.]+\.mock\..*$
|
||||
}
|
||||
|
||||
handle @mock {
|
||||
rewrite * /mock{uri}
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "hoppscotch-backend",
|
||||
"version": "2025.9.2",
|
||||
"version": "2025.10.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
|
@ -31,20 +31,21 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@apollo/server": "4.12.1",
|
||||
"@as-integrations/express5": "1.1.2",
|
||||
"@nestjs-modules/mailer": "2.0.2",
|
||||
"@nestjs/apollo": "13.1.0",
|
||||
"@nestjs/apollo": "13.2.1",
|
||||
"@nestjs/common": "11.1.6",
|
||||
"@nestjs/config": "4.0.2",
|
||||
"@nestjs/core": "11.1.6",
|
||||
"@nestjs/graphql": "13.1.0",
|
||||
"@nestjs/jwt": "11.0.0",
|
||||
"@nestjs/graphql": "13.2.0",
|
||||
"@nestjs/jwt": "11.0.1",
|
||||
"@nestjs/passport": "11.0.0",
|
||||
"@nestjs/platform-express": "11.1.6",
|
||||
"@nestjs/schedule": "6.0.1",
|
||||
"@nestjs/swagger": "11.2.0",
|
||||
"@nestjs/swagger": "11.2.1",
|
||||
"@nestjs/terminus": "11.0.0",
|
||||
"@nestjs/throttler": "6.4.0",
|
||||
"@prisma/client": "6.16.2",
|
||||
"@prisma/client": "6.17.1",
|
||||
"argon2": "0.44.0",
|
||||
"bcrypt": "6.0.0",
|
||||
"class-transformer": "0.5.1",
|
||||
|
|
@ -61,54 +62,54 @@
|
|||
"handlebars": "4.7.8",
|
||||
"io-ts": "2.2.22",
|
||||
"morgan": "1.10.1",
|
||||
"nodemailer": "7.0.6",
|
||||
"nodemailer": "7.0.9",
|
||||
"passport": "0.7.0",
|
||||
"passport-github2": "0.1.12",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-jwt": "4.0.1",
|
||||
"passport-local": "1.0.0",
|
||||
"passport-microsoft": "2.1.0",
|
||||
"posthog-node": "5.8.8",
|
||||
"prisma": "6.16.2",
|
||||
"posthog-node": "5.10.0",
|
||||
"prisma": "6.17.1",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rimraf": "6.0.1",
|
||||
"rxjs": "7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "3.3.1",
|
||||
"@eslint/js": "9.36.0",
|
||||
"@eslint/js": "9.37.0",
|
||||
"@nestjs/cli": "11.0.10",
|
||||
"@nestjs/schematics": "11.0.7",
|
||||
"@nestjs/schematics": "11.0.9",
|
||||
"@nestjs/testing": "11.1.6",
|
||||
"@relmify/jest-fp-ts": "2.1.1",
|
||||
"@types/bcrypt": "6.0.0",
|
||||
"@types/cookie-parser": "1.4.9",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/node": "24.5.2",
|
||||
"@types/nodemailer": "7.0.1",
|
||||
"@types/node": "24.9.1",
|
||||
"@types/nodemailer": "7.0.2",
|
||||
"@types/passport-github2": "1.2.9",
|
||||
"@types/passport-google-oauth20": "2.0.16",
|
||||
"@types/passport-jwt": "4.0.1",
|
||||
"@types/passport-microsoft": "2.1.0",
|
||||
"@types/supertest": "6.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.44.1",
|
||||
"@typescript-eslint/parser": "8.44.1",
|
||||
"cross-env": "10.0.0",
|
||||
"eslint": "9.36.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.1",
|
||||
"@typescript-eslint/parser": "8.46.1",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint": "9.37.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-prettier": "5.5.4",
|
||||
"globals": "16.4.0",
|
||||
"jest": "30.1.3",
|
||||
"jest": "30.2.0",
|
||||
"jest-mock-extended": "4.0.0",
|
||||
"prettier": "3.6.2",
|
||||
"source-map-support": "0.5.21",
|
||||
"supertest": "7.1.4",
|
||||
"ts-jest": "29.4.4",
|
||||
"ts-jest": "29.4.5",
|
||||
"ts-loader": "9.5.4",
|
||||
"ts-node": "10.9.2",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.9.2"
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "WorkspaceType" AS ENUM ('USER', 'TEAM');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MockServerAction" AS ENUM ('CREATED', 'DELETED', 'ACTIVATED', 'DEACTIVATED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MockServer" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"subdomain" TEXT NOT NULL,
|
||||
"creatorUid" TEXT,
|
||||
"collectionID" TEXT NOT NULL,
|
||||
"workspaceType" "WorkspaceType" NOT NULL,
|
||||
"workspaceID" TEXT NOT NULL,
|
||||
"delayInMs" INTEGER NOT NULL DEFAULT 0,
|
||||
"isPublic" BOOLEAN NOT NULL DEFAULT true,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"hitCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastHitAt" TIMESTAMPTZ(3),
|
||||
"createdOn" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedOn" TIMESTAMPTZ(3) NOT NULL,
|
||||
"deletedAt" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "MockServer_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MockServerLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"mockServerID" TEXT NOT NULL,
|
||||
"requestMethod" TEXT NOT NULL,
|
||||
"requestPath" TEXT NOT NULL,
|
||||
"requestHeaders" JSONB NOT NULL,
|
||||
"requestBody" JSONB,
|
||||
"requestQuery" JSONB,
|
||||
"responseStatus" INTEGER NOT NULL,
|
||||
"responseHeaders" JSONB NOT NULL,
|
||||
"responseBody" JSONB,
|
||||
"responseTime" INTEGER NOT NULL,
|
||||
"ipAddress" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"executedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "MockServerLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MockServerActivity" (
|
||||
"id" TEXT NOT NULL,
|
||||
"mockServerID" TEXT NOT NULL,
|
||||
"action" "MockServerAction" NOT NULL,
|
||||
"performedBy" TEXT,
|
||||
"performedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "MockServerActivity_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MockServer_subdomain_key" ON "MockServer"("subdomain");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MockServerLog_mockServerID_idx" ON "MockServerLog"("mockServerID");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MockServerLog_mockServerID_executedAt_idx" ON "MockServerLog"("mockServerID", "executedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MockServerActivity_mockServerID_idx" ON "MockServerActivity"("mockServerID");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MockServer" ADD CONSTRAINT "MockServer_creatorUid_fkey" FOREIGN KEY ("creatorUid") REFERENCES "User"("uid") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MockServerLog" ADD CONSTRAINT "MockServerLog_mockServerID_fkey" FOREIGN KEY ("mockServerID") REFERENCES "MockServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MockServerActivity" ADD CONSTRAINT "MockServerActivity_mockServerID_fkey" FOREIGN KEY ("mockServerID") REFERENCES "MockServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
|
||||
|
||||
-- Add mockExamples column to UserRequest
|
||||
ALTER TABLE "UserRequest"
|
||||
ADD COLUMN "mockExamples" JSONB;
|
||||
|
||||
-- Add mockExamples column to TeamRequest
|
||||
ALTER TABLE "TeamRequest"
|
||||
ADD COLUMN "mockExamples" JSONB;
|
||||
|
||||
-- Create function to sync mock examples
|
||||
CREATE OR REPLACE FUNCTION sync_mock_examples()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW."mockExamples" := jsonb_build_object(
|
||||
'examples',
|
||||
COALESCE(
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'key', key,
|
||||
'name', value->>'name',
|
||||
'endpoint', value->'originalRequest'->>'endpoint',
|
||||
'method', value->'originalRequest'->>'method',
|
||||
'headers', COALESCE(value->'originalRequest'->'headers', '[]'::jsonb),
|
||||
'statusCode', (value->>'code')::int,
|
||||
'statusText', value->>'status',
|
||||
'responseBody', value->>'body',
|
||||
'responseHeaders', COALESCE(value->'headers', '[]'::jsonb)
|
||||
)
|
||||
)
|
||||
FROM jsonb_each(NEW.request->'responses') AS responses(key, value)
|
||||
WHERE jsonb_typeof(NEW.request->'responses') = 'object'
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger for UserRequest
|
||||
CREATE TRIGGER trigger_sync_mock_examples_user_request
|
||||
BEFORE INSERT OR UPDATE OF request ON "UserRequest"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION sync_mock_examples();
|
||||
|
||||
-- Create trigger for TeamRequest
|
||||
CREATE TRIGGER trigger_sync_mock_examples_team_request
|
||||
BEFORE INSERT OR UPDATE OF request ON "TeamRequest"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION sync_mock_examples();
|
||||
|
||||
-- Backfill existing data for UserRequest
|
||||
UPDATE "UserRequest" SET request = request WHERE request IS NOT NULL;
|
||||
|
||||
-- Backfill existing data for TeamRequest
|
||||
UPDATE "TeamRequest" SET request = request WHERE request IS NOT NULL;
|
||||
|
||||
-- Add GIN indexes
|
||||
CREATE INDEX "idx_mock_examples_user_requests_gin" ON "UserRequest" USING GIN ("mockExamples");
|
||||
CREATE INDEX "idx_mock_examples_team_requests_gin" ON "TeamRequest" USING GIN ("mockExamples");
|
||||
|
|
@ -1,25 +1,25 @@
|
|||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-1.1.x", "debian-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
members TeamMember[]
|
||||
TeamInvitation TeamInvitation[]
|
||||
TeamCollection TeamCollection[]
|
||||
TeamRequest TeamRequest[]
|
||||
TeamEnvironment TeamEnvironment[]
|
||||
TeamInvitation TeamInvitation[]
|
||||
members TeamMember[]
|
||||
TeamRequest TeamRequest[]
|
||||
}
|
||||
|
||||
model TeamMember {
|
||||
id String @id @default(uuid()) // Membership ID
|
||||
id String @id @default(uuid())
|
||||
role TeamAccessRole
|
||||
userUid String
|
||||
teamID String
|
||||
|
|
@ -31,10 +31,10 @@ model TeamMember {
|
|||
model TeamInvitation {
|
||||
id String @id @default(cuid())
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
creatorUid String
|
||||
inviteeEmail String
|
||||
inviteeRole TeamAccessRole
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamID, inviteeEmail])
|
||||
@@index([teamID])
|
||||
|
|
@ -43,16 +43,16 @@ model TeamInvitation {
|
|||
model TeamCollection {
|
||||
id String @id @default(cuid())
|
||||
parentID String?
|
||||
data Json?
|
||||
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
||||
children TeamCollection[] @relation("TeamCollectionChildParent")
|
||||
requests TeamRequest[]
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
orderIndex Int
|
||||
createdOn DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamptz(3)
|
||||
data Json?
|
||||
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
||||
children TeamCollection[] @relation("TeamCollectionChildParent")
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
requests TeamRequest[]
|
||||
|
||||
@@unique([teamID, parentID, orderIndex])
|
||||
}
|
||||
|
|
@ -60,14 +60,15 @@ model TeamCollection {
|
|||
model TeamRequest {
|
||||
id String @id @default(cuid())
|
||||
collectionID String
|
||||
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
request Json
|
||||
mockExamples Json?
|
||||
orderIndex Int
|
||||
createdOn DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamptz(3)
|
||||
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamID, collectionID, orderIndex])
|
||||
}
|
||||
|
|
@ -75,21 +76,21 @@ model TeamRequest {
|
|||
model Shortcode {
|
||||
id String @id @unique
|
||||
request Json
|
||||
embedProperties Json?
|
||||
creatorUid String?
|
||||
User User? @relation(fields: [creatorUid], references: [uid])
|
||||
createdOn DateTime @default(now()) @db.Timestamptz(3)
|
||||
embedProperties Json?
|
||||
updatedOn DateTime @default(now()) @updatedAt @db.Timestamptz(3)
|
||||
User User? @relation(fields: [creatorUid], references: [uid])
|
||||
|
||||
@@unique(fields: [id, creatorUid], name: "creator_uid_shortcode_unique")
|
||||
@@unique([id, creatorUid], name: "creator_uid_shortcode_unique")
|
||||
}
|
||||
|
||||
model TeamEnvironment {
|
||||
id String @id @default(cuid())
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
variables Json
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model User {
|
||||
|
|
@ -99,100 +100,97 @@ model User {
|
|||
photoURL String?
|
||||
isAdmin Boolean @default(false)
|
||||
refreshToken String?
|
||||
providerAccounts Account[]
|
||||
VerificationToken VerificationToken[]
|
||||
settings UserSettings?
|
||||
UserHistory UserHistory[]
|
||||
UserEnvironments UserEnvironment[]
|
||||
userCollections UserCollection[]
|
||||
userRequests UserRequest[]
|
||||
currentRESTSession Json?
|
||||
currentGQLSession Json?
|
||||
createdOn DateTime @default(now()) @db.Timestamptz(3)
|
||||
lastLoggedOn DateTime? @db.Timestamptz(3)
|
||||
lastActiveOn DateTime? @db.Timestamptz(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamptz(3)
|
||||
providerAccounts Account[]
|
||||
invitedUsers InvitedUsers[]
|
||||
shortcodes Shortcode[]
|
||||
mockServers MockServer[]
|
||||
personalAccessTokens PersonalAccessToken[]
|
||||
shortcodes Shortcode[]
|
||||
userCollections UserCollection[]
|
||||
UserEnvironments UserEnvironment[]
|
||||
UserHistory UserHistory[]
|
||||
userRequests UserRequest[]
|
||||
settings UserSettings?
|
||||
VerificationToken VerificationToken[]
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [uid], onDelete: Cascade)
|
||||
provider String
|
||||
providerAccountId String
|
||||
providerRefreshToken String?
|
||||
providerAccessToken String?
|
||||
providerScope String?
|
||||
loggedIn DateTime @default(now()) @db.Timestamptz(3)
|
||||
user User @relation(fields: [userId], references: [uid], onDelete: Cascade)
|
||||
|
||||
@@unique(fields: [provider, providerAccountId], name: "verifyProviderAccount")
|
||||
@@unique([provider, providerAccountId], name: "verifyProviderAccount")
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
deviceIdentifier String
|
||||
token String @unique @default(cuid())
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
expiresOn DateTime @db.Timestamptz(3)
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
|
||||
@@unique(fields: [deviceIdentifier, token], name: "passwordless_deviceIdentifier_tokens")
|
||||
@@unique([deviceIdentifier, token], name: "passwordless_deviceIdentifier_tokens")
|
||||
}
|
||||
|
||||
model UserSettings {
|
||||
id String @id @default(cuid())
|
||||
userUid String @unique
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
properties Json
|
||||
updatedOn DateTime @updatedAt @db.Timestamptz(3)
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model UserHistory {
|
||||
id String @id @default(cuid())
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
reqType ReqType
|
||||
request Json
|
||||
responseMetadata Json
|
||||
isStarred Boolean
|
||||
executedOn DateTime @default(now()) @db.Timestamptz(3)
|
||||
}
|
||||
|
||||
enum ReqType {
|
||||
REST
|
||||
GQL
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model UserEnvironment {
|
||||
id String @id @default(cuid())
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
name String?
|
||||
variables Json
|
||||
isGlobal Boolean
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model InvitedUsers {
|
||||
adminUid String
|
||||
user User @relation(fields: [adminUid], references: [uid], onDelete: Cascade)
|
||||
adminEmail String
|
||||
inviteeEmail String @unique
|
||||
invitedOn DateTime @default(now()) @db.Timestamptz(3)
|
||||
user User @relation(fields: [adminUid], references: [uid], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model UserRequest {
|
||||
id String @id @default(cuid())
|
||||
userCollection UserCollection @relation(fields: [collectionID], references: [id])
|
||||
collectionID String
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
title String
|
||||
request Json
|
||||
mockExamples Json?
|
||||
type ReqType
|
||||
orderIndex Int
|
||||
createdOn DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamptz(3)
|
||||
userCollection UserCollection @relation(fields: [collectionID], references: [id])
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
|
||||
@@unique([userUid, collectionID, orderIndex])
|
||||
}
|
||||
|
|
@ -200,46 +198,40 @@ model UserRequest {
|
|||
model UserCollection {
|
||||
id String @id @default(cuid())
|
||||
parentID String?
|
||||
parent UserCollection? @relation("ParentUserCollection", fields: [parentID], references: [id], onDelete: Cascade)
|
||||
children UserCollection[] @relation("ParentUserCollection")
|
||||
requests UserRequest[]
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
title String
|
||||
data Json?
|
||||
orderIndex Int
|
||||
type ReqType
|
||||
createdOn DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamptz(3)
|
||||
data Json?
|
||||
parent UserCollection? @relation("ParentUserCollection", fields: [parentID], references: [id], onDelete: Cascade)
|
||||
children UserCollection[] @relation("ParentUserCollection")
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
requests UserRequest[]
|
||||
|
||||
@@unique([userUid, parentID, orderIndex])
|
||||
}
|
||||
|
||||
enum TeamAccessRole {
|
||||
OWNER
|
||||
VIEWER
|
||||
EDITOR
|
||||
}
|
||||
|
||||
model InfraConfig {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
value String?
|
||||
lastSyncedEnvFileValue String? // deprecated, use `value` instead
|
||||
isEncrypted Boolean @default(false) // Use case: Let's say, Admin wants to store a Secret Key, but doesn't want to store it in plain text in `value` column
|
||||
createdOn DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamptz(3)
|
||||
isEncrypted Boolean @default(false)
|
||||
lastSyncedEnvFileValue String?
|
||||
}
|
||||
|
||||
model PersonalAccessToken {
|
||||
id String @id @default(cuid())
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
label String
|
||||
token String @unique @default(uuid())
|
||||
expiresOn DateTime? @db.Timestamptz(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamptz(3)
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model InfraToken {
|
||||
|
|
@ -251,3 +243,79 @@ model InfraToken {
|
|||
createdOn DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedOn DateTime @default(now()) @db.Timestamptz(3)
|
||||
}
|
||||
|
||||
model MockServer {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
subdomain String @unique
|
||||
creatorUid String?
|
||||
collectionID String
|
||||
workspaceType WorkspaceType
|
||||
workspaceID String
|
||||
delayInMs Int @default(0)
|
||||
isPublic Boolean @default(true)
|
||||
isActive Boolean @default(true)
|
||||
hitCount Int @default(0)
|
||||
lastHitAt DateTime? @db.Timestamptz(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamptz(3)
|
||||
deletedAt DateTime? @db.Timestamptz(3)
|
||||
user User? @relation(fields: [creatorUid], references: [uid], onDelete: SetNull)
|
||||
requestLogs MockServerLog[]
|
||||
activityHistory MockServerActivity[]
|
||||
}
|
||||
|
||||
model MockServerLog {
|
||||
id String @id @default(cuid())
|
||||
mockServerID String
|
||||
requestMethod String
|
||||
requestPath String
|
||||
requestHeaders Json
|
||||
requestBody Json?
|
||||
requestQuery Json?
|
||||
responseStatus Int
|
||||
responseHeaders Json
|
||||
responseBody Json?
|
||||
responseTime Int
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
executedAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
mockServer MockServer @relation(fields: [mockServerID], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([mockServerID])
|
||||
@@index([mockServerID, executedAt])
|
||||
}
|
||||
|
||||
model MockServerActivity {
|
||||
id String @id @default(cuid())
|
||||
mockServerID String
|
||||
action MockServerAction
|
||||
performedBy String?
|
||||
performedAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
mockServer MockServer @relation(fields: [mockServerID], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([mockServerID])
|
||||
}
|
||||
|
||||
enum WorkspaceType {
|
||||
USER
|
||||
TEAM
|
||||
}
|
||||
|
||||
enum ReqType {
|
||||
REST
|
||||
GQL
|
||||
}
|
||||
|
||||
enum TeamAccessRole {
|
||||
OWNER
|
||||
VIEWER
|
||||
EDITOR
|
||||
}
|
||||
|
||||
enum MockServerAction {
|
||||
CREATED
|
||||
DELETED
|
||||
ACTIVATED
|
||||
DEACTIVATED
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { InfraTokenModule } from './infra-token/infra-token.module';
|
|||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { PubSubModule } from './pubsub/pubsub.module';
|
||||
import { SortModule } from './orchestration/sort/sort.module';
|
||||
import { MockServerModule } from './mock-server/mock-server.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -124,6 +125,7 @@ import { SortModule } from './orchestration/sort/sort.module';
|
|||
AccessTokenModule,
|
||||
InfraTokenModule,
|
||||
SortModule,
|
||||
MockServerModule,
|
||||
],
|
||||
providers: [
|
||||
GQLComplexityPlugin,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { RTCookie } from 'src/decorators/rt-cookie.decorator';
|
|||
import { AuthProvider, authCookieHandler, authProviderCheck } from './helper';
|
||||
import { GoogleSSOGuard } from './guards/google-sso.guard';
|
||||
import { GithubSSOGuard } from './guards/github-sso.guard';
|
||||
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
|
||||
import { MicrosoftSSOGuard } from './guards/microsoft-sso.guard';
|
||||
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
|
|
|
|||
|
|
@ -22,19 +22,19 @@ export const ADMIN_CAN_NOT_BE_DELETED =
|
|||
* Token Authorization failed (Check 'Authorization' Header)
|
||||
* (GqlAuthGuard)
|
||||
*/
|
||||
export const AUTH_FAIL = 'auth/fail';
|
||||
export const AUTH_FAIL = 'auth/fail' as const;
|
||||
|
||||
/**
|
||||
* Invalid JSON
|
||||
* (Utils)
|
||||
*/
|
||||
export const JSON_INVALID = 'json_invalid';
|
||||
export const JSON_INVALID = 'json_invalid' as const;
|
||||
|
||||
/**
|
||||
* Auth Provider not specified
|
||||
* (Auth)
|
||||
*/
|
||||
export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified';
|
||||
export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified' as const;
|
||||
|
||||
/**
|
||||
* Email not provided by OAuth provider
|
||||
|
|
@ -168,7 +168,7 @@ export const TEAM_NOT_REQUIRED_ROLE = 'team/not_required_role' as const;
|
|||
* Team name validation failure
|
||||
* (TeamService)
|
||||
*/
|
||||
export const TEAM_NAME_INVALID = 'team/name_invalid';
|
||||
export const TEAM_NAME_INVALID = 'team/name_invalid' as const;
|
||||
|
||||
/**
|
||||
* Couldn't find the sync data from the user
|
||||
|
|
@ -432,7 +432,6 @@ export const USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXISTS =
|
|||
*/
|
||||
export const USER_ENVIRONMENT_GLOBAL_ENV_EXISTS =
|
||||
'user_environment/global_env_already_exists' as const;
|
||||
/*
|
||||
|
||||
/**
|
||||
* User environment doesn't exist for the user
|
||||
|
|
@ -440,7 +439,6 @@ export const USER_ENVIRONMENT_GLOBAL_ENV_EXISTS =
|
|||
*/
|
||||
export const USER_ENVIRONMENT_ENV_DOES_NOT_EXISTS =
|
||||
'user_environment/user_env_does_not_exists' as const;
|
||||
/*
|
||||
|
||||
/**
|
||||
* Cannot delete the global user environment
|
||||
|
|
@ -448,7 +446,6 @@ export const USER_ENVIRONMENT_ENV_DOES_NOT_EXISTS =
|
|||
*/
|
||||
export const USER_ENVIRONMENT_GLOBAL_ENV_DELETION_FAILED =
|
||||
'user_environment/user_env_global_env_deletion_failed' as const;
|
||||
/*
|
||||
|
||||
/**
|
||||
* User environment is not a global environment
|
||||
|
|
@ -456,7 +453,6 @@ export const USER_ENVIRONMENT_GLOBAL_ENV_DELETION_FAILED =
|
|||
*/
|
||||
export const USER_ENVIRONMENT_IS_NOT_GLOBAL =
|
||||
'user_environment/user_env_is_not_global' as const;
|
||||
/*
|
||||
|
||||
/**
|
||||
* User environment update failed
|
||||
|
|
@ -464,7 +460,6 @@ export const USER_ENVIRONMENT_IS_NOT_GLOBAL =
|
|||
*/
|
||||
export const USER_ENVIRONMENT_UPDATE_FAILED =
|
||||
'user_environment/user_env_update_failed' as const;
|
||||
/*
|
||||
|
||||
/**
|
||||
* User environment invalid environment name
|
||||
|
|
@ -472,7 +467,6 @@ export const USER_ENVIRONMENT_UPDATE_FAILED =
|
|||
*/
|
||||
export const USER_ENVIRONMENT_INVALID_ENVIRONMENT_NAME =
|
||||
'user_environment/user_env_invalid_env_name' as const;
|
||||
/*
|
||||
|
||||
/**
|
||||
* User history not found
|
||||
|
|
@ -884,3 +878,52 @@ export const INFRA_TOKEN_EXPIRED = 'infra_token/expired';
|
|||
* (InfraTokenService)
|
||||
*/
|
||||
export const INFRA_TOKEN_CREATOR_NOT_FOUND = 'infra_token/creator_not_found';
|
||||
|
||||
/**
|
||||
* Mock server not found
|
||||
* (MockServerService)
|
||||
*/
|
||||
export const MOCK_SERVER_NOT_FOUND = 'mock_server/not_found';
|
||||
|
||||
/**
|
||||
* Mock server invalid collection
|
||||
* (MockServerService)
|
||||
*/
|
||||
export const MOCK_SERVER_INVALID_COLLECTION = 'mock_server/invalid_collection';
|
||||
|
||||
/**
|
||||
* Mock server already exists for this collection
|
||||
* (MockServerService)
|
||||
*/
|
||||
export const MOCK_SERVER_ALREADY_EXISTS = 'mock_server/already_exists';
|
||||
|
||||
/**
|
||||
* Mock server creation failed
|
||||
* (MockServerService)
|
||||
*/
|
||||
export const MOCK_SERVER_CREATION_FAILED = 'mock_server/creation_failed';
|
||||
|
||||
/**
|
||||
* Mock server update failed
|
||||
* (MockServerService)
|
||||
*/
|
||||
export const MOCK_SERVER_UPDATE_FAILED = 'mock_server/update_failed';
|
||||
|
||||
/**
|
||||
* Mock server deletion failed
|
||||
* (MockServerService)
|
||||
*/
|
||||
export const MOCK_SERVER_DELETION_FAILED = 'mock_server/deletion_failed';
|
||||
|
||||
/**
|
||||
* Mock server log not found
|
||||
* (MockServerService)
|
||||
*/
|
||||
export const MOCK_SERVER_LOG_NOT_FOUND = 'mock_server/log_not_found';
|
||||
|
||||
/**
|
||||
* Mock server log deletion failed
|
||||
* (MockServerService)
|
||||
*/
|
||||
export const MOCK_SERVER_LOG_DELETION_FAILED =
|
||||
'mock_server/log_deletion_failed';
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { InfraConfigResolver } from './infra-config/infra-config.resolver';
|
|||
import { InfraTokenResolver } from './infra-token/infra-token.resolver';
|
||||
import { SortTeamCollectionResolver } from './orchestration/sort/sort-team-collection.resolver';
|
||||
import { SortUserCollectionResolver } from './orchestration/sort/sort-user-collection.resolver';
|
||||
import { MockServerResolver } from './mock-server/mock-server.resolver';
|
||||
|
||||
/**
|
||||
* All the resolvers present in the application.
|
||||
|
|
@ -66,6 +67,7 @@ const RESOLVERS = [
|
|||
InfraTokenResolver,
|
||||
SortUserCollectionResolver,
|
||||
SortTeamCollectionResolver,
|
||||
MockServerResolver,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -127,6 +127,11 @@ export async function getDefaultInfraConfigs(): Promise<DefaultInfraConfig[]> {
|
|||
value: encrypt(randomBytes(32).toString('hex')),
|
||||
isEncrypted: true,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.SESSION_COOKIE_NAME,
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.TOKEN_SALT_COMPLEXITY,
|
||||
value: '10',
|
||||
|
|
@ -302,6 +307,11 @@ export async function getDefaultInfraConfigs(): Promise<DefaultInfraConfig[]> {
|
|||
value: 'true',
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MOCK_SERVER_WILDCARD_DOMAIN,
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
];
|
||||
|
||||
return infraConfigDefaultObjs;
|
||||
|
|
@ -370,14 +380,16 @@ export async function isInfraConfigTablePopulated(): Promise<boolean> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Stop the app after 5 seconds
|
||||
* (Docker will re-start the app)
|
||||
* Stop the app after 5 seconds with graceful shutdown
|
||||
* (Sends SIGTERM to trigger NestJS graceful shutdown, then Docker container stops)
|
||||
*/
|
||||
export function stopApp() {
|
||||
console.log('Stopping app in 5 seconds...');
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('Stopping app now...');
|
||||
console.log('Stopping app now with graceful shutdown...');
|
||||
// Send SIGTERM to the current process to trigger graceful shutdown
|
||||
// This will call app.close() which triggers onModuleDestroy lifecycle hooks
|
||||
process.kill(process.pid, 'SIGTERM');
|
||||
}, 5000);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -615,21 +615,26 @@ export class InfraConfigService implements OnModuleInit {
|
|||
InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
|
||||
];
|
||||
try {
|
||||
const infraConfigDefaultObjs = await getDefaultInfraConfigs();
|
||||
const updatedInfraConfigDefaultObjs = infraConfigDefaultObjs.filter(
|
||||
const defaultConfigs = await getDefaultInfraConfigs();
|
||||
|
||||
const configsToReset = defaultConfigs.filter(
|
||||
(p) => RESET_EXCLUSION_LIST.includes(p.name) === false,
|
||||
);
|
||||
|
||||
// Update ONBOARDING_COMPLETED value to false
|
||||
const onboardingCompletedIndex = configsToReset.findIndex(
|
||||
(p) => p.name === InfraConfigEnum.ONBOARDING_COMPLETED,
|
||||
);
|
||||
if (onboardingCompletedIndex !== -1) {
|
||||
configsToReset[onboardingCompletedIndex].value = 'false';
|
||||
}
|
||||
|
||||
await this.prisma.infraConfig.deleteMany({
|
||||
where: {
|
||||
name: {
|
||||
in: updatedInfraConfigDefaultObjs.map((p) => p.name),
|
||||
},
|
||||
},
|
||||
where: { name: { in: configsToReset.map((p) => p.name) } },
|
||||
});
|
||||
|
||||
await this.prisma.infraConfig.createMany({
|
||||
data: updatedInfraConfigDefaultObjs,
|
||||
data: configsToReset,
|
||||
});
|
||||
|
||||
stopApp();
|
||||
|
|
@ -673,6 +678,17 @@ export class InfraConfigService implements OnModuleInit {
|
|||
if (!validateSMTPEmail(value)) return fail();
|
||||
break;
|
||||
|
||||
case InfraConfigEnum.MOCK_SERVER_WILDCARD_DOMAIN:
|
||||
if (!value) break; // Allow empty value
|
||||
|
||||
if (!value.startsWith('*.mock.')) return fail();
|
||||
// Validate domain format after *.mock.
|
||||
const domainPart = value.substring(7); // Remove '*.mock.'
|
||||
const domainRegex =
|
||||
/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
if (!domainPart || !domainRegex.test(domainPart)) return fail();
|
||||
break;
|
||||
|
||||
case InfraConfigEnum.MAILER_SMTP_HOST:
|
||||
case InfraConfigEnum.MAILER_SMTP_PORT:
|
||||
case InfraConfigEnum.MAILER_SMTP_USER:
|
||||
|
|
@ -718,6 +734,11 @@ export class InfraConfigService implements OnModuleInit {
|
|||
return fail();
|
||||
break;
|
||||
|
||||
case InfraConfigEnum.SESSION_COOKIE_NAME:
|
||||
// Allow empty to fall back to default; otherwise enforce allowed characters
|
||||
if (value && !/^[A-Za-z0-9_-]+$/.test(value)) return fail();
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
|||
import { InfraTokenModule } from './infra-token/infra-token.module';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
|
||||
function setupSwagger(app, isProduction: boolean) {
|
||||
function setupSwagger(app: NestExpressApplication, isProduction: boolean): void {
|
||||
const swaggerDocPath = '/api-docs';
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
|
|
@ -49,8 +49,13 @@ async function bootstrap() {
|
|||
|
||||
app.use(
|
||||
session({
|
||||
// Allow overriding the default cookie name 'connect.sid' (which contains a dot).
|
||||
// Some proxies/load balancers (like older Kong versions) cannot hash cookie names with dots,
|
||||
// so we allow setting an alternative name via the INFRA.SESSION_COOKIE_NAME configuration.
|
||||
name:
|
||||
configService.get<string>('INFRA.SESSION_COOKIE_NAME') || 'connect.sid',
|
||||
secret:
|
||||
configService.get('INFRA.SESSION_SECRET') ||
|
||||
configService.get<string>('INFRA.SESSION_SECRET') ||
|
||||
crypto.randomBytes(16).toString('hex'),
|
||||
}),
|
||||
);
|
||||
|
|
@ -99,8 +104,10 @@ async function bootstrap() {
|
|||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
console.info('SIGTERM signal received');
|
||||
console.info('SIGTERM signal received, initiating graceful shutdown...');
|
||||
await app.close();
|
||||
console.info('Application closed successfully');
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,274 @@
|
|||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { MockServerService } from './mock-server.service';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { AccessTokenService } from 'src/access-token/access-token.service';
|
||||
import { TeamService } from 'src/team/team.service';
|
||||
import { WorkspaceType } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Guard to extract and validate mock server ID from either:
|
||||
* 1. Subdomain pattern: mock-server-id.mock.hopp.io/product
|
||||
* 2. Route pattern: backend.hopp.io/mock/mock-server-id/product
|
||||
*/
|
||||
@Injectable()
|
||||
export class MockRequestGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly mockServerService: MockServerService,
|
||||
private readonly accessTokenService: AccessTokenService,
|
||||
private readonly teamService: TeamService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
// Extract mock server ID from either subdomain or route
|
||||
const mockServerSubdomain = this.extractMockServerSubdomain(request);
|
||||
|
||||
if (!mockServerSubdomain) {
|
||||
throw new BadRequestException(
|
||||
'Invalid mock server request. Mock server ID not found in subdomain or path.',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate mock server exists (including inactive ones)
|
||||
const mockServerResult =
|
||||
await this.mockServerService.getMockServerBySubdomain(
|
||||
mockServerSubdomain,
|
||||
true, // includeInactive = true
|
||||
);
|
||||
|
||||
if (E.isLeft(mockServerResult)) {
|
||||
console.warn(
|
||||
`Mock server lookup failed for subdomain: ${String(mockServerSubdomain).replace(/\r|\n/g, "")}, error: ${mockServerResult.left}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
`Mock server '${mockServerSubdomain}' not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const mockServer = mockServerResult.right;
|
||||
|
||||
// Check if mock server is active and throw proper error if not
|
||||
if (!mockServer.isActive) {
|
||||
throw new BadRequestException(
|
||||
`Mock server '${mockServerSubdomain}' is currently inactive`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!mockServer.isPublic) {
|
||||
const apiKey = request.get('x-api-key');
|
||||
|
||||
if (!apiKey) {
|
||||
throw new BadRequestException(
|
||||
'API key is required. Please provide x-api-key header.',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate the Personal Access Token (PAT)
|
||||
await this.validatePAT(apiKey, mockServer);
|
||||
}
|
||||
|
||||
// Attach mock server info to request for downstream use
|
||||
(request as any).mockServer = mockServer;
|
||||
(request as any).mockServerId = mockServer.id;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract mock server ID from request using either subdomain or route-based pattern
|
||||
*
|
||||
* Supports two patterns:
|
||||
* 1. Subdomain: mock-server-id.mock.hopp.io/product → mock-server-id (from host)
|
||||
* After Caddy rewrite: path becomes /mock/product
|
||||
* 2. Route: backend.hopp.io/mock/mock-server-id/product → mock-server-id (from path)
|
||||
*
|
||||
* @param request Express request object
|
||||
* @returns Mock server ID or null if not found
|
||||
*/
|
||||
private extractMockServerSubdomain(request: Request): string | null {
|
||||
const host = request.get('host') || '';
|
||||
const path = request.path || '/';
|
||||
|
||||
// Try subdomain pattern first (Option 1)
|
||||
// For subdomain-based requests, Caddy rewrites path to /mock/...
|
||||
// but the mock server ID comes from the subdomain, not the path
|
||||
const subdomainId = this.extractFromSubdomain(host);
|
||||
if (subdomainId) {
|
||||
return subdomainId;
|
||||
}
|
||||
|
||||
// Try route-based pattern (Option 2)
|
||||
// Only use route extraction if there's no subdomain match
|
||||
// Route pattern: /mock/mock-server-id/...
|
||||
const routeId = this.extractFromRoute(path);
|
||||
if (routeId) {
|
||||
return routeId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract mock server ID from subdomain pattern
|
||||
* Supports: mock-server-id.mock.hopp.io or mock-server-id.mock.localhost
|
||||
*
|
||||
* @param host Host header value
|
||||
* @returns Mock server ID or null
|
||||
*/
|
||||
private extractFromSubdomain(host: string): string | null {
|
||||
// Remove port if present
|
||||
const hostname = host.split(':')[0];
|
||||
|
||||
// Split by dots
|
||||
const parts = hostname.split('.');
|
||||
|
||||
// Check if this is a mock subdomain pattern
|
||||
// For: mock-server-id.mock.hopp.io → ['mock-server-id', 'mock', 'hopp', 'io']
|
||||
// For: mock-server-id.mock.localhost → ['mock-server-id', 'mock', 'localhost']
|
||||
|
||||
if (parts.length >= 3) {
|
||||
// Check if second part is 'mock'
|
||||
if (parts[1] === 'mock') {
|
||||
const mockServerId = parts[0];
|
||||
|
||||
// Validate it's not empty and follows a reasonable pattern
|
||||
if (mockServerId && mockServerId.length > 0) {
|
||||
return mockServerId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also support: mock-server-id.localhost (for simpler local dev)
|
||||
if (parts.length === 2 && parts[1] === 'localhost') {
|
||||
const mockServerId = parts[0];
|
||||
if (mockServerId && mockServerId.length > 0) {
|
||||
return mockServerId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract mock server ID from route pattern
|
||||
* Supports: /mock/mock-server-id/product → mock-server-id
|
||||
* Note: Caddy prepends /mock to subdomain requests, so subdomain pattern
|
||||
* mock-server-id.mock.hopp.io/product becomes /mock/product
|
||||
*
|
||||
* @param path Request path
|
||||
* @returns Mock server ID or null
|
||||
*/
|
||||
private extractFromRoute(path: string): string | null {
|
||||
// Pattern: /mock/mock-server-id/...
|
||||
// We need to match: /mock/{id} or /mock/{id}/...
|
||||
|
||||
const mockPathRegex = /^\/mock\/([^\/]+)/;
|
||||
const match = path.match(mockPathRegex);
|
||||
|
||||
if (match && match[1]) {
|
||||
const mockServerId = match[1];
|
||||
|
||||
// Validate it's not empty and not the word 'mock' itself
|
||||
if (mockServerId && mockServerId !== 'mock' && mockServerId.length > 0) {
|
||||
return mockServerId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Personal Access Token (PAT) for private mock server access
|
||||
*
|
||||
* Rules:
|
||||
* - If mock server is in USER workspace: PAT must belong to that user
|
||||
* - If mock server is in TEAM workspace: PAT creator must be a member of that team
|
||||
*
|
||||
* @param apiKey The x-api-key header value (PAT)
|
||||
* @param mockServer The mock server being accessed
|
||||
* @throws UnauthorizedException if PAT is invalid or user lacks access
|
||||
*/
|
||||
private async validatePAT(apiKey: string, mockServer: any): Promise<void> {
|
||||
// Get the PAT and associated user
|
||||
const patResult = await this.accessTokenService.getUserPAT(apiKey);
|
||||
|
||||
if (E.isLeft(patResult)) {
|
||||
throw new UnauthorizedException(
|
||||
'Invalid or expired API key. Please provide a valid Personal Access Token.',
|
||||
);
|
||||
}
|
||||
|
||||
const pat = patResult.right;
|
||||
const userUid = pat.user.uid;
|
||||
|
||||
// Check if PAT has expired
|
||||
if (pat.expiresOn !== null && new Date() > pat.expiresOn) {
|
||||
throw new UnauthorizedException(
|
||||
'API key has expired. Please generate a new Personal Access Token.',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate based on workspace type
|
||||
if (mockServer.workspaceType === WorkspaceType.USER) {
|
||||
// For USER workspace: PAT must belong to the workspace owner
|
||||
if (userUid !== mockServer.workspaceID) {
|
||||
throw new UnauthorizedException(
|
||||
'Access denied. This Personal Access Token does not have permission to access this mock server.',
|
||||
);
|
||||
}
|
||||
} else if (mockServer.workspaceType === WorkspaceType.TEAM) {
|
||||
// For TEAM workspace: PAT creator must be a member of the team
|
||||
const teamMember = await this.teamService.getTeamMember(
|
||||
mockServer.workspaceID,
|
||||
userUid,
|
||||
);
|
||||
|
||||
if (!teamMember) {
|
||||
throw new UnauthorizedException(
|
||||
'Access denied. You must be a member of the team to access this mock server.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new BadRequestException('Invalid workspace type for mock server.');
|
||||
}
|
||||
|
||||
// Update last used timestamp for the PAT
|
||||
await this.accessTokenService.updateLastUsedForPAT(apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actual path without the /mock/mock-server-id prefix
|
||||
* This is useful for route-based pattern to get the actual endpoint path
|
||||
*
|
||||
* @param fullPath Full request path
|
||||
* @param mockServerId Mock server ID
|
||||
* @returns Clean path for the mock endpoint
|
||||
*/
|
||||
static getCleanPath(fullPath: string, mockServerId: string): string {
|
||||
// If route-based: /mock/mock-server-id/product → /product
|
||||
const routePrefix = `/mock/${mockServerId}`;
|
||||
if (fullPath.startsWith(routePrefix)) {
|
||||
const cleanPath = fullPath.substring(routePrefix.length);
|
||||
return cleanPath || '/';
|
||||
}
|
||||
|
||||
// If subdomain-based: Caddy rewrites to /mock/product → /product
|
||||
// Strip the /mock prefix added by Caddy
|
||||
if (fullPath.startsWith('/mock/')) {
|
||||
const cleanPath = fullPath.substring(5); // Remove '/mock'
|
||||
return cleanPath || '/';
|
||||
}
|
||||
|
||||
// Fallback: return as-is
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { MockServer as dbMockServer, MockServerAction } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class MockServerAnalyticsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Record mock server activity
|
||||
* @param mockServer The mock server database object
|
||||
* @param action The action being performed (CREATED, ACTIVATED, DEACTIVATED, DELETED, UPDATED)
|
||||
* @param performedBy Optional userUid who performed the action
|
||||
*/
|
||||
async recordActivity(
|
||||
mockServer: dbMockServer,
|
||||
action: MockServerAction,
|
||||
performedBy?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Skip if trying to activate an already active server
|
||||
if (action === MockServerAction.ACTIVATED && mockServer.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if trying to deactivate an already inactive server
|
||||
if (action === MockServerAction.DEACTIVATED && !mockServer.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.mockServerActivity.create({
|
||||
data: {
|
||||
mockServerID: mockServer.id,
|
||||
action: action,
|
||||
performedBy: performedBy || null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Log error but don't throw - analytics shouldn't break main flow
|
||||
console.error('Failed to record mock server activity:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { Request, Response } from 'express';
|
||||
import { MockServer } from '@prisma/client';
|
||||
import { MockServerService } from './mock-server.service';
|
||||
|
||||
@Injectable()
|
||||
export class MockServerLoggingInterceptor implements NestInterceptor {
|
||||
constructor(private readonly mockServerService: MockServerService) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const httpContext = context.switchToHttp();
|
||||
const request = httpContext.getRequest<Request>();
|
||||
const response = httpContext.getResponse<Response>();
|
||||
|
||||
// Capture request start time
|
||||
const startTime = Date.now();
|
||||
|
||||
// Extract mock server info (attached by MockRequestGuard)
|
||||
const mockServer = (request as any).mockServer as MockServer;
|
||||
const mockServerId = (request as any).mockServerId as string;
|
||||
|
||||
// If no mock server info, skip logging
|
||||
if (!mockServer || !mockServerId) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
// Capture request details
|
||||
const requestMethod = request.method;
|
||||
const requestPath = request.path;
|
||||
const requestHeaders = this.extractHeaders(request);
|
||||
const requestBody = request.body || {};
|
||||
const requestQuery = this.extractQueryParams(request);
|
||||
|
||||
if (!requestBody || typeof requestBody !== 'object') {
|
||||
console.warn('Request body is not properly parsed');
|
||||
}
|
||||
|
||||
// Extract client info
|
||||
const ipAddress =
|
||||
(request.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
|
||||
request.socket.remoteAddress ||
|
||||
undefined;
|
||||
const userAgent = request.headers['user-agent'] as string | undefined;
|
||||
|
||||
// Capture response - use finalize to ensure logging happens regardless of success/error
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: () => {
|
||||
// Success case - log after response is sent
|
||||
const responseTime = Date.now() - startTime;
|
||||
const responseStatus = response.statusCode || 200;
|
||||
const responseHeaders = this.extractResponseHeaders(response);
|
||||
|
||||
// Log the request asynchronously (fire and forget)
|
||||
this.mockServerService
|
||||
.logRequest({
|
||||
mockServerID: mockServerId,
|
||||
requestMethod,
|
||||
requestPath,
|
||||
requestHeaders,
|
||||
requestBody,
|
||||
requestQuery,
|
||||
responseStatus,
|
||||
responseHeaders,
|
||||
responseTime,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
})
|
||||
.catch((err) => console.error('Failed to log mock request:', err));
|
||||
|
||||
// Increment hit count asynchronously (fire and forget)
|
||||
this.mockServerService
|
||||
.incrementHitCount(mockServerId)
|
||||
.catch((err) =>
|
||||
console.error('Failed to increment hit count:', err),
|
||||
);
|
||||
},
|
||||
error: (error) => {
|
||||
// Error case - log the error but let it propagate to user
|
||||
const responseTime = Date.now() - startTime;
|
||||
const responseStatus = error.status || 500;
|
||||
|
||||
// Log error response asynchronously
|
||||
this.mockServerService
|
||||
.logRequest({
|
||||
mockServerID: mockServerId,
|
||||
requestMethod,
|
||||
requestPath,
|
||||
requestHeaders,
|
||||
requestBody,
|
||||
requestQuery,
|
||||
responseStatus,
|
||||
responseHeaders: {},
|
||||
responseTime,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
})
|
||||
.catch((err) =>
|
||||
console.error('Failed to log mock request error:', err),
|
||||
);
|
||||
|
||||
// Still increment hit count even for errors
|
||||
this.mockServerService
|
||||
.incrementHitCount(mockServerId)
|
||||
.catch((err) =>
|
||||
console.error('Failed to increment hit count:', err),
|
||||
);
|
||||
|
||||
// Error will automatically propagate to user
|
||||
// No need to re-throw, tap operator handles this
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract request headers as a plain object
|
||||
*/
|
||||
private extractHeaders(request: Request): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
Object.keys(request.headers).forEach((key) => {
|
||||
const value = request.headers[key];
|
||||
if (typeof value === 'string') {
|
||||
headers[key.toLowerCase()] = value;
|
||||
} else if (Array.isArray(value)) {
|
||||
headers[key.toLowerCase()] = value[0];
|
||||
}
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract query parameters as a plain object
|
||||
*/
|
||||
private extractQueryParams(
|
||||
request: Request,
|
||||
): Record<string, string> | undefined {
|
||||
const queryParams = request.query as Record<string, string>;
|
||||
return Object.keys(queryParams).length > 0 ? queryParams : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract response headers as a plain object
|
||||
*/
|
||||
private extractResponseHeaders(response: Response): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
const headerNames = response.getHeaderNames();
|
||||
|
||||
headerNames.forEach((name) => {
|
||||
const value = response.getHeader(name);
|
||||
if (typeof value === 'string') {
|
||||
headers[name.toLowerCase()] = value;
|
||||
} else if (typeof value === 'number') {
|
||||
headers[name.toLowerCase()] = value.toString();
|
||||
} else if (Array.isArray(value)) {
|
||||
headers[name.toLowerCase()] = value.join(', ');
|
||||
}
|
||||
});
|
||||
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import {
|
||||
Controller,
|
||||
All,
|
||||
Req,
|
||||
Res,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { MockServerService } from './mock-server.service';
|
||||
import { MockServerLoggingInterceptor } from './mock-server-logging.interceptor';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { MockRequestGuard } from './mock-request.guard';
|
||||
import { MockServer } from '@prisma/client';
|
||||
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
||||
|
||||
/**
|
||||
* Mock server controller with dual routing support:
|
||||
* 1. Subdomain pattern: mock-server-id.mock.hopp.io/product
|
||||
* 2. Route pattern: backend.hopp.io/mock/mock-server-id/product
|
||||
*
|
||||
* The MockRequestGuard handles extraction of mock server ID from both patterns
|
||||
* The MockServerLoggingInterceptor handles logging of all requests
|
||||
*/
|
||||
@UseGuards(ThrottlerBehindProxyGuard)
|
||||
@Controller({ path: 'mock' })
|
||||
export class MockServerController {
|
||||
constructor(private readonly mockServerService: MockServerService) {}
|
||||
|
||||
@All('*path')
|
||||
@UseGuards(MockRequestGuard)
|
||||
@UseInterceptors(MockServerLoggingInterceptor)
|
||||
async handleMockRequest(@Req() req: Request, @Res() res: Response) {
|
||||
// Mock server ID and info are attached by the guard
|
||||
const mockServerId = (req as any).mockServerId as string;
|
||||
const mockServer = (req as any).mockServer as MockServer;
|
||||
|
||||
if (!mockServerId) {
|
||||
return res.status(HttpStatus.NOT_FOUND).json({
|
||||
error: 'Not found',
|
||||
message: 'Mock server ID not found',
|
||||
});
|
||||
}
|
||||
|
||||
const method = req.method;
|
||||
// Get clean path (removes /mock/mock-server-id prefix for route-based pattern)
|
||||
const path = MockRequestGuard.getCleanPath(
|
||||
req.path || '/',
|
||||
mockServer.subdomain,
|
||||
);
|
||||
|
||||
// Extract query parameters
|
||||
const queryParams = req.query as Record<string, string>;
|
||||
|
||||
// Extract request headers (convert to lowercase for case-insensitive matching)
|
||||
const requestHeaders: Record<string, string> = {};
|
||||
Object.keys(req.headers).forEach((key) => {
|
||||
const value = req.headers[key];
|
||||
if (typeof value === 'string') {
|
||||
requestHeaders[key.toLowerCase()] = value;
|
||||
} else if (Array.isArray(value)) {
|
||||
requestHeaders[key.toLowerCase()] = value[0];
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await this.mockServerService.handleMockRequest(
|
||||
mockServer,
|
||||
path,
|
||||
method,
|
||||
queryParams,
|
||||
requestHeaders,
|
||||
);
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
return res.status(HttpStatus.NOT_FOUND).json({
|
||||
error: 'Endpoint not found',
|
||||
message: result.left,
|
||||
});
|
||||
}
|
||||
|
||||
const mockResponse = result.right;
|
||||
|
||||
// Set response headers if any
|
||||
if (mockResponse.headers) {
|
||||
try {
|
||||
const headers = JSON.parse(mockResponse.headers);
|
||||
Object.keys(headers).forEach((key) => {
|
||||
console.log('Setting header:', key, headers[key]);
|
||||
res.setHeader(key, headers[key]);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error parsing response headers:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Add delay if specified
|
||||
if (mockServer.delayInMs && mockServer.delayInMs > 0) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, mockServer.delayInMs),
|
||||
);
|
||||
}
|
||||
|
||||
// Only set Content-Type if not already set
|
||||
if (!res.getHeader('Content-Type')) {
|
||||
let defaultContentType = 'text/plain';
|
||||
|
||||
// Check if body is a string and try to parse it to determine content type
|
||||
if (typeof mockResponse.body === 'string') {
|
||||
try {
|
||||
JSON.parse(mockResponse.body);
|
||||
// If parsing succeeds, it's JSON
|
||||
defaultContentType = 'application/json';
|
||||
} catch {
|
||||
// If parsing fails, it's plain text
|
||||
defaultContentType = 'text/plain';
|
||||
}
|
||||
} else if (typeof mockResponse.body === 'object') {
|
||||
// If it's already an object, it's JSON
|
||||
defaultContentType = 'application/json';
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', defaultContentType);
|
||||
}
|
||||
// Security headers
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
// Send response
|
||||
return res.status(mockResponse.statusCode).send(mockResponse.body);
|
||||
} catch (error) {
|
||||
console.error('Error handling mock request:', error);
|
||||
return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to process mock request',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
307
packages/hoppscotch-backend/src/mock-server/mock-server.model.ts
Normal file
307
packages/hoppscotch-backend/src/mock-server/mock-server.model.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import {
|
||||
Field,
|
||||
ID,
|
||||
ObjectType,
|
||||
ArgsType,
|
||||
InputType,
|
||||
registerEnumType,
|
||||
} from '@nestjs/graphql';
|
||||
import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { WorkspaceType } from 'src/types/WorkspaceTypes';
|
||||
|
||||
// Regex pattern for mock server name validation
|
||||
// Allows letters, numbers, spaces, dots, brackets, underscores, and hyphens
|
||||
const MOCK_SERVER_NAME_PATTERN = /^[a-zA-Z0-9 .()[\]{}<>_-]+$/;
|
||||
const MOCK_SERVER_NAME_ERROR_MESSAGE =
|
||||
'Name can only contain letters, numbers, spaces, dots, brackets, underscores, and hyphens';
|
||||
|
||||
@ObjectType()
|
||||
export class MockServer {
|
||||
@Field(() => ID, {
|
||||
description: 'ID of the mock server',
|
||||
})
|
||||
id: string;
|
||||
|
||||
@Field({
|
||||
description: 'Name of the mock server',
|
||||
})
|
||||
name: string;
|
||||
|
||||
@Field({
|
||||
description: 'Subdomain for the mock server (e.g., mock-1234)',
|
||||
})
|
||||
subdomain: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
'Server URL for the mock server using subdomain pattern (e.g., https://1234.mock.backend-hoppscotch.io)',
|
||||
})
|
||||
serverUrlDomainBased: string;
|
||||
|
||||
@Field({
|
||||
description:
|
||||
'Server URL for the mock server using path pattern (e.g., https://backend.hoppscotch.io/mock/1234)',
|
||||
})
|
||||
serverUrlPathBased: string;
|
||||
|
||||
@Field(() => WorkspaceType, {
|
||||
description: 'Type of workspace: USER or TEAM',
|
||||
})
|
||||
workspaceType: WorkspaceType;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
'ID of the workspace (user or team) to associate with the mock server',
|
||||
})
|
||||
workspaceID?: string;
|
||||
|
||||
@Field({
|
||||
description: 'Delay in milliseconds before responding',
|
||||
})
|
||||
delayInMs: number;
|
||||
|
||||
@Field({
|
||||
description: 'Whether the mock server is active',
|
||||
})
|
||||
isActive: boolean;
|
||||
|
||||
@Field({
|
||||
description: 'Whether the mock server is publicly accessible',
|
||||
})
|
||||
isPublic: boolean;
|
||||
|
||||
@Field({
|
||||
description: 'Date and time when the mock server was created',
|
||||
})
|
||||
createdOn: Date;
|
||||
|
||||
@Field({
|
||||
description: 'Date and time when the mock server was last updated',
|
||||
})
|
||||
updatedOn: Date;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class MockServerCollection {
|
||||
@Field(() => ID, {
|
||||
description: 'ID of the collection',
|
||||
})
|
||||
id: string;
|
||||
|
||||
@Field({
|
||||
description: 'Title of the collection',
|
||||
})
|
||||
title: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class CreateMockServerInput {
|
||||
@Field({
|
||||
description: 'Name of the mock server',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
@Matches(MOCK_SERVER_NAME_PATTERN, {
|
||||
message: MOCK_SERVER_NAME_ERROR_MESSAGE,
|
||||
})
|
||||
name: string;
|
||||
|
||||
@Field({
|
||||
description:
|
||||
'ID of the (team or user) collection to associate with the mock server',
|
||||
})
|
||||
collectionID: string;
|
||||
|
||||
@Field(() => WorkspaceType, {
|
||||
description: 'Type of workspace: USER or TEAM',
|
||||
})
|
||||
workspaceType: WorkspaceType;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
'ID of the workspace (user or team) to associate with the mock server',
|
||||
})
|
||||
workspaceID?: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
defaultValue: 0,
|
||||
description: 'Delay in milliseconds before responding',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Max(60000)
|
||||
delayInMs?: number;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
defaultValue: true,
|
||||
description: 'Whether the mock server is publicly accessible',
|
||||
})
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateMockServerInput {
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Name of the mock server',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
@Matches(MOCK_SERVER_NAME_PATTERN, {
|
||||
message: MOCK_SERVER_NAME_ERROR_MESSAGE,
|
||||
})
|
||||
name?: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Delay in milliseconds before responding',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Max(60000)
|
||||
delayInMs?: number;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Whether the mock server is active',
|
||||
})
|
||||
isActive?: boolean;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Whether the mock server is publicly accessible',
|
||||
})
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class MockServerResponse {
|
||||
@Field({
|
||||
description: 'HTTP status code to return',
|
||||
})
|
||||
statusCode: number;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Response body to return',
|
||||
})
|
||||
body?: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Response headers as JSON string',
|
||||
})
|
||||
headers?: string;
|
||||
|
||||
@Field({
|
||||
defaultValue: 0,
|
||||
description: 'Delay in milliseconds before response',
|
||||
})
|
||||
delay: number;
|
||||
}
|
||||
|
||||
@ArgsType()
|
||||
export class MockServerMutationArgs {
|
||||
@Field(() => ID, {
|
||||
description: 'ID of the mock server',
|
||||
})
|
||||
id: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class MockServerLog {
|
||||
@Field(() => ID, {
|
||||
description: 'ID of the log entry',
|
||||
})
|
||||
id: string;
|
||||
|
||||
@Field(() => ID, {
|
||||
description: 'ID of the mock server',
|
||||
})
|
||||
mockServerID: string;
|
||||
|
||||
@Field({
|
||||
description: 'HTTP method of the request',
|
||||
})
|
||||
requestMethod: string;
|
||||
|
||||
@Field({
|
||||
description: 'Path of the request',
|
||||
})
|
||||
requestPath: string;
|
||||
|
||||
@Field({
|
||||
description: 'Request headers as JSON string',
|
||||
})
|
||||
requestHeaders: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Request body as JSON string',
|
||||
})
|
||||
requestBody?: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Request query parameters as JSON string',
|
||||
})
|
||||
requestQuery?: string;
|
||||
|
||||
@Field({
|
||||
description: 'HTTP status code of the response',
|
||||
})
|
||||
responseStatus: number;
|
||||
|
||||
@Field({
|
||||
description: 'Response headers as JSON string',
|
||||
})
|
||||
responseHeaders: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Response body as JSON string',
|
||||
})
|
||||
responseBody?: string;
|
||||
|
||||
@Field({
|
||||
description: 'Response time in milliseconds',
|
||||
})
|
||||
responseTime: number;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'IP address of the requester',
|
||||
})
|
||||
ipAddress?: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'User agent of the requester',
|
||||
})
|
||||
userAgent?: string;
|
||||
|
||||
@Field({
|
||||
description: 'Date and time when the request was executed',
|
||||
})
|
||||
executedAt: Date;
|
||||
}
|
||||
|
||||
registerEnumType(WorkspaceType, {
|
||||
name: 'WorkspaceType',
|
||||
});
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { MockServerService } from './mock-server.service';
|
||||
import { MockServerAnalyticsService } from './mock-server-analytics.service';
|
||||
import { MockServerLoggingInterceptor } from './mock-server-logging.interceptor';
|
||||
import { MockServerResolver } from './mock-server.resolver';
|
||||
import { TeamModule } from 'src/team/team.module';
|
||||
import { TeamRequestModule } from 'src/team-request/team-request.module';
|
||||
import { MockServerController } from './mock-server.controller';
|
||||
import { AccessTokenModule } from 'src/access-token/access-token.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, TeamModule, TeamRequestModule, AccessTokenModule],
|
||||
controllers: [MockServerController],
|
||||
providers: [
|
||||
MockServerService,
|
||||
MockServerAnalyticsService,
|
||||
MockServerLoggingInterceptor,
|
||||
MockServerResolver,
|
||||
],
|
||||
})
|
||||
export class MockServerModule {}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
import {
|
||||
Resolver,
|
||||
Query,
|
||||
Mutation,
|
||||
Args,
|
||||
ID,
|
||||
ResolveField,
|
||||
Parent,
|
||||
} from '@nestjs/graphql';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
|
||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||
import { User } from 'src/user/user.model';
|
||||
import { MockServerService } from './mock-server.service';
|
||||
import {
|
||||
MockServer,
|
||||
CreateMockServerInput,
|
||||
UpdateMockServerInput,
|
||||
MockServerMutationArgs,
|
||||
MockServerCollection,
|
||||
MockServerLog,
|
||||
} from './mock-server.model';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { OffsetPaginationArgs } from 'src/types/input-types.args';
|
||||
import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard';
|
||||
import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator';
|
||||
import { TeamAccessRole } from 'src/team/team.model';
|
||||
import { throwErr } from 'src/utils';
|
||||
import { MockServerAnalyticsService } from './mock-server-analytics.service';
|
||||
|
||||
@Resolver(() => MockServer)
|
||||
export class MockServerResolver {
|
||||
constructor(
|
||||
private readonly mockServerService: MockServerService,
|
||||
private readonly mockServerAnalyticsService: MockServerAnalyticsService,
|
||||
) {}
|
||||
|
||||
// Resolve Fields
|
||||
|
||||
@ResolveField(() => User, {
|
||||
nullable: true,
|
||||
description: 'Returns the creator of the mock server',
|
||||
})
|
||||
async creator(@Parent() mockServer: MockServer): Promise<User> {
|
||||
const creator = await this.mockServerService.getMockServerCreator(
|
||||
mockServer.id,
|
||||
);
|
||||
|
||||
if (E.isLeft(creator)) throwErr(creator.left);
|
||||
return {
|
||||
...creator.right,
|
||||
currentGQLSession: JSON.stringify(creator.right.currentGQLSession),
|
||||
currentRESTSession: JSON.stringify(creator.right.currentRESTSession),
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => MockServerCollection, {
|
||||
nullable: true,
|
||||
description: 'Returns the collection of the mock server',
|
||||
})
|
||||
async collection(
|
||||
@Parent() mockServer: MockServer,
|
||||
): Promise<MockServerCollection | null> {
|
||||
const collection = await this.mockServerService.getMockServerCollection(
|
||||
mockServer.id,
|
||||
);
|
||||
|
||||
if (E.isLeft(collection)) throwErr(collection.left);
|
||||
return collection.right;
|
||||
}
|
||||
|
||||
// Queries
|
||||
|
||||
@Query(() => [MockServer], {
|
||||
description: 'Get all mock servers for the authenticated user',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async myMockServers(
|
||||
@GqlUser() user: User,
|
||||
@Args() args: OffsetPaginationArgs,
|
||||
): Promise<MockServer[]> {
|
||||
return this.mockServerService.getUserMockServers(user.uid, args);
|
||||
}
|
||||
|
||||
@Query(() => [MockServer], {
|
||||
description: 'Get all mock servers for a specific team',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
||||
@RequiresTeamRole(
|
||||
TeamAccessRole.VIEWER,
|
||||
TeamAccessRole.EDITOR,
|
||||
TeamAccessRole.OWNER,
|
||||
)
|
||||
async teamMockServers(
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Id of the team to add to',
|
||||
})
|
||||
teamID: string,
|
||||
@Args() args: OffsetPaginationArgs,
|
||||
): Promise<MockServer[]> {
|
||||
return this.mockServerService.getTeamMockServers(teamID, args);
|
||||
}
|
||||
|
||||
@Query(() => MockServer, {
|
||||
description: 'Get a specific mock server by ID',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async mockServer(
|
||||
@GqlUser() user: User,
|
||||
@Args({
|
||||
name: 'id',
|
||||
type: () => ID,
|
||||
description: 'Id of the mock server to retrieve',
|
||||
})
|
||||
id: string,
|
||||
): Promise<MockServer> {
|
||||
const result = await this.mockServerService.getMockServer(id, user.uid);
|
||||
|
||||
if (E.isLeft(result)) throwErr(result.left);
|
||||
return result.right;
|
||||
}
|
||||
|
||||
@Query(() => [MockServerLog], {
|
||||
description:
|
||||
'Get logs for a specific mock server with pagination, sorted by execution time (most recent first)',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async mockServerLogs(
|
||||
@GqlUser() user: User,
|
||||
@Args({
|
||||
name: 'mockServerID',
|
||||
type: () => ID,
|
||||
description: 'ID of the mock server',
|
||||
})
|
||||
mockServerID: string,
|
||||
@Args() args: OffsetPaginationArgs,
|
||||
): Promise<MockServerLog[]> {
|
||||
const result = await this.mockServerService.getMockServerLogs(
|
||||
mockServerID,
|
||||
user.uid,
|
||||
args,
|
||||
);
|
||||
|
||||
if (E.isLeft(result)) throwErr(result.left);
|
||||
return result.right;
|
||||
}
|
||||
|
||||
// Mutations
|
||||
|
||||
@Mutation(() => MockServer, {
|
||||
description: 'Create a new mock server',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async createMockServer(
|
||||
@Args('input') input: CreateMockServerInput,
|
||||
@GqlUser() user: User,
|
||||
): Promise<MockServer> {
|
||||
const result = await this.mockServerService.createMockServer(user, input);
|
||||
|
||||
if (E.isLeft(result)) throwErr(result.left);
|
||||
return result.right;
|
||||
}
|
||||
|
||||
@Mutation(() => MockServer, {
|
||||
description: 'Update a mock server',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async updateMockServer(
|
||||
@GqlUser() user: User,
|
||||
@Args() args: MockServerMutationArgs,
|
||||
@Args('input') input: UpdateMockServerInput,
|
||||
): Promise<MockServer> {
|
||||
const result = await this.mockServerService.updateMockServer(
|
||||
args.id,
|
||||
user.uid,
|
||||
input,
|
||||
);
|
||||
|
||||
if (E.isLeft(result)) throwErr(result.left);
|
||||
return result.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Delete a mock server',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async deleteMockServer(
|
||||
@GqlUser() user: User,
|
||||
@Args() args: MockServerMutationArgs,
|
||||
): Promise<boolean> {
|
||||
const result = await this.mockServerService.deleteMockServer(
|
||||
args.id,
|
||||
user.uid,
|
||||
);
|
||||
|
||||
if (E.isLeft(result)) throwErr(result.left);
|
||||
return result.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Delete a mock server log by log ID',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async deleteMockServerLog(
|
||||
@GqlUser() user: User,
|
||||
@Args({
|
||||
name: 'logID',
|
||||
type: () => ID,
|
||||
description: 'Id of the log to delete',
|
||||
})
|
||||
logID: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.mockServerService.deleteMockServerLog(
|
||||
logID,
|
||||
user.uid,
|
||||
);
|
||||
|
||||
if (E.isLeft(result)) throwErr(result.left);
|
||||
return result.right;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
1063
packages/hoppscotch-backend/src/mock-server/mock-server.service.ts
Normal file
1063
packages/hoppscotch-backend/src/mock-server/mock-server.service.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
|||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||
import { TeamCollectionService } from 'src/team-collection/team-collection.service';
|
||||
import { TeamRequestService } from 'src/team-request/team-request.service';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { OnModuleInit } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnModuleInit, Injectable } from '@nestjs/common';
|
||||
import { PubSub as LocalPubSub } from 'graphql-subscriptions';
|
||||
import { TopicDef } from './topicsDefs';
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
escapeSqlLikeString,
|
||||
isValidLength,
|
||||
transformCollectionData,
|
||||
stringToJson,
|
||||
} from 'src/utils';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import * as O from 'fp-ts/Option';
|
||||
|
|
@ -36,7 +37,6 @@ import {
|
|||
TeamRequest,
|
||||
} from '@prisma/client';
|
||||
import { CollectionFolder } from 'src/types/CollectionFolder';
|
||||
import { stringToJson } from 'src/utils';
|
||||
import { CollectionSearchNode } from 'src/types/CollectionSearchNode';
|
||||
import {
|
||||
GetCollectionResponse,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ for (let i = 1; i <= 10; i++) {
|
|||
collectionID: teamCollection.id,
|
||||
teamID: team.id,
|
||||
request: {},
|
||||
mockExamples: {},
|
||||
title: `Test Request ${i}`,
|
||||
orderIndex: i,
|
||||
createdOn: new Date(),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export enum InfraConfigEnum {
|
|||
|
||||
JWT_SECRET = 'JWT_SECRET',
|
||||
SESSION_SECRET = 'SESSION_SECRET',
|
||||
SESSION_COOKIE_NAME = 'SESSION_COOKIE_NAME',
|
||||
TOKEN_SALT_COMPLEXITY = 'TOKEN_SALT_COMPLEXITY',
|
||||
MAGIC_LINK_TOKEN_VALIDITY = 'MAGIC_LINK_TOKEN_VALIDITY',
|
||||
REFRESH_TOKEN_VALIDITY = 'REFRESH_TOKEN_VALIDITY',
|
||||
|
|
@ -48,4 +49,6 @@ export enum InfraConfigEnum {
|
|||
IS_FIRST_TIME_INFRA_SETUP = 'IS_FIRST_TIME_INFRA_SETUP',
|
||||
|
||||
USER_HISTORY_STORE_ENABLED = 'USER_HISTORY_STORE_ENABLED',
|
||||
|
||||
MOCK_SERVER_WILDCARD_DOMAIN = 'MOCK_SERVER_WILDCARD_DOMAIN',
|
||||
}
|
||||
|
|
|
|||
4
packages/hoppscotch-backend/src/types/WorkspaceTypes.ts
Normal file
4
packages/hoppscotch-backend/src/types/WorkspaceTypes.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export enum WorkspaceType {
|
||||
USER = 'USER',
|
||||
TEAM = 'TEAM',
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ const dbUserRequests: DbUserRequest[] = [
|
|||
userUid: user.uid,
|
||||
title: 'Request 1',
|
||||
request: {},
|
||||
mockExamples: {},
|
||||
type: DbRequestType.REST,
|
||||
createdOn: new Date(),
|
||||
updatedOn: new Date(),
|
||||
|
|
@ -62,6 +63,7 @@ const dbUserRequests: DbUserRequest[] = [
|
|||
userUid: user.uid,
|
||||
title: 'Request 2',
|
||||
request: {},
|
||||
mockExamples: {},
|
||||
type: DbRequestType.REST,
|
||||
createdOn: new Date(),
|
||||
updatedOn: new Date(),
|
||||
|
|
@ -73,6 +75,7 @@ const dbUserRequests: DbUserRequest[] = [
|
|||
userUid: user.uid,
|
||||
title: 'Request 3',
|
||||
request: {},
|
||||
mockExamples: {},
|
||||
type: DbRequestType.REST,
|
||||
createdOn: new Date(),
|
||||
updatedOn: new Date(),
|
||||
|
|
@ -84,6 +87,7 @@ const dbUserRequests: DbUserRequest[] = [
|
|||
userUid: user.uid,
|
||||
title: 'Request 4',
|
||||
request: {},
|
||||
mockExamples: {},
|
||||
type: DbRequestType.REST,
|
||||
createdOn: new Date(),
|
||||
updatedOn: new Date(),
|
||||
|
|
@ -95,6 +99,7 @@ const dbUserRequests: DbUserRequest[] = [
|
|||
userUid: user.uid,
|
||||
title: 'Request 1',
|
||||
request: {},
|
||||
mockExamples: {},
|
||||
type: DbRequestType.REST,
|
||||
createdOn: new Date(),
|
||||
updatedOn: new Date(),
|
||||
|
|
@ -106,6 +111,7 @@ const dbUserRequests: DbUserRequest[] = [
|
|||
userUid: user.uid,
|
||||
title: 'Request 2',
|
||||
request: {},
|
||||
mockExamples: {},
|
||||
type: DbRequestType.REST,
|
||||
createdOn: new Date(),
|
||||
updatedOn: new Date(),
|
||||
|
|
@ -117,6 +123,7 @@ const dbUserRequests: DbUserRequest[] = [
|
|||
userUid: user.uid,
|
||||
title: 'Request 3',
|
||||
request: {},
|
||||
mockExamples: {},
|
||||
type: DbRequestType.REST,
|
||||
createdOn: new Date(),
|
||||
updatedOn: new Date(),
|
||||
|
|
@ -128,6 +135,7 @@ const dbUserRequests: DbUserRequest[] = [
|
|||
userUid: user.uid,
|
||||
title: 'Request 4',
|
||||
request: {},
|
||||
mockExamples: {},
|
||||
type: DbRequestType.REST,
|
||||
createdOn: new Date(),
|
||||
updatedOn: new Date(),
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import {
|
|||
USERS_NOT_FOUND,
|
||||
USER_NOT_FOUND,
|
||||
USER_SHORT_DISPLAY_NAME,
|
||||
USER_UPDATE_FAILED,
|
||||
} from 'src/errors';
|
||||
import { SessionType, User } from './user.model';
|
||||
import { USER_UPDATE_FAILED } from 'src/errors';
|
||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||
import { encrypt, stringToJson, taskEitherValidateArraySeq } from 'src/utils';
|
||||
import { UserDataHandler } from './user.data.handler';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@hoppscotch/cli",
|
||||
"version": "0.25.0",
|
||||
"version": "0.26.0",
|
||||
"description": "A CLI to run Hoppscotch test scripts in CI environments.",
|
||||
"homepage": "https://hoppscotch.io",
|
||||
"type": "module",
|
||||
|
|
@ -64,9 +64,9 @@
|
|||
"fp-ts": "2.16.11",
|
||||
"prettier": "3.6.2",
|
||||
"qs": "6.11.2",
|
||||
"semver": "7.7.2",
|
||||
"semver": "7.7.3",
|
||||
"tsup": "8.5.0",
|
||||
"typescript": "5.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "3.2.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -8,6 +8,7 @@ import {
|
|||
parseTemplateStringE,
|
||||
generateJWTToken,
|
||||
HoppCollectionVariable,
|
||||
calculateHawkHeader
|
||||
} from "@hoppscotch/data";
|
||||
import { runPreRequestScript } from "@hoppscotch/js-sandbox/node";
|
||||
import * as A from "fp-ts/Array";
|
||||
|
|
@ -34,8 +35,6 @@ import {
|
|||
generateDigestAuthHeader,
|
||||
} from "./auth/digest";
|
||||
|
||||
import { calculateHawkHeader } from "@hoppscotch/data";
|
||||
|
||||
/**
|
||||
* Runs pre-request-script runner over given request which extracts set ENVs and
|
||||
* applies them on current request to generate updated request.
|
||||
|
|
@ -69,23 +68,36 @@ export const preRequestScriptRunner = (
|
|||
const { selected, global } = updatedEnvs;
|
||||
|
||||
return {
|
||||
updatedEnvs: <Environment>{
|
||||
// Keep the original updatedEnvs with separate global and selected arrays
|
||||
preRequestUpdatedEnvs: updatedEnvs,
|
||||
// Create Environment format for getEffectiveRESTRequest
|
||||
envForEffectiveRequest: <Environment>{
|
||||
name: "Env",
|
||||
variables: [...(selected ?? []), ...(global ?? [])],
|
||||
},
|
||||
updatedRequest: updatedRequest ?? {},
|
||||
};
|
||||
}),
|
||||
TE.chainW(({ updatedEnvs, updatedRequest }) => {
|
||||
TE.chainW(({ preRequestUpdatedEnvs, envForEffectiveRequest, updatedRequest }) => {
|
||||
const finalRequest = { ...request, ...updatedRequest };
|
||||
|
||||
return TE.tryCatch(
|
||||
() =>
|
||||
getEffectiveRESTRequest(
|
||||
async () => {
|
||||
const result = await getEffectiveRESTRequest(
|
||||
finalRequest,
|
||||
updatedEnvs,
|
||||
envForEffectiveRequest,
|
||||
collectionVariables
|
||||
),
|
||||
);
|
||||
// Replace the updatedEnvs from getEffectiveRESTRequest with the one from pre-request script
|
||||
// This preserves the global/selected separation
|
||||
if (E.isRight(result)) {
|
||||
return E.right({
|
||||
...result.right,
|
||||
updatedEnvs: preRequestUpdatedEnvs,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
(reason) => error({ code: "PRE_REQUEST_SCRIPT_ERROR", data: reason })
|
||||
);
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -367,7 +367,8 @@
|
|||
"request_change": "Are you sure you want to discard current request, unsaved changes will be lost.",
|
||||
"save_unsaved_tab": "Do you want to save changes made in this tab?",
|
||||
"sync": "Would you like to restore your workspace from cloud? This will discard your local progress.",
|
||||
"delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?"
|
||||
"delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?",
|
||||
"delete_mock_server": "Are you sure you want to delete this mock server?"
|
||||
},
|
||||
"context_menu": {
|
||||
"add_parameters": "Add to parameters",
|
||||
|
|
@ -439,7 +440,8 @@
|
|||
"teams": "You don't belong to any workspaces",
|
||||
"tests": "There are no tests for this request",
|
||||
"access_tokens": "Access tokens are empty",
|
||||
"response": "No response received"
|
||||
"response": "No response received",
|
||||
"mock_servers": "No mock servers found"
|
||||
},
|
||||
"environment": {
|
||||
"heading": "Environment",
|
||||
|
|
@ -686,6 +688,8 @@
|
|||
"from_postman": "Import from Postman",
|
||||
"from_postman_description": "Import from Postman collection",
|
||||
"from_postman_import_summary": "Collections, Requests and response examples will be imported.",
|
||||
"import_scripts": "Import scripts",
|
||||
"import_scripts_description": "Supports Postman Collection v2.0/v2.1.",
|
||||
"from_url": "Import from URL",
|
||||
"gist_url": "Enter Gist URL",
|
||||
"from_har": "Import from HAR",
|
||||
|
|
@ -712,6 +716,9 @@
|
|||
"import_summary_pre_request_scripts_title": "Pre-request scripts",
|
||||
"import_summary_post_request_scripts_title": "Post request scripts",
|
||||
"import_summary_not_supported_by_hoppscotch_import": "We do not support importing {featureLabel} from this source right now.",
|
||||
"import_summary_script_found": "script found but not imported",
|
||||
"import_summary_scripts_found": "scripts found but not imported",
|
||||
"import_summary_enable_experimental_sandbox": "To import Postman scripts, enable 'Experimental scripting sandbox' in settings. Note: This feature is experimental.",
|
||||
"cors_error_modal": {
|
||||
"title": "CORS Error Detected",
|
||||
"description": "The import failed due to CORS (Cross-Origin Resource Sharing) restrictions imposed by the server.",
|
||||
|
|
@ -842,10 +849,59 @@
|
|||
"profile": "Profile",
|
||||
"realtime": "Realtime",
|
||||
"rest": "REST",
|
||||
"mock_servers": "Mock Servers",
|
||||
"settings": "Settings",
|
||||
"goto_app": "Goto App",
|
||||
"authentication": "Authentication"
|
||||
},
|
||||
"mock_server": {
|
||||
"confirm_delete_log": "Are you sure you want to delete this log?",
|
||||
"create_mock_server": "Configure Mock Server",
|
||||
"mock_server_configuration": "Mock Server Configuration",
|
||||
"mock_server_name": "Mock Server Name",
|
||||
"mock_server_name_placeholder": "Enter mock server name",
|
||||
"base_url": "Base URL",
|
||||
"start_server": "Start Server",
|
||||
"stop_server": "Stop Server",
|
||||
"mock_server_created": "Mock server created successfully",
|
||||
"mock_server_started": "Mock server started successfully",
|
||||
"mock_server_stopped": "Mock server stopped successfully",
|
||||
"active": "Mock server is active",
|
||||
"inactive": "Mock server is inactive",
|
||||
"edit_mock_server": "Edit Mock Server",
|
||||
"path_based_url": "Path based URL",
|
||||
"subdomain_based_url": "Subdomain based URL",
|
||||
"mock_server_updated": "Mock server updated successfully",
|
||||
"no_collection": "No collection",
|
||||
"collection_deleted": "associated collection deleted.",
|
||||
"private_access_hint": "For private mock servers, include the header 'x-api-key' with your Personal Access Token (create one from your profile).",
|
||||
"status": "Status",
|
||||
"server_running": "Server is running",
|
||||
"server_stopped": "Server is stopped",
|
||||
"delay_ms": "Response Delay (ms)",
|
||||
"delay_placeholder": "Enter delay in milliseconds",
|
||||
"delay_description": "Add artificial delay to mock responses",
|
||||
"public_access": "Public Access",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"public_description": "Anyone with the URL can access this mock server",
|
||||
"private_description": "Only authenticated users can access this mock server",
|
||||
"select_collection": "Select a collection",
|
||||
"select_collection_error": "Please select a collection",
|
||||
"invalid_collection_error": "Failed to create a mock server for the collection.",
|
||||
"url_copied": "URL copied to clipboard",
|
||||
"make_public": "Make Public",
|
||||
"view_logs": "View logs",
|
||||
"logs_title": "Mock Server Logs",
|
||||
"no_logs": "No logs available",
|
||||
"request_headers": "Request Headers",
|
||||
"request_body": "Request Body",
|
||||
"response_status": "Response Status",
|
||||
"response_headers": "Response Headers",
|
||||
"response_body": "Response Body",
|
||||
"log_deleted": "Log deleted successfully",
|
||||
"description": "Mock servers allow you to simulate API responses based on your collection's example responses."
|
||||
},
|
||||
"preRequest": {
|
||||
"javascript_code": "JavaScript Code",
|
||||
"learn": "Read documentation",
|
||||
|
|
@ -1027,6 +1083,7 @@
|
|||
"ai_request_naming_style_custom": "Custom",
|
||||
"ai_request_naming_style_custom_placeholder": "Enter your custom naming style template...",
|
||||
"experimental_scripting_sandbox": "Experimental scripting sandbox",
|
||||
"enable_experimental_mock_servers": "Enable Mock Servers",
|
||||
"sync": "Synchronise",
|
||||
"sync_collections": "Collections",
|
||||
"sync_description": "These settings are synced to cloud.",
|
||||
|
|
@ -1045,6 +1102,7 @@
|
|||
"validate_certificates": "Validate SSL/TLS Certificates",
|
||||
"verify_host": "Verify Host",
|
||||
"verify_peer": "Verify Peer",
|
||||
"follow_redirects": "Follow Redirects",
|
||||
"client_certificates": "Client Certificates",
|
||||
"certificate_settings": "Certificate Settings",
|
||||
"certificate": "Certificate",
|
||||
|
|
@ -1313,6 +1371,7 @@
|
|||
"download_failed": "Download failed",
|
||||
"download_started": "Download started",
|
||||
"enabled": "Enabled",
|
||||
"experimental": "Experimental",
|
||||
"file_imported": "File imported",
|
||||
"finished_in": "Finished in {duration} ms",
|
||||
"hide": "Hide",
|
||||
|
|
@ -1321,6 +1380,7 @@
|
|||
"loading": "Loading...",
|
||||
"message_received": "Message: {message} arrived on topic: {topic}",
|
||||
"mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}",
|
||||
"no_content_found": "No content found",
|
||||
"none": "None",
|
||||
"nothing_found": "Nothing found for",
|
||||
"published_error": "Something went wrong while publishing msg: {topic} to topic: {message}",
|
||||
|
|
@ -1474,6 +1534,7 @@
|
|||
"shared_requests": "Shared Requests",
|
||||
"codegen": "Generate Code",
|
||||
"code_snippet": "Code snippet",
|
||||
"mock_servers": "Mock Servers",
|
||||
"share_tab_request": "Share tab request",
|
||||
"socketio": "Socket.IO",
|
||||
"sse": "SSE",
|
||||
|
|
@ -1940,5 +2001,63 @@
|
|||
"app_console": {
|
||||
"entries": "Console entries",
|
||||
"no_entries": "No entries"
|
||||
},
|
||||
"mockServer": {
|
||||
"create_modal": {
|
||||
"title": "Create Mock Server",
|
||||
"name_label": "Mock Server Name",
|
||||
"name_placeholder": "Enter mock server name",
|
||||
"name_required": "Mock server name is required",
|
||||
"collection_source_label": "Collection Source",
|
||||
"existing_collection": "Existing Collection",
|
||||
"new_collection": "New Collection",
|
||||
"select_collection_label": "Select Collection",
|
||||
"select_collection_placeholder": "Choose a collection",
|
||||
"collection_required": "Please select a collection",
|
||||
"collection_name_label": "Collection Name",
|
||||
"collection_name_placeholder": "Enter collection name",
|
||||
"collection_name_required": "Collection name is required",
|
||||
"request_config_label": "Request Configuration",
|
||||
"add_request": "Add Request",
|
||||
"create_button": "Create Mock Server",
|
||||
"success": "Mock server created successfully",
|
||||
"error": "Failed to create mock server",
|
||||
"no_collections": "No collections available"
|
||||
},
|
||||
"edit_modal": {
|
||||
"title": "Edit Mock Server",
|
||||
"name_label": "Mock Server Name",
|
||||
"name_placeholder": "Enter mock server name",
|
||||
"name_required": "Mock server name is required",
|
||||
"active_label": "Active",
|
||||
"url_label": "Mock Server URL",
|
||||
"collection_label": "Collection",
|
||||
"update_button": "Update Mock Server",
|
||||
"success": "Mock server updated successfully",
|
||||
"error": "Failed to update mock server",
|
||||
"url_copied": "URL copied to clipboard"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Mock Servers",
|
||||
"subtitle": "Create and manage your API mock servers",
|
||||
"create_button": "Create Mock Server",
|
||||
"create_first": "Create your first mock server",
|
||||
"empty_title": "No mock servers found",
|
||||
"empty_description": "Create mock servers based on your API collections to enable frontend and mobile development without backend dependencies.",
|
||||
"collection": "Collection",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"mock_url": "Mock URL",
|
||||
"endpoints": "Endpoints",
|
||||
"created": "Created",
|
||||
"view_collection": "View Collection",
|
||||
"documentation": "Documentation",
|
||||
"doc_description": "Use this URL as your API base URL in your applications:",
|
||||
"url_copied": "Mock server URL copied to clipboard",
|
||||
"delete_title": "Delete Mock Server",
|
||||
"delete_description": "Are you sure you want to delete this mock server?",
|
||||
"delete_success": "Mock server deleted successfully",
|
||||
"delete_error": "Failed to delete mock server"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@hoppscotch/common",
|
||||
"private": true,
|
||||
"version": "2025.9.2",
|
||||
"version": "2025.10.0",
|
||||
"scripts": {
|
||||
"dev": "pnpm exec npm-run-all -p -l dev:*",
|
||||
"test": "vitest --run",
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
"do-lintfix": "pnpm run lintfix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "12.0.0",
|
||||
"@apidevtools/swagger-parser": "12.1.0",
|
||||
"@codemirror/autocomplete": "6.18.6",
|
||||
"@codemirror/commands": "6.8.1",
|
||||
"@codemirror/lang-javascript": "6.2.4",
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
"@codemirror/search": "6.5.11",
|
||||
"@codemirror/state": "6.5.2",
|
||||
"@codemirror/view": "6.38.1",
|
||||
"@guolao/vue-monaco-editor": "1.5.5",
|
||||
"@guolao/vue-monaco-editor": "1.6.0",
|
||||
"@hoppscotch/codemirror-lang-graphql": "workspace:^",
|
||||
"@hoppscotch/data": "workspace:^",
|
||||
"@hoppscotch/httpsnippet": "3.0.9",
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
"@tauri-apps/plugin-store": "2.2.0",
|
||||
"@types/hawk": "9.0.6",
|
||||
"@types/markdown-it": "14.1.2",
|
||||
"@unhead/vue": "2.0.17",
|
||||
"@unhead/vue": "2.0.19",
|
||||
"@urql/core": "6.0.1",
|
||||
"@urql/devtools": "2.0.3",
|
||||
"@urql/exchange-auth": "3.0.0",
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
"jsonc-parser": "3.3.1",
|
||||
"jsonpath-plus": "10.3.0",
|
||||
"lodash-es": "4.17.21",
|
||||
"lossless-json": "4.2.0",
|
||||
"lossless-json": "4.3.0",
|
||||
"markdown-it": "14.1.0",
|
||||
"minisearch": "7.2.0",
|
||||
"monaco-editor": "0.52.2",
|
||||
|
|
@ -98,7 +98,7 @@
|
|||
"splitpanes": "3.1.5",
|
||||
"stream-browserify": "3.0.0",
|
||||
"subscriptions-transport-ws": "0.11.0",
|
||||
"superjson": "2.2.2",
|
||||
"superjson": "2.2.3",
|
||||
"tern": "0.24.3",
|
||||
"timers": "0.1.1",
|
||||
"tippy.js": "6.3.7",
|
||||
|
|
@ -110,7 +110,7 @@
|
|||
"vue-i18n": "11.1.12",
|
||||
"vue-json-pretty": "2.5.0",
|
||||
"vue-pdf-embed": "2.1.3",
|
||||
"vue-router": "4.5.1",
|
||||
"vue-router": "4.6.3",
|
||||
"vue-tippy": "6.7.1",
|
||||
"vuedraggable-es": "4.1.1",
|
||||
"wonka": "6.3.5",
|
||||
|
|
@ -133,7 +133,7 @@
|
|||
"@iconify-json/lucide": "1.2.68",
|
||||
"@intlify/unplugin-vue-i18n": "6.0.8",
|
||||
"@relmify/jest-fp-ts": "2.1.1",
|
||||
"@rushstack/eslint-patch": "1.12.0",
|
||||
"@rushstack/eslint-patch": "1.14.0",
|
||||
"@types/har-format": "1.2.16",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
|
|
@ -144,18 +144,18 @@
|
|||
"@types/splitpanes": "2.2.6",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@types/yargs-parser": "21.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.44.1",
|
||||
"@typescript-eslint/parser": "8.44.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.2",
|
||||
"@typescript-eslint/parser": "8.46.2",
|
||||
"@vitejs/plugin-vue": "5.1.4",
|
||||
"@vue/compiler-sfc": "3.5.22",
|
||||
"@vue/eslint-config-typescript": "13.0.0",
|
||||
"@vue/runtime-core": "3.5.22",
|
||||
"autoprefixer": "10.4.21",
|
||||
"cross-env": "10.0.0",
|
||||
"dotenv": "17.2.2",
|
||||
"cross-env": "10.1.0",
|
||||
"dotenv": "17.2.3",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-prettier": "5.5.4",
|
||||
"eslint-plugin-vue": "10.5.0",
|
||||
"eslint-plugin-vue": "10.5.1",
|
||||
"glob": "11.0.3",
|
||||
"jsdom": "26.1.0",
|
||||
"npm-run-all": "4.1.5",
|
||||
|
|
@ -166,7 +166,7 @@
|
|||
"rollup-plugin-polyfill-node": "0.13.0",
|
||||
"sass": "1.93.2",
|
||||
"tailwindcss": "3.4.16",
|
||||
"typescript": "5.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"unplugin-fonts": "1.4.0",
|
||||
"unplugin-icons": "22.2.0",
|
||||
"unplugin-vue-components": "29.0.0",
|
||||
|
|
@ -176,7 +176,7 @@
|
|||
"vite-plugin-html-config": "2.0.2",
|
||||
"vite-plugin-pages": "0.33.1",
|
||||
"vite-plugin-pages-sitemap": "1.7.1",
|
||||
"vite-plugin-pwa": "1.0.3",
|
||||
"vite-plugin-pwa": "1.1.0",
|
||||
"vite-plugin-vue-layouts": "0.11.0",
|
||||
"vitest": "3.2.4",
|
||||
"vue-tsc": "1.8.8"
|
||||
|
|
|
|||
|
|
@ -143,6 +143,8 @@ declare module 'vue' {
|
|||
HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing']
|
||||
HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio']
|
||||
HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup']
|
||||
HoppSmartSelect: typeof import('@hoppscotch/ui')['HoppSmartSelect']
|
||||
HoppSmartSelectOption: typeof import('@hoppscotch/ui')['HoppSmartSelectOption']
|
||||
HoppSmartSelectWrapper: typeof import('@hoppscotch/ui')['HoppSmartSelectWrapper']
|
||||
HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver']
|
||||
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
|
||||
|
|
@ -212,9 +214,11 @@ declare module 'vue' {
|
|||
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
|
||||
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
|
||||
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
|
||||
IconLucideCheck: typeof import('~icons/lucide/check')['default']
|
||||
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
|
||||
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
||||
IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default']
|
||||
IconLucideCopy: typeof import('~icons/lucide/copy')['default']
|
||||
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
|
||||
IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default']
|
||||
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
|
||||
|
|
@ -256,6 +260,11 @@ declare module 'vue' {
|
|||
LensesRenderersVideoLensRenderer: typeof import('./components/lenses/renderers/VideoLensRenderer.vue')['default']
|
||||
LensesRenderersXMLLensRenderer: typeof import('./components/lenses/renderers/XMLLensRenderer.vue')['default']
|
||||
LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default']
|
||||
MockServerCreateMockServer: typeof import('./components/mockServer/CreateMockServer.vue')['default']
|
||||
MockServerEditMockServer: typeof import('./components/mockServer/EditMockServer.vue')['default']
|
||||
MockServerLogSection: typeof import('./components/mockServer/LogSection.vue')['default']
|
||||
MockServerMockServerDashboard: typeof import('./components/mockServer/MockServerDashboard.vue')['default']
|
||||
MockServerMockServerLogs: typeof import('./components/mockServer/MockServerLogs.vue')['default']
|
||||
MonacoScriptEditor: typeof import('./components/MonacoScriptEditor.vue')['default']
|
||||
ProfileUserDelete: typeof import('./components/profile/UserDelete.vue')['default']
|
||||
RealtimeCommunication: typeof import('./components/realtime/Communication.vue')['default']
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@
|
|||
class="!focus-visible:text-blue-600 !hover:text-blue-600 h-8 rounded border border-blue-600/25 bg-blue-500/10 pr-8 !text-blue-500 hover:border-blue-600/20 hover:bg-blue-600/20 focus-visible:border-blue-600/20 focus-visible:bg-blue-600/20"
|
||||
/>
|
||||
</HoppSmartSelectWrapper>
|
||||
<template #content="{ hide }">
|
||||
<template #content="{ hide, state }">
|
||||
<div
|
||||
ref="accountActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
|
|
@ -211,7 +211,7 @@
|
|||
@keyup.escape="hide()"
|
||||
@click="hide()"
|
||||
>
|
||||
<WorkspaceSelector />
|
||||
<WorkspaceSelector :state="state" />
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ const platforms = [
|
|||
{
|
||||
name: "Twitter",
|
||||
icon: IconTwitter,
|
||||
link: `https://twitter.com/intent/tweet?text=${text} ${description}&url=${url}&via=${twitter}`,
|
||||
link: `https://x.com/intent/tweet?text=${text} ${description}&url=${url}&via=${twitter}`,
|
||||
},
|
||||
{
|
||||
name: "Facebook",
|
||||
|
|
|
|||
|
|
@ -56,6 +56,26 @@
|
|||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
||||
{{ collectionName }}
|
||||
</span>
|
||||
<!-- Mock Server Status Indicator -->
|
||||
<span
|
||||
v-if="mockServerStatus.exists"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
mockServerStatus.isActive
|
||||
? t('mock_server.active')
|
||||
: t('mock_server.inactive')
|
||||
"
|
||||
class="ml-2 flex items-center"
|
||||
>
|
||||
<component
|
||||
:is="IconServer"
|
||||
class="svg-icons"
|
||||
:class="{
|
||||
'text-green-500': mockServerStatus.isActive,
|
||||
'text-secondaryLight': !mockServerStatus.isActive,
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -115,6 +135,9 @@
|
|||
@keyup.p="propertiesAction?.$el.click()"
|
||||
@keyup.t="runCollectionAction?.$el.click()"
|
||||
@keyup.s="sortAction?.$el.click()"
|
||||
@keyup.m="
|
||||
isMockServerVisible && mockServerAction?.$el.click()
|
||||
"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<HoppSmartItem
|
||||
|
|
@ -155,6 +178,23 @@
|
|||
}
|
||||
"
|
||||
/>
|
||||
<HoppSmartItem
|
||||
v-if="
|
||||
!hasNoTeamAccess &&
|
||||
isRootCollection &&
|
||||
isMockServerVisible
|
||||
"
|
||||
ref="mockServerAction"
|
||||
:icon="IconServer"
|
||||
:label="t('mock_server.create_mock_server')"
|
||||
:shortcut="['M']"
|
||||
@click="
|
||||
() => {
|
||||
handleMockServerAction()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<HoppSmartItem
|
||||
v-if="!hasNoTeamAccess"
|
||||
ref="edit"
|
||||
|
|
@ -280,11 +320,16 @@ import IconFolderOpen from "~icons/lucide/folder-open"
|
|||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconPlaySquare from "~icons/lucide/play-square"
|
||||
import IconServer from "~icons/lucide/server"
|
||||
import IconSettings2 from "~icons/lucide/settings-2"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconArrowUpDown from "~icons/lucide/arrow-up-down"
|
||||
import { CurrentSortValuesService } from "~/services/current-sort.service"
|
||||
import { useService } from "dioc/vue"
|
||||
import { useMockServerStatus } from "~/composables/mockServer"
|
||||
import { useMockServerVisibility } from "~/composables/mockServerVisibility"
|
||||
import { platform } from "~/platform"
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
|
||||
type CollectionType = "my-collections" | "team-collections"
|
||||
type FolderType = "collection" | "folder"
|
||||
|
|
@ -337,6 +382,7 @@ const emit = defineEmits<{
|
|||
(event: "duplicate-collection"): void
|
||||
(event: "export-data"): void
|
||||
(event: "remove-collection"): void
|
||||
(event: "create-mock-server"): void
|
||||
(event: "drop-event", payload: DataTransfer): void
|
||||
(event: "drag-event", payload: DataTransfer): void
|
||||
(event: "dragging", payload: boolean): void
|
||||
|
|
@ -360,6 +406,7 @@ const edit = ref<HTMLButtonElement | null>(null)
|
|||
const duplicateAction = ref<HTMLButtonElement | null>(null)
|
||||
const deleteAction = ref<HTMLButtonElement | null>(null)
|
||||
const exportAction = ref<HTMLButtonElement | null>(null)
|
||||
const mockServerAction = ref<HTMLButtonElement | null>(null)
|
||||
const options = ref<TippyComponent | null>(null)
|
||||
const propertiesAction = ref<HTMLButtonElement | null>(null)
|
||||
const runCollectionAction = ref<HTMLButtonElement | null>(null)
|
||||
|
|
@ -415,6 +462,29 @@ const isCollectionLoading = computed(() => {
|
|||
return props.teamLoadingCollections!.includes(props.id)
|
||||
})
|
||||
|
||||
// Mock Server Status
|
||||
const { isMockServerVisible } = useMockServerVisibility()
|
||||
const { getMockServerStatus } = useMockServerStatus()
|
||||
|
||||
const mockServerStatus = computed(() => {
|
||||
if (!isMockServerVisible.value) {
|
||||
return { exists: false, isActive: false }
|
||||
}
|
||||
|
||||
const collectionId =
|
||||
props.collectionsType === "my-collections"
|
||||
? ((props.data as HoppCollection).id ??
|
||||
(props.data as HoppCollection)._ref_id)
|
||||
: (props.data as TeamCollection).id
|
||||
|
||||
return getMockServerStatus(collectionId || "")
|
||||
})
|
||||
|
||||
// Determine if this is a root collection (not a child folder)
|
||||
const isRootCollection = computed(() => {
|
||||
return props.folderType === "collection"
|
||||
})
|
||||
|
||||
// Used to determine if the collection is being dragged to a different destination
|
||||
// This is used to make the highlight effect work
|
||||
watch(
|
||||
|
|
@ -580,6 +650,19 @@ const sortCollection = () => {
|
|||
})
|
||||
}
|
||||
|
||||
const handleMockServerAction = () => {
|
||||
const currentUser = platform.auth.getCurrentUser()
|
||||
|
||||
if (!currentUser) {
|
||||
// Show login modal if user is not authenticated
|
||||
invokeAction("modals.login.toggle")
|
||||
return
|
||||
}
|
||||
|
||||
// User is authenticated, proceed with mock server creation
|
||||
emit("create-mock-server")
|
||||
}
|
||||
|
||||
const resetDragState = () => {
|
||||
dragging.value = false
|
||||
ordering.value = false
|
||||
|
|
|
|||
|
|
@ -33,7 +33,11 @@ import { defineStep } from "~/composables/step-components"
|
|||
import AllCollectionImport from "~/components/importExport/ImportExportSteps/AllCollectionImport.vue"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
|
||||
import {
|
||||
appendRESTCollections,
|
||||
restCollections$,
|
||||
setRESTCollections,
|
||||
} from "~/newstore/collections"
|
||||
|
||||
import IconInsomnia from "~icons/hopp/insomnia"
|
||||
import IconPostman from "~icons/hopp/postman"
|
||||
|
|
@ -46,6 +50,11 @@ import { useReadonlyStream } from "~/composables/stream"
|
|||
import IconUser from "~icons/lucide/user"
|
||||
|
||||
import { getTeamCollectionJSON } from "~/helpers/backend/helpers"
|
||||
import {
|
||||
importUserCollectionsFromJSON,
|
||||
fetchAndConvertUserCollections,
|
||||
} from "~/helpers/backend/mutations/UserCollection"
|
||||
import { ReqType } from "~/helpers/backend/graphql"
|
||||
|
||||
import { platform } from "~/platform"
|
||||
|
||||
|
|
@ -59,7 +68,6 @@ import { GistSource } from "~/helpers/import-export/import/import-sources/GistSo
|
|||
import { TeamWorkspace } from "~/services/workspace.service"
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
|
||||
const isPostmanImporterInProgress = ref(false)
|
||||
const isInsomniaImporterInProgress = ref(false)
|
||||
const isOpenAPIImporterInProgress = ref(false)
|
||||
const isRESTImporterInProgress = ref(false)
|
||||
|
|
@ -102,7 +110,7 @@ const showImportFailedError = () => {
|
|||
const handleImportToStore = async (collections: HoppCollection[]) => {
|
||||
const importResult =
|
||||
props.collectionsType.type === "my-collections"
|
||||
? importToPersonalWorkspace(collections)
|
||||
? await importToPersonalWorkspace(collections)
|
||||
: await importToTeamsWorkspace(collections)
|
||||
|
||||
if (E.isRight(importResult)) {
|
||||
|
|
@ -112,11 +120,46 @@ const handleImportToStore = async (collections: HoppCollection[]) => {
|
|||
}
|
||||
}
|
||||
|
||||
const importToPersonalWorkspace = (collections: HoppCollection[]) => {
|
||||
appendRESTCollections(collections)
|
||||
return E.right({
|
||||
success: true,
|
||||
})
|
||||
const importToPersonalWorkspace = async (collections: HoppCollection[]) => {
|
||||
// If user is logged in, try to import to backend first
|
||||
if (currentUser.value) {
|
||||
try {
|
||||
const transformedCollection = collections.map((collection) =>
|
||||
translateToPersonalCollectionFormat(collection)
|
||||
)
|
||||
|
||||
const res = await importUserCollectionsFromJSON(
|
||||
JSON.stringify(transformedCollection),
|
||||
ReqType.Rest
|
||||
)()
|
||||
|
||||
if (E.isRight(res)) {
|
||||
// Backend import succeeded, now fetch and persist collections in store
|
||||
const fetchResult = await fetchAndConvertUserCollections(ReqType.Rest)
|
||||
|
||||
if (E.isRight(fetchResult)) {
|
||||
// Replace local collections with backend collections
|
||||
setRESTCollections(fetchResult.right)
|
||||
} else {
|
||||
// Failed to fetch, append to local store as fallback
|
||||
appendRESTCollections(collections)
|
||||
}
|
||||
|
||||
return E.right({ success: true })
|
||||
}
|
||||
// Backend import failed, fall back to local storage
|
||||
appendRESTCollections(collections)
|
||||
return E.right({ success: true })
|
||||
} catch {
|
||||
// Backend import failed, fall back to local storage
|
||||
appendRESTCollections(collections)
|
||||
return E.right({ success: true })
|
||||
}
|
||||
} else {
|
||||
// User not logged in, use local storage
|
||||
appendRESTCollections(collections)
|
||||
return E.right({ success: true })
|
||||
}
|
||||
}
|
||||
|
||||
function translateToTeamCollectionFormat(x: HoppCollection) {
|
||||
|
|
@ -141,6 +184,28 @@ function translateToTeamCollectionFormat(x: HoppCollection) {
|
|||
return obj
|
||||
}
|
||||
|
||||
function translateToPersonalCollectionFormat(x: HoppCollection) {
|
||||
const folders: HoppCollection[] = (x.folders ?? []).map(
|
||||
translateToPersonalCollectionFormat
|
||||
)
|
||||
|
||||
const data = {
|
||||
auth: x.auth,
|
||||
headers: x.headers,
|
||||
variables: x.variables,
|
||||
}
|
||||
|
||||
const obj = {
|
||||
...x,
|
||||
folders,
|
||||
data,
|
||||
}
|
||||
|
||||
if (x.id) obj.id = x.id
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
const importToTeamsWorkspace = async (collections: HoppCollection[]) => {
|
||||
if (!hasTeamWriteAccess.value || !selectedTeamID.value) {
|
||||
return E.left({
|
||||
|
|
@ -171,6 +236,7 @@ const emit = defineEmits<{
|
|||
const isHoppMyCollectionExporterInProgress = ref(false)
|
||||
const isHoppTeamCollectionExporterInProgress = ref(false)
|
||||
const isHoppGistCollectionExporterInProgress = ref(false)
|
||||
const isPostmanImporterInProgress = ref(false)
|
||||
|
||||
const isTeamWorkspace = computed(() => {
|
||||
return props.collectionsType.type === "team-collections"
|
||||
|
|
@ -179,19 +245,83 @@ const isTeamWorkspace = computed(() => {
|
|||
const currentImportSummary: Ref<{
|
||||
showImportSummary: boolean
|
||||
importedCollections: HoppCollection[] | null
|
||||
scriptsImported?: boolean
|
||||
originalScriptCounts?: { preRequest: number; test: number }
|
||||
}> = ref({
|
||||
showImportSummary: false,
|
||||
importedCollections: null,
|
||||
scriptsImported: false,
|
||||
originalScriptCounts: undefined,
|
||||
})
|
||||
|
||||
const setCurrentImportSummary = (collections: HoppCollection[]) => {
|
||||
const setCurrentImportSummary = (
|
||||
collections: HoppCollection[],
|
||||
scriptsImported?: boolean,
|
||||
originalScriptCounts?: { preRequest: number; test: number }
|
||||
) => {
|
||||
currentImportSummary.value.importedCollections = collections
|
||||
currentImportSummary.value.showImportSummary = true
|
||||
currentImportSummary.value.scriptsImported = scriptsImported
|
||||
currentImportSummary.value.originalScriptCounts = originalScriptCounts
|
||||
}
|
||||
|
||||
const unsetCurrentImportSummary = () => {
|
||||
currentImportSummary.value.importedCollections = null
|
||||
currentImportSummary.value.showImportSummary = false
|
||||
currentImportSummary.value.scriptsImported = false
|
||||
currentImportSummary.value.originalScriptCounts = undefined
|
||||
}
|
||||
|
||||
// Count scripts in raw Postman collection JSON (before import strips them)
|
||||
const countPostmanScripts = (
|
||||
content: string[]
|
||||
): { preRequest: number; test: number } => {
|
||||
let preRequestCount = 0
|
||||
let testCount = 0
|
||||
|
||||
const countInItem = (item: any) => {
|
||||
// Only count if this is a request (has request object), not a folder
|
||||
const isRequest = item?.request !== undefined
|
||||
|
||||
if (isRequest && item?.event) {
|
||||
const prerequest = item.event.find((e: any) => e.listen === "prerequest")
|
||||
const test = item.event.find((e: any) => e.listen === "test")
|
||||
|
||||
if (
|
||||
prerequest?.script?.exec &&
|
||||
Array.isArray(prerequest.script.exec) &&
|
||||
prerequest.script.exec.some((line: string) => line?.trim())
|
||||
) {
|
||||
preRequestCount++
|
||||
}
|
||||
|
||||
if (
|
||||
test?.script?.exec &&
|
||||
Array.isArray(test.script.exec) &&
|
||||
test.script.exec.some((line: string) => line?.trim())
|
||||
) {
|
||||
testCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively count in nested items (folders)
|
||||
if (item?.item && Array.isArray(item.item)) {
|
||||
item.item.forEach(countInItem)
|
||||
}
|
||||
}
|
||||
|
||||
content.forEach((fileContent) => {
|
||||
try {
|
||||
const collection = JSON.parse(fileContent)
|
||||
if (collection?.item && Array.isArray(collection.item)) {
|
||||
collection.item.forEach(countInItem)
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid JSON, skip
|
||||
}
|
||||
})
|
||||
|
||||
return { preRequest: preRequestCount, test: testCount }
|
||||
}
|
||||
|
||||
const HoppRESTImporter: ImporterOrExporter = {
|
||||
|
|
@ -379,15 +509,20 @@ const HoppPostmanImporter: ImporterOrExporter = {
|
|||
caption: "import.from_file",
|
||||
acceptedFileTypes: ".json",
|
||||
description: "import.from_postman_import_summary",
|
||||
onImportFromFile: async (content) => {
|
||||
showPostmanScriptOption: true,
|
||||
onImportFromFile: async (content: string[], importScripts?: boolean) => {
|
||||
isPostmanImporterInProgress.value = true
|
||||
|
||||
const res = await hoppPostmanImporter(content)()
|
||||
// Count scripts from raw Postman JSON before importing
|
||||
const originalCounts =
|
||||
importScripts === undefined ? countPostmanScripts(content) : undefined
|
||||
|
||||
const res = await hoppPostmanImporter(content, importScripts ?? false)()
|
||||
|
||||
if (E.isRight(res)) {
|
||||
await handleImportToStore(res.right)
|
||||
|
||||
setCurrentImportSummary(res.right)
|
||||
setCurrentImportSummary(res.right, importScripts, originalCounts)
|
||||
|
||||
platform.analytics?.logEvent({
|
||||
platform: "rest",
|
||||
|
|
|
|||
|
|
@ -99,6 +99,13 @@
|
|||
collection: node.data.data.data,
|
||||
})
|
||||
"
|
||||
@create-mock-server="
|
||||
node.data.type === 'collections' &&
|
||||
emit('create-mock-server', {
|
||||
collectionIndex: node.id,
|
||||
collection: node.data.data.data,
|
||||
})
|
||||
"
|
||||
@export-data="
|
||||
node.data.type === 'collections' &&
|
||||
emit('export-data', node.data.data.data)
|
||||
|
|
@ -647,6 +654,13 @@ const emit = defineEmits<{
|
|||
(event: "select", payload: Picked | null): void
|
||||
(event: "display-modal-import-export"): void
|
||||
(event: "select-response", payload: ResponsePayload): void
|
||||
(
|
||||
event: "create-mock-server",
|
||||
payload: {
|
||||
collectionIndex: string
|
||||
collection: HoppCollection
|
||||
}
|
||||
): void
|
||||
}>()
|
||||
|
||||
const refFilterCollection = toRef(props, "filteredCollections")
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ import { TeamWorkspace } from "~/services/workspace.service"
|
|||
import IconSparkle from "~icons/lucide/sparkles"
|
||||
import IconThumbsDown from "~icons/lucide/thumbs-down"
|
||||
import IconThumbsUp from "~icons/lucide/thumbs-up"
|
||||
import { handleTokenValidation } from "~/helpers/handleTokenValidation"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
|
@ -312,6 +313,9 @@ const onSelect = (pickedVal: Picked | null) => {
|
|||
}
|
||||
|
||||
const saveRequestAs = async () => {
|
||||
const isValidToken = await handleTokenValidation()
|
||||
if (!isValidToken) return
|
||||
|
||||
if (!requestName.value) {
|
||||
toast.error(`${t("error.empty_req_name")}`)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -118,6 +118,13 @@
|
|||
collection: node.data.data.data,
|
||||
})
|
||||
"
|
||||
@create-mock-server="
|
||||
node.data.type === 'collections' &&
|
||||
emit('create-mock-server', {
|
||||
collectionID: node.data.data.data.id,
|
||||
collection: node.data.data.data,
|
||||
})
|
||||
"
|
||||
@export-data="
|
||||
node.data.type === 'collections' &&
|
||||
emit('export-data', node.data.data.data)
|
||||
|
|
@ -716,6 +723,13 @@ const emit = defineEmits<{
|
|||
event: "run-collection",
|
||||
payload: { collectionID: string; path: string }
|
||||
): void
|
||||
(
|
||||
event: "create-mock-server",
|
||||
payload: {
|
||||
collectionID: string
|
||||
collection: TeamCollection
|
||||
}
|
||||
): void
|
||||
}>()
|
||||
|
||||
const currentSortValuesService = useService(CurrentSortValuesService)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ import { useToast } from "~/composables/toast"
|
|||
import { ImporterOrExporter } from "~/components/importExport/types"
|
||||
import { FileSource } from "~/helpers/import-export/import/import-sources/FileSource"
|
||||
import { GistSource } from "~/helpers/import-export/import/import-sources/GistSource"
|
||||
import {
|
||||
importUserCollectionsFromJSON,
|
||||
fetchAndConvertUserCollections,
|
||||
} from "~/helpers/backend/mutations/UserCollection"
|
||||
import { ReqType } from "~/helpers/backend/graphql"
|
||||
|
||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
import IconUser from "~icons/lucide/user"
|
||||
|
|
@ -28,6 +33,7 @@ import { platform } from "~/platform"
|
|||
import {
|
||||
appendGraphqlCollections,
|
||||
graphqlCollections$,
|
||||
setGraphqlCollections,
|
||||
} from "~/newstore/collections"
|
||||
import { hoppGqlCollectionsImporter } from "~/helpers/import-export/import/hoppGql"
|
||||
import { gqlCollectionsExporter } from "~/helpers/import-export/export/gqlCollections"
|
||||
|
|
@ -71,7 +77,7 @@ const GqlCollectionsHoppImporter: ImporterOrExporter = {
|
|||
)()
|
||||
|
||||
if (E.isRight(validatedCollection)) {
|
||||
handleImportToStore(validatedCollection.right)
|
||||
await handleImportToStore(validatedCollection.right)
|
||||
|
||||
platform.analytics?.logEvent({
|
||||
type: "HOPP_IMPORT_COLLECTION",
|
||||
|
|
@ -110,7 +116,7 @@ const GqlCollectionsGistImporter: ImporterOrExporter = {
|
|||
return
|
||||
}
|
||||
|
||||
handleImportToStore(res.right)
|
||||
await handleImportToStore(res.right)
|
||||
|
||||
platform.analytics?.logEvent({
|
||||
type: "HOPP_IMPORT_COLLECTION",
|
||||
|
|
@ -231,9 +237,71 @@ const showImportFailedError = () => {
|
|||
toast.error(t("import.failed"))
|
||||
}
|
||||
|
||||
const handleImportToStore = (gqlCollections: HoppCollection[]) => {
|
||||
appendGraphqlCollections(gqlCollections)
|
||||
toast.success(t("state.file_imported"))
|
||||
const handleImportToStore = async (gqlCollections: HoppCollection[]) => {
|
||||
// If user is logged in, try to import to backend first
|
||||
if (currentUser.value) {
|
||||
try {
|
||||
const transformedCollection = gqlCollections.map((collection) =>
|
||||
translateToPersonalCollectionFormat(collection)
|
||||
)
|
||||
|
||||
const res = await importUserCollectionsFromJSON(
|
||||
JSON.stringify(transformedCollection),
|
||||
ReqType.Gql
|
||||
)()
|
||||
|
||||
if (E.isRight(res)) {
|
||||
// Backend import succeeded, now fetch and persist collections in store
|
||||
const fetchResult = await fetchAndConvertUserCollections(ReqType.Gql)
|
||||
|
||||
if (E.isRight(fetchResult)) {
|
||||
// Replace local collections with backend collections
|
||||
setGraphqlCollections(fetchResult.right)
|
||||
} else {
|
||||
// Failed to fetch, append to local store as fallback
|
||||
appendGraphqlCollections(gqlCollections)
|
||||
}
|
||||
|
||||
toast.success(t("state.file_imported"))
|
||||
return
|
||||
}
|
||||
// Backend import failed, fall back to local storage
|
||||
appendGraphqlCollections(gqlCollections)
|
||||
toast.success(t("state.file_imported"))
|
||||
return
|
||||
} catch {
|
||||
// Backend import failed, fall back to local storage
|
||||
appendGraphqlCollections(gqlCollections)
|
||||
toast.success(t("state.file_imported"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// User not logged in, use local storage
|
||||
appendGraphqlCollections(gqlCollections)
|
||||
toast.success(t("state.file_imported"))
|
||||
}
|
||||
}
|
||||
|
||||
function translateToPersonalCollectionFormat(x: HoppCollection) {
|
||||
const folders: HoppCollection[] = (x.folders ?? []).map(
|
||||
translateToPersonalCollectionFormat
|
||||
)
|
||||
|
||||
const data = {
|
||||
auth: x.auth,
|
||||
headers: x.headers,
|
||||
variables: x.variables,
|
||||
}
|
||||
|
||||
const obj = {
|
||||
...x,
|
||||
folders,
|
||||
data,
|
||||
}
|
||||
|
||||
if (x.id) obj.id = x.id
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@
|
|||
@duplicate-request="duplicateRequest"
|
||||
@duplicate-response="duplicateResponse"
|
||||
@edit-properties="editProperties"
|
||||
@create-mock-server="createMockServer"
|
||||
@export-data="exportData"
|
||||
@remove-collection="removeCollection"
|
||||
@remove-folder="removeFolder"
|
||||
|
|
@ -104,6 +105,7 @@
|
|||
@edit-request="editRequest"
|
||||
@edit-response="editResponse"
|
||||
@edit-properties="editProperties"
|
||||
@create-mock-server="createTeamMockServer"
|
||||
@export-data="exportData"
|
||||
@expand-team-collection="expandTeamCollection"
|
||||
@remove-collection="removeCollection"
|
||||
|
|
@ -220,6 +222,8 @@
|
|||
:collection-runner-data="collectionRunnerData"
|
||||
@hide-modal="showCollectionsRunnerModal = false"
|
||||
/>
|
||||
|
||||
<MockServerCreateMockServer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -1073,6 +1077,46 @@ const updateEditingCollection = async (newName: string) => {
|
|||
}
|
||||
}
|
||||
|
||||
const createMockServer = (payload: {
|
||||
collectionIndex: string
|
||||
collection: HoppCollection
|
||||
}) => {
|
||||
// Import the mock server store dynamically to avoid circular dependencies
|
||||
import("~/newstore/mockServers").then(({ showCreateMockServerModal$ }) => {
|
||||
let collectionID = payload.collection.id ?? payload.collection._ref_id
|
||||
|
||||
// If this is a child collection (folder), we need to get the root collection ID
|
||||
if (payload.collectionIndex.includes("/")) {
|
||||
// Extract the root collection index from the path (e.g., "0/1/2" -> "0")
|
||||
const rootIndex = payload.collectionIndex.split("/")[0]
|
||||
const rootCollection = myCollections.value[parseInt(rootIndex)]
|
||||
if (rootCollection) {
|
||||
collectionID = rootCollection.id ?? rootCollection._ref_id
|
||||
}
|
||||
}
|
||||
|
||||
showCreateMockServerModal$.next({
|
||||
show: true,
|
||||
collectionID: collectionID,
|
||||
collectionName: payload.collection.name,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const createTeamMockServer = (payload: {
|
||||
collectionID: string
|
||||
collection: TeamCollection
|
||||
}) => {
|
||||
// Import the mock server store dynamically to avoid circular dependencies
|
||||
import("~/newstore/mockServers").then(({ showCreateMockServerModal$ }) => {
|
||||
showCreateMockServerModal$.next({
|
||||
show: true,
|
||||
collectionID: payload.collectionID,
|
||||
collectionName: payload.collection.title,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const editFolder = (payload: {
|
||||
folderPath: string | undefined
|
||||
folder: HoppCollection | TeamCollection
|
||||
|
|
@ -2787,9 +2831,12 @@ const exportData = async (collection: HoppCollection | TeamCollection) => {
|
|||
if (collectionsType.value.type === "my-collections") {
|
||||
const collectionJSON = JSON.stringify(collection, null, 2)
|
||||
|
||||
// Strip `export {};\n` from `testScript` and `preRequestScript` fields
|
||||
const cleanedCollectionJSON = collectionJSON.replace(/export \{\};\\n/g, "")
|
||||
|
||||
const name = (collection as HoppCollection).name
|
||||
|
||||
initializeDownloadCollection(collectionJSON, name)
|
||||
initializeDownloadCollection(cleanedCollectionJSON, name)
|
||||
} else {
|
||||
if (!collection.id) return
|
||||
exportLoading.value = true
|
||||
|
|
@ -2806,8 +2853,14 @@ const exportData = async (collection: HoppCollection | TeamCollection) => {
|
|||
const hoppColl = teamCollToHoppRESTColl(coll)
|
||||
const collectionJSONString = JSON.stringify(hoppColl, null, 2)
|
||||
|
||||
// Strip `export {};\n` from `testScript` and `preRequestScript` fields
|
||||
const cleanedCollectionJSON = collectionJSONString.replace(
|
||||
/export \{\};\\n/g,
|
||||
""
|
||||
)
|
||||
|
||||
await initializeDownloadCollection(
|
||||
collectionJSONString,
|
||||
cleanedCollectionJSON,
|
||||
hoppColl.name
|
||||
)
|
||||
exportLoading.value = false
|
||||
|
|
|
|||
|
|
@ -270,6 +270,7 @@ import { RESTTabService } from "~/services/tab/rest"
|
|||
import { getMethodLabelColor } from "~/helpers/rest/labelColoring"
|
||||
import { WorkspaceService } from "~/services/workspace.service"
|
||||
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
|
||||
import { handleTokenValidation } from "~/helpers/handleTokenValidation"
|
||||
|
||||
const t = useI18n()
|
||||
const interceptorService = useService(KernelInterceptorService)
|
||||
|
|
@ -514,7 +515,10 @@ const cycleDownMethod = () => {
|
|||
}
|
||||
}
|
||||
|
||||
const saveRequest = () => {
|
||||
const saveRequest = async () => {
|
||||
const isValidToken = await handleTokenValidation()
|
||||
if (!isValidToken) return
|
||||
|
||||
const saveCtx = tab.value.document.saveContext
|
||||
|
||||
if (!saveCtx) {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,19 @@
|
|||
class="px-4 mt-4"
|
||||
/>
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
v-if="isMockServerVisible"
|
||||
:id="'mock-servers'"
|
||||
:icon="IconServer"
|
||||
:label="`${t('tab.mock_servers')}`"
|
||||
>
|
||||
<div
|
||||
class="flex items-center overflow-x-auto whitespace-nowrap border-b border-dividerLight px-4 py-2 text-tiny text-secondaryLight"
|
||||
>
|
||||
<span class="truncate"> {{ t("tab.mock_servers") }} </span>
|
||||
</div>
|
||||
<MockServerDashboard v-if="selectedNavigationTab === 'mock-servers'" />
|
||||
</HoppSmartTab>
|
||||
</HoppSmartTabs>
|
||||
</template>
|
||||
|
||||
|
|
@ -60,17 +73,27 @@ import IconLayers from "~icons/lucide/layers"
|
|||
import IconFolder from "~icons/lucide/folder"
|
||||
import IconShare2 from "~icons/lucide/share-2"
|
||||
import IconCode from "~icons/lucide/code"
|
||||
import IconServer from "~icons/lucide/server"
|
||||
import { ref } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import MockServerDashboard from "~/components/mockServer/MockServerDashboard.vue"
|
||||
import { useMockServerWorkspaceSync } from "~/composables/mockServerWorkspace"
|
||||
import { useMockServerVisibility } from "~/composables/mockServerVisibility"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const { isMockServerVisible } = useMockServerVisibility()
|
||||
|
||||
type RequestOptionTabs =
|
||||
| "history"
|
||||
| "collections"
|
||||
| "env"
|
||||
| "share-request"
|
||||
| "codegen"
|
||||
| "mock-servers"
|
||||
|
||||
const selectedNavigationTab = ref<RequestOptionTabs>("collections")
|
||||
|
||||
// Ensure mock servers are kept in sync with workspace changes globally
|
||||
useMockServerWorkspaceSync()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -202,6 +202,8 @@ props.importerModules.forEach((importer) => {
|
|||
props: () => ({
|
||||
collections: importSummary.value.importedCollections,
|
||||
importFormat: importer.metadata.format,
|
||||
scriptsImported: importSummary.value.scriptsImported,
|
||||
originalScriptCounts: importSummary.value.originalScriptCounts,
|
||||
"on-close": () => {
|
||||
emit("hide-modal")
|
||||
},
|
||||
|
|
|
|||
|
|
@ -52,13 +52,41 @@
|
|||
}}
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<!-- Postman-specific: Script import checkbox (only use case so far) -->
|
||||
<div
|
||||
v-if="showPostmanScriptOption && experimentalScriptingEnabled"
|
||||
class="flex items-start space-x-3 px-1"
|
||||
>
|
||||
<HoppSmartCheckbox
|
||||
:on="importScripts"
|
||||
@change="importScripts = !importScripts"
|
||||
/>
|
||||
<label
|
||||
for="importScriptsCheckbox"
|
||||
class="cursor-pointer select-none text-secondary flex flex-col space-y-0.5"
|
||||
>
|
||||
<span class="font-semibold flex space-x-1">
|
||||
<span>
|
||||
{{ t("import.import_scripts") }}
|
||||
</span>
|
||||
<span class="text-tiny text-secondaryLight">
|
||||
({{ t("state.experimental") }})
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-tiny text-secondaryLight">
|
||||
{{ t("import.import_scripts_description") }}</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<HoppButtonPrimary
|
||||
:disabled="disableImportCTA"
|
||||
:label="t('import.title')"
|
||||
:loading="loading"
|
||||
class="w-full"
|
||||
@click="emit('importFromFile', fileContent)"
|
||||
@click="handleImport"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -69,6 +97,7 @@ import { useI18n } from "@composables/i18n"
|
|||
import { useToast } from "@composables/toast"
|
||||
import { computed, ref } from "vue"
|
||||
import { platform } from "~/platform"
|
||||
import { useSetting } from "~/composables/settings"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
|
@ -76,16 +105,24 @@ const props = withDefaults(
|
|||
acceptedFileTypes: string
|
||||
loading?: boolean
|
||||
description?: string
|
||||
showPostmanScriptOption?: boolean
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
description: undefined,
|
||||
showPostmanScriptOption: false,
|
||||
}
|
||||
)
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
// Postman-specific: Script import state (only use case so far)
|
||||
const importScripts = ref(false)
|
||||
const experimentalScriptingEnabled = useSetting(
|
||||
"EXPERIMENTAL_SCRIPTING_SANDBOX"
|
||||
)
|
||||
|
||||
const ALLOWED_FILE_SIZE_LIMIT = platform.limits?.collectionImportSizeLimit ?? 10 // Default to 10 MB
|
||||
|
||||
const importFilesCount = ref(0)
|
||||
|
|
@ -97,7 +134,7 @@ const fileContent = ref<string[]>([])
|
|||
const inputChooseFileToImportFrom = ref<HTMLInputElement | any>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "importFromFile", content: string[]): void
|
||||
(e: "importFromFile", content: string[], ...additionalArgs: any[]): void
|
||||
}>()
|
||||
|
||||
// Disable the import CTA if no file is selected, the file size limit is exceeded, or during an import action indicated by the `isLoading` prop
|
||||
|
|
@ -106,6 +143,16 @@ const disableImportCTA = computed(
|
|||
!hasFile.value || showFileSizeLimitExceededWarning.value || props.loading
|
||||
)
|
||||
|
||||
const handleImport = () => {
|
||||
// If Postman script option is enabled AND experimental sandbox is enabled, pass the importScripts value
|
||||
// Otherwise, don't pass it (undefined) to indicate the feature wasn't available
|
||||
if (props.showPostmanScriptOption && experimentalScriptingEnabled.value) {
|
||||
emit("importFromFile", fileContent.value, importScripts.value)
|
||||
} else {
|
||||
emit("importFromFile", fileContent.value)
|
||||
}
|
||||
}
|
||||
|
||||
const onFileChange = async () => {
|
||||
// Reset the state on entering the handler to avoid any stale state
|
||||
if (showFileSizeLimitExceededWarning.value) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,120 @@
|
|||
<template>
|
||||
<div class="flex flex-col p-1">
|
||||
<div class="space-y-4 p-1">
|
||||
<div v-for="feature in visibleFeatures" :key="feature.id">
|
||||
<p class="flex items-center">
|
||||
<span
|
||||
class="inline-flex items-center justify-center flex-shrink-0 mr-4 border-4 rounded-full border-primary"
|
||||
:class="{
|
||||
'text-green-500':
|
||||
featureSupportForImportFormat[feature.id] === 'SUPPORTED' ||
|
||||
featureSupportForImportFormat[feature.id] === 'SKIPPED',
|
||||
'text-amber-500':
|
||||
featureSupportForImportFormat[feature.id] ===
|
||||
'NOT_SUPPORTED_BY_HOPPSCOTCH_IMPORT',
|
||||
}"
|
||||
>
|
||||
<icon-lucide-check-circle
|
||||
v-if="
|
||||
featureSupportForImportFormat[feature.id] === 'SUPPORTED' ||
|
||||
featureSupportForImportFormat[feature.id] === 'SKIPPED'
|
||||
"
|
||||
class="svg-icons"
|
||||
/>
|
||||
|
||||
<IconInfo
|
||||
v-else-if="
|
||||
featureSupportForImportFormat[feature.id] ===
|
||||
'NOT_SUPPORTED_BY_HOPPSCOTCH_IMPORT'
|
||||
"
|
||||
class="svg-icons"
|
||||
/>
|
||||
</span>
|
||||
<span>{{ t(feature.label) }}</span>
|
||||
</p>
|
||||
|
||||
<p class="ml-10 text-secondaryLight">
|
||||
<template
|
||||
v-if="featureSupportForImportFormat[feature.id] === 'SUPPORTED'"
|
||||
>
|
||||
{{ feature.count }}
|
||||
{{
|
||||
feature.count != 1
|
||||
? t(feature.label)
|
||||
: t(feature.label).slice(0, -1)
|
||||
}}
|
||||
Imported
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-else-if="featureSupportForImportFormat[feature.id] === 'SKIPPED'"
|
||||
>
|
||||
0 {{ t(feature.label) }} Imported
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-else-if="
|
||||
featureSupportForImportFormat[feature.id] ===
|
||||
'NOT_SUPPORTED_BY_HOPPSCOTCH_IMPORT'
|
||||
"
|
||||
>
|
||||
<!-- Special message for Postman scripts when using legacy sandbox -->
|
||||
<template
|
||||
v-if="
|
||||
importFormat === 'postman' &&
|
||||
(feature.id === 'preRequestScripts' ||
|
||||
feature.id === 'testScripts') &&
|
||||
scriptsImported === undefined
|
||||
"
|
||||
>
|
||||
0 {{ t(feature.label) }} Imported
|
||||
</template>
|
||||
<!-- Generic message for other unsupported features -->
|
||||
<template v-else>
|
||||
{{
|
||||
t("import.import_summary_not_supported_by_hoppscotch_import", {
|
||||
featureLabel: t(feature.label),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Informational banner for script imports when experimental sandbox is disabled -->
|
||||
<div
|
||||
v-if="showScriptImportInfo"
|
||||
class="mt-6 flex items-start space-x-3 rounded border border-dividerLight shadow-sm bg-primaryLight px-2 py-4"
|
||||
>
|
||||
<IconInfo class="flex-shrink-0 text-accent svg-icons" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<p class="font-semibold text-secondary">
|
||||
{{ totalScriptsCount }}
|
||||
{{
|
||||
totalScriptsCount === 1
|
||||
? t("import.import_summary_script_found")
|
||||
: t("import.import_summary_scripts_found")
|
||||
}}
|
||||
</p>
|
||||
<p class="text-secondaryLight">
|
||||
{{ t("import.import_summary_enable_experimental_sandbox") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-9">
|
||||
<HoppButtonSecondary
|
||||
class="w-full"
|
||||
:label="t('action.close')"
|
||||
outline
|
||||
filled
|
||||
@click="onClose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
|
||||
import { computed, Ref, ref, watch } from "vue"
|
||||
|
|
@ -18,6 +135,7 @@ type FeatureStatus =
|
|||
| "SUPPORTED"
|
||||
| "NOT_SUPPORTED_BY_HOPPSCOTCH_IMPORT"
|
||||
| "NOT_SUPPORTED_BY_SOURCE"
|
||||
| "SKIPPED"
|
||||
|
||||
type FeatureWithCount = {
|
||||
count: number
|
||||
|
|
@ -28,6 +146,8 @@ type FeatureWithCount = {
|
|||
const props = defineProps<{
|
||||
importFormat: SupportedImportFormat
|
||||
collections: HoppCollection[]
|
||||
scriptsImported?: boolean
|
||||
originalScriptCounts?: { preRequest: number; test: number }
|
||||
onClose: () => void
|
||||
}>()
|
||||
|
||||
|
|
@ -158,7 +278,30 @@ watch(
|
|||
)
|
||||
|
||||
const featureSupportForImportFormat = computed(() => {
|
||||
return importSourceAndSupportedFeatures[props.importFormat]
|
||||
const baseSupport = importSourceAndSupportedFeatures[props.importFormat]
|
||||
|
||||
// Handle Postman script import status
|
||||
if (props.importFormat === "postman") {
|
||||
if (props.scriptsImported === true) {
|
||||
// User checked the box and imported scripts
|
||||
return {
|
||||
...baseSupport,
|
||||
preRequestScripts: "SUPPORTED" as FeatureStatus,
|
||||
testScripts: "SUPPORTED" as FeatureStatus,
|
||||
}
|
||||
} else if (props.scriptsImported === false) {
|
||||
// User explicitly didn't import scripts (checkbox unchecked)
|
||||
return {
|
||||
...baseSupport,
|
||||
preRequestScripts: "SKIPPED" as FeatureStatus,
|
||||
testScripts: "SKIPPED" as FeatureStatus,
|
||||
}
|
||||
}
|
||||
// props.scriptsImported === undefined means legacy sandbox or old import
|
||||
// Keep default NOT_SUPPORTED_BY_HOPPSCOTCH_IMPORT
|
||||
}
|
||||
|
||||
return baseSupport
|
||||
})
|
||||
|
||||
const visibleFeatures = computed(() => {
|
||||
|
|
@ -169,74 +312,23 @@ const visibleFeatures = computed(() => {
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
const showScriptImportInfo = computed(() => {
|
||||
return (
|
||||
props.importFormat === "postman" &&
|
||||
props.scriptsImported === undefined &&
|
||||
totalScriptsCount.value > 0
|
||||
)
|
||||
})
|
||||
|
||||
const totalScriptsCount = computed(() => {
|
||||
if (props.importFormat !== "postman" || props.scriptsImported !== undefined)
|
||||
return 0
|
||||
|
||||
// Use original counts from raw Postman JSON
|
||||
const preRequestScripts = props.originalScriptCounts?.preRequest || 0
|
||||
const testScripts = props.originalScriptCounts?.test || 0
|
||||
|
||||
return preRequestScripts + testScripts
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div v-for="feature in visibleFeatures" :key="feature.id">
|
||||
<p class="flex items-center">
|
||||
<span
|
||||
class="inline-flex items-center justify-center flex-shrink-0 mr-4 border-4 rounded-full border-primary"
|
||||
:class="{
|
||||
'text-green-500':
|
||||
featureSupportForImportFormat[feature.id] === 'SUPPORTED',
|
||||
'text-amber-500':
|
||||
featureSupportForImportFormat[feature.id] ===
|
||||
'NOT_SUPPORTED_BY_HOPPSCOTCH_IMPORT',
|
||||
}"
|
||||
>
|
||||
<icon-lucide-check-circle
|
||||
v-if="featureSupportForImportFormat[feature.id] === 'SUPPORTED'"
|
||||
class="svg-icons"
|
||||
/>
|
||||
|
||||
<IconInfo
|
||||
v-else-if="
|
||||
featureSupportForImportFormat[feature.id] ===
|
||||
'NOT_SUPPORTED_BY_HOPPSCOTCH_IMPORT'
|
||||
"
|
||||
class="svg-icons"
|
||||
/>
|
||||
</span>
|
||||
<span>{{ t(feature.label) }}</span>
|
||||
</p>
|
||||
|
||||
<p class="ml-10 text-secondaryLight">
|
||||
<template
|
||||
v-if="featureSupportForImportFormat[feature.id] === 'SUPPORTED'"
|
||||
>
|
||||
{{ feature.count }}
|
||||
{{
|
||||
feature.count != 1
|
||||
? t(feature.label)
|
||||
: t(feature.label).slice(0, -1)
|
||||
}}
|
||||
Imported
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-else-if="
|
||||
featureSupportForImportFormat[feature.id] ===
|
||||
'NOT_SUPPORTED_BY_HOPPSCOTCH_IMPORT'
|
||||
"
|
||||
>
|
||||
{{
|
||||
t("import.import_summary_not_supported_by_hoppscotch_import", {
|
||||
featureLabel: t(feature.label),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-10">
|
||||
<HoppButtonSecondary
|
||||
class="w-full"
|
||||
:label="t('action.close')"
|
||||
outline
|
||||
filled
|
||||
@click="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,543 @@
|
|||
<template>
|
||||
<HoppSmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="
|
||||
isExistingMockServer
|
||||
? t('mock_server.mock_server_configuration')
|
||||
: t('mock_server.create_mock_server')
|
||||
"
|
||||
@close="closeModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col space-y-6">
|
||||
<!-- Collection Selector or Info -->
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label class="text-sm font-semibold text-secondaryDark">
|
||||
{{ t("collection.title") }}
|
||||
</label>
|
||||
<!-- Collection Selector (when no collection is pre-selected) -->
|
||||
<div v-if="!collectionID && !isExistingMockServer" class="flex">
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions?.focus()"
|
||||
>
|
||||
<HoppSmartSelectWrapper>
|
||||
<HoppButtonSecondary
|
||||
class="flex flex-1 !justify-start rounded-none pr-8"
|
||||
:label="
|
||||
selectedCollectionName || t('mock_server.select_collection')
|
||||
"
|
||||
outline
|
||||
/>
|
||||
</HoppSmartSelectWrapper>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<HoppSmartLink
|
||||
v-for="option in collectionOptions"
|
||||
:key="option.value"
|
||||
class="flex flex-1"
|
||||
:class="{
|
||||
'opacity-50 cursor-not-allowed': option.disabled,
|
||||
}"
|
||||
@click="
|
||||
() => {
|
||||
if (!option.disabled) {
|
||||
selectCollection(option)
|
||||
hide()
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<HoppSmartItem
|
||||
:label="option.label"
|
||||
:active-info-icon="selectedCollectionID === option.value"
|
||||
:info-icon="
|
||||
selectedCollectionID === option.value
|
||||
? IconCheck
|
||||
: option.hasMockServer
|
||||
? IconServer
|
||||
: null
|
||||
"
|
||||
:disabled="option.disabled"
|
||||
/>
|
||||
</HoppSmartLink>
|
||||
<div
|
||||
v-if="collectionOptions.length === 0"
|
||||
class="flex items-center justify-center px-4 py-8 text-secondaryLight"
|
||||
>
|
||||
{{ t("empty.collections") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</div>
|
||||
<!-- Collection Info (when collection is pre-selected) -->
|
||||
<div v-else class="text-body text-secondary">
|
||||
{{ collectionName }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Mock Server Info -->
|
||||
<div v-if="isExistingMockServer" class="flex flex-col space-y-4">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label class="text-sm font-semibold text-secondaryDark">
|
||||
{{ t("mock_server.mock_server_name") }}
|
||||
</label>
|
||||
<div class="text-body text-secondary">
|
||||
{{ existingMockServer?.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label class="text-sm font-semibold text-secondaryDark">
|
||||
{{ t("mock_server.base_url") }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
class="flex-1 px-3 py-2 border border-divider rounded bg-primaryLight"
|
||||
>
|
||||
{{
|
||||
existingMockServer?.serverUrlPathBased ||
|
||||
existingMockServer?.serverUrlDomainBased ||
|
||||
""
|
||||
}}
|
||||
</div>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copyIcon"
|
||||
@click="
|
||||
copyToClipboard(
|
||||
existingMockServer?.serverUrlPathBased ||
|
||||
existingMockServer?.serverUrlDomainBased ||
|
||||
''
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label class="text-sm font-semibold text-secondaryDark">
|
||||
{{ t("app.status") }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="
|
||||
existingMockServer?.isActive
|
||||
? 'bg-green-600/20 text-green-500 border border-green-600/30'
|
||||
: 'text-secondary border border-secondaryLight'
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full mr-2"
|
||||
:class="
|
||||
existingMockServer?.isActive
|
||||
? 'bg-green-400'
|
||||
: 'bg-secondaryLight'
|
||||
"
|
||||
></span>
|
||||
{{
|
||||
existingMockServer?.isActive
|
||||
? t("mockServer.dashboard.active")
|
||||
: t("mockServer.dashboard.inactive")
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Mock Server Form -->
|
||||
<div v-else class="flex flex-col space-y-6">
|
||||
<HoppSmartInput
|
||||
v-model="mockServerName"
|
||||
v-focus
|
||||
:label="t('mock_server.mock_server_name')"
|
||||
input-styles="floating-input"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-48">
|
||||
<HoppSmartInput
|
||||
v-model="delayInMsVal"
|
||||
:label="t('mock_server.delay_ms')"
|
||||
type="number"
|
||||
input-styles="floating-input"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<HoppSmartToggle :on="isPublic" @change="isPublic = !isPublic">
|
||||
{{ t("mock_server.make_public") }}
|
||||
</HoppSmartToggle>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hint for private mock servers -->
|
||||
<div v-if="!isPublic" class="w-full mt-2 text-xs text-secondaryLight">
|
||||
{{ t("mock_server.private_access_hint") }}
|
||||
</div>
|
||||
|
||||
<!-- Display created server info -->
|
||||
<div v-if="createdServer" class="flex flex-col space-y-4">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label class="text-sm font-semibold text-secondaryDark">
|
||||
{{ t("mock_server.path_based_url") }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
class="flex-1 px-3 py-2 border border-divider rounded bg-primaryLight text-body font-mono"
|
||||
>
|
||||
{{ createdServer.serverUrlPathBased }}
|
||||
</div>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copyIcon"
|
||||
@click="
|
||||
copyToClipboard(createdServer.serverUrlPathBased || '')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subdomain-based URL (May be null) -->
|
||||
<div
|
||||
v-if="createdServer.serverUrlDomainBased"
|
||||
class="flex flex-col space-y-2"
|
||||
>
|
||||
<label class="text-sm font-semibold text-secondaryDark">
|
||||
{{ t("mock_server.subdomain_based_url") }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
class="flex-1 px-3 py-2 border border-divider rounded bg-primaryLight text-body font-mono"
|
||||
>
|
||||
{{ createdServer.serverUrlDomainBased }}
|
||||
</div>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copyIcon"
|
||||
@click="copyToClipboard(createdServer.serverUrlDomainBased)"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-secondaryLight">
|
||||
<span class="font-medium">{{ t("mock_server.note") }}:</span>
|
||||
{{ t("mock_server.subdomain_note") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Text -->
|
||||
<div
|
||||
class="py-4 px-3 bg-primaryLight rounded-md border border-dividerLight shadow-sm"
|
||||
>
|
||||
<p class="text-secondary flex space-x-2 items-start">
|
||||
<Icon-lucide-info class="svg-icons text-accent" />
|
||||
<span>
|
||||
{{ t("mock_server.description") }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end space-x-2">
|
||||
<!-- Start/Stop Server Button for existing mock server -->
|
||||
<HoppButtonPrimary
|
||||
v-if="isExistingMockServer"
|
||||
:label="
|
||||
existingMockServer?.isActive
|
||||
? t('mock_server.stop_server')
|
||||
: t('mock_server.start_server')
|
||||
"
|
||||
:loading="loading"
|
||||
:icon="existingMockServer?.isActive ? IconSquare : IconPlay"
|
||||
@click="toggleMockServer"
|
||||
/>
|
||||
|
||||
<!-- Create Mock Server Button for new mock server -->
|
||||
<HoppButtonPrimary
|
||||
v-else
|
||||
:label="t('mock_server.create_mock_server')"
|
||||
:loading="loading"
|
||||
:disabled="!mockServerName.trim() || !effectiveCollectionID"
|
||||
:icon="IconServer"
|
||||
@click="createMockServer"
|
||||
/>
|
||||
|
||||
<HoppButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
outline
|
||||
@click="closeModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { useService } from "dioc/vue"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
import { MockServer, WorkspaceType } from "~/helpers/backend/graphql"
|
||||
import {
|
||||
createMockServer as createMockServerMutation,
|
||||
updateMockServer,
|
||||
} from "~/helpers/backend/mutations/MockServer"
|
||||
import { copyToClipboard as copyToClipboardHelper } from "~/helpers/utils/clipboard"
|
||||
import { restCollections$ } from "~/newstore/collections"
|
||||
import {
|
||||
addMockServer,
|
||||
mockServers$,
|
||||
showCreateMockServerModal$,
|
||||
updateMockServer as updateMockServerInStore,
|
||||
} from "~/newstore/mockServers"
|
||||
import { TeamCollectionsService } from "~/services/team-collection.service"
|
||||
import { WorkspaceService } from "~/services/workspace.service"
|
||||
|
||||
// Icons
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconPlay from "~icons/lucide/play"
|
||||
import IconServer from "~icons/lucide/server"
|
||||
import IconSquare from "~icons/lucide/square"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
const workspaceService = useService(WorkspaceService)
|
||||
const teamCollectionsService = useService(TeamCollectionsService)
|
||||
|
||||
// Modal state
|
||||
const modalData = useReadonlyStream(showCreateMockServerModal$, {
|
||||
show: false,
|
||||
collectionID: undefined,
|
||||
collectionName: undefined,
|
||||
})
|
||||
|
||||
const mockServers = useReadonlyStream(mockServers$, [])
|
||||
const collections = useReadonlyStream(restCollections$, [])
|
||||
const currentWorkspace = computed(() => workspaceService.currentWorkspace.value)
|
||||
|
||||
// Get collections based on current workspace
|
||||
const availableCollections = computed(() => {
|
||||
if (currentWorkspace.value.type === "team" && currentWorkspace.value.teamID) {
|
||||
return teamCollectionsService.collections.value || []
|
||||
}
|
||||
return collections.value
|
||||
})
|
||||
|
||||
// Component state
|
||||
const mockServerName = ref("")
|
||||
const loading = ref(false)
|
||||
const showCloseButton = ref(false)
|
||||
const createdServer = ref<MockServer | null>(null)
|
||||
const delayInMsVal = ref<string>("0")
|
||||
const isPublic = ref<boolean>(true)
|
||||
const selectedCollectionID = ref("")
|
||||
const selectedCollectionName = ref("")
|
||||
const tippyActions = ref<TippyComponent | null>(null)
|
||||
|
||||
// Props computed from modal data
|
||||
const show = computed(() => modalData.value.show)
|
||||
const collectionID = computed(() => modalData.value.collectionID)
|
||||
const collectionName = computed(() => {
|
||||
// Prefer name provided by modalData (pre-selected from caller)
|
||||
if (modalData.value.collectionName) return modalData.value.collectionName
|
||||
|
||||
// If user selected a collection inside the modal, use that
|
||||
if (selectedCollectionName.value) return selectedCollectionName.value
|
||||
|
||||
// Try finding the collection from availableCollections using effectiveCollectionID
|
||||
const id = effectiveCollectionID.value
|
||||
if (!id) return "Unknown Collection"
|
||||
|
||||
const coll = availableCollections.value.find((c: any) => (c as any).id === id)
|
||||
return (coll as any)?.name || (coll as any)?.title || "Unknown Collection"
|
||||
})
|
||||
|
||||
// Find existing mock server for the effective collection (pre-selected or user-selected)
|
||||
const existingMockServer = computed(() => {
|
||||
const collId = effectiveCollectionID.value
|
||||
if (!collId) return null
|
||||
return mockServers.value.find((server) => server.collectionID === collId)
|
||||
})
|
||||
|
||||
const isExistingMockServer = computed(() => !!existingMockServer.value)
|
||||
|
||||
// Collection options for the selector (only root collections)
|
||||
const collectionOptions = computed(() => {
|
||||
return availableCollections.value.map((collection) => {
|
||||
const collectionId =
|
||||
currentWorkspace.value.type === "team"
|
||||
? collection.id
|
||||
: (collection.id ?? collection._ref_id) // TODO: fix this fallback logic for personal workspaces in the future
|
||||
const hasMockServer = mockServers.value.some(
|
||||
(server) => server.collectionID === collectionId
|
||||
)
|
||||
|
||||
return {
|
||||
label: collection.name || collection.title,
|
||||
value: collectionId,
|
||||
collection: collection,
|
||||
hasMockServer: hasMockServer,
|
||||
disabled: hasMockServer,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Get the effective collection ID (either pre-selected or user-selected)
|
||||
const effectiveCollectionID = computed(() => {
|
||||
return collectionID.value || selectedCollectionID.value
|
||||
})
|
||||
|
||||
// Collection selection handler
|
||||
const selectCollection = (option: any) => {
|
||||
// Prevent selection of collections that already have mock servers
|
||||
if (option.disabled || option.hasMockServer) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedCollectionID.value = option.value
|
||||
selectedCollectionName.value = option.label
|
||||
}
|
||||
|
||||
// Copy functionality
|
||||
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
IconCopy,
|
||||
1000
|
||||
)
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
copyToClipboardHelper(text)
|
||||
copyIcon.value = IconCheck
|
||||
toast.success(t("state.copied_to_clipboard"))
|
||||
}
|
||||
|
||||
// Reset form when modal opens/closes
|
||||
watch(show, (newShow) => {
|
||||
if (newShow) {
|
||||
mockServerName.value = ""
|
||||
loading.value = false
|
||||
delayInMsVal.value = "0"
|
||||
isPublic.value = true
|
||||
selectedCollectionID.value = ""
|
||||
selectedCollectionName.value = ""
|
||||
showCloseButton.value = false
|
||||
createdServer.value = null
|
||||
}
|
||||
})
|
||||
|
||||
// Create new mock server
|
||||
const createMockServer = async () => {
|
||||
if (!mockServerName.value.trim() || !effectiveCollectionID.value) {
|
||||
if (!effectiveCollectionID.value) {
|
||||
toast.error(t("mock_server.select_collection_error"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
// Determine workspace type and ID based on current workspace
|
||||
const workspaceType =
|
||||
currentWorkspace.value.type === "team"
|
||||
? WorkspaceType.Team
|
||||
: WorkspaceType.User
|
||||
const workspaceID =
|
||||
currentWorkspace.value.type === "team"
|
||||
? currentWorkspace.value.teamID
|
||||
: undefined
|
||||
|
||||
await pipe(
|
||||
createMockServerMutation(
|
||||
mockServerName.value.trim(),
|
||||
effectiveCollectionID.value,
|
||||
workspaceType,
|
||||
workspaceID,
|
||||
Number(delayInMsVal.value) || 0, // delayInMs
|
||||
Boolean(isPublic.value) // isPublic
|
||||
),
|
||||
TE.match(
|
||||
(error) => {
|
||||
// `error` here is the message string produced by the mutation helper.
|
||||
// Show the backend-provided error message if available, otherwise fallback to generic
|
||||
toast.error(String(error) || t("error.something_went_wrong"))
|
||||
loading.value = false
|
||||
},
|
||||
(result) => {
|
||||
toast.success(t("mock_server.mock_server_created"))
|
||||
|
||||
// Add the new mock server to the store
|
||||
addMockServer(result)
|
||||
|
||||
// Store the created server data and show close button
|
||||
createdServer.value = result
|
||||
showCloseButton.value = true
|
||||
|
||||
loading.value = false
|
||||
// Don't close the modal automatically
|
||||
}
|
||||
)
|
||||
)()
|
||||
}
|
||||
|
||||
// Toggle mock server active state
|
||||
const toggleMockServer = async () => {
|
||||
if (!existingMockServer.value) return
|
||||
|
||||
loading.value = true
|
||||
const newActiveState = !existingMockServer.value.isActive
|
||||
|
||||
await pipe(
|
||||
updateMockServer(existingMockServer.value.id, { isActive: newActiveState }),
|
||||
TE.match(
|
||||
() => {
|
||||
toast.error(t("error.something_went_wrong"))
|
||||
loading.value = false
|
||||
},
|
||||
() => {
|
||||
toast.success(
|
||||
newActiveState
|
||||
? t("mock_server.mock_server_started")
|
||||
: t("mock_server.mock_server_stopped")
|
||||
)
|
||||
|
||||
// Update the mock server in the store
|
||||
updateMockServerInStore(existingMockServer.value!.id, {
|
||||
isActive: newActiveState,
|
||||
})
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
)
|
||||
)()
|
||||
}
|
||||
|
||||
// Close modal function
|
||||
const closeModal = () => {
|
||||
showCreateMockServerModal$.next({
|
||||
show: false,
|
||||
collectionID: undefined,
|
||||
collectionName: undefined,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
<template>
|
||||
<HoppSmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('mock_server.edit_mock_server')"
|
||||
@close="emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col space-y-6">
|
||||
<!-- Mock Server Name -->
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label class="text-sm font-semibold text-secondaryDark">
|
||||
{{ t("mock_server.mock_server_name") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="mockServerName"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('mock_server.mock_server_name_placeholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Collection Info (Read-only) -->
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label class="text-sm font-semibold text-secondaryDark">
|
||||
{{ t("collection.title") }}
|
||||
</label>
|
||||
<div class="text-body text-secondary">
|
||||
{{ mockServer.collection?.title || t("mock_server.no_collection") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Base URL (Read-only) -->
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label class="text-sm font-semibold text-secondaryDark">
|
||||
{{ t("mock_server.base_url") }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
class="flex-1 px-3 py-2 border border-divider rounded bg-primaryLight"
|
||||
>
|
||||
{{
|
||||
mockServer.serverUrlDomainBased || mockServer.serverUrlPathBased
|
||||
}}
|
||||
</div>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copyIcon"
|
||||
@click="
|
||||
copyToClipboardHandler(
|
||||
mockServer.serverUrlDomainBased ||
|
||||
mockServer.serverUrlPathBased ||
|
||||
''
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Display (Read-only) -->
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label class="text-sm font-semibold text-secondaryDark">
|
||||
{{ t("app.status") }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="
|
||||
isActive
|
||||
? 'bg-green-600/20 text-green-500 border border-green-600/30'
|
||||
: 'text-secondary border border-secondaryLight'
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full mr-2"
|
||||
:class="isActive ? 'bg-green-400' : 'bg-secondaryLight'"
|
||||
></span>
|
||||
{{
|
||||
isActive
|
||||
? t("mockServer.dashboard.active")
|
||||
: t("mockServer.dashboard.inactive")
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delay Settings -->
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label class="text-sm font-semibold text-secondaryDark">
|
||||
{{ t("mock_server.delay_ms") }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="delayInMs"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('mock_server.delay_placeholder')"
|
||||
/>
|
||||
<span class="text-xs text-secondaryLight">
|
||||
{{ t("mock_server.delay_description") }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Public Access -->
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label class="text-sm font-semibold text-secondaryDark">
|
||||
{{ t("mock_server.public_access") }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-3">
|
||||
<HoppSmartToggle :on="isPublic" @change="isPublic = !isPublic" />
|
||||
<span class="text-secondaryLight">
|
||||
{{
|
||||
isPublic
|
||||
? t("mock_server.public_description")
|
||||
: t("mock_server.private_description")
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!isPublic" class="text-xs text-secondaryLight">
|
||||
{{ t("mock_server.private_access_hint") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end space-x-2">
|
||||
<!-- Start/Stop Server Button (consistent with CreateMockServer) -->
|
||||
<HoppButtonPrimary
|
||||
:label="
|
||||
isActive
|
||||
? t('mock_server.stop_server')
|
||||
: t('mock_server.start_server')
|
||||
"
|
||||
:loading="loading"
|
||||
:icon="isActive ? IconSquare : IconPlay"
|
||||
@click="toggleMockServer"
|
||||
/>
|
||||
|
||||
<!-- Save button for other settings -->
|
||||
<HoppButtonSecondary
|
||||
outline
|
||||
:label="t('action.save')"
|
||||
:loading="loading"
|
||||
@click="updateMockServer"
|
||||
/>
|
||||
|
||||
<HoppButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
@click="emit('hide-modal')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { ref, watch } from "vue"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import { updateMockServer as updateMockServerMutation } from "~/helpers/backend/mutations/MockServer"
|
||||
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||
import type { MockServer } from "~/newstore/mockServers"
|
||||
import { updateMockServer as updateMockServerInStore } from "~/newstore/mockServers"
|
||||
|
||||
// Icons
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconPlay from "~icons/lucide/play"
|
||||
import IconSquare from "~icons/lucide/square"
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
mockServer: MockServer
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const loading = ref(false)
|
||||
const copyIcon = ref(IconCopy)
|
||||
|
||||
// Form data
|
||||
const mockServerName = ref(props.mockServer.name)
|
||||
const isActive = ref(props.mockServer.isActive)
|
||||
const delayInMs = ref(props.mockServer.delayInMs || 0)
|
||||
const isPublic = ref(props.mockServer.isPublic)
|
||||
|
||||
// Watch for prop changes
|
||||
watch(
|
||||
() => props.mockServer,
|
||||
(newMockServer) => {
|
||||
mockServerName.value = newMockServer.name
|
||||
isActive.value = newMockServer.isActive
|
||||
delayInMs.value = newMockServer.delayInMs || 0
|
||||
isPublic.value = newMockServer.isPublic
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const updateMockServer = async () => {
|
||||
loading.value = true
|
||||
|
||||
// Prepare payload
|
||||
const payload = {
|
||||
name: mockServerName.value,
|
||||
isActive: isActive.value,
|
||||
delayInMs: delayInMs.value,
|
||||
isPublic: isPublic.value,
|
||||
}
|
||||
|
||||
await pipe(
|
||||
updateMockServerMutation(props.mockServer.id, payload),
|
||||
TE.match(
|
||||
() => {
|
||||
toast.error(t("error.something_went_wrong"))
|
||||
loading.value = false
|
||||
},
|
||||
() => {
|
||||
// Update the mock server in the store with the changed fields
|
||||
updateMockServerInStore(props.mockServer.id, payload)
|
||||
|
||||
toast.success(t("mock_server.mock_server_updated"))
|
||||
emit("hide-modal")
|
||||
|
||||
// Update local state in case parent doesn't refresh immediately
|
||||
mockServerName.value = payload.name
|
||||
isActive.value = payload.isActive
|
||||
delayInMs.value = payload.delayInMs || 0
|
||||
isPublic.value = payload.isPublic
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
)
|
||||
)()
|
||||
}
|
||||
|
||||
// Toggle mock server active state (consistent with CreateMockServer)
|
||||
const toggleMockServer = async () => {
|
||||
loading.value = true
|
||||
const newActiveState = !isActive.value
|
||||
|
||||
await pipe(
|
||||
updateMockServerMutation(props.mockServer.id, { isActive: newActiveState }),
|
||||
TE.match(
|
||||
() => {
|
||||
toast.error(t("error.something_went_wrong"))
|
||||
loading.value = false
|
||||
},
|
||||
() => {
|
||||
toast.success(
|
||||
newActiveState
|
||||
? t("mock_server.mock_server_started")
|
||||
: t("mock_server.mock_server_stopped")
|
||||
)
|
||||
|
||||
// Update the mock server in the store
|
||||
updateMockServerInStore(props.mockServer.id, {
|
||||
isActive: newActiveState,
|
||||
})
|
||||
|
||||
// Update local state
|
||||
isActive.value = newActiveState
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
)
|
||||
)()
|
||||
}
|
||||
|
||||
const copyToClipboardHandler = async (text: string) => {
|
||||
try {
|
||||
await copyToClipboard(text)
|
||||
copyIcon.value = IconCheck
|
||||
toast.success(t("state.copied_to_clipboard"))
|
||||
setTimeout(() => {
|
||||
copyIcon.value = IconCopy
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
toast.error(t("error.copy_failed"))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
<template>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
v-if="content && content.trim()"
|
||||
class="flex items-center space-x-1 font-medium text-secondary hover:text-secondaryDark transition-colors"
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
<icon-lucide-chevron-right
|
||||
:class="[
|
||||
'h-4 w-4 transition-transform duration-200',
|
||||
isExpanded ? 'rotate-90' : 'rotate-0',
|
||||
]"
|
||||
/>
|
||||
<span>{{ title }}</span>
|
||||
</button>
|
||||
<span v-else class="font-medium text-secondary">{{ title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="content && content.trim() && isExpanded" class="relative group">
|
||||
<div
|
||||
class="absolute top-2 right-3 z-10 p-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<HoppSmartItem
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copySuccess ? IconCheck : IconCopy"
|
||||
class="p-1 rounded transition-colors"
|
||||
@click="copyContent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<pre
|
||||
class="relative whitespace-pre-wrap cursor-text select-text break-words bg-primaryLight border border-dividerLight rounded-sm p-4 max-h-96 overflow-y-auto"
|
||||
:class="[isValidJSON ? 'text-accent' : 'text-secondaryLight']"
|
||||
>{{ formattedContent }}</pre
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!content || !content.trim()"
|
||||
class="text-xs text-secondaryLight italic py-2"
|
||||
>
|
||||
{{ t("state.no_content_found") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import { HoppSmartItem } from "@hoppscotch/ui"
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
content: string | null | undefined
|
||||
defaultExpanded?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
defaultExpanded: false,
|
||||
})
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const isExpanded = ref(props.defaultExpanded)
|
||||
const copySuccess = refAutoReset(false, 1000)
|
||||
|
||||
const toggleExpanded = () => {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
const isValidJSON = computed(() => {
|
||||
if (!props.content) return false
|
||||
try {
|
||||
JSON.parse(props.content)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const formattedContent = computed(() => {
|
||||
if (!props.content) return ""
|
||||
|
||||
if (isValidJSON.value) {
|
||||
try {
|
||||
const parsed = JSON.parse(props.content)
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch {
|
||||
return props.content
|
||||
}
|
||||
}
|
||||
|
||||
return props.content
|
||||
})
|
||||
|
||||
const copyContent = () => {
|
||||
if (!props.content) return
|
||||
|
||||
copyToClipboard(formattedContent.value)
|
||||
copySuccess.value = true
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="sticky z-10 flex flex-1 flex-shrink-0 justify-between overflow-x-auto border-b border-dividerLight bg-primary"
|
||||
>
|
||||
<HoppButtonSecondary
|
||||
v-if="!hasNoAccess"
|
||||
:icon="IconPlus"
|
||||
:label="t('action.new')"
|
||||
class="!rounded-none"
|
||||
@click="openCreateModal"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-else
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
disabled
|
||||
class="!rounded-none"
|
||||
:icon="IconPlus"
|
||||
:title="t('team.no_access')"
|
||||
:label="t('action.new')"
|
||||
/>
|
||||
<span class="flex">
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/documentation/features/mock-servers"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex flex-1 flex-col items-center justify-center p-4"
|
||||
>
|
||||
<HoppSmartSpinner class="mb-4" />
|
||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="mockServers.length === 0"
|
||||
class="flex flex-1 flex-col items-center justify-center p-4"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/add_files.svg`"
|
||||
:alt="`${t('empty.mock_servers')}`"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4 opacity-75"
|
||||
/>
|
||||
<span class="pb-4 text-center text-secondaryLight">
|
||||
{{ t("empty.mock_servers") }}
|
||||
</span>
|
||||
<HoppButtonSecondary
|
||||
v-if="!hasNoAccess"
|
||||
:label="t('mock_server.create_mock_server')"
|
||||
:icon="IconPlus"
|
||||
filled
|
||||
@click="openCreateModal"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex flex-1 flex-col space-y-2 py-2">
|
||||
<div
|
||||
v-for="mockServer in mockServers"
|
||||
:key="mockServer.id"
|
||||
class="group flex items-stretch"
|
||||
>
|
||||
<span
|
||||
class="flex cursor-pointer items-center justify-center px-4"
|
||||
@click="openMockServerLogs(mockServer)"
|
||||
>
|
||||
<component
|
||||
:is="IconServer"
|
||||
class="svg-icons"
|
||||
:class="{
|
||||
'text-green-500': mockServer.isActive,
|
||||
'text-secondaryLight': !mockServer.isActive,
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="flex min-w-0 flex-1 cursor-pointer pr-2 transition group-hover:text-secondaryDark"
|
||||
@click="openMockServerLogs(mockServer)"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="truncate font-semibold">
|
||||
{{ mockServer.name }}
|
||||
</span>
|
||||
<span class="truncate text-secondaryLight">
|
||||
{{
|
||||
mockServer.collection === null
|
||||
? t("mock_server.collection_deleted")
|
||||
: mockServer.collection?.title ||
|
||||
t("mock_server.no_collection")
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<HoppButtonSecondary
|
||||
v-if="
|
||||
mockServer.serverUrlDomainBased || mockServer.serverUrlPathBased
|
||||
"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copyIcon"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="
|
||||
copyToClipboardHandler(
|
||||
mockServer.serverUrlDomainBased ||
|
||||
mockServer.serverUrlPathBased ||
|
||||
''
|
||||
)
|
||||
"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-if="!hasNoAccess"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.edit')"
|
||||
:icon="IconEdit"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="editMockServer(mockServer)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions?.focus?.()"
|
||||
>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.s="toggleAction?.$el.click()"
|
||||
@keyup.delete="deleteAction?.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<HoppSmartItem
|
||||
ref="toggleAction"
|
||||
:icon="mockServer.isActive ? IconStop : IconPlay"
|
||||
:label="
|
||||
mockServer.isActive
|
||||
? t('mock_server.stop_server')
|
||||
: t('mock_server.start_server')
|
||||
"
|
||||
@click="
|
||||
() => {
|
||||
toggleMockServer(mockServer)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<HoppSmartItem
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="t('action.delete')"
|
||||
@click="
|
||||
() => {
|
||||
deleteMockServer(mockServer)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<MockServerCreateMockServer
|
||||
v-if="showCreateModal"
|
||||
:show="showCreateModal"
|
||||
@hide-modal="showCreateModal = false"
|
||||
/>
|
||||
<MockServerEditMockServer
|
||||
v-if="showEditModal && selectedMockServer"
|
||||
:show="showEditModal"
|
||||
:mock-server="selectedMockServer"
|
||||
@hide-modal="showEditModal = false"
|
||||
/>
|
||||
<MockServerLogs
|
||||
v-if="showLogsModal && selectedMockServer"
|
||||
:show="showLogsModal"
|
||||
:mock-server-i-d="selectedMockServer.id"
|
||||
@close="showLogsModal = false"
|
||||
/>
|
||||
<HoppSmartConfirmModal
|
||||
:show="confirmDeleteMockServer"
|
||||
:loading-state="loading"
|
||||
:title="t('confirm.delete_mock_server')"
|
||||
@hide-modal="confirmDeleteMockServer = false"
|
||||
@resolve="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { computed, ref } from "vue"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
import { useMockServerStatus } from "~/composables/mockServer"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||
import type { MockServer } from "~/newstore/mockServers"
|
||||
import { platform } from "~/platform"
|
||||
|
||||
import {
|
||||
deleteMockServer as deleteMockServerInStore,
|
||||
showCreateMockServerModal$,
|
||||
updateMockServer as updateMockServerInStore,
|
||||
} from "~/newstore/mockServers"
|
||||
|
||||
import MockServerCreateMockServer from "~/components/mockServer/CreateMockServer.vue"
|
||||
import MockServerEditMockServer from "~/components/mockServer/EditMockServer.vue"
|
||||
import MockServerLogs from "~/components/mockServer/MockServerLogs.vue"
|
||||
import {
|
||||
deleteMockServer as deleteMockServerMutation,
|
||||
updateMockServer as updateMockServerMutation,
|
||||
} from "~/helpers/backend/mutations/MockServer"
|
||||
|
||||
// Icons
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconPlay from "~icons/lucide/play"
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconServer from "~icons/lucide/server"
|
||||
import IconStop from "~icons/lucide/stop-circle"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
const colorMode = useColorMode()
|
||||
const { mockServers } = useMockServerStatus()
|
||||
const loading = ref(false)
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showLogsModal = ref(false)
|
||||
const selectedMockServer = ref<MockServer | null>(null)
|
||||
const copyIcon = ref(IconCopy)
|
||||
const tippyActions = ref<TippyComponent | null>(null)
|
||||
const toggleAction = ref<HTMLButtonElement | null>(null)
|
||||
const deleteAction = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
// Check if user has access (not logged in or no permissions)
|
||||
const hasNoAccess = computed(() => {
|
||||
return !platform.auth.getCurrentUser()
|
||||
})
|
||||
|
||||
const editMockServer = (mockServer: MockServer) => {
|
||||
selectedMockServer.value = mockServer
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
const openMockServerLogs = (mockServer: MockServer) => {
|
||||
selectedMockServer.value = mockServer
|
||||
showLogsModal.value = true
|
||||
}
|
||||
|
||||
const toggleMockServer = async (mockServer: MockServer) => {
|
||||
loading.value = true
|
||||
|
||||
const newActiveState = !mockServer.isActive
|
||||
|
||||
await pipe(
|
||||
updateMockServerMutation(mockServer.id, { isActive: newActiveState }),
|
||||
TE.match(
|
||||
() => {
|
||||
toast.error(t("error.something_went_wrong"))
|
||||
loading.value = false
|
||||
},
|
||||
() => {
|
||||
// Show success toast
|
||||
toast.success(
|
||||
newActiveState
|
||||
? t("mock_server.mock_server_started")
|
||||
: t("mock_server.mock_server_stopped")
|
||||
)
|
||||
|
||||
// Update local store state
|
||||
updateMockServerInStore(mockServer.id, { isActive: newActiveState })
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
)
|
||||
)()
|
||||
}
|
||||
|
||||
const confirmDeleteMockServer = ref(false)
|
||||
const pendingMockServerToDelete = ref<MockServer | null>(null)
|
||||
|
||||
// Open confirm modal for deletion
|
||||
const deleteMockServer = async (mockServer: MockServer) => {
|
||||
pendingMockServerToDelete.value = mockServer
|
||||
confirmDeleteMockServer.value = true
|
||||
}
|
||||
|
||||
// Called when the confirm modal is resolved
|
||||
const confirmDelete = async () => {
|
||||
const mockServer = pendingMockServerToDelete.value
|
||||
if (!mockServer) return
|
||||
|
||||
loading.value = true
|
||||
// hide the modal
|
||||
confirmDeleteMockServer.value = false
|
||||
|
||||
await pipe(
|
||||
deleteMockServerMutation(mockServer.id),
|
||||
TE.match(
|
||||
() => {
|
||||
toast.error(t("error.something_went_wrong"))
|
||||
loading.value = false
|
||||
pendingMockServerToDelete.value = null
|
||||
},
|
||||
(result) => {
|
||||
if (result) {
|
||||
// Remove from local store
|
||||
deleteMockServerInStore(mockServer.id)
|
||||
|
||||
// If the deleted server was selected, clear selection and close logs modal
|
||||
if (selectedMockServer.value?.id === mockServer.id) {
|
||||
selectedMockServer.value = null
|
||||
showLogsModal.value = false
|
||||
showEditModal.value = false
|
||||
}
|
||||
|
||||
toast.success(t("state.deleted"))
|
||||
} else {
|
||||
toast.error(t("error.something_went_wrong"))
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
pendingMockServerToDelete.value = null
|
||||
}
|
||||
)
|
||||
)()
|
||||
}
|
||||
|
||||
const copyToClipboardHandler = async (text: string) => {
|
||||
try {
|
||||
await copyToClipboard(text)
|
||||
copyIcon.value = IconCheck
|
||||
// Show which URL was copied
|
||||
toast.success(`${t("mock_server.url_copied")}: ${text}`)
|
||||
setTimeout(() => {
|
||||
copyIcon.value = IconCopy
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
toast.error(t("error.copy_failed"))
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
// Open the create modal without a pre-selected collection
|
||||
showCreateMockServerModal$.next({
|
||||
show: true,
|
||||
collectionID: undefined,
|
||||
collectionName: undefined,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
<template>
|
||||
<HoppSmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('mock_server.logs_title')"
|
||||
styles="sm:max-w-4xl"
|
||||
@close="close"
|
||||
>
|
||||
<template #body>
|
||||
<div class="p-4">
|
||||
<div v-if="loading" class="flex justify-center py-8">
|
||||
<HoppSmartSpinner />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="logs.length === 0" class="text-center text-secondary">
|
||||
{{ t("mock_server.no_logs") }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
class="mb-4 border border-dividerDark rounded overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="p-3 cursor-pointer hover:bg-primaryLight/5 transition-colors duration-200"
|
||||
@click="toggleLogExpansion(log.id)"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-3">
|
||||
<icon-lucide-chevron-right
|
||||
class="w-4 h-4 transition-transform duration-200"
|
||||
:class="{ 'rotate-90': isLogExpanded(log.id) }"
|
||||
/>
|
||||
<div
|
||||
:style="{
|
||||
color: getMethodLabelColor(log.requestMethod),
|
||||
}"
|
||||
class="flex-1"
|
||||
>
|
||||
{{ log.requestMethod }}
|
||||
</div>
|
||||
<div class="text-secondaryDark truncate">
|
||||
{{ log.requestPath }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="log.responseStatus"
|
||||
class="px-2 py-1 rounded text-xs font-medium"
|
||||
:class="getStatusColor(log.responseStatus)"
|
||||
>
|
||||
{{ log.responseStatus }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-secondaryLight flex flex-1 justify-center">
|
||||
{{ formatExecutedAt(log.executedAt) }}
|
||||
</div>
|
||||
<HoppSmartItem
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.delete')"
|
||||
:icon="IconTrash"
|
||||
class="bg-transparent hover:bg-transparent !text-red-500"
|
||||
@click.stop="confirmRemoveLog(log.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isLogExpanded(log.id)"
|
||||
class="border-t border-dividerDark"
|
||||
>
|
||||
<div class="py-4 px-3 text-xs flex flex-col space-y-4">
|
||||
<MockServerLogSection
|
||||
:title="t('mock_server.request_headers')"
|
||||
:content="log.requestHeaders"
|
||||
/>
|
||||
|
||||
<MockServerLogSection
|
||||
v-if="log.requestBody"
|
||||
:title="t('mock_server.request_body')"
|
||||
:content="log.requestBody"
|
||||
/>
|
||||
|
||||
<MockServerLogSection
|
||||
:title="t('mock_server.response_headers')"
|
||||
:content="log.responseHeaders"
|
||||
/>
|
||||
|
||||
<MockServerLogSection
|
||||
v-if="log.responseBody"
|
||||
:title="t('mock_server.response_body')"
|
||||
:content="log.responseBody"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<HoppButtonPrimary :label="t('action.close')" @click="close" />
|
||||
</div>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
|
||||
<HoppSmartConfirmModal
|
||||
:show="showDeleteConfirm"
|
||||
:title="t('mock_server.confirm_delete_log')"
|
||||
@hide-modal="showDeleteConfirm = false"
|
||||
@resolve="confirmDelete"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import {
|
||||
getMockServerLogs,
|
||||
deleteMockServerLog,
|
||||
} from "~/helpers/backend/queries/MockServerLogs"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import { getMethodLabelColor } from "~/helpers/rest/labelColoring"
|
||||
import { HoppSmartItem } from "@hoppscotch/ui"
|
||||
|
||||
const props = defineProps<{ show: boolean; mockServerID: string }>()
|
||||
const emit = defineEmits<{ (e: "close"): void }>()
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const loading = ref(false)
|
||||
const logs = ref<any[]>([])
|
||||
const expandedLogs = ref<Set<string>>(new Set())
|
||||
const showDeleteConfirm = ref(false)
|
||||
const logToDelete = ref<string | null>(null)
|
||||
|
||||
const fetchLogs = async () => {
|
||||
loading.value = true
|
||||
await pipe(
|
||||
getMockServerLogs(props.mockServerID),
|
||||
TE.match(
|
||||
() => {
|
||||
toast.error(t("error.something_went_wrong"))
|
||||
loading.value = false
|
||||
},
|
||||
(res) => {
|
||||
logs.value = res
|
||||
loading.value = false
|
||||
}
|
||||
)
|
||||
)()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.show) fetchLogs()
|
||||
})
|
||||
|
||||
const close = () => emit("close")
|
||||
|
||||
const confirmRemoveLog = (id: string) => {
|
||||
logToDelete.value = id
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (logToDelete.value) {
|
||||
await pipe(
|
||||
deleteMockServerLog(logToDelete.value),
|
||||
TE.match(
|
||||
() => {
|
||||
toast.error(t("error.something_went_wrong"))
|
||||
},
|
||||
(res) => {
|
||||
if (res) {
|
||||
logs.value = logs.value.filter((l) => l.id !== logToDelete.value)
|
||||
toast.success(t("mock_server.log_deleted"))
|
||||
logToDelete.value = null
|
||||
showDeleteConfirm.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
)()
|
||||
}
|
||||
}
|
||||
|
||||
const formatExecutedAt = (executedAt: string) => {
|
||||
return new Date(executedAt).toLocaleString()
|
||||
}
|
||||
|
||||
const toggleLogExpansion = (id: string) => {
|
||||
if (expandedLogs.value.has(id)) {
|
||||
expandedLogs.value.delete(id)
|
||||
} else {
|
||||
expandedLogs.value.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
const isLogExpanded = (id: string) => {
|
||||
return expandedLogs.value.has(id)
|
||||
}
|
||||
|
||||
const getStatusColor = (statusCode: number) => {
|
||||
const status = statusCode.toString()
|
||||
if (status.startsWith("2")) return "bg-green-800/20 text-green-600"
|
||||
if (status.startsWith("4")) return "bg-yellow-800/20 text-yellow-400"
|
||||
if (status.startsWith("5")) return "bg-red-800/20 text-red-400"
|
||||
return "bg-gray-600/20 text-secondaryDark"
|
||||
}
|
||||
</script>
|
||||
|
|
@ -31,6 +31,14 @@
|
|||
{{ t("settings.verify_peer") }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<HoppSmartToggle
|
||||
:on="domainSettings[selectedDomain]?.options?.followRedirects ?? true"
|
||||
@change="toggleFollowRedirects"
|
||||
/>
|
||||
{{ t("settings.follow_redirects") }}
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-4">
|
||||
<HoppButtonSecondary
|
||||
:icon="IconFileBadge"
|
||||
|
|
@ -517,6 +525,10 @@ function updateDomainSettings(newSettings: any) {
|
|||
...newSettings.security?.certificates,
|
||||
},
|
||||
},
|
||||
options: {
|
||||
...currentSettings?.options,
|
||||
...newSettings.options,
|
||||
},
|
||||
}
|
||||
|
||||
store.saveDomainSettings(domain, domainSettings[domain])
|
||||
|
|
@ -538,6 +550,17 @@ function toggleVerifyPeer() {
|
|||
})
|
||||
}
|
||||
|
||||
function toggleFollowRedirects() {
|
||||
const currentValue =
|
||||
domainSettings[selectedDomain.value]?.options?.followRedirects ?? true
|
||||
|
||||
updateDomainSettings({
|
||||
options: {
|
||||
followRedirects: !currentValue,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function toggleProxy() {
|
||||
updateDomainSettings({
|
||||
proxy: domainSettings[selectedDomain.value]?.proxy
|
||||
|
|
|
|||
|
|
@ -31,6 +31,14 @@
|
|||
{{ t("settings.verify_peer") }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<HoppSmartToggle
|
||||
:on="domainSettings[selectedDomain]?.options?.followRedirects ?? true"
|
||||
@change="toggleFollowRedirects"
|
||||
/>
|
||||
{{ t("settings.follow_redirects") }}
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-4">
|
||||
<HoppButtonSecondary
|
||||
:icon="IconFileBadge"
|
||||
|
|
@ -513,6 +521,10 @@ function updateDomainSettings(newSettings: any) {
|
|||
...newSettings.security?.certificates,
|
||||
},
|
||||
},
|
||||
options: {
|
||||
...currentSettings?.options,
|
||||
...newSettings.options,
|
||||
},
|
||||
}
|
||||
|
||||
store.saveDomainSettings(domain, domainSettings[domain])
|
||||
|
|
@ -534,6 +546,17 @@ function toggleVerifyPeer() {
|
|||
})
|
||||
}
|
||||
|
||||
function toggleFollowRedirects() {
|
||||
const currentValue =
|
||||
domainSettings[selectedDomain.value]?.options?.followRedirects ?? true
|
||||
|
||||
updateDomainSettings({
|
||||
options: {
|
||||
followRedirects: !currentValue,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function toggleProxy() {
|
||||
updateDomainSettings({
|
||||
proxy: domainSettings[selectedDomain.value]?.proxy
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div ref="rootEl">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col">
|
||||
<HoppSmartItem
|
||||
|
|
@ -81,6 +81,7 @@
|
|||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { useReadonlyStream } from "~/composables/stream"
|
||||
|
|
@ -96,11 +97,16 @@ import { useLocalState } from "~/newstore/localstate"
|
|||
import { defineActionHandler, invokeAction } from "~/helpers/actions"
|
||||
import { WorkspaceService } from "~/services/workspace.service"
|
||||
import { useService } from "dioc/vue"
|
||||
import { useElementVisibility, useIntervalFn } from "@vueuse/core"
|
||||
import { useIntervalFn, watchDebounced } from "@vueuse/core"
|
||||
import { TippyState } from "~/modules/tippy"
|
||||
|
||||
const t = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const props = defineProps<{
|
||||
state: TippyState | null
|
||||
}>()
|
||||
|
||||
const showModalAdd = ref(false)
|
||||
|
||||
const currentUser = useReadonlyStream(
|
||||
|
|
@ -116,27 +122,35 @@ const teamListAdapterError = useReadonlyStream(teamListadapter.error$, null)
|
|||
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
|
||||
const teamListFetched = ref(false)
|
||||
|
||||
const rootEl = ref<HTMLElement>()
|
||||
const elVisible = useElementVisibility(rootEl)
|
||||
|
||||
const { pause: pauseListPoll, resume: resumeListPoll } = useIntervalFn(() => {
|
||||
if (teamListadapter.isInitialized) {
|
||||
teamListadapter.fetchList()
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
watch(
|
||||
elVisible,
|
||||
const {
|
||||
pause: pauseListPoll,
|
||||
resume: resumeListPoll,
|
||||
isActive: isListPolling,
|
||||
} = useIntervalFn(
|
||||
() => {
|
||||
if (elVisible.value) {
|
||||
if (teamListadapter.isInitialized) {
|
||||
teamListadapter.fetchList()
|
||||
}
|
||||
},
|
||||
10000,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
resumeListPoll()
|
||||
// A debounced watcher to avoid rapid polling when component is mounted.
|
||||
// only poll when the component is visible and pause when not visible.
|
||||
watchDebounced(
|
||||
() => props.state?.isVisible,
|
||||
(isVisible) => {
|
||||
if (isVisible) {
|
||||
if (!isListPolling.value) {
|
||||
teamListadapter.fetchList()
|
||||
resumeListPoll()
|
||||
}
|
||||
} else {
|
||||
pauseListPoll()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
{ debounce: 200 }
|
||||
)
|
||||
|
||||
watch(myTeams, (teams) => {
|
||||
|
|
|
|||
|
|
@ -268,6 +268,19 @@ const getEditorLanguage = (
|
|||
completer: Completer | undefined
|
||||
): Extension => hoppLang(getLanguage(langMime) ?? undefined, linter, completer)
|
||||
|
||||
const MODULE_PREFIX = "export {};\n" as const
|
||||
|
||||
/**
|
||||
* Strips the `export {};\n` prefix from the value for display in the editor.
|
||||
* The above is only used internally for Monaco editor's module scope,
|
||||
* and should not be visible in the CodeMirror editor.
|
||||
*/
|
||||
const stripModulePrefix = (value?: string): string | undefined => {
|
||||
return value?.startsWith(MODULE_PREFIX)
|
||||
? value.slice(MODULE_PREFIX.length)
|
||||
: value
|
||||
}
|
||||
|
||||
export function useCodemirror(
|
||||
el: Ref<any | null>,
|
||||
value: Ref<string | undefined>,
|
||||
|
|
@ -474,7 +487,10 @@ export function useCodemirror(
|
|||
view.value = new EditorView({
|
||||
parent: el,
|
||||
state: EditorState.create({
|
||||
doc: parseDoc(value.value, options.extendedEditorConfig.mode ?? ""),
|
||||
doc: parseDoc(
|
||||
stripModulePrefix(value.value),
|
||||
options.extendedEditorConfig.mode ?? ""
|
||||
),
|
||||
extensions,
|
||||
}),
|
||||
// scroll to top when mounting
|
||||
|
|
@ -514,13 +530,17 @@ export function useCodemirror(
|
|||
if (!view.value && el.value) {
|
||||
initView(el.value)
|
||||
}
|
||||
|
||||
// Strip `export {};\n` before displaying in CodeMirror
|
||||
const displayValue = stripModulePrefix(newVal) ?? ""
|
||||
|
||||
if (cachedValue.value !== newVal) {
|
||||
view.value?.dispatch({
|
||||
filter: false,
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.value.state.doc.length,
|
||||
insert: newVal,
|
||||
insert: displayValue,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
70
packages/hoppscotch-common/src/composables/mockServer.ts
Normal file
70
packages/hoppscotch-common/src/composables/mockServer.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { computed } from "vue"
|
||||
import { useReadonlyStream } from "~/composables/stream"
|
||||
import { mockServers$ } from "~/newstore/mockServers"
|
||||
import type { MockServer } from "~/newstore/mockServers"
|
||||
|
||||
/**
|
||||
* Composable to get mock server status for collections
|
||||
*/
|
||||
export function useMockServerStatus() {
|
||||
const mockServers = useReadonlyStream(mockServers$, [])
|
||||
|
||||
/**
|
||||
* Get mock server for a specific collection
|
||||
*/
|
||||
const getMockServerForCollection = (
|
||||
collectionId: string
|
||||
): MockServer | null => {
|
||||
return (
|
||||
mockServers.value.find(
|
||||
(server) =>
|
||||
server.collection?.id === collectionId ||
|
||||
server.collectionID === collectionId
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a collection has an active mock server
|
||||
*/
|
||||
const hasActiveMockServer = (collectionId: string): boolean => {
|
||||
const mockServer = getMockServerForCollection(collectionId)
|
||||
return mockServer?.isActive === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a collection has any mock server (active or inactive)
|
||||
*/
|
||||
const hasMockServer = (collectionId: string): boolean => {
|
||||
return getMockServerForCollection(collectionId) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mock server status for a collection
|
||||
*/
|
||||
const getMockServerStatus = (collectionId: string) => {
|
||||
const mockServer = getMockServerForCollection(collectionId)
|
||||
|
||||
if (!mockServer) {
|
||||
return {
|
||||
exists: false,
|
||||
isActive: false,
|
||||
mockServer: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
isActive: mockServer.isActive,
|
||||
mockServer,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mockServers: computed(() => mockServers.value),
|
||||
getMockServerForCollection,
|
||||
hasActiveMockServer,
|
||||
hasMockServer,
|
||||
getMockServerStatus,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { computed } from "vue"
|
||||
|
||||
import { useSetting } from "~/composables/settings"
|
||||
|
||||
/**
|
||||
* Composable to determine mock server visibility based on experimental flags
|
||||
*/
|
||||
export function useMockServerVisibility() {
|
||||
const ENABLE_EXPERIMENTAL_MOCK_SERVERS = useSetting(
|
||||
"ENABLE_EXPERIMENTAL_MOCK_SERVERS"
|
||||
)
|
||||
|
||||
/**
|
||||
* Check if mock servers should be visible based on experimental flag
|
||||
*/
|
||||
const isMockServerVisible = computed(
|
||||
() => ENABLE_EXPERIMENTAL_MOCK_SERVERS.value
|
||||
)
|
||||
|
||||
return {
|
||||
isMockServerVisible,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { onMounted, watch } from "vue"
|
||||
import { useService } from "dioc/vue"
|
||||
import { WorkspaceService } from "~/services/workspace.service"
|
||||
import { setMockServers, loadMockServers } from "~/newstore/mockServers"
|
||||
import { platform } from "~/platform"
|
||||
import { useMockServerVisibility } from "./mockServerVisibility"
|
||||
|
||||
/**
|
||||
* Composable to handle mock server state when workspace changes
|
||||
* This ensures mock servers are cleared immediately when switching workspaces
|
||||
* to prevent showing stale data from the previous workspace
|
||||
*/
|
||||
export function useMockServerWorkspaceSync() {
|
||||
const workspaceService = useService(WorkspaceService)
|
||||
const { isMockServerVisible } = useMockServerVisibility()
|
||||
const isAuthenticated = !!platform.auth.getCurrentUser()
|
||||
|
||||
// Initial load of mock servers for the current workspace
|
||||
onMounted(() => {
|
||||
if (!isAuthenticated || !isMockServerVisible.value) return
|
||||
loadMockServers().catch(() => setMockServers([]))
|
||||
})
|
||||
|
||||
// Watch for workspace changes and clear mock servers immediately
|
||||
watch(
|
||||
() => workspaceService.currentWorkspace.value,
|
||||
(newWorkspace, oldWorkspace) => {
|
||||
if (!isAuthenticated || !isMockServerVisible.value) return
|
||||
|
||||
// Clear mock servers when workspace changes to prevent stale data
|
||||
if (
|
||||
newWorkspace?.type !== oldWorkspace?.type ||
|
||||
(newWorkspace?.type === "team" &&
|
||||
oldWorkspace?.type === "team" &&
|
||||
newWorkspace.teamID !== oldWorkspace.teamID)
|
||||
) {
|
||||
// Clear mock servers immediately to prevent showing stale data
|
||||
setMockServers([])
|
||||
|
||||
// If user is authenticated, reload mock servers for the new workspace
|
||||
if (platform.auth.getCurrentUser()) {
|
||||
// fire-and-forget; loadMockServers handles errors internally
|
||||
loadMockServers().catch(() => setMockServers([]))
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: false }
|
||||
)
|
||||
}
|
||||
|
|
@ -178,6 +178,9 @@ export const useOAuth2GrantTypes = (
|
|||
id: "plain" | "S256"
|
||||
label: string
|
||||
} | null> = refWithCallbackOnChange(
|
||||
// If the collection was imported before `codeVerifierMethod` existed,
|
||||
// default to 'plain' when PKCE is enabled so the UI and validation
|
||||
// remain consistent.
|
||||
auth.value.grantTypeInfo.codeVerifierMethod
|
||||
? {
|
||||
id: auth.value.grantTypeInfo.codeVerifierMethod,
|
||||
|
|
@ -186,7 +189,12 @@ export const useOAuth2GrantTypes = (
|
|||
? "Plain"
|
||||
: "SHA-256",
|
||||
}
|
||||
: null,
|
||||
: auth.value.grantTypeInfo.isPKCE
|
||||
? {
|
||||
id: "plain",
|
||||
label: "Plain",
|
||||
}
|
||||
: null,
|
||||
(value) => {
|
||||
if (!("codeVerifierMethod" in auth.value.grantTypeInfo) || !value) {
|
||||
return
|
||||
|
|
@ -249,7 +257,12 @@ export const useOAuth2GrantTypes = (
|
|||
clientSecret: clientSecret.value,
|
||||
scopes: scopes.value,
|
||||
isPKCE: isPKCE.value,
|
||||
codeVerifierMethod: codeChallenge.value?.id,
|
||||
// Ensure older collections without `codeVerifierMethod` get a default
|
||||
// so schema validation does not fail. Default to 'plain' when PKCE
|
||||
// is enabled.
|
||||
codeVerifierMethod:
|
||||
codeChallenge.value?.id ??
|
||||
(isPKCE.value ? ("plain" as const) : undefined),
|
||||
authRequestParams: preparedAuthRequestParams.value,
|
||||
tokenRequestParams: preparedTokenRequestParams.value,
|
||||
refreshRequestParams: preparedRefreshRequestParams.value,
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import {
|
|||
} from "./workers/sandbox.worker"
|
||||
import { transformInheritedCollectionVariablesToAggregateEnv } from "./utils/inheritedCollectionVarTransformer"
|
||||
import { isJSONContentType } from "./utils/contenttypes"
|
||||
import { applyScriptRequestUpdates } from "./experimental-sandbox-integration"
|
||||
|
||||
const sandboxWorker = new Worker(
|
||||
new URL("./workers/sandbox.worker.ts", import.meta.url),
|
||||
|
|
@ -468,10 +469,10 @@ export function runRESTRequest$(
|
|||
secret,
|
||||
}))
|
||||
|
||||
const finalRequest = {
|
||||
...resolvedRequest,
|
||||
...(preRequestScriptResult.right.updatedRequest ?? {}),
|
||||
}
|
||||
const finalRequest = applyScriptRequestUpdates(
|
||||
resolvedRequest,
|
||||
preRequestScriptResult.right.updatedRequest
|
||||
)
|
||||
|
||||
// Propagate changes to request variables from the scripting context to the UI
|
||||
tab.value.document.request.requestVariables = finalRequest.requestVariables
|
||||
|
|
@ -686,10 +687,10 @@ export function runTestRunnerRequest(
|
|||
)
|
||||
|
||||
// Calculate the final updated request after pre-request script changes
|
||||
const finalRequest = {
|
||||
...request,
|
||||
...(preRequestScriptResult.right.updatedRequest ?? {}),
|
||||
}
|
||||
const finalRequest = applyScriptRequestUpdates(
|
||||
request,
|
||||
preRequestScriptResult.right.updatedRequest
|
||||
)
|
||||
|
||||
const effectiveRequest = await getEffectiveRESTRequest(finalRequest, {
|
||||
id: "env-id",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,14 +2,13 @@
|
|||
* For example, sending a request.
|
||||
*/
|
||||
|
||||
import { Ref, onBeforeUnmount, onMounted, reactive, watch } from "vue"
|
||||
import { Ref, onBeforeUnmount, onMounted, reactive, watch, computed } from "vue"
|
||||
import { BehaviorSubject } from "rxjs"
|
||||
import { HoppRequestDocument } from "./rest/document"
|
||||
import { Environment, HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
|
||||
import { RESTOptionTabs } from "~/components/http/RequestOptions.vue"
|
||||
import { HoppGQLSaveContext } from "./graphql/document"
|
||||
import { GQLOptionTabs } from "~/components/graphql/RequestOptions.vue"
|
||||
import { computed } from "vue"
|
||||
import { getKernelMode } from "@hoppscotch/kernel"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
|
||||
|
|
|
|||
|
|
@ -96,11 +96,20 @@ const createHoppClient = () => {
|
|||
willAuthError() {
|
||||
return platform.auth.willBackendHaveAuthError()
|
||||
},
|
||||
didAuthError() {
|
||||
return false
|
||||
didAuthError(error) {
|
||||
// Check for specific error patterns that indicate expired token
|
||||
return error.graphQLErrors.some(
|
||||
(e) =>
|
||||
e.message.includes("auth/fail") ||
|
||||
e.message.includes("jwt expired") ||
|
||||
e.extensions?.code === "UNAUTHENTICATED"
|
||||
)
|
||||
},
|
||||
async refreshAuth() {
|
||||
// TODO
|
||||
const refresh = platform.auth.refreshAuthToken
|
||||
// should we logout if refreshAuthToken is not defined?
|
||||
if (!refresh) return
|
||||
await refresh()
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
mutation CreateMockServer($input: CreateMockServerInput!) {
|
||||
createMockServer(input: $input) {
|
||||
id
|
||||
name
|
||||
subdomain
|
||||
serverUrlPathBased
|
||||
serverUrlDomainBased
|
||||
workspaceType
|
||||
workspaceID
|
||||
delayInMs
|
||||
isPublic
|
||||
isActive
|
||||
createdOn
|
||||
updatedOn
|
||||
creator {
|
||||
uid
|
||||
}
|
||||
collection {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
mutation DeleteMockServer($id: ID!) {
|
||||
deleteMockServer(id: $id)
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
mutation DeleteMockServerLog($logID: ID!) {
|
||||
deleteMockServerLog(logID: $logID)
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
mutation ImportUserCollectionsFromJSON(
|
||||
$jsonString: String!
|
||||
$reqType: ReqType!
|
||||
$parentCollectionID: ID
|
||||
) {
|
||||
importUserCollectionsFromJSON(
|
||||
jsonString: $jsonString
|
||||
reqType: $reqType
|
||||
parentCollectionID: $parentCollectionID
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
mutation UpdateMockServer($id: ID!, $input: UpdateMockServerInput!) {
|
||||
updateMockServer(id: $id, input: $input) {
|
||||
id
|
||||
name
|
||||
subdomain
|
||||
serverUrlPathBased
|
||||
serverUrlDomainBased
|
||||
workspaceType
|
||||
workspaceID
|
||||
delayInMs
|
||||
isPublic
|
||||
isActive
|
||||
createdOn
|
||||
updatedOn
|
||||
creator {
|
||||
uid
|
||||
}
|
||||
collection {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
query GetGQLRootUserCollections($cursor: ID, $take: Int) {
|
||||
rootGQLUserCollections(cursor: $cursor, take: $take) {
|
||||
id
|
||||
title
|
||||
data
|
||||
type
|
||||
parent {
|
||||
id
|
||||
}
|
||||
requests {
|
||||
id
|
||||
title
|
||||
request
|
||||
type
|
||||
collectionID
|
||||
}
|
||||
childrenGQL {
|
||||
id
|
||||
title
|
||||
data
|
||||
type
|
||||
parent {
|
||||
id
|
||||
}
|
||||
requests {
|
||||
id
|
||||
title
|
||||
request
|
||||
type
|
||||
collectionID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
query GetMockServer($id: ID!) {
|
||||
mockServer(id: $id) {
|
||||
id
|
||||
name
|
||||
subdomain
|
||||
serverUrlPathBased
|
||||
serverUrlDomainBased
|
||||
workspaceType
|
||||
workspaceID
|
||||
delayInMs
|
||||
isPublic
|
||||
isActive
|
||||
createdOn
|
||||
updatedOn
|
||||
creator {
|
||||
uid
|
||||
}
|
||||
collection {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
query GetMockServerLogs($mockServerID: ID!, $skip: Int, $take: Int) {
|
||||
mockServerLogs(mockServerID: $mockServerID, skip: $skip, take: $take) {
|
||||
id
|
||||
mockServerID
|
||||
requestMethod
|
||||
requestPath
|
||||
requestHeaders
|
||||
requestBody
|
||||
requestQuery
|
||||
responseStatus
|
||||
responseHeaders
|
||||
responseBody
|
||||
responseTime
|
||||
ipAddress
|
||||
userAgent
|
||||
executedAt
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
query GetMyMockServers($skip: Int, $take: Int) {
|
||||
myMockServers(skip: $skip, take: $take) {
|
||||
id
|
||||
name
|
||||
subdomain
|
||||
serverUrlPathBased
|
||||
serverUrlDomainBased
|
||||
workspaceType
|
||||
workspaceID
|
||||
delayInMs
|
||||
isPublic
|
||||
isActive
|
||||
createdOn
|
||||
updatedOn
|
||||
creator {
|
||||
uid
|
||||
}
|
||||
collection {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
query GetTeamMockServers($teamID: ID!, $skip: Int, $take: Int) {
|
||||
teamMockServers(teamID: $teamID, skip: $skip, take: $take) {
|
||||
id
|
||||
name
|
||||
subdomain
|
||||
serverUrlPathBased
|
||||
serverUrlDomainBased
|
||||
workspaceType
|
||||
workspaceID
|
||||
delayInMs
|
||||
isPublic
|
||||
isActive
|
||||
createdOn
|
||||
updatedOn
|
||||
creator {
|
||||
uid
|
||||
}
|
||||
collection {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
query GetUserRootCollections($cursor: ID, $take: Int) {
|
||||
rootRESTUserCollections(cursor: $cursor, take: $take) {
|
||||
id
|
||||
title
|
||||
data
|
||||
type
|
||||
parent {
|
||||
id
|
||||
}
|
||||
requests {
|
||||
id
|
||||
title
|
||||
request
|
||||
type
|
||||
collectionID
|
||||
}
|
||||
childrenREST {
|
||||
id
|
||||
title
|
||||
data
|
||||
type
|
||||
parent {
|
||||
id
|
||||
}
|
||||
requests {
|
||||
id
|
||||
title
|
||||
request
|
||||
type
|
||||
collectionID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
import * as TE from "fp-ts/TaskEither"
|
||||
import { client } from "../GQLClient"
|
||||
import { GQLError } from "../GQLClient"
|
||||
import { getI18n } from "~/modules/i18n"
|
||||
import {
|
||||
CreateMockServerDocument,
|
||||
UpdateMockServerDocument,
|
||||
DeleteMockServerDocument,
|
||||
GetTeamMockServersDocument,
|
||||
WorkspaceType,
|
||||
} from "../graphql"
|
||||
|
||||
// Types for mock server
|
||||
export type MockServer = {
|
||||
id: string
|
||||
name: string
|
||||
subdomain: string
|
||||
serverUrlPathBased?: string
|
||||
serverUrlDomainBased?: string | null
|
||||
workspaceType: WorkspaceType
|
||||
workspaceID?: string | null
|
||||
delayInMs?: number
|
||||
isPublic: boolean
|
||||
isActive: boolean
|
||||
createdOn: Date
|
||||
updatedOn: Date
|
||||
creator?: {
|
||||
uid: string
|
||||
}
|
||||
collection?: {
|
||||
id: string
|
||||
title: string
|
||||
requests?: any[]
|
||||
}
|
||||
// Legacy fields for backward compatibility
|
||||
userUid?: string
|
||||
collectionID?: string
|
||||
}
|
||||
|
||||
type CreateMockServerError =
|
||||
| "mock_server/invalid_collection"
|
||||
| "mock_server/invalid_collection_id"
|
||||
| "mock_server/name_too_short"
|
||||
| "mock_server/limit_exceeded"
|
||||
| "mock_server/already_exists"
|
||||
|
||||
type UpdateMockServerError =
|
||||
| "mock_server/not_found"
|
||||
| "mock_server/access_denied"
|
||||
|
||||
type DeleteMockServerError =
|
||||
| "mock_server/not_found"
|
||||
| "mock_server/access_denied"
|
||||
|
||||
export const createMockServer = (
|
||||
name: string,
|
||||
collectionID: string,
|
||||
workspaceType: WorkspaceType = WorkspaceType.User,
|
||||
workspaceID?: string,
|
||||
delayInMs: number = 0,
|
||||
isPublic: boolean = true
|
||||
) =>
|
||||
TE.tryCatch(
|
||||
async () => {
|
||||
const result = await client
|
||||
.value!.mutation(CreateMockServerDocument, {
|
||||
input: {
|
||||
name,
|
||||
collectionID,
|
||||
workspaceType,
|
||||
workspaceID,
|
||||
delayInMs,
|
||||
isPublic,
|
||||
},
|
||||
})
|
||||
.toPromise()
|
||||
|
||||
if (result.error) {
|
||||
// Try to extract a useful error message from the GraphQL error
|
||||
const err: any = result.error
|
||||
let message = err.message
|
||||
|
||||
// urql exposes GraphQL errors in graphQLErrors array
|
||||
const gqlErr = (err.graphQLErrors && err.graphQLErrors[0]) || null
|
||||
if (gqlErr) {
|
||||
// Prefer originalError.message from backend if present (it may be an array of messages)
|
||||
const orig =
|
||||
gqlErr.extensions &&
|
||||
gqlErr.extensions.originalError &&
|
||||
gqlErr.extensions.originalError.message
|
||||
if (orig) {
|
||||
message = Array.isArray(orig) ? orig.join(", ") : String(orig)
|
||||
} else if (gqlErr.message) {
|
||||
message = gqlErr.message
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
throw new Error("No data returned from create mock server mutation")
|
||||
}
|
||||
|
||||
const data = result.data.createMockServer
|
||||
// Map the GraphQL response to frontend format
|
||||
return {
|
||||
...data,
|
||||
userUid: data.creator?.uid || "", // Legacy field
|
||||
collectionID: data.collection?.id || collectionID, // Legacy field
|
||||
} as MockServer
|
||||
},
|
||||
(error) => (error as Error).message as CreateMockServerError
|
||||
)
|
||||
|
||||
export const updateMockServer = (
|
||||
id: string,
|
||||
input: {
|
||||
name?: string
|
||||
isActive?: boolean
|
||||
delayInMs?: number
|
||||
isPublic?: boolean
|
||||
}
|
||||
) =>
|
||||
TE.tryCatch(
|
||||
async () => {
|
||||
const result = await client
|
||||
.value!.mutation(UpdateMockServerDocument, {
|
||||
id,
|
||||
input,
|
||||
})
|
||||
.toPromise()
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || "Failed to update mock server")
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
throw new Error("No data returned from update mock server mutation")
|
||||
}
|
||||
|
||||
const data = result.data.updateMockServer
|
||||
// Map the GraphQL response to frontend format
|
||||
return {
|
||||
...data,
|
||||
userUid: data.creator?.uid || "", // Legacy field
|
||||
collectionID: data.collection?.id || "", // Legacy field
|
||||
} as MockServer
|
||||
},
|
||||
(error) => (error as Error).message as UpdateMockServerError
|
||||
)
|
||||
|
||||
export const deleteMockServer = (id: string) =>
|
||||
TE.tryCatch(
|
||||
async () => {
|
||||
const result = await client
|
||||
.value!.mutation(DeleteMockServerDocument, { id })
|
||||
.toPromise()
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || "Failed to delete mock server")
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
throw new Error("No data returned from delete mock server mutation")
|
||||
}
|
||||
|
||||
return result.data.deleteMockServer as boolean
|
||||
},
|
||||
(error) => (error as Error).message as DeleteMockServerError
|
||||
)
|
||||
|
||||
export const getTeamMockServers = (
|
||||
teamID: string,
|
||||
skip?: number,
|
||||
take?: number
|
||||
) =>
|
||||
TE.tryCatch(
|
||||
async () => {
|
||||
const result = await client
|
||||
.value!.query(GetTeamMockServersDocument, {
|
||||
teamID,
|
||||
skip,
|
||||
take,
|
||||
})
|
||||
.toPromise()
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
result.error.message || "Failed to get team mock servers"
|
||||
)
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
throw new Error("No data returned from get team mock servers query")
|
||||
}
|
||||
|
||||
const data = result.data.teamMockServers
|
||||
// Map the GraphQL response to frontend format
|
||||
return data.map((mockServer: any) => ({
|
||||
...mockServer,
|
||||
userUid: mockServer.creator?.uid || "", // Legacy field
|
||||
collectionID: mockServer.collection?.id || "", // Legacy field
|
||||
})) as MockServer[]
|
||||
},
|
||||
(error) => (error as Error).message as CreateMockServerError
|
||||
)
|
||||
|
||||
// Centralized mapper for backend GraphQL error tokens to user-facing messages.
|
||||
export const getErrorMessage = (err: GQLError<string> | string | Error) => {
|
||||
const t = getI18n()
|
||||
|
||||
// Normalize to GQLError-like shape
|
||||
let gErr: GQLError<string> | null = null
|
||||
|
||||
if (typeof err === "string") {
|
||||
gErr = { type: "gql_error", error: err }
|
||||
} else if (err instanceof Error) {
|
||||
gErr = { type: "network_error", error: err }
|
||||
} else if ((err as any)?.type) {
|
||||
gErr = err as GQLError<string>
|
||||
}
|
||||
|
||||
if (!gErr) return t("error.something_went_wrong")
|
||||
|
||||
if (gErr.type === "network_error") {
|
||||
return t("error.network_error")
|
||||
}
|
||||
|
||||
const code = String(gErr.error)
|
||||
|
||||
switch (code) {
|
||||
case "mock_server/invalid_collection":
|
||||
case "mock_server/invalid_collection_id":
|
||||
return t("mock_server.invalid_collection_error")
|
||||
default:
|
||||
return t("error.something_went_wrong")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
import { runMutation } from "../GQLClient"
|
||||
import { runGQLQuery } from "../GQLClient"
|
||||
import {
|
||||
GetGqlRootUserCollectionsDocument,
|
||||
GetGqlRootUserCollectionsQuery,
|
||||
GetGqlRootUserCollectionsQueryVariables,
|
||||
GetUserRootCollectionsDocument,
|
||||
GetUserRootCollectionsQuery,
|
||||
GetUserRootCollectionsQueryVariables,
|
||||
ImportUserCollectionsFromJsonDocument,
|
||||
ImportUserCollectionsFromJsonMutation,
|
||||
ImportUserCollectionsFromJsonMutationVariables,
|
||||
ReqType,
|
||||
UserCollection,
|
||||
UserRequest,
|
||||
} from "../graphql"
|
||||
import {
|
||||
HoppCollection,
|
||||
makeCollection,
|
||||
HoppRESTRequest,
|
||||
HoppGQLRequest,
|
||||
getDefaultRESTRequest,
|
||||
getDefaultGQLRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import * as E from "fp-ts/Either"
|
||||
|
||||
export const importUserCollectionsFromJSON = (
|
||||
collectionJSON: string,
|
||||
reqType: ReqType,
|
||||
parentCollectionID?: string
|
||||
) =>
|
||||
runMutation<
|
||||
ImportUserCollectionsFromJsonMutation,
|
||||
ImportUserCollectionsFromJsonMutationVariables,
|
||||
""
|
||||
>(ImportUserCollectionsFromJsonDocument, {
|
||||
jsonString: collectionJSON,
|
||||
reqType,
|
||||
parentCollectionID,
|
||||
})
|
||||
|
||||
// Use generated GraphQL documents instead of inline gql tags
|
||||
export const getUserRootCollections = () =>
|
||||
runGQLQuery<
|
||||
GetUserRootCollectionsQuery,
|
||||
GetUserRootCollectionsQueryVariables,
|
||||
""
|
||||
>({
|
||||
query: GetUserRootCollectionsDocument,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
export const getGQLRootUserCollections = () =>
|
||||
runGQLQuery<
|
||||
GetGqlRootUserCollectionsQuery,
|
||||
GetGqlRootUserCollectionsQueryVariables,
|
||||
""
|
||||
>({
|
||||
query: GetGqlRootUserCollectionsDocument,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
/**
|
||||
* Converts a UserRequest from backend format to HoppRequest format
|
||||
*/
|
||||
function convertUserRequestToHoppRequest(
|
||||
userRequest: UserRequest
|
||||
): HoppRESTRequest | HoppGQLRequest {
|
||||
try {
|
||||
const parsedRequest = JSON.parse(userRequest.request)
|
||||
|
||||
// Add the backend ID and title to the request
|
||||
const request = {
|
||||
...parsedRequest,
|
||||
id: userRequest.id,
|
||||
name: userRequest.title,
|
||||
}
|
||||
|
||||
return request
|
||||
} catch {
|
||||
// Return a default request if parsing fails
|
||||
if (userRequest.type === ReqType.Rest) {
|
||||
const defaultRequest = getDefaultRESTRequest()
|
||||
defaultRequest.id = userRequest.id
|
||||
defaultRequest.name = userRequest.title
|
||||
return defaultRequest
|
||||
}
|
||||
const defaultRequest = getDefaultGQLRequest()
|
||||
defaultRequest.id = userRequest.id
|
||||
defaultRequest.name = userRequest.title
|
||||
return defaultRequest
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse collection data similar to the existing parseCollectionData function in helpers.ts
|
||||
*/
|
||||
function parseUserCollectionData(data: string | null | undefined) {
|
||||
const defaultDataProps = {
|
||||
auth: { authType: "inherit", authActive: true },
|
||||
headers: [],
|
||||
variables: [],
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return defaultDataProps
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedData = JSON.parse(data)
|
||||
return {
|
||||
auth: parsedData?.auth || defaultDataProps.auth,
|
||||
headers: parsedData?.headers || defaultDataProps.headers,
|
||||
variables: parsedData?.variables || defaultDataProps.variables,
|
||||
}
|
||||
} catch {
|
||||
return defaultDataProps
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a UserCollection from backend format to HoppCollection format
|
||||
* Following the same pattern as teamCollectionJSONToHoppRESTColl in helpers.ts
|
||||
*/
|
||||
export function convertUserCollectionToHoppCollection(
|
||||
userCollection: UserCollection,
|
||||
reqType: ReqType
|
||||
): HoppCollection {
|
||||
const { auth, headers, variables } = parseUserCollectionData(
|
||||
userCollection.data
|
||||
)
|
||||
|
||||
// Get the appropriate children based on request type
|
||||
const children =
|
||||
reqType === ReqType.Rest
|
||||
? userCollection.childrenREST
|
||||
: userCollection.childrenGQL
|
||||
|
||||
// Convert requests - filter by type and convert
|
||||
const requests = userCollection.requests
|
||||
? userCollection.requests
|
||||
.filter((req) => req.type === reqType)
|
||||
.map(convertUserRequestToHoppRequest)
|
||||
: []
|
||||
|
||||
const collection = makeCollection({
|
||||
name: userCollection.title,
|
||||
folders: children
|
||||
? children.map((child) =>
|
||||
convertUserCollectionToHoppCollection(child, reqType)
|
||||
)
|
||||
: [],
|
||||
requests: requests,
|
||||
auth,
|
||||
headers,
|
||||
variables,
|
||||
})
|
||||
|
||||
// Add the backend ID to the collection
|
||||
collection.id = userCollection.id
|
||||
|
||||
return collection
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user collections from backend and converts them to HoppCollection format
|
||||
*/
|
||||
export const fetchAndConvertUserCollections = async (reqType: ReqType) => {
|
||||
const fetchFunction =
|
||||
reqType === ReqType.Rest
|
||||
? getUserRootCollections
|
||||
: getGQLRootUserCollections
|
||||
|
||||
const result = await fetchFunction()
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
return E.left(result.left)
|
||||
}
|
||||
|
||||
if (reqType === ReqType.Rest) {
|
||||
const right = result.right as GetUserRootCollectionsQuery
|
||||
const collections = right.rootRESTUserCollections
|
||||
const convertedCollections = collections.map((collection) =>
|
||||
convertUserCollectionToHoppCollection(
|
||||
collection as unknown as UserCollection,
|
||||
reqType
|
||||
)
|
||||
)
|
||||
return E.right(convertedCollections)
|
||||
}
|
||||
const right = result.right as GetGqlRootUserCollectionsQuery
|
||||
const collections = right.rootGQLUserCollections
|
||||
const convertedCollections = collections.map((collection) =>
|
||||
convertUserCollectionToHoppCollection(
|
||||
collection as unknown as UserCollection,
|
||||
reqType
|
||||
)
|
||||
)
|
||||
return E.right(convertedCollections)
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import * as TE from "fp-ts/TaskEither"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { runGQLQuery } from "../GQLClient"
|
||||
import {
|
||||
GetMyMockServersDocument,
|
||||
GetTeamMockServersDocument,
|
||||
GetMockServerDocument,
|
||||
type GetMyMockServersQuery,
|
||||
type GetTeamMockServersQuery,
|
||||
type GetMockServerQuery,
|
||||
} from "../graphql"
|
||||
|
||||
type GetMyMockServersError = "user/not_authenticated"
|
||||
|
||||
type GetTeamMockServersError = "team/not_found" | "team/access_denied"
|
||||
|
||||
type GetMockServerError = "mock_server/not_found" | "mock_server/access_denied"
|
||||
|
||||
export const getMyMockServers = (skip?: number, take?: number) =>
|
||||
TE.tryCatch(
|
||||
async () => {
|
||||
const result = await runGQLQuery({
|
||||
query: GetMyMockServersDocument,
|
||||
variables: { skip, take },
|
||||
})
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
throw result.left
|
||||
}
|
||||
|
||||
const data = result.right as GetMyMockServersQuery
|
||||
// Map the GraphQL response to frontend format
|
||||
return data.myMockServers.map((mockServer) => ({
|
||||
...mockServer,
|
||||
creator: mockServer.creator
|
||||
? { uid: mockServer.creator.uid }
|
||||
: undefined,
|
||||
userUid: mockServer.creator?.uid || "", // Legacy field
|
||||
collectionID: mockServer.collection?.id || "", // Legacy field
|
||||
}))
|
||||
},
|
||||
(error) => error as GetMyMockServersError
|
||||
)
|
||||
|
||||
export const getTeamMockServers = (
|
||||
teamID: string,
|
||||
skip?: number,
|
||||
take?: number
|
||||
) =>
|
||||
TE.tryCatch(
|
||||
async () => {
|
||||
const result = await runGQLQuery({
|
||||
query: GetTeamMockServersDocument,
|
||||
variables: { teamID, skip, take },
|
||||
})
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
throw result.left
|
||||
}
|
||||
|
||||
const data = result.right as GetTeamMockServersQuery
|
||||
// Map the GraphQL response to frontend format
|
||||
return data.teamMockServers.map((mockServer) => ({
|
||||
...mockServer,
|
||||
creator: mockServer.creator
|
||||
? { uid: mockServer.creator.uid }
|
||||
: undefined,
|
||||
userUid: mockServer.creator?.uid || "", // Legacy field
|
||||
collectionID: mockServer.collection?.id || "", // Legacy field
|
||||
}))
|
||||
},
|
||||
(error) => error as GetTeamMockServersError
|
||||
)
|
||||
|
||||
export const getMockServer = (id: string) =>
|
||||
TE.tryCatch(
|
||||
async () => {
|
||||
const result = await runGQLQuery({
|
||||
query: GetMockServerDocument,
|
||||
variables: { id },
|
||||
})
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
throw result.left
|
||||
}
|
||||
|
||||
const data = result.right as GetMockServerQuery
|
||||
// Map the GraphQL response to frontend format
|
||||
return {
|
||||
...data.mockServer,
|
||||
userUid: data.mockServer.creator?.uid || "", // Legacy field
|
||||
collectionID: data.mockServer.collection?.id || "", // Legacy field
|
||||
}
|
||||
},
|
||||
(error) => error as GetMockServerError
|
||||
)
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import * as TE from "fp-ts/TaskEither"
|
||||
import { client } from "../GQLClient"
|
||||
import {
|
||||
GetMockServerLogsDocument,
|
||||
GetMockServerLogsQuery,
|
||||
GetMockServerLogsQueryVariables,
|
||||
DeleteMockServerLogDocument,
|
||||
DeleteMockServerLogMutation,
|
||||
DeleteMockServerLogMutationVariables,
|
||||
} from "../graphql"
|
||||
|
||||
export const getMockServerLogs = (
|
||||
mockServerID: string,
|
||||
skip?: number,
|
||||
take?: number
|
||||
) =>
|
||||
TE.tryCatch(
|
||||
async () => {
|
||||
const result = await client
|
||||
.value!.query<
|
||||
GetMockServerLogsQuery,
|
||||
GetMockServerLogsQueryVariables
|
||||
>(GetMockServerLogsDocument, { mockServerID, skip, take })
|
||||
.toPromise()
|
||||
|
||||
if (result.error)
|
||||
throw new Error(
|
||||
result.error.message || "Failed to fetch mock server logs"
|
||||
)
|
||||
if (!result.data) throw new Error("No data returned from mockServerLogs")
|
||||
|
||||
return result.data.mockServerLogs
|
||||
},
|
||||
(e) => (e as Error).message
|
||||
)
|
||||
|
||||
export const deleteMockServerLog = (logID: string) =>
|
||||
TE.tryCatch(
|
||||
async () => {
|
||||
const result = await client
|
||||
.value!.mutation<
|
||||
DeleteMockServerLogMutation,
|
||||
DeleteMockServerLogMutationVariables
|
||||
>(DeleteMockServerLogDocument, { logID })
|
||||
.toPromise()
|
||||
if (result.error)
|
||||
throw new Error(
|
||||
result.error.message || "Failed to delete mock server log"
|
||||
)
|
||||
if (!result.data)
|
||||
throw new Error("No data returned from deleteMockServerLog")
|
||||
return result.data.deleteMockServerLog as boolean
|
||||
},
|
||||
(e) => (e as Error).message
|
||||
)
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import { HoppRESTRequest } from "@hoppscotch/data"
|
||||
|
||||
/**
|
||||
* Applies pre-request script modifications to the original request.
|
||||
*
|
||||
* For legacy sandbox: Returns original request unchanged (`updatedRequest` is `undefined`).
|
||||
* For experimental sandbox: Merges script changes while preserving file uploads
|
||||
* lost during JSON serialization.
|
||||
*
|
||||
* Context: When the experimental scripting sandbox is enabled, requests are
|
||||
* sent to a Web Worker for pre-request script execution. The request undergoes
|
||||
* JSON serialization which converts File/Blob objects to empty objects `{}`.
|
||||
* A Zod transform then converts file fields with empty arrays to text fields
|
||||
* (`isFile: false`, `value: ""`).
|
||||
*
|
||||
* This function uses hybrid matching to handle both:
|
||||
* - Duplicate keys (e.g., multiple fields with `key="file"`) via index matching
|
||||
* - Field reordering by scripts via key-based fallback
|
||||
*
|
||||
* @param originalRequest The original request with file uploads intact
|
||||
* @param updatedRequest The request returned from sandbox (undefined for legacy, modified for experimental)
|
||||
* @returns Merged request with file uploads preserved and script changes applied
|
||||
*
|
||||
* @see https://github.com/hoppscotch/hoppscotch/issues/5443
|
||||
* @see FormDataKeyValue schema in ~/hoppscotch-data/src/rest/v/9/body.ts
|
||||
*/
|
||||
export const applyScriptRequestUpdates = (
|
||||
originalRequest: HoppRESTRequest,
|
||||
updatedRequest?: HoppRESTRequest
|
||||
): HoppRESTRequest => {
|
||||
if (!updatedRequest) {
|
||||
return originalRequest
|
||||
}
|
||||
|
||||
const originalBody = originalRequest.body
|
||||
const updatedBody = updatedRequest.body
|
||||
|
||||
if (
|
||||
originalBody.contentType === "multipart/form-data" &&
|
||||
updatedBody.contentType === "multipart/form-data"
|
||||
) {
|
||||
const originalFormData = originalBody.body
|
||||
const updatedFormData = updatedBody.body
|
||||
const usedIndices = new Set<number>()
|
||||
|
||||
const mergedFormData = updatedFormData.map((updatedField, index) => {
|
||||
// Hybrid matching: try position first (handles duplicate keys like "file", "file", "file"),
|
||||
// then search by key (handles field reordering by scripts)
|
||||
const samePositionMatch =
|
||||
index < originalFormData.length &&
|
||||
!usedIndices.has(index) &&
|
||||
originalFormData[index].key === updatedField.key
|
||||
|
||||
const matchedIndex = samePositionMatch
|
||||
? index
|
||||
: originalFormData.findIndex(
|
||||
(field, i) => !usedIndices.has(i) && field.key === updatedField.key
|
||||
)
|
||||
|
||||
// If matched, restore file data from original (only `originalField` has `isFile=true`)
|
||||
if (matchedIndex >= 0) {
|
||||
usedIndices.add(matchedIndex)
|
||||
const originalField = originalFormData[matchedIndex]
|
||||
|
||||
if (originalField.isFile) {
|
||||
return {
|
||||
...updatedField,
|
||||
value: originalField.value,
|
||||
isFile: true as const,
|
||||
...(originalField.contentType && {
|
||||
contentType: originalField.contentType,
|
||||
}),
|
||||
} as typeof updatedField
|
||||
}
|
||||
}
|
||||
|
||||
return updatedField
|
||||
})
|
||||
|
||||
return {
|
||||
...originalRequest,
|
||||
...updatedRequest,
|
||||
body: { ...updatedBody, body: mergedFormData },
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
originalBody.contentType === "application/octet-stream" &&
|
||||
updatedBody.contentType === "application/octet-stream" &&
|
||||
originalBody.body instanceof Blob
|
||||
) {
|
||||
return {
|
||||
...originalRequest,
|
||||
...updatedRequest,
|
||||
body: { ...updatedBody, body: originalBody.body },
|
||||
}
|
||||
}
|
||||
|
||||
// No files to preserve
|
||||
return {
|
||||
...originalRequest,
|
||||
...updatedRequest,
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,14 @@ export type InputDomainSetting = {
|
|||
}
|
||||
}
|
||||
}
|
||||
options?: {
|
||||
followRedirects?: boolean
|
||||
maxRedirects?: number
|
||||
timeout?: number
|
||||
decompress?: boolean
|
||||
cookies?: boolean
|
||||
keepAlive?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const convertStoreFile = (file: StoreFile): O.Option<Uint8Array> =>
|
||||
|
|
@ -207,19 +215,41 @@ const convertProxy = (
|
|||
})
|
||||
)
|
||||
|
||||
const convertOptions = (
|
||||
options?: InputDomainSetting["options"]
|
||||
): O.Option<NonNullable<RelayRequest["meta"]>["options"]> =>
|
||||
pipe(
|
||||
O.fromNullable(options),
|
||||
O.map((opts) => ({
|
||||
...(opts.followRedirects !== undefined && {
|
||||
followRedirects: opts.followRedirects,
|
||||
}),
|
||||
...(opts.maxRedirects !== undefined && {
|
||||
maxRedirects: Math.min(opts.maxRedirects, 10),
|
||||
}),
|
||||
...(opts.timeout !== undefined && { timeout: opts.timeout }),
|
||||
...(opts.decompress !== undefined && { decompress: opts.decompress }),
|
||||
...(opts.cookies !== undefined && { cookies: opts.cookies }),
|
||||
...(opts.keepAlive !== undefined && { keepAlive: opts.keepAlive }),
|
||||
})),
|
||||
O.filter((opts) => Object.keys(opts).length > 0)
|
||||
)
|
||||
|
||||
export const convertDomainSetting = (
|
||||
input: InputDomainSetting
|
||||
): E.Either<Error, Pick<RelayRequest, "proxy" | "security">> => {
|
||||
): E.Either<Error, Pick<RelayRequest, "proxy" | "security" | "meta">> => {
|
||||
if (input.version !== "v1") {
|
||||
return E.left(new Error("Invalid version"))
|
||||
}
|
||||
|
||||
const security = convertSecurity(input.security)
|
||||
const proxy = convertProxy(input.proxy)
|
||||
const options = convertOptions(input.options)
|
||||
|
||||
const result: Pick<RelayRequest, "proxy" | "security"> = {
|
||||
const result: Pick<RelayRequest, "proxy" | "security" | "meta"> = {
|
||||
proxy: O.isSome(proxy) ? proxy.value : undefined,
|
||||
security: O.isSome(security) ? security.value : undefined,
|
||||
meta: O.isSome(options) ? { options: options.value } : undefined,
|
||||
}
|
||||
|
||||
return E.right(result)
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ import {
|
|||
getDefaultGQLRequest,
|
||||
getDefaultRESTRequest,
|
||||
translateToNewRESTCollection,
|
||||
HoppGQLRequest,
|
||||
translateToNewGQLCollection,
|
||||
} from "@hoppscotch/data"
|
||||
import * as A from "fp-ts/Array"
|
||||
import * as O from "fp-ts/Option"
|
||||
import * as RA from "fp-ts/ReadonlyArray"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { flow, pipe } from "fp-ts/function"
|
||||
|
||||
import { HoppGQLRequest, translateToNewGQLCollection } from "@hoppscotch/data"
|
||||
import { safeParseJSON } from "~/helpers/functional/json"
|
||||
import { IMPORTER_INVALID_FILE_FORMAT } from "."
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,18 @@ import FileImportVue from "~/components/importExport/ImportExportSteps/FileImpor
|
|||
import { defineStep } from "~/composables/step-components"
|
||||
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
import { Ref } from "vue"
|
||||
import type { Ref } from "vue"
|
||||
|
||||
export function FileSource(metadata: {
|
||||
acceptedFileTypes: string
|
||||
caption: string
|
||||
onImportFromFile: (content: string[]) => any | Promise<any>
|
||||
onImportFromFile: (
|
||||
content: string[],
|
||||
importScripts?: boolean
|
||||
) => any | Promise<any>
|
||||
isLoading?: Ref<boolean>
|
||||
description?: string
|
||||
showPostmanScriptOption?: boolean
|
||||
}) {
|
||||
const stepID = uuidv4()
|
||||
|
||||
|
|
@ -19,5 +23,6 @@ export function FileSource(metadata: {
|
|||
onImportFromFile: metadata.onImportFromFile,
|
||||
loading: metadata.isLoading?.value,
|
||||
description: metadata.description,
|
||||
showPostmanScriptOption: metadata.showPostmanScriptOption,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,30 @@ const safeParseJSON = (jsonStr: string) => O.tryCatch(() => JSON.parse(jsonStr))
|
|||
|
||||
const isPMItem = (x: unknown): x is Item => Item.isItem(x)
|
||||
|
||||
/**
|
||||
* Checks if the Postman collection schema version supports scripts (v2.0+)
|
||||
* @param schema - The schema URL from collection.info.schema
|
||||
* @returns true if v2.0 or v2.1, false otherwise
|
||||
*/
|
||||
const isSchemaVersionSupported = (schema?: string): boolean => {
|
||||
if (!schema) return false
|
||||
// Support both schema.getpostman.com and schema.postman.com
|
||||
return schema.includes("/v2.0.") || schema.includes("/v2.1.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the collection schema from raw JSON data
|
||||
* Note: PMCollection SDK doesn't expose .info.schema, so we parse raw JSON
|
||||
*/
|
||||
const getCollectionSchema = (jsonStr: string): string | null => {
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
return data?.info?.schema ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const replacePMVarTemplating = flow(
|
||||
S.replace(/{{\s*/g, "<<"),
|
||||
S.replace(/\s*}}/g, ">>")
|
||||
|
|
@ -303,6 +327,31 @@ const getHoppReqAuth = (
|
|||
const token = replacePMVarTemplating(
|
||||
getVariableValue(auth.oauth2, "accessToken") ?? ""
|
||||
)
|
||||
const clientSecret = replacePMVarTemplating(
|
||||
getVariableValue(auth.oauth2, "clientSecret") ?? ""
|
||||
)
|
||||
|
||||
// Check for PKCE settings
|
||||
const usePkce = getVariableValue(auth.oauth2, "usePkce")
|
||||
const isPKCE = usePkce === "true"
|
||||
|
||||
// Get challenge algorithm, default to S256 if PKCE is enabled but no algorithm specified
|
||||
const challengeAlgorithm = getVariableValue(
|
||||
auth.oauth2,
|
||||
"challengeAlgorithm"
|
||||
)
|
||||
let codeVerifierMethod: "plain" | "S256" | undefined
|
||||
|
||||
if (isPKCE) {
|
||||
// Postman uses "SHA-256" or "plain" - normalize to our format
|
||||
// Default to S256 for any value other than "plain"
|
||||
if (challengeAlgorithm === "plain") {
|
||||
codeVerifierMethod = "plain"
|
||||
} else {
|
||||
// Covers "S256", "SHA-256", undefined, and any other value
|
||||
codeVerifierMethod = "S256"
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authType: "oauth-2",
|
||||
|
|
@ -314,8 +363,9 @@ const getHoppReqAuth = (
|
|||
scopes: scope,
|
||||
token: token,
|
||||
tokenEndpoint: accessTokenURL,
|
||||
clientSecret: "",
|
||||
isPKCE: false,
|
||||
clientSecret: clientSecret,
|
||||
isPKCE: isPKCE,
|
||||
...(codeVerifierMethod ? { codeVerifierMethod } : {}),
|
||||
authRequestParams: [],
|
||||
tokenRequestParams: [],
|
||||
refreshRequestParams: [],
|
||||
|
|
@ -456,7 +506,56 @@ const getHoppReqURL = (url: Item["request"]["url"] | null): string => {
|
|||
)
|
||||
}
|
||||
|
||||
const getHoppRequest = (item: Item): HoppRESTRequest => {
|
||||
/**
|
||||
* Extracts script content from a Postman event
|
||||
* Handles both string format and exec array format
|
||||
*/
|
||||
const extractScriptFromEvent = (event: any): string => {
|
||||
if (!event?.script) return ""
|
||||
|
||||
if (typeof event.script === "string") {
|
||||
return event.script
|
||||
}
|
||||
|
||||
if (event.script.exec && Array.isArray(event.script.exec)) {
|
||||
return event.script.exec.join("\n")
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
const getHoppScripts = (
|
||||
item: Item,
|
||||
importScripts: boolean
|
||||
): { preRequestScript: string; testScript: string } => {
|
||||
if (!importScripts) {
|
||||
return { preRequestScript: "", testScript: "" }
|
||||
}
|
||||
|
||||
let preRequestScript = ""
|
||||
let testScript = ""
|
||||
|
||||
// Postman stores scripts in the events array
|
||||
if (item.events) {
|
||||
const events = item.events.all()
|
||||
events.forEach((event: any) => {
|
||||
if (event.listen === "prerequest") {
|
||||
preRequestScript = extractScriptFromEvent(event)
|
||||
} else if (event.listen === "test") {
|
||||
testScript = extractScriptFromEvent(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { preRequestScript, testScript }
|
||||
}
|
||||
|
||||
const getHoppRequest = (
|
||||
item: Item,
|
||||
importScripts: boolean
|
||||
): HoppRESTRequest => {
|
||||
const { preRequestScript, testScript } = getHoppScripts(item, importScripts)
|
||||
|
||||
return makeRESTRequest({
|
||||
name: item.name,
|
||||
endpoint: getHoppReqURL(item.request.url),
|
||||
|
|
@ -470,38 +569,68 @@ const getHoppRequest = (item: Item): HoppRESTRequest => {
|
|||
}),
|
||||
requestVariables: getHoppReqVariables(item.request.url.variables),
|
||||
responses: getHoppResponses(item.responses),
|
||||
|
||||
// TODO: Decide about this
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
preRequestScript,
|
||||
testScript,
|
||||
})
|
||||
}
|
||||
|
||||
const getHoppFolder = (ig: ItemGroup<Item>): HoppCollection =>
|
||||
const getHoppFolder = (
|
||||
ig: ItemGroup<Item>,
|
||||
importScripts: boolean
|
||||
): HoppCollection =>
|
||||
makeCollection({
|
||||
name: ig.name,
|
||||
folders: pipe(
|
||||
ig.items.all(),
|
||||
A.filter(isPMItemGroup),
|
||||
A.map(getHoppFolder)
|
||||
A.map((folder) => getHoppFolder(folder, importScripts))
|
||||
),
|
||||
requests: pipe(
|
||||
ig.items.all(),
|
||||
A.filter(isPMItem),
|
||||
A.map((item) => getHoppRequest(item, importScripts))
|
||||
),
|
||||
requests: pipe(ig.items.all(), A.filter(isPMItem), A.map(getHoppRequest)),
|
||||
auth: getHoppReqAuth(ig.auth),
|
||||
headers: [],
|
||||
variables: getHoppCollVariables(ig),
|
||||
})
|
||||
|
||||
export const getHoppCollections = (collections: PMCollection[]) => {
|
||||
return collections.map(getHoppFolder)
|
||||
export const getHoppCollections = (
|
||||
collections: PMCollection[],
|
||||
importScripts: boolean
|
||||
) => {
|
||||
return collections.map((collection) =>
|
||||
getHoppFolder(collection, importScripts)
|
||||
)
|
||||
}
|
||||
|
||||
export const hoppPostmanImporter = (fileContents: string[]) =>
|
||||
export const hoppPostmanImporter = (
|
||||
fileContents: string[],
|
||||
importScripts = false
|
||||
) =>
|
||||
pipe(
|
||||
// Try reading
|
||||
fileContents,
|
||||
A.traverse(O.Applicative)(readPMCollection),
|
||||
|
||||
O.map(flow(getHoppCollections)),
|
||||
O.map((collections) => {
|
||||
// Validate schema version if importing scripts
|
||||
if (importScripts && fileContents.length > 0) {
|
||||
const schema = getCollectionSchema(fileContents[0])
|
||||
const isSupported = isSchemaVersionSupported(schema ?? undefined)
|
||||
|
||||
if (!isSupported) {
|
||||
console.warn(
|
||||
`[Postman Import] Script import requested but collection schema "${schema ?? "unknown"}" does not support scripts. ` +
|
||||
`Only Postman Collection Format v2.0 and v2.1 are supported. Scripts will be skipped.`
|
||||
)
|
||||
// Skip script import for unsupported versions
|
||||
return getHoppCollections(collections, false)
|
||||
}
|
||||
}
|
||||
|
||||
return getHoppCollections(collections, importScripts)
|
||||
}),
|
||||
|
||||
TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT)
|
||||
)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue