Compare commits

...

53 commits

Author SHA1 Message Date
TheGuildBot
b35f9fbc1f
Upcoming Release Changes (#7995)
Some checks failed
ci / package (push) Has been cancelled
ci / build (push) Has been cancelled
ci / publish_docker_cli (push) Has been cancelled
ci / trigger staging deployment (push) Has been cancelled
ci / code-style (push) Has been cancelled
ci / typescript (push) Has been cancelled
ci / graphql-schema (push) Has been cancelled
2026-04-17 14:33:30 +02:00
Michael Skorokhodov
863f920b86
feat: create initial collection prompt + edit name (#7989) 2026-04-17 13:00:28 +02:00
TheGuildBot
64e7fed1d4
Upcoming Release Changes (#7994) 2026-04-17 09:16:47 +02:00
Jonathan Brennan
a71b45bf76
fix Sentry errors related to schema-editor dynamic import (#7990) 2026-04-17 08:36:51 +02:00
Laurin
730771fb50
fix: address vulnerabilities 2026-04-17 (#7993)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-17 08:34:37 +02:00
Michael Skorokhodov
5a85fb9dd6
Feat/lab settings (#7943)
Co-authored-by: Laurin <laurinquast@googlemail.com>
2026-04-16 12:58:10 +02:00
Laurin
d7e7025624
fix: vulnerabilities 2026-04-16 (#7988) 2026-04-16 11:04:18 +02:00
Laurin
fe06ddec5a
fix: a quick changelog fix (#7987) 2026-04-16 09:09:40 +02:00
Jonathan Brennan
a237b4e782
fix wonky SubgraphChip and associated tooltip styling (#7985) 2026-04-15 17:59:09 -05:00
Jonathan Brennan
5706e2de0e
restore GraphQL syntax highlighting across Monaco editors (#7981) 2026-04-15 12:01:08 -05:00
jdolle
9c6989cd92
Support type extensions with unreachable types policy rule (#7978) 2026-04-15 08:12:48 -07:00
Laurin
c46b2f2219
chore: bump axios (#7980) 2026-04-15 14:45:25 +02:00
Michael Skorokhodov
4a8bd4fd1b
CONSOLE-1958: Operation picker (when multiple exists in document) (#7963) 2026-04-15 13:39:54 +02:00
Dotan Simha
e3d9750cc9
chore: remove apollo-router-hive-fork from this repo (#7979) 2026-04-15 11:50:18 +03:00
Jonathan Brennan
7d4ef94432
fix: ensure vars as passed through turbo (#7977) 2026-04-14 13:18:11 -05:00
Michael Skorokhodov
fab4b03ace
Feat/lab query plan (#7892)
Co-authored-by: Laurin <laurinquast@googlemail.com>
2026-04-14 14:47:09 +02:00
Laurin
ed9ab34c70
fix: bump follow-redirects (#7976) 2026-04-14 14:37:30 +02:00
Jonathan Brennan
f7a74ce419
fix sentry sourcemaps: point the inject and upload commands at dist/client instead of dist (#7972) 2026-04-14 07:12:57 -05:00
Laurin
2feec5d5db
chore: bump basic-ftp (#7974) 2026-04-14 13:57:42 +02:00
Adam Benhassen
0162def432
fix(app-deployments): auto-inject MCP directive definitions during operation validation (#7970) 2026-04-13 17:08:08 +00:00
TheGuildBot
c6905421e7
Update apollo-router to 2.13.1 (#7944)
Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com>
2026-04-12 11:41:19 +03:00
TheGuildBot
01184317c7
Update apollo-router to 2.13.0, update rust toolchain 1.94.1 (#7939)
Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com>
Co-authored-by: Dotan Simha <dotansimha@gmail.com>
2026-04-12 10:43:32 +03:00
Laurin
9708f71aa3
fix: schema contract composition applying @inaccessible on the federation types ContextArgument and FieldValue on the supergraph SDL (#7967) 2026-04-10 12:44:41 +02:00
Laurin
40fd27d9c0
chore: update vulnerabilities 2026-04-09 (#7961)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-09 10:27:33 +02:00
Jonathan Brennan
77d6063512
Console 2012 client side source maps have never been uploaded to sentry (#7952) 2026-04-08 14:16:23 -05:00
Laurin
1333fbcafa
fix: fiter null values from result (#7951) 2026-04-08 08:16:51 -05:00
Laurin
61394f30b0
chore: bump ladle (#7954) 2026-04-08 09:38:37 +02:00
Laurin
0d81a7a01b
chore: bump vite and vitest peer dependency (#7953) 2026-04-08 08:48:48 +02:00
TheGuildBot
2879316c05
Update apollo-router to 2.12.1 (#7903)
Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com>
2026-04-07 14:56:33 +03:00
Dotan Simha
dae36931f9
fix(deployment): upgrade contour to latest version (1.33) and update chart url (#7947) 2026-04-07 14:50:46 +03:00
Jonathan Brennan
ca69b1c59f
update node to 24.14.1 and @types/node to 24.12.2 to address Aikido i… (#7946)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Laurin Quast <laurinquast@googlemail.com>
2026-04-07 11:26:02 +02:00
Laurin
9ce8fda7f8
bump vulnerable dependencies (#7950) 2026-04-07 11:01:41 +02:00
Laurin
84043d9b3a
feat: slonik major upgrade (#7897) 2026-04-02 12:43:37 +02:00
TheGuildBot
615fb095d8
Upcoming Release Changes (#7933) 2026-04-02 12:14:37 +02:00
Laurin
007dc4b8df
chore: bump lodash (#7942) 2026-04-02 11:07:13 +02:00
Dotan Simha
f042e51bc3
fix(deployment): configure loadBalancerPolicy correctly in envoy (#7938) 2026-04-01 11:41:19 +03:00
jdolle
742f50c52e
fix: lint policy block title color; add policy link to lines and rule id tooltip (#7940) 2026-04-01 09:23:06 +02:00
Laurin
73ba28d914
chore: remove envoy auth rate limit (#7937) 2026-03-31 09:25:14 +02:00
jdolle
96bd390c7f
fix: do not cache edge types in graphql eslint (#7936) 2026-03-31 09:03:58 +02:00
Dotan Simha
34528114f3
fix(deployment): update envoy limit for staging 2026-03-30 17:46:03 +03:00
Dotan Simha
fd0b8251c5
fix(deployment): adjust cpu limit/requests config for pods (#7931) 2026-03-30 17:02:42 +03:00
Michael Skorokhodov
574a5d823e
enhancement: lab to fetch schema if no introspection provided in inte… (#7888) 2026-03-30 16:02:23 +02:00
Dotan Simha
d3e0ef500f
fix(deployment): enable metrics scraping for envoy and contour (#7932) 2026-03-30 15:50:49 +02:00
Laurin
14858198e1
chore: vulnerable dependencies (#7930) 2026-03-30 13:20:06 +02:00
Laurin
bd695d0f64
chore: dependency vulnerabilities 2026-03-30 (#7928) 2026-03-30 12:13:12 +02:00
Dotan Simha
aa51ecd0de
fix(deployment): use replicas=1 for otel-collector and allow it to scale when needed (#7929) 2026-03-30 13:09:10 +03:00
Jonathan Brennan
72cf5b8659
upload sentry sourcemaps dir for web/app (#7927) 2026-03-30 12:02:31 +02:00
Laurin
02f3ace50b
chore: bump brace-expansion (#7926) 2026-03-27 15:04:36 +01:00
Laurin
12c990c2d8
fix: change last used date encoding (#7925) 2026-03-27 14:34:14 +01:00
Laurin
883e0bcb02
chore: always run the maximum amount of usage services (#7924) 2026-03-27 13:17:09 +01:00
Copilot
aac23596ec
refactor: shared internal postgres package around slonik (#7887)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: n1ru4l <14338007+n1ru4l@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Laurin Quast <laurinquast@googlemail.com>
2026-03-27 10:20:05 +01:00
Laurin
e83bc29e2a
chore: vulnerabilities 2026-03-27 (#7923) 2026-03-27 10:02:49 +01:00
jdolle
7834a4d52a
Improve delete token dialog box text (#7917) 2026-03-26 19:23:54 +00:00
322 changed files with 9531 additions and 23563 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -1 +1 @@
24.13 24.14.1

7675
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,3 +0,0 @@
[workspace]
resolver = "2"
members = ["packages/libraries/router", "scripts/compress"]

7313
configs/cargo/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -314,7 +314,7 @@ export class Observability {
{ {
role: 'pod', role: 'pod',
namespaces: { namespaces: {
names: ['default'], names: ['default', 'contour'],
}, },
}, },
], ],

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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.

View 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:*"
}
}

View file

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

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

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

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

View 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;

View file

@ -0,0 +1,5 @@
import { psql } from './psql';
export function toDate(date: Date) {
return psql`to_timestamp(${date.getTime() / 1000})`;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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>;

View 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} />;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1 +0,0 @@
target/**

View file

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

View file

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

View file

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

View 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