mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
Compare commits
53 commits
hive@11.0.
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b35f9fbc1f | ||
|
|
863f920b86 | ||
|
|
64e7fed1d4 | ||
|
|
a71b45bf76 | ||
|
|
730771fb50 | ||
|
|
5a85fb9dd6 | ||
|
|
d7e7025624 | ||
|
|
fe06ddec5a | ||
|
|
a237b4e782 | ||
|
|
5706e2de0e | ||
|
|
9c6989cd92 | ||
|
|
c46b2f2219 | ||
|
|
4a8bd4fd1b | ||
|
|
e3d9750cc9 | ||
|
|
7d4ef94432 | ||
|
|
fab4b03ace | ||
|
|
ed9ab34c70 | ||
|
|
f7a74ce419 | ||
|
|
2feec5d5db | ||
|
|
0162def432 | ||
|
|
c6905421e7 | ||
|
|
01184317c7 | ||
|
|
9708f71aa3 | ||
|
|
40fd27d9c0 | ||
|
|
77d6063512 | ||
|
|
1333fbcafa | ||
|
|
61394f30b0 | ||
|
|
0d81a7a01b | ||
|
|
2879316c05 | ||
|
|
dae36931f9 | ||
|
|
ca69b1c59f | ||
|
|
9ce8fda7f8 | ||
|
|
84043d9b3a | ||
|
|
615fb095d8 | ||
|
|
007dc4b8df | ||
|
|
f042e51bc3 | ||
|
|
742f50c52e | ||
|
|
73ba28d914 | ||
|
|
96bd390c7f | ||
|
|
34528114f3 | ||
|
|
fd0b8251c5 | ||
|
|
574a5d823e | ||
|
|
d3e0ef500f | ||
|
|
14858198e1 | ||
|
|
bd695d0f64 | ||
|
|
aa51ecd0de | ||
|
|
72cf5b8659 | ||
|
|
02f3ace50b | ||
|
|
12c990c2d8 | ||
|
|
883e0bcb02 | ||
|
|
aac23596ec | ||
|
|
e83bc29e2a | ||
|
|
7834a4d52a |
322 changed files with 9531 additions and 23563 deletions
|
|
@ -1,3 +0,0 @@
|
||||||
# The following are aliases you can use with "cargo command_name"
|
|
||||||
[alias]
|
|
||||||
"clippy:fix" = "clippy --all --fix --allow-dirty --allow-staged"
|
|
||||||
169
.github/workflows/apollo-router-release.yaml
vendored
169
.github/workflows/apollo-router-release.yaml
vendored
|
|
@ -1,169 +0,0 @@
|
||||||
name: apollo-router-release
|
|
||||||
on:
|
|
||||||
# For PRs, this pipeline will use the commit ID as Docker image tag and R2 artifact prefix.
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- 'packages/libraries/router/**'
|
|
||||||
- 'packages/libraries/sdk-rs/**'
|
|
||||||
- 'docker/router.dockerfile'
|
|
||||||
- 'scripts/compress/**'
|
|
||||||
- 'configs/cargo/Cargo.lock'
|
|
||||||
- 'Cargo.lock'
|
|
||||||
- 'Cargo.toml'
|
|
||||||
# For `main` changes, this pipeline will look for changes in Rust crates or plugin versioning, and
|
|
||||||
# publish them only if changes are found and image does not exists in GH Packages.
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'packages/libraries/router/**'
|
|
||||||
- 'packages/libraries/sdk-rs/**'
|
|
||||||
- 'docker/router.dockerfile'
|
|
||||||
- 'scripts/compress/**'
|
|
||||||
- 'configs/cargo/Cargo.lock'
|
|
||||||
- 'Cargo.lock'
|
|
||||||
- 'Cargo.toml'
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# This script is doing the following:
|
|
||||||
# 1. Get the version of the apollo-router and the plugin from the Cargo.toml and package.json files
|
|
||||||
# 2. Check if there are changes in the Cargo.toml and package.json files in the current commit
|
|
||||||
# 3. If there are changes, check if the image tag exists in the GitHub Container Registry
|
|
||||||
find-changes:
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
|
||||||
outputs:
|
|
||||||
should_release: ${{ steps.find_changes.outputs.should_release }}
|
|
||||||
release_version: ${{ steps.find_changes.outputs.release_version }}
|
|
||||||
release_latest: ${{ steps.find_changes.outputs.release_latest }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
|
|
||||||
- name: find changes in versions
|
|
||||||
id: find_changes
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
if [ "$GITHUB_EVENT_NAME" == "pull_request" ]; then
|
|
||||||
echo "Running in a PR, using commit ID as tag"
|
|
||||||
echo "should_release=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "release_latest=false" >> $GITHUB_OUTPUT
|
|
||||||
echo "release_version=$GITHUB_SHA" >> $GITHUB_OUTPUT
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Running on push event, looking for changes in Rust crates or plugin versioning"
|
|
||||||
|
|
||||||
image_name="apollo-router"
|
|
||||||
github_org="graphql-hive"
|
|
||||||
router_version=$(cargo tree -i apollo-router --quiet | head -n 1 | awk -F" v" '{print $2}')
|
|
||||||
plugin_version=$(jq -r '.version' packages/libraries/router/package.json)
|
|
||||||
has_changes=$(git diff HEAD~ HEAD --name-only -- 'packages/libraries/router/Cargo.toml' 'packages/libraries/router/package.json' 'packages/libraries/sdk-rs/Cargo.toml' 'packages/libraries/sdk-rs/package.json' 'Cargo.lock' 'configs/cargo/Cargo.lock')
|
|
||||||
|
|
||||||
if [ "$has_changes" ]; then
|
|
||||||
image_tag_version="router${router_version}-plugin${plugin_version}"
|
|
||||||
|
|
||||||
response=$(curl -L \
|
|
||||||
-H "Accept: application/vnd.github+json" \
|
|
||||||
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
||||||
-s \
|
|
||||||
https://api.github.com/orgs/${github_org}/packages/container/${image_name}/versions)
|
|
||||||
tag_exists=$(echo "$response" | jq -r ".[] | .metadata.container.tags[] | select(. | contains(\"${image_tag_version}\"))")
|
|
||||||
|
|
||||||
if [ ! "$tag_exists" ]; then
|
|
||||||
echo "Found changes in version $version_to_publish"
|
|
||||||
echo "release_version=$image_tag_version" >> $GITHUB_OUTPUT
|
|
||||||
echo "should_release=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "release_latest=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "No changes found in version $image_tag_version"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Builds Rust crates, and creates Docker images
|
|
||||||
dockerize:
|
|
||||||
uses: ./.github/workflows/build-and-dockerize.yaml
|
|
||||||
name: image-build
|
|
||||||
needs:
|
|
||||||
- find-changes
|
|
||||||
if: ${{ needs.find-changes.outputs.should_release == 'true' }}
|
|
||||||
with:
|
|
||||||
imageTag: ${{ needs.find-changes.outputs.release_version }}
|
|
||||||
publishLatest: ${{ needs.find-changes.outputs.release_latest == 'true' }}
|
|
||||||
targets: apollo-router-hive-build
|
|
||||||
build: false
|
|
||||||
publishPrComment: true
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
# Test the Docker image, if it was published
|
|
||||||
test-image:
|
|
||||||
name: test apollo-router docker image
|
|
||||||
needs:
|
|
||||||
- dockerize
|
|
||||||
- find-changes
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
env:
|
|
||||||
HIVE_TOKEN: ${{ secrets.HIVE_TOKEN }}
|
|
||||||
steps:
|
|
||||||
- name: Run Docker image
|
|
||||||
run: |
|
|
||||||
# Create router.yaml
|
|
||||||
cat << EOF > router.yaml
|
|
||||||
supergraph:
|
|
||||||
listen: 0.0.0.0:4000
|
|
||||||
health_check:
|
|
||||||
listen: 0.0.0.0:8088
|
|
||||||
enabled: true
|
|
||||||
path: /health
|
|
||||||
plugins:
|
|
||||||
hive.usage:
|
|
||||||
enabled: false
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Download supergraph
|
|
||||||
curl -sSL https://supergraph.demo.starstuff.dev/ > ./supergraph.graphql
|
|
||||||
|
|
||||||
# Run Docker image
|
|
||||||
docker run -p 4000:4000 -p 8088:8088 --name apollo_router_test -d \
|
|
||||||
--env HIVE_TOKEN="fake" \
|
|
||||||
--mount "type=bind,source=/$(pwd)/router.yaml,target=/dist/config/router.yaml" \
|
|
||||||
--mount "type=bind,source=/$(pwd)/supergraph.graphql,target=/dist/config/supergraph.graphql" \
|
|
||||||
ghcr.io/graphql-hive/apollo-router:${{ needs.find-changes.outputs.release_version }} \
|
|
||||||
--log debug \
|
|
||||||
--supergraph /dist/config/supergraph.graphql \
|
|
||||||
--config /dist/config/router.yaml
|
|
||||||
|
|
||||||
# Wait for the container to be ready
|
|
||||||
echo "Waiting for the container to be ready..."
|
|
||||||
sleep 20
|
|
||||||
HTTP_RESPONSE=$(curl --retry 5 --retry-delay 5 --max-time 30 --write-out "%{http_code}" --silent --output /dev/null "http://127.0.0.1:8088/health")
|
|
||||||
|
|
||||||
# Check if the HTTP response code is 200 (OK)
|
|
||||||
if [ $HTTP_RESPONSE -eq 200 ]; then
|
|
||||||
echo "Health check successful."
|
|
||||||
docker stop apollo_router_test
|
|
||||||
docker rm apollo_router_test
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo "Health check failed with HTTP status code $HTTP_RESPONSE."
|
|
||||||
docker stop apollo_router_test
|
|
||||||
docker rm apollo_router_test
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build and publish Rust crates and binaries
|
|
||||||
binary:
|
|
||||||
uses: ./.github/workflows/publish-rust.yaml
|
|
||||||
secrets: inherit
|
|
||||||
needs:
|
|
||||||
- find-changes
|
|
||||||
if: ${{ needs.find-changes.outputs.should_release == 'true' }}
|
|
||||||
with:
|
|
||||||
publish: true
|
|
||||||
latest: ${{ needs.find-changes.outputs.release_latest == 'true' }}
|
|
||||||
version: ${{ needs.find-changes.outputs.release_version }}
|
|
||||||
57
.github/workflows/apollo-router-updater.yaml
vendored
57
.github/workflows/apollo-router-updater.yaml
vendored
|
|
@ -1,57 +0,0 @@
|
||||||
name: Apollo Router Updater
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# Every 2 hours
|
|
||||||
- cron: '0 */2 * * *'
|
|
||||||
# Allows you to run this workflow manually from the Actions tab
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update:
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: checkout
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: setup environment
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
with:
|
|
||||||
codegen: false
|
|
||||||
actor: apollo-router-updater
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
|
||||||
with:
|
|
||||||
toolchain: '1.91.1'
|
|
||||||
default: true
|
|
||||||
override: true
|
|
||||||
|
|
||||||
- name: Check for updates
|
|
||||||
id: check
|
|
||||||
run: |
|
|
||||||
pnpm tsx ./scripts/apollo-router-action.ts
|
|
||||||
|
|
||||||
- name: Run updates
|
|
||||||
if: steps.check.outputs.update == 'true'
|
|
||||||
run: cargo update -p apollo-router --precise ${{ steps.check.outputs.version }}
|
|
||||||
|
|
||||||
- name: Create Pull Request
|
|
||||||
if: steps.check.outputs.update == 'true'
|
|
||||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
|
||||||
commit-message: Update apollo-router to version ${{ steps.check.outputs.version }}
|
|
||||||
branch: apollo-router-update-${{ steps.check.outputs.version }}
|
|
||||||
delete-branch: true
|
|
||||||
title: ${{ steps.check.outputs.title }}
|
|
||||||
body: |
|
|
||||||
Automatic update of apollo-router to version ${{ steps.check.outputs.version }}.
|
|
||||||
assignees: kamilkisiela,dotansimha
|
|
||||||
reviewers: kamilkisiela,dotansimha
|
|
||||||
7
.github/workflows/build-and-dockerize.yaml
vendored
7
.github/workflows/build-and-dockerize.yaml
vendored
|
|
@ -45,6 +45,9 @@ jobs:
|
||||||
suffix: '-arm64'
|
suffix: '-arm64'
|
||||||
runs-on: ${{ matrix.builder }}
|
runs-on: ${{ matrix.builder }}
|
||||||
name: dockerize (${{ matrix.platform }})
|
name: dockerize (${{ matrix.platform }})
|
||||||
|
env:
|
||||||
|
SENTRY_ORG: the-guild-z4
|
||||||
|
SENTRY_PROJECT: graphql-hive
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|
@ -66,6 +69,8 @@ jobs:
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max-old-space-size=14336' # GitHub Actions gives us 16GB, it's ok to use 80%
|
NODE_OPTIONS: '--max-old-space-size=14336' # GitHub Actions gives us 16GB, it's ok to use 80%
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
RELEASE: ${{ inputs.imageTag }}
|
||||||
|
|
||||||
- name: test ESM & CJS exports integrity
|
- name: test ESM & CJS exports integrity
|
||||||
if: ${{ inputs.build }}
|
if: ${{ inputs.build }}
|
||||||
|
|
@ -191,8 +196,6 @@ jobs:
|
||||||
if: ${{ inputs.publishSourceMaps }}
|
if: ${{ inputs.publishSourceMaps }}
|
||||||
env:
|
env:
|
||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
SENTRY_ORG: the-guild-z4
|
|
||||||
SENTRY_PROJECT: graphql-hive
|
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
SENTRY_RELEASE: ${{ inputs.imageTag }}
|
SENTRY_RELEASE: ${{ inputs.imageTag }}
|
||||||
run: pnpm upload-sourcemaps
|
run: pnpm upload-sourcemaps
|
||||||
|
|
|
||||||
2
.github/workflows/pr.yaml
vendored
2
.github/workflows/pr.yaml
vendored
|
|
@ -93,7 +93,7 @@ jobs:
|
||||||
restoreDeletedChangesets: true
|
restoreDeletedChangesets: true
|
||||||
buildScript: build:libraries
|
buildScript: build:libraries
|
||||||
packageManager: pnpm
|
packageManager: pnpm
|
||||||
nodeVersion: '24.13'
|
nodeVersion: '24.14'
|
||||||
secrets:
|
secrets:
|
||||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||||
npmToken: ${{ secrets.NPM_TOKEN }}
|
npmToken: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
|
||||||
169
.github/workflows/publish-rust.yaml
vendored
169
.github/workflows/publish-rust.yaml
vendored
|
|
@ -1,169 +0,0 @@
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
publish:
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
required: true
|
|
||||||
latest:
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
required: true
|
|
||||||
version:
|
|
||||||
default: ${{ github.sha }}
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
detect-changes:
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
outputs:
|
|
||||||
rust_changed: ${{ steps.rust_changed.outputs.rust_changed }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
- name: Look for changes
|
|
||||||
id: rust_changed
|
|
||||||
run: |
|
|
||||||
lines=$( git diff HEAD~ HEAD --name-only -- 'packages/libraries/router' 'packages/libraries/sdk-rs' 'Cargo.toml' 'configs/cargo/Cargo.lock' | wc -l )
|
|
||||||
if [ $lines -gt 0 ]; then
|
|
||||||
echo 'rust_changed=true' >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
test-rust:
|
|
||||||
needs: detect-changes
|
|
||||||
if: needs.detect-changes.outputs.rust_changed == 'true'
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
|
|
||||||
- name: setup environment
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
with:
|
|
||||||
actor: test-rust
|
|
||||||
codegen: false
|
|
||||||
|
|
||||||
- name: Install Protoc
|
|
||||||
uses: arduino/setup-protoc@v3
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
|
||||||
with:
|
|
||||||
toolchain: '1.91.1'
|
|
||||||
default: true
|
|
||||||
override: true
|
|
||||||
|
|
||||||
- name: Cache Rust
|
|
||||||
uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: cargo test
|
|
||||||
|
|
||||||
publish-rust:
|
|
||||||
needs: [detect-changes, test-rust]
|
|
||||||
if: needs.detect-changes.outputs.rust_changed == 'true'
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
timeout-minutes: 60
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
|
|
||||||
- name: setup environment
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
with:
|
|
||||||
actor: publish-rust
|
|
||||||
codegen: false
|
|
||||||
|
|
||||||
- name: Prepare MacOS
|
|
||||||
if: ${{ matrix.os == 'macos-latest' }}
|
|
||||||
run: |
|
|
||||||
echo "RUST_TARGET=x86_64-apple-darwin" >> $GITHUB_ENV
|
|
||||||
echo "RUST_TARGET_FILE=router" >> $GITHUB_ENV
|
|
||||||
echo "RUST_TARGET_OS=macos" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Prepare Linux
|
|
||||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
|
||||||
run: |
|
|
||||||
echo "RUST_TARGET=x86_64-unknown-linux-gnu" >> $GITHUB_ENV
|
|
||||||
echo "RUST_TARGET_FILE=router" >> $GITHUB_ENV
|
|
||||||
echo "RUST_TARGET_OS=linux" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Prepare Windows
|
|
||||||
if: ${{ matrix.os == 'windows-latest' }}
|
|
||||||
run: |
|
|
||||||
echo "RUST_TARGET=x86_64-pc-windows-msvc" | Out-File -FilePath $env:GITHUB_ENV -Append
|
|
||||||
echo "RUST_TARGET_FILE=router.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
|
||||||
echo "RUST_TARGET_OS=win" | Out-File -FilePath $env:GITHUB_ENV -Append
|
|
||||||
npm run cargo:fix
|
|
||||||
|
|
||||||
- name: Install Protoc
|
|
||||||
uses: arduino/setup-protoc@v3
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
|
||||||
with:
|
|
||||||
toolchain: '1.91.1'
|
|
||||||
target: ${{ env.RUST_TARGET }}
|
|
||||||
default: true
|
|
||||||
override: true
|
|
||||||
|
|
||||||
- name: Cache Rust
|
|
||||||
uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
|
||||||
with:
|
|
||||||
command: build
|
|
||||||
args: --release
|
|
||||||
|
|
||||||
- name: Strip binary from debug symbols
|
|
||||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
|
||||||
run: strip target/release/${{ env.RUST_TARGET_FILE }}
|
|
||||||
|
|
||||||
- name: Compress
|
|
||||||
run: |
|
|
||||||
./target/release/compress ./target/release/${{ env.RUST_TARGET_FILE }} ./router.tar.gz
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4
|
|
||||||
with:
|
|
||||||
name: router-${{ env.RUST_TARGET_OS }}
|
|
||||||
path: router.tar.gz
|
|
||||||
|
|
||||||
- name: Upload to R2 (${{ inputs.version }})
|
|
||||||
if: ${{ inputs.publish }}
|
|
||||||
uses: randomairborne/r2-release@9cbc35a2039ee2ef453a6988cd2a85bb2d7ba8af # v1.0.2
|
|
||||||
with:
|
|
||||||
endpoint: https://6d5bc18cd8d13babe7ed321adba3d8ae.r2.cloudflarestorage.com
|
|
||||||
accesskeyid: ${{ secrets.R2_ACCESS_KEY_ID }}
|
|
||||||
secretaccesskey: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
|
||||||
bucket: apollo-router
|
|
||||||
file: router.tar.gz
|
|
||||||
destination: ${{ inputs.version }}/${{ env.RUST_TARGET_OS }}/router.tar.gz
|
|
||||||
|
|
||||||
- name: Upload to R2 (latest)
|
|
||||||
if: ${{ inputs.publish && inputs.latest }}
|
|
||||||
uses: randomairborne/r2-release@9cbc35a2039ee2ef453a6988cd2a85bb2d7ba8af # v1.0.2
|
|
||||||
with:
|
|
||||||
endpoint: https://6d5bc18cd8d13babe7ed321adba3d8ae.r2.cloudflarestorage.com
|
|
||||||
accesskeyid: ${{ secrets.R2_ACCESS_KEY_ID }}
|
|
||||||
secretaccesskey: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
|
||||||
bucket: apollo-router
|
|
||||||
file: router.tar.gz
|
|
||||||
destination: latest/${{ env.RUST_TARGET_OS }}/router.tar.gz
|
|
||||||
2
.github/workflows/release-alpha.yaml
vendored
2
.github/workflows/release-alpha.yaml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
||||||
npmTag: alpha
|
npmTag: alpha
|
||||||
buildScript: build:libraries
|
buildScript: build:libraries
|
||||||
packageManager: pnpm
|
packageManager: pnpm
|
||||||
nodeVersion: '24.13'
|
nodeVersion: '24.14'
|
||||||
secrets:
|
secrets:
|
||||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||||
npmToken: ${{ secrets.NPM_TOKEN }}
|
npmToken: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
|
||||||
8
.github/workflows/release-stable.yaml
vendored
8
.github/workflows/release-stable.yaml
vendored
|
|
@ -118,11 +118,3 @@ jobs:
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.cli.outputs.version }}
|
VERSION: ${{ steps.cli.outputs.version }}
|
||||||
run: pnpm oclif promote --no-xz --sha ${GITHUB_SHA:0:7} --version $VERSION
|
run: pnpm oclif promote --no-xz --sha ${GITHUB_SHA:0:7} --version $VERSION
|
||||||
|
|
||||||
- name: release to Crates.io
|
|
||||||
if:
|
|
||||||
steps.changesets.outputs.published && contains(steps.changesets.outputs.publishedPackages,
|
|
||||||
'"hive-apollo-router-plugin"')
|
|
||||||
run: |
|
|
||||||
cargo login ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
|
||||||
cargo publish --allow-dirty --no-verify
|
|
||||||
|
|
|
||||||
65
.github/workflows/tests-integration.yaml
vendored
65
.github/workflows/tests-integration.yaml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
# Divide integration tests into 3 shards, to run them in parallel.
|
# Divide integration tests into 3 shards, to run them in parallel.
|
||||||
shardIndex: [1, 2, 3, 'apollo-router']
|
shardIndex: [1, 2, 3]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOCKER_REGISTRY: ${{ inputs.registry }}/${{ inputs.imageName }}/
|
DOCKER_REGISTRY: ${{ inputs.registry }}/${{ inputs.imageName }}/
|
||||||
|
|
@ -78,68 +78,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
docker compose -f docker/docker-compose.community.yml -f ./integration-tests/docker-compose.integration.yaml --env-file ./integration-tests/.env ps
|
docker compose -f docker/docker-compose.community.yml -f ./integration-tests/docker-compose.integration.yaml --env-file ./integration-tests/.env ps
|
||||||
|
|
||||||
## ---- START ---- Apollo Router specific steps
|
- name: run integration tests
|
||||||
|
|
||||||
- if: matrix.shardIndex == 'apollo-router'
|
|
||||||
name: Install Protoc
|
|
||||||
uses: arduino/setup-protoc@v3
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- if: matrix.shardIndex == 'apollo-router'
|
|
||||||
name: Install Rust
|
|
||||||
uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
|
||||||
with:
|
|
||||||
toolchain: '1.91.1'
|
|
||||||
default: true
|
|
||||||
override: true
|
|
||||||
|
|
||||||
- if: matrix.shardIndex == 'apollo-router'
|
|
||||||
name: Cache Rust
|
|
||||||
uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
|
|
||||||
|
|
||||||
# ---- START ---- Disk space cleanup before apollo router tests
|
|
||||||
- if: matrix.shardIndex == 'apollo-router'
|
|
||||||
name: Before cleanup disk space
|
|
||||||
run: df -h
|
|
||||||
- if: matrix.shardIndex == 'apollo-router'
|
|
||||||
name: Cleanup disk space
|
|
||||||
run: |
|
|
||||||
sudo rm -rf \
|
|
||||||
/usr/lib/jvm \
|
|
||||||
/usr/share/swift \
|
|
||||||
/usr/local/julia* \
|
|
||||||
/usr/local/share/chromium \
|
|
||||||
/opt/az \
|
|
||||||
/usr/local/share/powershell \
|
|
||||||
/opt/microsoft /opt/google \
|
|
||||||
/usr/local/lib/android \
|
|
||||||
/usr/local/.ghcup \
|
|
||||||
/usr/share/dotnet \
|
|
||||||
/usr/local/lib/android \
|
|
||||||
/opt/ghc /opt/hostedtoolcache/CodeQL
|
|
||||||
sudo docker image prune --all --force
|
|
||||||
sudo docker builder prune -a
|
|
||||||
- if: matrix.shardIndex == 'apollo-router'
|
|
||||||
name: After cleanup disk space
|
|
||||||
run: df -h
|
|
||||||
# ---- END ---- Disk space cleanup before apollo router tests
|
|
||||||
|
|
||||||
- if: matrix.shardIndex == 'apollo-router'
|
|
||||||
name: build apollo router
|
|
||||||
run: |
|
|
||||||
cargo build
|
|
||||||
|
|
||||||
- if: matrix.shardIndex == 'apollo-router'
|
|
||||||
name: run apollo router integration tests
|
|
||||||
timeout-minutes: 30
|
|
||||||
run: |
|
|
||||||
pnpm test:integration:apollo-router
|
|
||||||
|
|
||||||
## ---- END ---- Apollo Router specific steps
|
|
||||||
|
|
||||||
- if: matrix.shardIndex != 'apollo-router'
|
|
||||||
name: run integration tests
|
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
run: |
|
run: |
|
||||||
pnpm test:integration --shard=${{ matrix.shardIndex }}/3
|
pnpm test:integration --shard=${{ matrix.shardIndex }}/3
|
||||||
|
|
|
||||||
22
.gitignore
vendored
22
.gitignore
vendored
|
|
@ -110,9 +110,6 @@ integration-tests/testkit/gql/
|
||||||
|
|
||||||
npm-shrinkwrap.json
|
npm-shrinkwrap.json
|
||||||
|
|
||||||
# Rust
|
|
||||||
/target
|
|
||||||
|
|
||||||
# bob
|
# bob
|
||||||
.bob/
|
.bob/
|
||||||
|
|
||||||
|
|
@ -127,6 +124,7 @@ packages/web/app/next.config.mjs
|
||||||
packages/web/app/environment-*.mjs
|
packages/web/app/environment-*.mjs
|
||||||
packages/web/app/src/gql/*.ts
|
packages/web/app/src/gql/*.ts
|
||||||
packages/web/app/src/gql/*.json
|
packages/web/app/src/gql/*.json
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
packages/web/app/src/components/ui/changelog/generated-changelog.ts
|
packages/web/app/src/components/ui/changelog/generated-changelog.ts
|
||||||
|
|
||||||
|
|
@ -142,21 +140,7 @@ resolvers.generated.ts
|
||||||
docker/docker-compose.override.yml
|
docker/docker-compose.override.yml
|
||||||
|
|
||||||
test-results/
|
test-results/
|
||||||
Cargo.lock
|
|
||||||
Cargo.lock
|
target
|
||||||
Cargo.lock
|
|
||||||
Cargo.lock
|
|
||||||
Cargo.lock
|
|
||||||
Cargo.lock
|
|
||||||
Cargo.lock
|
|
||||||
Cargo.lock
|
|
||||||
Cargo.lock
|
|
||||||
Cargo.lock
|
|
||||||
Cargo.lock
|
|
||||||
Cargo.lock
|
|
||||||
Cargo.lock
|
|
||||||
Cargo.lock
|
|
||||||
Cargo.lock
|
|
||||||
Cargo.lock
|
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
24.13
|
24.14.1
|
||||||
|
|
|
||||||
7675
Cargo.lock
generated
7675
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +0,0 @@
|
||||||
[workspace]
|
|
||||||
resolver = "2"
|
|
||||||
members = ["packages/libraries/router", "scripts/compress"]
|
|
||||||
7313
configs/cargo/Cargo.lock
generated
7313
configs/cargo/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,98 @@
|
||||||
# hive
|
# hive
|
||||||
|
|
||||||
|
## 11.0.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#7993](https://github.com/graphql-hive/console/pull/7993)
|
||||||
|
[`730771f`](https://github.com/graphql-hive/console/commit/730771fb503fd91974c1494944cd5426cf74a552)
|
||||||
|
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
|
||||||
|
[GHSA-72c6-fx6q-fr5w](https://github.com/advisories/GHSA-72c6-fx6q-fr5w).
|
||||||
|
|
||||||
|
- [#7988](https://github.com/graphql-hive/console/pull/7988)
|
||||||
|
[`d7e7025`](https://github.com/graphql-hive/console/commit/d7e7025624ba66459515778c0724a58397a5f1b4)
|
||||||
|
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
|
||||||
|
[GHSA-247c-9743-5963](https://github.com/advisories/GHSA-247c-9743-5963).
|
||||||
|
|
||||||
|
- [#7961](https://github.com/graphql-hive/console/pull/7961)
|
||||||
|
[`40fd27d`](https://github.com/graphql-hive/console/commit/40fd27d9c060df5417c18c750b02af65451e5323)
|
||||||
|
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Update
|
||||||
|
[`nodemailer`](https://github.com/nodemailer/nodemailer) to address vulnerability
|
||||||
|
[GHSA-vvjj-xcjg-gr5g](https://github.com/advisories/GHSA-vvjj-xcjg-gr5g).
|
||||||
|
|
||||||
|
- [#7993](https://github.com/graphql-hive/console/pull/7993)
|
||||||
|
[`730771f`](https://github.com/graphql-hive/console/commit/730771fb503fd91974c1494944cd5426cf74a552)
|
||||||
|
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
|
||||||
|
[GHSA-v9ww-2j6r-98q6](https://github.com/advisories/GHSA-v9ww-2j6r-98q6).
|
||||||
|
|
||||||
|
- [#7976](https://github.com/graphql-hive/console/pull/7976)
|
||||||
|
[`ed9ab34`](https://github.com/graphql-hive/console/commit/ed9ab34c705be4b7946dfcbede91926f00f1ed4a)
|
||||||
|
Thanks [@n1ru4l](https://github.com/n1ru4l)! - address vulnerability
|
||||||
|
[GHSA-r4q5-vmmm-2653](https://github.com/advisories/GHSA-r4q5-vmmm-2653)
|
||||||
|
|
||||||
|
- [#7967](https://github.com/graphql-hive/console/pull/7967)
|
||||||
|
[`9708f71`](https://github.com/graphql-hive/console/commit/9708f71aa3e6dcd613f3877a0777c1e72710b200)
|
||||||
|
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Fix schema contract composition applying
|
||||||
|
`@inaccessible` on the federation types `ContextArgument` and `FieldValue` on the supergraph SDL.
|
||||||
|
|
||||||
|
This mitigates the following error in apollo-router upon processing the supergraph:
|
||||||
|
|
||||||
|
```
|
||||||
|
could not create router: Api error(s): The supergraph schema failed to produce a valid API schema: The following errors occurred:
|
||||||
|
- Core feature type `join__ContextArgument` cannot use @inaccessible.
|
||||||
|
- Core feature type `join__FieldValue` cannot use @inaccessible.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [#7993](https://github.com/graphql-hive/console/pull/7993)
|
||||||
|
[`730771f`](https://github.com/graphql-hive/console/commit/730771fb503fd91974c1494944cd5426cf74a552)
|
||||||
|
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
|
||||||
|
[GHSA-xq3m-2v4x-88gg](https://github.com/advisories/GHSA-xq3m-2v4x-88gg).
|
||||||
|
|
||||||
|
- [#7978](https://github.com/graphql-hive/console/pull/7978)
|
||||||
|
[`9c6989c`](https://github.com/graphql-hive/console/commit/9c6989cd929df3b071cba2c5652cc18988127897)
|
||||||
|
Thanks [@jdolle](https://github.com/jdolle)! - Add schema linting support for type extensions
|
||||||
|
|
||||||
|
- [#7993](https://github.com/graphql-hive/console/pull/7993)
|
||||||
|
[`730771f`](https://github.com/graphql-hive/console/commit/730771fb503fd91974c1494944cd5426cf74a552)
|
||||||
|
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
|
||||||
|
[CVE-2026-6414](https://github.com/advisories/GHSA-x428-ghpx-8j92).
|
||||||
|
|
||||||
|
- [#7988](https://github.com/graphql-hive/console/pull/7988)
|
||||||
|
[`d7e7025`](https://github.com/graphql-hive/console/commit/d7e7025624ba66459515778c0724a58397a5f1b4)
|
||||||
|
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
|
||||||
|
[GHSA-39q2-94rc-95cp](https://github.com/advisories/GHSA-39q2-94rc-95cp).
|
||||||
|
|
||||||
|
- [#7980](https://github.com/graphql-hive/console/pull/7980)
|
||||||
|
[`c46b2f2`](https://github.com/graphql-hive/console/commit/c46b2f221936ca60e49bce3a2fea25bb40378266)
|
||||||
|
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
|
||||||
|
[GHSA-fvcv-3m26-pcqx](https://github.com/advisories/GHSA-fvcv-3m26-pcqx).
|
||||||
|
|
||||||
|
- [#7993](https://github.com/graphql-hive/console/pull/7993)
|
||||||
|
[`730771f`](https://github.com/graphql-hive/console/commit/730771fb503fd91974c1494944cd5426cf74a552)
|
||||||
|
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
|
||||||
|
[CVE-2026-6410](https://github.com/advisories/GHSA-pr96-94w5-mx2h).
|
||||||
|
|
||||||
|
- [#7961](https://github.com/graphql-hive/console/pull/7961)
|
||||||
|
[`40fd27d`](https://github.com/graphql-hive/console/commit/40fd27d9c060df5417c18c750b02af65451e5323)
|
||||||
|
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Update
|
||||||
|
[`opentelemetry-go`](https://github.com/open-telemetry/opentelemetry-go) to address vulnerability
|
||||||
|
[CVE-2026-39883](https://github.com/advisories/GHSA-hfvc-g4fc-pqhx).
|
||||||
|
|
||||||
|
## 11.0.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#7940](https://github.com/graphql-hive/console/pull/7940)
|
||||||
|
[`742f50c`](https://github.com/graphql-hive/console/commit/742f50c52e846ab63843635c6f408016a30f6288)
|
||||||
|
Thanks [@jdolle](https://github.com/jdolle)! - Fix lint policy block title color; add policy link
|
||||||
|
to lines and rule id tooltip
|
||||||
|
|
||||||
|
- [#7936](https://github.com/graphql-hive/console/pull/7936)
|
||||||
|
[`96bd390`](https://github.com/graphql-hive/console/commit/96bd390c7ffa75baf7db0c0bb3a3117c6ef6f631)
|
||||||
|
Thanks [@jdolle](https://github.com/jdolle)! - Do not cache edge types in graphql eslint. This
|
||||||
|
fixes an issue where edge types were cached between runs and only the cached edge types would be
|
||||||
|
referenced for subsequent runs
|
||||||
|
|
||||||
## 11.0.1
|
## 11.0.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ async function generateVectorDevTypes() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateContourTypes() {
|
async function generateContourTypes() {
|
||||||
const helmValuesFileUrl = `https://raw.githubusercontent.com/bitnami/charts/contour/${CONTOUR_CHART.version}/bitnami/contour/values.yaml`;
|
const helmValuesFileUrl = `https://raw.githubusercontent.com/projectcontour/helm-charts/refs/tags/contour-${CONTOUR_CHART.version}/charts/contour/values.yaml`;
|
||||||
const valuesFile = await fetch(helmValuesFileUrl).then(r => r.text());
|
const valuesFile = await fetch(helmValuesFileUrl).then(r => r.text());
|
||||||
|
|
||||||
const valuesTempFile = fileSync();
|
const valuesTempFile = fileSync();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "hive",
|
"name": "hive",
|
||||||
"version": "11.0.1",
|
"version": "11.0.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "tsx generate.ts",
|
"generate": "tsx generate.ts",
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
"@graphql-hive/gateway": "^2.1.19",
|
"@graphql-hive/gateway": "^2.1.19",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/mime-types": "2.1.4",
|
"@types/mime-types": "2.1.4",
|
||||||
"@types/node": "24.10.9",
|
"@types/node": "24.12.2",
|
||||||
"@types/tmp": "0.2.6",
|
"@types/tmp": "0.2.6",
|
||||||
"json-schema-to-typescript": "15.0.3",
|
"json-schema-to-typescript": "15.0.3",
|
||||||
"tmp": "0.2.5",
|
"tmp": "0.2.5",
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,8 @@ export function prepareEnvironment(input: {
|
||||||
},
|
},
|
||||||
envoy: {
|
envoy: {
|
||||||
replicas: isProduction || isStaging ? 3 : 1,
|
replicas: isProduction || isStaging ? 3 : 1,
|
||||||
cpuLimit: isProduction ? '1500m' : '120m',
|
cpuLimit: isProduction ? '1500m' : isStaging ? '300m' : '120m',
|
||||||
memoryLimit: isProduction ? '2Gi' : '200Mi',
|
memoryLimit: isProduction || isStaging ? '2Gi' : '200Mi',
|
||||||
timeouts: {
|
timeouts: {
|
||||||
idleTimeout: 905,
|
idleTimeout: 905,
|
||||||
},
|
},
|
||||||
|
|
@ -60,29 +60,34 @@ export function prepareEnvironment(input: {
|
||||||
memoryLimit: isProduction || isStaging ? '3584Mi' : '1Gi',
|
memoryLimit: isProduction || isStaging ? '3584Mi' : '1Gi',
|
||||||
},
|
},
|
||||||
usageService: {
|
usageService: {
|
||||||
replicas: isProduction || isStaging ? 3 : 1,
|
replicas: isProduction || isStaging ? 6 : 1,
|
||||||
cpuLimit: isProduction ? '1000m' : '100m',
|
cpuMin: isProduction || isStaging ? '200m' : '100m',
|
||||||
|
cpuMax: isProduction || isStaging ? '1000m' : '100m',
|
||||||
maxReplicas: isProduction || isStaging ? 6 : 1,
|
maxReplicas: isProduction || isStaging ? 6 : 1,
|
||||||
cpuAverageToScale: 60,
|
cpuAverageToScale: 60,
|
||||||
},
|
},
|
||||||
usageIngestorService: {
|
usageIngestorService: {
|
||||||
replicas: isProduction || isStaging ? 6 : 1,
|
replicas: isProduction || isStaging ? 6 : 1,
|
||||||
cpuLimit: isProduction ? '1000m' : '100m',
|
cpuMax: isProduction || isStaging ? '1000m' : '100m',
|
||||||
|
cpuMin: isProduction || isStaging ? '300m' : '100m',
|
||||||
maxReplicas: isProduction || isStaging ? /* numberOfPartitions */ 16 : 2,
|
maxReplicas: isProduction || isStaging ? /* numberOfPartitions */ 16 : 2,
|
||||||
cpuAverageToScale: 60,
|
cpuAverageToScale: 60,
|
||||||
},
|
},
|
||||||
redis: {
|
redis: {
|
||||||
memoryLimit: isProduction ? '4Gi' : '100Mi',
|
memoryLimit: isProduction || isStaging ? '4Gi' : '100Mi',
|
||||||
cpuLimit: isProduction ? '1000m' : '50m',
|
cpuMax: isProduction || isStaging ? '1000m' : '100m',
|
||||||
|
cpuMin: isProduction || isStaging ? '100m' : '50m',
|
||||||
},
|
},
|
||||||
internalObservability: {
|
internalObservability: {
|
||||||
cpuLimit: isProduction ? '512m' : '150m',
|
cpuLimit: isProduction ? '512m' : '150m',
|
||||||
memoryLimit: isProduction ? '1000Mi' : '300Mi',
|
memoryLimit: isProduction ? '1000Mi' : '300Mi',
|
||||||
},
|
},
|
||||||
tracingCollector: {
|
tracingCollector: {
|
||||||
cpuLimit: isProduction || isStaging ? '1000m' : '100m',
|
cpuMax: isProduction || isStaging ? '1000m' : '300m',
|
||||||
|
cpuMin: '100m',
|
||||||
memoryLimit: isProduction || isStaging ? '1000Mi' : '512Mi',
|
memoryLimit: isProduction || isStaging ? '1000Mi' : '512Mi',
|
||||||
maxReplicas: isProduction || isStaging ? 3 : 1,
|
maxReplicas: isProduction || isStaging ? 3 : 1,
|
||||||
|
replicas: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -36,17 +36,20 @@ export function deployOTELCollector(args: {
|
||||||
livenessProbe: '/',
|
livenessProbe: '/',
|
||||||
startupProbe: '/',
|
startupProbe: '/',
|
||||||
exposesMetrics: true,
|
exposesMetrics: true,
|
||||||
replicas: args.environment.podsConfig.tracingCollector.maxReplicas,
|
replicas: args.environment.podsConfig.tracingCollector.replicas,
|
||||||
pdb: true,
|
pdb: true,
|
||||||
availabilityOnEveryNode: true,
|
availabilityOnEveryNode: true,
|
||||||
port: 4318,
|
port: 4318,
|
||||||
memoryLimit: args.environment.podsConfig.tracingCollector.memoryLimit,
|
memory: {
|
||||||
|
limit: args.environment.podsConfig.tracingCollector.memoryLimit,
|
||||||
|
},
|
||||||
|
cpu: {
|
||||||
|
limit: args.environment.podsConfig.tracingCollector.cpuMax,
|
||||||
|
requests: args.environment.podsConfig.tracingCollector.cpuMin,
|
||||||
|
},
|
||||||
autoScaling: {
|
autoScaling: {
|
||||||
maxReplicas: args.environment.podsConfig.tracingCollector.maxReplicas,
|
maxReplicas: args.environment.podsConfig.tracingCollector.maxReplicas,
|
||||||
cpu: {
|
cpuAverageToScale: 80,
|
||||||
limit: args.environment.podsConfig.tracingCollector.cpuLimit,
|
|
||||||
cpuAverageToScale: 80,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[args.clickhouse.deployment, args.clickhouse.service, args.dbMigrations],
|
[args.clickhouse.deployment, args.clickhouse.service, args.dbMigrations],
|
||||||
|
|
|
||||||
|
|
@ -96,16 +96,13 @@ export function deployProxy({
|
||||||
service: graphql.service,
|
service: graphql.service,
|
||||||
requestTimeout: '60s',
|
requestTimeout: '60s',
|
||||||
retriable: true,
|
retriable: true,
|
||||||
rateLimit: {
|
|
||||||
maxRequests: 10,
|
|
||||||
unit: 'minute',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'usage',
|
name: 'usage',
|
||||||
path: '/usage',
|
path: '/usage',
|
||||||
service: usage.service,
|
service: usage.service,
|
||||||
retriable: true,
|
retriable: true,
|
||||||
|
loadBalancerPolicy: 'WeightedLeastRequest',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.registerService({ record: environment.apiDns }, [
|
.registerService({ record: environment.apiDns }, [
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,10 @@ export function deployRedis(input: { environment: Environment }) {
|
||||||
}).deploy({
|
}).deploy({
|
||||||
limits: {
|
limits: {
|
||||||
memory: input.environment.podsConfig.redis.memoryLimit,
|
memory: input.environment.podsConfig.redis.memoryLimit,
|
||||||
cpu: input.environment.podsConfig.redis.cpuLimit,
|
cpu: input.environment.podsConfig.redis.cpuMax,
|
||||||
|
},
|
||||||
|
requests: {
|
||||||
|
cpu: input.environment.podsConfig.redis.cpuMin,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,9 @@ export function deploySchema({
|
||||||
startupProbe: '/_health',
|
startupProbe: '/_health',
|
||||||
exposesMetrics: true,
|
exposesMetrics: true,
|
||||||
replicas: environment.podsConfig.general.replicas,
|
replicas: environment.podsConfig.general.replicas,
|
||||||
memoryLimit: environment.podsConfig.schemaService.memoryLimit,
|
memory: {
|
||||||
|
limit: environment.podsConfig.schemaService.memoryLimit,
|
||||||
|
},
|
||||||
pdb: true,
|
pdb: true,
|
||||||
},
|
},
|
||||||
[redis.deployment, redis.service],
|
[redis.deployment, redis.service],
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,12 @@ export function deployUsageIngestor({
|
||||||
exposesMetrics: true,
|
exposesMetrics: true,
|
||||||
port: 4000,
|
port: 4000,
|
||||||
pdb: true,
|
pdb: true,
|
||||||
|
cpu: {
|
||||||
|
limit: environment.podsConfig.usageIngestorService.cpuMax,
|
||||||
|
requests: environment.podsConfig.usageIngestorService.cpuMin,
|
||||||
|
},
|
||||||
autoScaling: {
|
autoScaling: {
|
||||||
cpu: {
|
cpuAverageToScale: environment.podsConfig.usageIngestorService.cpuAverageToScale,
|
||||||
cpuAverageToScale: environment.podsConfig.usageIngestorService.cpuAverageToScale,
|
|
||||||
limit: environment.podsConfig.usageIngestorService.cpuLimit,
|
|
||||||
},
|
|
||||||
maxReplicas: environment.podsConfig.usageIngestorService.maxReplicas,
|
maxReplicas: environment.podsConfig.usageIngestorService.maxReplicas,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -81,11 +81,12 @@ export function deployUsage({
|
||||||
exposesMetrics: true,
|
exposesMetrics: true,
|
||||||
port: 4000,
|
port: 4000,
|
||||||
pdb: true,
|
pdb: true,
|
||||||
|
cpu: {
|
||||||
|
limit: environment.podsConfig.usageService.cpuMax,
|
||||||
|
requests: environment.podsConfig.usageService.cpuMin,
|
||||||
|
},
|
||||||
autoScaling: {
|
autoScaling: {
|
||||||
cpu: {
|
cpuAverageToScale: environment.podsConfig.usageService.cpuAverageToScale,
|
||||||
cpuAverageToScale: environment.podsConfig.usageService.cpuAverageToScale,
|
|
||||||
limit: environment.podsConfig.usageService.cpuLimit,
|
|
||||||
},
|
|
||||||
maxReplicas: environment.podsConfig.usageService.maxReplicas,
|
maxReplicas: environment.podsConfig.usageService.maxReplicas,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -263,168 +263,6 @@ export interface ContourValues {
|
||||||
};
|
};
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
};
|
};
|
||||||
defaultBackend?: {
|
|
||||||
affinity?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
args?: unknown[];
|
|
||||||
command?: unknown[];
|
|
||||||
containerPorts?: {
|
|
||||||
http?: number;
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
containerSecurityContext?: {
|
|
||||||
allowPrivilegeEscalation?: boolean;
|
|
||||||
capabilities?: {
|
|
||||||
drop?: string[];
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
enabled?: boolean;
|
|
||||||
privileged?: boolean;
|
|
||||||
readOnlyRootFilesystem?: boolean;
|
|
||||||
runAsGroup?: number;
|
|
||||||
runAsNonRoot?: boolean;
|
|
||||||
runAsUser?: number;
|
|
||||||
seLinuxOptions?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
seccompProfile?: {
|
|
||||||
type?: string;
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
customLivenessProbe?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
customReadinessProbe?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
customStartupProbe?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
enabled?: boolean;
|
|
||||||
extraArgs?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
extraEnvVars?: unknown[];
|
|
||||||
extraEnvVarsCM?: string;
|
|
||||||
extraEnvVarsSecret?: string;
|
|
||||||
extraVolumeMounts?: unknown[];
|
|
||||||
extraVolumes?: unknown[];
|
|
||||||
hostAliases?: unknown[];
|
|
||||||
image?: {
|
|
||||||
digest?: string;
|
|
||||||
pullPolicy?: string;
|
|
||||||
pullSecrets?: unknown[];
|
|
||||||
registry?: string;
|
|
||||||
repository?: string;
|
|
||||||
tag?: string;
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
initContainers?: unknown[];
|
|
||||||
lifecycleHooks?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
livenessProbe?: {
|
|
||||||
enabled?: boolean;
|
|
||||||
failureThreshold?: number;
|
|
||||||
initialDelaySeconds?: number;
|
|
||||||
periodSeconds?: number;
|
|
||||||
successThreshold?: number;
|
|
||||||
timeoutSeconds?: number;
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
networkPolicy?: {
|
|
||||||
allowExternal?: boolean;
|
|
||||||
allowExternalEgress?: boolean;
|
|
||||||
enabled?: boolean;
|
|
||||||
extraEgress?: unknown[];
|
|
||||||
extraIngress?: unknown[];
|
|
||||||
ingressNSMatchLabels?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
ingressNSPodMatchLabels?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
nodeAffinityPreset?: {
|
|
||||||
key?: string;
|
|
||||||
type?: string;
|
|
||||||
values?: unknown[];
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
nodeSelector?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
pdb?: {
|
|
||||||
create?: boolean;
|
|
||||||
maxUnavailable?: string;
|
|
||||||
minAvailable?: string;
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
podAffinityPreset?: string;
|
|
||||||
podAnnotations?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
podAntiAffinityPreset?: string;
|
|
||||||
podLabels?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
podSecurityContext?: {
|
|
||||||
enabled?: boolean;
|
|
||||||
fsGroup?: number;
|
|
||||||
fsGroupChangePolicy?: string;
|
|
||||||
supplementalGroups?: unknown[];
|
|
||||||
sysctls?: unknown[];
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
priorityClassName?: string;
|
|
||||||
readinessProbe?: {
|
|
||||||
enabled?: boolean;
|
|
||||||
failureThreshold?: number;
|
|
||||||
initialDelaySeconds?: number;
|
|
||||||
periodSeconds?: number;
|
|
||||||
successThreshold?: number;
|
|
||||||
timeoutSeconds?: number;
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
replicaCount?: number;
|
|
||||||
resources?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
resourcesPreset?: string;
|
|
||||||
schedulerName?: string;
|
|
||||||
service?: {
|
|
||||||
annotations?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
ports?: {
|
|
||||||
http?: number;
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
type?: string;
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
sidecars?: unknown[];
|
|
||||||
startupProbe?: {
|
|
||||||
enabled?: boolean;
|
|
||||||
failureThreshold?: number;
|
|
||||||
initialDelaySeconds?: number;
|
|
||||||
periodSeconds?: number;
|
|
||||||
successThreshold?: number;
|
|
||||||
timeoutSeconds?: number;
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
terminationGracePeriodSeconds?: number;
|
|
||||||
tolerations?: unknown[];
|
|
||||||
topologySpreadConstraints?: unknown[];
|
|
||||||
updateStrategy?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
diagnosticMode?: {
|
diagnosticMode?: {
|
||||||
args?: number[];
|
args?: number[];
|
||||||
command?: string[];
|
command?: string[];
|
||||||
|
|
@ -485,6 +323,37 @@ export interface ContourValues {
|
||||||
customStartupProbe?: {
|
customStartupProbe?: {
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
};
|
};
|
||||||
|
defaultInitContainers?: {
|
||||||
|
initConfig?: {
|
||||||
|
containerSecurityContext?: {
|
||||||
|
allowPrivilegeEscalation?: boolean;
|
||||||
|
capabilities?: {
|
||||||
|
drop?: string[];
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
enabled?: boolean;
|
||||||
|
privileged?: boolean;
|
||||||
|
readOnlyRootFilesystem?: boolean;
|
||||||
|
runAsGroup?: number;
|
||||||
|
runAsNonRoot?: boolean;
|
||||||
|
runAsUser?: number;
|
||||||
|
seLinuxOptions?: {
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
seccompProfile?: {
|
||||||
|
type?: string;
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
resources?: {
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
resourcesPreset?: string;
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
dnsPolicy?: string;
|
dnsPolicy?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
extraArgs?: unknown[];
|
extraArgs?: unknown[];
|
||||||
|
|
@ -516,30 +385,6 @@ export interface ContourValues {
|
||||||
tag?: string;
|
tag?: string;
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
};
|
};
|
||||||
initConfig?: {
|
|
||||||
containerSecurityContext?: {
|
|
||||||
allowPrivilegeEscalation?: boolean;
|
|
||||||
capabilities?: {
|
|
||||||
drop?: string[];
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
enabled?: boolean;
|
|
||||||
privileged?: boolean;
|
|
||||||
readOnlyRootFilesystem?: boolean;
|
|
||||||
runAsGroup?: number;
|
|
||||||
runAsNonRoot?: boolean;
|
|
||||||
runAsUser?: number;
|
|
||||||
seLinuxOptions?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
seccompProfile?: {
|
|
||||||
type?: string;
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
initContainers?: unknown[];
|
initContainers?: unknown[];
|
||||||
kind?: string;
|
kind?: string;
|
||||||
lifecycleHooks?: {
|
lifecycleHooks?: {
|
||||||
|
|
@ -796,34 +641,9 @@ export interface ContourValues {
|
||||||
defaultStorageClass?: string;
|
defaultStorageClass?: string;
|
||||||
imagePullSecrets?: unknown[];
|
imagePullSecrets?: unknown[];
|
||||||
imageRegistry?: string;
|
imageRegistry?: string;
|
||||||
security?: {
|
|
||||||
allowInsecureImages?: boolean;
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
storageClass?: string;
|
storageClass?: string;
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
};
|
};
|
||||||
ingress?: {
|
|
||||||
annotations?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
apiVersion?: string;
|
|
||||||
certManager?: boolean;
|
|
||||||
enabled?: boolean;
|
|
||||||
extraHosts?: unknown[];
|
|
||||||
extraPaths?: unknown[];
|
|
||||||
extraRules?: unknown[];
|
|
||||||
extraTls?: unknown[];
|
|
||||||
hostname?: string;
|
|
||||||
ingressClassName?: string;
|
|
||||||
path?: string;
|
|
||||||
pathType?: string;
|
|
||||||
rulesOverride?: unknown[];
|
|
||||||
secrets?: unknown[];
|
|
||||||
selfSigned?: boolean;
|
|
||||||
tls?: boolean;
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
kubeVersion?: string;
|
kubeVersion?: string;
|
||||||
metrics?: {
|
metrics?: {
|
||||||
prometheusRule?: {
|
prometheusRule?: {
|
||||||
|
|
|
||||||
|
|
@ -314,7 +314,7 @@ export class Observability {
|
||||||
{
|
{
|
||||||
role: 'pod',
|
role: 'pod',
|
||||||
namespaces: {
|
namespaces: {
|
||||||
names: ['default'],
|
names: ['default', 'contour'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,16 @@ export class Redis {
|
||||||
},
|
},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
deploy(input: { limits: { memory: string; cpu: string } }) {
|
deploy(input: { limits: { memory: string; cpu: string }; requests: { cpu: string } }) {
|
||||||
const redisService = getLocalComposeConfig().service('redis');
|
const redisService = getLocalComposeConfig().service('redis');
|
||||||
const name = 'redis-store';
|
const name = 'redis-store';
|
||||||
const limits: k8s.types.input.core.v1.ResourceRequirements['limits'] = {
|
const limits: k8s.types.input.core.v1.ResourceRequirements['limits'] = {
|
||||||
memory: input.limits.memory,
|
memory: input.limits.memory,
|
||||||
cpu: input.limits.cpu,
|
cpu: input.limits.cpu,
|
||||||
};
|
};
|
||||||
|
const requests: k8s.types.input.core.v1.ResourceRequirements['requests'] = {
|
||||||
|
cpu: input.requests.cpu,
|
||||||
|
};
|
||||||
|
|
||||||
const env: k8s.types.input.core.v1.EnvVar[] = normalizeEnv(this.options.env ?? {}).concat([
|
const env: k8s.types.input.core.v1.EnvVar[] = normalizeEnv(this.options.env ?? {}).concat([
|
||||||
{
|
{
|
||||||
|
|
@ -100,6 +103,7 @@ export class Redis {
|
||||||
ports: [{ containerPort: REDIS_PORT, protocol: 'TCP' }],
|
ports: [{ containerPort: REDIS_PORT, protocol: 'TCP' }],
|
||||||
resources: {
|
resources: {
|
||||||
limits,
|
limits,
|
||||||
|
requests,
|
||||||
},
|
},
|
||||||
livenessProbe: {
|
livenessProbe: {
|
||||||
initialDelaySeconds: 3,
|
initialDelaySeconds: 3,
|
||||||
|
|
@ -139,6 +143,10 @@ export class Redis {
|
||||||
cpu: '200m',
|
cpu: '200m',
|
||||||
memory: '200Mi',
|
memory: '200Mi',
|
||||||
},
|
},
|
||||||
|
requests: {
|
||||||
|
cpu: '100m',
|
||||||
|
memory: '100Mi',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { ContourValues } from './contour.types';
|
||||||
import { helmChart } from './helm';
|
import { helmChart } from './helm';
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
export const CONTOUR_CHART = helmChart('https://raw.githubusercontent.com/bitnami/charts/refs/heads/index/bitnami/', 'contour', '20.0.3');
|
export const CONTOUR_CHART = helmChart('https://projectcontour.github.io/helm-charts/', 'contour', '0.4.0');
|
||||||
|
|
||||||
export class Proxy {
|
export class Proxy {
|
||||||
private lbService: Output<k8s.core.v1.Service> | null = null;
|
private lbService: Output<k8s.core.v1.Service> | null = null;
|
||||||
|
|
@ -84,23 +84,13 @@ export class Proxy {
|
||||||
requestTimeout?: `${number}s` | 'infinity';
|
requestTimeout?: `${number}s` | 'infinity';
|
||||||
idleTimeout?: `${number}s`;
|
idleTimeout?: `${number}s`;
|
||||||
retriable?: boolean;
|
retriable?: boolean;
|
||||||
|
loadBalancerPolicy?:
|
||||||
|
| 'WeightedLeastRequest'
|
||||||
|
| 'RoundRobin'
|
||||||
|
| 'Random'
|
||||||
|
| 'RequestHash'
|
||||||
|
| 'Cookie';
|
||||||
customRewrite?: string;
|
customRewrite?: string;
|
||||||
virtualHost?: Output<string>;
|
|
||||||
httpsUpstream?: boolean;
|
|
||||||
withWwwDomain?: boolean;
|
|
||||||
// https://projectcontour.io/docs/1.29/config/rate-limiting/#local-rate-limiting
|
|
||||||
rateLimit?: {
|
|
||||||
// Max amount of request allowed with the "unit" parameter.
|
|
||||||
maxRequests: number;
|
|
||||||
unit: 'second' | 'minute' | 'hour';
|
|
||||||
// defining the number of requests above the baseline rate that are allowed in a short period of time.
|
|
||||||
// This would allow occasional larger bursts of traffic not to be rate limited.
|
|
||||||
burst?: number;
|
|
||||||
// default 429
|
|
||||||
responseStatusCode?: number;
|
|
||||||
// headers to add to the response in case of a rate limit
|
|
||||||
responseHeadersToAdd?: Record<string, string>;
|
|
||||||
};
|
|
||||||
}[],
|
}[],
|
||||||
) {
|
) {
|
||||||
const cert = new k8s.apiextensions.CustomResource(`cert-${dns.record}`, {
|
const cert = new k8s.apiextensions.CustomResource(`cert-${dns.record}`, {
|
||||||
|
|
@ -153,32 +143,10 @@ export class Proxy {
|
||||||
port: route.service.spec.ports[0].port,
|
port: route.service.spec.ports[0].port,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// https://projectcontour.io/docs/1.29/config/request-routing/#session-affinity
|
// https://projectcontour.io/docs/1.33/config/request-routing/
|
||||||
loadBalancerPolicy: {
|
loadBalancerPolicy: {
|
||||||
strategy: 'Cookie',
|
strategy: route.loadBalancerPolicy ?? 'RoundRobin',
|
||||||
},
|
},
|
||||||
// https://projectcontour.io/docs/1.29/config/rate-limiting/#local-rate-limiting
|
|
||||||
rateLimitPolicy: route.rateLimit
|
|
||||||
? {
|
|
||||||
local: {
|
|
||||||
requests: route.rateLimit.maxRequests,
|
|
||||||
unit: route.rateLimit.unit,
|
|
||||||
responseHeadersToAdd: [
|
|
||||||
{
|
|
||||||
name: 'x-rate-limit-active',
|
|
||||||
value: 'true',
|
|
||||||
},
|
|
||||||
...(route.rateLimit.responseHeadersToAdd
|
|
||||||
? Object.entries(route.rateLimit.responseHeadersToAdd).map(
|
|
||||||
([key, value]) => ({ name: key, value }),
|
|
||||||
)
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
responseStatusCode: route.rateLimit.responseStatusCode || 429,
|
|
||||||
burst: route.rateLimit.burst,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
...(route.path === '/'
|
...(route.path === '/'
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
|
|
@ -312,16 +280,7 @@ export class Proxy {
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
// Needed because we override the `contour.image.repository` field.
|
|
||||||
global: {
|
|
||||||
security: {
|
|
||||||
allowInsecureImages: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
contour: {
|
contour: {
|
||||||
image: {
|
|
||||||
repository: 'bitnamilegacy/contour',
|
|
||||||
},
|
|
||||||
podAnnotations: {
|
podAnnotations: {
|
||||||
'prometheus.io/scrape': 'true',
|
'prometheus.io/scrape': 'true',
|
||||||
'prometheus.io/port': '8000',
|
'prometheus.io/port': '8000',
|
||||||
|
|
@ -331,14 +290,13 @@ export class Proxy {
|
||||||
podLabels: {
|
podLabels: {
|
||||||
'vector.dev/exclude': 'true',
|
'vector.dev/exclude': 'true',
|
||||||
},
|
},
|
||||||
|
// Placeholder, see below
|
||||||
resources: {
|
resources: {
|
||||||
limits: {},
|
limits: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
envoy: {
|
envoy: {
|
||||||
image: {
|
// Placeholder, see below
|
||||||
repository: 'bitnamilegacy/envoy',
|
|
||||||
},
|
|
||||||
resources: {
|
resources: {
|
||||||
limits: {},
|
limits: {},
|
||||||
},
|
},
|
||||||
|
|
@ -380,7 +338,7 @@ export class Proxy {
|
||||||
const proxyController = new k8s.helm.v3.Chart('contour-proxy', {
|
const proxyController = new k8s.helm.v3.Chart('contour-proxy', {
|
||||||
...CONTOUR_CHART,
|
...CONTOUR_CHART,
|
||||||
namespace: ns.metadata.name,
|
namespace: ns.metadata.name,
|
||||||
// https://github.com/bitnami/charts/tree/master/bitnami/contour
|
// https://artifacthub.io/packages/helm/contour/contour
|
||||||
values: chartValues,
|
values: chartValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,14 @@ export class ServiceDeployment {
|
||||||
livenessProbe?: string | ProbeConfig;
|
livenessProbe?: string | ProbeConfig;
|
||||||
readinessProbe?: string | ProbeConfig;
|
readinessProbe?: string | ProbeConfig;
|
||||||
startupProbe?: string | ProbeConfig;
|
startupProbe?: string | ProbeConfig;
|
||||||
memoryLimit?: string;
|
memory?: {
|
||||||
|
limit?: string;
|
||||||
|
requests?: string;
|
||||||
|
};
|
||||||
|
cpu?: {
|
||||||
|
limit?: string;
|
||||||
|
requests?: string;
|
||||||
|
};
|
||||||
volumes?: k8s.types.input.core.v1.Volume[];
|
volumes?: k8s.types.input.core.v1.Volume[];
|
||||||
volumeMounts?: k8s.types.input.core.v1.VolumeMount[];
|
volumeMounts?: k8s.types.input.core.v1.VolumeMount[];
|
||||||
/**
|
/**
|
||||||
|
|
@ -58,10 +65,7 @@ export class ServiceDeployment {
|
||||||
autoScaling?: {
|
autoScaling?: {
|
||||||
minReplicas?: number;
|
minReplicas?: number;
|
||||||
maxReplicas: number;
|
maxReplicas: number;
|
||||||
cpu: {
|
cpuAverageToScale: number;
|
||||||
limit: string;
|
|
||||||
cpuAverageToScale: number;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
availabilityOnEveryNode?: boolean;
|
availabilityOnEveryNode?: boolean;
|
||||||
command?: pulumi.Input<pulumi.Input<string>[]>;
|
command?: pulumi.Input<pulumi.Input<string>[]>;
|
||||||
|
|
@ -205,14 +209,23 @@ export class ServiceDeployment {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourcesLimits: Record<string, string> = {};
|
const resourcesLimits: { cpu?: string; memory?: string } = {};
|
||||||
|
const resourcesRequests: { cpu?: string; memory?: string } = {};
|
||||||
|
|
||||||
if (this.options?.autoScaling?.cpu.limit) {
|
if (this.options?.cpu?.limit) {
|
||||||
resourcesLimits.cpu = this.options.autoScaling.cpu.limit;
|
resourcesLimits.cpu = this.options?.cpu?.limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.memoryLimit) {
|
if (this.options?.cpu?.requests) {
|
||||||
resourcesLimits.memory = this.options.memoryLimit;
|
resourcesRequests.cpu = this.options?.cpu?.requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options?.memory?.limit) {
|
||||||
|
resourcesLimits.memory = this.options?.memory?.limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options?.memory?.requests) {
|
||||||
|
resourcesRequests.memory = this.options?.memory?.requests;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pb = new PodBuilder({
|
const pb = new PodBuilder({
|
||||||
|
|
@ -248,6 +261,7 @@ export class ServiceDeployment {
|
||||||
image: this.options.image,
|
image: this.options.image,
|
||||||
resources: {
|
resources: {
|
||||||
limits: resourcesLimits,
|
limits: resourcesLimits,
|
||||||
|
requests: resourcesRequests,
|
||||||
},
|
},
|
||||||
args: this.options.args,
|
args: this.options.args,
|
||||||
ports: {
|
ports: {
|
||||||
|
|
@ -342,7 +356,7 @@ export class ServiceDeployment {
|
||||||
name: 'cpu',
|
name: 'cpu',
|
||||||
target: {
|
target: {
|
||||||
type: 'Utilization',
|
type: 'Utilization',
|
||||||
averageUtilization: this.options.autoScaling.cpu.cpuAverageToScale,
|
averageUtilization: this.options.autoScaling.cpuAverageToScale,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:24.13.0-slim
|
FROM node:24.14.1-slim
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ require (
|
||||||
go.opentelemetry.io/collector/extension v1.53.0
|
go.opentelemetry.io/collector/extension v1.53.0
|
||||||
go.opentelemetry.io/collector/extension/extensionauth v1.53.0
|
go.opentelemetry.io/collector/extension/extensionauth v1.53.0
|
||||||
go.opentelemetry.io/collector/extension/extensiontest v0.147.0
|
go.opentelemetry.io/collector/extension/extensiontest v0.147.0
|
||||||
go.opentelemetry.io/otel v1.40.0
|
go.opentelemetry.io/otel v1.43.0
|
||||||
go.opentelemetry.io/otel/metric v1.40.0
|
go.opentelemetry.io/otel/metric v1.43.0
|
||||||
go.uber.org/goleak v1.3.0
|
go.uber.org/goleak v1.3.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
golang.org/x/sync v0.19.0
|
golang.org/x/sync v0.19.0
|
||||||
|
|
@ -41,13 +41,13 @@ require (
|
||||||
go.opentelemetry.io/collector/featuregate v1.53.0 // indirect
|
go.opentelemetry.io/collector/featuregate v1.53.0 // indirect
|
||||||
go.opentelemetry.io/collector/internal/componentalias v0.147.0 // indirect
|
go.opentelemetry.io/collector/internal/componentalias v0.147.0 // indirect
|
||||||
go.opentelemetry.io/collector/pdata v1.53.0 // indirect
|
go.opentelemetry.io/collector/pdata v1.53.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
google.golang.org/grpc v1.79.3 // indirect
|
google.golang.org/grpc v1.79.3 // indirect
|
||||||
|
|
|
||||||
|
|
@ -79,14 +79,24 @@ go.opentelemetry.io/collector/pdata v1.53.0 h1:DlYDbRwammEZaxDZHINx5v0n8SEOVNniP
|
||||||
go.opentelemetry.io/collector/pdata v1.53.0/go.mod h1:LRSYGNjKXaUrZEwZv3Yl+8/zV2HmRGKXW62zB2bysms=
|
go.opentelemetry.io/collector/pdata v1.53.0/go.mod h1:LRSYGNjKXaUrZEwZv3Yl+8/zV2HmRGKXW62zB2bysms=
|
||||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||||
|
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||||
|
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||||
|
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||||
|
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
go.opentelemetry.io/proto/slim/otlp v1.9.0 h1:fPVMv8tP3TrsqlkH1HWYUpbCY9cAIemx184VGkS6vlE=
|
go.opentelemetry.io/proto/slim/otlp v1.9.0 h1:fPVMv8tP3TrsqlkH1HWYUpbCY9cAIemx184VGkS6vlE=
|
||||||
go.opentelemetry.io/proto/slim/otlp v1.9.0/go.mod h1:xXdeJJ90Gqyll+orzUkY4bOd2HECo5JofeoLpymVqdI=
|
go.opentelemetry.io/proto/slim/otlp v1.9.0/go.mod h1:xXdeJJ90Gqyll+orzUkY4bOd2HECo5JofeoLpymVqdI=
|
||||||
go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0 h1:o13nadWDNkH/quoDomDUClnQBpdQQ2Qqv0lQBjIXjE8=
|
go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0 h1:o13nadWDNkH/quoDomDUClnQBpdQQ2Qqv0lQBjIXjE8=
|
||||||
|
|
@ -107,6 +117,8 @@ golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
|
|
|
||||||
|
|
@ -337,25 +337,6 @@ target "app" {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
target "apollo-router" {
|
|
||||||
inherits = ["router-base", get_target()]
|
|
||||||
contexts = {
|
|
||||||
router_pkg = "${PWD}/packages/libraries/router"
|
|
||||||
config = "${PWD}/configs/cargo"
|
|
||||||
}
|
|
||||||
args = {
|
|
||||||
IMAGE_TITLE = "graphql-hive/apollo-router"
|
|
||||||
PORT = "4000"
|
|
||||||
IMAGE_DESCRIPTION = "Apollo Router for GraphQL Hive."
|
|
||||||
}
|
|
||||||
tags = [
|
|
||||||
local_image_tag("apollo-router"),
|
|
||||||
stable_image_tag("apollo-router"),
|
|
||||||
image_tag("apollo-router", COMMIT_SHA),
|
|
||||||
image_tag("apollo-router", BRANCH_NAME)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
target "otel-collector" {
|
target "otel-collector" {
|
||||||
inherits = ["otel-collector-base", get_target()]
|
inherits = ["otel-collector-base", get_target()]
|
||||||
context = "${PWD}/docker/configs/otel-collector"
|
context = "${PWD}/docker/configs/otel-collector"
|
||||||
|
|
@ -421,12 +402,6 @@ group "integration-tests" {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
group "apollo-router-hive-build" {
|
|
||||||
targets = [
|
|
||||||
"apollo-router"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
group "cli" {
|
group "cli" {
|
||||||
targets = [
|
targets = [
|
||||||
"cli"
|
"cli"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:24.13.0-slim
|
FROM node:24.14.1-slim
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y ca-certificates
|
RUN apt-get update && apt-get install -y ca-certificates
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
# syntax=docker/dockerfile:1
|
|
||||||
FROM scratch AS router_pkg
|
|
||||||
FROM scratch AS config
|
|
||||||
|
|
||||||
FROM rust:1.91.1-slim-bookworm AS build
|
|
||||||
|
|
||||||
# Required by Apollo Router
|
|
||||||
RUN apt-get update
|
|
||||||
RUN apt-get -y install npm protobuf-compiler cmake
|
|
||||||
RUN rm -rf /var/lib/apt/lists/*
|
|
||||||
RUN update-ca-certificates
|
|
||||||
RUN rustup component add rustfmt
|
|
||||||
|
|
||||||
WORKDIR /usr/src
|
|
||||||
# Create blank projects
|
|
||||||
RUN USER=root cargo new router
|
|
||||||
|
|
||||||
# Copy Cargo files
|
|
||||||
COPY --from=router_pkg Cargo.toml /usr/src/router/
|
|
||||||
COPY --from=config Cargo.lock /usr/src/router/
|
|
||||||
|
|
||||||
WORKDIR /usr/src/router
|
|
||||||
# Get the dependencies cached, so we can use dummy input files so Cargo wont fail
|
|
||||||
RUN echo 'fn main() { println!(""); }' > ./src/main.rs
|
|
||||||
RUN echo 'fn main() { println!(""); }' > ./src/lib.rs
|
|
||||||
RUN cargo build --release
|
|
||||||
|
|
||||||
# Copy in the actual source code
|
|
||||||
COPY --from=router_pkg src ./src
|
|
||||||
RUN touch ./src/main.rs
|
|
||||||
RUN touch ./src/lib.rs
|
|
||||||
|
|
||||||
# Real build this time
|
|
||||||
RUN cargo build --release
|
|
||||||
|
|
||||||
# Runtime
|
|
||||||
FROM debian:bookworm-slim AS runtime
|
|
||||||
|
|
||||||
RUN apt-get update
|
|
||||||
RUN apt-get -y install ca-certificates
|
|
||||||
RUN rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title=$IMAGE_TITLE
|
|
||||||
LABEL org.opencontainers.image.version=$RELEASE
|
|
||||||
LABEL org.opencontainers.image.description=$IMAGE_DESCRIPTION
|
|
||||||
LABEL org.opencontainers.image.authors="The Guild"
|
|
||||||
LABEL org.opencontainers.image.vendor="Kamil Kisiela"
|
|
||||||
LABEL org.opencontainers.image.url="https://github.com/graphql-hive/console"
|
|
||||||
LABEL org.opencontainers.image.source="https://github.com/graphql-hive/console"
|
|
||||||
|
|
||||||
RUN mkdir -p /dist/config
|
|
||||||
RUN mkdir /dist/schema
|
|
||||||
|
|
||||||
# Copy in the required files from our build image
|
|
||||||
COPY --from=build --chown=root:root /usr/src/router/target/release/router /dist
|
|
||||||
COPY --from=router_pkg router.yaml /dist/config/router.yaml
|
|
||||||
|
|
||||||
WORKDIR /dist
|
|
||||||
|
|
||||||
ENV APOLLO_ROUTER_CONFIG_PATH="/dist/config/router.yaml"
|
|
||||||
|
|
||||||
ENTRYPOINT ["./router"]
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:24.13.0-slim
|
FROM node:24.14.1-slim
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y wget ca-certificates && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y wget ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ networks:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
local_cdn:
|
local_cdn:
|
||||||
image: node:24.13.0-alpine3.23
|
image: node:24.14.1-alpine3.23
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: ['node', 'index.js']
|
command: ['node', 'index.js']
|
||||||
networks:
|
networks:
|
||||||
|
|
@ -38,7 +38,7 @@ services:
|
||||||
S3_BUCKET_NAME: artifacts
|
S3_BUCKET_NAME: artifacts
|
||||||
|
|
||||||
local_broker:
|
local_broker:
|
||||||
image: node:24.13.0-alpine3.23
|
image: node:24.14.1-alpine3.23
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: ['node', 'broker.js']
|
command: ['node', 'broker.js']
|
||||||
networks:
|
networks:
|
||||||
|
|
@ -90,7 +90,7 @@ services:
|
||||||
SECRET: '${EXTERNAL_COMPOSITION_SECRET}'
|
SECRET: '${EXTERNAL_COMPOSITION_SECRET}'
|
||||||
|
|
||||||
external_composition:
|
external_composition:
|
||||||
image: node:24.13.0-alpine3.23
|
image: node:24.14.1-alpine3.23
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: ['node', 'example.mjs']
|
command: ['node', 'example.mjs']
|
||||||
networks:
|
networks:
|
||||||
|
|
@ -290,10 +290,10 @@ services:
|
||||||
|
|
||||||
# It's not part of integration tests
|
# It's not part of integration tests
|
||||||
app:
|
app:
|
||||||
image: node:24.13.0-alpine3.23
|
image: node:24.14.1-alpine3.23
|
||||||
command: ['npx', 'http-server']
|
command: ['npx', 'http-server']
|
||||||
|
|
||||||
# Redpand is used for integration tests, instead of Kafka. Zookeeper is no longer needed
|
# Redpand is used for integration tests, instead of Kafka. Zookeeper is no longer needed
|
||||||
zookeeper:
|
zookeeper:
|
||||||
image: node:24.13.0-alpine3.23
|
image: node:24.14.1-alpine3.23
|
||||||
command: ['npx', 'http-server']
|
command: ['npx', 'http-server']
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,11 @@
|
||||||
"prepare:env": "cd ../ && pnpm build:libraries && pnpm build:services",
|
"prepare:env": "cd ../ && pnpm build:libraries && pnpm build:services",
|
||||||
"start": "./local.sh",
|
"start": "./local.sh",
|
||||||
"test:integration": "vitest",
|
"test:integration": "vitest",
|
||||||
"test:integration:apollo-router": "TEST_APOLLO_ROUTER=1 vitest tests/apollo-router",
|
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@apollo/gateway": "2.13.2",
|
"@apollo/gateway": "2.13.2",
|
||||||
"@apollo/server": "5.4.0",
|
"@apollo/server": "5.5.0",
|
||||||
"@apollo/subgraph": "2.13.2",
|
"@apollo/subgraph": "2.13.2",
|
||||||
"@aws-sdk/client-s3": "3.723.0",
|
"@aws-sdk/client-s3": "3.723.0",
|
||||||
"@esm2cjs/execa": "6.1.1-cjs.1",
|
"@esm2cjs/execa": "6.1.1-cjs.1",
|
||||||
|
|
@ -20,11 +19,12 @@
|
||||||
"@graphql-hive/core": "workspace:*",
|
"@graphql-hive/core": "workspace:*",
|
||||||
"@graphql-typed-document-node/core": "3.2.0",
|
"@graphql-typed-document-node/core": "3.2.0",
|
||||||
"@hive/commerce": "workspace:*",
|
"@hive/commerce": "workspace:*",
|
||||||
|
"@hive/postgres": "workspace:*",
|
||||||
"@hive/schema": "workspace:*",
|
"@hive/schema": "workspace:*",
|
||||||
"@hive/server": "workspace:*",
|
"@hive/server": "workspace:*",
|
||||||
"@hive/service-common": "workspace:*",
|
"@hive/service-common": "workspace:*",
|
||||||
"@hive/storage": "workspace:*",
|
"@hive/storage": "workspace:*",
|
||||||
"@theguild/federation-composition": "0.22.1",
|
"@theguild/federation-composition": "0.22.2",
|
||||||
"@trpc/client": "10.45.3",
|
"@trpc/client": "10.45.3",
|
||||||
"@trpc/server": "10.45.3",
|
"@trpc/server": "10.45.3",
|
||||||
"@types/async-retry": "1.4.8",
|
"@types/async-retry": "1.4.8",
|
||||||
|
|
@ -42,10 +42,9 @@
|
||||||
"human-id": "4.1.1",
|
"human-id": "4.1.1",
|
||||||
"ioredis": "5.8.2",
|
"ioredis": "5.8.2",
|
||||||
"set-cookie-parser": "2.7.1",
|
"set-cookie-parser": "2.7.1",
|
||||||
"slonik": "30.4.4",
|
|
||||||
"strip-ansi": "7.1.2",
|
"strip-ansi": "7.1.2",
|
||||||
"tslib": "2.8.1",
|
"tslib": "2.8.1",
|
||||||
"vitest": "4.0.9",
|
"vitest": "4.1.3",
|
||||||
"zod": "3.25.76"
|
"zod": "3.25.76"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { DatabasePool } from 'slonik';
|
|
||||||
import {
|
import {
|
||||||
AccessTokenKeyContainer,
|
AccessTokenKeyContainer,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
} from '@hive/api/modules/auth/lib/supertokens-at-home/crypto';
|
} from '@hive/api/modules/auth/lib/supertokens-at-home/crypto';
|
||||||
import { SuperTokensStore } from '@hive/api/modules/auth/providers/supertokens-store';
|
import { SuperTokensStore } from '@hive/api/modules/auth/providers/supertokens-store';
|
||||||
import { NoopLogger } from '@hive/api/modules/shared/providers/logger';
|
import { NoopLogger } from '@hive/api/modules/shared/providers/logger';
|
||||||
|
import { PostgresDatabasePool } from '@hive/postgres';
|
||||||
import type { InternalApi } from '@hive/server';
|
import type { InternalApi } from '@hive/server';
|
||||||
import { createNewSession } from '@hive/server/supertokens-at-home/shared';
|
import { createNewSession } from '@hive/server/supertokens-at-home/shared';
|
||||||
import { createTRPCProxyClient, httpLink } from '@trpc/client';
|
import { createTRPCProxyClient, httpLink } from '@trpc/client';
|
||||||
|
|
@ -76,7 +76,7 @@ const tokenResponsePromise: {
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
export async function authenticate(
|
export async function authenticate(
|
||||||
pool: DatabasePool,
|
pool: PostgresDatabasePool,
|
||||||
email: string,
|
email: string,
|
||||||
oidcIntegrationId?: string,
|
oidcIntegrationId?: string,
|
||||||
): Promise<{ access_token: string; refresh_token: string; supertokensUserId: string }> {
|
): Promise<{ access_token: string; refresh_token: string; supertokensUserId: string }> {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import type { AddressInfo } from 'node:net';
|
import type { AddressInfo } from 'node:net';
|
||||||
import humanId from 'human-id';
|
import humanId from 'human-id';
|
||||||
import setCookie from 'set-cookie-parser';
|
import setCookie from 'set-cookie-parser';
|
||||||
import { sql, type DatabasePool } from 'slonik';
|
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import formDataPlugin from '@fastify/formbody';
|
import formDataPlugin from '@fastify/formbody';
|
||||||
|
import { psql, type PostgresDatabasePool } from '@hive/postgres';
|
||||||
import { createServer, type FastifyReply, type FastifyRequest } from '@hive/service-common';
|
import { createServer, type FastifyReply, type FastifyRequest } from '@hive/service-common';
|
||||||
import { graphql } from './gql';
|
import { graphql } from './gql';
|
||||||
import { execute } from './graphql';
|
import { execute } from './graphql';
|
||||||
|
|
@ -157,7 +157,7 @@ const VerifyEmailMutation = graphql(`
|
||||||
export async function createOIDCIntegration(args: {
|
export async function createOIDCIntegration(args: {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
getPool: () => Promise<DatabasePool>;
|
getPool: () => Promise<PostgresDatabasePool>;
|
||||||
}) {
|
}) {
|
||||||
const { accessToken: authToken, getPool } = args;
|
const { accessToken: authToken, getPool } = args;
|
||||||
const result = await execute({
|
const result = await execute({
|
||||||
|
|
@ -192,7 +192,7 @@ export async function createOIDCIntegration(args: {
|
||||||
}) + '.local';
|
}) + '.local';
|
||||||
|
|
||||||
const pool = await getPool();
|
const pool = await getPool();
|
||||||
const query = sql`
|
const query = psql`
|
||||||
INSERT INTO "oidc_integration_domains" (
|
INSERT INTO "oidc_integration_domains" (
|
||||||
"organization_id"
|
"organization_id"
|
||||||
, "oidc_integration_id"
|
, "oidc_integration_id"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { formatISO, subHours } from 'date-fns';
|
import { formatISO, subHours } from 'date-fns';
|
||||||
import { humanId } from 'human-id';
|
import { humanId } from 'human-id';
|
||||||
import { createPool, sql } from 'slonik';
|
import z from 'zod';
|
||||||
import { NoopLogger } from '@hive/api/modules/shared/providers/logger';
|
import { NoopLogger } from '@hive/api/modules/shared/providers/logger';
|
||||||
import { createRedisClient } from '@hive/api/modules/shared/providers/redis';
|
import { createRedisClient } from '@hive/api/modules/shared/providers/redis';
|
||||||
|
import { createPostgresDatabasePool, psql } from '@hive/postgres';
|
||||||
import type { Report } from '../../packages/libraries/core/src/client/usage.js';
|
import type { Report } from '../../packages/libraries/core/src/client/usage.js';
|
||||||
import { authenticate, userEmail } from './auth';
|
import { authenticate, userEmail } from './auth';
|
||||||
import {
|
import {
|
||||||
|
|
@ -73,7 +74,7 @@ import { UpdateSchemaPolicyForOrganization, UpdateSchemaPolicyForProject } from
|
||||||
import { collect, CollectedOperation, legacyCollect } from './usage';
|
import { collect, CollectedOperation, legacyCollect } from './usage';
|
||||||
import { generateUnique, getServiceHost, pollForEmailVerificationLink } from './utils';
|
import { generateUnique, getServiceHost, pollForEmailVerificationLink } from './utils';
|
||||||
|
|
||||||
function createConnectionPool() {
|
function getPGConnectionString() {
|
||||||
const pg = {
|
const pg = {
|
||||||
user: ensureEnv('POSTGRES_USER'),
|
user: ensureEnv('POSTGRES_USER'),
|
||||||
password: ensureEnv('POSTGRES_PASSWORD'),
|
password: ensureEnv('POSTGRES_PASSWORD'),
|
||||||
|
|
@ -82,9 +83,13 @@ function createConnectionPool() {
|
||||||
db: ensureEnv('POSTGRES_DB'),
|
db: ensureEnv('POSTGRES_DB'),
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPool(
|
return `postgres://${pg.user}:${pg.password}@${pg.host}:${pg.port}/${pg.db}?sslmode=disable`;
|
||||||
`postgres://${pg.user}:${pg.password}@${pg.host}:${pg.port}/${pg.db}?sslmode=disable`,
|
}
|
||||||
);
|
|
||||||
|
function createConnectionPool() {
|
||||||
|
return createPostgresDatabasePool({
|
||||||
|
connectionParameters: getPGConnectionString(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createDbConnection() {
|
async function createDbConnection() {
|
||||||
|
|
@ -97,9 +102,9 @@ async function createDbConnection() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initSeed() {
|
let sharedDBPoolPromise: ReturnType<typeof createDbConnection>;
|
||||||
let sharedDBPoolPromise: ReturnType<typeof createDbConnection>;
|
|
||||||
|
|
||||||
|
export function initSeed() {
|
||||||
function getPool() {
|
function getPool() {
|
||||||
if (!sharedDBPoolPromise) {
|
if (!sharedDBPoolPromise) {
|
||||||
sharedDBPoolPromise = createDbConnection();
|
sharedDBPoolPromise = createDbConnection();
|
||||||
|
|
@ -118,7 +123,7 @@ export function initSeed() {
|
||||||
|
|
||||||
if (opts?.verifyEmail ?? true) {
|
if (opts?.verifyEmail ?? true) {
|
||||||
const pool = await getPool();
|
const pool = await getPool();
|
||||||
await pool.query(sql`
|
await pool.query(psql`
|
||||||
INSERT INTO "email_verifications" ("user_identity_id", "email", "verified_at")
|
INSERT INTO "email_verifications" ("user_identity_id", "email", "verified_at")
|
||||||
VALUES (${auth.supertokensUserId}, ${email}, NOW())
|
VALUES (${auth.supertokensUserId}, ${email}, NOW())
|
||||||
`);
|
`);
|
||||||
|
|
@ -140,20 +145,25 @@ export function initSeed() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pollForEmailVerificationLink,
|
pollForEmailVerificationLink,
|
||||||
|
getPGConnectionString,
|
||||||
async purgeOIDCDomains() {
|
async purgeOIDCDomains() {
|
||||||
const pool = await getPool();
|
const pool = await getPool();
|
||||||
await pool.query(sql`
|
await pool.query(psql`
|
||||||
TRUNCATE "oidc_integration_domains"
|
TRUNCATE "oidc_integration_domains"
|
||||||
`);
|
`);
|
||||||
},
|
},
|
||||||
async forgeOIDCDNSChallenge(orgSlug: string) {
|
async forgeOIDCDNSChallenge(orgSlug: string) {
|
||||||
const pool = await getPool();
|
const pool = await getPool();
|
||||||
|
|
||||||
const domainChallengeId = await pool.oneFirst<string>(sql`
|
const domainChallengeId = await pool
|
||||||
|
.oneFirst(
|
||||||
|
psql`
|
||||||
SELECT "oidc_integration_domains"."id"
|
SELECT "oidc_integration_domains"."id"
|
||||||
FROM "oidc_integration_domains" INNER JOIN "organizations" ON "oidc_integration_domains"."organization_id" = "organizations"."id"
|
FROM "oidc_integration_domains" INNER JOIN "organizations" ON "oidc_integration_domains"."organization_id" = "organizations"."id"
|
||||||
WHERE "organizations"."clean_id" = ${orgSlug}
|
WHERE "organizations"."clean_id" = ${orgSlug}
|
||||||
`);
|
`,
|
||||||
|
)
|
||||||
|
.then(z.string().parse);
|
||||||
const key = `hive:oidcDomainChallenge:${domainChallengeId}`;
|
const key = `hive:oidcDomainChallenge:${domainChallengeId}`;
|
||||||
|
|
||||||
const challenge = {
|
const challenge = {
|
||||||
|
|
@ -208,7 +218,7 @@ export function initSeed() {
|
||||||
async overrideOrgPlan(plan: 'PRO' | 'ENTERPRISE' | 'HOBBY') {
|
async overrideOrgPlan(plan: 'PRO' | 'ENTERPRISE' | 'HOBBY') {
|
||||||
const pool = await createConnectionPool();
|
const pool = await createConnectionPool();
|
||||||
|
|
||||||
await pool.query(sql`
|
await pool.query(psql`
|
||||||
UPDATE organizations SET plan_name = ${plan} WHERE id = ${organization.id}
|
UPDATE organizations SET plan_name = ${plan} WHERE id = ${organization.id}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
@ -260,8 +270,8 @@ export function initSeed() {
|
||||||
async setFeatureFlag(name: string, value: boolean | string[]) {
|
async setFeatureFlag(name: string, value: boolean | string[]) {
|
||||||
const pool = await createConnectionPool();
|
const pool = await createConnectionPool();
|
||||||
|
|
||||||
await pool.query(sql`
|
await pool.query(psql`
|
||||||
UPDATE organizations SET feature_flags = ${sql.jsonb({
|
UPDATE organizations SET feature_flags = ${psql.jsonb({
|
||||||
[name]: value,
|
[name]: value,
|
||||||
})}
|
})}
|
||||||
WHERE id = ${organization.id}
|
WHERE id = ${organization.id}
|
||||||
|
|
@ -272,7 +282,7 @@ export function initSeed() {
|
||||||
async setDataRetention(days: number) {
|
async setDataRetention(days: number) {
|
||||||
const pool = await createConnectionPool();
|
const pool = await createConnectionPool();
|
||||||
|
|
||||||
await pool.query(sql`
|
await pool.query(psql`
|
||||||
UPDATE organizations SET limit_retention_days = ${days} WHERE id = ${organization.id}
|
UPDATE organizations SET limit_retention_days = ${days} WHERE id = ${organization.id}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
@ -340,13 +350,15 @@ export function initSeed() {
|
||||||
/** Expires tokens */
|
/** Expires tokens */
|
||||||
async forceExpireTokens(tokenIds: string[]) {
|
async forceExpireTokens(tokenIds: string[]) {
|
||||||
const pool = await createConnectionPool();
|
const pool = await createConnectionPool();
|
||||||
const result = await pool.query(sql`
|
const result = await pool.any(psql`
|
||||||
UPDATE "organization_access_tokens"
|
UPDATE "organization_access_tokens"
|
||||||
SET "expires_at"=NOW()
|
SET "expires_at" = NOW()
|
||||||
WHERE id IN (${sql.join(tokenIds, sql`, `)}) AND organization_id=${organization.id}
|
WHERE id IN (${psql.join(tokenIds, psql.fragment`, `)}) AND organization_id = ${organization.id}
|
||||||
|
RETURNING
|
||||||
|
"id"
|
||||||
`);
|
`);
|
||||||
await pool.end();
|
await pool.end();
|
||||||
expect(result.rowCount).toBe(tokenIds.length);
|
expect(result.length).toBe(tokenIds.length);
|
||||||
for (const id of tokenIds) {
|
for (const id of tokenIds) {
|
||||||
await purgeOrganizationAccessTokenById(id);
|
await purgeOrganizationAccessTokenById(id);
|
||||||
}
|
}
|
||||||
|
|
@ -390,7 +402,7 @@ export function initSeed() {
|
||||||
async setNativeFederation(enabled: boolean) {
|
async setNativeFederation(enabled: boolean) {
|
||||||
const pool = await createConnectionPool();
|
const pool = await createConnectionPool();
|
||||||
|
|
||||||
await pool.query(sql`
|
await pool.query(psql`
|
||||||
UPDATE projects SET native_federation = ${enabled} WHERE id = ${project.id}
|
UPDATE projects SET native_federation = ${enabled} WHERE id = ${project.id}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { buildASTSchema, parse } from 'graphql';
|
import { buildASTSchema, parse } from 'graphql';
|
||||||
import { createLogger } from 'graphql-yoga';
|
import { createLogger } from 'graphql-yoga';
|
||||||
import { sql } from 'slonik';
|
|
||||||
import { pollFor } from 'testkit/flow';
|
import { pollFor } from 'testkit/flow';
|
||||||
import { initSeed } from 'testkit/seed';
|
import { initSeed } from 'testkit/seed';
|
||||||
import { getServiceHost } from 'testkit/utils';
|
import { getServiceHost } from 'testkit/utils';
|
||||||
|
import z from 'zod';
|
||||||
import { createHive } from '@graphql-hive/core';
|
import { createHive } from '@graphql-hive/core';
|
||||||
import { clickHouseInsert, clickHouseQuery } from '../../testkit/clickhouse';
|
import { psql } from '@hive/postgres';
|
||||||
|
import { clickHouseInsert } from '../../testkit/clickhouse';
|
||||||
import { graphql } from '../../testkit/gql';
|
import { graphql } from '../../testkit/gql';
|
||||||
import { execute } from '../../testkit/graphql';
|
import { execute } from '../../testkit/graphql';
|
||||||
|
|
||||||
|
|
@ -957,6 +958,221 @@ test('add documents to app deployment fails if document does not pass validation
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('app deployment validates documents with MCP directives when schema does not define them', async () => {
|
||||||
|
const { createOrg } = await initSeed().createOwner();
|
||||||
|
const { createProject, setFeatureFlag } = await createOrg();
|
||||||
|
await setFeatureFlag('appDeployments', true);
|
||||||
|
const { createTargetAccessToken } = await createProject();
|
||||||
|
const token = await createTargetAccessToken({});
|
||||||
|
|
||||||
|
await token.publishSchema({
|
||||||
|
sdl: /* GraphQL */ `
|
||||||
|
type Query {
|
||||||
|
weather(location: String!): Weather
|
||||||
|
}
|
||||||
|
type Weather {
|
||||||
|
temp: Float
|
||||||
|
conditions: String
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await execute({
|
||||||
|
document: CreateAppDeployment,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
appName: 'mcp-test',
|
||||||
|
appVersion: '1.0.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authToken: token.secret,
|
||||||
|
}).then(res => res.expectNoGraphQLErrors());
|
||||||
|
|
||||||
|
const { addDocumentsToAppDeployment } = await execute({
|
||||||
|
document: AddDocumentsToAppDeployment,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
appName: 'mcp-test',
|
||||||
|
appVersion: '1.0.0',
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
hash: 'mcp-weather',
|
||||||
|
body: [
|
||||||
|
'query GetWeather(',
|
||||||
|
' $location: String! @mcpDescription(provider: "langfuse:loc") @mcpHeader(name: "X-Location")',
|
||||||
|
') @mcpTool(name: "get_weather", description: "Get weather") {',
|
||||||
|
' weather(location: $location) { temp conditions }',
|
||||||
|
'}',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hash: 'mcp-weather-title',
|
||||||
|
body: [
|
||||||
|
'query GetWeatherTitle(',
|
||||||
|
' $location: String! @mcpDescription(provider: "langfuse:loc")',
|
||||||
|
') @mcpTool(name: "get_weather_title", title: "Weather Tool", meta: "{}") {',
|
||||||
|
' weather(location: $location) { temp conditions }',
|
||||||
|
'}',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authToken: token.secret,
|
||||||
|
}).then(res => res.expectNoGraphQLErrors());
|
||||||
|
|
||||||
|
expect(addDocumentsToAppDeployment.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('app deployment validates documents with MCP directives when schema already defines them', async () => {
|
||||||
|
const { createOrg } = await initSeed().createOwner();
|
||||||
|
const { createProject, setFeatureFlag } = await createOrg();
|
||||||
|
await setFeatureFlag('appDeployments', true);
|
||||||
|
const { createTargetAccessToken } = await createProject();
|
||||||
|
const token = await createTargetAccessToken({});
|
||||||
|
|
||||||
|
await token.publishSchema({
|
||||||
|
sdl: /* GraphQL */ `
|
||||||
|
scalar JSON
|
||||||
|
directive @mcpTool(name: String!, description: String) on QUERY | MUTATION
|
||||||
|
directive @mcpDescription(provider: String!) on VARIABLE_DEFINITION | FIELD
|
||||||
|
directive @mcpHeader(name: String!) on VARIABLE_DEFINITION
|
||||||
|
type Query {
|
||||||
|
weather(location: String!): Weather
|
||||||
|
}
|
||||||
|
type Weather {
|
||||||
|
temp: Float
|
||||||
|
conditions: String
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await execute({
|
||||||
|
document: CreateAppDeployment,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
appName: 'mcp-existing',
|
||||||
|
appVersion: '1.0.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authToken: token.secret,
|
||||||
|
}).then(res => res.expectNoGraphQLErrors());
|
||||||
|
|
||||||
|
const { addDocumentsToAppDeployment: successResult } = await execute({
|
||||||
|
document: AddDocumentsToAppDeployment,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
appName: 'mcp-existing',
|
||||||
|
appVersion: '1.0.0',
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
hash: 'mcp-weather-existing',
|
||||||
|
body: [
|
||||||
|
'query GetWeather(',
|
||||||
|
' $location: String! @mcpDescription(provider: "langfuse:loc")',
|
||||||
|
') @mcpTool(name: "get_weather", description: "Get weather") {',
|
||||||
|
' weather(location: $location) { temp conditions }',
|
||||||
|
'}',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authToken: token.secret,
|
||||||
|
}).then(res => res.expectNoGraphQLErrors());
|
||||||
|
|
||||||
|
expect(successResult.error).toBeNull();
|
||||||
|
|
||||||
|
const { addDocumentsToAppDeployment: failResult } = await execute({
|
||||||
|
document: AddDocumentsToAppDeployment,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
appName: 'mcp-existing',
|
||||||
|
appVersion: '1.0.0',
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
hash: 'mcp-weather-title',
|
||||||
|
body: [
|
||||||
|
'query GetWeather(',
|
||||||
|
' $location: String! @mcpDescription(provider: "langfuse:loc")',
|
||||||
|
') @mcpTool(name: "get_weather", title: "Weather Tool") {',
|
||||||
|
' weather(location: $location) { temp conditions }',
|
||||||
|
'}',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authToken: token.secret,
|
||||||
|
}).then(res => res.expectNoGraphQLErrors());
|
||||||
|
|
||||||
|
expect(failResult.error).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: expect.stringContaining('not valid'),
|
||||||
|
details: expect.objectContaining({
|
||||||
|
message: expect.stringContaining('title'),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('app deployment injects only missing MCP directives when schema partially defines them', async () => {
|
||||||
|
const { createOrg } = await initSeed().createOwner();
|
||||||
|
const { createProject, setFeatureFlag } = await createOrg();
|
||||||
|
await setFeatureFlag('appDeployments', true);
|
||||||
|
const { createTargetAccessToken } = await createProject();
|
||||||
|
const token = await createTargetAccessToken({});
|
||||||
|
|
||||||
|
await token.publishSchema({
|
||||||
|
sdl: /* GraphQL */ `
|
||||||
|
directive @mcpTool(name: String!, description: String) on QUERY | MUTATION
|
||||||
|
type Query {
|
||||||
|
weather(location: String!): Weather
|
||||||
|
}
|
||||||
|
type Weather {
|
||||||
|
temp: Float
|
||||||
|
conditions: String
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await execute({
|
||||||
|
document: CreateAppDeployment,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
appName: 'mcp-partial',
|
||||||
|
appVersion: '1.0.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authToken: token.secret,
|
||||||
|
}).then(res => res.expectNoGraphQLErrors());
|
||||||
|
|
||||||
|
const { addDocumentsToAppDeployment } = await execute({
|
||||||
|
document: AddDocumentsToAppDeployment,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
appName: 'mcp-partial',
|
||||||
|
appVersion: '1.0.0',
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
hash: 'mcp-partial-weather',
|
||||||
|
body: [
|
||||||
|
'query GetWeather(',
|
||||||
|
' $location: String! @mcpDescription(provider: "langfuse:loc")',
|
||||||
|
') @mcpTool(name: "get_weather", description: "Get weather") {',
|
||||||
|
' weather(location: $location) { temp conditions }',
|
||||||
|
'}',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authToken: token.secret,
|
||||||
|
}).then(res => res.expectNoGraphQLErrors());
|
||||||
|
|
||||||
|
expect(addDocumentsToAppDeployment.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test('add documents to app deployment fails if document contains multiple executable operation definitions', async () => {
|
test('add documents to app deployment fails if document contains multiple executable operation definitions', async () => {
|
||||||
const { createOrg } = await initSeed().createOwner();
|
const { createOrg } = await initSeed().createOwner();
|
||||||
const { createProject, setFeatureFlag } = await createOrg();
|
const { createProject, setFeatureFlag } = await createOrg();
|
||||||
|
|
@ -2056,12 +2272,20 @@ test('activeAppDeployments works for > 1000 records with a date filter (neverUse
|
||||||
);
|
);
|
||||||
|
|
||||||
// insert into postgres
|
// insert into postgres
|
||||||
const result = await conn.pool.query(sql`
|
const result = await conn.pool
|
||||||
|
.any(
|
||||||
|
psql`
|
||||||
INSERT INTO app_deployments ("target_id", "name", "version", "activated_at")
|
INSERT INTO app_deployments ("target_id", "name", "version", "activated_at")
|
||||||
SELECT * FROM ${sql.unnest(appDeploymentRows, ['uuid', 'text', 'text', 'timestamptz'])}
|
SELECT * FROM ${psql.unnest(appDeploymentRows, ['uuid', 'text', 'text', 'timestamptz'])}
|
||||||
RETURNING "id", "target_id", "name", "version"
|
RETURNING "id", "target_id", "name", "version"
|
||||||
`);
|
`,
|
||||||
expect(result.rowCount).toBe(1200);
|
)
|
||||||
|
.then(
|
||||||
|
z.array(
|
||||||
|
z.object({ id: z.string(), target_id: z.string(), name: z.string(), version: z.string() }),
|
||||||
|
).parse,
|
||||||
|
);
|
||||||
|
expect(result.length).toBe(1200);
|
||||||
|
|
||||||
// insert into clickhouse and activate
|
// insert into clickhouse and activate
|
||||||
const query = `INSERT INTO app_deployments (
|
const query = `INSERT INTO app_deployments (
|
||||||
|
|
@ -2071,7 +2295,7 @@ test('activeAppDeployments works for > 1000 records with a date filter (neverUse
|
||||||
,"app_version"
|
,"app_version"
|
||||||
,"is_active"
|
,"is_active"
|
||||||
) VALUES
|
) VALUES
|
||||||
${result.rows
|
${result
|
||||||
.map(
|
.map(
|
||||||
r => `(
|
r => `(
|
||||||
'${r['target_id']}'
|
'${r['target_id']}'
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { pollFor } from 'testkit/flow';
|
||||||
import { graphql } from 'testkit/gql';
|
import { graphql } from 'testkit/gql';
|
||||||
import { ResourceAssignmentModeType } from 'testkit/gql/graphql';
|
import { ResourceAssignmentModeType } from 'testkit/gql/graphql';
|
||||||
import { execute } from 'testkit/graphql';
|
import { execute } from 'testkit/graphql';
|
||||||
|
|
@ -115,15 +116,19 @@ test.concurrent('cannot delete a role with members', async ({ expect }) => {
|
||||||
test.concurrent('email invitation', async ({ expect }) => {
|
test.concurrent('email invitation', async ({ expect }) => {
|
||||||
const seed = initSeed();
|
const seed = initSeed();
|
||||||
const { createOrg } = await seed.createOwner();
|
const { createOrg } = await seed.createOwner();
|
||||||
const { inviteMember } = await createOrg();
|
const { inviteMember, organization } = await createOrg();
|
||||||
|
|
||||||
const inviteEmail = seed.generateEmail();
|
const inviteEmail = seed.generateEmail();
|
||||||
const invitationResult = await inviteMember(inviteEmail);
|
const invitationResult = await inviteMember(inviteEmail);
|
||||||
const inviteCode = invitationResult.ok?.createdOrganizationInvitation.code;
|
const inviteCode = invitationResult.ok?.createdOrganizationInvitation.code;
|
||||||
expect(inviteCode).toBeDefined();
|
expect(inviteCode).toBeDefined();
|
||||||
|
|
||||||
const sentEmails = await history();
|
await pollFor(async () => {
|
||||||
expect(sentEmails).toContainEqual(expect.objectContaining({ to: inviteEmail }));
|
const sentEmails = await history(inviteEmail);
|
||||||
|
return sentEmails.length > 0;
|
||||||
|
});
|
||||||
|
const sentEmails = await history(inviteEmail);
|
||||||
|
expect(sentEmails[0].subject).toEqual('You have been invited to join ' + organization.slug);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.concurrent('can not invite with role not existing in organization', async ({ expect }) => {
|
test.concurrent('can not invite with role not existing in organization', async ({ expect }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,28 @@
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { sql, type CommonQueryMethods } from 'slonik';
|
|
||||||
/* eslint-disable no-process-env */
|
/* eslint-disable no-process-env */
|
||||||
import { ProjectType } from 'testkit/gql/graphql';
|
import { ProjectType } from 'testkit/gql/graphql';
|
||||||
import { test } from 'vitest';
|
import { test } from 'vitest';
|
||||||
|
import z from 'zod';
|
||||||
|
import { psql, type CommonQueryMethods } from '@hive/postgres';
|
||||||
import { initSeed } from '../../../testkit/seed';
|
import { initSeed } from '../../../testkit/seed';
|
||||||
|
|
||||||
async function fetchCoordinates(db: CommonQueryMethods, target: { id: string }) {
|
async function fetchCoordinates(db: CommonQueryMethods, target: { id: string }) {
|
||||||
const result = await db.query<{
|
const result = await db
|
||||||
coordinate: string;
|
.any(
|
||||||
created_in_version_id: string;
|
psql`
|
||||||
deprecated_in_version_id: string | null;
|
|
||||||
}>(sql`
|
|
||||||
SELECT coordinate, created_in_version_id, deprecated_in_version_id
|
SELECT coordinate, created_in_version_id, deprecated_in_version_id
|
||||||
FROM schema_coordinate_status WHERE target_id = ${target.id}
|
FROM schema_coordinate_status WHERE target_id = ${target.id}
|
||||||
`);
|
`,
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
z.object({
|
||||||
|
coordinate: z.string(),
|
||||||
|
created_in_version_id: z.string(),
|
||||||
|
deprecated_in_version_id: z.string().nullable(),
|
||||||
|
}).parse,
|
||||||
|
);
|
||||||
|
|
||||||
return result.rows;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe.skip('schema cleanup tracker', () => {
|
describe.skip('schema cleanup tracker', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { createPool, sql } from 'slonik';
|
|
||||||
import { graphql } from 'testkit/gql';
|
import { graphql } from 'testkit/gql';
|
||||||
/* eslint-disable no-process-env */
|
/* eslint-disable no-process-env */
|
||||||
import { ProjectType } from 'testkit/gql/graphql';
|
import { ProjectType } from 'testkit/gql/graphql';
|
||||||
import { execute } from 'testkit/graphql';
|
import { execute } from 'testkit/graphql';
|
||||||
import { assertNonNull, getServiceHost } from 'testkit/utils';
|
import { assertNonNull, getServiceHost } from 'testkit/utils';
|
||||||
|
import z from 'zod';
|
||||||
|
import { createPostgresDatabasePool, psql } from '@hive/postgres';
|
||||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
import { createStorage } from '@hive/storage';
|
import { createStorage } from '@hive/storage';
|
||||||
import { createTarget, publishSchema, updateSchemaComposition } from '../../../testkit/flow';
|
import { createTarget, publishSchema, updateSchemaComposition } from '../../../testkit/flow';
|
||||||
|
|
@ -3820,7 +3821,7 @@ test.concurrent(
|
||||||
);
|
);
|
||||||
|
|
||||||
const insertLegacyVersion = async (
|
const insertLegacyVersion = async (
|
||||||
pool: Awaited<ReturnType<typeof createPool>>,
|
pool: Awaited<ReturnType<typeof createPostgresDatabasePool>>,
|
||||||
args: {
|
args: {
|
||||||
sdl: string;
|
sdl: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -3828,7 +3829,9 @@ const insertLegacyVersion = async (
|
||||||
serviceUrl: string;
|
serviceUrl: string;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const logId = await pool.oneFirst<string>(sql`
|
const logId = await pool
|
||||||
|
.oneFirst(
|
||||||
|
psql`
|
||||||
INSERT INTO schema_log
|
INSERT INTO schema_log
|
||||||
(
|
(
|
||||||
author,
|
author,
|
||||||
|
|
@ -3854,9 +3857,13 @@ const insertLegacyVersion = async (
|
||||||
'PUSH'
|
'PUSH'
|
||||||
)
|
)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`);
|
`,
|
||||||
|
)
|
||||||
|
.then(z.string().parse);
|
||||||
|
|
||||||
const versionId = await pool.oneFirst<string>(sql`
|
const versionId = await pool
|
||||||
|
.oneFirst(
|
||||||
|
psql`
|
||||||
INSERT INTO schema_versions
|
INSERT INTO schema_versions
|
||||||
(
|
(
|
||||||
is_composable,
|
is_composable,
|
||||||
|
|
@ -3870,9 +3877,11 @@ const insertLegacyVersion = async (
|
||||||
${logId}
|
${logId}
|
||||||
)
|
)
|
||||||
RETURNING "id"
|
RETURNING "id"
|
||||||
`);
|
`,
|
||||||
|
)
|
||||||
|
.then(z.string().parse);
|
||||||
|
|
||||||
await pool.query(sql`
|
await pool.query(psql`
|
||||||
INSERT INTO
|
INSERT INTO
|
||||||
schema_version_to_log
|
schema_version_to_log
|
||||||
(version_id, action_id)
|
(version_id, action_id)
|
||||||
|
|
@ -3886,7 +3895,7 @@ const insertLegacyVersion = async (
|
||||||
test.concurrent(
|
test.concurrent(
|
||||||
'service url change from legacy to new version is displayed correctly',
|
'service url change from legacy to new version is displayed correctly',
|
||||||
async ({ expect }) => {
|
async ({ expect }) => {
|
||||||
let pool: Awaited<ReturnType<typeof createPool>> | undefined;
|
let pool: Awaited<ReturnType<typeof createPostgresDatabasePool>> | undefined;
|
||||||
try {
|
try {
|
||||||
const { createOrg } = await initSeed().createOwner();
|
const { createOrg } = await initSeed().createOwner();
|
||||||
const { createProject } = await createOrg();
|
const { createProject } = await createOrg();
|
||||||
|
|
@ -3899,7 +3908,9 @@ test.concurrent(
|
||||||
// We need to seed a legacy entry in the database
|
// We need to seed a legacy entry in the database
|
||||||
|
|
||||||
const conn = connectionString();
|
const conn = connectionString();
|
||||||
pool = await createPool(conn);
|
pool = await createPostgresDatabasePool({
|
||||||
|
connectionParameters: conn,
|
||||||
|
});
|
||||||
|
|
||||||
const sdl = 'type Query { ping: String! }';
|
const sdl = 'type Query { ping: String! }';
|
||||||
|
|
||||||
|
|
@ -3950,7 +3961,7 @@ test.concurrent(
|
||||||
test.concurrent(
|
test.concurrent(
|
||||||
'service url change from legacy to legacy version is displayed correctly',
|
'service url change from legacy to legacy version is displayed correctly',
|
||||||
async ({ expect }) => {
|
async ({ expect }) => {
|
||||||
let pool: Awaited<ReturnType<typeof createPool>> | undefined;
|
let pool: Awaited<ReturnType<typeof createPostgresDatabasePool>> | undefined;
|
||||||
try {
|
try {
|
||||||
const { createOrg } = await initSeed().createOwner();
|
const { createOrg } = await initSeed().createOwner();
|
||||||
const { createProject } = await createOrg();
|
const { createProject } = await createOrg();
|
||||||
|
|
@ -3963,7 +3974,7 @@ test.concurrent(
|
||||||
// We need to seed a legacy entry in the database
|
// We need to seed a legacy entry in the database
|
||||||
|
|
||||||
const conn = connectionString();
|
const conn = connectionString();
|
||||||
pool = await createPool(conn);
|
pool = await createPostgresDatabasePool({ connectionParameters: conn });
|
||||||
|
|
||||||
const sdl = 'type Query { ping: String! }';
|
const sdl = 'type Query { ping: String! }';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { pollFor, readTokenInfo } from 'testkit/flow';
|
import { pollFor, readTokenInfo } from 'testkit/flow';
|
||||||
import { ProjectType } from 'testkit/gql/graphql';
|
import { ProjectType } from 'testkit/gql/graphql';
|
||||||
|
import { createTokenStorage } from '@hive/storage';
|
||||||
|
import { generateToken } from '@hive/tokens';
|
||||||
import { initSeed } from '../../testkit/seed';
|
import { initSeed } from '../../testkit/seed';
|
||||||
|
|
||||||
test.concurrent('deleting a token should clear the cache', async () => {
|
test.concurrent('deleting a token should clear the cache', async () => {
|
||||||
|
|
@ -144,3 +146,59 @@ test.concurrent(
|
||||||
`);
|
`);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test.concurrent(
|
||||||
|
'regression: reading existing token with "last_used_at" from pg database (and not redis cache) does not raise an exception',
|
||||||
|
async ({ expect }) => {
|
||||||
|
const seed = initSeed();
|
||||||
|
const { createOrg } = await seed.createOwner();
|
||||||
|
const { createProject, organization } = await createOrg();
|
||||||
|
const { project, target } = await createProject();
|
||||||
|
|
||||||
|
const tokenStorage = await createTokenStorage(seed.getPGConnectionString(), 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = generateToken();
|
||||||
|
|
||||||
|
// create new token so it does not yet exist in redis cache
|
||||||
|
const record = await tokenStorage.createToken({
|
||||||
|
name: 'foo',
|
||||||
|
organization: organization.id,
|
||||||
|
project: project.id,
|
||||||
|
target: target.id,
|
||||||
|
scopes: [],
|
||||||
|
token: token.hash,
|
||||||
|
tokenAlias: token.alias,
|
||||||
|
});
|
||||||
|
|
||||||
|
// touch the token so it has a date
|
||||||
|
await tokenStorage.touchTokens({ tokens: [{ token: record.token, date: new Date() }] });
|
||||||
|
const result = await readTokenInfo(token.secret).then(res => res.expectNoGraphQLErrors());
|
||||||
|
expect(result.tokenInfo).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
__typename: TokenInfo,
|
||||||
|
hasOrganizationDelete: false,
|
||||||
|
hasOrganizationIntegrations: false,
|
||||||
|
hasOrganizationMembers: false,
|
||||||
|
hasOrganizationRead: false,
|
||||||
|
hasOrganizationSettings: false,
|
||||||
|
hasProjectAlerts: false,
|
||||||
|
hasProjectDelete: false,
|
||||||
|
hasProjectOperationsStoreRead: false,
|
||||||
|
hasProjectOperationsStoreWrite: false,
|
||||||
|
hasProjectRead: false,
|
||||||
|
hasProjectSettings: false,
|
||||||
|
hasTargetDelete: false,
|
||||||
|
hasTargetRead: false,
|
||||||
|
hasTargetRegistryRead: false,
|
||||||
|
hasTargetRegistryWrite: false,
|
||||||
|
hasTargetSettings: false,
|
||||||
|
hasTargetTokensRead: false,
|
||||||
|
hasTargetTokensWrite: false,
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
} finally {
|
||||||
|
await tokenStorage.destroy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
import { existsSync, rmSync, writeFileSync } from 'node:fs';
|
|
||||||
import { createServer } from 'node:http';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { MaybePromise } from 'slonik/dist/src/types';
|
|
||||||
import { ProjectType } from 'testkit/gql/graphql';
|
|
||||||
import { initSeed } from 'testkit/seed';
|
|
||||||
import { getServiceHost } from 'testkit/utils';
|
|
||||||
import { execa } from '@esm2cjs/execa';
|
|
||||||
|
|
||||||
describe('Apollo Router Integration', () => {
|
|
||||||
const getAvailablePort = () =>
|
|
||||||
new Promise<number>(resolve => {
|
|
||||||
const server = createServer();
|
|
||||||
server.listen(0, () => {
|
|
||||||
const address = server.address();
|
|
||||||
if (address && typeof address === 'object') {
|
|
||||||
const port = address.port;
|
|
||||||
server.close(() => resolve(port));
|
|
||||||
} else {
|
|
||||||
throw new Error('Could not get available port');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
function defer(deferFn: () => MaybePromise<void>) {
|
|
||||||
return {
|
|
||||||
async [Symbol.asyncDispose]() {
|
|
||||||
return deferFn();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
it('fetches the supergraph and sends usage reports', async () => {
|
|
||||||
const { createOrg } = await initSeed().createOwner();
|
|
||||||
const { createProject } = await createOrg();
|
|
||||||
const { createTargetAccessToken, createCdnAccess, target, waitForOperationsCollected } =
|
|
||||||
await createProject(ProjectType.Federation);
|
|
||||||
const writeToken = await createTargetAccessToken({});
|
|
||||||
|
|
||||||
// Publish Schema
|
|
||||||
const publishSchemaResult = await writeToken
|
|
||||||
.publishSchema({
|
|
||||||
author: 'Arda',
|
|
||||||
commit: 'abc123',
|
|
||||||
sdl: /* GraphQL */ `
|
|
||||||
type Query {
|
|
||||||
me: User
|
|
||||||
}
|
|
||||||
type User {
|
|
||||||
id: ID!
|
|
||||||
name: String!
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
service: 'users',
|
|
||||||
url: 'https://federation-demo.theguild.workers.dev/users',
|
|
||||||
})
|
|
||||||
.then(r => r.expectNoGraphQLErrors());
|
|
||||||
|
|
||||||
expect(publishSchemaResult.schemaPublish.__typename).toBe('SchemaPublishSuccess');
|
|
||||||
|
|
||||||
const cdnAccessResult = await createCdnAccess();
|
|
||||||
|
|
||||||
const usageAddress = await getServiceHost('usage', 8081);
|
|
||||||
|
|
||||||
const routerBinPath = join(__dirname, '../../../target/debug/router');
|
|
||||||
if (!existsSync(routerBinPath)) {
|
|
||||||
throw new Error(
|
|
||||||
`Apollo Router binary not found at path: ${routerBinPath}, make sure to build it first with 'cargo build'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const routerPort = await getAvailablePort();
|
|
||||||
const routerConfigContent = `
|
|
||||||
supergraph:
|
|
||||||
listen: 0.0.0.0:${routerPort}
|
|
||||||
plugins:
|
|
||||||
hive.usage: {}
|
|
||||||
`.trim();
|
|
||||||
const routerConfigPath = join(tmpdir(), `apollo-router-config-${Date.now()}.yaml`);
|
|
||||||
writeFileSync(routerConfigPath, routerConfigContent, 'utf-8');
|
|
||||||
|
|
||||||
const cdnEndpoint = await getServiceHost('server', 8082).then(
|
|
||||||
v => `http://${v}/artifacts/v1/${target.id}`,
|
|
||||||
);
|
|
||||||
const routerProc = execa(routerBinPath, ['--dev', '--config', routerConfigPath], {
|
|
||||||
all: true,
|
|
||||||
env: {
|
|
||||||
HIVE_CDN_ENDPOINT: cdnEndpoint,
|
|
||||||
HIVE_CDN_KEY: cdnAccessResult.secretAccessToken,
|
|
||||||
HIVE_ENDPOINT: `http://${usageAddress}`,
|
|
||||||
HIVE_TOKEN: writeToken.secret,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
let log = '';
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
routerProc.catch(err => {
|
|
||||||
if (!err.isCanceled) {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const routerProcOut = routerProc.all;
|
|
||||||
if (!routerProcOut) {
|
|
||||||
return reject(new Error('No stdout from Apollo Router process'));
|
|
||||||
}
|
|
||||||
routerProcOut.on('data', data => {
|
|
||||||
log += data.toString();
|
|
||||||
if (log.includes('GraphQL endpoint exposed at')) {
|
|
||||||
resolve(true);
|
|
||||||
}
|
|
||||||
process.stdout.write(log);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await using _ = defer(() => {
|
|
||||||
rmSync(routerConfigPath);
|
|
||||||
routerProc.cancel();
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = `http://localhost:${routerPort}/`;
|
|
||||||
async function sendOperation(i: number) {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
accept: 'application/json',
|
|
||||||
'content-type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
query: `
|
|
||||||
query Query${i} {
|
|
||||||
me {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
const result = await response.json();
|
|
||||||
expect(result).toEqual({
|
|
||||||
data: {
|
|
||||||
me: {
|
|
||||||
id: '1',
|
|
||||||
name: 'Ada Lovelace',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const cnt = 1000;
|
|
||||||
const jobs = [];
|
|
||||||
for (let i = 0; i < cnt; i++) {
|
|
||||||
if (i % 100 === 0) {
|
|
||||||
await new Promise(res => setTimeout(res, 500));
|
|
||||||
}
|
|
||||||
jobs.push(sendOperation(i));
|
|
||||||
}
|
|
||||||
await Promise.all(jobs);
|
|
||||||
await waitForOperationsCollected(cnt);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -74,7 +74,6 @@ test('update-retention script skips gracefully when no env vars are set', async
|
||||||
delete process.env.CLICKHOUSE_TTL_HOURLY_MV_TABLES;
|
delete process.env.CLICKHOUSE_TTL_HOURLY_MV_TABLES;
|
||||||
delete process.env.CLICKHOUSE_TTL_MINUTELY_MV_TABLES;
|
delete process.env.CLICKHOUSE_TTL_MINUTELY_MV_TABLES;
|
||||||
|
|
||||||
vi.resetModules();
|
|
||||||
const { updateRetention } = await import(
|
const { updateRetention } = await import(
|
||||||
'../../../packages/migrations/src/scripts/update-retention'
|
'../../../packages/migrations/src/scripts/update-retention'
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,6 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
setupFiles,
|
setupFiles,
|
||||||
testTimeout: 90_000,
|
testTimeout: 90_000,
|
||||||
exclude: process.env.TEST_APOLLO_ROUTER
|
exclude: defaultExclude,
|
||||||
? defaultExclude
|
|
||||||
: [...defaultExclude, 'tests/apollo-router/**'],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
22
package.json
22
package.json
|
|
@ -14,7 +14,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.18.3+sha512.bbd16e6d7286fd7e01f6b3c0b3c932cda2965c06a908328f74663f10a9aea51f1129eea615134bf992831b009eabe167ecb7008b597f40ff9bc75946aadfb08d",
|
"packageManager": "pnpm@10.18.3+sha512.bbd16e6d7286fd7e01f6b3c0b3c932cda2965c06a908328f74663f10a9aea51f1129eea615134bf992831b009eabe167ecb7008b597f40ff9bc75946aadfb08d",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.13",
|
"node": ">=24.14.1",
|
||||||
"pnpm": ">=10.16.0"
|
"pnpm": ">=10.16.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
"prerelease": "pnpm build:libraries",
|
"prerelease": "pnpm build:libraries",
|
||||||
"prettier": "prettier --cache --write --list-different --ignore-unknown \"**/*\"",
|
"prettier": "prettier --cache --write --list-different --ignore-unknown \"**/*\"",
|
||||||
"release": "pnpm build:libraries && changeset publish",
|
"release": "pnpm build:libraries && changeset publish",
|
||||||
"release:version": "changeset version && pnpm --filter hive-apollo-router-plugin sync-cargo-file && pnpm build:libraries && pnpm --filter @graphql-hive/cli oclif:readme",
|
"release:version": "changeset version && pnpm build:libraries && pnpm --filter @graphql-hive/cli oclif:readme",
|
||||||
"seed:app-deployments": "tsx scripts/seed-app-deployments.mts",
|
"seed:app-deployments": "tsx scripts/seed-app-deployments.mts",
|
||||||
"seed:insights": "tsx scripts/seed-insights.mts",
|
"seed:insights": "tsx scripts/seed-insights.mts",
|
||||||
"seed:org": "tsx scripts/seed-organization.mts",
|
"seed:org": "tsx scripts/seed-organization.mts",
|
||||||
|
|
@ -54,7 +54,6 @@
|
||||||
"test:e2e:local": "CYPRESS_BASE_URL=http://localhost:3000 RUN_AGAINST_LOCAL_SERVICES=1 cypress open --browser chrome",
|
"test:e2e:local": "CYPRESS_BASE_URL=http://localhost:3000 RUN_AGAINST_LOCAL_SERVICES=1 cypress open --browser chrome",
|
||||||
"test:e2e:open": "CYPRESS_BASE_URL=$HIVE_APP_BASE_URL cypress open",
|
"test:e2e:open": "CYPRESS_BASE_URL=$HIVE_APP_BASE_URL cypress open",
|
||||||
"test:integration": "cd integration-tests && pnpm test:integration",
|
"test:integration": "cd integration-tests && pnpm test:integration",
|
||||||
"test:integration:apollo-router": "cd integration-tests && pnpm test:integration:apollo-router",
|
|
||||||
"typecheck": "pnpm run -r --filter '!hive' typecheck",
|
"typecheck": "pnpm run -r --filter '!hive' typecheck",
|
||||||
"upload-sourcemaps": "./scripts/upload-sourcemaps.sh",
|
"upload-sourcemaps": "./scripts/upload-sourcemaps.sh",
|
||||||
"workspace": "pnpm run --filter $1 $2"
|
"workspace": "pnpm run --filter $1 $2"
|
||||||
|
|
@ -83,9 +82,9 @@
|
||||||
"@sentry/cli": "2.40.0",
|
"@sentry/cli": "2.40.0",
|
||||||
"@swc/core": "1.13.5",
|
"@swc/core": "1.13.5",
|
||||||
"@theguild/eslint-config": "0.12.1",
|
"@theguild/eslint-config": "0.12.1",
|
||||||
"@theguild/federation-composition": "0.22.1",
|
"@theguild/federation-composition": "0.22.2",
|
||||||
"@theguild/prettier-config": "2.0.7",
|
"@theguild/prettier-config": "2.0.7",
|
||||||
"@types/node": "24.10.9",
|
"@types/node": "24.12.2",
|
||||||
"bob-the-bundler": "7.0.1",
|
"bob-the-bundler": "7.0.1",
|
||||||
"cypress": "13.17.0",
|
"cypress": "13.17.0",
|
||||||
"dotenv": "16.4.7",
|
"dotenv": "16.4.7",
|
||||||
|
|
@ -109,7 +108,7 @@
|
||||||
"turbo": "2.5.8",
|
"turbo": "2.5.8",
|
||||||
"typescript": "5.7.3",
|
"typescript": "5.7.3",
|
||||||
"vite-tsconfig-paths": "5.1.4",
|
"vite-tsconfig-paths": "5.1.4",
|
||||||
"vitest": "4.0.9"
|
"vitest": "4.1.3"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides.esbuild": "To address CVE: https://github.com/graphql-hive/console/security/dependabot/259",
|
"overrides.esbuild": "To address CVE: https://github.com/graphql-hive/console/security/dependabot/259",
|
||||||
|
|
@ -126,9 +125,9 @@
|
||||||
"overrides.fast-xml-parser@5.x.x": "address https://github.com/graphql-hive/console/security/dependabot/576",
|
"overrides.fast-xml-parser@5.x.x": "address https://github.com/graphql-hive/console/security/dependabot/576",
|
||||||
"overrides.minimatch@10.x.x": "address https://github.com/graphql-hive/console/security/dependabot/505",
|
"overrides.minimatch@10.x.x": "address https://github.com/graphql-hive/console/security/dependabot/505",
|
||||||
"overrides.qs@<6.14.2": "address https://github.com/graphql-hive/console/security/dependabot/499",
|
"overrides.qs@<6.14.2": "address https://github.com/graphql-hive/console/security/dependabot/499",
|
||||||
"overrides.dompurify@3.x.x": "address https://github.com/graphql-hive/console/security/dependabot/536",
|
|
||||||
"overrides.ajv@8.x.x": "address https://github.com/graphql-hive/console/security/dependabot/507",
|
"overrides.ajv@8.x.x": "address https://github.com/graphql-hive/console/security/dependabot/507",
|
||||||
"overrides.yauzl@2.x.x": "address https://github.com/graphql-hive/console/security/dependabot/542",
|
"overrides.yauzl@2.x.x": "address https://github.com/graphql-hive/console/security/dependabot/542",
|
||||||
|
"overrides.path-to-regexp@0.x.x": "address https://github.com/graphql-hive/console/security/dependabot/619",
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"esbuild": "0.25.9",
|
"esbuild": "0.25.9",
|
||||||
"csstype": "3.1.2",
|
"csstype": "3.1.2",
|
||||||
|
|
@ -161,10 +160,10 @@
|
||||||
"minimatch@3.x.x": "^3.1.3",
|
"minimatch@3.x.x": "^3.1.3",
|
||||||
"minimatch@4.x.x": "^4.2.4",
|
"minimatch@4.x.x": "^4.2.4",
|
||||||
"qs@<6.14.2": "^6.14.2",
|
"qs@<6.14.2": "^6.14.2",
|
||||||
"dompurify@3.x.x": "^3.3.2",
|
|
||||||
"ajv@8.x.x": "^8.18.0",
|
"ajv@8.x.x": "^8.18.0",
|
||||||
"yauzl@2.x.x": "^3.2.1",
|
"yauzl@2.x.x": "^3.2.1",
|
||||||
"glob@10.x.x": "^10.5.0"
|
"glob@10.x.x": "^10.5.0",
|
||||||
|
"path-to-regexp@0.x.x": "^0.1.13"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"mjml-core@4.14.0": "patches/mjml-core@4.14.0.patch",
|
"mjml-core@4.14.0": "patches/mjml-core@4.14.0.patch",
|
||||||
|
|
@ -173,7 +172,6 @@
|
||||||
"eslint@8.57.1": "patches/eslint@8.57.1.patch",
|
"eslint@8.57.1": "patches/eslint@8.57.1.patch",
|
||||||
"@graphql-eslint/eslint-plugin@3.20.1": "patches/@graphql-eslint__eslint-plugin@3.20.1.patch",
|
"@graphql-eslint/eslint-plugin@3.20.1": "patches/@graphql-eslint__eslint-plugin@3.20.1.patch",
|
||||||
"got@14.4.7": "patches/got@14.4.7.patch",
|
"got@14.4.7": "patches/got@14.4.7.patch",
|
||||||
"slonik@30.4.4": "patches/slonik@30.4.4.patch",
|
|
||||||
"@oclif/core@3.26.6": "patches/@oclif__core@3.26.6.patch",
|
"@oclif/core@3.26.6": "patches/@oclif__core@3.26.6.patch",
|
||||||
"oclif": "patches/oclif.patch",
|
"oclif": "patches/oclif.patch",
|
||||||
"graphiql": "patches/graphiql.patch",
|
"graphiql": "patches/graphiql.patch",
|
||||||
|
|
@ -182,7 +180,9 @@
|
||||||
"p-cancelable@4.0.1": "patches/p-cancelable@4.0.1.patch",
|
"p-cancelable@4.0.1": "patches/p-cancelable@4.0.1.patch",
|
||||||
"bentocache": "patches/bentocache.patch",
|
"bentocache": "patches/bentocache.patch",
|
||||||
"@graphql-codegen/schema-ast": "patches/@graphql-codegen__schema-ast.patch",
|
"@graphql-codegen/schema-ast": "patches/@graphql-codegen__schema-ast.patch",
|
||||||
"@fastify/vite": "patches/@fastify__vite.patch"
|
"@fastify/vite": "patches/@fastify__vite.patch",
|
||||||
|
"@slonik/pg-driver": "patches/@slonik__pg-driver.patch",
|
||||||
|
"slonik": "patches/slonik.patch"
|
||||||
},
|
},
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"msw"
|
"msw"
|
||||||
|
|
|
||||||
3
packages/internal/postgres/README.md
Normal file
3
packages/internal/postgres/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Internal Postgres Client
|
||||||
|
|
||||||
|
This is a lightweight abstraction on top of Slonik, that sets up some things for ease of usage.
|
||||||
19
packages/internal/postgres/package.json
Normal file
19
packages/internal/postgres/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "@hive/postgres",
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"private": true,
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "1.0.0",
|
||||||
|
"slonik": "48.13.2",
|
||||||
|
"slonik-interceptor-query-logging": "48.13.2",
|
||||||
|
"slonik-sql-tag-raw": "48.13.2",
|
||||||
|
"zod": "3.25.76"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@hive/service-common": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
export function createConnectionString(config: {
|
export type PostgresConnectionParamaters = {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
password: string | undefined;
|
password: string | undefined;
|
||||||
user: string;
|
user: string;
|
||||||
db: string;
|
db: string;
|
||||||
ssl: boolean;
|
ssl: boolean;
|
||||||
}) {
|
};
|
||||||
|
|
||||||
|
/** Create a Postgres Connection String */
|
||||||
|
export function createConnectionString(config: PostgresConnectionParamaters) {
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
const encodedUser = encodeURIComponent(config.user);
|
const encodedUser = encodeURIComponent(config.user);
|
||||||
const encodedPassword =
|
const encodedPassword =
|
||||||
17
packages/internal/postgres/src/index.ts
Normal file
17
packages/internal/postgres/src/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
export {
|
||||||
|
PostgresDatabasePool,
|
||||||
|
createPostgresDatabasePool,
|
||||||
|
type CommonQueryMethods,
|
||||||
|
} from './postgres-database-pool';
|
||||||
|
export { type PostgresConnectionParamaters, createConnectionString } from './connection-string';
|
||||||
|
export { psql, type TaggedTemplateLiteralInvocation } from './psql';
|
||||||
|
export {
|
||||||
|
UniqueIntegrityConstraintViolationError,
|
||||||
|
ForeignKeyIntegrityConstraintViolationError,
|
||||||
|
type PrimitiveValueExpression,
|
||||||
|
type SerializableValue,
|
||||||
|
type Interceptor,
|
||||||
|
type Query,
|
||||||
|
type QueryContext,
|
||||||
|
} from 'slonik';
|
||||||
|
export { toDate } from './utils';
|
||||||
91
packages/internal/postgres/src/pg-pool-bridge.ts
Normal file
91
packages/internal/postgres/src/pg-pool-bridge.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import type { PoolClient } from 'pg';
|
||||||
|
import { sql, type DatabasePool, type DatabasePoolConnection } from 'slonik';
|
||||||
|
import { raw } from 'slonik-sql-tag-raw';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge {slonik.DatabasePool} to an {pg.Pool} for usage with Postgraphile Workers.
|
||||||
|
*
|
||||||
|
* This is a very very pragmatic approach, since slonik moved away from using {pg.Pool} internally.
|
||||||
|
* https://github.com/gajus/slonik/issues/768
|
||||||
|
**/
|
||||||
|
export class PgPoolBridge {
|
||||||
|
constructor(private pool: DatabasePool) {}
|
||||||
|
|
||||||
|
end(): never {
|
||||||
|
throw new Error('Not implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<PoolClient> {
|
||||||
|
const pgClientAvailableP = Promise.withResolvers<any>();
|
||||||
|
const pgClientReleasedP = Promise.withResolvers<void>();
|
||||||
|
|
||||||
|
// slonik connect works in a way where the client is acquired for the callback handler closure only.
|
||||||
|
// It is released once the Promise returned from the handler resolves.
|
||||||
|
// We need to be a bit creative to support the "pg.Pool" API and obviously
|
||||||
|
// trust graphile-workers to call the `release` method on our fake {pg.Client} :)
|
||||||
|
// so the client is released back to the pool
|
||||||
|
void this.pool.connect(async client => {
|
||||||
|
pgClientAvailableP.resolve(new PgClientBridge(client, pgClientReleasedP.resolve));
|
||||||
|
await pgClientReleasedP.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
return pgClientAvailableP.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Some of graphile-workers logic just calls the `query` method on {pg.Pool} - without first acquiring a connection. */
|
||||||
|
query(query: unknown, values?: unknown, callback?: unknown): any {
|
||||||
|
// not used, but just in case so we can catch it in the future...
|
||||||
|
if (typeof callback !== 'undefined') {
|
||||||
|
throw new Error('PgClientBridge.query: callback not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((typeof query !== 'string' && typeof query !== 'object') || !query) {
|
||||||
|
throw new Error('PgClientBridge.query: unsupported query input');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof query === 'string') {
|
||||||
|
return this.pool.query(sql.unsafe`${raw(query, values as any)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.pool.query(sql.unsafe`${raw((query as any).text as any, (query as any).values)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
on(): this {
|
||||||
|
// Note: we can skip setting up event handlers, as graphile workers is only setting up error handlers to avoid uncaught exceptions
|
||||||
|
// For us, the error handlers are already set up by slonik
|
||||||
|
// https://github.com/graphile/worker/blob/5650fbc4406fa3ce197b2ab582e08fd20974e50c/src/lib.ts#L351-L359
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeListener(): this {
|
||||||
|
// Note: we can skip tearing down event handlers, since we ship setting them up in the first place
|
||||||
|
// https://github.com/graphile/worker/blob/5650fbc4406fa3ce197b2ab582e08fd20974e50c/src/lib.ts#L351-L359
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PgClientBridge {
|
||||||
|
constructor(
|
||||||
|
private connection: DatabasePoolConnection,
|
||||||
|
/** This is invoked for again releasing the connection. */
|
||||||
|
public release: () => void,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
query(query: unknown, values?: unknown, callback?: unknown): any {
|
||||||
|
if (typeof callback !== 'undefined') {
|
||||||
|
throw new Error('PgClientBridge.query: callback not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((typeof query !== 'string' && typeof query !== 'object') || !query) {
|
||||||
|
throw new Error('PgClientBridge.query: unsupported query input');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof query === 'string') {
|
||||||
|
return this.connection.query(sql.unsafe`${raw(query, values as any)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.connection.query(
|
||||||
|
sql.unsafe`${raw((query as any).text as any, (query as any).values)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
223
packages/internal/postgres/src/postgres-database-pool.ts
Normal file
223
packages/internal/postgres/src/postgres-database-pool.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import type { Pool } from 'pg';
|
||||||
|
import {
|
||||||
|
createPool,
|
||||||
|
createTypeParserPreset,
|
||||||
|
type DatabasePool,
|
||||||
|
type Interceptor,
|
||||||
|
type PrimitiveValueExpression,
|
||||||
|
type QuerySqlToken,
|
||||||
|
type CommonQueryMethods as SlonikCommonQueryMethods,
|
||||||
|
} from 'slonik';
|
||||||
|
import { createQueryLoggingInterceptor } from 'slonik-interceptor-query-logging';
|
||||||
|
import { context, SpanKind, SpanStatusCode, trace } from '@hive/service-common';
|
||||||
|
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
||||||
|
import { createConnectionString, type PostgresConnectionParamaters } from './connection-string';
|
||||||
|
import { PgPoolBridge } from './pg-pool-bridge';
|
||||||
|
|
||||||
|
const tracer = trace.getTracer('storage');
|
||||||
|
|
||||||
|
export interface CommonQueryMethods {
|
||||||
|
exists<T extends StandardSchemaV1>(
|
||||||
|
sql: QuerySqlToken<T>,
|
||||||
|
values?: PrimitiveValueExpression[],
|
||||||
|
): Promise<boolean>;
|
||||||
|
any<T extends StandardSchemaV1>(
|
||||||
|
sql: QuerySqlToken<T>,
|
||||||
|
values?: PrimitiveValueExpression[],
|
||||||
|
): Promise<ReadonlyArray<StandardSchemaV1.InferOutput<T>>>;
|
||||||
|
maybeOne<T extends StandardSchemaV1>(
|
||||||
|
sql: QuerySqlToken<T>,
|
||||||
|
values?: PrimitiveValueExpression[],
|
||||||
|
): Promise<null | StandardSchemaV1.InferOutput<T>>;
|
||||||
|
query(sql: QuerySqlToken<any>, values?: PrimitiveValueExpression[]): Promise<void>;
|
||||||
|
oneFirst<T extends StandardSchemaV1>(
|
||||||
|
sql: QuerySqlToken<T>,
|
||||||
|
values?: PrimitiveValueExpression[],
|
||||||
|
): Promise<StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]>;
|
||||||
|
one<T extends StandardSchemaV1>(
|
||||||
|
sql: QuerySqlToken<T>,
|
||||||
|
values?: PrimitiveValueExpression[],
|
||||||
|
): Promise<StandardSchemaV1.InferOutput<T>>;
|
||||||
|
anyFirst<T extends StandardSchemaV1>(
|
||||||
|
sql: QuerySqlToken<T>,
|
||||||
|
values?: PrimitiveValueExpression[],
|
||||||
|
): Promise<ReadonlyArray<StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]>>;
|
||||||
|
maybeOneFirst<T extends StandardSchemaV1>(
|
||||||
|
sql: QuerySqlToken<T>,
|
||||||
|
values?: PrimitiveValueExpression[],
|
||||||
|
): Promise<null | StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PostgresDatabasePool implements CommonQueryMethods {
|
||||||
|
constructor(private pool: DatabasePool) {}
|
||||||
|
|
||||||
|
getPgPoolCompat(): Pool {
|
||||||
|
return new PgPoolBridge(this.pool) as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrieve the raw Slonik instance. Refrain from using this API. */
|
||||||
|
getSlonikPool() {
|
||||||
|
return this.pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists<T extends StandardSchemaV1>(
|
||||||
|
sql: QuerySqlToken<T>,
|
||||||
|
values?: PrimitiveValueExpression[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.pool.exists(sql, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
async any<T extends StandardSchemaV1>(
|
||||||
|
sql: QuerySqlToken<T>,
|
||||||
|
values?: PrimitiveValueExpression[],
|
||||||
|
): Promise<ReadonlyArray<StandardSchemaV1.InferOutput<T>>> {
|
||||||
|
return this.pool.any(sql, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
async maybeOne<T extends StandardSchemaV1>(
|
||||||
|
sql: QuerySqlToken<T>,
|
||||||
|
values?: PrimitiveValueExpression[],
|
||||||
|
): Promise<null | StandardSchemaV1.InferOutput<T>> {
|
||||||
|
return this.pool.maybeOne(sql, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(sql: QuerySqlToken<any>, values?: PrimitiveValueExpression[]): Promise<void> {
|
||||||
|
await this.pool.query(sql, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
async oneFirst<T extends StandardSchemaV1>(
|
||||||
|
sql: QuerySqlToken<T>,
|
||||||
|
values?: PrimitiveValueExpression[],
|
||||||
|
): Promise<StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]> {
|
||||||
|
return await this.pool.oneFirst(sql, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
async maybeOneFirst<T extends StandardSchemaV1>(
|
||||||
|
sql: QuerySqlToken<T>,
|
||||||
|
values?: PrimitiveValueExpression[],
|
||||||
|
): Promise<null | StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]> {
|
||||||
|
return await this.pool.maybeOneFirst(sql, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
async one<T extends StandardSchemaV1>(
|
||||||
|
sql: QuerySqlToken<T>,
|
||||||
|
values?: PrimitiveValueExpression[],
|
||||||
|
): Promise<StandardSchemaV1.InferOutput<T>> {
|
||||||
|
return await this.pool.one(sql, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
async anyFirst<T extends StandardSchemaV1>(
|
||||||
|
sql: QuerySqlToken<T>,
|
||||||
|
values?: PrimitiveValueExpression[],
|
||||||
|
): Promise<
|
||||||
|
ReadonlyArray<StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]>
|
||||||
|
> {
|
||||||
|
return await this.pool.anyFirst(sql, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
async transaction<T = void>(
|
||||||
|
name: string,
|
||||||
|
handler: (methods: CommonQueryMethods) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const span = tracer.startSpan(`PG Transaction: ${name}`, {
|
||||||
|
kind: SpanKind.INTERNAL,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.with(trace.setSpan(context.active(), span), async () => {
|
||||||
|
return await this.pool.transaction(async methods => {
|
||||||
|
try {
|
||||||
|
return await handler({
|
||||||
|
exists: methods.exists,
|
||||||
|
any: methods.any,
|
||||||
|
maybeOne: methods.maybeOne,
|
||||||
|
async query(
|
||||||
|
sql: QuerySqlToken<any>,
|
||||||
|
values?: PrimitiveValueExpression[],
|
||||||
|
): Promise<void> {
|
||||||
|
await methods.query(sql, values);
|
||||||
|
},
|
||||||
|
oneFirst: methods.oneFirst,
|
||||||
|
maybeOneFirst: methods.maybeOneFirst,
|
||||||
|
anyFirst: methods.anyFirst,
|
||||||
|
one: methods.one,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
span.setAttribute('error', 'true');
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
span.setAttribute('error.type', err.name);
|
||||||
|
span.setAttribute('error.message', err.message);
|
||||||
|
span.setStatus({
|
||||||
|
code: SpanStatusCode.ERROR,
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
span.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
end(): Promise<void> {
|
||||||
|
return this.pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbInterceptors: Interceptor[] = [createQueryLoggingInterceptor()];
|
||||||
|
|
||||||
|
const typeParsers = [
|
||||||
|
...createTypeParserPreset().filter(parser => parser.name !== 'int8'),
|
||||||
|
{
|
||||||
|
name: 'int8',
|
||||||
|
parse: (value: string) => parseInt(value, 10),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function createPostgresDatabasePool(args: {
|
||||||
|
connectionParameters: PostgresConnectionParamaters | string;
|
||||||
|
maximumPoolSize?: number;
|
||||||
|
additionalInterceptors?: Interceptor[];
|
||||||
|
statementTimeout?: number;
|
||||||
|
}) {
|
||||||
|
const connectionString =
|
||||||
|
typeof args.connectionParameters === 'string'
|
||||||
|
? args.connectionParameters
|
||||||
|
: createConnectionString(args.connectionParameters);
|
||||||
|
const pool = await createPool(connectionString, {
|
||||||
|
interceptors: dbInterceptors.concat(args.additionalInterceptors ?? []),
|
||||||
|
typeParsers,
|
||||||
|
captureStackTrace: false,
|
||||||
|
maximumPoolSize: args.maximumPoolSize,
|
||||||
|
idleTimeout: 30000,
|
||||||
|
statementTimeout: args.statementTimeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
function interceptError<K extends Exclude<keyof SlonikCommonQueryMethods, 'transaction'>>(
|
||||||
|
methodName: K,
|
||||||
|
) {
|
||||||
|
const original: SlonikCommonQueryMethods[K] = pool[methodName];
|
||||||
|
|
||||||
|
function interceptor(
|
||||||
|
this: any,
|
||||||
|
sql: QuerySqlToken<any>,
|
||||||
|
values?: PrimitiveValueExpression[],
|
||||||
|
): any {
|
||||||
|
return (original as any).call(this, sql, values).catch((error: any) => {
|
||||||
|
error.sql = sql.sql;
|
||||||
|
error.values = sql.values || values;
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pool[methodName] = interceptor as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interceptError('one');
|
||||||
|
interceptError('many');
|
||||||
|
|
||||||
|
return new PostgresDatabasePool(pool);
|
||||||
|
}
|
||||||
23
packages/internal/postgres/src/psql.ts
Normal file
23
packages/internal/postgres/src/psql.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { createSqlTag, QuerySqlToken, type SqlTag, type ValueExpression } from 'slonik';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { StandardSchemaV1 } from '@standard-schema/spec';
|
||||||
|
|
||||||
|
const tag = createSqlTag();
|
||||||
|
|
||||||
|
interface TemplateStringsArray extends ReadonlyArray<string> {
|
||||||
|
readonly raw: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallableTag<
|
||||||
|
T extends StandardSchemaV1<unknown, unknown> = StandardSchemaV1<unknown, unknown>,
|
||||||
|
> = (template: TemplateStringsArray, ...values: ValueExpression[]) => QuerySqlToken<T>;
|
||||||
|
|
||||||
|
export type TaggedTemplateLiteralInvocation = QuerySqlToken<any>;
|
||||||
|
|
||||||
|
function psqlFn(template: TemplateStringsArray, ...values: ValueExpression[]) {
|
||||||
|
return tag.type(z.unknown())(template, ...values);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(psqlFn, createSqlTag());
|
||||||
|
|
||||||
|
export const psql = psqlFn as any as SqlTag<any> & CallableTag;
|
||||||
5
packages/internal/postgres/src/utils.ts
Normal file
5
packages/internal/postgres/src/utils.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { psql } from './psql';
|
||||||
|
|
||||||
|
export function toDate(date: Date) {
|
||||||
|
return psql`to_timestamp(${date.getTime() / 1000})`;
|
||||||
|
}
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
"@graphql-hive/logger": "^1.0.9"
|
"@graphql-hive/logger": "^1.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@apollo/server": "5.4.0",
|
"@apollo/server": "5.5.0",
|
||||||
"@as-integrations/express4": "1.1.2",
|
"@as-integrations/express4": "1.1.2",
|
||||||
"@graphql-tools/schema": "10.0.25",
|
"@graphql-tools/schema": "10.0.25",
|
||||||
"@types/express": "4.17.21",
|
"@types/express": "4.17.21",
|
||||||
|
|
@ -60,7 +60,7 @@
|
||||||
"graphql": "16.9.0",
|
"graphql": "16.9.0",
|
||||||
"graphql-ws": "5.16.1",
|
"graphql-ws": "5.16.1",
|
||||||
"nock": "14.0.10",
|
"nock": "14.0.10",
|
||||||
"vitest": "4.0.9",
|
"vitest": "4.1.3",
|
||||||
"ws": "8.18.0"
|
"ws": "8.18.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@
|
||||||
"@oclif/core": "3.26.6",
|
"@oclif/core": "3.26.6",
|
||||||
"@oclif/plugin-help": "6.2.36",
|
"@oclif/plugin-help": "6.2.36",
|
||||||
"@oclif/plugin-update": "4.7.16",
|
"@oclif/plugin-update": "4.7.16",
|
||||||
"@theguild/federation-composition": "0.22.1",
|
"@theguild/federation-composition": "0.22.2",
|
||||||
"cli-table3": "0.6.5",
|
"cli-table3": "0.6.5",
|
||||||
"colors": "1.4.0",
|
"colors": "1.4.0",
|
||||||
"env-ci": "7.3.0",
|
"env-ci": "7.3.0",
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
"graphql": "16.9.0",
|
"graphql": "16.9.0",
|
||||||
"nock": "14.0.10",
|
"nock": "14.0.10",
|
||||||
"tslib": "2.8.1",
|
"tslib": "2.8.1",
|
||||||
"vitest": "4.0.9"
|
"vitest": "4.1.3"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"registry": "https://registry.npmjs.org",
|
"registry": "https://registry.npmjs.org",
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,9 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@apollo/composition": "2.13.2",
|
"@apollo/composition": "2.13.2",
|
||||||
"@types/node": "24.10.9",
|
"@types/node": "24.12.2",
|
||||||
"esbuild": "0.25.9",
|
"esbuild": "0.25.9",
|
||||||
"fastify": "5.8.3",
|
"fastify": "5.8.5",
|
||||||
"graphql": "16.9.0"
|
"graphql": "16.9.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,39 @@
|
||||||
# @graphql-hive/laboratory
|
# @graphql-hive/laboratory
|
||||||
|
|
||||||
|
## 0.1.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#7989](https://github.com/graphql-hive/console/pull/7989)
|
||||||
|
[`863f920`](https://github.com/graphql-hive/console/commit/863f920b86505a3d84c9001fef1c3e8a723bdca9)
|
||||||
|
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - Enhanced behavior when no collection
|
||||||
|
exists and the user attempts to save an operation, along with the ability to edit the collection
|
||||||
|
name.
|
||||||
|
|
||||||
|
## 0.1.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#7963](https://github.com/graphql-hive/console/pull/7963)
|
||||||
|
[`4a8bd4f`](https://github.com/graphql-hive/console/commit/4a8bd4fd1b4fbb34076e97d06ed1341432de451d)
|
||||||
|
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - Implemented functionality that allows
|
||||||
|
to have multiple queries in same operation while working only with focused one (run button, query
|
||||||
|
builder)
|
||||||
|
|
||||||
|
- [#7892](https://github.com/graphql-hive/console/pull/7892)
|
||||||
|
[`fab4b03`](https://github.com/graphql-hive/console/commit/fab4b03ace2ff20759bbcd33465d00a5cbbc4c97)
|
||||||
|
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - Hive Laboratory renders Hive Router
|
||||||
|
query plan if included in response extensions
|
||||||
|
|
||||||
|
## 0.1.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#7888](https://github.com/graphql-hive/console/pull/7888)
|
||||||
|
[`574a5d8`](https://github.com/graphql-hive/console/commit/574a5d823e71ca1d0628897a73e2fab1d0d5bfe0)
|
||||||
|
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - If schema introspection isn't provided
|
||||||
|
as property to Laboratory, lab will start interval to fetch schema every second.
|
||||||
|
|
||||||
## 0.1.2
|
## 0.1.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,11 @@
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
"iconLibrary": "lucide",
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/laboratory/components",
|
"components": "src/components",
|
||||||
"utils": "@/laboratory/lib/utils",
|
"utils": "src/lib/utils",
|
||||||
"ui": "@/laboratory/components/ui",
|
"ui": "src/components/ui",
|
||||||
"lib": "@/laboratory/lib",
|
"lib": "src/lib",
|
||||||
"hooks": "@/laboratory/hooks"
|
"hooks": "src/hooks"
|
||||||
},
|
},
|
||||||
"registries": {}
|
"registries": {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@graphql-hive/laboratory",
|
"name": "@graphql-hive/laboratory",
|
||||||
"version": "0.1.2",
|
"version": "0.1.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "./dist/hive-laboratory.cjs.js",
|
"main": "./dist/hive-laboratory.cjs.js",
|
||||||
|
|
@ -29,20 +29,23 @@
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@tanstack/react-form": "^1.23.8",
|
"@tanstack/react-form": "^1.23.8",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"graphql-ws": "^6.0.6",
|
|
||||||
"lucide-react": "^0.548.0",
|
"lucide-react": "^0.548.0",
|
||||||
"lz-string": "^1.5.0",
|
"lz-string": "^1.5.0",
|
||||||
"react": "^18.0.0 || ^19.0.0",
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
"react-dom": "^18.0.0 || ^19.0.0",
|
"react-dom": "^18.0.0 || ^19.0.0",
|
||||||
|
"subscriptions-transport-ws": "^0.11.0",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.1.0",
|
||||||
|
"@graphql-tools/url-loader": "^9.1.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
"react-zoom-pan-pinch": "^3.7.0",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dagrejs/dagre": "^1.1.8",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
|
@ -76,7 +79,7 @@
|
||||||
"@tanstack/router-plugin": "^1.154.13",
|
"@tanstack/router-plugin": "^1.154.13",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/lodash": "^4.17.23",
|
"@types/lodash": "^4.17.23",
|
||||||
"@types/node": "24.10.9",
|
"@types/node": "24.12.2",
|
||||||
"@types/react": "18.3.18",
|
"@types/react": "18.3.18",
|
||||||
"@types/react-dom": "18.3.5",
|
"@types/react-dom": "18.3.5",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
|
|
@ -96,8 +99,7 @@
|
||||||
"eslint-plugin-react-refresh": "^0.4.26",
|
"eslint-plugin-react-refresh": "^0.4.26",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"graphql": "^16.12.0",
|
"graphql": "^16.12.0",
|
||||||
"graphql-ws": "^6.0.6",
|
"lodash": "^4.18.1",
|
||||||
"lodash": "^4.17.23",
|
|
||||||
"lucide-react": "^0.548.0",
|
"lucide-react": "^0.548.0",
|
||||||
"lz-string": "^1.5.0",
|
"lz-string": "^1.5.0",
|
||||||
"monaco-editor": "^0.52.2",
|
"monaco-editor": "^0.52.2",
|
||||||
|
|
@ -112,6 +114,7 @@
|
||||||
"react-shadow": "^20.6.0",
|
"react-shadow": "^20.6.0",
|
||||||
"rollup-plugin-typescript2": "^0.36.0",
|
"rollup-plugin-typescript2": "^0.36.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"subscriptions-transport-ws": "^0.11.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tailwindcss-scoped-preflight": "^3.5.7",
|
"tailwindcss-scoped-preflight": "^3.5.7",
|
||||||
|
|
|
||||||
519
packages/libraries/laboratory/src/components/flow.tsx
Normal file
519
packages/libraries/laboratory/src/components/flow.tsx
Normal file
|
|
@ -0,0 +1,519 @@
|
||||||
|
import { MouseEvent, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react';
|
||||||
|
import { LucideProps, MaximizeIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import dagre from '@dagrejs/dagre';
|
||||||
|
|
||||||
|
export interface FlowNode {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
next?: string[];
|
||||||
|
icon?: (props: LucideProps) => React.ReactNode;
|
||||||
|
content?: (props: { node: FlowNode }) => React.ReactNode;
|
||||||
|
headerSuffix?: (props: { node: FlowNode }) => React.ReactNode;
|
||||||
|
children?: FlowNode[];
|
||||||
|
maxWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowGraphInternal extends FlowNode {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Point = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function orthogonalPoints(from: Point, to: Point, t = 0.5): [Point, Point, Point, Point] {
|
||||||
|
const midX = from.x + (to.x - from.x) * t;
|
||||||
|
|
||||||
|
return [from, { x: midX, y: from.y }, { x: midX, y: to.y }, to];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roundedOrthogonalPath(
|
||||||
|
[p0, p1, p2, p3]: [Point, Point, Point, Point],
|
||||||
|
radius = 12,
|
||||||
|
): string {
|
||||||
|
const r1 = Math.min(radius, Math.abs(p1.x - p0.x), Math.abs(p2.y - p1.y));
|
||||||
|
const r2 = Math.min(radius, Math.abs(p2.y - p1.y), Math.abs(p3.x - p2.x));
|
||||||
|
|
||||||
|
const p1a = {
|
||||||
|
x: p1.x - Math.sign(p1.x - p0.x) * r1,
|
||||||
|
y: p1.y,
|
||||||
|
};
|
||||||
|
|
||||||
|
const p1b = {
|
||||||
|
x: p1.x,
|
||||||
|
y: p1.y + Math.sign(p2.y - p1.y) * r1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const p2a = {
|
||||||
|
x: p2.x,
|
||||||
|
y: p2.y - Math.sign(p2.y - p1.y) * r2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const p2b = {
|
||||||
|
x: p2.x + Math.sign(p3.x - p2.x) * r2,
|
||||||
|
y: p2.y,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
`M ${p0.x} ${p0.y}`,
|
||||||
|
`L ${p1a.x} ${p1a.y}`,
|
||||||
|
`Q ${p1.x} ${p1.y} ${p1b.x} ${p1b.y}`,
|
||||||
|
`L ${p2a.x} ${p2a.y}`,
|
||||||
|
`Q ${p2.x} ${p2.y} ${p2b.x} ${p2b.y}`,
|
||||||
|
`L ${p3.x} ${p3.y}`,
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_SCALE = 0.2;
|
||||||
|
const MAX_SCALE = 3;
|
||||||
|
const ZOOM_STEP = 0.02;
|
||||||
|
|
||||||
|
export const Flow = (props: {
|
||||||
|
nodes: FlowNode[];
|
||||||
|
margin?: number;
|
||||||
|
gapX?: number;
|
||||||
|
gapY?: number;
|
||||||
|
onGraphLayout?: (graph: dagre.graphlib.Graph) => void;
|
||||||
|
disableBackground?: boolean;
|
||||||
|
disableGestures?: boolean;
|
||||||
|
className?: string;
|
||||||
|
containerClassName?: string;
|
||||||
|
isChild?: boolean;
|
||||||
|
}) => {
|
||||||
|
const [isCanvasActive, setIsCanvasActive] = useState(false);
|
||||||
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const panStartRef = useRef<Point | null>(null);
|
||||||
|
const [view, setView] = useState<{ x: number; y: number; scale: number }>({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
});
|
||||||
|
const [nodeSizes, setNodeSizes] = useState<Record<string, { width: number; height: number }>>({});
|
||||||
|
const [nodes, edges, graphSize] = useMemo(() => {
|
||||||
|
if (Object.keys(nodeSizes).length === 0) {
|
||||||
|
return [
|
||||||
|
props.nodes.map(node => ({ ...node, x: 0, y: 0, width: 0, height: 0, isCluster: false })),
|
||||||
|
[],
|
||||||
|
{ width: 0, height: 0 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new dagre.graphlib.Graph({
|
||||||
|
compound: true,
|
||||||
|
})
|
||||||
|
.setGraph({
|
||||||
|
rankdir: 'LR',
|
||||||
|
align: 'UL',
|
||||||
|
ranksep: props.gapX ?? 48,
|
||||||
|
nodesep: props.gapY ?? 48,
|
||||||
|
marginx: props.margin ?? 32,
|
||||||
|
marginy: props.margin ?? 64,
|
||||||
|
graph: 'tight-tree',
|
||||||
|
})
|
||||||
|
.setDefaultEdgeLabel(() => ({}));
|
||||||
|
|
||||||
|
for (const node of props.nodes) {
|
||||||
|
result.setNode(node.id, {
|
||||||
|
width: nodeSizes[node.id]?.width,
|
||||||
|
height: nodeSizes[node.id]?.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of props.nodes) {
|
||||||
|
if (node.next) {
|
||||||
|
for (const next of node.next) {
|
||||||
|
result.setEdge(node.id, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dagre.layout(result);
|
||||||
|
|
||||||
|
props.onGraphLayout?.(result);
|
||||||
|
|
||||||
|
const graph = result.graph();
|
||||||
|
|
||||||
|
return [
|
||||||
|
props.nodes.map(node => {
|
||||||
|
const graphNode = result.node(node.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
x: graphNode?.x ?? 0,
|
||||||
|
y: graphNode?.y ?? 0,
|
||||||
|
width: graphNode?.width ?? 0,
|
||||||
|
height: graphNode?.height ?? 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
result.edges().map(edge => {
|
||||||
|
return {
|
||||||
|
from: edge.v,
|
||||||
|
to: edge.w,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
{ width: graph.width, height: graph.height },
|
||||||
|
];
|
||||||
|
}, [nodeSizes, props.nodes, props.margin, props.gapX]);
|
||||||
|
|
||||||
|
const handleWheel = useCallback(
|
||||||
|
(event: WheelEvent<HTMLDivElement>) => {
|
||||||
|
if (props.disableGestures) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
const bounds = event.currentTarget.getBoundingClientRect();
|
||||||
|
const pointerX = event.clientX - bounds.left;
|
||||||
|
const pointerY = event.clientY - bounds.top;
|
||||||
|
|
||||||
|
setView(prev => {
|
||||||
|
const zoomFactor = Math.exp(-event.deltaY * ZOOM_STEP);
|
||||||
|
const scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, prev.scale * zoomFactor));
|
||||||
|
const ratio = scale / prev.scale;
|
||||||
|
const x = pointerX - (pointerX - prev.x) * ratio;
|
||||||
|
const y = pointerY - (pointerY - prev.y) * ratio;
|
||||||
|
|
||||||
|
return { x, y, scale };
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[props.disableGestures],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopPanning = useCallback(() => {
|
||||||
|
setIsPanning(false);
|
||||||
|
panStartRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(event: MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!containerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.nativeEvent.composedPath().includes(containerRef.current)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.disableGestures) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
setIsPanning(true);
|
||||||
|
panStartRef.current = { x: event.clientX, y: event.clientY };
|
||||||
|
|
||||||
|
function handleMouseUp() {
|
||||||
|
stopPanning();
|
||||||
|
setIsCanvasActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[props.disableGestures],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback(
|
||||||
|
(event: MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (props.disableGestures || !isPanning || !panStartRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX = event.clientX - panStartRef.current.x;
|
||||||
|
const deltaY = event.clientY - panStartRef.current.y;
|
||||||
|
panStartRef.current = { x: event.clientX, y: event.clientY };
|
||||||
|
|
||||||
|
setView(prev => ({
|
||||||
|
...prev,
|
||||||
|
x: prev.x + deltaX,
|
||||||
|
y: prev.y + deltaY,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[isPanning, props.disableGestures],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fitInView = useCallback(() => {
|
||||||
|
const { width, height } = graphSize;
|
||||||
|
const container = containerRef.current;
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width: containerWidth, height: containerHeight } = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
container,
|
||||||
|
containerWidth,
|
||||||
|
containerHeight,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scale = Math.min(
|
||||||
|
MAX_SCALE,
|
||||||
|
Math.max(MIN_SCALE, Math.min(containerWidth / width, containerHeight / height)),
|
||||||
|
);
|
||||||
|
|
||||||
|
setView(prev => ({
|
||||||
|
...prev,
|
||||||
|
scale,
|
||||||
|
x: containerWidth / 2 - (width * scale) / 2,
|
||||||
|
y: containerHeight / 2 - (height * scale) / 2,
|
||||||
|
}));
|
||||||
|
}, [graphSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.disableGestures || !containerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = containerRef.current;
|
||||||
|
|
||||||
|
const preventNativeGesture = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener('gesturestart', preventNativeGesture, { passive: false });
|
||||||
|
element.addEventListener('gesturechange', preventNativeGesture, { passive: false });
|
||||||
|
element.addEventListener('gestureend', preventNativeGesture, { passive: false });
|
||||||
|
element.addEventListener('wheel', preventNativeGesture, { passive: false });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('gesturestart', preventNativeGesture);
|
||||||
|
element.removeEventListener('gesturechange', preventNativeGesture);
|
||||||
|
element.removeEventListener('gestureend', preventNativeGesture);
|
||||||
|
element.removeEventListener('wheel', preventNativeGesture);
|
||||||
|
};
|
||||||
|
}, [props.disableGestures]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.disableGestures) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preventBrowserZoomHotkeys = (event: KeyboardEvent) => {
|
||||||
|
if (!isCanvasActive || (!event.metaKey && !event.ctrlKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['+', '-', '=', '0'].includes(event.key)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', preventBrowserZoomHotkeys);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', preventBrowserZoomHotkeys);
|
||||||
|
};
|
||||||
|
}, [isCanvasActive, props.disableGestures]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn(
|
||||||
|
'bg-background relative h-full w-full touch-none',
|
||||||
|
{
|
||||||
|
'cursor-grab': !props.disableGestures && !isPanning,
|
||||||
|
'cursor-grabbing': !props.disableGestures && isPanning,
|
||||||
|
},
|
||||||
|
props.containerClassName,
|
||||||
|
)}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
stopPanning();
|
||||||
|
setIsCanvasActive(false);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setIsCanvasActive(true)}
|
||||||
|
>
|
||||||
|
{!props.disableBackground && (
|
||||||
|
<div className="bg-size-[16px_16px] absolute inset-0 h-full w-full bg-[radial-gradient(hsl(var(--border))_1px,transparent_1px)] opacity-50" />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn('relative', props.className)}
|
||||||
|
style={{
|
||||||
|
width: graphSize.width,
|
||||||
|
height: graphSize.height,
|
||||||
|
transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})`,
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nodes.map(node => {
|
||||||
|
const hasFollowers = !!node.next?.length;
|
||||||
|
const hasPrevious = nodes.some(n => n.next?.includes(node.id));
|
||||||
|
const content = node.content ? node.content({ node }) : null;
|
||||||
|
const hasContent = !!content;
|
||||||
|
const hasChildren = !!node.children?.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={node.id}
|
||||||
|
ref={ref => {
|
||||||
|
if (ref && !nodeSizes[node.id]) {
|
||||||
|
setNodeSizes(prev => ({
|
||||||
|
...prev,
|
||||||
|
[node.id]: { width: ref.clientWidth, height: ref.clientHeight },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'bg-card transition-color absolute flex grid min-w-72 grid-cols-1 grid-rows-1 justify-start gap-2 rounded-lg border p-2 text-sm shadow-sm',
|
||||||
|
{
|
||||||
|
'w-72': !hasChildren,
|
||||||
|
'grid-rows-[auto_1fr]': hasContent || hasChildren,
|
||||||
|
'grid-rows-[auto_auto_1fr]': hasContent && hasChildren,
|
||||||
|
'rounded-xl border-dashed bg-transparent shadow-none': hasChildren,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: node.x - node.width / 2,
|
||||||
|
top: node.y - node.height / 2,
|
||||||
|
minWidth: Math.min(Math.max(node.width, 256), node.maxWidth ?? Infinity),
|
||||||
|
minHeight: node.height,
|
||||||
|
maxWidth: node.maxWidth,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center gap-2">
|
||||||
|
{node.icon ? node.icon({ className: 'size-4 text-secondary-foreground' }) : null}
|
||||||
|
<span className="font-medium">{node.title}</span>
|
||||||
|
<div className="ml-auto">
|
||||||
|
{node.headerSuffix ? node.headerSuffix({ node }) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-secondary w-full rounded-sm p-2 empty:hidden">
|
||||||
|
{node.content ? node.content({ node }) : null}
|
||||||
|
</div>
|
||||||
|
{!!node.children?.length && (
|
||||||
|
<div className="size-full rounded-sm">
|
||||||
|
<Flow
|
||||||
|
nodes={node.children}
|
||||||
|
margin={0}
|
||||||
|
gapX={24}
|
||||||
|
gapY={16}
|
||||||
|
onGraphLayout={graph => {
|
||||||
|
const { width, height } = graph.graph();
|
||||||
|
|
||||||
|
setNodeSizes(prev => ({
|
||||||
|
...prev,
|
||||||
|
[node.id]: {
|
||||||
|
width: width + 20,
|
||||||
|
height: node.height + height + 4,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
disableBackground
|
||||||
|
disableGestures
|
||||||
|
className="bg-transparent"
|
||||||
|
isChild
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasFollowers && (
|
||||||
|
<div className="border-border bg-background absolute left-full top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 transition-all" />
|
||||||
|
)}
|
||||||
|
{hasPrevious && (
|
||||||
|
<div className="border-border bg-background absolute left-0 top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 transition-all" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none absolute left-0 top-0 -z-10"
|
||||||
|
style={{ width: graphSize.width, height: graphSize.height }}
|
||||||
|
>
|
||||||
|
{edges.filter(Boolean).map(edge => {
|
||||||
|
const fromNode = nodes.find(node => node.id === edge.from);
|
||||||
|
const toNode = nodes.find(node => node.id === edge.to);
|
||||||
|
|
||||||
|
if (!fromNode || !toNode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={edge.from + edge.to}
|
||||||
|
className="stroke-border animate-dash transition-color animate-[dash_500ms_linear_infinite] fill-none stroke-2 [stroke-dasharray:12_8]"
|
||||||
|
d={roundedOrthogonalPath(
|
||||||
|
orthogonalPoints(
|
||||||
|
{
|
||||||
|
x: fromNode.x + fromNode.width / 2,
|
||||||
|
y: fromNode.y,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: toNode.x - toNode.width / 2,
|
||||||
|
y: toNode.y,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
4,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{!props.isChild && (
|
||||||
|
<div className="absolute left-4 top-4 z-10 flex items-center gap-2">
|
||||||
|
<div className="bg-card grid w-96 grid-cols-[1fr_auto_auto] items-center gap-2 rounded-lg border p-2 shadow-sm">
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => setView(prev => ({ ...prev, scale: prev.scale - ZOOM_STEP }))}
|
||||||
|
>
|
||||||
|
<ZoomOutIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Zoom out</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Slider
|
||||||
|
value={[view.scale]}
|
||||||
|
onValueChange={value => setView(prev => ({ ...prev, scale: value[0] }))}
|
||||||
|
min={MIN_SCALE}
|
||||||
|
max={MAX_SCALE}
|
||||||
|
step={ZOOM_STEP}
|
||||||
|
/>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => setView(prev => ({ ...prev, scale: prev.scale + ZOOM_STEP }))}
|
||||||
|
>
|
||||||
|
<ZoomInIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Zoom in</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Separator orientation="vertical" className="h-6!" />
|
||||||
|
<Button variant="ghost" size="sm" onClick={fitInView}>
|
||||||
|
<MaximizeIcon className="size-4" />
|
||||||
|
Fit in view
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -21,7 +21,7 @@ import {
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
TextAlignStartIcon,
|
TextAlignStartIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ToggleGroup, ToggleGroupItem } from '@/laboratory/components/ui/toggle-group';
|
import { toast } from 'sonner';
|
||||||
import type { LaboratoryOperation } from '../../lib/operations';
|
import type { LaboratoryOperation } from '../../lib/operations';
|
||||||
import {
|
import {
|
||||||
getFieldByPath,
|
getFieldByPath,
|
||||||
|
|
@ -40,6 +40,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '..
|
||||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '../ui/input-group';
|
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '../ui/input-group';
|
||||||
import { ScrollArea, ScrollBar } from '../ui/scroll-area';
|
import { ScrollArea, ScrollBar } from '../ui/scroll-area';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||||
import { useLaboratory } from './context';
|
import { useLaboratory } from './context';
|
||||||
|
|
||||||
|
|
@ -48,6 +49,7 @@ export const BuilderArgument = (props: {
|
||||||
path: string[];
|
path: string[];
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
operation?: LaboratoryOperation | null;
|
operation?: LaboratoryOperation | null;
|
||||||
|
operationName?: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
schema,
|
schema,
|
||||||
|
|
@ -89,9 +91,18 @@ export const BuilderArgument = (props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checked) {
|
if (checked) {
|
||||||
addArgToActiveOperation(props.path.join('.'), props.field.name, schema);
|
addArgToActiveOperation(
|
||||||
|
props.path.join('.'),
|
||||||
|
props.field.name,
|
||||||
|
schema,
|
||||||
|
props.operationName,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
deleteArgFromActiveOperation(props.path.join('.'), props.field.name);
|
deleteArgFromActiveOperation(
|
||||||
|
props.path.join('.'),
|
||||||
|
props.field.name,
|
||||||
|
props.operationName,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -111,6 +122,7 @@ export const BuilderScalarField = (props: {
|
||||||
isSearchActive?: boolean;
|
isSearchActive?: boolean;
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
operation?: LaboratoryOperation | null;
|
operation?: LaboratoryOperation | null;
|
||||||
|
operationName?: string | null;
|
||||||
searchValue?: string;
|
searchValue?: string;
|
||||||
label?: React.ReactNode;
|
label?: React.ReactNode;
|
||||||
disableChildren?: boolean;
|
disableChildren?: boolean;
|
||||||
|
|
@ -140,16 +152,18 @@ export const BuilderScalarField = (props: {
|
||||||
);
|
);
|
||||||
|
|
||||||
const isInQuery = useMemo(() => {
|
const isInQuery = useMemo(() => {
|
||||||
return isPathInQuery(operation?.query ?? '', path);
|
return isPathInQuery(operation?.query ?? '', path, props.operationName);
|
||||||
}, [operation?.query, path]);
|
}, [operation?.query, path, props.operationName]);
|
||||||
|
|
||||||
const args = useMemo(() => {
|
const args = useMemo(() => {
|
||||||
return (props.field as GraphQLField<unknown, unknown, unknown>).args ?? [];
|
return (props.field as GraphQLField<unknown, unknown, unknown>).args ?? [];
|
||||||
}, [props.field]);
|
}, [props.field]);
|
||||||
|
|
||||||
const hasArgs = useMemo(() => {
|
const hasArgs = useMemo(() => {
|
||||||
return args.some(arg => isArgInQuery(operation?.query ?? '', path, arg.name));
|
return args.some(arg =>
|
||||||
}, [operation?.query, args, path]);
|
isArgInQuery(operation?.query ?? '', path, arg.name, props.operationName),
|
||||||
|
);
|
||||||
|
}, [operation?.query, args, path, props.operationName]);
|
||||||
|
|
||||||
const shouldHighlight = useMemo(() => {
|
const shouldHighlight = useMemo(() => {
|
||||||
const splittedName = splitIdentifier(props.field.name);
|
const splittedName = splitIdentifier(props.field.name);
|
||||||
|
|
@ -185,9 +199,9 @@ export const BuilderScalarField = (props: {
|
||||||
onCheckedChange={checked => {
|
onCheckedChange={checked => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
addPathToActiveOperation(path);
|
addPathToActiveOperation(path, props.operationName);
|
||||||
} else {
|
} else {
|
||||||
deletePathFromActiveOperation(path);
|
deletePathFromActiveOperation(path, props.operationName);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -237,9 +251,9 @@ export const BuilderScalarField = (props: {
|
||||||
onCheckedChange={checked => {
|
onCheckedChange={checked => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
addPathToActiveOperation(path);
|
addPathToActiveOperation(path, props.operationName);
|
||||||
} else {
|
} else {
|
||||||
deletePathFromActiveOperation(path);
|
deletePathFromActiveOperation(path, props.operationName);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -321,9 +335,9 @@ export const BuilderScalarField = (props: {
|
||||||
disabled={activeTab?.type !== 'operation'}
|
disabled={activeTab?.type !== 'operation'}
|
||||||
onCheckedChange={checked => {
|
onCheckedChange={checked => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
addPathToActiveOperation(props.path.join('.'));
|
addPathToActiveOperation(props.path.join('.'), props.operationName);
|
||||||
} else {
|
} else {
|
||||||
deletePathFromActiveOperation(props.path.join('.'));
|
deletePathFromActiveOperation(props.path.join('.'), props.operationName);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -352,6 +366,7 @@ export const BuilderObjectField = (props: {
|
||||||
isSearchActive?: boolean;
|
isSearchActive?: boolean;
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
operation?: LaboratoryOperation | null;
|
operation?: LaboratoryOperation | null;
|
||||||
|
operationName?: string | null;
|
||||||
searchValue?: string;
|
searchValue?: string;
|
||||||
label?: React.ReactNode;
|
label?: React.ReactNode;
|
||||||
disableChildren?: boolean;
|
disableChildren?: boolean;
|
||||||
|
|
@ -441,9 +456,9 @@ export const BuilderObjectField = (props: {
|
||||||
onCheckedChange={checked => {
|
onCheckedChange={checked => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
addPathToActiveOperation(path);
|
addPathToActiveOperation(path, props.operationName);
|
||||||
} else {
|
} else {
|
||||||
deletePathFromActiveOperation(path);
|
deletePathFromActiveOperation(path, props.operationName);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -492,9 +507,9 @@ export const BuilderObjectField = (props: {
|
||||||
onCheckedChange={checked => {
|
onCheckedChange={checked => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
addPathToActiveOperation(path);
|
addPathToActiveOperation(path, props.operationName);
|
||||||
} else {
|
} else {
|
||||||
deletePathFromActiveOperation(path);
|
deletePathFromActiveOperation(path, props.operationName);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -564,6 +579,7 @@ export const BuilderObjectField = (props: {
|
||||||
isSearchActive={props.isSearchActive}
|
isSearchActive={props.isSearchActive}
|
||||||
isReadOnly={props.isReadOnly}
|
isReadOnly={props.isReadOnly}
|
||||||
operation={operation}
|
operation={operation}
|
||||||
|
operationName={props.operationName}
|
||||||
searchValue={props.searchValue}
|
searchValue={props.searchValue}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -583,6 +599,7 @@ export const BuilderField = (props: {
|
||||||
forcedOpenPaths?: Set<string> | null;
|
forcedOpenPaths?: Set<string> | null;
|
||||||
isSearchActive?: boolean;
|
isSearchActive?: boolean;
|
||||||
operation?: LaboratoryOperation | null;
|
operation?: LaboratoryOperation | null;
|
||||||
|
operationName?: string | null;
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
searchValue?: string;
|
searchValue?: string;
|
||||||
label?: React.ReactNode;
|
label?: React.ReactNode;
|
||||||
|
|
@ -609,6 +626,7 @@ export const BuilderField = (props: {
|
||||||
isSearchActive={props.isSearchActive}
|
isSearchActive={props.isSearchActive}
|
||||||
isReadOnly={props.isReadOnly}
|
isReadOnly={props.isReadOnly}
|
||||||
operation={props.operation}
|
operation={props.operation}
|
||||||
|
operationName={props.operationName}
|
||||||
searchValue={props.searchValue}
|
searchValue={props.searchValue}
|
||||||
label={props.label}
|
label={props.label}
|
||||||
disableChildren={props.disableChildren}
|
disableChildren={props.disableChildren}
|
||||||
|
|
@ -627,6 +645,7 @@ export const BuilderField = (props: {
|
||||||
isSearchActive={props.isSearchActive}
|
isSearchActive={props.isSearchActive}
|
||||||
isReadOnly={props.isReadOnly}
|
isReadOnly={props.isReadOnly}
|
||||||
operation={props.operation}
|
operation={props.operation}
|
||||||
|
operationName={props.operationName}
|
||||||
searchValue={props.searchValue}
|
searchValue={props.searchValue}
|
||||||
label={props.label}
|
label={props.label}
|
||||||
disableChildren={props.disableChildren}
|
disableChildren={props.disableChildren}
|
||||||
|
|
@ -651,6 +670,7 @@ export const BuilderSearchResults = (props: {
|
||||||
mode: BuilderSearchResultMode;
|
mode: BuilderSearchResultMode;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
operation: LaboratoryOperation | null;
|
operation: LaboratoryOperation | null;
|
||||||
|
operationName?: string | null;
|
||||||
searchValue: string;
|
searchValue: string;
|
||||||
schema: GraphQLSchema;
|
schema: GraphQLSchema;
|
||||||
tab: OperationTypeNode;
|
tab: OperationTypeNode;
|
||||||
|
|
@ -675,6 +695,7 @@ export const BuilderSearchResults = (props: {
|
||||||
isSearchActive={props.isSearchActive}
|
isSearchActive={props.isSearchActive}
|
||||||
isReadOnly={props.isReadOnly}
|
isReadOnly={props.isReadOnly}
|
||||||
operation={props.operation}
|
operation={props.operation}
|
||||||
|
operationName={props.operationName}
|
||||||
searchValue={props.searchValue}
|
searchValue={props.searchValue}
|
||||||
disableChildren
|
disableChildren
|
||||||
label={
|
label={
|
||||||
|
|
@ -726,6 +747,7 @@ export const BuilderSearchResults = (props: {
|
||||||
isSearchActive={props.isSearchActive}
|
isSearchActive={props.isSearchActive}
|
||||||
isReadOnly={props.isReadOnly}
|
isReadOnly={props.isReadOnly}
|
||||||
operation={props.operation}
|
operation={props.operation}
|
||||||
|
operationName={props.operationName}
|
||||||
searchValue={props.searchValue}
|
searchValue={props.searchValue}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -734,6 +756,7 @@ export const BuilderSearchResults = (props: {
|
||||||
|
|
||||||
export const Builder = (props: {
|
export const Builder = (props: {
|
||||||
operation?: LaboratoryOperation | null;
|
operation?: LaboratoryOperation | null;
|
||||||
|
operationName?: string | null;
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { schema, activeOperation, endpoint, setEndpoint, defaultEndpoint } = useLaboratory();
|
const { schema, activeOperation, endpoint, setEndpoint, defaultEndpoint } = useLaboratory();
|
||||||
|
|
@ -791,8 +814,6 @@ export const Builder = (props: {
|
||||||
});
|
});
|
||||||
}, [schema, deferredSearchValue, isSearchActive, tabValue]);
|
}, [schema, deferredSearchValue, isSearchActive, tabValue]);
|
||||||
|
|
||||||
console.log(searchResult);
|
|
||||||
|
|
||||||
const visiblePaths = isSearchActive ? (searchResult?.visiblePaths ?? null) : null;
|
const visiblePaths = isSearchActive ? (searchResult?.visiblePaths ?? null) : null;
|
||||||
const forcedOpenPaths =
|
const forcedOpenPaths =
|
||||||
isSearchActive && deferredSearchValue.includes('.')
|
isSearchActive && deferredSearchValue.includes('.')
|
||||||
|
|
@ -818,6 +839,8 @@ export const Builder = (props: {
|
||||||
const restoreEndpoint = useCallback(() => {
|
const restoreEndpoint = useCallback(() => {
|
||||||
setEndpointValue(endpoint ?? '');
|
setEndpointValue(endpoint ?? '');
|
||||||
setEndpoint(defaultEndpoint ?? '');
|
setEndpoint(defaultEndpoint ?? '');
|
||||||
|
|
||||||
|
toast.success('Endpoint restored to default');
|
||||||
}, [defaultEndpoint, setEndpointValue]);
|
}, [defaultEndpoint, setEndpointValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -853,14 +876,18 @@ export const Builder = (props: {
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
{defaultEndpoint && (
|
{defaultEndpoint && (
|
||||||
<InputGroupAddon align="inline-end">
|
<InputGroupAddon align="inline-end">
|
||||||
<InputGroupButton className="rounded-full" size="icon-xs" onClick={restoreEndpoint}>
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger>
|
||||||
<TooltipTrigger>
|
<InputGroupButton
|
||||||
|
className="rounded-full"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={restoreEndpoint}
|
||||||
|
>
|
||||||
<RotateCcwIcon className="size-4" />
|
<RotateCcwIcon className="size-4" />
|
||||||
</TooltipTrigger>
|
</InputGroupButton>
|
||||||
<TooltipContent>Restore default endpoint</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent>Restore default endpoint</TooltipContent>
|
||||||
</InputGroupButton>
|
</Tooltip>
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
)}
|
)}
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
|
|
@ -975,6 +1002,7 @@ export const Builder = (props: {
|
||||||
isSearchActive={isSearchActive}
|
isSearchActive={isSearchActive}
|
||||||
isReadOnly={props.isReadOnly}
|
isReadOnly={props.isReadOnly}
|
||||||
operation={operation}
|
operation={operation}
|
||||||
|
operationName={props.operationName}
|
||||||
searchValue={deferredSearchValue}
|
searchValue={deferredSearchValue}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|
@ -1011,6 +1039,7 @@ export const Builder = (props: {
|
||||||
isSearchActive={isSearchActive}
|
isSearchActive={isSearchActive}
|
||||||
isReadOnly={props.isReadOnly}
|
isReadOnly={props.isReadOnly}
|
||||||
operation={operation}
|
operation={operation}
|
||||||
|
operationName={props.operationName}
|
||||||
searchValue={deferredSearchValue}
|
searchValue={deferredSearchValue}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|
@ -1047,6 +1076,7 @@ export const Builder = (props: {
|
||||||
isSearchActive={isSearchActive}
|
isSearchActive={isSearchActive}
|
||||||
isReadOnly={props.isReadOnly}
|
isReadOnly={props.isReadOnly}
|
||||||
operation={operation}
|
operation={operation}
|
||||||
|
operationName={props.operationName}
|
||||||
searchValue={deferredSearchValue}
|
searchValue={deferredSearchValue}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
CheckIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
FolderOpenIcon,
|
FolderOpenIcon,
|
||||||
FolderPlusIcon,
|
FolderPlusIcon,
|
||||||
|
PencilIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupInput,
|
||||||
|
} from '@/components/ui/input-group';
|
||||||
import { TooltipTrigger } from '@radix-ui/react-tooltip';
|
import { TooltipTrigger } from '@radix-ui/react-tooltip';
|
||||||
import type { LaboratoryCollection, LaboratoryCollectionOperation } from '../../lib/collections';
|
import type { LaboratoryCollection, LaboratoryCollectionOperation } from '../../lib/collections';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
|
|
@ -44,6 +52,7 @@ export const CollectionItem = (props: { collection: LaboratoryCollection }) => {
|
||||||
addOperation,
|
addOperation,
|
||||||
setActiveOperation,
|
setActiveOperation,
|
||||||
deleteCollection,
|
deleteCollection,
|
||||||
|
updateCollection,
|
||||||
deleteOperationFromCollection,
|
deleteOperationFromCollection,
|
||||||
addTab,
|
addTab,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
|
|
@ -51,67 +60,155 @@ export const CollectionItem = (props: { collection: LaboratoryCollection }) => {
|
||||||
} = useLaboratory();
|
} = useLaboratory();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editedName, setEditedName] = useState(props.collection.name);
|
||||||
|
|
||||||
|
const hasActiveOperation = useMemo(() => {
|
||||||
|
return props.collection.operations.some(operation => operation.id === activeOperation?.id);
|
||||||
|
}, [props.collection.operations, activeOperation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasActiveOperation) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}, [hasActiveOperation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button
|
{isEditing ? (
|
||||||
variant="ghost"
|
<InputGroup className="!bg-accent/50 h-8 border-none">
|
||||||
className="bg-background group sticky top-0 w-full justify-start px-2"
|
<InputGroupAddon className="pl-2.5">
|
||||||
size="sm"
|
{isOpen ? (
|
||||||
>
|
<FolderOpenIcon className="text-muted-foreground size-4" />
|
||||||
{isOpen ? (
|
) : (
|
||||||
<FolderOpenIcon className="text-muted-foreground size-4" />
|
<FolderIcon className="text-muted-foreground size-4" />
|
||||||
) : (
|
)}
|
||||||
<FolderIcon className="text-muted-foreground size-4" />
|
</InputGroupAddon>
|
||||||
)}
|
<InputGroupInput
|
||||||
{props.collection.name}
|
autoFocus
|
||||||
{checkPermissions?.('collections:delete') && (
|
defaultValue={editedName}
|
||||||
<Tooltip>
|
className="!pl-1.5 font-medium"
|
||||||
<TooltipTrigger asChild>
|
onChange={e => setEditedName(e.target.value)}
|
||||||
<AlertDialog>
|
onKeyDown={e => {
|
||||||
<AlertDialogTrigger asChild>
|
if (e.key === 'Enter') {
|
||||||
|
updateCollection(props.collection.id, {
|
||||||
|
name: editedName,
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setEditedName(props.collection.name);
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
<InputGroupButton
|
||||||
|
className="p-1!"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
updateCollection(props.collection.id, {
|
||||||
|
name: editedName,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsEditing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
</InputGroupButton>
|
||||||
|
<InputGroupButton
|
||||||
|
className="p-1!"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditedName(props.collection.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</InputGroupButton>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="bg-background !hover:bg-accent/50 group sticky top-0 w-full justify-start px-2"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<FolderOpenIcon className="text-muted-foreground size-4" />
|
||||||
|
) : (
|
||||||
|
<FolderIcon className="text-muted-foreground size-4" />
|
||||||
|
)}
|
||||||
|
{props.collection.name}
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{checkPermissions?.('collections:update') && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
className="text-muted-foreground hover:text-destructive p-1! pr-0! ml-auto opacity-0 transition-opacity group-hover:opacity-100"
|
className="text-muted-foreground p-1! pr-0! opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
setIsEditing(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TrashIcon />
|
<PencilIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</TooltipTrigger>
|
||||||
<AlertDialogContent>
|
<TooltipContent>Edit collection</TooltipContent>
|
||||||
<AlertDialogHeader>
|
</Tooltip>
|
||||||
<AlertDialogTitle>
|
)}
|
||||||
Are you sure you want to delete collection?
|
{checkPermissions?.('collections:delete') && (
|
||||||
</AlertDialogTitle>
|
<Tooltip>
|
||||||
<AlertDialogDescription>
|
<TooltipTrigger>
|
||||||
{props.collection.name} will be permanently deleted. All operations in this
|
<AlertDialog>
|
||||||
collection will be deleted as well.
|
<AlertDialogTrigger asChild>
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction asChild>
|
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="link"
|
||||||
|
className="text-muted-foreground hover:text-destructive p-1! pr-0! opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
deleteCollection(props.collection.id);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
<TrashIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogAction>
|
</AlertDialogTrigger>
|
||||||
</AlertDialogFooter>
|
<AlertDialogContent>
|
||||||
</AlertDialogContent>
|
<AlertDialogHeader>
|
||||||
</AlertDialog>
|
<AlertDialogTitle>
|
||||||
</TooltipTrigger>
|
Are you sure you want to delete collection?
|
||||||
<TooltipContent>Delete collection</TooltipContent>
|
</AlertDialogTitle>
|
||||||
</Tooltip>
|
<AlertDialogDescription>
|
||||||
)}
|
{props.collection.name} will be permanently deleted. All operations in
|
||||||
</Button>
|
this collection will be deleted as well.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction asChild>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteCollection(props.collection.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Delete collection</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className={cn('border-border ml-4 flex flex-col gap-1 border-l pl-2')}>
|
<CollapsibleContent className={cn('border-border ml-4 flex flex-col gap-1 border-l pl-2')}>
|
||||||
{isOpen &&
|
{isOpen &&
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,16 @@
|
||||||
import { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState } from 'react';
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useImperativeHandle,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { OperationDefinitionNode, parse } from 'graphql';
|
||||||
import * as monaco from 'monaco-editor';
|
import * as monaco from 'monaco-editor';
|
||||||
|
import { MonacoGraphQLAPI } from 'monaco-graphql/esm/api.js';
|
||||||
import { initializeMode } from 'monaco-graphql/initializeMode';
|
import { initializeMode } from 'monaco-graphql/initializeMode';
|
||||||
import MonacoEditor, { loader } from '@monaco-editor/react';
|
import MonacoEditor, { loader } from '@monaco-editor/react';
|
||||||
import { useLaboratory } from './context';
|
import { useLaboratory } from './context';
|
||||||
|
|
@ -121,46 +132,62 @@ export type EditorProps = React.ComponentProps<typeof MonacoEditor> & {
|
||||||
uri?: monaco.Uri;
|
uri?: monaco.Uri;
|
||||||
variablesUri?: monaco.Uri;
|
variablesUri?: monaco.Uri;
|
||||||
extraLibs?: string[];
|
extraLibs?: string[];
|
||||||
|
onOperationNameChange?: (operationName: string | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
|
const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||||
const { introspection, endpoint, theme } = useLaboratory();
|
const { introspection, endpoint, theme } = useLaboratory();
|
||||||
|
const wantsJson = props.language === 'json' || props.defaultLanguage === 'json';
|
||||||
const [typescriptReady, setTypescriptReady] = useState(!!monaco.languages.typescript);
|
const [typescriptReady, setTypescriptReady] = useState(!!monaco.languages.typescript);
|
||||||
|
const [jsonReady, setJsonReady] = useState(
|
||||||
|
monaco.languages.getLanguages().some(language => language.id === 'json'),
|
||||||
|
);
|
||||||
|
const apiRef = useRef<MonacoGraphQLAPI | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (introspection) {
|
if (introspection) {
|
||||||
const api = initializeMode({
|
if (apiRef.current) {
|
||||||
schemas: [
|
apiRef.current.setSchemaConfig([
|
||||||
{
|
{
|
||||||
introspectionJSON: introspection,
|
introspectionJSON: introspection,
|
||||||
uri: `schema_${endpoint}.graphql`,
|
uri: `schema_${endpoint}.graphql`,
|
||||||
},
|
},
|
||||||
],
|
]);
|
||||||
diagnosticSettings:
|
} else {
|
||||||
props.uri && props.variablesUri
|
apiRef.current = initializeMode({
|
||||||
? {
|
schemas: [
|
||||||
validateVariablesJSON: {
|
{
|
||||||
[props.uri.toString()]: [props.variablesUri.toString()],
|
introspectionJSON: introspection,
|
||||||
},
|
uri: `schema_${endpoint}.graphql`,
|
||||||
jsonDiagnosticSettings: {
|
},
|
||||||
allowComments: true, // allow json, parse with a jsonc parser to make requests
|
],
|
||||||
},
|
diagnosticSettings:
|
||||||
}
|
props.uri && props.variablesUri
|
||||||
: undefined,
|
? {
|
||||||
});
|
validateVariablesJSON: {
|
||||||
|
[props.uri.toString()]: [props.variablesUri.toString()],
|
||||||
|
},
|
||||||
|
jsonDiagnosticSettings: {
|
||||||
|
allowComments: true, // allow json, parse with a jsonc parser to make requests
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
api.setCompletionSettings({
|
apiRef.current.setCompletionSettings({
|
||||||
__experimental__fillLeafsOnComplete: true,
|
__experimental__fillLeafsOnComplete: true,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [introspection, props.uri?.toString(), props.variablesUri?.toString()]);
|
}, [endpoint, introspection, props.uri?.toString(), props.variablesUri?.toString()]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async function () {
|
void (async function () {
|
||||||
if (!props.extraLibs?.length) {
|
if (wantsJson && !jsonReady) {
|
||||||
return;
|
await import('monaco-editor/esm/vs/language/json/monaco.contribution');
|
||||||
|
setJsonReady(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!monaco.languages.typescript) {
|
if (!monaco.languages.typescript) {
|
||||||
|
|
@ -168,6 +195,10 @@ const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
|
||||||
setTypescriptReady(true);
|
setTypescriptReady(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!props.extraLibs?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const ts = monaco.languages.typescript;
|
const ts = monaco.languages.typescript;
|
||||||
|
|
||||||
if (!ts) {
|
if (!ts) {
|
||||||
|
|
@ -197,7 +228,7 @@ const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
}, [id, props.extraLibs]);
|
}, [id, jsonReady, props.extraLibs, wantsJson]);
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
|
|
@ -211,19 +242,124 @@ const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setupDecorationsHandler = useCallback(
|
||||||
|
(editor: monaco.editor.IStandaloneCodeEditor) => {
|
||||||
|
let decorationsCollection: monaco.editor.IEditorDecorationsCollection | null = null;
|
||||||
|
|
||||||
|
const handler = () => {
|
||||||
|
decorationsCollection?.clear();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const value = editor.getValue();
|
||||||
|
const doc = parse(value);
|
||||||
|
|
||||||
|
const definition = doc.definitions.find(definition => {
|
||||||
|
if (definition.kind !== 'OperationDefinition') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!definition.loc) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursorPosition = editor.getPosition();
|
||||||
|
|
||||||
|
if (cursorPosition) {
|
||||||
|
return (
|
||||||
|
definition.loc.startToken.line <= cursorPosition.lineNumber &&
|
||||||
|
definition.loc.endToken.line >= cursorPosition.lineNumber
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (definition?.loc) {
|
||||||
|
const decorations: monaco.editor.IModelDeltaDecoration[] = [];
|
||||||
|
|
||||||
|
if (definition.loc.startToken.line > 1) {
|
||||||
|
decorations.push({
|
||||||
|
range: new monaco.Range(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
definition.loc.startToken.line - 1,
|
||||||
|
definition.loc.startToken.column,
|
||||||
|
),
|
||||||
|
options: {
|
||||||
|
isWholeLine: true,
|
||||||
|
inlineClassName: 'inactive-line',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineCount = editor.getModel()?.getLineCount() ?? 0;
|
||||||
|
const lastLineMaxColumn = editor.getModel()?.getLineMaxColumn(lineCount) ?? 0;
|
||||||
|
|
||||||
|
if (definition.loc.endToken.line < lineCount) {
|
||||||
|
decorations.push({
|
||||||
|
range: new monaco.Range(
|
||||||
|
definition.loc.endToken.line + 1,
|
||||||
|
definition.loc.endToken.column,
|
||||||
|
lineCount,
|
||||||
|
lastLineMaxColumn,
|
||||||
|
),
|
||||||
|
options: {
|
||||||
|
isWholeLine: true,
|
||||||
|
inlineClassName: 'inactive-line',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
decorationsCollection = editor.createDecorationsCollection(decorations);
|
||||||
|
|
||||||
|
props.onOperationNameChange?.(
|
||||||
|
(definition as OperationDefinitionNode).name?.value ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.onDidChangeCursorPosition(handler);
|
||||||
|
|
||||||
|
handler();
|
||||||
|
},
|
||||||
|
[props.onOperationNameChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMount = useCallback(
|
||||||
|
(editor: monaco.editor.IStandaloneCodeEditor) => {
|
||||||
|
editorRef.current = editor;
|
||||||
|
setupDecorationsHandler(editor);
|
||||||
|
},
|
||||||
|
[setupDecorationsHandler],
|
||||||
|
);
|
||||||
|
|
||||||
|
const recentCursorPosition = useRef<{ lineNumber: number; column: number } | null>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
recentCursorPosition.current = editorRef.current?.getPosition() ?? null;
|
||||||
|
}, [props.value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editorRef.current && recentCursorPosition.current) {
|
||||||
|
editorRef.current.setPosition(recentCursorPosition.current);
|
||||||
|
recentCursorPosition.current = null;
|
||||||
|
}
|
||||||
|
}, [props.value]);
|
||||||
|
|
||||||
if (!typescriptReady && props.language === 'typescript') {
|
if (!typescriptReady && props.language === 'typescript') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!jsonReady && wantsJson) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="size-full overflow-hidden">
|
<div className="size-full overflow-hidden">
|
||||||
<MonacoEditor
|
<MonacoEditor
|
||||||
className="size-full"
|
className="size-full"
|
||||||
{...props}
|
{...props}
|
||||||
theme={theme === 'dark' ? 'hive-laboratory-dark' : 'hive-laboratory-light'}
|
theme={theme === 'dark' ? 'hive-laboratory-dark' : 'hive-laboratory-light'}
|
||||||
onMount={editor => {
|
onMount={handleMount}
|
||||||
editorRef.current = editor;
|
|
||||||
}}
|
|
||||||
loading={null}
|
loading={null}
|
||||||
options={{
|
options={{
|
||||||
...props.options,
|
...props.options,
|
||||||
|
|
|
||||||
|
|
@ -433,7 +433,7 @@ const LaboratoryContent = () => {
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Tabs />
|
<Tabs />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-card flex-1 overflow-hidden">{contentNode}</div>
|
<div className="bg-card relative flex-1 overflow-hidden">{contentNode}</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -514,7 +514,10 @@ export const Laboratory = (
|
||||||
const pluginsApi = usePlugins(props);
|
const pluginsApi = usePlugins(props);
|
||||||
const testsApi = useTests(props);
|
const testsApi = useTests(props);
|
||||||
const tabsApi = useTabs(props);
|
const tabsApi = useTabs(props);
|
||||||
const endpointApi = useEndpoint(props);
|
const endpointApi = useEndpoint({
|
||||||
|
...props,
|
||||||
|
settingsApi,
|
||||||
|
});
|
||||||
const collectionsApi = useCollections({
|
const collectionsApi = useCollections({
|
||||||
...props,
|
...props,
|
||||||
tabsApi,
|
tabsApi,
|
||||||
|
|
@ -623,7 +626,7 @@ export const Laboratory = (
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -642,160 +645,9 @@ export const Laboratory = (
|
||||||
className={cn('hive-laboratory bg-background size-full', props.theme, {
|
className={cn('hive-laboratory bg-background size-full', props.theme, {
|
||||||
'fixed inset-0 z-50': isFullScreen,
|
'fixed inset-0 z-50': isFullScreen,
|
||||||
})}
|
})}
|
||||||
ref={containerRef}
|
ref={setContainer}
|
||||||
>
|
>
|
||||||
<Toaster richColors closeButton position="top-right" theme={props.theme} />
|
<Toaster richColors closeButton position="top-right" theme={props.theme} />
|
||||||
<Dialog open={isUpdateEndpointDialogOpen} onOpenChange={setIsUpdateEndpointDialogOpen}>
|
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Update endpoint</DialogTitle>
|
|
||||||
<DialogDescription>Update the endpoint of your laboratory.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<form
|
|
||||||
id="update-endpoint-form"
|
|
||||||
onSubmit={e => {
|
|
||||||
e.preventDefault();
|
|
||||||
void updateEndpointForm.handleSubmit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FieldGroup>
|
|
||||||
<updateEndpointForm.Field name="endpoint">
|
|
||||||
{field => {
|
|
||||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
id={field.name}
|
|
||||||
name={field.name}
|
|
||||||
value={field.state.value}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
onChange={e => field.handleChange(e.target.value)}
|
|
||||||
aria-invalid={isInvalid}
|
|
||||||
placeholder="Enter endpoint"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</updateEndpointForm.Field>
|
|
||||||
</FieldGroup>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button type="submit" form="update-endpoint-form">
|
|
||||||
Update endpoint
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
<PreflightPromptModal
|
|
||||||
open={isPreflightPromptModalOpen}
|
|
||||||
onOpenChange={setIsPreflightPromptModalOpen}
|
|
||||||
{...preflightPromptModalProps}
|
|
||||||
/>
|
|
||||||
<Dialog open={isAddCollectionDialogOpen} onOpenChange={setIsAddCollectionDialogOpen}>
|
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add collection</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Add a new collection of operations to your laboratory.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<form
|
|
||||||
id="add-collection-form"
|
|
||||||
onSubmit={e => {
|
|
||||||
e.preventDefault();
|
|
||||||
void addCollectionForm.handleSubmit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FieldGroup>
|
|
||||||
<addCollectionForm.Field name="name">
|
|
||||||
{field => {
|
|
||||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
|
||||||
return (
|
|
||||||
<Field data-invalid={isInvalid}>
|
|
||||||
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id={field.name}
|
|
||||||
name={field.name}
|
|
||||||
value={field.state.value}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
onChange={e => field.handleChange(e.target.value)}
|
|
||||||
aria-invalid={isInvalid}
|
|
||||||
placeholder="Enter name of the collection"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</addCollectionForm.Field>
|
|
||||||
</FieldGroup>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button type="submit" form="add-collection-form">
|
|
||||||
Add collection
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
<Dialog open={isAddTestDialogOpen} onOpenChange={setIsAddTestDialogOpen}>
|
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add test</DialogTitle>
|
|
||||||
<DialogDescription>Add a new test to your laboratory.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<form
|
|
||||||
id="add-test-form"
|
|
||||||
onSubmit={e => {
|
|
||||||
e.preventDefault();
|
|
||||||
void addTestForm.handleSubmit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FieldGroup>
|
|
||||||
<addTestForm.Field name="name">
|
|
||||||
{field => {
|
|
||||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
|
||||||
return (
|
|
||||||
<Field data-invalid={isInvalid}>
|
|
||||||
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id={field.name}
|
|
||||||
name={field.name}
|
|
||||||
value={field.state.value}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
onChange={e => field.handleChange(e.target.value)}
|
|
||||||
aria-invalid={isInvalid}
|
|
||||||
placeholder="Enter name of the test"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</addTestForm.Field>
|
|
||||||
</FieldGroup>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button type="submit" form="add-test-form">
|
|
||||||
Add test
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<LaboratoryProvider
|
<LaboratoryProvider
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -809,7 +661,7 @@ export const Laboratory = (
|
||||||
{...collectionsApi}
|
{...collectionsApi}
|
||||||
{...operationsApi}
|
{...operationsApi}
|
||||||
{...historyApi}
|
{...historyApi}
|
||||||
container={containerRef.current}
|
container={container}
|
||||||
openAddCollectionDialog={openAddCollectionDialog}
|
openAddCollectionDialog={openAddCollectionDialog}
|
||||||
openUpdateEndpointDialog={openUpdateEndpointDialog}
|
openUpdateEndpointDialog={openUpdateEndpointDialog}
|
||||||
openAddTestDialog={openAddTestDialog}
|
openAddTestDialog={openAddTestDialog}
|
||||||
|
|
@ -819,6 +671,157 @@ export const Laboratory = (
|
||||||
isFullScreen={isFullScreen}
|
isFullScreen={isFullScreen}
|
||||||
checkPermissions={checkPermissions}
|
checkPermissions={checkPermissions}
|
||||||
>
|
>
|
||||||
|
<Dialog open={isUpdateEndpointDialogOpen} onOpenChange={setIsUpdateEndpointDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Update endpoint</DialogTitle>
|
||||||
|
<DialogDescription>Update the endpoint of your laboratory.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<form
|
||||||
|
id="update-endpoint-form"
|
||||||
|
onSubmit={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
void updateEndpointForm.handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<updateEndpointForm.Field name="endpoint">
|
||||||
|
{field => {
|
||||||
|
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
id={field.name}
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={e => field.handleChange(e.target.value)}
|
||||||
|
aria-invalid={isInvalid}
|
||||||
|
placeholder="Enter endpoint"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</updateEndpointForm.Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button type="submit" form="update-endpoint-form">
|
||||||
|
Update endpoint
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<PreflightPromptModal
|
||||||
|
open={isPreflightPromptModalOpen}
|
||||||
|
onOpenChange={setIsPreflightPromptModalOpen}
|
||||||
|
{...preflightPromptModalProps}
|
||||||
|
/>
|
||||||
|
<Dialog open={isAddCollectionDialogOpen} onOpenChange={setIsAddCollectionDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add collection</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add a new collection of operations to your laboratory.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<form
|
||||||
|
id="add-collection-form"
|
||||||
|
onSubmit={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
void addCollectionForm.handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<addCollectionForm.Field name="name">
|
||||||
|
{field => {
|
||||||
|
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||||
|
return (
|
||||||
|
<Field data-invalid={isInvalid}>
|
||||||
|
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={field.name}
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={e => field.handleChange(e.target.value)}
|
||||||
|
aria-invalid={isInvalid}
|
||||||
|
placeholder="Enter name of the collection"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</addCollectionForm.Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button type="submit" form="add-collection-form">
|
||||||
|
Add collection
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<Dialog open={isAddTestDialogOpen} onOpenChange={setIsAddTestDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add test</DialogTitle>
|
||||||
|
<DialogDescription>Add a new test to your laboratory.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<form
|
||||||
|
id="add-test-form"
|
||||||
|
onSubmit={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
void addTestForm.handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<addTestForm.Field name="name">
|
||||||
|
{field => {
|
||||||
|
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||||
|
return (
|
||||||
|
<Field data-invalid={isInvalid}>
|
||||||
|
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={field.name}
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={e => field.handleChange(e.target.value)}
|
||||||
|
aria-invalid={isInvalid}
|
||||||
|
placeholder="Enter name of the test"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</addTestForm.Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button type="submit" form="add-test-form">
|
||||||
|
Add test
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
<LaboratoryContent />
|
<LaboratoryContent />
|
||||||
</LaboratoryProvider>
|
</LaboratoryProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
AlignLeftIcon,
|
||||||
BookmarkIcon,
|
BookmarkIcon,
|
||||||
CircleCheckIcon,
|
CircleCheckIcon,
|
||||||
CircleXIcon,
|
CircleXIcon,
|
||||||
|
|
@ -7,6 +8,9 @@ import {
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
HistoryIcon,
|
HistoryIcon,
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
|
NetworkIcon,
|
||||||
|
PanelLeftCloseIcon,
|
||||||
|
PanelLeftOpenIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
PowerIcon,
|
PowerIcon,
|
||||||
PowerOffIcon,
|
PowerOffIcon,
|
||||||
|
|
@ -16,6 +20,9 @@ import { compressToEncodedURIComponent } from 'lz-string';
|
||||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { QueryPlanSchema } from '@/lib/query-plan/schema';
|
||||||
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';
|
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';
|
||||||
import { useForm } from '@tanstack/react-form';
|
import { useForm } from '@tanstack/react-form';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -24,6 +31,7 @@ import type {
|
||||||
LaboratoryHistorySubscription,
|
LaboratoryHistorySubscription,
|
||||||
} from '../../lib/history';
|
} from '../../lib/history';
|
||||||
import type { LaboratoryOperation } from '../../lib/operations';
|
import type { LaboratoryOperation } from '../../lib/operations';
|
||||||
|
import { QueryPlanTree, renderQueryPlan } from '../../lib/query-plan/utils';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { Badge } from '../ui/badge';
|
import { Badge } from '../ui/badge';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
|
|
@ -38,13 +46,14 @@ import {
|
||||||
} from '../ui/dialog';
|
} from '../ui/dialog';
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from '../ui/dropdown-menu';
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from '../ui/dropdown-menu';
|
||||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../ui/empty';
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../ui/empty';
|
||||||
import { Field, FieldGroup, FieldLabel } from '../ui/field';
|
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field';
|
||||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../ui/resizable';
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../ui/resizable';
|
||||||
import { ScrollArea, ScrollBar } from '../ui/scroll-area';
|
import { ScrollArea, ScrollBar } from '../ui/scroll-area';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||||
import { Spinner } from '../ui/spinner';
|
import { Spinner } from '../ui/spinner';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
||||||
import { Toggle } from '../ui/toggle';
|
import { Toggle } from '../ui/toggle';
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
|
||||||
import { Builder } from './builder';
|
import { Builder } from './builder';
|
||||||
import { useLaboratory } from './context';
|
import { useLaboratory } from './context';
|
||||||
import { Editor } from './editor';
|
import { Editor } from './editor';
|
||||||
|
|
@ -86,6 +95,7 @@ const Headers = (props: { operation?: LaboratoryOperation | null; isReadOnly?: b
|
||||||
<Editor
|
<Editor
|
||||||
uri={monaco.Uri.file('headers.json')}
|
uri={monaco.Uri.file('headers.json')}
|
||||||
value={operation?.headers ?? ''}
|
value={operation?.headers ?? ''}
|
||||||
|
language="json"
|
||||||
onChange={value => {
|
onChange={value => {
|
||||||
updateActiveOperation({
|
updateActiveOperation({
|
||||||
headers: value ?? '',
|
headers: value ?? '',
|
||||||
|
|
@ -109,6 +119,7 @@ const Extensions = (props: { operation?: LaboratoryOperation | null; isReadOnly?
|
||||||
<Editor
|
<Editor
|
||||||
uri={monaco.Uri.file('extensions.json')}
|
uri={monaco.Uri.file('extensions.json')}
|
||||||
value={operation?.extensions ?? ''}
|
value={operation?.extensions ?? ''}
|
||||||
|
language="json"
|
||||||
onChange={value => {
|
onChange={value => {
|
||||||
updateActiveOperation({
|
updateActiveOperation({
|
||||||
extensions: value ?? '',
|
extensions: value ?? '',
|
||||||
|
|
@ -178,6 +189,56 @@ export const ResponsePreflight = ({ historyItem }: { historyItem?: LaboratoryHis
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
export const ResponseQueryPlan = ({ historyItem }: { historyItem?: LaboratoryHistory | null }) => {
|
||||||
|
const [mode, setMode] = useState<'text' | 'visual'>('text');
|
||||||
|
|
||||||
|
const queryPlan = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const queryPlan =
|
||||||
|
JSON.parse((historyItem as LaboratoryHistoryRequest)?.response ?? '{}').extensions
|
||||||
|
?.queryPlan ?? {};
|
||||||
|
|
||||||
|
if (!queryPlan) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return QueryPlanSchema.safeParse(queryPlan).success ? queryPlan : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [historyItem]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative size-full">
|
||||||
|
<ToggleGroup
|
||||||
|
className="bg-card absolute right-4 top-4 z-10 shadow-sm"
|
||||||
|
type="single"
|
||||||
|
variant="outline"
|
||||||
|
value={mode}
|
||||||
|
onValueChange={value => setMode(value as 'text' | 'visual')}
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value="text">
|
||||||
|
<AlignLeftIcon className="size-4" />
|
||||||
|
Text
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="visual">
|
||||||
|
<NetworkIcon className="size-4" />
|
||||||
|
Visual
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
{mode === 'visual' ? (
|
||||||
|
<QueryPlanTree key={historyItem?.id} plan={queryPlan} />
|
||||||
|
) : (
|
||||||
|
<Editor
|
||||||
|
value={renderQueryPlan(queryPlan)}
|
||||||
|
defaultLanguage="graphql"
|
||||||
|
theme="hive-laboratory"
|
||||||
|
options={{ readOnly: true }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const ResponseSubscription = ({
|
export const ResponseSubscription = ({
|
||||||
historyItem,
|
historyItem,
|
||||||
|
|
@ -245,6 +306,8 @@ export const ResponseSubscription = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryRequest | null }) => {
|
export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryRequest | null }) => {
|
||||||
|
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||||
|
|
||||||
const isError = useMemo(() => {
|
const isError = useMemo(() => {
|
||||||
if (!historyItem) {
|
if (!historyItem) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -261,12 +324,65 @@ export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryReque
|
||||||
);
|
);
|
||||||
}, [historyItem]);
|
}, [historyItem]);
|
||||||
|
|
||||||
|
const hasValidQueryPlan = useMemo(() => {
|
||||||
|
if (!historyItem) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryPlan = JSON.parse(historyItem.response).extensions?.queryPlan;
|
||||||
|
|
||||||
|
if (!queryPlan) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return QueryPlanSchema.safeParse(queryPlan).success;
|
||||||
|
}, [historyItem?.response]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="response" className="grid size-full grid-rows-[auto_1fr]">
|
<Tabs
|
||||||
<TabsList className="h-[49.5px] w-full justify-start rounded-none border-b bg-transparent p-3">
|
defaultValue="response"
|
||||||
|
className={cn('bg-card grid size-full grid-rows-[auto_1fr]', {
|
||||||
|
'z-100 absolute inset-0 size-full': isFullScreen,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<TabsList className="h-[50px] w-full items-center justify-start rounded-none border-b bg-transparent p-3">
|
||||||
|
{isFullScreen ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="mr-2 mt-0.5 h-6 w-6"
|
||||||
|
onClick={() => setIsFullScreen(false)}
|
||||||
|
>
|
||||||
|
<PanelLeftOpenIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Minimize panel</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="mr-2 mt-0.5 h-6 w-6"
|
||||||
|
onClick={() => setIsFullScreen(true)}
|
||||||
|
>
|
||||||
|
<PanelLeftCloseIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Maximize panel</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<TabsTrigger value="response" className="grow-0 rounded-sm">
|
<TabsTrigger value="response" className="grow-0 rounded-sm">
|
||||||
Response
|
Response
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
{hasValidQueryPlan && (
|
||||||
|
<TabsTrigger value="query-plan" className="grow-0 rounded-sm">
|
||||||
|
Query Plan
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
<TabsTrigger value="headers" className="grow-0 rounded-sm">
|
<TabsTrigger value="headers" className="grow-0 rounded-sm">
|
||||||
Headers
|
Headers
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
@ -277,7 +393,7 @@ export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryReque
|
||||||
)}
|
)}
|
||||||
{historyItem ? (
|
{historyItem ? (
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{historyItem?.status && (
|
{!!historyItem?.status && (
|
||||||
<Badge
|
<Badge
|
||||||
className={cn('bg-green-400/10 text-green-500', {
|
className={cn('bg-green-400/10 text-green-500', {
|
||||||
'bg-red-400/10 text-red-500': isError,
|
'bg-red-400/10 text-red-500': isError,
|
||||||
|
|
@ -315,6 +431,9 @@ export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryReque
|
||||||
<TabsContent value="response" className="overflow-hidden">
|
<TabsContent value="response" className="overflow-hidden">
|
||||||
<ResponseBody historyItem={historyItem} />
|
<ResponseBody historyItem={historyItem} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="query-plan" className="overflow-hidden">
|
||||||
|
<ResponseQueryPlan historyItem={historyItem} />
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="headers" className="overflow-hidden">
|
<TabsContent value="headers" className="overflow-hidden">
|
||||||
<ResponseHeaders historyItem={historyItem} />
|
<ResponseHeaders historyItem={historyItem} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
@ -332,6 +451,7 @@ const saveToCollectionFormSchema = z.object({
|
||||||
export const Query = (props: {
|
export const Query = (props: {
|
||||||
onAfterOperationRun?: (historyItem: LaboratoryHistory | null) => void;
|
onAfterOperationRun?: (historyItem: LaboratoryHistory | null) => void;
|
||||||
operation?: LaboratoryOperation | null;
|
operation?: LaboratoryOperation | null;
|
||||||
|
onOperationNameChange?: (operationName: string | null) => void;
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
|
|
@ -342,6 +462,7 @@ export const Query = (props: {
|
||||||
updateActiveOperation,
|
updateActiveOperation,
|
||||||
collections,
|
collections,
|
||||||
addOperationToCollection,
|
addOperationToCollection,
|
||||||
|
addCollection,
|
||||||
addHistory,
|
addHistory,
|
||||||
stopActiveOperation,
|
stopActiveOperation,
|
||||||
addResponseToHistory,
|
addResponseToHistory,
|
||||||
|
|
@ -358,6 +479,8 @@ export const Query = (props: {
|
||||||
setPluginsState,
|
setPluginsState,
|
||||||
} = useLaboratory();
|
} = useLaboratory();
|
||||||
|
|
||||||
|
const [operationName, setOperationName] = useState<string | null>(null);
|
||||||
|
|
||||||
const operation = useMemo(() => {
|
const operation = useMemo(() => {
|
||||||
return props.operation ?? activeOperation ?? null;
|
return props.operation ?? activeOperation ?? null;
|
||||||
}, [props.operation, activeOperation]);
|
}, [props.operation, activeOperation]);
|
||||||
|
|
@ -401,6 +524,7 @@ export const Query = (props: {
|
||||||
void runActiveOperation(endpoint, {
|
void runActiveOperation(endpoint, {
|
||||||
env: result?.env,
|
env: result?.env,
|
||||||
headers: result?.headers,
|
headers: result?.headers,
|
||||||
|
operationName: operationName ?? undefined,
|
||||||
onResponse: data => {
|
onResponse: data => {
|
||||||
addResponseToHistory(newItemHistory.id, data);
|
addResponseToHistory(newItemHistory.id, data);
|
||||||
},
|
},
|
||||||
|
|
@ -413,22 +537,38 @@ export const Query = (props: {
|
||||||
const response = await runActiveOperation(endpoint, {
|
const response = await runActiveOperation(endpoint, {
|
||||||
env: result?.env,
|
env: result?.env,
|
||||||
headers: result?.headers,
|
headers: result?.headers,
|
||||||
|
operationName: operationName ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response) {
|
if (!response) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = response.status;
|
const extensionsResponse = (response.extensions?.response as {
|
||||||
|
status: number;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
}) ?? {
|
||||||
|
status: 0,
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
delete response.extensions?.request;
|
||||||
|
delete response.extensions?.response;
|
||||||
|
|
||||||
|
if (Object.keys(response.extensions ?? {}).length === 0) {
|
||||||
|
delete response.extensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = extensionsResponse.status;
|
||||||
const duration = performance.now() - startTime;
|
const duration = performance.now() - startTime;
|
||||||
const responseText = await response.text();
|
const responseText = JSON.stringify(response, null, 2);
|
||||||
const size = responseText.length;
|
const size = responseText.length;
|
||||||
|
|
||||||
const newItemHistory = addHistory({
|
const newItemHistory = addHistory({
|
||||||
status,
|
status,
|
||||||
duration,
|
duration,
|
||||||
size,
|
size,
|
||||||
headers: JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2),
|
headers: JSON.stringify(extensionsResponse.headers, null, 2),
|
||||||
operation,
|
operation,
|
||||||
preflightLogs: result?.logs ?? [],
|
preflightLogs: result?.logs ?? [],
|
||||||
response: responseText,
|
response: responseText,
|
||||||
|
|
@ -439,6 +579,7 @@ export const Query = (props: {
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
operation,
|
operation,
|
||||||
|
operationName,
|
||||||
endpoint,
|
endpoint,
|
||||||
isActiveOperationSubscription,
|
isActiveOperationSubscription,
|
||||||
addHistory,
|
addHistory,
|
||||||
|
|
@ -477,15 +618,34 @@ export const Query = (props: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
addOperationToCollection(value.collectionId, {
|
const collection = collections.find(c => c.id === value.collectionId);
|
||||||
id: operation.id ?? '',
|
|
||||||
name: operation.name ?? '',
|
if (!collection) {
|
||||||
query: operation.query ?? '',
|
addCollection({
|
||||||
variables: operation.variables ?? '',
|
name: value.collectionId,
|
||||||
headers: operation.headers ?? '',
|
operations: [
|
||||||
extensions: operation.extensions ?? '',
|
{
|
||||||
description: '',
|
id: operation.id ?? '',
|
||||||
});
|
name: operation.name ?? '',
|
||||||
|
query: operation.query ?? '',
|
||||||
|
variables: operation.variables ?? '',
|
||||||
|
headers: operation.headers ?? '',
|
||||||
|
extensions: operation.extensions ?? '',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addOperationToCollection(value.collectionId, {
|
||||||
|
id: operation.id ?? '',
|
||||||
|
name: operation.name ?? '',
|
||||||
|
query: operation.query ?? '',
|
||||||
|
variables: operation.variables ?? '',
|
||||||
|
headers: operation.headers ?? '',
|
||||||
|
extensions: operation.extensions ?? '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaveToCollectionDialogOpen(false);
|
setIsSaveToCollectionDialogOpen(false);
|
||||||
},
|
},
|
||||||
|
|
@ -529,10 +689,8 @@ export const Query = (props: {
|
||||||
<Dialog open={isSaveToCollectionDialogOpen} onOpenChange={setIsSaveToCollectionDialogOpen}>
|
<Dialog open={isSaveToCollectionDialogOpen} onOpenChange={setIsSaveToCollectionDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add collection</DialogTitle>
|
<DialogTitle>Save operation to collection</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>Save the current operation to a collection.</DialogDescription>
|
||||||
Add a new collection of operations to your laboratory.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<form
|
<form
|
||||||
|
|
@ -543,33 +701,58 @@ export const Query = (props: {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<saveToCollectionForm.Field name="collectionId">
|
{collections.length > 0 ? (
|
||||||
{field => {
|
<saveToCollectionForm.Field name="collectionId">
|
||||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
{field => {
|
||||||
|
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Field data-invalid={isInvalid}>
|
<Field data-invalid={isInvalid}>
|
||||||
<FieldLabel htmlFor={field.name}>Collection</FieldLabel>
|
<FieldLabel htmlFor={field.name}>Collection</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
name={field.name}
|
name={field.name}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onValueChange={field.handleChange}
|
onValueChange={field.handleChange}
|
||||||
>
|
>
|
||||||
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
|
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
|
||||||
<SelectValue placeholder="Select collection" />
|
<SelectValue placeholder="Select collection" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{collections.map(c => (
|
{collections.map(c => (
|
||||||
<SelectItem key={c.id} value={c.id}>
|
<SelectItem key={c.id} value={c.id}>
|
||||||
{c.name}
|
{c.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</saveToCollectionForm.Field>
|
</saveToCollectionForm.Field>
|
||||||
|
) : (
|
||||||
|
<saveToCollectionForm.Field name="collectionId">
|
||||||
|
{field => {
|
||||||
|
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field data-invalid={isInvalid}>
|
||||||
|
<FieldLabel htmlFor={field.name}>New collection name</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={field.name}
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={e => field.handleChange(e.target.value)}
|
||||||
|
aria-invalid={isInvalid}
|
||||||
|
placeholder="Enter name of the collection"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</saveToCollectionForm.Field>
|
||||||
|
)}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -700,6 +883,10 @@ export const Query = (props: {
|
||||||
query: value ?? '',
|
query: value ?? '',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onOperationNameChange={operationName => {
|
||||||
|
setOperationName(operationName);
|
||||||
|
props.onOperationNameChange?.(operationName);
|
||||||
|
}}
|
||||||
language="graphql"
|
language="graphql"
|
||||||
theme="hive-laboratory"
|
theme="hive-laboratory"
|
||||||
options={{
|
options={{
|
||||||
|
|
@ -715,6 +902,7 @@ export const Operation = (props: {
|
||||||
historyItem?: LaboratoryHistory;
|
historyItem?: LaboratoryHistory;
|
||||||
}) => {
|
}) => {
|
||||||
const { activeOperation, history } = useLaboratory();
|
const { activeOperation, history } = useLaboratory();
|
||||||
|
const [operationName, setOperationName] = useState<string | null>(null);
|
||||||
|
|
||||||
const operation = useMemo(() => {
|
const operation = useMemo(() => {
|
||||||
return props.operation ?? activeOperation ?? null;
|
return props.operation ?? activeOperation ?? null;
|
||||||
|
|
@ -735,16 +923,20 @@ export const Operation = (props: {
|
||||||
}, [props.historyItem]);
|
}, [props.historyItem]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card size-full">
|
<div className="bg-card relative size-full">
|
||||||
<ResizablePanelGroup direction="horizontal" className="size-full">
|
<ResizablePanelGroup direction="horizontal" className="size-full">
|
||||||
<ResizablePanel defaultSize={25}>
|
<ResizablePanel defaultSize={25}>
|
||||||
<Builder operation={operation} isReadOnly={isReadOnly} />
|
<Builder operation={operation} operationName={operationName} isReadOnly={isReadOnly} />
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle />
|
<ResizableHandle />
|
||||||
<ResizablePanel minSize={10} defaultSize={40}>
|
<ResizablePanel minSize={10} defaultSize={40}>
|
||||||
<ResizablePanelGroup direction="vertical">
|
<ResizablePanelGroup direction="vertical">
|
||||||
<ResizablePanel defaultSize={70}>
|
<ResizablePanel defaultSize={70}>
|
||||||
<Query operation={operation} isReadOnly={isReadOnly} />
|
<Query
|
||||||
|
operation={operation}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
onOperationNameChange={setOperationName}
|
||||||
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle />
|
<ResizableHandle />
|
||||||
<ResizablePanel minSize={10} defaultSize={30}>
|
<ResizablePanel minSize={10} defaultSize={30}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { useForm } from '@tanstack/react-form';
|
import { useForm } from '@tanstack/react-form';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
||||||
import { Field, FieldGroup, FieldLabel } from '../ui/field';
|
import { Field, FieldGroup, FieldLabel } from '../ui/field';
|
||||||
|
|
@ -8,6 +10,16 @@ import { useLaboratory } from './context';
|
||||||
const settingsFormSchema = z.object({
|
const settingsFormSchema = z.object({
|
||||||
fetch: z.object({
|
fetch: z.object({
|
||||||
credentials: z.enum(['include', 'omit', 'same-origin']),
|
credentials: z.enum(['include', 'omit', 'same-origin']),
|
||||||
|
timeout: z.number().optional(),
|
||||||
|
retry: z.number().optional(),
|
||||||
|
useGETForQueries: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
subscriptions: z.object({
|
||||||
|
protocol: z.enum(['SSE', 'GRAPHQL_SSE', 'WS', 'LEGACY_WS']),
|
||||||
|
}),
|
||||||
|
introspection: z.object({
|
||||||
|
method: z.enum(['GET', 'POST']).optional(),
|
||||||
|
schemaDescription: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -25,12 +37,12 @@ export const Settings = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card size-full p-3">
|
<div className="bg-card size-full overflow-y-auto p-3">
|
||||||
<form
|
<form
|
||||||
id="settings-form"
|
id="settings-form"
|
||||||
onSubmit={form.handleSubmit}
|
onSubmit={form.handleSubmit}
|
||||||
onChange={form.handleSubmit}
|
onChange={form.handleSubmit}
|
||||||
className="mx-auto max-w-2xl"
|
className="mx-auto flex max-w-2xl flex-col gap-4"
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -66,6 +78,148 @@ export const Settings = () => {
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
<form.Field name="fetch.timeout">
|
||||||
|
{field => {
|
||||||
|
return (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={field.name}>Timeout</FieldLabel>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value ?? ''}
|
||||||
|
onChange={e =>
|
||||||
|
field.handleChange(
|
||||||
|
e.target.value === '' ? undefined : Number(e.target.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
<form.Field name="fetch.retry">
|
||||||
|
{field => {
|
||||||
|
return (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={field.name}>Retry</FieldLabel>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value ?? ''}
|
||||||
|
onChange={e =>
|
||||||
|
field.handleChange(
|
||||||
|
e.target.value === '' ? undefined : Number(e.target.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
<form.Field name="fetch.useGETForQueries">
|
||||||
|
{field => {
|
||||||
|
return (
|
||||||
|
<Field className="flex-row items-center">
|
||||||
|
<Switch
|
||||||
|
className="!w-8"
|
||||||
|
checked={field.state.value ?? false}
|
||||||
|
onCheckedChange={field.handleChange}
|
||||||
|
/>
|
||||||
|
<FieldLabel htmlFor={field.name}>Use GET for queries</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Subscriptions</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure the subscriptions options for the laboratory.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FieldGroup>
|
||||||
|
<form.Field name="subscriptions.protocol">
|
||||||
|
{field => {
|
||||||
|
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field data-invalid={isInvalid}>
|
||||||
|
<FieldLabel htmlFor={field.name}>Protocol</FieldLabel>
|
||||||
|
<Select
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onValueChange={value =>
|
||||||
|
field.handleChange(value as 'SSE' | 'GRAPHQL_SSE' | 'WS' | 'LEGACY_WS')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
|
||||||
|
<SelectValue placeholder="Select protocol" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="SSE">SSE</SelectItem>
|
||||||
|
<SelectItem value="GRAPHQL_SSE">GRAPHQL_SSE</SelectItem>
|
||||||
|
<SelectItem value="WS">WS</SelectItem>
|
||||||
|
<SelectItem value="LEGACY_WS">LEGACY_WS</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Introspection</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure the introspection options for the laboratory.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FieldGroup>
|
||||||
|
<form.Field name="introspection.method">
|
||||||
|
{field => {
|
||||||
|
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={field.name}>Method</FieldLabel>
|
||||||
|
<Select
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onValueChange={value => field.handleChange(value as 'GET' | 'POST')}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
|
||||||
|
<SelectValue placeholder="Select method" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="GET">GET</SelectItem>
|
||||||
|
<SelectItem value="POST">POST</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
<form.Field name="introspection.schemaDescription">
|
||||||
|
{field => {
|
||||||
|
return (
|
||||||
|
<Field className="flex-row items-center">
|
||||||
|
<Switch
|
||||||
|
className="!w-8"
|
||||||
|
checked={field.state.value ?? false}
|
||||||
|
onCheckedChange={field.handleChange}
|
||||||
|
/>
|
||||||
|
<FieldLabel htmlFor={field.name}>Schema description</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
|
import { useLaboratory } from '../laboratory/context';
|
||||||
import { buttonVariants } from './button';
|
import { buttonVariants } from './button';
|
||||||
|
|
||||||
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
|
@ -13,7 +14,11 @@ function AlertDialogTrigger({
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
const { container } = useLaboratory();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} container={container} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogOverlay({
|
function AlertDialogOverlay({
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ const buttonVariants = cva(
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
destructive:
|
destructive:
|
||||||
'!text-white hover:bg-destructive/90 focus-visible:ring-destructive/40 bg-destructive/60',
|
'!text-white !hover:bg-destructive/90 !focus-visible:ring-destructive/40 !bg-destructive/60',
|
||||||
outline:
|
outline:
|
||||||
'border shadow-sm hover:text-accent-foreground bg-input/30 border-input hover:bg-input/50',
|
'border shadow-sm hover:text-accent-foreground bg-input/30 border-input hover:bg-input/50',
|
||||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { XIcon } from 'lucide-react';
|
import { XIcon } from 'lucide-react';
|
||||||
|
import { useLaboratory } from '@/components/laboratory/context';
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
|
@ -12,14 +12,9 @@ function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
const { container } = useLaboratory();
|
||||||
|
|
||||||
return (
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} container={container} />;
|
||||||
<>
|
|
||||||
<DialogPrimitive.Portal data-slot="dialog-portal" {...props} container={container} />
|
|
||||||
<div ref={setContainer} style={{ display: 'contents' }} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
|
@ -34,7 +29,7 @@ function DialogOverlay({
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 z-150 fixed inset-0 bg-black/50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -56,7 +51,7 @@ function DialogContent({
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 z-150 fixed left-[50%] top-[50%] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ function SelectContent({
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border shadow-md',
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-200 relative max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border shadow-md',
|
||||||
position === 'popper' &&
|
position === 'popper' &&
|
||||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
className,
|
className,
|
||||||
|
|
|
||||||
55
packages/libraries/laboratory/src/components/ui/slider.tsx
Normal file
55
packages/libraries/laboratory/src/components/ui/slider.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Slider as SliderPrimitive } from 'radix-ui';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
className,
|
||||||
|
defaultValue,
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||||
|
const _values = React.useMemo(
|
||||||
|
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
|
||||||
|
[value, defaultValue, min, max],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
data-slot="slider"
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full touch-none select-none items-center data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track
|
||||||
|
data-slot="slider-track"
|
||||||
|
className={cn(
|
||||||
|
'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-1.5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Range
|
||||||
|
data-slot="slider-range"
|
||||||
|
className={cn(
|
||||||
|
'bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{Array.from({ length: _values.length }, (_, index) => (
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
data-slot="slider-thumb"
|
||||||
|
key={index}
|
||||||
|
className="border-primary ring-ring/50 focus-visible:outline-hidden block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Slider };
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { type VariantProps } from 'class-variance-authority';
|
import { type VariantProps } from 'class-variance-authority';
|
||||||
import { ToggleGroup as ToggleGroupPrimitive } from 'radix-ui';
|
import { ToggleGroup as ToggleGroupPrimitive } from 'radix-ui';
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { toggleVariants } from './toggle';
|
import { toggleVariants } from './toggle';
|
||||||
|
|
||||||
const ToggleGroupContext = React.createContext<
|
const ToggleGroupContext = React.createContext<
|
||||||
|
|
@ -3,7 +3,7 @@ import * as TogglePrimitive from '@radix-ui/react-toggle';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
const toggleVariants = cva(
|
const toggleVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
"cursor-pointer inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { useLaboratory } from '@/components/laboratory/context';
|
||||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
|
@ -33,7 +33,7 @@ function TooltipContent({
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
const { container } = useLaboratory();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -42,7 +42,7 @@ function TooltipContent({
|
||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-[var(--radix-tooltip-content-transform-origin)] text-balance rounded-md px-3 py-1.5 text-xs',
|
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-150 w-fit origin-[var(--radix-tooltip-content-transform-origin)] text-balance rounded-md px-3 py-1.5 text-xs',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -51,7 +51,6 @@ function TooltipContent({
|
||||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
</TooltipPrimitive.Content>
|
</TooltipPrimitive.Content>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
<div ref={setContainer} style={{ display: 'contents' }} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hive-laboratory .inactive-line {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.hive-laboratory {
|
.hive-laboratory {
|
||||||
--color-neutral-1: 0 0% 99%;
|
--color-neutral-1: 0 0% 99%;
|
||||||
--color-neutral-2: 180 9% 97%;
|
--color-neutral-2: 180 9% 97%;
|
||||||
|
|
@ -57,14 +61,14 @@
|
||||||
--color-neutral-12: 175 23% 10%;
|
--color-neutral-12: 175 23% 10%;
|
||||||
|
|
||||||
--color-accent: 206 96% 35%;
|
--color-accent: 206 96% 35%;
|
||||||
|
|
||||||
--color-ring: 216 58% 49%;
|
--color-ring: 216 58% 49%;
|
||||||
|
--color-destructive: 357 96% 58%;
|
||||||
|
|
||||||
--radius: var(--hive-laboratory-radius, 0.5rem);
|
--radius: var(--hive-laboratory-radius, 0.5rem);
|
||||||
--background: var(--hive-laboratory-background, var(--color-neutral-2));
|
--background: var(--hive-laboratory-background, var(--color-neutral-2));
|
||||||
--foreground: var(--hive-laboratory-foreground, var(--color-neutral-11));
|
--foreground: var(--hive-laboratory-foreground, var(--color-neutral-11));
|
||||||
|
|
||||||
--muted: var(--hive-laboratory-muted, 24 9.8% 10%);
|
--muted: var(--hive-laboratory-muted, var(--color-neutral-3));
|
||||||
--muted-foreground: var(--hive-laboratory-muted-foreground, var(--color-neutral-11));
|
--muted-foreground: var(--hive-laboratory-muted-foreground, var(--color-neutral-11));
|
||||||
|
|
||||||
--popover: var(--hive-laboratory-popover, var(--color-neutral-3));
|
--popover: var(--hive-laboratory-popover, var(--color-neutral-3));
|
||||||
|
|
@ -80,12 +84,12 @@
|
||||||
--primary-foreground: var(--hive-laboratory-primary-foreground, var(--color-neutral-1));
|
--primary-foreground: var(--hive-laboratory-primary-foreground, var(--color-neutral-1));
|
||||||
|
|
||||||
--secondary: var(--hive-laboratory-secondary, var(--color-neutral-3));
|
--secondary: var(--hive-laboratory-secondary, var(--color-neutral-3));
|
||||||
--secondary-foreground: var(--hive-laboratory-secondary-foreground, var(--color-neutral-11));
|
--secondary-foreground: var(--hive-laboratory-secondary-foreground, var(--color-neutral-8));
|
||||||
|
|
||||||
--accent: var(--hive-laboratory-accent, var(--color-neutral-4));
|
--accent: var(--hive-laboratory-accent, var(--color-neutral-4));
|
||||||
--accent-foreground: var(--hive-laboratory-accent-foreground, var(--color-neutral-11));
|
--accent-foreground: var(--hive-laboratory-accent-foreground, var(--color-neutral-11));
|
||||||
|
|
||||||
--destructive: var(--hive-laboratory-destructive, var(--red-500));
|
--destructive: var(--hive-laboratory-destructive, var(--color-destructive));
|
||||||
--destructive-foreground: var(--hive-laboratory-destructive-foreground, var(--color-neutral-1));
|
--destructive-foreground: var(--hive-laboratory-destructive-foreground, var(--color-neutral-1));
|
||||||
|
|
||||||
--ring: var(--hive-laboratory-ring, var(--color-ring));
|
--ring: var(--hive-laboratory-ring, var(--color-ring));
|
||||||
|
|
@ -106,6 +110,7 @@
|
||||||
--color-neutral-12: 204 14% 93%;
|
--color-neutral-12: 204 14% 93%;
|
||||||
|
|
||||||
--color-accent: 48 100% 83%;
|
--color-accent: 48 100% 83%;
|
||||||
|
--color-destructive: 358.75 100% 70%;
|
||||||
|
|
||||||
--radius: var(--hive-laboratory-radius, 0.5rem);
|
--radius: var(--hive-laboratory-radius, 0.5rem);
|
||||||
--background: var(--hive-laboratory-background, var(--color-neutral-1));
|
--background: var(--hive-laboratory-background, var(--color-neutral-1));
|
||||||
|
|
@ -132,7 +137,7 @@
|
||||||
--accent: var(--hive-laboratory-accent, var(--color-neutral-6));
|
--accent: var(--hive-laboratory-accent, var(--color-neutral-6));
|
||||||
--accent-foreground: var(--hive-laboratory-accent-foreground, var(--color-neutral-11));
|
--accent-foreground: var(--hive-laboratory-accent-foreground, var(--color-neutral-11));
|
||||||
|
|
||||||
--destructive: var(--hive-laboratory-destructive, var(--red-500));
|
--destructive: var(--hive-laboratory-destructive, var(--color-destructive));
|
||||||
--destructive-foreground: var(--hive-laboratory-destructive-foreground, var(--color-neutral-1));
|
--destructive-foreground: var(--hive-laboratory-destructive-foreground, var(--color-neutral-1));
|
||||||
|
|
||||||
--ring: var(--hive-laboratory-ring, var(--color-ring));
|
--ring: var(--hive-laboratory-ring, var(--color-ring));
|
||||||
|
|
@ -159,3 +164,9 @@
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes dash {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: -20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { Laboratory } from './components/laboratory/laboratory';
|
import { Laboratory, LaboratoryProps } from './components/laboratory/laboratory';
|
||||||
|
|
||||||
export * from './components/laboratory/laboratory';
|
export * from './components/laboratory/laboratory';
|
||||||
export * from './components/laboratory/context';
|
export * from './components/laboratory/context';
|
||||||
|
|
@ -17,7 +17,7 @@ export * from './lib/tabs';
|
||||||
export * from './lib/tests';
|
export * from './lib/tests';
|
||||||
export * from './lib/plugins';
|
export * from './lib/plugins';
|
||||||
|
|
||||||
export const renderLaboratory = (el: HTMLElement) => {
|
export const renderLaboratory = (el: HTMLElement, props: LaboratoryProps) => {
|
||||||
const prefix = 'hive-laboratory';
|
const prefix = 'hive-laboratory';
|
||||||
|
|
||||||
const getLocalStorage = (key: string) => {
|
const getLocalStorage = (key: string) => {
|
||||||
|
|
@ -74,6 +74,7 @@ export const renderLaboratory = (el: HTMLElement) => {
|
||||||
onHistoryChange={history => {
|
onHistoryChange={history => {
|
||||||
setLocalStorage('history', history);
|
setLocalStorage('history', history);
|
||||||
}}
|
}}
|
||||||
|
{...props}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import * as React from 'react';
|
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
|
||||||
import { Toggle as TogglePrimitive } from 'radix-ui';
|
|
||||||
import { cn } from '../../../lib/utils';
|
|
||||||
|
|
||||||
const toggleVariants = cva(
|
|
||||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: 'bg-transparent',
|
|
||||||
outline:
|
|
||||||
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: 'h-9 min-w-9 px-2',
|
|
||||||
sm: 'h-8 min-w-8 px-1.5',
|
|
||||||
lg: 'h-10 min-w-10 px-2.5',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: 'default',
|
|
||||||
size: 'default',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
function Toggle({
|
|
||||||
className,
|
|
||||||
variant,
|
|
||||||
size,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
|
||||||
return (
|
|
||||||
<TogglePrimitive.Root
|
|
||||||
data-slot="toggle"
|
|
||||||
className={cn(toggleVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Toggle, toggleVariants };
|
|
||||||
|
|
@ -20,8 +20,10 @@ export interface LaboratoryCollection {
|
||||||
|
|
||||||
export interface LaboratoryCollectionsActions {
|
export interface LaboratoryCollectionsActions {
|
||||||
addCollection: (
|
addCollection: (
|
||||||
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>,
|
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'> & {
|
||||||
) => void;
|
operations?: Omit<LaboratoryCollectionOperation, 'createdAt'>[];
|
||||||
|
},
|
||||||
|
) => LaboratoryCollection;
|
||||||
addOperationToCollection: (
|
addOperationToCollection: (
|
||||||
collectionId: string,
|
collectionId: string,
|
||||||
operation: Omit<LaboratoryCollectionOperation, 'createdAt'>,
|
operation: Omit<LaboratoryCollectionOperation, 'createdAt'>,
|
||||||
|
|
@ -30,7 +32,7 @@ export interface LaboratoryCollectionsActions {
|
||||||
deleteOperationFromCollection: (collectionId: string, operationId: string) => void;
|
deleteOperationFromCollection: (collectionId: string, operationId: string) => void;
|
||||||
updateCollection: (
|
updateCollection: (
|
||||||
collectionId: string,
|
collectionId: string,
|
||||||
collection: Omit<LaboratoryCollection, 'id' | 'createdAt'>,
|
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>,
|
||||||
) => void;
|
) => void;
|
||||||
updateOperationInCollection: (
|
updateOperationInCollection: (
|
||||||
collectionId: string,
|
collectionId: string,
|
||||||
|
|
@ -73,17 +75,27 @@ export const useCollections = (
|
||||||
);
|
);
|
||||||
|
|
||||||
const addCollection = useCallback(
|
const addCollection = useCallback(
|
||||||
(collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>) => {
|
(
|
||||||
|
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'> & {
|
||||||
|
operations?: Omit<LaboratoryCollectionOperation, 'createdAt'>[];
|
||||||
|
},
|
||||||
|
) => {
|
||||||
const newCollection: LaboratoryCollection = {
|
const newCollection: LaboratoryCollection = {
|
||||||
...collection,
|
...collection,
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
operations: [],
|
operations:
|
||||||
|
collection.operations?.map(operation => ({
|
||||||
|
...operation,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
})) ?? [],
|
||||||
};
|
};
|
||||||
const newCollections = [...collections, newCollection];
|
const newCollections = [...collections, newCollection];
|
||||||
setCollections(newCollections);
|
setCollections(newCollections);
|
||||||
props.onCollectionsChange?.(newCollections);
|
props.onCollectionsChange?.(newCollections);
|
||||||
props.onCollectionCreate?.(newCollection);
|
props.onCollectionCreate?.(newCollection);
|
||||||
|
|
||||||
|
return newCollection;
|
||||||
},
|
},
|
||||||
[collections, props],
|
[collections, props],
|
||||||
);
|
);
|
||||||
|
|
@ -94,6 +106,7 @@ export const useCollections = (
|
||||||
...operation,
|
...operation,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const newCollections = collections.map(collection =>
|
const newCollections = collections.map(collection =>
|
||||||
collection.id === collectionId
|
collection.id === collectionId
|
||||||
? {
|
? {
|
||||||
|
|
@ -105,7 +118,9 @@ export const useCollections = (
|
||||||
|
|
||||||
setCollections(newCollections);
|
setCollections(newCollections);
|
||||||
props.onCollectionsChange?.(newCollections);
|
props.onCollectionsChange?.(newCollections);
|
||||||
|
|
||||||
const updatedCollection = newCollections.find(collection => collection.id === collectionId);
|
const updatedCollection = newCollections.find(collection => collection.id === collectionId);
|
||||||
|
|
||||||
if (updatedCollection) {
|
if (updatedCollection) {
|
||||||
props.onCollectionUpdate?.(updatedCollection);
|
props.onCollectionUpdate?.(updatedCollection);
|
||||||
props.onCollectionOperationCreate?.(updatedCollection, newOperation);
|
props.onCollectionOperationCreate?.(updatedCollection, newOperation);
|
||||||
|
|
@ -158,7 +173,10 @@ export const useCollections = (
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateCollection = useCallback(
|
const updateCollection = useCallback(
|
||||||
(collectionId: string, collection: Omit<LaboratoryCollection, 'id' | 'createdAt'>) => {
|
(
|
||||||
|
collectionId: string,
|
||||||
|
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>,
|
||||||
|
) => {
|
||||||
const newCollections = collections.map(c =>
|
const newCollections = collections.map(c =>
|
||||||
c.id === collectionId ? { ...c, ...collection } : c,
|
c.id === collectionId ? { ...c, ...collection } : c,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
buildClientSchema,
|
buildClientSchema,
|
||||||
getIntrospectionQuery,
|
|
||||||
GraphQLSchema,
|
GraphQLSchema,
|
||||||
|
introspectionFromSchema,
|
||||||
type IntrospectionQuery,
|
type IntrospectionQuery,
|
||||||
} from 'graphql';
|
} from 'graphql';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
// import z from 'zod';
|
||||||
|
import { asyncInterval } from '@/lib/utils';
|
||||||
|
import { SubscriptionProtocol, UrlLoader } from '@graphql-tools/url-loader';
|
||||||
|
import type { LaboratorySettingsActions, LaboratorySettingsState } from './settings';
|
||||||
|
|
||||||
export interface LaboratoryEndpointState {
|
export interface LaboratoryEndpointState {
|
||||||
endpoint: string | null;
|
endpoint: string | null;
|
||||||
|
|
@ -24,6 +28,7 @@ export const useEndpoint = (props: {
|
||||||
defaultEndpoint?: string | null;
|
defaultEndpoint?: string | null;
|
||||||
onEndpointChange?: (endpoint: string | null) => void;
|
onEndpointChange?: (endpoint: string | null) => void;
|
||||||
defaultSchemaIntrospection?: IntrospectionQuery | null;
|
defaultSchemaIntrospection?: IntrospectionQuery | null;
|
||||||
|
settingsApi?: LaboratorySettingsState & LaboratorySettingsActions;
|
||||||
}): LaboratoryEndpointState & LaboratoryEndpointActions => {
|
}): LaboratoryEndpointState & LaboratoryEndpointActions => {
|
||||||
const [endpoint, _setEndpoint] = useState<string | null>(props.defaultEndpoint ?? null);
|
const [endpoint, _setEndpoint] = useState<string | null>(props.defaultEndpoint ?? null);
|
||||||
const [introspection, setIntrospection] = useState<IntrospectionQuery | null>(null);
|
const [introspection, setIntrospection] = useState<IntrospectionQuery | null>(null);
|
||||||
|
|
@ -40,35 +45,104 @@ export const useEndpoint = (props: {
|
||||||
return introspection ? buildClientSchema(introspection) : null;
|
return introspection ? buildClientSchema(introspection) : null;
|
||||||
}, [introspection]);
|
}, [introspection]);
|
||||||
|
|
||||||
const fetchSchema = useCallback(async () => {
|
const loader = useMemo(() => new UrlLoader(), []);
|
||||||
if (endpoint === props.defaultEndpoint && props.defaultSchemaIntrospection) {
|
|
||||||
setIntrospection(props.defaultSchemaIntrospection);
|
const fetchSchema = useCallback(
|
||||||
|
async (signal?: AbortSignal) => {
|
||||||
|
if (endpoint === props.defaultEndpoint && props.defaultSchemaIntrospection) {
|
||||||
|
setIntrospection(props.defaultSchemaIntrospection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endpoint) {
|
||||||
|
setIntrospection(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await loader.load(endpoint, {
|
||||||
|
subscriptionsEndpoint: endpoint,
|
||||||
|
subscriptionsProtocol:
|
||||||
|
(props.settingsApi?.settings.subscriptions.protocol as SubscriptionProtocol) ??
|
||||||
|
SubscriptionProtocol.GRAPHQL_SSE,
|
||||||
|
credentials: props.settingsApi?.settings.fetch.credentials,
|
||||||
|
specifiedByUrl: true,
|
||||||
|
directiveIsRepeatable: true,
|
||||||
|
inputValueDeprecation: true,
|
||||||
|
retry: props.settingsApi?.settings.fetch.retry,
|
||||||
|
timeout: props.settingsApi?.settings.fetch.timeout,
|
||||||
|
useGETForQueries: props.settingsApi?.settings.fetch.useGETForQueries,
|
||||||
|
exposeHTTPDetailsInExtensions: true,
|
||||||
|
descriptions: props.settingsApi?.settings.introspection.schemaDescription ?? false,
|
||||||
|
method: props.settingsApi?.settings.introspection.method ?? 'POST',
|
||||||
|
fetch: (input: string | URL | Request, init?: RequestInit) =>
|
||||||
|
fetch(input, {
|
||||||
|
...init,
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
throw new Error('Failed to fetch schema');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result[0].schema) {
|
||||||
|
throw new Error('Failed to fetch schema');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIntrospection(introspectionFromSchema(result[0].schema));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (
|
||||||
|
error &&
|
||||||
|
typeof error === 'object' &&
|
||||||
|
'message' in error &&
|
||||||
|
typeof error.message === 'string'
|
||||||
|
) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to fetch schema');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIntrospection(null);
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
endpoint,
|
||||||
|
props.settingsApi?.settings.fetch.timeout,
|
||||||
|
props.settingsApi?.settings.introspection.method,
|
||||||
|
props.settingsApi?.settings.introspection.schemaDescription,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const shouldPollSchema = useMemo(() => {
|
||||||
|
return endpoint !== props.defaultEndpoint || !props.defaultSchemaIntrospection;
|
||||||
|
}, [endpoint, props.defaultEndpoint, props.defaultSchemaIntrospection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldPollSchema || !endpoint) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!endpoint) {
|
const intervalController = new AbortController();
|
||||||
setIntrospection(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
void asyncInterval(
|
||||||
const response = await fetch(endpoint, {
|
async () => {
|
||||||
method: 'POST',
|
try {
|
||||||
body: JSON.stringify({
|
await fetchSchema(intervalController.signal);
|
||||||
query: getIntrospectionQuery(),
|
} catch {
|
||||||
}),
|
intervalController.abort();
|
||||||
headers: {
|
}
|
||||||
'Content-Type': 'application/json',
|
},
|
||||||
},
|
5000,
|
||||||
}).then(r => r.json());
|
intervalController.signal,
|
||||||
|
);
|
||||||
|
|
||||||
setIntrospection(response.data as IntrospectionQuery);
|
return () => {
|
||||||
} catch {
|
intervalController.abort();
|
||||||
toast.error('Failed to fetch schema');
|
};
|
||||||
setIntrospection(null);
|
}, [shouldPollSchema, fetchSchema]);
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, [endpoint]);
|
|
||||||
|
|
||||||
const restoreDefaultEndpoint = useCallback(() => {
|
const restoreDefaultEndpoint = useCallback(() => {
|
||||||
if (props.defaultEndpoint) {
|
if (props.defaultEndpoint) {
|
||||||
|
|
@ -77,10 +151,10 @@ export const useEndpoint = (props: {
|
||||||
}, [props.defaultEndpoint]);
|
}, [props.defaultEndpoint]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (endpoint) {
|
if (endpoint && !shouldPollSchema) {
|
||||||
void fetchSchema();
|
void fetchSchema();
|
||||||
}
|
}
|
||||||
}, [endpoint, fetchSchema]);
|
}, [endpoint, fetchSchema, shouldPollSchema]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
endpoint,
|
endpoint,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import type { GraphQLSchema } from 'graphql';
|
import {
|
||||||
import { createClient } from 'graphql-ws';
|
DocumentNode,
|
||||||
|
ExecutionResult,
|
||||||
|
getOperationAST,
|
||||||
|
GraphQLError,
|
||||||
|
Kind,
|
||||||
|
parse,
|
||||||
|
type GraphQLSchema,
|
||||||
|
} from 'graphql';
|
||||||
import { decompressFromEncodedURIComponent } from 'lz-string';
|
import { decompressFromEncodedURIComponent } from 'lz-string';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { isAsyncIterable } from '@/lib/utils';
|
||||||
|
import { SubscriptionProtocol, UrlLoader } from '@graphql-tools/url-loader';
|
||||||
import { LaboratoryPermission, LaboratoryPermissions } from '../components/laboratory/context';
|
import { LaboratoryPermission, LaboratoryPermissions } from '../components/laboratory/context';
|
||||||
import type {
|
import type {
|
||||||
LaboratoryCollectionOperation,
|
LaboratoryCollectionOperation,
|
||||||
|
|
@ -23,6 +32,14 @@ import type { LaboratoryPreflightActions, LaboratoryPreflightState } from './pre
|
||||||
import type { LaboratorySettingsActions, LaboratorySettingsState } from './settings';
|
import type { LaboratorySettingsActions, LaboratorySettingsState } from './settings';
|
||||||
import type { LaboratoryTabOperation, LaboratoryTabsActions, LaboratoryTabsState } from './tabs';
|
import type { LaboratoryTabOperation, LaboratoryTabsActions, LaboratoryTabsState } from './tabs';
|
||||||
|
|
||||||
|
function getOperationType(query: string): 'query' | 'mutation' | 'subscription' | null {
|
||||||
|
try {
|
||||||
|
return getOperationAST(parse(query))?.operation ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface LaboratoryOperation {
|
export interface LaboratoryOperation {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -45,18 +62,28 @@ export interface LaboratoryOperationsActions {
|
||||||
setOperations: (operations: LaboratoryOperation[]) => void;
|
setOperations: (operations: LaboratoryOperation[]) => void;
|
||||||
updateActiveOperation: (operation: Partial<Omit<LaboratoryOperation, 'id'>>) => void;
|
updateActiveOperation: (operation: Partial<Omit<LaboratoryOperation, 'id'>>) => void;
|
||||||
deleteOperation: (operationId: string) => void;
|
deleteOperation: (operationId: string) => void;
|
||||||
addPathToActiveOperation: (path: string) => void;
|
addPathToActiveOperation: (path: string, operationName?: string | null) => void;
|
||||||
deletePathFromActiveOperation: (path: string) => void;
|
deletePathFromActiveOperation: (path: string, operationName?: string | null) => void;
|
||||||
addArgToActiveOperation: (path: string, argName: string, schema: GraphQLSchema) => void;
|
addArgToActiveOperation: (
|
||||||
deleteArgFromActiveOperation: (path: string, argName: string) => void;
|
path: string,
|
||||||
|
argName: string,
|
||||||
|
schema: GraphQLSchema,
|
||||||
|
operationName?: string | null,
|
||||||
|
) => void;
|
||||||
|
deleteArgFromActiveOperation: (
|
||||||
|
path: string,
|
||||||
|
argName: string,
|
||||||
|
operationName?: string | null,
|
||||||
|
) => void;
|
||||||
runActiveOperation: (
|
runActiveOperation: (
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options?: {
|
options?: {
|
||||||
env?: LaboratoryEnv;
|
env?: LaboratoryEnv;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
operationName?: string;
|
||||||
onResponse?: (response: string) => void;
|
onResponse?: (response: string) => void;
|
||||||
},
|
},
|
||||||
) => Promise<Response | null>;
|
) => Promise<ExecutionResult | null>;
|
||||||
stopActiveOperation: (() => void) | null;
|
stopActiveOperation: (() => void) | null;
|
||||||
isActiveOperationLoading: boolean;
|
isActiveOperationLoading: boolean;
|
||||||
isOperationLoading: (operationId: string) => boolean;
|
isOperationLoading: (operationId: string) => boolean;
|
||||||
|
|
@ -70,6 +97,27 @@ export interface LaboratoryOperationsCallbacks {
|
||||||
onOperationDelete?: (operation: LaboratoryOperation) => void;
|
onOperationDelete?: (operation: LaboratoryOperation) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getOperationWithFragments = (
|
||||||
|
document: DocumentNode,
|
||||||
|
operationName?: string,
|
||||||
|
): DocumentNode => {
|
||||||
|
const definitions = document.definitions.filter(definition => {
|
||||||
|
if (
|
||||||
|
definition.kind === Kind.OPERATION_DEFINITION &&
|
||||||
|
operationName &&
|
||||||
|
definition.name?.value !== operationName
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: Kind.DOCUMENT,
|
||||||
|
definitions,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const useOperations = (
|
export const useOperations = (
|
||||||
props: {
|
props: {
|
||||||
checkPermissions: (
|
checkPermissions: (
|
||||||
|
|
@ -243,13 +291,13 @@ export const useOperations = (
|
||||||
);
|
);
|
||||||
|
|
||||||
const addPathToActiveOperation = useCallback(
|
const addPathToActiveOperation = useCallback(
|
||||||
(path: string) => {
|
(path: string, operationName?: string | null) => {
|
||||||
if (!activeOperation) {
|
if (!activeOperation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newActiveOperation = {
|
const newActiveOperation = {
|
||||||
...activeOperation,
|
...activeOperation,
|
||||||
query: addPathToQuery(activeOperation.query, path),
|
query: addPathToQuery(activeOperation.query, path, operationName),
|
||||||
};
|
};
|
||||||
updateActiveOperation(newActiveOperation);
|
updateActiveOperation(newActiveOperation);
|
||||||
},
|
},
|
||||||
|
|
@ -257,14 +305,14 @@ export const useOperations = (
|
||||||
);
|
);
|
||||||
|
|
||||||
const deletePathFromActiveOperation = useCallback(
|
const deletePathFromActiveOperation = useCallback(
|
||||||
(path: string) => {
|
(path: string, operationName?: string | null) => {
|
||||||
if (!activeOperation?.query) {
|
if (!activeOperation?.query) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newActiveOperation = {
|
const newActiveOperation = {
|
||||||
...activeOperation,
|
...activeOperation,
|
||||||
query: deletePathFromQuery(activeOperation.query, path),
|
query: deletePathFromQuery(activeOperation.query, path, operationName),
|
||||||
};
|
};
|
||||||
updateActiveOperation(newActiveOperation);
|
updateActiveOperation(newActiveOperation);
|
||||||
},
|
},
|
||||||
|
|
@ -272,14 +320,14 @@ export const useOperations = (
|
||||||
);
|
);
|
||||||
|
|
||||||
const addArgToActiveOperation = useCallback(
|
const addArgToActiveOperation = useCallback(
|
||||||
(path: string, argName: string, schema: GraphQLSchema) => {
|
(path: string, argName: string, schema: GraphQLSchema, operationName?: string | null) => {
|
||||||
if (!activeOperation?.query) {
|
if (!activeOperation?.query) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newActiveOperation = {
|
const newActiveOperation = {
|
||||||
...activeOperation,
|
...activeOperation,
|
||||||
query: addArgToField(activeOperation.query, path, argName, schema),
|
query: addArgToField(activeOperation.query, path, argName, schema, operationName),
|
||||||
};
|
};
|
||||||
updateActiveOperation(newActiveOperation);
|
updateActiveOperation(newActiveOperation);
|
||||||
},
|
},
|
||||||
|
|
@ -287,14 +335,14 @@ export const useOperations = (
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteArgFromActiveOperation = useCallback(
|
const deleteArgFromActiveOperation = useCallback(
|
||||||
(path: string, argName: string) => {
|
(path: string, argName: string, operationName?: string | null) => {
|
||||||
if (!activeOperation?.query) {
|
if (!activeOperation?.query) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newActiveOperation = {
|
const newActiveOperation = {
|
||||||
...activeOperation,
|
...activeOperation,
|
||||||
query: removeArgFromField(activeOperation.query, path, argName),
|
query: removeArgFromField(activeOperation.query, path, argName, operationName),
|
||||||
};
|
};
|
||||||
updateActiveOperation(newActiveOperation);
|
updateActiveOperation(newActiveOperation);
|
||||||
},
|
},
|
||||||
|
|
@ -316,6 +364,8 @@ export const useOperations = (
|
||||||
return activeOperation ? isOperationLoading(activeOperation.id) : false;
|
return activeOperation ? isOperationLoading(activeOperation.id) : false;
|
||||||
}, [activeOperation, isOperationLoading]);
|
}, [activeOperation, isOperationLoading]);
|
||||||
|
|
||||||
|
const loader = useMemo(() => new UrlLoader(), []);
|
||||||
|
|
||||||
const runActiveOperation = useCallback(
|
const runActiveOperation = useCallback(
|
||||||
async (
|
async (
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
|
|
@ -323,10 +373,11 @@ export const useOperations = (
|
||||||
env?: LaboratoryEnv;
|
env?: LaboratoryEnv;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
onResponse?: (response: string) => void;
|
onResponse?: (response: string) => void;
|
||||||
|
operationName?: string;
|
||||||
},
|
},
|
||||||
plugins: LaboratoryPlugin[] = props.pluginsApi?.plugins ?? [],
|
plugins: LaboratoryPlugin[] = props.pluginsApi?.plugins ?? [],
|
||||||
pluginsState: Record<string, any> = props.pluginsApi?.pluginsState ?? {},
|
pluginsState: Record<string, any> = props.pluginsApi?.pluginsState ?? {},
|
||||||
) => {
|
): Promise<ExecutionResult | null> => {
|
||||||
if (!activeOperation?.query) {
|
if (!activeOperation?.query) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -382,100 +433,91 @@ export const useOperations = (
|
||||||
)
|
)
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
if (activeOperation.query.startsWith('subscription')) {
|
const executor = loader.getExecutorAsync(endpoint, {
|
||||||
const client = createClient({
|
subscriptionsEndpoint: endpoint,
|
||||||
url: endpoint.replace('http', 'ws'),
|
subscriptionsProtocol:
|
||||||
connectionParams: {
|
(props.settingsApi?.settings.subscriptions.protocol as SubscriptionProtocol) ??
|
||||||
...mergedHeaders,
|
SubscriptionProtocol.GRAPHQL_SSE,
|
||||||
|
credentials: props.settingsApi?.settings.fetch.credentials,
|
||||||
|
specifiedByUrl: true,
|
||||||
|
directiveIsRepeatable: true,
|
||||||
|
inputValueDeprecation: true,
|
||||||
|
retry: props.settingsApi?.settings.fetch.retry,
|
||||||
|
timeout: props.settingsApi?.settings.fetch.timeout,
|
||||||
|
useGETForQueries: props.settingsApi?.settings.fetch.useGETForQueries,
|
||||||
|
exposeHTTPDetailsInExtensions: true,
|
||||||
|
fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const document = getOperationWithFragments(parse(activeOperation.query));
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
setStopOperationsFunctions(prev => ({
|
||||||
|
...prev,
|
||||||
|
[activeOperation.id]: () => {
|
||||||
|
abortController.abort();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await executor({
|
||||||
|
document,
|
||||||
|
operationName: options?.operationName,
|
||||||
|
variables,
|
||||||
|
extensions: {
|
||||||
|
...extensions,
|
||||||
|
headers: mergedHeaders,
|
||||||
},
|
},
|
||||||
|
signal: abortController.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on('connected', () => {
|
if (isAsyncIterable(response)) {
|
||||||
console.log('connected');
|
try {
|
||||||
});
|
for await (const item of response) {
|
||||||
|
options?.onResponse?.(JSON.stringify(item ?? {}));
|
||||||
client.on('error', () => {
|
}
|
||||||
setStopOperationsFunctions(prev => {
|
} finally {
|
||||||
const newStopOperationsFunctions = { ...prev };
|
|
||||||
delete newStopOperationsFunctions[activeOperation.id];
|
|
||||||
return newStopOperationsFunctions;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('closed', () => {
|
|
||||||
setStopOperationsFunctions(prev => {
|
|
||||||
const newStopOperationsFunctions = { ...prev };
|
|
||||||
delete newStopOperationsFunctions[activeOperation.id];
|
|
||||||
return newStopOperationsFunctions;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
client.subscribe(
|
|
||||||
{
|
|
||||||
query: activeOperation.query,
|
|
||||||
variables,
|
|
||||||
extensions,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
next: message => {
|
|
||||||
options?.onResponse?.(JSON.stringify(message ?? {}));
|
|
||||||
},
|
|
||||||
error: () => {},
|
|
||||||
complete: () => {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
setStopOperationsFunctions(prev => ({
|
|
||||||
...prev,
|
|
||||||
[activeOperation.id]: () => {
|
|
||||||
void client.dispose();
|
|
||||||
setStopOperationsFunctions(prev => {
|
setStopOperationsFunctions(prev => {
|
||||||
const newStopOperationsFunctions = { ...prev };
|
const newStopOperationsFunctions = { ...prev };
|
||||||
delete newStopOperationsFunctions[activeOperation.id];
|
delete newStopOperationsFunctions[activeOperation.id];
|
||||||
return newStopOperationsFunctions;
|
return newStopOperationsFunctions;
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
}));
|
|
||||||
|
|
||||||
return Promise.resolve(new Response());
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const abortController = new AbortController();
|
if (response.extensions?.response?.body) {
|
||||||
|
delete response.extensions.response.body;
|
||||||
|
}
|
||||||
|
|
||||||
const response = fetch(endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: props.settingsApi?.settings.fetch.credentials,
|
|
||||||
body: JSON.stringify({
|
|
||||||
query: activeOperation.query,
|
|
||||||
variables,
|
|
||||||
extensions,
|
|
||||||
}),
|
|
||||||
headers: {
|
|
||||||
...mergedHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
signal: abortController.signal,
|
|
||||||
}).finally(() => {
|
|
||||||
setStopOperationsFunctions(prev => {
|
setStopOperationsFunctions(prev => {
|
||||||
const newStopOperationsFunctions = { ...prev };
|
const newStopOperationsFunctions = { ...prev };
|
||||||
delete newStopOperationsFunctions[activeOperation.id];
|
delete newStopOperationsFunctions[activeOperation.id];
|
||||||
|
|
||||||
return newStopOperationsFunctions;
|
return newStopOperationsFunctions;
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
setStopOperationsFunctions(prev => ({
|
return response;
|
||||||
...prev,
|
} catch (error) {
|
||||||
[activeOperation.id]: () => abortController.abort(),
|
setStopOperationsFunctions(prev => {
|
||||||
}));
|
const newStopOperationsFunctions = { ...prev };
|
||||||
|
delete newStopOperationsFunctions[activeOperation.id];
|
||||||
|
return newStopOperationsFunctions;
|
||||||
|
});
|
||||||
|
|
||||||
return response;
|
if (error instanceof Error) {
|
||||||
|
return new GraphQLError(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GraphQLError('An unknown error occurred');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[activeOperation, props.preflightApi, props.envApi, props.pluginsApi],
|
[activeOperation, props.preflightApi, props.envApi, props.pluginsApi, props.settingsApi],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isOperationSubscription = useCallback((operation: LaboratoryOperation) => {
|
const isOperationSubscription = useCallback((operation: LaboratoryOperation) => {
|
||||||
return operation.query?.startsWith('subscription') ?? false;
|
return getOperationType(operation.query) === 'subscription';
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isActiveOperationSubscription = useMemo(() => {
|
const isActiveOperationSubscription = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function healQuery(query: string) {
|
||||||
return query.replace(/\{(\s+)?\}/g, '');
|
return query.replace(/\{(\s+)?\}/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPathInQuery(query: string, path: string, operationName?: string) {
|
export function isPathInQuery(query: string, path: string, operationName?: string | null) {
|
||||||
if (!query || !path) {
|
if (!query || !path) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +98,7 @@ export function isPathInQuery(query: string, path: string, operationName?: strin
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addPathToQuery(query: string, path: string, operationName?: string) {
|
export function addPathToQuery(query: string, path: string, operationName?: string | null) {
|
||||||
query = healQuery(query);
|
query = healQuery(query);
|
||||||
|
|
||||||
const [operation, ...parts] = path.split('.') as [OperationTypeNode, ...string[]];
|
const [operation, ...parts] = path.split('.') as [OperationTypeNode, ...string[]];
|
||||||
|
|
@ -244,7 +244,7 @@ export function addPathToQuery(query: string, path: string, operationName?: stri
|
||||||
return print(doc);
|
return print(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deletePathFromQuery(query: string, path: string, operationName?: string) {
|
export function deletePathFromQuery(query: string, path: string, operationName?: string | null) {
|
||||||
query = healQuery(query);
|
query = healQuery(query);
|
||||||
|
|
||||||
const [operation, ...segments] = path.split('.') as [OperationTypeNode, ...string[]];
|
const [operation, ...segments] = path.split('.') as [OperationTypeNode, ...string[]];
|
||||||
|
|
@ -348,8 +348,6 @@ export async function getOperationHash(
|
||||||
operation: Pick<LaboratoryOperation, 'query' | 'variables'>,
|
operation: Pick<LaboratoryOperation, 'query' | 'variables'>,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
console.log(operation.query, operation.variables);
|
|
||||||
|
|
||||||
const canonicalQuery = print(parse(operation.query));
|
const canonicalQuery = print(parse(operation.query));
|
||||||
const canonicalVariables = '';
|
const canonicalVariables = '';
|
||||||
const canonical = `${canonicalQuery}\n${canonicalVariables}`;
|
const canonical = `${canonicalQuery}\n${canonicalVariables}`;
|
||||||
|
|
@ -393,7 +391,12 @@ export function getOperationType(query: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isArgInQuery(query: string, path: string, argName: string, operationName?: string) {
|
export function isArgInQuery(
|
||||||
|
query: string,
|
||||||
|
path: string,
|
||||||
|
argName: string,
|
||||||
|
operationName?: string | null,
|
||||||
|
) {
|
||||||
if (!query || !path) {
|
if (!query || !path) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -527,7 +530,7 @@ export function addArgToField(
|
||||||
path: string,
|
path: string,
|
||||||
argName: string,
|
argName: string,
|
||||||
schema: GraphQLSchema,
|
schema: GraphQLSchema,
|
||||||
operationName?: string,
|
operationName?: string | null,
|
||||||
) {
|
) {
|
||||||
query = healQuery(query);
|
query = healQuery(query);
|
||||||
|
|
||||||
|
|
@ -784,7 +787,7 @@ export function removeArgFromField(
|
||||||
query: string,
|
query: string,
|
||||||
path: string,
|
path: string,
|
||||||
argName: string,
|
argName: string,
|
||||||
operationName?: string,
|
operationName?: string | null,
|
||||||
) {
|
) {
|
||||||
query = healQuery(query);
|
query = healQuery(query);
|
||||||
|
|
||||||
|
|
|
||||||
225
packages/libraries/laboratory/src/lib/query-plan/schema.ts
Normal file
225
packages/libraries/laboratory/src/lib/query-plan/schema.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const FlattenNodePathSegmentSchema = z.union([
|
||||||
|
z.object({ Field: z.string() }),
|
||||||
|
z.object({ TypeCondition: z.array(z.string()) }),
|
||||||
|
z.literal('@'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type FlattenNodePathSegment = z.infer<typeof FlattenNodePathSegmentSchema>;
|
||||||
|
|
||||||
|
export const FlattenNodePathSchema = z.array(FlattenNodePathSegmentSchema);
|
||||||
|
export type FlattenNodePath = z.infer<typeof FlattenNodePathSchema>;
|
||||||
|
|
||||||
|
export const FlattenNodePathsSchema = z.array(FlattenNodePathSchema);
|
||||||
|
export type FlattenNodePaths = z.infer<typeof FlattenNodePathsSchema>;
|
||||||
|
|
||||||
|
export const FetchNodePathSegmentSchema = z.union([
|
||||||
|
z.object({ Key: z.string() }),
|
||||||
|
z.object({ TypenameEquals: z.array(z.string()) }),
|
||||||
|
]);
|
||||||
|
export type FetchNodePathSegment = z.infer<typeof FetchNodePathSegmentSchema>;
|
||||||
|
|
||||||
|
export const ValueSetterSchema = z.object({
|
||||||
|
path: z.array(FetchNodePathSegmentSchema),
|
||||||
|
setValueTo: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const KeyRenamerSchema = z.object({
|
||||||
|
path: z.array(FetchNodePathSegmentSchema),
|
||||||
|
renameKeyTo: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FetchRewriteSchema = z.union([ValueSetterSchema, KeyRenamerSchema]);
|
||||||
|
export type FetchRewrite = z.infer<typeof FetchRewriteSchema>;
|
||||||
|
|
||||||
|
export const SelectionFieldSchema = z.object({
|
||||||
|
kind: z.literal('Field'),
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SelectionInlineFragmentSchema = z.object({
|
||||||
|
kind: z.literal('InlineFragment'),
|
||||||
|
typeCondition: z.string().nullish(),
|
||||||
|
selections: z.array(SelectionFieldSchema).nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SelectionInlineFragment = z.infer<typeof SelectionInlineFragmentSchema>;
|
||||||
|
|
||||||
|
export const SelectionFragmentSpreadSchema = z.object({
|
||||||
|
kind: z.literal('FragmentSpread'),
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SelectionItemSchema = z.union([
|
||||||
|
SelectionInlineFragmentSchema,
|
||||||
|
SelectionFragmentSpreadSchema,
|
||||||
|
SelectionFieldSchema,
|
||||||
|
]);
|
||||||
|
export type SelectionItem = z.infer<typeof SelectionItemSchema>;
|
||||||
|
|
||||||
|
export const SelectionSetSchema = z.array(SelectionItemSchema);
|
||||||
|
export type SelectionSet = z.infer<typeof SelectionSetSchema>;
|
||||||
|
|
||||||
|
export interface SequenceNodePlan {
|
||||||
|
kind: 'Sequence';
|
||||||
|
nodes: PlanNode[];
|
||||||
|
}
|
||||||
|
export interface ParallelNodePlan {
|
||||||
|
kind: 'Parallel';
|
||||||
|
nodes: PlanNode[];
|
||||||
|
}
|
||||||
|
export interface FlattenNodePlan {
|
||||||
|
kind: 'Flatten';
|
||||||
|
path: FlattenNodePath;
|
||||||
|
node: PlanNode;
|
||||||
|
}
|
||||||
|
export interface ConditionNodePlan {
|
||||||
|
kind: 'Condition';
|
||||||
|
condition: string;
|
||||||
|
ifClause?: PlanNode | null;
|
||||||
|
elseClause?: PlanNode | null;
|
||||||
|
}
|
||||||
|
export interface SubscriptionNodePlan {
|
||||||
|
kind: 'Subscription';
|
||||||
|
primary: PlanNode;
|
||||||
|
}
|
||||||
|
export interface DeferNodePlan {
|
||||||
|
kind: 'Defer';
|
||||||
|
primary: DeferPrimary;
|
||||||
|
deferred: DeferredNode[];
|
||||||
|
}
|
||||||
|
export interface DeferPrimary {
|
||||||
|
subselection?: string | null;
|
||||||
|
node?: PlanNode | null;
|
||||||
|
}
|
||||||
|
export interface DeferredNode {
|
||||||
|
depends: DeferDependency[];
|
||||||
|
label?: string | null;
|
||||||
|
queryPath: string[];
|
||||||
|
subselection?: string | null;
|
||||||
|
node?: PlanNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FetchNodePlan = z.infer<typeof FetchNodePlanSchema>;
|
||||||
|
export type BatchFetchNodePlan = z.infer<typeof BatchFetchNodePlanSchema>;
|
||||||
|
|
||||||
|
export type PlanNode =
|
||||||
|
| FetchNodePlan
|
||||||
|
| BatchFetchNodePlan
|
||||||
|
| SequenceNodePlan
|
||||||
|
| ParallelNodePlan
|
||||||
|
| FlattenNodePlan
|
||||||
|
| ConditionNodePlan
|
||||||
|
| SubscriptionNodePlan
|
||||||
|
| DeferNodePlan;
|
||||||
|
|
||||||
|
export const PlanNodeSchema: z.ZodType<PlanNode> = z.lazy(() =>
|
||||||
|
z.discriminatedUnion('kind', [
|
||||||
|
FetchNodePlanSchema,
|
||||||
|
BatchFetchNodePlanSchema,
|
||||||
|
SequenceNodePlanSchema,
|
||||||
|
ParallelNodePlanSchema,
|
||||||
|
FlattenNodePlanSchema,
|
||||||
|
ConditionNodePlanSchema,
|
||||||
|
SubscriptionNodePlanSchema,
|
||||||
|
DeferNodePlanSchema,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FetchNodePlanSchema = z.object({
|
||||||
|
kind: z.literal('Fetch'),
|
||||||
|
serviceName: z.string(),
|
||||||
|
operationKind: z.string().nullish(),
|
||||||
|
operationName: z.string().nullish(),
|
||||||
|
operation: z.string(),
|
||||||
|
variableUsages: z.array(z.string()).nullish(),
|
||||||
|
requires: SelectionSetSchema.nullish(),
|
||||||
|
inputRewrites: z.array(FetchRewriteSchema).nullish(),
|
||||||
|
outputRewrites: z.array(FetchRewriteSchema).nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EntityBatchAliasSchema = z.object({
|
||||||
|
alias: z.string(),
|
||||||
|
representationsVariableName: z.string(),
|
||||||
|
paths: FlattenNodePathsSchema,
|
||||||
|
requires: SelectionSetSchema,
|
||||||
|
inputRewrites: z.array(FetchRewriteSchema).nullish(),
|
||||||
|
outputRewrites: z.array(FetchRewriteSchema).nullish(),
|
||||||
|
});
|
||||||
|
export type EntityBatchAlias = z.infer<typeof EntityBatchAliasSchema>;
|
||||||
|
|
||||||
|
export const EntityBatchSchema = z.object({
|
||||||
|
aliases: z.array(EntityBatchAliasSchema),
|
||||||
|
});
|
||||||
|
export type EntityBatch = z.infer<typeof EntityBatchSchema>;
|
||||||
|
|
||||||
|
export const BatchFetchNodePlanSchema = z.object({
|
||||||
|
kind: z.literal('BatchFetch'),
|
||||||
|
serviceName: z.string(),
|
||||||
|
operationKind: z.string().nullish(),
|
||||||
|
operationName: z.string().nullish(),
|
||||||
|
operation: z.string(),
|
||||||
|
variableUsages: z.array(z.string()).nullish(),
|
||||||
|
entityBatch: EntityBatchSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SequenceNodePlanSchema = z.object({
|
||||||
|
kind: z.literal('Sequence'),
|
||||||
|
nodes: z.array(PlanNodeSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ParallelNodePlanSchema = z.object({
|
||||||
|
kind: z.literal('Parallel'),
|
||||||
|
nodes: z.array(PlanNodeSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FlattenNodePlanSchema = z.object({
|
||||||
|
kind: z.literal('Flatten'),
|
||||||
|
path: FlattenNodePathSchema,
|
||||||
|
node: PlanNodeSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ConditionNodePlanSchema = z.object({
|
||||||
|
kind: z.literal('Condition'),
|
||||||
|
condition: z.string(),
|
||||||
|
ifClause: PlanNodeSchema.nullish(),
|
||||||
|
elseClause: PlanNodeSchema.nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SubscriptionNodePlanSchema = z.object({
|
||||||
|
kind: z.literal('Subscription'),
|
||||||
|
primary: PlanNodeSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DeferDependencySchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
deferLabel: z.string().nullish(),
|
||||||
|
});
|
||||||
|
export type DeferDependency = z.infer<typeof DeferDependencySchema>;
|
||||||
|
|
||||||
|
export const DeferPrimarySchema = z.object({
|
||||||
|
subselection: z.string().nullish(),
|
||||||
|
node: PlanNodeSchema.nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DeferredNodeSchema = z.object({
|
||||||
|
depends: z.array(DeferDependencySchema),
|
||||||
|
label: z.string().nullish(),
|
||||||
|
queryPath: z.array(z.string()),
|
||||||
|
subselection: z.string().nullish(),
|
||||||
|
node: PlanNodeSchema.nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DeferNodePlanSchema = z.object({
|
||||||
|
kind: z.literal('Defer'),
|
||||||
|
primary: DeferPrimarySchema,
|
||||||
|
deferred: z.array(DeferredNodeSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const QueryPlanSchema = z.object({
|
||||||
|
kind: z.literal('QueryPlan'),
|
||||||
|
node: PlanNodeSchema.nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type QueryPlan = z.infer<typeof QueryPlanSchema>;
|
||||||
795
packages/libraries/laboratory/src/lib/query-plan/utils.tsx
Normal file
795
packages/libraries/laboratory/src/lib/query-plan/utils.tsx
Normal file
|
|
@ -0,0 +1,795 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { parse, print } from 'graphql';
|
||||||
|
import { isArray } from 'lodash';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Boxes,
|
||||||
|
ClockIcon,
|
||||||
|
GitForkIcon,
|
||||||
|
Layers2Icon,
|
||||||
|
ListOrderedIcon,
|
||||||
|
LucideProps,
|
||||||
|
NetworkIcon,
|
||||||
|
UnlinkIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { Flow, FlowNode } from '@/components/flow';
|
||||||
|
import { GraphQLIcon } from '@/components/icons';
|
||||||
|
import { Editor } from '@/components/laboratory/editor';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
BatchFetchNodePlan,
|
||||||
|
ConditionNodePlan,
|
||||||
|
DeferNodePlan,
|
||||||
|
FetchNodePlan,
|
||||||
|
FlattenNodePath,
|
||||||
|
FlattenNodePathSegment,
|
||||||
|
FlattenNodePlan,
|
||||||
|
ParallelNodePlan,
|
||||||
|
PlanNode,
|
||||||
|
QueryPlan,
|
||||||
|
SelectionInlineFragment,
|
||||||
|
SelectionSet,
|
||||||
|
SequenceNodePlan,
|
||||||
|
SubscriptionNodePlan,
|
||||||
|
} from './schema';
|
||||||
|
|
||||||
|
function indent(depth: number): string {
|
||||||
|
return ' '.repeat(depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStringSet(value: string[] | Set<string> | null | undefined): string[] {
|
||||||
|
if (!value) return [];
|
||||||
|
return Array.isArray(value) ? value : Array.from(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFlattenPathSegment(seg: FlattenNodePathSegment): string {
|
||||||
|
if (seg === '@') return '@';
|
||||||
|
|
||||||
|
if (isObject(seg) && 'Field' in seg) {
|
||||||
|
return String(seg.Field);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isObject(seg) && 'TypeCondition' in seg) {
|
||||||
|
const names = normalizeStringSet(seg.TypeCondition as string[] | Set<string>);
|
||||||
|
return `|[${names.join('|')}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isArray(seg)) {
|
||||||
|
return renderFlattenPath(seg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(seg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderFlattenPath(path: FlattenNodePath): string {
|
||||||
|
let out = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < path.length; i++) {
|
||||||
|
const current = path[i];
|
||||||
|
const next = path[i + 1];
|
||||||
|
out += renderFlattenPathSegment(current);
|
||||||
|
|
||||||
|
if (next !== undefined) {
|
||||||
|
const nextIsTypeCondition = isObject(next) && 'TypeCondition' in next;
|
||||||
|
if (!nextIsTypeCondition) out += '.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSelectionSet(
|
||||||
|
selectionSet: SelectionSet | null | undefined,
|
||||||
|
depth = 0,
|
||||||
|
): string {
|
||||||
|
if (!selectionSet?.length) return '';
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
for (const item of selectionSet) {
|
||||||
|
if (item.kind === 'InlineFragment') {
|
||||||
|
lines.push(`${indent(depth)}... on ${item.typeCondition} {`);
|
||||||
|
|
||||||
|
for (const property of item.selections ?? []) {
|
||||||
|
lines.push(`${indent(depth + 1)}${property.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`${indent(depth)}}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderQueryPlan(plan: QueryPlan): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push('QueryPlan {');
|
||||||
|
|
||||||
|
if (plan.node) {
|
||||||
|
lines.push(renderPlanNode(plan.node, 1));
|
||||||
|
} else {
|
||||||
|
lines.push(`${indent(1)}None`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('}');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPlanNode(node: PlanNode, depth = 0): string {
|
||||||
|
switch (node.kind) {
|
||||||
|
case 'Fetch':
|
||||||
|
return renderFetchNode(node, depth);
|
||||||
|
|
||||||
|
case 'BatchFetch':
|
||||||
|
return renderBatchFetchNode(node, depth);
|
||||||
|
|
||||||
|
case 'Flatten':
|
||||||
|
return renderFlattenNode(node, depth);
|
||||||
|
|
||||||
|
case 'Sequence':
|
||||||
|
return renderSequenceNode(node, depth);
|
||||||
|
|
||||||
|
case 'Parallel':
|
||||||
|
return renderParallelNode(node, depth);
|
||||||
|
|
||||||
|
case 'Condition':
|
||||||
|
return renderConditionNode(node, depth);
|
||||||
|
|
||||||
|
case 'Subscription':
|
||||||
|
return renderSubscriptionNode(node, depth);
|
||||||
|
|
||||||
|
case 'Defer':
|
||||||
|
return renderDeferNode(node, depth);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return `${indent(depth)}<UnknownNode kind="${(node as { kind?: string }).kind ?? 'unknown'}">`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderFetchNode(node: FetchNodePlan, depth = 0): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`${indent(depth)}Fetch(service: "${node.serviceName}") {`);
|
||||||
|
|
||||||
|
if (node.requires) {
|
||||||
|
lines.push(`${indent(depth + 1)}{`);
|
||||||
|
const requires = renderSelectionSet(node.requires, depth + 2);
|
||||||
|
if (requires) lines.push(requires);
|
||||||
|
lines.push(`${indent(depth + 1)}} =>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slice = node.operation.includes('_entities') ? 2 : 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
lines.push(
|
||||||
|
`${indent(depth + 1)}{`,
|
||||||
|
renderMultilineBlock(
|
||||||
|
print(parse(node.operation))
|
||||||
|
.split('\n')
|
||||||
|
.slice(slice, slice * -1)
|
||||||
|
.join('\n'),
|
||||||
|
depth + 2 - slice,
|
||||||
|
),
|
||||||
|
`${indent(depth + 1)}}`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
lines.push(`${indent(depth + 1)}${node.operation}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`${indent(depth)}}`);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderBatchFetchNode(node: BatchFetchNodePlan, depth = 0): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`${indent(depth)}BatchFetch(service: "${node.serviceName}") {`);
|
||||||
|
|
||||||
|
for (let i = 0; i < node.entityBatch.aliases.length; i++) {
|
||||||
|
const alias = node.entityBatch.aliases[i];
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`${indent(depth + 1)}${alias.alias} {`,
|
||||||
|
`${indent(depth + 2)}paths: [`,
|
||||||
|
...alias.paths.map(path => `${indent(depth + 3)}"${renderFlattenPath(path)}"`),
|
||||||
|
`${indent(depth + 2)}]`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const requires = renderSelectionSet(alias.requires, depth + 3);
|
||||||
|
|
||||||
|
if (requires) {
|
||||||
|
lines.push(`${indent(depth + 2)}{`, requires, `${indent(depth + 2)}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < node.entityBatch.aliases.length - 1) {
|
||||||
|
lines.push(`${indent(depth + 1)}}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
lines.push(
|
||||||
|
`${indent(depth + 1)}}`,
|
||||||
|
`${indent(depth + 1)}{`,
|
||||||
|
renderMultilineBlock(
|
||||||
|
print(parse(node.operation)).split('\n').slice(1, -1).join('\n'),
|
||||||
|
depth + 1,
|
||||||
|
),
|
||||||
|
`${indent(depth + 1)}}`,
|
||||||
|
`${indent(depth)}}`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
lines.push(`${indent(depth + 1)}${node.operation}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderFlattenNode(node: FlattenNodePlan, depth = 0): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(
|
||||||
|
`${indent(depth)}Flatten(path: "${renderFlattenPath(node.path)}") {`,
|
||||||
|
renderPlanNode(node.node, depth + 1),
|
||||||
|
`${indent(depth)}}`,
|
||||||
|
);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSequenceNode(node: SequenceNodePlan, depth = 0): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`${indent(depth)}Sequence {`);
|
||||||
|
for (const child of node.nodes) {
|
||||||
|
lines.push(renderPlanNode(child, depth + 1));
|
||||||
|
}
|
||||||
|
lines.push(`${indent(depth)}}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderParallelNode(node: ParallelNodePlan, depth = 0): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`${indent(depth)}Parallel {`);
|
||||||
|
for (const child of node.nodes) {
|
||||||
|
lines.push(renderPlanNode(child, depth + 1));
|
||||||
|
}
|
||||||
|
lines.push(`${indent(depth)}}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderConditionNode(node: ConditionNodePlan, depth = 0): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
if (node.ifClause && !node.elseClause) {
|
||||||
|
lines.push(
|
||||||
|
`${indent(depth)}Include(if: $${node.condition}) {`,
|
||||||
|
renderPlanNode(node.ifClause, depth + 1),
|
||||||
|
`${indent(depth)}}`,
|
||||||
|
);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.ifClause && node.elseClause) {
|
||||||
|
lines.push(
|
||||||
|
`${indent(depth)}Skip(if: $${node.condition}) {`,
|
||||||
|
renderPlanNode(node.elseClause, depth + 1),
|
||||||
|
`${indent(depth)}}`,
|
||||||
|
);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.ifClause && node.elseClause) {
|
||||||
|
lines.push(
|
||||||
|
`${indent(depth)}Condition(if: $${node.condition}) {`,
|
||||||
|
`${indent(depth + 1)}if {`,
|
||||||
|
renderPlanNode(node.ifClause, depth + 2),
|
||||||
|
`${indent(depth + 1)}}`,
|
||||||
|
`${indent(depth + 1)}else {`,
|
||||||
|
renderPlanNode(node.elseClause, depth + 2),
|
||||||
|
`${indent(depth + 1)}}`,
|
||||||
|
`${indent(depth)}}`,
|
||||||
|
);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${indent(depth)}Condition(if: $${node.condition}) {}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSubscriptionNode(node: SubscriptionNodePlan, depth = 0): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(
|
||||||
|
`${indent(depth)}Subscription {`,
|
||||||
|
`${indent(depth + 1)}primary {`,
|
||||||
|
renderPlanNode(node.primary, depth + 2),
|
||||||
|
`${indent(depth + 1)}}`,
|
||||||
|
`${indent(depth)}}`,
|
||||||
|
);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDeferNode(node: DeferNodePlan, depth = 0): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`${indent(depth)}Defer {`, `${indent(depth + 1)}primary {`);
|
||||||
|
if (node.primary.subselection) {
|
||||||
|
lines.push(`${indent(depth + 2)}subselection: ${JSON.stringify(node.primary.subselection)}`);
|
||||||
|
}
|
||||||
|
if (node.primary.node) {
|
||||||
|
lines.push(renderPlanNode(node.primary.node, depth + 2));
|
||||||
|
}
|
||||||
|
lines.push(`${indent(depth + 1)}}`);
|
||||||
|
|
||||||
|
if (node.deferred.length > 0) {
|
||||||
|
lines.push(`${indent(depth + 1)}deferred {`);
|
||||||
|
for (const d of node.deferred) {
|
||||||
|
lines.push(`${indent(depth + 2)}item {`);
|
||||||
|
if (d.label) lines.push(`${indent(depth + 3)}label: ${JSON.stringify(d.label)}`);
|
||||||
|
lines.push(`${indent(depth + 3)}queryPath: [${d.queryPath.join(', ')}]`);
|
||||||
|
if (d.subselection) {
|
||||||
|
lines.push(`${indent(depth + 3)}subselection: ${JSON.stringify(d.subselection)}`);
|
||||||
|
}
|
||||||
|
if (d.depends.length) {
|
||||||
|
lines.push(
|
||||||
|
`${indent(depth + 3)}depends: [${d.depends
|
||||||
|
.map(x => (x.deferLabel ? `${x.id}:${x.deferLabel}` : x.id))
|
||||||
|
.join(', ')}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (d.node) {
|
||||||
|
lines.push(renderPlanNode(d.node, depth + 3));
|
||||||
|
}
|
||||||
|
lines.push(`${indent(depth + 2)}}`);
|
||||||
|
}
|
||||||
|
lines.push(`${indent(depth + 1)}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`${indent(depth)}}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMultilineBlock(value: string, depth = 0): string {
|
||||||
|
return value
|
||||||
|
.split('\n')
|
||||||
|
.map(line => `${indent(depth)}${line}`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExtractedOperation = {
|
||||||
|
path: string;
|
||||||
|
nodeKind: 'Fetch' | 'BatchFetch';
|
||||||
|
serviceName: string;
|
||||||
|
operationKind?: string | null;
|
||||||
|
operationName?: string | null;
|
||||||
|
graphql: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function extractOperations(plan: QueryPlan): ExtractedOperation[] {
|
||||||
|
if (!plan.node) return [];
|
||||||
|
return extractOperationsFromNode(plan.node, 'root');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOperationsFromNode(node: PlanNode, path: string): ExtractedOperation[] {
|
||||||
|
switch (node.kind) {
|
||||||
|
case 'Fetch':
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
path,
|
||||||
|
nodeKind: 'Fetch',
|
||||||
|
serviceName: node.serviceName,
|
||||||
|
operationKind: node.operationKind ?? null,
|
||||||
|
operationName: node.operationName ?? null,
|
||||||
|
graphql: node.operation,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'BatchFetch':
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
path,
|
||||||
|
nodeKind: 'BatchFetch',
|
||||||
|
serviceName: node.serviceName,
|
||||||
|
operationKind: node.operationKind ?? null,
|
||||||
|
operationName: node.operationName ?? null,
|
||||||
|
graphql: node.operation,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'Flatten':
|
||||||
|
return extractOperationsFromNode(
|
||||||
|
node.node,
|
||||||
|
`${path}.flatten(${renderFlattenPath(node.path)})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Sequence':
|
||||||
|
return node.nodes.flatMap((child, i) =>
|
||||||
|
extractOperationsFromNode(child, `${path}.sequence[${i}]`),
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Parallel':
|
||||||
|
return node.nodes.flatMap((child, i) =>
|
||||||
|
extractOperationsFromNode(child, `${path}.parallel[${i}]`),
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Condition': {
|
||||||
|
const out: ExtractedOperation[] = [];
|
||||||
|
if (node.ifClause) {
|
||||||
|
out.push(...extractOperationsFromNode(node.ifClause, `${path}.if($${node.condition})`));
|
||||||
|
}
|
||||||
|
if (node.elseClause) {
|
||||||
|
out.push(...extractOperationsFromNode(node.elseClause, `${path}.else($${node.condition})`));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Subscription':
|
||||||
|
return extractOperationsFromNode(node.primary, `${path}.subscription.primary`);
|
||||||
|
|
||||||
|
case 'Defer': {
|
||||||
|
const out: ExtractedOperation[] = [];
|
||||||
|
if (node.primary.node) {
|
||||||
|
out.push(...extractOperationsFromNode(node.primary.node, `${path}.defer.primary`));
|
||||||
|
}
|
||||||
|
node.deferred.forEach((d, i) => {
|
||||||
|
if (d.node) {
|
||||||
|
out.push(
|
||||||
|
...extractOperationsFromNode(
|
||||||
|
d.node,
|
||||||
|
`${path}.defer.deferred[${i}]${d.label ? `(${d.label})` : ''}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryPlanNode extends FlowNode {
|
||||||
|
kind: PlanNode['kind'] | 'Root';
|
||||||
|
children?: QueryPlanNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function visitNode(
|
||||||
|
node: PlanNode,
|
||||||
|
parentNode: QueryPlanNode | null,
|
||||||
|
nodes: QueryPlanNode[],
|
||||||
|
contentPrefix?: React.ReactNode,
|
||||||
|
detailsContent?: string,
|
||||||
|
): QueryPlanNode {
|
||||||
|
let result: QueryPlanNode | null = {
|
||||||
|
id: uuidv4(),
|
||||||
|
title: node.kind,
|
||||||
|
kind: node.kind as QueryPlanNode['kind'],
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (node.kind) {
|
||||||
|
case 'Fetch':
|
||||||
|
result.content = () => {
|
||||||
|
const entity = (node.requires?.[0] as SelectionInlineFragment)?.typeCondition;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{contentPrefix}
|
||||||
|
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
|
||||||
|
<span className="font-medium">Service</span>
|
||||||
|
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
{node.serviceName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{entity && (
|
||||||
|
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
|
||||||
|
<span className="font-medium">Entity</span>
|
||||||
|
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
{entity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="border-t border-dashed">
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="link" className="h-auto w-full pt-2 text-xs">
|
||||||
|
Show details
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl! max-h-150 h-full w-full">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Fetch</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="h-full overflow-hidden rounded-sm border">
|
||||||
|
<Editor value={detailsContent ?? renderFetchNode(node)} language="graphql" />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'BatchFetch':
|
||||||
|
result.headerSuffix = () => {
|
||||||
|
const totalPaths = node.entityBatch.aliases.reduce(
|
||||||
|
(acc, alias) => acc + alias.paths.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-muted flex items-center gap-1 rounded-md p-0.5 pl-1 font-mono text-xs leading-none">
|
||||||
|
Total paths:
|
||||||
|
<div className="bg-primary/10 border-primary text-primary rounded-sm border px-1 text-xs font-medium leading-none">
|
||||||
|
{totalPaths}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
result.content = () => {
|
||||||
|
return (
|
||||||
|
<div className="*:border-border flex flex-col gap-2 *:border-b *:border-dashed *:pb-2 *:last:border-b-0 *:last:pb-0">
|
||||||
|
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
|
||||||
|
<span className="font-medium">Service</span>
|
||||||
|
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
{node.serviceName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{node.entityBatch.aliases.map(alias => {
|
||||||
|
return (
|
||||||
|
<div key={alias.alias} className="grid gap-2">
|
||||||
|
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
|
||||||
|
<span className="font-medium">Path</span>
|
||||||
|
{alias.paths.length > 1 ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="text-secondary-foreground cursor-auto overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
<div className="bg-card rounded-sm border px-1 text-xs font-medium">
|
||||||
|
{alias.paths.length}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="whitespace-pre-wrap font-mono">
|
||||||
|
{alias.paths.map(path => renderFlattenPath(path)).join(',\n')}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="text-secondary-foreground cursor-auto overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
{renderFlattenPath(alias.paths[0])}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{renderFlattenPath(alias.paths[0])}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
|
||||||
|
<span className="font-medium">Enitity</span>
|
||||||
|
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
{(alias.requires[0] as SelectionInlineFragment).typeCondition}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="-mt-2">
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="link" className="h-auto w-full pt-2 text-xs">
|
||||||
|
Show details
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-2xl! max-h-150 h-full w-full"
|
||||||
|
onMouseDown={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>BatchFetch</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="h-full overflow-hidden rounded-sm border">
|
||||||
|
<Editor value={renderBatchFetchNode(node)} language="graphql" />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Flatten':
|
||||||
|
result = null;
|
||||||
|
|
||||||
|
visitNode(
|
||||||
|
node.node,
|
||||||
|
result,
|
||||||
|
nodes,
|
||||||
|
<>
|
||||||
|
{contentPrefix}
|
||||||
|
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
|
||||||
|
<span className="font-medium">Path</span>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="text-secondary-foreground cursor-auto overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
{renderFlattenPath(node.path)}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{renderFlattenPath(node.path)}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
detailsContent ?? renderFlattenNode(node),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Sequence': {
|
||||||
|
result = null;
|
||||||
|
|
||||||
|
let prevChild: QueryPlanNode | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < node.nodes.length; i++) {
|
||||||
|
const child = node.nodes[i];
|
||||||
|
|
||||||
|
const childNode = visitNode(
|
||||||
|
child,
|
||||||
|
prevChild,
|
||||||
|
i === 0 ? [] : nodes,
|
||||||
|
contentPrefix,
|
||||||
|
detailsContent,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
result = childNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevChild) {
|
||||||
|
prevChild.next = [childNode.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
prevChild = childNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Parallel':
|
||||||
|
result.children = [];
|
||||||
|
|
||||||
|
for (const child of node.nodes) {
|
||||||
|
visitNode(child, result, result.children, contentPrefix, detailsContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Condition':
|
||||||
|
result = null;
|
||||||
|
|
||||||
|
if (node.ifClause) {
|
||||||
|
visitNode(
|
||||||
|
node.ifClause,
|
||||||
|
result,
|
||||||
|
nodes,
|
||||||
|
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
|
||||||
|
<span className="font-medium">Include</span>
|
||||||
|
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
if: ${node.condition}
|
||||||
|
</span>
|
||||||
|
</div>,
|
||||||
|
renderConditionNode(node),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (node.elseClause) {
|
||||||
|
visitNode(
|
||||||
|
node.elseClause,
|
||||||
|
result,
|
||||||
|
nodes,
|
||||||
|
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
|
||||||
|
<span className="font-medium">Skip</span>
|
||||||
|
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
if: ${node.condition}
|
||||||
|
</span>
|
||||||
|
</div>,
|
||||||
|
renderConditionNode(node),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Subscription':
|
||||||
|
visitNode(node.primary, result, nodes, contentPrefix, detailsContent);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Defer':
|
||||||
|
if (node.primary.node) {
|
||||||
|
visitNode(node.primary.node, result, nodes, contentPrefix, detailsContent);
|
||||||
|
}
|
||||||
|
for (const deferred of node.deferred) {
|
||||||
|
if (deferred.node) {
|
||||||
|
visitNode(deferred.node, result, nodes, contentPrefix, detailsContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentNode && result) {
|
||||||
|
parentNode.next = [...(parentNode.next ?? []), result.id!];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
result.icon = queryPlanNodeIcon(result.kind);
|
||||||
|
nodes.push(result as QueryPlanNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as QueryPlanNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const queryPlanNodeIcon = (
|
||||||
|
kind: QueryPlanNode['kind'],
|
||||||
|
): ((props: LucideProps) => React.ReactNode) => {
|
||||||
|
return (props: LucideProps) => {
|
||||||
|
switch (kind) {
|
||||||
|
case 'Root':
|
||||||
|
return (
|
||||||
|
<GraphQLIcon {...props} className={cn(props.className, 'size-6 min-w-6 text-pink-500')} />
|
||||||
|
);
|
||||||
|
case 'Fetch':
|
||||||
|
return <Box {...props} />;
|
||||||
|
case 'BatchFetch':
|
||||||
|
return <Boxes {...props} />;
|
||||||
|
case 'Flatten':
|
||||||
|
return <Layers2Icon {...props} />;
|
||||||
|
case 'Sequence':
|
||||||
|
return <ListOrderedIcon {...props} />;
|
||||||
|
case 'Parallel':
|
||||||
|
return <NetworkIcon {...props} />;
|
||||||
|
case 'Condition':
|
||||||
|
return <GitForkIcon {...props} className={cn('rotate-90', props.className)} />;
|
||||||
|
case 'Subscription':
|
||||||
|
return <UnlinkIcon {...props} />;
|
||||||
|
case 'Defer':
|
||||||
|
return <ClockIcon {...props} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function QueryPlanTree(props: { plan: QueryPlan }) {
|
||||||
|
const nodes = useMemo(() => {
|
||||||
|
const nodes: QueryPlanNode[] = [];
|
||||||
|
|
||||||
|
const rootNode: QueryPlanNode = {
|
||||||
|
id: uuidv4(),
|
||||||
|
title: '',
|
||||||
|
kind: 'Root',
|
||||||
|
maxWidth: 42,
|
||||||
|
};
|
||||||
|
|
||||||
|
nodes.push(rootNode);
|
||||||
|
|
||||||
|
if (props.plan.node) {
|
||||||
|
visitNode(props.plan.node, rootNode, nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes.map(node => {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
icon: queryPlanNodeIcon(node.kind),
|
||||||
|
} satisfies FlowNode;
|
||||||
|
});
|
||||||
|
}, [props.plan]);
|
||||||
|
|
||||||
|
return <Flow nodes={nodes} />;
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,56 @@ import { useCallback, useState } from 'react';
|
||||||
export type LaboratorySettings = {
|
export type LaboratorySettings = {
|
||||||
fetch: {
|
fetch: {
|
||||||
credentials: 'include' | 'omit' | 'same-origin';
|
credentials: 'include' | 'omit' | 'same-origin';
|
||||||
|
timeout?: number;
|
||||||
|
retry?: number;
|
||||||
|
useGETForQueries?: boolean;
|
||||||
|
};
|
||||||
|
subscriptions: {
|
||||||
|
protocol: 'SSE' | 'GRAPHQL_SSE' | 'WS' | 'LEGACY_WS';
|
||||||
|
};
|
||||||
|
introspection: {
|
||||||
|
method?: 'GET' | 'POST';
|
||||||
|
schemaDescription?: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const defaultLaboratorySettings: LaboratorySettings = {
|
||||||
|
fetch: {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
timeout: 10000,
|
||||||
|
retry: 3,
|
||||||
|
useGETForQueries: false,
|
||||||
|
},
|
||||||
|
subscriptions: {
|
||||||
|
protocol: 'WS',
|
||||||
|
},
|
||||||
|
introspection: {
|
||||||
|
method: 'POST',
|
||||||
|
schemaDescription: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeLaboratorySettings = (
|
||||||
|
settings?: Partial<LaboratorySettings> | null,
|
||||||
|
): LaboratorySettings => ({
|
||||||
|
fetch: {
|
||||||
|
credentials: settings?.fetch?.credentials ?? defaultLaboratorySettings.fetch.credentials,
|
||||||
|
timeout: settings?.fetch?.timeout ?? defaultLaboratorySettings.fetch.timeout,
|
||||||
|
retry: settings?.fetch?.retry ?? defaultLaboratorySettings.fetch.retry,
|
||||||
|
useGETForQueries:
|
||||||
|
settings?.fetch?.useGETForQueries ?? defaultLaboratorySettings.fetch.useGETForQueries,
|
||||||
|
},
|
||||||
|
subscriptions: {
|
||||||
|
protocol: settings?.subscriptions?.protocol ?? defaultLaboratorySettings.subscriptions.protocol,
|
||||||
|
},
|
||||||
|
introspection: {
|
||||||
|
method: settings?.introspection?.method ?? defaultLaboratorySettings.introspection.method,
|
||||||
|
schemaDescription:
|
||||||
|
settings?.introspection?.schemaDescription ??
|
||||||
|
defaultLaboratorySettings.introspection.schemaDescription,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export interface LaboratorySettingsState {
|
export interface LaboratorySettingsState {
|
||||||
settings: LaboratorySettings;
|
settings: LaboratorySettings;
|
||||||
}
|
}
|
||||||
|
|
@ -19,17 +66,14 @@ export const useSettings = (props: {
|
||||||
onSettingsChange?: (settings: LaboratorySettings | null) => void;
|
onSettingsChange?: (settings: LaboratorySettings | null) => void;
|
||||||
}): LaboratorySettingsState & LaboratorySettingsActions => {
|
}): LaboratorySettingsState & LaboratorySettingsActions => {
|
||||||
const [settings, _setSettings] = useState<LaboratorySettings>(
|
const [settings, _setSettings] = useState<LaboratorySettings>(
|
||||||
props.defaultSettings ?? {
|
normalizeLaboratorySettings(props.defaultSettings),
|
||||||
fetch: {
|
|
||||||
credentials: 'same-origin',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const setSettings = useCallback(
|
const setSettings = useCallback(
|
||||||
(settings: LaboratorySettings) => {
|
(settings: LaboratorySettings) => {
|
||||||
_setSettings(settings);
|
const normalizedSettings = normalizeLaboratorySettings(settings);
|
||||||
props.onSettingsChange?.(settings);
|
_setSettings(normalizedSettings);
|
||||||
|
props.onSettingsChange?.(normalizedSettings);
|
||||||
},
|
},
|
||||||
[props],
|
[props],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,3 +17,30 @@ export function splitIdentifier(input: string): string[] {
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.map(w => w.toLowerCase());
|
.map(w => w.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function asyncInterval(
|
||||||
|
fn: () => Promise<void>,
|
||||||
|
delay: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
while (!signal?.aborted) {
|
||||||
|
await fn();
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(resolve, delay);
|
||||||
|
|
||||||
|
signal?.addEventListener(
|
||||||
|
'abort',
|
||||||
|
() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'));
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAsyncIterable<T>(val: unknown): val is AsyncIterable<T> {
|
||||||
|
return typeof Object(val)[Symbol.asyncIterator] === 'function';
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tslib": "2.8.1",
|
"tslib": "2.8.1",
|
||||||
"vitest": "4.0.9"
|
"vitest": "4.1.3"
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"typescript": {
|
"typescript": {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,50 @@
|
||||||
# @graphql-yoga/render-graphiql
|
# @graphql-yoga/render-graphiql
|
||||||
|
|
||||||
|
## 0.1.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#7989](https://github.com/graphql-hive/console/pull/7989)
|
||||||
|
[`863f920`](https://github.com/graphql-hive/console/commit/863f920b86505a3d84c9001fef1c3e8a723bdca9)
|
||||||
|
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - Enhanced behavior when no collection
|
||||||
|
exists and the user attempts to save an operation, along with the ability to edit the collection
|
||||||
|
name.
|
||||||
|
- Updated dependencies
|
||||||
|
[[`863f920`](https://github.com/graphql-hive/console/commit/863f920b86505a3d84c9001fef1c3e8a723bdca9)]:
|
||||||
|
- @graphql-hive/laboratory@0.1.5
|
||||||
|
|
||||||
|
## 0.1.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#7963](https://github.com/graphql-hive/console/pull/7963)
|
||||||
|
[`4a8bd4f`](https://github.com/graphql-hive/console/commit/4a8bd4fd1b4fbb34076e97d06ed1341432de451d)
|
||||||
|
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - Implemented functionality that allows
|
||||||
|
to have multiple queries in same operation while working only with focused one (run button, query
|
||||||
|
builder)
|
||||||
|
|
||||||
|
- [#7892](https://github.com/graphql-hive/console/pull/7892)
|
||||||
|
[`fab4b03`](https://github.com/graphql-hive/console/commit/fab4b03ace2ff20759bbcd33465d00a5cbbc4c97)
|
||||||
|
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - Hive Laboratory renders Hive Router
|
||||||
|
query plan if included in response extensions
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
[[`4a8bd4f`](https://github.com/graphql-hive/console/commit/4a8bd4fd1b4fbb34076e97d06ed1341432de451d),
|
||||||
|
[`fab4b03`](https://github.com/graphql-hive/console/commit/fab4b03ace2ff20759bbcd33465d00a5cbbc4c97)]:
|
||||||
|
- @graphql-hive/laboratory@0.1.4
|
||||||
|
|
||||||
|
## 0.1.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#7888](https://github.com/graphql-hive/console/pull/7888)
|
||||||
|
[`574a5d8`](https://github.com/graphql-hive/console/commit/574a5d823e71ca1d0628897a73e2fab1d0d5bfe0)
|
||||||
|
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - If schema introspection isn't provided
|
||||||
|
as property to Laboratory, lab will start interval to fetch schema every second.
|
||||||
|
- Updated dependencies
|
||||||
|
[[`574a5d8`](https://github.com/graphql-hive/console/commit/574a5d823e71ca1d0628897a73e2fab1d0d5bfe0)]:
|
||||||
|
- @graphql-hive/laboratory@0.1.3
|
||||||
|
|
||||||
## 0.1.2
|
## 0.1.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@graphql-hive/render-laboratory",
|
"name": "@graphql-hive/render-laboratory",
|
||||||
"version": "0.1.2",
|
"version": "0.1.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "",
|
"description": "",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { GraphiQLOptions } from 'graphql-yoga';
|
import type { GraphiQLOptions } from 'graphql-yoga';
|
||||||
|
import type { LaboratoryProps } from '@graphql-hive/laboratory';
|
||||||
import {
|
import {
|
||||||
editorWorkerService,
|
editorWorkerService,
|
||||||
favicon,
|
favicon,
|
||||||
|
|
@ -9,6 +10,30 @@ import {
|
||||||
typescriptWorker,
|
typescriptWorker,
|
||||||
} from './laboratory.js';
|
} from './laboratory.js';
|
||||||
|
|
||||||
|
const mapGraphiQLOptionsToLaboratoryProps = (opts?: GraphiQLOptions): LaboratoryProps => {
|
||||||
|
if (!opts) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultSettings: {
|
||||||
|
fetch: {
|
||||||
|
credentials: opts.credentials ?? 'same-origin',
|
||||||
|
timeout: opts.timeout,
|
||||||
|
retry: opts.retry,
|
||||||
|
useGETForQueries: opts.useGETForQueries,
|
||||||
|
},
|
||||||
|
subscriptions: {
|
||||||
|
protocol: opts.subscriptionsProtocol ?? 'WS',
|
||||||
|
},
|
||||||
|
introspection: {
|
||||||
|
method: opts.method,
|
||||||
|
schemaDescription: opts.schemaDescription,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies LaboratoryProps;
|
||||||
|
};
|
||||||
|
|
||||||
export const renderLaboratory = (opts?: GraphiQLOptions) => /* HTML */ `
|
export const renderLaboratory = (opts?: GraphiQLOptions) => /* HTML */ `
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
@ -63,7 +88,10 @@ export const renderLaboratory = (opts?: GraphiQLOptions) => /* HTML */ `
|
||||||
|
|
||||||
${js};
|
${js};
|
||||||
|
|
||||||
HiveLaboratory.renderLaboratory(window.document.querySelector('#root'));
|
HiveLaboratory.renderLaboratory(
|
||||||
|
window.document.querySelector('#root'),
|
||||||
|
${JSON.stringify(mapGraphiQLOptionsToLaboratoryProps(opts))},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
target/**
|
|
||||||
|
|
@ -1,352 +0,0 @@
|
||||||
# 16.10.2024
|
|
||||||
|
|
||||||
## 3.0.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- [#7815](https://github.com/graphql-hive/console/pull/7815)
|
|
||||||
[`078e661`](https://github.com/graphql-hive/console/commit/078e6611cbbd94b2ba325dc35bfbf636d2458f24)
|
|
||||||
Thanks [@ardatan](https://github.com/ardatan)! - Bump `hive-console-sdk` to `0.3.7` to pin
|
|
||||||
`graphql-tools` to a compatible version. The previous `hive-console-sdk@0.3.5` allowed
|
|
||||||
`graphql-tools@^0.5` which resolves to `0.5.2`, a version that removes public API traits
|
|
||||||
(`SchemaDocumentExtension`, `FieldByNameExtension`, etc.) that `hive-console-sdk` depends on.
|
|
||||||
|
|
||||||
## 3.0.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- [#7585](https://github.com/graphql-hive/console/pull/7585)
|
|
||||||
[`9a6e8a9`](https://github.com/graphql-hive/console/commit/9a6e8a9fe7f337c4a2ee6b7375281f5ae42a38e3)
|
|
||||||
Thanks [@dotansimha](https://github.com/dotansimha)! - Upgrade to latest `hive-console-sdk` and
|
|
||||||
drop direct dependency on `graphql-tools`
|
|
||||||
|
|
||||||
## 3.0.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- [#7476](https://github.com/graphql-hive/console/pull/7476)
|
|
||||||
[`f4d5f7e`](https://github.com/graphql-hive/console/commit/f4d5f7ee5bf50bc8b621b011696d43757de2e071)
|
|
||||||
Thanks [@kamilkisiela](https://github.com/kamilkisiela)! - Updated `hive-apollo-router-plugin` to
|
|
||||||
use `hive-console-sdk` from crates.io instead of a local dependency. The plugin now uses
|
|
||||||
`graphql-tools::parser` instead of `graphql-parser` to leverage the parser we now ship in
|
|
||||||
`graphql-tools` crate.
|
|
||||||
|
|
||||||
## 3.0.0
|
|
||||||
|
|
||||||
### Major Changes
|
|
||||||
|
|
||||||
- [#7379](https://github.com/graphql-hive/console/pull/7379)
|
|
||||||
[`b134461`](https://github.com/graphql-hive/console/commit/b13446109d9663ccabef07995eb25cf9dff34f37)
|
|
||||||
Thanks [@ardatan](https://github.com/ardatan)! - - Multiple endpoints support for `HiveRegistry`
|
|
||||||
and `PersistedOperationsPlugin`
|
|
||||||
|
|
||||||
Breaking Changes:
|
|
||||||
|
|
||||||
- Now there is no `endpoint` field in the configuration, it has been replaced with `endpoints`,
|
|
||||||
which is an array of strings. You are not affected if you use environment variables to set the
|
|
||||||
endpoint.
|
|
||||||
|
|
||||||
```diff
|
|
||||||
HiveRegistry::new(
|
|
||||||
Some(
|
|
||||||
HiveRegistryConfig {
|
|
||||||
- endpoint: String::from("CDN_ENDPOINT"),
|
|
||||||
+ endpoints: vec![String::from("CDN_ENDPOINT1"), String::from("CDN_ENDPOINT2")],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- [#7479](https://github.com/graphql-hive/console/pull/7479)
|
|
||||||
[`382b481`](https://github.com/graphql-hive/console/commit/382b481e980e588e3e6cf7831558b2d0811253f5)
|
|
||||||
Thanks [@ardatan](https://github.com/ardatan)! - Update dependencies
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
[[`b134461`](https://github.com/graphql-hive/console/commit/b13446109d9663ccabef07995eb25cf9dff34f37),
|
|
||||||
[`b134461`](https://github.com/graphql-hive/console/commit/b13446109d9663ccabef07995eb25cf9dff34f37)]:
|
|
||||||
- hive-console-sdk-rs@0.3.0
|
|
||||||
|
|
||||||
## 2.3.6
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
[[`0ac2e06`](https://github.com/graphql-hive/console/commit/0ac2e06fd6eb94c9d9817f78faf6337118f945eb),
|
|
||||||
[`4b796f9`](https://github.com/graphql-hive/console/commit/4b796f95bbc0fc37aac2c3a108a6165858b42b49),
|
|
||||||
[`a9905ec`](https://github.com/graphql-hive/console/commit/a9905ec7198cf1bec977a281c5021e0ef93c2c34)]:
|
|
||||||
- hive-console-sdk-rs@0.2.3
|
|
||||||
|
|
||||||
## 2.3.5
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
[[`24c0998`](https://github.com/graphql-hive/console/commit/24c099818e4dfec43feea7775e8189d0f305a10c)]:
|
|
||||||
- hive-console-sdk-rs@0.2.2
|
|
||||||
|
|
||||||
## 2.3.4
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
[[`69e2f74`](https://github.com/graphql-hive/console/commit/69e2f74ab867ee5e97bbcfcf6a1b69bb23ccc7b2)]:
|
|
||||||
- hive-console-sdk-rs@0.2.1
|
|
||||||
|
|
||||||
## 2.3.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
[[`cc6cd28`](https://github.com/graphql-hive/console/commit/cc6cd28eb52d774683c088ce456812d3541d977d)]:
|
|
||||||
- hive-console-sdk-rs@0.2.0
|
|
||||||
|
|
||||||
## 2.3.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
[[`d8f6e25`](https://github.com/graphql-hive/console/commit/d8f6e252ee3cd22948eb0d64b9d25c9b04dba47c)]:
|
|
||||||
- hive-console-sdk-rs@0.1.1
|
|
||||||
|
|
||||||
## 2.3.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- [#7196](https://github.com/graphql-hive/console/pull/7196)
|
|
||||||
[`7878736`](https://github.com/graphql-hive/console/commit/7878736643578ab23d95412b893c091e32691e60)
|
|
||||||
Thanks [@ardatan](https://github.com/ardatan)! - Breaking;
|
|
||||||
|
|
||||||
- `UsageAgent` now accepts `Duration` for `connect_timeout` and `request_timeout` instead of
|
|
||||||
`u64`.
|
|
||||||
- `SupergraphFetcher` now accepts `Duration` for `connect_timeout` and `request_timeout` instead
|
|
||||||
of `u64`.
|
|
||||||
- `PersistedDocumentsManager` now accepts `Duration` for `connect_timeout` and `request_timeout`
|
|
||||||
instead of `u64`.
|
|
||||||
- Use original `graphql-parser` and `graphql-tools` crates instead of forked versions.
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
[[`7878736`](https://github.com/graphql-hive/console/commit/7878736643578ab23d95412b893c091e32691e60)]:
|
|
||||||
- hive-console-sdk-rs@0.1.0
|
|
||||||
|
|
||||||
## 2.3.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- [#7143](https://github.com/graphql-hive/console/pull/7143)
|
|
||||||
[`b80e896`](https://github.com/graphql-hive/console/commit/b80e8960f492e3bcfe1012caab294d9066d86fe3)
|
|
||||||
Thanks [@ardatan](https://github.com/ardatan)! - Extract Hive Console integration implementation
|
|
||||||
into a new package `hive-console-sdk` which can be used by any Rust library for Hive Console
|
|
||||||
integration
|
|
||||||
|
|
||||||
It also includes a refactor to use less Mutexes like replacing `lru` + `Mutex` with the
|
|
||||||
thread-safe `moka` package. Only one place that handles queueing uses `Mutex` now.
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- [#7143](https://github.com/graphql-hive/console/pull/7143)
|
|
||||||
[`b80e896`](https://github.com/graphql-hive/console/commit/b80e8960f492e3bcfe1012caab294d9066d86fe3)
|
|
||||||
Thanks [@ardatan](https://github.com/ardatan)! - Fixes a bug when Persisted Operations are enabled
|
|
||||||
by default which should be explicitly enabled
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
[[`b80e896`](https://github.com/graphql-hive/console/commit/b80e8960f492e3bcfe1012caab294d9066d86fe3)]:
|
|
||||||
- hive-console-sdk-rs@0.0.1
|
|
||||||
|
|
||||||
## 2.2.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- [#6906](https://github.com/graphql-hive/console/pull/6906)
|
|
||||||
[`7fe1c27`](https://github.com/graphql-hive/console/commit/7fe1c271a596353d23ad770ce667f7781be6cc13)
|
|
||||||
Thanks [@egoodwinx](https://github.com/egoodwinx)! - Advanced breaking change detection for inputs
|
|
||||||
and arguments.
|
|
||||||
|
|
||||||
With this change, inputs and arguments will now be collected from the GraphQL operations executed
|
|
||||||
by the router, and will be reported to Hive Console.
|
|
||||||
|
|
||||||
Additional references:
|
|
||||||
|
|
||||||
- https://github.com/graphql-hive/console/pull/6764
|
|
||||||
- https://github.com/graphql-hive/console/issues/6649
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- [#7173](https://github.com/graphql-hive/console/pull/7173)
|
|
||||||
[`eba62e1`](https://github.com/graphql-hive/console/commit/eba62e13f658f00a4a8f6db6b4d8501070fbed45)
|
|
||||||
Thanks [@dotansimha](https://github.com/dotansimha)! - Use the correct plugin version in the
|
|
||||||
User-Agent header used for Console requests
|
|
||||||
|
|
||||||
- [#6906](https://github.com/graphql-hive/console/pull/6906)
|
|
||||||
[`7fe1c27`](https://github.com/graphql-hive/console/commit/7fe1c271a596353d23ad770ce667f7781be6cc13)
|
|
||||||
Thanks [@egoodwinx](https://github.com/egoodwinx)! - Update Rust version to 1.90
|
|
||||||
|
|
||||||
## 2.1.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- [#6753](https://github.com/graphql-hive/console/pull/6753)
|
|
||||||
[`7ef800e`](https://github.com/graphql-hive/console/commit/7ef800e8401a4e3fda4e8d1208b940ad6743449e)
|
|
||||||
Thanks [@Intellicode](https://github.com/Intellicode)! - fix tmp dir filename
|
|
||||||
|
|
||||||
## 2.1.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- [#6788](https://github.com/graphql-hive/console/pull/6788)
|
|
||||||
[`6f0af0e`](https://github.com/graphql-hive/console/commit/6f0af0eb712ce358b212b335f11d4a86ede08931)
|
|
||||||
Thanks [@dotansimha](https://github.com/dotansimha)! - Bump version to trigger release, fix
|
|
||||||
lockfile
|
|
||||||
|
|
||||||
## 2.1.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- [#6714](https://github.com/graphql-hive/console/pull/6714)
|
|
||||||
[`3f823c9`](https://github.com/graphql-hive/console/commit/3f823c9e1f3bd5fd8fde4e375a15f54a9d5b4b4e)
|
|
||||||
Thanks [@github-actions](https://github.com/apps/github-actions)! - Updated internal Apollo crates
|
|
||||||
to get downstream fix for advisories. See
|
|
||||||
https://github.com/apollographql/router/releases/tag/v2.1.1
|
|
||||||
|
|
||||||
## 2.1.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- [#6577](https://github.com/graphql-hive/console/pull/6577)
|
|
||||||
[`c5d7822`](https://github.com/graphql-hive/console/commit/c5d78221b6c088f2377e6491b5bd3c7799d53e94)
|
|
||||||
Thanks [@dotansimha](https://github.com/dotansimha)! - Add support for providing a target for
|
|
||||||
usage reporting with organization access tokens.
|
|
||||||
|
|
||||||
This can either be a slug following the format `$organizationSlug/$projectSlug/$targetSlug` (e.g
|
|
||||||
`the-guild/graphql-hive/staging`) or an UUID (e.g. `a0f4c605-6541-4350-8cfe-b31f21a4bf80`).
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ... other apollo-router configuration
|
|
||||||
plugins:
|
|
||||||
hive.usage:
|
|
||||||
enabled: true
|
|
||||||
registry_token: 'ORGANIZATION_ACCESS_TOKEN'
|
|
||||||
target: 'my-org/my-project/my-target'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2.0.0
|
|
||||||
|
|
||||||
### Major Changes
|
|
||||||
|
|
||||||
- [#6549](https://github.com/graphql-hive/console/pull/6549)
|
|
||||||
[`158b63b`](https://github.com/graphql-hive/console/commit/158b63b4f217bf08f59dbef1fa14553106074cc9)
|
|
||||||
Thanks [@dotansimha](https://github.com/dotansimha)! - Updated core dependnecies (body, http) to
|
|
||||||
match apollo-router v2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- [#6549](https://github.com/graphql-hive/console/pull/6549)
|
|
||||||
[`158b63b`](https://github.com/graphql-hive/console/commit/158b63b4f217bf08f59dbef1fa14553106074cc9)
|
|
||||||
Thanks [@dotansimha](https://github.com/dotansimha)! - Updated thiserror, jsonschema, lru, rand to
|
|
||||||
latest and adjust the code
|
|
||||||
|
|
||||||
## 1.1.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- [#6383](https://github.com/graphql-hive/console/pull/6383)
|
|
||||||
[`ec356a7`](https://github.com/graphql-hive/console/commit/ec356a7784d1f59722f80a69f501f1f250b2f6b2)
|
|
||||||
Thanks [@kamilkisiela](https://github.com/kamilkisiela)! - Collect custom scalars from arguments
|
|
||||||
and input object fields
|
|
||||||
|
|
||||||
## 1.1.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- [#5732](https://github.com/graphql-hive/console/pull/5732)
|
|
||||||
[`1d3c566`](https://github.com/graphql-hive/console/commit/1d3c566ddcf5eb31c68545931da32bcdf4b8a047)
|
|
||||||
Thanks [@dotansimha](https://github.com/dotansimha)! - Updated Apollo-Router custom plugin for
|
|
||||||
Hive to use Usage reporting spec v2.
|
|
||||||
[Learn more](https://the-guild.dev/graphql/hive/docs/specs/usage-reports)
|
|
||||||
|
|
||||||
- [#5732](https://github.com/graphql-hive/console/pull/5732)
|
|
||||||
[`1d3c566`](https://github.com/graphql-hive/console/commit/1d3c566ddcf5eb31c68545931da32bcdf4b8a047)
|
|
||||||
Thanks [@dotansimha](https://github.com/dotansimha)! - Add support for persisted documents using
|
|
||||||
Hive App Deployments.
|
|
||||||
[Learn more](https://the-guild.dev/graphql/hive/product-updates/2024-07-30-persisted-documents-app-deployments-preview)
|
|
||||||
|
|
||||||
## 1.0.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- [#6057](https://github.com/graphql-hive/console/pull/6057)
|
|
||||||
[`e4f8b0a`](https://github.com/graphql-hive/console/commit/e4f8b0a51d1158da966a719f321bc13e5af39ea0)
|
|
||||||
Thanks [@kamilkisiela](https://github.com/kamilkisiela)! - Explain what Hive is in README
|
|
||||||
|
|
||||||
## 1.0.0
|
|
||||||
|
|
||||||
### Major Changes
|
|
||||||
|
|
||||||
- [#5941](https://github.com/graphql-hive/console/pull/5941)
|
|
||||||
[`762bcd8`](https://github.com/graphql-hive/console/commit/762bcd83941d7854873f6670580ae109c4901dea)
|
|
||||||
Thanks [@dotansimha](https://github.com/dotansimha)! - Release v1 of Hive plugin for apollo-router
|
|
||||||
|
|
||||||
## 0.1.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- [#5991](https://github.com/graphql-hive/console/pull/5991)
|
|
||||||
[`1ea4df9`](https://github.com/graphql-hive/console/commit/1ea4df95b5fcef85f19caf682a827baf1849a28d)
|
|
||||||
Thanks [@dotansimha](https://github.com/dotansimha)! - Improvements to release pipeline and added
|
|
||||||
missing metadata to Cargo file
|
|
||||||
|
|
||||||
## 0.1.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- [#5930](https://github.com/graphql-hive/console/pull/5930)
|
|
||||||
[`1b7acd6`](https://github.com/graphql-hive/console/commit/1b7acd6978391e402fe04cc752b5e61ec05d0f03)
|
|
||||||
Thanks [@dotansimha](https://github.com/dotansimha)! - Fixes for Crate publishing flow
|
|
||||||
|
|
||||||
## 0.1.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- [#5922](https://github.com/graphql-hive/console/pull/5922)
|
|
||||||
[`28c6da8`](https://github.com/graphql-hive/console/commit/28c6da8b446d62dcc4460be946fe3aecdbed858d)
|
|
||||||
Thanks [@dotansimha](https://github.com/dotansimha)! - Initial release of Hive plugin for
|
|
||||||
Apollo-Router
|
|
||||||
|
|
||||||
## 0.0.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- [#5898](https://github.com/graphql-hive/console/pull/5898)
|
|
||||||
[`1a92d7d`](https://github.com/graphql-hive/console/commit/1a92d7decf9d0593450e81b394d12c92f40c2b3d)
|
|
||||||
Thanks [@dotansimha](https://github.com/dotansimha)! - Initial release of
|
|
||||||
hive-apollo-router-plugin crate
|
|
||||||
|
|
||||||
- Report enum values when an enum is used as an output type and align with JS implementation
|
|
||||||
|
|
||||||
# 19.07.2024
|
|
||||||
|
|
||||||
- Writes `supergraph-schema.graphql` file to a temporary directory (the path depends on OS), and
|
|
||||||
this is now the default of `HIVE_CDN_SCHEMA_FILE_PATH`.
|
|
||||||
|
|
||||||
# 10.04.2024
|
|
||||||
|
|
||||||
- `HIVE_CDN_ENDPOINT` and `endpoint` accept an URL with and without the `/supergraph` part
|
|
||||||
|
|
||||||
# 09.01.2024
|
|
||||||
|
|
||||||
- Introduce `HIVE_CDN_SCHEMA_FILE_PATH` environment variable to specify where to download the
|
|
||||||
supergraph schema (default is `./supergraph-schema.graphql`)
|
|
||||||
|
|
||||||
# 11.07.2023
|
|
||||||
|
|
||||||
- Use debug level when logging dropped operations
|
|
||||||
|
|
||||||
# 07.06.2023
|
|
||||||
|
|
||||||
- Introduce `enabled` flag (Usage Plugin)
|
|
||||||
|
|
||||||
# 23.08.2022
|
|
||||||
|
|
||||||
- Don't panic on scalars used as variable types
|
|
||||||
- Introduce `buffer_size`
|
|
||||||
- Ignore operations including `__schema` or `__type`
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "hive-apollo-router-plugin"
|
|
||||||
authors = ["Kamil Kisiela <kamil.kisiela@gmail.com>"]
|
|
||||||
repository = "https://github.com/graphql-hive/console/"
|
|
||||||
edition = "2021"
|
|
||||||
license = "MIT"
|
|
||||||
publish = true
|
|
||||||
version = "3.0.3"
|
|
||||||
description = "Apollo-Router Plugin for Hive"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "router"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "hive_apollo_router_plugin"
|
|
||||||
path = "src/lib.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
apollo-router = { version = "^2.0.0" }
|
|
||||||
axum-core = "0.5"
|
|
||||||
hive-console-sdk = "=0.3.7"
|
|
||||||
sha2 = { version = "0.10.8", features = ["std"] }
|
|
||||||
anyhow = "1"
|
|
||||||
tracing = "0.1"
|
|
||||||
bytes = "1.11.1"
|
|
||||||
async-trait = "0.1.77"
|
|
||||||
futures = { version = "0.3.30", features = ["thread-pool"] }
|
|
||||||
schemars = { version = "1.0.4", features = ["url2"] }
|
|
||||||
serde = "1"
|
|
||||||
serde_json = "1"
|
|
||||||
tokio = { version = "1.36.0", features = ["full"] }
|
|
||||||
tower = { version = "0.5", features = ["full"] }
|
|
||||||
http = "1"
|
|
||||||
http-body-util = "0.1"
|
|
||||||
rand = "0.9.0"
|
|
||||||
tokio-util = "0.7.16"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
httpmock = "0.7.0"
|
|
||||||
jsonschema = { version = "0.29.0", default-features = false, features = [
|
|
||||||
"resolve-file",
|
|
||||||
] }
|
|
||||||
lazy_static = "1.5.0"
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
# Hive plugin for Apollo-Router
|
|
||||||
|
|
||||||
[Hive](https://the-guild.dev/graphql/hive) is a fully open-source schema registry, analytics,
|
|
||||||
metrics and gateway for [GraphQL federation](https://the-guild.dev/graphql/hive/federation) and
|
|
||||||
other GraphQL APIs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This project includes a Hive integration plugin for Apollo-Router.
|
|
||||||
|
|
||||||
At the moment, the following are implemented:
|
|
||||||
|
|
||||||
- [Fetching Supergraph from Hive CDN](https://the-guild.dev/graphql/hive/docs/high-availability-cdn)
|
|
||||||
- [Sending usage information](https://the-guild.dev/graphql/hive/docs/schema-registry/usage-reporting)
|
|
||||||
from a running Apollo Router instance to Hive
|
|
||||||
- Persisted Operations using Hive's
|
|
||||||
[App Deployments](https://the-guild.dev/graphql/hive/docs/schema-registry/app-deployments)
|
|
||||||
|
|
||||||
This project is constructed as a Rust project that implements Apollo-Router plugin interface.
|
|
||||||
|
|
||||||
This build of this project creates an artifact identical to Apollo-Router releases, with additional
|
|
||||||
features provided by Hive.
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
### Binary/Docker
|
|
||||||
|
|
||||||
We provide a custom build of Apollo-Router that acts as a drop-in replacement, and adds Hive
|
|
||||||
integration to Apollo-Router.
|
|
||||||
|
|
||||||
[Please follow this guide and documentation for integrating Hive with Apollo Router](https://the-guild.dev/graphql/hive/docs/other-integrations/apollo-router)
|
|
||||||
|
|
||||||
### As a Library
|
|
||||||
|
|
||||||
If you are
|
|
||||||
[building a custom Apollo-Router with your own native plugins](https://www.apollographql.com/docs/graphos/routing/customization/native-plugins),
|
|
||||||
you can use the Hive plugin as a dependency from Crates.io:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[dependencies]
|
|
||||||
hive-apollo-router-plugin = "..."
|
|
||||||
```
|
|
||||||
|
|
||||||
And then in your codebase, make sure to import and register the Hive plugin:
|
|
||||||
|
|
||||||
```rs
|
|
||||||
use apollo_router::register_plugin;
|
|
||||||
// import the registry instance and the plugin registration function
|
|
||||||
use hive_apollo_router_plugin::registry::HiveRegistry;
|
|
||||||
// Import the usage plugin
|
|
||||||
use hive_apollo_router_plugin::usage::UsagePlugin;
|
|
||||||
// Import persisted documents plugin, if needed
|
|
||||||
use persisted_documents::PersistedDocumentsPlugin;
|
|
||||||
|
|
||||||
|
|
||||||
// In your main function, make sure to register the plugin before you create or initialize Apollo-Router
|
|
||||||
fn main() {
|
|
||||||
// Register the Hive usage_reporting plugin
|
|
||||||
register_plugin!("hive", "usage", UsagePlugin);
|
|
||||||
// Register the persisted documents plugin, if needed
|
|
||||||
register_plugin!("hive", "persisted_documents", PersistedDocumentsPlugin);
|
|
||||||
|
|
||||||
// Initialize the Hive Registry instance and start the Apollo Router
|
|
||||||
match HiveRegistry::new(None).and(apollo_router::main()) {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("{}", e);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
0. Install latest version of Rust
|
|
||||||
1. To get started with development, it is recommended to ensure Rust-analyzer extension is enabled
|
|
||||||
on your VSCode instance.
|
|
||||||
2. Validate project status by running `cargo check`
|
|
||||||
3. To start the server with the demo config file (`./router.yaml`), use
|
|
||||||
`cargo run -- --config router.yaml`. Make sure to set environment variables required for your
|
|
||||||
setup and development process
|
|
||||||
([docs](https://the-guild.dev/graphql/hive/docs/other-integrations/apollo-router#configuration)).
|
|
||||||
4. You can also just run
|
|
||||||
`cargo run -- --config router.yaml --log debug --dev --supergraph some.supergraph.graphql` for
|
|
||||||
running it with a test supergraph file.
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"name": "hive-apollo-router-plugin",
|
|
||||||
"version": "3.0.3",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"sync-cargo-file": "ls -l sync-cargo-file.sh && bash ./sync-cargo-file.sh"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue