chore: v2025.10.0 release

This commit is contained in:
James George 2025-10-31 11:32:37 +05:30 committed by GitHub
commit 654e18fce9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
261 changed files with 45922 additions and 5485 deletions

View file

@ -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

View file

@ -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

View file

@ -16,7 +16,7 @@
</b>
<p>
[![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen?logo=github)](CODE_OF_CONDUCT.md) [![Website](https://img.shields.io/website?url=https%3A%2F%2Fhoppscotch.io&logo=hoppscotch)](https://hoppscotch.io) [![Tests](https://github.com/hoppscotch/hoppscotch/actions/workflows/tests.yml/badge.svg)](https://github.com/hoppscotch/hoppscotch/actions) [![Tweet](https://img.shields.io/twitter/url?url=https%3A%2F%2Fhoppscotch.io%2F)](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)
[![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen?logo=github)](CODE_OF_CONDUCT.md) [![Website](https://img.shields.io/website?url=https%3A%2F%2Fhoppscotch.io&logo=hoppscotch)](https://hoppscotch.io) [![Tests](https://github.com/hoppscotch/hoppscotch/actions/workflows/tests.yml/badge.svg)](https://github.com/hoppscotch/hoppscotch/actions) [![Tweet](https://img.shields.io/twitter/url?url=https%3A%2F%2Fhoppscotch.io%2F)](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>

View file

@ -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
}
}

View file

@ -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

View file

@ -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

View file

@ -45,7 +45,7 @@
[[redirects]]
from = "/twitter"
to = "https://twitter.com/hoppscotch_io"
to = "https://x.com/hoppscotch_io"
status = 301
force = true

View file

@ -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",

View file

@ -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"
}
}

View file

@ -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",

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -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",

View file

@ -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",

View file

@ -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
}
}

View file

@ -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": [

View file

@ -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");

View file

@ -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
}

View file

@ -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,

View file

@ -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';

View file

@ -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';

View file

@ -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,
];
/**

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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);
});
}

View file

@ -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;
}
}

View file

@ -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);
}
}
}

View file

@ -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;
}
}

View file

@ -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',
});
}
}
}

View 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',
});

View file

@ -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 {}

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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';

View file

@ -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';

View file

@ -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,

View file

@ -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(),

View file

@ -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',
}

View file

@ -0,0 +1,4 @@
export enum WorkspaceType {
USER = 'USER',
TEAM = 'TEAM',
}

View file

@ -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(),

View file

@ -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';

View file

@ -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

View file

@ -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 })
);
}),

View file

@ -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"
}
}
}

View file

@ -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"

View file

@ -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']

View file

@ -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>

View file

@ -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",

View file

@ -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

View file

@ -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",

View file

@ -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")

View file

@ -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

View file

@ -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)

View file

@ -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<{

View file

@ -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

View file

@ -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) {

View file

@ -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>

View file

@ -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")
},

View file

@ -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) {

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -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) => {

View file

@ -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,
},
})
}

View 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,
}
}

View file

@ -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,
}
}

View file

@ -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 }
)
}

View file

@ -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,

View file

@ -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",

View file

@ -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"

View file

@ -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()
},
}
}),

View file

@ -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
}
}
}

View file

@ -0,0 +1,3 @@
mutation DeleteMockServer($id: ID!) {
deleteMockServer(id: $id)
}

View file

@ -0,0 +1,3 @@
mutation DeleteMockServerLog($logID: ID!) {
deleteMockServerLog(logID: $logID)
}

View file

@ -0,0 +1,11 @@
mutation ImportUserCollectionsFromJSON(
$jsonString: String!
$reqType: ReqType!
$parentCollectionID: ID
) {
importUserCollectionsFromJSON(
jsonString: $jsonString
reqType: $reqType
parentCollectionID: $parentCollectionID
)
}

View file

@ -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
}
}
}

View file

@ -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
}
}
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}
}
}

View file

@ -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")
}
}

View file

@ -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)
}

View file

@ -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
)

View file

@ -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
)

View file

@ -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,
}
}

View file

@ -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)

View file

@ -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 "."

View file

@ -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,
}))
}

View file

@ -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