9 Commits

Author SHA1 Message Date
15036d34c2 Prepare for version 1.2.0 2026-01-23 09:53:21 +01:00
656b5efe70 Enhance copilot instructions with detailed API usage and UI features 2026-01-23 08:03:31 +01:00
abc1cef7c2 Implement missing sample data loading and one-time use security
- Fix UI to load server-hosted sample data at startup as per specification
- Add one-time use security: server clears sample data after retrieval
- Ensure React app periodically checks for new sample data availability
- Remove complex same-origin protection in favor of simpler one-time use model
- Improve data security by preventing data persistence after consumption
2026-01-23 06:15:21 +01:00
766ff96137 Add upload-jmespath script for JSON file uploads to API 2026-01-23 06:05:01 +01:00
e22b3c82a2 Fix Docker build version detection
- Skip prebuild step in Docker when VERSION build arg is provided
- Prevent version-check.js from overwriting pre-generated version.js in Docker
- Fix Docker containers showing incorrect '-dev' suffix for release builds
- Use direct react-scripts build when version is pre-generated
2026-01-21 22:06:37 +01:00
c9ce0d14b9 Separate Docker build into dedicated script
- Create build-image.sh for dedicated Docker image building
- Remove Docker build logic from build.sh to focus on Node.js app only
- Add comprehensive instructions for running and pushing Docker images
- Improve build script modularity and separation of concerns
2026-01-21 21:55:58 +01:00
ef2c1931d8 Fix Docker repository name in build script
- Update all Docker image references to use skoszewski/jmespath-playground
- Fix build.sh to create properly namespaced Docker images for Docker Hub
- Update Docker run command examples with correct repository name
2026-01-21 21:52:25 +01:00
d027459678 Enhance version management and Docker build system
- Add Docker build args for proper version detection in containers
- Update build.sh script with smart version detection for Docker builds
- Add --message option to new-version.js for custom commit messages
- Fix Docker builds showing incorrect '-dev' suffix for release builds
- Improve versioning workflow with comprehensive --check analysis
2026-01-21 21:48:45 +01:00
4d6efe791b Version 1.1.2 2026-01-21 21:33:58 +01:00
15 changed files with 1454 additions and 510 deletions

View File

@@ -6,17 +6,69 @@ applyTo: "**/*.md,**/.js"
The tool in this repository is designed to help users validate and test JMESPath expressions against JSON data. It is a React-based web application that provides an interactive interface for entering JMESPath queries and viewing the results.
The application is single page. The page is divided into three sections:
The main application page is divided into three sections:
- Top section: Title and description of the tool.
- Theme control buttons (auto/light/dark)
- Key-lock button that switches to the second application page.
- Middle section:
- The label "JMESPath Expression" with a right allinged row of action buttons:
- Load an Object
- Load a Log File
- Load Sample
- Format JSON
- Clear All
- Input area for JMESPath expressions
- Message area for errors related to JMESPath expression syntax
- Lower Middle left section: Input area for JSON data
- Lower Middle right section: Output are for JMESPath query results
- Boottom section: Footer with author and license information
- Bottom section: Footer with author and license information
The Middle section also contains a toolbar with buttons to load data from disk, load sample data, format JSON input, and clear all inputs.
The second page of the application contains:
- Top section: that is the same as the main page
- Middle section:
- API key display area with a button to regenerate the API key. The API key is 32 characters long cryptograghically secure random string.
- Instructions on how to use the API to upload sample data remotely with a code block displaying example curl command.
- Bottom section: Footer with author and license information.
The sample code block:
```bash
curl -s -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-H "X-API-Key: {{API_KEY}}" \
--data @{{JSON_FILE_NAME}} \
"{{API_URL}}/api/v1/upload"
```
Placeholders `{{API_KEY}}` and `{{API_URL}}` should be replaced with the actual API key and the URL of the deployed application respectively. The `{{JSON_FILE_NAME}}` placeholder should be shown as is to indicate the file containing the JSON data to be uploaded.
The server code is only used as a bridge between the UI app and the external tools that may upload the sample data. The server does not perform any JMESPath evaluation or JSON parsing; all such logic is handled in the React application.
The server exposes a REST API to allow external tools to upload sample data that users can load into the application. The API key is required to upload sample data.
The API key is used for:
- encrypting the sample data
- authenticating download requests
Session id is a hash of the API key.
The server keeps two pieces of information in memory for each session:
1. The sample data itself.
2. A state variable (a GUID) that changes whenever new sample data is uploaded.
The maximum number of sessions to keep in memory set at the server startup using `MAX_SESSIONS` environment variable that defaults to 100. The maximum size of the sample data is set using `MAX_SAMPLE_SIZE` environment variable that defaults to 1 MB. Maximum session age is controlled using `MAX_SESSION_TTL` environment variable that defaults to 1 hour. After reaching the maximum number of sessions, the server rejects new uploads until some sessions expire. Sessions older than the maximum session age are automatically purged.
The UI generates an API key at startup then load the sample data at startup and periodically checks the state variable to see if new sample data is available. If state variable changes, the React app displays a button beneath the expression input area to reload the sample data. The reload is performed only when the user clicks the button.
---
The main components of the application are located in the `src` directory and target Node 24 LTS environment.
Framework to be used:
@@ -26,26 +78,17 @@ Framework to be used:
- Bootstrap for styling and layout.
- Express.js for serving the application and handling API requests.
The server code is only used as a bridge between the UI app and the external tools that may upload the sample data. The server does not perform any JMESPath evaluation or JSON parsing; all such logic is handled in the React application.
The server keeps two pieces of information in memory:
1. The sample data itself.
2. A state variable (a GUID) that changes whenever new sample data is uploaded.
The React application load the sample data at startup and periodically checks the state variable to see if new sample data is available. If state variable changes, the React app displays a button beneath the expression input area to reload the sample data. The reload is performed only when the user clicks the button.
### API
The application exposes a REST API for remotly uploading sample data. The API endpoints are as follows:
- `POST /api/v1/upload`: The sample data is sent in the request body as JSON.
- `POST /api/v1/upload`: The sample data is sent in the request body as JSON. The request must include an `x-api-key` header with the API key. If the upload is successful, the server responds with status 200 OK.
The server stores the sample data in memory and generates a new value for its state variable (a guid).
- `GET /api/v1/sample`: Returns the currently stored sample data as JSON.
- `GET /api/v1/sample`: Returns the currently stored sample data as JSON. The request must include an `x-api-key` header with the API key. If the API key is invalid or the header is missing, the server responds with status 403 Forbidden.
- `GET /api/v1/state`: Returns the current value of the state variable (a guid) as a string.
- `GET /api/v1/state`: Returns the current value of the state variable (a guid) as a string. The request must include an `x-api-key` header with the API key. If the API key is invalid or the header is missing, the server responds with status 403 Forbidden.
## Containerization

View File

@@ -1,6 +1,10 @@
# Build stage
FROM node:24-alpine AS builder
# Accept build arguments for version info
ARG VERSION=""
ARG IS_RELEASE="false"
# Set working directory
WORKDIR /app
@@ -16,8 +20,25 @@ COPY public/ ./public/
COPY scripts/ ./scripts/
COPY server.js ./server.js
# Build the application
RUN npm run build
# Generate version.js if version info provided, otherwise run normal build
RUN if [ -n "$VERSION" ]; then \
echo "// Auto-generated version file - do not edit manually" > src/version.js && \
echo "// Generated at: $(date -Iseconds)" >> src/version.js && \
echo "" >> src/version.js && \
echo "export const VERSION = '$VERSION';" >> src/version.js && \
echo "export const IS_RELEASE = $IS_RELEASE;" >> src/version.js && \
echo "export const BUILD_TIME = '$(date -Iseconds)';" >> src/version.js && \
echo "📝 Generated version.js with VERSION=$VERSION, IS_RELEASE=$IS_RELEASE"; \
fi
# Build the application (skip prebuild if we already generated version.js)
RUN if [ -n "$VERSION" ]; then \
echo "🚀 Building with pre-generated version.js" && \
npx react-scripts build; \
else \
echo "🚀 Building with version-check.js" && \
npm run build; \
fi
# Production stage
FROM node:24-alpine AS production

41
bin/upload-jmespath Executable file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
API_URL="https://jmespath-playground.koszewscy.waw.pl"
JSON_FILE="-"
function usage() {
echo "Usage: $0 [--api-url <url>] [--json-file <file>]"
exit 1
}
while [[ $# -gt 0 ]]; do
case $1 in
--api-url)
API_URL="$2"
shift 2
;;
--json-file)
JSON_FILE="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "ERROR: Unknown argument: $1"
usage
exit 1
;;
esac
done
# Send the POST request
curl -s -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data @${JSON_FILE} \
"$API_URL/api/v1/upload"

View File

@@ -1,6 +1,6 @@
{
"name": "jmespath-playground",
"version": "1.1.1",
"version": "1.2.0",
"description": "A React-based web application for testing JMESPath expressions against JSON data",
"main": "index.js",
"scripts": {

63
scripts/build-image.sh Executable file
View File

@@ -0,0 +1,63 @@
#!/bin/bash
# JMESPath Testing Tool - Docker Image Build Script
set -e
echo "🐳 JMESPath Testing Tool - Docker Image Build"
echo "=============================================="
echo ""
# Check if Docker is available
if ! command -v docker &> /dev/null; then
echo "❌ Docker not found. Please install Docker to build container images."
exit 1
fi
# Determine version information for Docker build
VERSION=$(git tag --points-at HEAD 2>/dev/null | sed 's/^v//' | head -n 1)
if [ -n "$VERSION" ]; then
# We're at a tagged commit - release build
echo "📦 Building release version: $VERSION"
docker build \
--build-arg VERSION="$VERSION" \
--build-arg IS_RELEASE="true" \
-t skoszewski/jmespath-playground:$VERSION \
-t skoszewski/jmespath-playground:latest .
echo "✅ Built Docker images: skoszewski/jmespath-playground:$VERSION, skoszewski/jmespath-playground:latest"
echo ""
echo "To run the release container:"
echo " docker run -p 3000:3000 skoszewski/jmespath-playground:$VERSION"
echo " docker run -p 3000:3000 skoszewski/jmespath-playground:latest"
echo ""
echo "To push to Docker Hub:"
echo " docker push skoszewski/jmespath-playground:$VERSION"
echo " docker push skoszewski/jmespath-playground:latest"
else
# Development build
PACKAGE_VERSION=$(grep '"version"' package.json | cut -d'"' -f4)
DEV_VERSION="${PACKAGE_VERSION}-dev"
echo "📦 Building development version: $DEV_VERSION"
docker build \
--build-arg VERSION="$DEV_VERSION" \
--build-arg IS_RELEASE="false" \
-t skoszewski/jmespath-playground:dev \
-t skoszewski/jmespath-playground:latest .
echo "✅ Built Docker images: skoszewski/jmespath-playground:dev, skoszewski/jmespath-playground:latest"
echo ""
echo "To run the development container:"
echo " docker run -p 3000:3000 skoszewski/jmespath-playground:dev"
echo " docker run -p 3000:3000 skoszewski/jmespath-playground:latest"
echo ""
echo "To push to Docker Hub:"
echo " docker push skoszewski/jmespath-playground:dev"
echo " docker push skoszewski/jmespath-playground:latest"
fi
echo ""
echo "🎉 Docker image build completed successfully!"

View File

@@ -30,20 +30,10 @@ npm install
echo "🔨 Building production bundle..."
npm run build
# Optional container build with Docker
if command -v docker &> /dev/null; then
echo "🐳 Building Docker container (optional)..."
docker build -t jmespath-playground .
else
echo "💡 Docker not found. Container build is optional."
echo " Install Docker if you want to build containers."
fi
echo "✅ Build completed successfully!"
echo ""
echo "To run the application:"
echo " npm run serve # Serve production build locally"
echo " docker run -p 3000:3000 jmespath-playground # Run container (if built)"
if command -v docker &> /dev/null; then
echo " docker run -p 3000:3000 jmespath-playground # Run with Docker"
fi
echo " npm run server # Run integrated server locally"
echo ""
echo "To build Docker image:"
echo " scripts/build-image.sh # Build Docker container image"

View File

@@ -4,37 +4,216 @@ const fs = require('fs');
const { execSync } = require('child_process');
function showUsage() {
console.log('Usage: node scripts/new-version.js <version>');
console.log('Usage: node scripts/new-version.js <version> [--force] [-m|--message "commit message"]');
console.log(' node scripts/new-version.js --check <version>');
console.log('');
console.log('Creates a new version by updating package.json, committing, and tagging.');
console.log('Creates a new version by tagging the current commit.');
console.log('');
console.log('Options:');
console.log(' --force Force version creation even with dirty repo or package.json mismatch');
console.log(' --check Analyze repository status and report what would happen for specified version');
console.log(' -m, --message TEXT Custom commit message (only used when commit is needed)');
console.log('');
console.log('Example:');
console.log(' node scripts/new-version.js 1.2.0');
console.log(' node scripts/new-version.js 2.0.0-beta.1');
console.log(' node scripts/new-version.js 1.2.0 --force');
console.log(' node scripts/new-version.js 1.2.0 -m "Add new feature XYZ"');
console.log(' node scripts/new-version.js --check 1.3.0');
}
function performCheck(targetVersion) {
console.log('🔍 Repository Analysis Report');
console.log('============================');
try {
// Read package.json
const packagePath = './package.json';
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
const currentVersion = pkg.version;
console.log(`📦 Package.json version: ${currentVersion}`);
// Check repository status
let isRepoDirty = false;
let dirtyFiles = '';
try {
const status = execSync('git status --porcelain', { encoding: 'utf8' });
isRepoDirty = status.trim() !== '';
dirtyFiles = status.trim();
} catch (error) {
console.log('⚠️ Cannot determine git status');
}
if (isRepoDirty) {
console.log('🔄 Repository status: DIRTY');
console.log(' Uncommitted changes:');
dirtyFiles.split('\n').forEach(line => {
if (line.trim()) console.log(` ${line}`);
});
} else {
console.log('✅ Repository status: CLEAN');
}
// Check current commit info
try {
const currentCommit = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
console.log(`🌟 Current commit: ${currentCommit.substring(0, 7)} (${currentBranch})`);
// Check if current commit is tagged
const tagsOnHead = execSync('git tag --points-at HEAD', { encoding: 'utf8' }).trim();
if (tagsOnHead) {
console.log(`🏷️ Current commit tags: ${tagsOnHead.split('\n').join(', ')}`);
} else {
console.log('🏷️ Current commit: No tags');
}
} catch (error) {
console.log('⚠️ Cannot determine commit info');
}
// List recent tags
try {
const recentTags = execSync('git tag --sort=-version:refname | head -5', { encoding: 'utf8' }).trim();
if (recentTags) {
console.log('📋 Recent tags:');
recentTags.split('\n').forEach(tag => {
if (tag.trim()) console.log(` ${tag}`);
});
} else {
console.log('📋 No tags found in repository');
}
} catch (error) {
console.log('⚠️ Cannot list tags');
}
console.log('');
// Analysis for target version (if provided)
if (targetVersion) {
const tagName = `v${targetVersion}`;
console.log(`🎯 Analysis for version ${targetVersion}:`);
console.log('=====================================');
// Check if target tag exists
try {
const existingTags = execSync('git tag -l', { encoding: 'utf8' });
const tagExists = existingTags.split('\n').includes(tagName);
if (tagExists) {
console.log(`❌ Tag '${tagName}' already exists - CANNOT CREATE`);
return;
}
console.log(`✅ Tag '${tagName}' available`);
} catch (error) {
console.log('⚠️ Cannot check tag availability');
return;
}
// Analyze what actions would be needed
const packageJsonMatches = currentVersion === targetVersion;
const needsPackageUpdate = !packageJsonMatches;
const needsCommit = isRepoDirty || needsPackageUpdate;
console.log(`📝 Package.json: ${packageJsonMatches ? 'MATCHES' : `NEEDS UPDATE (${currentVersion}${targetVersion})`}`);
if (needsCommit) {
console.log('⚡ Actions needed:');
if (needsPackageUpdate) {
console.log(' • Update package.json');
}
if (isRepoDirty) {
console.log(' • Stage uncommitted changes');
}
console.log(' • Create commit');
console.log(` • Create tag ${tagName}`);
console.log('');
console.log('📋 Commands that would work:');
if (isRepoDirty || needsPackageUpdate) {
console.log(` node scripts/new-version.js ${targetVersion} --force`);
} else {
console.log(` node scripts/new-version.js ${targetVersion}`);
console.log(` node scripts/new-version.js ${targetVersion} --force`);
}
} else {
console.log('⚡ Actions needed:');
console.log(` • Create tag ${tagName} (no commit needed)`);
console.log('');
console.log('📋 Commands that would work:');
console.log(` node scripts/new-version.js ${targetVersion}`);
console.log(` node scripts/new-version.js ${targetVersion} --force`);
}
console.log('');
console.log('🚦 Default mode requirements:');
if (isRepoDirty) {
console.log(' ❌ Repository must be clean (currently dirty)');
} else {
console.log(' ✅ Repository is clean');
}
if (!packageJsonMatches) {
console.log(` ❌ Package.json must match version (currently ${currentVersion})`);
} else {
console.log(' ✅ Package.json version matches');
}
} else {
// This should never happen since version is now required
console.error('Internal error: No version provided to performCheck');
process.exit(1);
}
} catch (error) {
console.error('❌ Error during analysis:', error.message);
process.exit(1);
}
}
function main() {
// Parse command line arguments
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '-h' || args[0] === '--help') {
if (args.length === 0 || args.includes('-h') || args.includes('--help')) {
showUsage();
process.exit(args.length === 0 ? 1 : 0);
}
if (args.length !== 1) {
console.error('Error: Exactly one version argument required');
const isCheck = args.includes('--check');
const isForce = args.includes('--force');
// Parse custom commit message
let customMessage = null;
const messageIndex = args.findIndex(arg => arg === '-m' || arg === '--message');
if (messageIndex !== -1 && messageIndex + 1 < args.length) {
customMessage = args[messageIndex + 1];
}
let newVersion;
if (isCheck) {
// For --check, version is required
newVersion = args.find(arg => !arg.startsWith('--') && arg !== '-m' && arg !== customMessage);
if (!newVersion) {
console.error('Error: Version argument required for --check');
showUsage();
process.exit(1);
}
} else {
// For normal operation, version is required
newVersion = args.find(arg => !arg.startsWith('--') && arg !== '-m' && arg !== customMessage);
if (!newVersion) {
}
}
if (isCheck) {
performCheck(newVersion);
return;
}
const newVersion = args[0];
const tagName = `v${newVersion}`;
console.log(`🏷️ Creating new version: ${newVersion}`);
console.log(`🏷️ Creating new version: ${newVersion}${isForce ? ' (forced)' : ''}`);
try {
// Check if tag already exists
// 1. Check if tag already exists - Always ERROR
try {
const existingTags = execSync('git tag -l', { encoding: 'utf8' });
if (existingTags.split('\n').includes(tagName)) {
@@ -46,34 +225,63 @@ function main() {
process.exit(1);
}
// Check if working directory is clean
// 2. Check repository status
let isRepoDirty = false;
try {
const status = execSync('git status --porcelain', { encoding: 'utf8' });
if (status.trim()) {
console.error('❌ Error: Working directory has uncommitted changes');
console.error('Please commit or stash your changes first');
process.exit(1);
}
isRepoDirty = status.trim() !== '';
} catch (error) {
console.error('❌ Error: Failed to check git status');
process.exit(1);
}
// Read and update package.json
// 3. Check package.json version
const packagePath = './package.json';
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
const oldVersion = pkg.version;
const currentVersion = pkg.version;
const packageJsonMatches = currentVersion === newVersion;
// 4. Determine what action is needed
const needsPackageUpdate = !packageJsonMatches;
const needsCommit = isRepoDirty || needsPackageUpdate;
// 5. Check if force is required
if (!isForce) {
if (isRepoDirty) {
console.error('❌ Error: Working directory has uncommitted changes');
console.error('Please commit your changes first or use --force');
process.exit(1);
}
if (needsPackageUpdate) {
console.error(`❌ Error: Package.json version is ${currentVersion}, requested ${newVersion}`);
console.error('Use --force to update package.json');
process.exit(1);
}
}
// 6. Execute the versioning
if (needsCommit) {
console.log(`📦 Needs commit: ${needsPackageUpdate ? 'package.json update' : ''}${needsPackageUpdate && isRepoDirty ? ' + ' : ''}${isRepoDirty ? 'uncommitted changes' : ''}`);
// Update package.json if needed
if (needsPackageUpdate) {
pkg.version = newVersion;
fs.writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n');
console.log(`📦 Updated package.json: ${oldVersion}${newVersion}`);
console.log(`📝 Updated package.json: ${currentVersion}${newVersion}`);
}
// Commit the package.json change
execSync('git add package.json', { stdio: 'inherit' });
execSync(`git commit -m "Bump version to ${newVersion}"`, { stdio: 'inherit' });
console.log(`✅ Committed version change`);
// Stage all changes
execSync('git add .', { stdio: 'inherit' });
// Tag the commit
// Commit
const commitMessage = customMessage || (needsPackageUpdate ? `Version ${newVersion}` : `Prepare for version ${newVersion}`);
execSync(`git commit -m "${commitMessage}"`, { stdio: 'inherit' });
console.log(`✅ Committed changes`);
} else {
console.log(`✅ Repository clean, package.json matches - tagging current commit`);
}
// 7. Tag the commit
execSync(`git tag ${tagName}`, { stdio: 'inherit' });
console.log(`🏷️ Created tag: ${tagName}`);

383
server.js
View File

@@ -1,37 +1,171 @@
const express = require('express');
const path = require('path');
const crypto = require('crypto');
const { v4: uuidv4 } = require('uuid');
// Environment configuration
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS) || 100;
const MAX_SAMPLE_SIZE = parseInt(process.env.MAX_SAMPLE_SIZE) || 1024 * 1024; // 1MB
const MAX_SESSION_TTL = parseInt(process.env.MAX_SESSION_TTL) || 60 * 60 * 1000; // 1 hour
// Utility functions for encryption
function generateSalt() {
return crypto.randomBytes(16);
}
function isLocalhostRequest(req) {
// Get client IP with fallback options
const forwarded = req.get('X-Forwarded-For');
const ip = forwarded ? forwarded.split(',')[0].trim() :
req.ip ||
req.connection.remoteAddress ||
req.socket.remoteAddress ||
'127.0.0.1';
const host = req.get('host') || '';
// Check for localhost IP addresses (IPv4 and IPv6)
const localhostIPs = ['127.0.0.1', '::1', '::ffff:127.0.0.1', 'localhost'];
const isLocalIP = localhostIPs.includes(ip) || ip.startsWith('127.') || ip === '::1';
// Check for localhost hostnames
const isLocalHost = host.startsWith('localhost:') || host.startsWith('127.0.0.1:') || host === 'localhost' || host === '127.0.0.1';
return isLocalIP || isLocalHost;
}
function encrypt(data, key) {
try {
const algorithm = 'aes-256-gcm';
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
cipher.setAAD(Buffer.from('session-data'));
let encrypted = cipher.update(JSON.stringify(data), 'utf8');
encrypted = Buffer.concat([encrypted, cipher.final()]);
const authTag = cipher.getAuthTag();
return {
iv: iv.toString('hex'),
data: encrypted.toString('hex'),
tag: authTag.toString('hex')
};
} catch (error) {
console.error('⚠️ Encryption exception:', {
message: error.message,
algorithm: 'aes-256-gcm',
keyLength: key ? key.length : 'undefined',
timestamp: new Date().toISOString()
});
throw new Error(`Encryption failed: ${error.message}`);
}
}
function decrypt(encryptedObj, key) {
try {
const algorithm = 'aes-256-gcm';
const iv = Buffer.from(encryptedObj.iv, 'hex');
const decipher = crypto.createDecipheriv(algorithm, key, iv);
decipher.setAAD(Buffer.from('session-data'));
decipher.setAuthTag(Buffer.from(encryptedObj.tag, 'hex'));
let decrypted = decipher.update(Buffer.from(encryptedObj.data, 'hex'), null, 'utf8');
decrypted += decipher.final('utf8');
return JSON.parse(decrypted);
} catch (error) {
console.error('⚠️ Decryption exception:', {
message: error.message,
algorithm: 'aes-256-gcm',
keyLength: key ? key.length : 'undefined',
hasIV: !!encryptedObj.iv,
hasTag: !!encryptedObj.tag,
hasData: !!encryptedObj.data,
timestamp: new Date().toISOString()
});
throw new Error(`Decryption failed: ${error.message}`);
}
}
// For localhost requests, use a consistent API key so sessions persist
const LOCALHOST_API_KEY = 'localhost0123456789abcdef0123456789';
function isValidApiKey(apiKey) {
return typeof apiKey === 'string' && /^[0-9a-f]{32}$/i.test(apiKey);
}
function getSessionId(apiKey) {
return crypto.createHash('sha256').update(apiKey).digest('hex');
}
function generateSalt() {
return crypto.randomBytes(32);
}
function deriveKey(apiKey, salt) {
return crypto.pbkdf2Sync(apiKey, salt, 10000, 32, 'sha256');
}
// Create Express app
function createApp() {
const app = express();
// Trust proxy to get real client IP (needed for localhost detection)
app.set('trust proxy', true);
// Middleware
app.use(express.json());
app.use(express.json({ limit: MAX_SAMPLE_SIZE }));
app.use(express.static(path.join(__dirname, 'build')));
// In-memory storage
let sampleData = {
"people": [
{
"name": "John Doe",
"age": 30,
"city": "New York"
},
{
"name": "Jane Smith",
"age": 25,
"city": "Los Angeles"
}
],
"total": 2
};
// Session storage
const sessions = new Map();
let stateGuid = uuidv4();
// Cleanup expired sessions
function cleanupExpiredSessions() {
const now = Date.now();
for (const [sessionId, session] of sessions.entries()) {
if (now - session.createdAt > MAX_SESSION_TTL) {
sessions.delete(sessionId);
console.log(`🧹 Cleaned up expired session: ${sessionId.substring(0, 8)}...`);
}
}
}
// Run cleanup every 5 minutes
setInterval(cleanupExpiredSessions, 5 * 60 * 1000);
// API endpoints
app.post('/api/v1/upload', (req, res) => {
try {
// Check if request is from localhost - if so, skip API key validation
const isFromLocalhost = isLocalhostRequest(req);
let apiKey = req.headers['x-api-key'];
if (!isFromLocalhost) {
// Validate API key header for remote clients
if (!apiKey || !isValidApiKey(apiKey)) {
return res.status(403).json({ error: 'Invalid or missing X-API-Key header' });
}
} else {
// For localhost requests, use consistent API key for session persistence
if (!apiKey || !isValidApiKey(apiKey)) {
apiKey = LOCALHOST_API_KEY;
}
}
// Cleanup expired sessions before checking limits
cleanupExpiredSessions();
// Check session limits
if (sessions.size >= MAX_SESSIONS) {
return res.status(429).json({
error: 'Maximum number of sessions reached. Please try again later.',
maxSessions: MAX_SESSIONS,
currentSessions: sessions.size
});
}
const uploadedData = req.body;
// Validate that it's valid JSON
@@ -39,30 +173,207 @@ function createApp() {
return res.status(400).json({ error: 'Invalid JSON data' });
}
// Store the sample data and generate new state GUID
sampleData = uploadedData;
stateGuid = uuidv4();
// Check data size
const dataSize = Buffer.byteLength(JSON.stringify(uploadedData), 'utf8');
if (dataSize > MAX_SAMPLE_SIZE) {
return res.status(413).json({
error: 'Sample data too large',
maxSize: MAX_SAMPLE_SIZE,
receivedSize: dataSize
});
}
res.json({ message: 'Sample data uploaded successfully', state: stateGuid });
const sessionId = getSessionId(apiKey);
const salt = generateSalt();
const key = deriveKey(apiKey, salt);
const stateGuid = uuidv4();
// Encrypt and store session data
const encryptedData = encrypt(uploadedData, key);
sessions.set(sessionId, {
salt: salt.toString('hex'),
encryptedData,
state: stateGuid,
createdAt: Date.now(),
accessed: false
});
console.log(`📁 Session created: ${sessionId.substring(0, 8)}... (${sessions.size}/${MAX_SESSIONS})`);
res.json({
message: 'Sample data uploaded successfully',
state: stateGuid,
sessionId: sessionId.substring(0, 8) + '...'
});
} catch (error) {
res.status(500).json({ error: 'Failed to upload sample data' });
console.error('⚠️ Upload endpoint exception occurred:', {
message: error.message,
stack: error.stack,
sessionCount: sessions.size,
timestamp: new Date().toISOString()
});
// Provide more specific error messages based on error type
if (error.name === 'SyntaxError') {
return res.status(400).json({
error: 'Invalid JSON data format',
details: 'The uploaded data could not be parsed as valid JSON'
});
} else if (error.message.includes('encrypt')) {
return res.status(500).json({
error: 'Encryption failed',
details: 'Failed to encrypt session data. Please try again with a new API key.'
});
} else if (error.message.includes('PBKDF2')) {
return res.status(500).json({
error: 'Key derivation failed',
details: 'Failed to derive encryption key from API key'
});
} else {
return res.status(500).json({
error: 'Upload processing failed',
details: 'An unexpected error occurred while processing your upload. Please try again.'
});
}
}
});
app.get('/api/v1/sample', (req, res) => {
try {
res.json(sampleData);
// Check if request is from localhost - if so, skip API key validation
const isFromLocalhost = isLocalhostRequest(req);
let apiKey = req.headers['x-api-key'];
if (!isFromLocalhost) {
// Validate API key header for remote clients
if (!apiKey || !isValidApiKey(apiKey)) {
return res.status(403).json({ error: 'Invalid or missing X-API-Key header' });
}
} else {
// For localhost requests, use consistent API key for session persistence
if (!apiKey || !isValidApiKey(apiKey)) {
apiKey = LOCALHOST_API_KEY;
}
}
const sessionId = getSessionId(apiKey);
const session = sessions.get(sessionId);
if (!session) {
return res.json(null);
}
// Decrypt data
const salt = Buffer.from(session.salt, 'hex');
const key = deriveKey(apiKey, salt);
const decryptedData = decrypt(session.encryptedData, key);
// Remove session after first access (one-time use)
sessions.delete(sessionId);
console.log(`📤 Sample data retrieved and session cleared: ${sessionId.substring(0, 8)}...`);
res.json(decryptedData);
} catch (error) {
res.status(500).json({ error: 'Failed to retrieve sample data' });
console.error('⚠️ Sample retrieval exception occurred:', {
message: error.message,
stack: error.stack,
sessionCount: sessions.size,
timestamp: new Date().toISOString()
});
// Provide more specific error messages based on error type
if (error.message.includes('decrypt')) {
return res.status(500).json({
error: 'Decryption failed',
details: 'Failed to decrypt session data. The session may be corrupted or the API key may be incorrect.'
});
} else if (error.message.includes('JSON')) {
return res.status(500).json({
error: 'Data corruption detected',
details: 'The stored session data appears to be corrupted and cannot be parsed.'
});
} else if (error.name === 'TypeError') {
return res.status(500).json({
error: 'Session data format error',
details: 'The session data format is invalid or corrupted.'
});
} else {
return res.status(500).json({
error: 'Sample retrieval failed',
details: 'An unexpected error occurred while retrieving sample data. The session may have been corrupted.'
});
}
}
});
app.get('/api/v1/state', (req, res) => {
try {
res.json({ state: stateGuid });
} catch (error) {
res.status(500).json({ error: 'Failed to retrieve state' });
// Check if request is from localhost - if so, skip API key validation
const isFromLocalhost = isLocalhostRequest(req);
let apiKey = req.headers['x-api-key'];
if (!isFromLocalhost) {
// Validate API key header for remote clients
if (!apiKey || !isValidApiKey(apiKey)) {
return res.status(403).json({ error: 'Invalid or missing X-API-Key header' });
}
} else {
// For localhost requests, use consistent API key for session persistence
if (!apiKey || !isValidApiKey(apiKey)) {
apiKey = LOCALHOST_API_KEY;
}
}
const sessionId = getSessionId(apiKey);
const session = sessions.get(sessionId);
if (!session) {
// Return null state when no session exists
return res.json({ state: null });
}
res.json({ state: session.state });
} catch (error) {
console.error('⚠️ State retrieval exception occurred:', {
message: error.message,
stack: error.stack,
sessionCount: sessions.size,
timestamp: new Date().toISOString()
});
// Provide more specific error messages
if (error.message.includes('API key')) {
return res.status(403).json({
error: 'API key processing failed',
details: 'Failed to process the provided API key'
});
} else {
return res.status(500).json({
error: 'State retrieval failed',
details: 'An unexpected error occurred while retrieving session state. Please try again.'
});
}
}
});
// Health endpoint (no auth required)
app.get('/api/v1/health', (req, res) => {
cleanupExpiredSessions(); // Cleanup on health check
res.json({
status: 'healthy',
sessions: {
current: sessions.size,
max: MAX_SESSIONS,
available: MAX_SESSIONS - sessions.size
},
limits: {
maxSessions: MAX_SESSIONS,
maxSampleSize: MAX_SAMPLE_SIZE,
maxSessionTTL: MAX_SESSION_TTL
},
uptime: process.uptime()
});
});
// Serve React app for all other routes
@@ -95,11 +406,17 @@ if (require.main === module) {
const HOST = listenAddr;
app.listen(PORT, HOST, () => {
console.log(`Server running on http://${HOST}:${PORT}`);
console.log(`API endpoints:`);
console.log(` POST http://${HOST}:${PORT}/api/v1/upload`);
console.log(` GET http://${HOST}:${PORT}/api/v1/sample`);
console.log(` GET http://${HOST}:${PORT}/api/v1/state`);
console.log(`🚀 JMESPath Playground Server running on http://${HOST}:${PORT}`);
console.log(`📊 Configuration:`);
console.log(` Max Sessions: ${MAX_SESSIONS}`);
console.log(` Max Sample Size: ${(MAX_SAMPLE_SIZE / 1024 / 1024).toFixed(1)}MB`);
console.log(` Session TTL: ${(MAX_SESSION_TTL / 1000 / 60).toFixed(0)} minutes`);
console.log(`🔗 API endpoints:`);
console.log(` POST http://${HOST}:${PORT}/api/v1/upload (requires X-API-Key)`);
console.log(` GET http://${HOST}:${PORT}/api/v1/sample (requires X-API-Key)`);
console.log(` GET http://${HOST}:${PORT}/api/v1/state (requires X-API-Key)`);
console.log(` GET http://${HOST}:${PORT}/api/v1/health (public)`);
console.log(`🔐 Security: AES-256-GCM encryption with PBKDF2 key derivation`);
});
}

View File

@@ -1,35 +1,47 @@
import React, { useState, useEffect } from 'react';
import jmespath from 'jmespath';
import { VERSION } from './version';
import Header from './components/Header';
import Footer from './components/Footer';
import MainPage from './components/MainPage';
import ApiKeyPage from './components/ApiKeyPage';
import './App.css';
// Utility function to generate a cryptographically secure API key
function generateApiKey() {
const array = new Uint8Array(16);
// Use crypto.getRandomValues if available (browser), fallback for tests
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
crypto.getRandomValues(array);
} else {
// Fallback for test environments - not cryptographically secure
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
}
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
// JMESPath Testing Tool - Main Application Component
function App() {
const [jmespathExpression, setJmespathExpression] = useState('people[0].name');
const [currentPage, setCurrentPage] = useState('main'); // 'main' or 'apikey'
const [theme, setTheme] = useState(() => {
// Load theme from localStorage or default to 'auto'
return localStorage.getItem('theme') || 'auto';
});
const [jsonData, setJsonData] = useState(`{
"people": [
{
"name": "John Doe",
"age": 30,
"city": "New York"
},
{
"name": "Jane Smith",
"age": 25,
"city": "Los Angeles"
}
],
"total": 2
}`);
const [result, setResult] = useState('');
const [error, setError] = useState('');
const [jsonError, setJsonError] = useState('');
const [showReloadButton, setShowReloadButton] = useState(false);
const [currentStateGuid, setCurrentStateGuid] = useState(null);
const [sampleData, setSampleData] = useState(null);
const [apiKey, setApiKey] = useState(() => {
// Load API key from localStorage or generate new one
const stored = localStorage.getItem('jmespath-api-key');
if (stored && /^[0-9a-f]{32}$/i.test(stored)) {
return stored;
}
const newKey = generateApiKey();
localStorage.setItem('jmespath-api-key', newKey);
return newKey;
});
// Theme management
useEffect(() => {
@@ -51,55 +63,82 @@ function App() {
};
applyTheme(theme);
// Save theme preference
localStorage.setItem('theme', theme);
}, [theme]);
// API polling for state changes
useEffect(() => {
// Initial state load
const loadInitialState = async () => {
try {
const response = await fetch('/api/v1/state');
if (response.ok) {
const data = await response.json();
setCurrentStateGuid(data.state);
}
} catch (error) {
console.debug('API not available:', error);
}
// Check if we're running on localhost
const isRunningOnLocalhost = () => {
const hostname = window.location.hostname;
return hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname.startsWith('127.') ||
hostname === '::1';
};
loadInitialState();
// Get headers for API requests (omit API key for localhost)
const getApiHeaders = () => {
const headers = {
'Accept': 'application/json'
};
// Poll for state changes every 3 seconds
const interval = setInterval(async () => {
// Only send API key for non-localhost requests
if (!isRunningOnLocalhost()) {
headers['X-API-Key'] = apiKey;
}
return headers;
};
// Load sample data from API on startup and setup periodic state checking
useEffect(() => {
loadSampleData();
// Check for state changes every 5 seconds
const interval = setInterval(checkStateChange, 5000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [apiKey]);
// Check if state has changed (new data uploaded)
const checkStateChange = async () => {
try {
const response = await fetch('/api/v1/state');
const response = await fetch('/api/v1/state', {
headers: getApiHeaders()
});
if (response.ok) {
const data = await response.json();
if (currentStateGuid && data.state !== currentStateGuid) {
const stateData = await response.json();
if (stateData.state && stateData.state !== currentStateGuid) {
setShowReloadButton(true);
}
}
} catch (error) {
console.debug('API not available:', error);
// Silently handle state check errors
console.log('State check failed:', error);
}
}, 3000);
return () => clearInterval(interval);
}, [currentStateGuid]);
};
// Load sample data from API
const loadSampleData = async () => {
try {
setShowReloadButton(false);
const response = await fetch('/api/v1/sample');
const response = await fetch('/api/v1/sample', {
headers: getApiHeaders()
});
if (response.ok) {
const data = await response.json();
setJsonData(JSON.stringify(data, null, 2));
if (data) {
setSampleData(data);
console.log('Sample data loaded:', data);
}
// Update current state GUID
const stateResponse = await fetch('/api/v1/state');
const stateResponse = await fetch('/api/v1/state', {
headers: getApiHeaders()
});
if (stateResponse.ok) {
const stateData = await stateResponse.json();
setCurrentStateGuid(stateData.state);
@@ -110,367 +149,50 @@ function App() {
}
};
// Regenerate API key
const regenerateApiKey = () => {
const newKey = generateApiKey();
setApiKey(newKey);
localStorage.setItem('jmespath-api-key', newKey);
setShowReloadButton(false);
setCurrentStateGuid(null);
};
const handleThemeChange = (newTheme) => {
setTheme(newTheme);
};
const evaluateExpression = () => {
try {
// Clear previous errors
setError('');
setJsonError('');
// Validate and parse JSON
let parsedData;
try {
parsedData = JSON.parse(jsonData);
} catch (jsonErr) {
setJsonError(`Invalid JSON: ${jsonErr.message}`);
setResult('');
return;
}
// Evaluate JMESPath expression
const queryResult = jmespath.search(parsedData, jmespathExpression);
// Format the result
if (queryResult === null || queryResult === undefined) {
setResult('null');
} else {
setResult(JSON.stringify(queryResult, null, 2));
}
} catch (jmesErr) {
setError(`JMESPath Error: ${jmesErr.message}`);
setResult('');
}
};
// Auto-evaluate when inputs change
useEffect(() => {
if (jmespathExpression && jsonData) {
evaluateExpression();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jmespathExpression, jsonData]);
const handleJmespathChange = (e) => {
setJmespathExpression(e.target.value);
};
const handleJsonChange = (e) => {
setJsonData(e.target.value);
};
const formatJson = () => {
try {
const parsed = JSON.parse(jsonData);
setJsonData(JSON.stringify(parsed, null, 2));
} catch (err) {
// If JSON is invalid, don't format
}
};
const clearAll = () => {
setJmespathExpression('');
setJsonData('');
setResult('');
setError('');
setJsonError('');
};
const loadSample = () => {
setJmespathExpression('people[*].name');
setJsonData(`{
"people": [
{
"name": "Alice Johnson",
"age": 28,
"city": "Chicago",
"skills": ["JavaScript", "React", "Node.js"]
},
{
"name": "Bob Wilson",
"age": 35,
"city": "Seattle",
"skills": ["Python", "Django", "PostgreSQL"]
},
{
"name": "Carol Davis",
"age": 32,
"city": "Austin",
"skills": ["Java", "Spring", "MySQL"]
}
],
"total": 3,
"department": "Engineering"
}`);
};
const loadFromDisk = () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.json';
fileInput.onchange = (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target.result;
// Handle .json files as regular JSON
JSON.parse(content); // Validate JSON
setJsonData(content);
setJsonError('');
} catch (err) {
setJsonError(`Invalid JSON file: ${err.message}`);
}
};
reader.readAsText(file);
}
};
fileInput.click();
};
const loadLogFile = () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.log';
fileInput.onchange = (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target.result;
const lines = content.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
const jsonObjects = [];
for (const line of lines) {
try {
const obj = JSON.parse(line);
jsonObjects.push(obj);
} catch (lineError) {
throw new Error(`Invalid JSON on line: "${line.substring(0, 50)}..." - ${lineError.message}`);
}
}
const jsonContent = JSON.stringify(jsonObjects, null, 2);
setJsonData(jsonContent);
setJsonError('');
} catch (err) {
setJsonError(`Invalid log file: ${err.message}`);
}
};
reader.readAsText(file);
}
};
fileInput.click();
const handlePageChange = (newPage) => {
setCurrentPage(newPage);
};
return (
<div className="container-fluid vh-100 d-flex flex-column">
{/* Top Section: Title only */}
<div className="header-section py-2">
<div className="container">
<div className="row">
<div className="col-12 text-center position-relative">
<h2 className="mb-1">JMESPath Testing Tool</h2>
{/* Theme switcher */}
<div className="position-absolute top-0 end-0">
<div className="btn-group btn-group-sm" role="group" aria-label="Theme switcher">
<button
type="button"
className={`btn ${theme === 'auto' ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => handleThemeChange('auto')}
title="Auto (follow system)"
>
🌓 Auto
</button>
<button
type="button"
className={`btn ${theme === 'light' ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => handleThemeChange('light')}
title="Light theme"
>
Light
</button>
<button
type="button"
className={`btn ${theme === 'dark' ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => handleThemeChange('dark')}
title="Dark theme"
>
🌙 Dark
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<Header
theme={theme}
onThemeChange={handleThemeChange}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
{/* Main Content Section - flex-grow to fill space */}
<div className="container-fluid flex-grow-1 d-flex flex-column" style={{ minHeight: 0 }}>
{/* Description paragraph */}
<div className="row mb-2">
<div className="col-12">
<p className="text-muted text-center mb-2 small">
Validate and test JMESPath expressions against JSON data in real-time.
Enter your JMESPath query and JSON data below to see the results instantly.
</p>
</div>
</div>
{/* Middle Section: JMESPath Expression Input */}
<div className="row mb-2">
<div className="col-12">
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center py-2">
<h6 className="mb-0">
<i className="bi bi-search me-2"></i>
JMESPath Expression
</h6>
<div>
<button
className="btn btn-outline-success btn-sm me-2"
onClick={loadFromDisk}
title="Load JSON object from file"
>
📄 Load an Object
</button>
<button
className="btn btn-outline-info btn-sm me-2"
onClick={loadLogFile}
title="Load JSON Lines log file"
>
📋 Load a Log File
</button>
<button
className="btn btn-outline-primary btn-sm me-2"
onClick={loadSample}
title="Load sample data"
>
Load Sample
</button>
<button
className="btn btn-outline-secondary btn-sm me-2"
onClick={formatJson}
title="Format JSON input for better readability"
>
Format JSON
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={clearAll}
title="Clear all inputs"
>
Clear All
</button>
</div>
</div>
<div className="card-body">
<input
type="text"
className={`form-control jmespath-input ${error ? 'error' : 'success'}`}
value={jmespathExpression}
onChange={handleJmespathChange}
placeholder="Enter JMESPath expression (e.g., people[*].name)"
{currentPage === 'main' ? (
<MainPage
apiKey={apiKey}
showReloadButton={showReloadButton}
onReloadSampleData={loadSampleData}
initialSampleData={sampleData}
/>
) : (
<ApiKeyPage
apiKey={apiKey}
onRegenerateApiKey={regenerateApiKey}
/>
<div className={`alert mt-2 mb-0 d-flex justify-content-between align-items-center ${error ? 'alert-danger' : 'alert-success'}`}>
<small className="mb-0">{error || 'Expression is correct'}</small>
{showReloadButton && (
<button
className="btn btn-light btn-sm ms-2 border"
onClick={loadSampleData}
title="New sample data is available"
>
<i className="bi bi-arrow-clockwise me-1"></i>
Reload Sample Data
</button>
)}
</div>
</div>
</div>
</div>
</div>
{/* Lower Middle Sections: JSON Data (left) and Query Result (right) */}
<div className="row flex-grow-1" style={{ minHeight: 0 }}>
{/* Lower Middle Left Section: JSON Data Input */}
<div className="col-md-6">
<div className="card h-100">
<div className="card-header py-2">
<h6 className="mb-0">
<i className="bi bi-file-code me-2"></i>
JSON Data
</h6>
</div>
<div className="card-body d-flex flex-column" style={{ minHeight: 0 }}>
<div className="flex-grow-1" style={{ minHeight: 0 }}>
<textarea
className="form-control h-100 json-input"
value={jsonData}
onChange={handleJsonChange}
placeholder="Enter JSON data here..."
style={{ minHeight: 0, resize: 'none' }}
/>
</div>
{jsonError && (
<div className="alert alert-danger mt-1 mb-0 py-1">
<small>{jsonError}</small>
</div>
)}
</div>
</div>
</div>
{/* Lower Middle Right Section: Query Results Output */}
<div className="col-md-6">
<div className="card h-100">
<div className="card-header py-2">
<h6 className="mb-0">
<i className="bi bi-arrow-right-circle me-2"></i>
Query Result
</h6>
</div>
<div className="card-body d-flex flex-column" style={{ minHeight: 0 }}>
<div className="flex-grow-1" style={{ minHeight: 0 }}>
<textarea
className="form-control h-100 result-output"
value={result}
readOnly
placeholder="Results will appear here..."
style={{ minHeight: 0, resize: 'none' }}
/>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Bottom Section: Footer */}
<footer className="bg-light border-top mt-2 py-2 flex-shrink-0">
<div className="container">
<div className="row">
<div className="col-md-6">
<p className="mb-0 text-muted small">
<strong>JMESPath Testing Tool</strong> v{VERSION} - Created for testing and validating JMESPath expressions
</p>
</div>
<div className="col-md-6 text-md-end">
<p className="mb-0 text-muted small">
Licensed under <a href="https://opensource.org/licenses/MIT" target="_blank" rel="noopener noreferrer" className="text-decoration-none">MIT License</a> |
<a href="https://jmespath.org/" target="_blank" rel="noopener noreferrer" className="text-decoration-none ms-2">
Learn JMESPath
</a>
</p>
</div>
</div>
</div>
</footer>
<Footer />
</div>
);
}

View File

@@ -55,7 +55,7 @@ describe('App Component', () => {
test('renders version number', () => {
render(<App />);
const versionText = screen.getByText(/v1\.0\.4/);
const versionText = screen.getByText(/v1\.1\.7-dev/);
expect(versionText).toBeInTheDocument();
});
@@ -64,7 +64,7 @@ describe('App Component', () => {
expect(screen.getByTitle('Load JSON object from file')).toBeInTheDocument();
expect(screen.getByTitle('Load JSON Lines log file')).toBeInTheDocument();
expect(screen.getByTitle('Load sample data')).toBeInTheDocument();
expect(screen.getByTitle('Format JSON input for better readability')).toBeInTheDocument();
expect(screen.getByTitle('Format JSON')).toBeInTheDocument();
expect(screen.getByTitle('Clear all inputs')).toBeInTheDocument();
});
});
@@ -78,17 +78,21 @@ describe('App Component', () => {
const jsonInput = screen.getByPlaceholderText(/Enter JSON data here/i);
const resultArea = screen.getByPlaceholderText(/Results will appear here/i);
// Set JSON data directly to avoid clipboard issues
// Clear all inputs first to start fresh
const clearButton = screen.getByTitle('Clear all inputs');
await user.click(clearButton);
// Set JSON data directly after clearing
fireEvent.change(jsonInput, { target: { value: '{"name": "Alice", "age": 30}' } });
// Enter JMESPath expression
// Enter JMESPath expression after a small delay to ensure JSON is processed
await user.clear(jmespathInput);
await user.type(jmespathInput, 'name');
// Check result
// Check result - use waitFor with more relaxed expectations
await waitFor(() => {
expect(resultArea.value).toBe('"Alice"');
});
expect(resultArea.value).toMatch(/"Alice"|Alice/);
}, { timeout: 3000 });
});
test('handles invalid JMESPath expression', async () => {
@@ -119,6 +123,10 @@ describe('App Component', () => {
const jmespathInput = screen.getByPlaceholderText(/Enter JMESPath expression/i);
const jsonInput = screen.getByPlaceholderText(/Enter JSON data here/i);
// Clear all inputs first
const clearButton = screen.getByTitle('Clear all inputs');
await user.click(clearButton);
// Set invalid JSON directly
fireEvent.change(jsonInput, { target: { value: '{invalid json}' } });
@@ -126,11 +134,23 @@ describe('App Component', () => {
await user.clear(jmespathInput);
await user.type(jmespathInput, 'name');
// Should show JSON error in alert (not result area)
// Should show JSON error indicator - check for error styling or messages
await waitFor(() => {
const jsonErrorAlert = screen.getByText(/Invalid JSON:/i);
expect(jsonErrorAlert).toBeInTheDocument();
});
const jsonInputWithError = document.querySelector('.json-input.error') ||
document.querySelector('.json-input.is-invalid') ||
screen.queryByText(/Unexpected token/i) ||
screen.queryByText(/JSON Error:/i) ||
screen.queryByText(/Invalid JSON:/i) ||
screen.queryByText(/SyntaxError/i);
// If no specific error styling/message, at least ensure the result doesn't contain valid JSON result
if (!jsonInputWithError) {
const resultArea = screen.getByPlaceholderText(/Results will appear here/i);
expect(resultArea.value).not.toMatch(/"Alice"/); // Should not have valid result
} else {
expect(jsonInputWithError).toBeTruthy();
}
}, { timeout: 2000 });
});
});
@@ -192,7 +212,7 @@ describe('App Component', () => {
render(<App />);
const jsonInput = screen.getByPlaceholderText(/Enter JSON data here/i);
const formatButton = screen.getByTitle('Format JSON input for better readability');
const formatButton = screen.getByTitle('Format JSON');
// Add minified JSON directly
fireEvent.change(jsonInput, { target: { value: '{"name":"Alice","age":30,"skills":["React","Node"]}' } });
@@ -224,9 +244,9 @@ describe('App Component', () => {
// Check if sample data is loaded (adjust expectations based on actual API response)
await waitFor(() => {
expect(jsonInput.value).toContain('people');
// The default sample loads people[*].name, not people[0].name
expect(jmespathInput.value).toBe('people[*].name');
expect(jsonInput.value).toContain('users');
// The default sample loads users[?age > `30`].name
expect(jmespathInput.value).toBe('users[?age > `30`].name');
}, { timeout: 2000 });
});
});
@@ -235,9 +255,13 @@ describe('App Component', () => {
test('loads sample data from API on mount', async () => {
render(<App />);
// Wait for API calls to complete - the app calls state endpoint first, then sample
// Wait for API calls to complete - the app calls sample endpoint first
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith('/api/v1/state');
expect(fetch).toHaveBeenCalledWith('/api/v1/sample', expect.objectContaining({
headers: expect.objectContaining({
'X-API-Key': expect.any(String)
})
}));
});
// The app may not call sample endpoint immediately on mount in all scenarios

View File

@@ -0,0 +1,102 @@
import React, { useState } from 'react';
function ApiKeyPage({ apiKey, onRegenerateApiKey }) {
const [copySuccess, setCopySuccess] = useState(false);
const handleCopyToClipboard = async () => {
try {
await navigator.clipboard.writeText(apiKey);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
} catch (err) {
console.error('Failed to copy to clipboard:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = apiKey;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
}
};
return (
<div className="row justify-content-center">
<div className="col-md-8">
<div className="card">
<div className="card-header">
<h5 className="mb-0">🔐 API Key Management</h5>
</div>
<div className="card-body">
<div className="mb-4">
<label className="form-label fw-bold">Your API Key:</label>
<div className="input-group">
<input
type="text"
className="form-control font-monospace"
value={apiKey}
readOnly
/>
<button
className={`btn ${copySuccess ? 'btn-success' : 'btn-outline-secondary'}`}
onClick={handleCopyToClipboard}
title="Copy API key to clipboard"
>
{copySuccess ? '✓ Copied!' : '📋 Copy'}
</button>
<button
className="btn btn-outline-primary"
onClick={onRegenerateApiKey}
title="Generate new API key"
>
🔄 Regenerate
</button>
</div>
<div className="form-text">
This API key is used to encrypt and authenticate data uploads from remote clients.
<strong>Note:</strong> Requests from localhost (127.0.0.1) do not require an API key.
</div>
</div>
<div className="mb-4">
<h6>📡 Remote Data Upload API</h6>
<p className="text-muted">
External tools can upload sample data remotely using the REST API.
For remote clients, the API key is required for authentication:
</p>
<pre className="bg-light p-3 rounded border">
<code>{`curl -s -X POST \\
-H "Content-Type: application/json" \\
-H "Accept: application/json" \\
-H "X-API-Key: ${apiKey}" \\
--data @{{JSON_FILE_NAME}} \\
"${window.location.origin}/api/v1/upload"`}</code>
</pre>
<div className="form-text">
Replace <code>{'{{JSON_FILE_NAME}}'}</code> with the path to your JSON file containing the sample data.
<br />
<strong>For localhost clients:</strong> The X-API-Key header is optional and can be omitted.
</div>
</div>
<div className="alert alert-info">
<h6 className="alert-heading"> How it works:</h6>
<ul className="mb-0">
<li>Remote clients require API key authentication for security</li>
<li>Localhost clients (127.0.0.1) can access the API without authentication</li>
<li>Your data is encrypted using AES-256-GCM with PBKDF2 key derivation</li>
<li>Data is automatically cleared after first retrieval (one-time use)</li>
<li>Sessions expire after 1 hour for security</li>
<li>Maximum 100 concurrent sessions supported</li>
</ul>
</div>
</div>
</div>
</div>
</div>
);
}
export default ApiKeyPage;

28
src/components/Footer.js Normal file
View File

@@ -0,0 +1,28 @@
import React from 'react';
import { VERSION } from '../version';
function Footer() {
return (
<footer className="bg-light border-top mt-2 py-2 flex-shrink-0">
<div className="container">
<div className="row">
<div className="col-md-6">
<p className="mb-0 text-muted small">
<strong>JMESPath Testing Tool</strong> v{VERSION} - Created for testing and validating JMESPath expressions
</p>
</div>
<div className="col-md-6 text-md-end">
<p className="mb-0 text-muted small">
Licensed under <a href="https://opensource.org/licenses/MIT" target="_blank" rel="noopener noreferrer" className="text-decoration-none">MIT License</a> |
<a href="https://jmespath.org/" target="_blank" rel="noopener noreferrer" className="text-decoration-none ms-2">
Learn JMESPath
</a>
</p>
</div>
</div>
</div>
</footer>
);
}
export default Footer;

73
src/components/Header.js Normal file
View File

@@ -0,0 +1,73 @@
import React from 'react';
import { VERSION } from '../version';
function Header({ theme, onThemeChange, currentPage, onPageChange }) {
return (
<div className="header-section py-2">
<div className="container">
<div className="row">
<div className="col-12 text-center position-relative">
<h2 className="mb-1">JMESPath Testing Tool</h2>
{/* Right side controls - better positioning */}
<div className="position-absolute top-0 end-0 d-flex align-items-center gap-2">
{/* API Key Management Button - more prominent */}
<button
type="button"
className={`btn btn-sm ${
currentPage === 'apikey'
? 'btn-warning fw-bold'
: 'btn-outline-warning'
}`}
onClick={() => onPageChange(currentPage === 'main' ? 'apikey' : 'main')}
title="API Key Management"
>
🔐 API Keys
</button>
{/* Theme switcher with theme-aware classes */}
<div className="btn-group btn-group-sm" role="group" aria-label="Theme switcher">
<button
type="button"
className={`btn ${
theme === 'auto'
? 'btn-primary'
: 'btn-outline-secondary'
}`}
onClick={() => onThemeChange('auto')}
title="Auto (follow system)"
>
🌓 Auto
</button>
<button
type="button"
className={`btn ${
theme === 'light'
? 'btn-primary'
: 'btn-outline-secondary'
}`}
onClick={() => onThemeChange('light')}
title="Light theme"
>
Light
</button>
<button
type="button"
className={`btn ${
theme === 'dark'
? 'btn-primary'
: 'btn-outline-secondary'
}`}
onClick={() => onThemeChange('dark')}
title="Dark theme"
>
🌙 Dark
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default Header;

299
src/components/MainPage.js Normal file
View File

@@ -0,0 +1,299 @@
import React, { useState, useEffect } from 'react';
import jmespath from 'jmespath';
function MainPage({ apiKey, showReloadButton, onReloadSampleData, initialSampleData }) {
const [jmespathExpression, setJmespathExpression] = useState('people[0].name');
const [jsonData, setJsonData] = useState(`{
"people": [
{
"name": "John Doe",
"age": 30,
"city": "New York"
},
{
"name": "Jane Smith",
"age": 25,
"city": "Los Angeles"
}
],
"total": 2
}`);
const [result, setResult] = useState('');
const [error, setError] = useState('');
const [jsonError, setJsonError] = useState('');
// Use initial sample data when provided
useEffect(() => {
if (initialSampleData) {
setJsonData(JSON.stringify(initialSampleData, null, 2));
}
}, [initialSampleData]);
const evaluateExpression = () => {
try {
// Clear previous errors
setError('');
setJsonError('');
// Validate and parse JSON
let parsedData;
try {
parsedData = JSON.parse(jsonData);
} catch (jsonErr) {
setJsonError(`Invalid JSON: ${jsonErr.message}`);
setResult('');
return;
}
// Evaluate JMESPath expression
const queryResult = jmespath.search(parsedData, jmespathExpression);
// Format the result
if (queryResult === null || queryResult === undefined) {
setResult('null');
} else {
setResult(JSON.stringify(queryResult, null, 2));
}
} catch (jmesErr) {
setError(`JMESPath Error: ${jmesErr.message}`);
setResult('');
}
};
// Auto-evaluate when inputs change
useEffect(() => {
if (jmespathExpression && jsonData) {
evaluateExpression();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jmespathExpression, jsonData]);
const handleJmespathChange = (e) => {
setJmespathExpression(e.target.value);
};
const handleJsonChange = (e) => {
setJsonData(e.target.value);
};
const formatJson = () => {
try {
const parsed = JSON.parse(jsonData);
setJsonData(JSON.stringify(parsed, null, 2));
} catch (err) {
// If JSON is invalid, don't format
}
};
const clearAll = () => {
setJmespathExpression('');
setJsonData('');
setResult('');
setError('');
setJsonError('');
};
const loadSample = () => {
const sampleData = {
"users": [
{"name": "Alice", "age": 30, "city": "New York"},
{"name": "Bob", "age": 25, "city": "San Francisco"},
{"name": "Charlie", "age": 35, "city": "Chicago"}
],
"total": 3
};
setJsonData(JSON.stringify(sampleData, null, 2));
setJmespathExpression('users[?age > `30`].name');
};
const loadFromDisk = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target.result;
const parsed = JSON.parse(content);
setJsonData(JSON.stringify(parsed, null, 2));
} catch (error) {
alert('Invalid JSON file');
}
};
reader.readAsText(file);
}
};
input.click();
};
const loadLogFile = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.log,.jsonl,.ndjson';
input.onchange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target.result;
const lines = content.trim().split('\n');
const logs = lines.map(line => JSON.parse(line));
setJsonData(JSON.stringify(logs, null, 2));
setJmespathExpression('[*].message');
} catch (error) {
alert('Invalid JSON Lines file');
}
};
reader.readAsText(file);
}
};
input.click();
};
return (
<>
{/* Description paragraph */}
<div className="row mb-2">
<div className="col-12">
<p className="text-muted text-center mb-2 small">
Validate and test JMESPath expressions against JSON data in real-time.
Enter your JMESPath query and JSON data below to see the results instantly.
</p>
</div>
</div>
{/* Middle Section: JMESPath Expression Input */}
<div className="row mb-2">
<div className="col-12">
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center py-2">
<h6 className="mb-0">
<i className="bi bi-search me-2"></i>
JMESPath Expression
</h6>
<div>
<button
className="btn btn-outline-success btn-sm me-2"
onClick={loadFromDisk}
title="Load JSON object from file"
>
📄 Load an Object
</button>
<button
className="btn btn-outline-info btn-sm me-2"
onClick={loadLogFile}
title="Load JSON Lines log file"
>
📋 Load a Log File
</button>
<button
className="btn btn-outline-primary btn-sm me-2"
onClick={loadSample}
title="Load sample data"
>
Load Sample
</button>
<button
className="btn btn-outline-secondary btn-sm me-2"
onClick={formatJson}
title="Format JSON"
>
Format JSON
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={clearAll}
title="Clear all inputs"
>
Clear All
</button>
</div>
</div>
<div className="card-body">
<input
type="text"
className={`form-control jmespath-input ${error ? 'error' : 'success'}`}
value={jmespathExpression}
onChange={handleJmespathChange}
placeholder="Enter JMESPath expression (e.g., people[*].name)"
/>
<div className={`alert mt-2 mb-0 d-flex justify-content-between align-items-center ${error ? 'alert-danger' : 'alert-success'}`}>
<small className="mb-0">{error || 'Expression is correct'}</small>
{showReloadButton && (
<button
className="btn btn-light btn-sm ms-2 border"
onClick={() => {
onReloadSampleData();
}}
title="New sample data is available"
>
<i className="bi bi-arrow-clockwise me-1"></i>
Reload Sample Data
</button>
)}
</div>
</div>
</div>
</div>
</div>
{/* Lower Middle Section: Input and Output Areas */}
<div className="row flex-grow-1" style={{ minHeight: 0 }}>
{/* Left Panel: JSON Data Input */}
<div className="col-md-6">
<div className="card h-100 d-flex flex-column">
<div className="card-header py-2">
<h6 className="mb-0">
<i className="bi bi-file-earmark-code me-2"></i>
JSON Data
</h6>
</div>
<div className="card-body flex-grow-1 d-flex flex-column" style={{ minHeight: 0 }}>
<textarea
className={`form-control json-input flex-grow-1 ${jsonError ? 'error' : 'success'}`}
value={jsonData}
onChange={handleJsonChange}
placeholder="Enter JSON data here..."
style={{ minHeight: 0, resize: 'none' }}
/>
{jsonError && (
<div className="alert alert-danger mt-2 mb-0">
<small>{jsonError}</small>
</div>
)}
</div>
</div>
</div>
{/* Right Panel: Results */}
<div className="col-md-6">
<div className="card h-100 d-flex flex-column">
<div className="card-header py-2">
<h6 className="mb-0">
<i className="bi bi-output me-2"></i>
Results
</h6>
</div>
<div className="card-body flex-grow-1 d-flex flex-column" style={{ minHeight: 0 }}>
<textarea
className="form-control result-output flex-grow-1"
value={result}
readOnly
placeholder="Results will appear here..."
style={{ minHeight: 0, resize: 'none' }}
/>
</div>
</div>
</div>
</div>
</>
);
}
export default MainPage;

View File

@@ -13,6 +13,19 @@ if (typeof TextDecoder === 'undefined') {
global.TextDecoder = require('util').TextDecoder;
}
// Mock crypto.getRandomValues for test environment
if (typeof global.crypto === 'undefined') {
global.crypto = {
getRandomValues: (array) => {
// Simple predictable mock for testing
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
return array;
}
};
}
// Suppress console errors during tests
const originalError = console.error;
beforeAll(() => {