SYNC: Merge pull request #63 from dbgate/feature/test-api-e2e

This commit is contained in:
Jan Prochazka
2026-02-24 14:39:17 +01:00
committed by Diflow
parent 66fc6b93ae
commit 8c34added7
30 changed files with 3803 additions and 3 deletions
+3
View File
@@ -3,3 +3,6 @@
## Rules
- In newly added code, always use `DBGM-00000` for message/error codes; do not introduce new numbered DBGM codes such as `DBGM-00316`.
- GUI uses Svelte4 (packages/web)
- GUI is tested with E2E tests in `e2e-tests` folder, using Cypress. Use data-testid attribute in components to make them easier to test.
- data-testid format: ComponentName_identifier. Use reasonable identifiers
+18
View File
@@ -3,8 +3,26 @@ const os = require('os');
const fs = require('fs');
const baseDir = path.join(os.homedir(), '.dbgate');
const testApiPidFile = path.join(__dirname, 'tmpdata', 'test-api.pid');
function clearTestingData() {
if (fs.existsSync(testApiPidFile)) {
try {
const pid = Number(fs.readFileSync(testApiPidFile, 'utf-8'));
if (Number.isInteger(pid) && pid > 0) {
process.kill(pid);
}
} catch (err) {
// ignore stale PID files and dead processes
}
try {
fs.unlinkSync(testApiPidFile);
} catch (err) {
// ignore cleanup errors
}
}
if (fs.existsSync(path.join(baseDir, 'connections-e2etests.jsonl'))) {
fs.unlinkSync(path.join(baseDir, 'connections-e2etests.jsonl'));
}
+3
View File
@@ -37,6 +37,9 @@ module.exports = defineConfig({
case 'browse-data':
serverProcess = exec('yarn start:browse-data');
break;
case 'rest':
serverProcess = exec('yarn start:rest');
break;
case 'team':
serverProcess = exec('yarn start:team');
break;
+39
View File
@@ -0,0 +1,39 @@
Cypress.on('uncaught:exception', err => {
if (err.message.includes("Failed to execute 'importScripts' on 'WorkerGlobalScope'")) {
return false;
}
});
beforeEach(() => {
cy.visit('http://localhost:3000');
cy.viewport(1250, 900);
});
describe('REST API connections', () => {
it('GraphQL test', () => {
cy.contains('REST GraphQL').click();
cy.contains('products').click();
cy.testid('GraphQlExplorerNode_toggle_products').click();
cy.testid('GraphQlExplorerNode_checkbox_products.name').click();
cy.testid('GraphQlExplorerNode_checkbox_products.price').click();
cy.testid('GraphQlExplorerNode_checkbox_products.description').click();
cy.testid('GraphQlExplorerNode_checkbox_products.category').click();
cy.testid('GraphQlQueryTab_execute').click();
cy.contains('Electronics');
cy.themeshot('rest-graphql-query');
});
it('REST OpenAPI test', () => {
cy.contains('REST OpenAPI').click();
cy.contains('/api/categories').click();
cy.testid('RestApiEndpointTab_execute').click();
cy.contains('Electronics');
cy.themeshot('rest-openapi-query');
});
it('REST OData test', () => {
cy.contains('REST OData').click();
cy.contains('/Users').click();
cy.testid('ODataEndpointTab_execute').click();
cy.contains('Henry');
cy.themeshot('rest-odata-query');
});
});
+14
View File
@@ -0,0 +1,14 @@
CONNECTIONS=odata,openapi,graphql
LABEL_odata=REST OData
ENGINE_odata=odata@rest
APISERVERURL1_odata=http://localhost:4444/odata/noauth
LABEL_openapi=REST OpenAPI
ENGINE_openapi=openapi@rest
APISERVERURL1_openapi=http://localhost:4444/openapi.json
APISERVERURL2_openapi=http://localhost:4444/openapi/noauth
LABEL_graphql=REST GraphQL
ENGINE_graphql=graphql@rest
APISERVERURL1_graphql=http://localhost:4444/graphql/noauth
+102
View File
@@ -0,0 +1,102 @@
const fs = require('fs');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const rootDir = path.resolve(__dirname, '..', '..');
const testApiDir = path.join(rootDir, 'test-api');
const pidFile = path.resolve(__dirname, '..', 'tmpdata', 'test-api.pid');
const isWindows = process.platform === 'win32';
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function waitForApiReady(timeoutMs = 30000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch('http://localhost:4444/openapi.json');
if (response.ok) {
return;
}
} catch (err) {
// continue waiting
}
await delay(500);
}
throw new Error('DBGM-00000 test-api did not start on port 4444 in time');
}
function stopPreviousTestApi() {
if (!fs.existsSync(pidFile)) {
return;
}
try {
const pid = Number(fs.readFileSync(pidFile, 'utf-8'));
if (Number.isInteger(pid) && pid > 0) {
process.kill(pid);
}
} catch (err) {
// ignore stale pid file or already terminated process
}
try {
fs.unlinkSync(pidFile);
} catch (err) {
// ignore
}
}
function startTestApi() {
const command = isWindows ? 'cmd.exe' : 'yarn';
const args = isWindows ? ['/c', 'yarn start'] : ['start'];
const child = spawn(command, args, {
cwd: testApiDir,
env: {
...process.env,
PORT: '4444',
},
detached: true,
stdio: 'ignore',
});
child.unref();
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
fs.writeFileSync(pidFile, String(child.pid));
}
function ensureTestApiDependencies() {
const dependencyCheckFile = path.join(testApiDir, 'node_modules', 'swagger-jsdoc', 'package.json');
if (fs.existsSync(dependencyCheckFile)) {
return;
}
const installCommand = isWindows ? 'cmd.exe' : 'yarn';
const installArgs = isWindows ? ['/c', 'yarn install --silent'] : ['install', '--silent'];
const result = spawnSync(installCommand, installArgs, {
cwd: testApiDir,
stdio: 'inherit',
env: process.env,
});
if (result.status !== 0) {
throw new Error('DBGM-00000 Failed to install test-api dependencies');
}
}
async function run() {
stopPreviousTestApi();
ensureTestApiDependencies();
startTestApi();
await waitForApiReady();
}
run().catch(err => {
console.error(err);
process.exit(1);
});
+4 -1
View File
@@ -19,6 +19,7 @@
"cy:run:portal": "cypress run --spec cypress/e2e/portal.cy.js",
"cy:run:oauth": "cypress run --spec cypress/e2e/oauth.cy.js",
"cy:run:browse-data": "cypress run --spec cypress/e2e/browse-data.cy.js",
"cy:run:rest": "cypress run --spec cypress/e2e/rest.cy.js",
"cy:run:team": "cypress run --spec cypress/e2e/team.cy.js",
"cy:run:multi-sql": "cypress run --spec cypress/e2e/multi-sql.cy.js",
"cy:run:cloud": "cypress run --spec cypress/e2e/cloud.cy.js",
@@ -28,6 +29,7 @@
"start:portal": "node clearTestingData && cd .. && env-cmd -f e2e-tests/env/portal/.env node e2e-tests/init/portal.js && env-cmd -f e2e-tests/env/portal/.env node packer/build/bundle.js --listen-api --run-e2e-tests",
"start:oauth": "node clearTestingData && cd .. && env-cmd -f e2e-tests/env/oauth/.env node packer/build/bundle.js --listen-api --run-e2e-tests",
"start:browse-data": "node clearTestingData && cd .. && env-cmd -f e2e-tests/env/browse-data/.env node e2e-tests/init/browse-data.js && env-cmd -f e2e-tests/env/browse-data/.env node packer/build/bundle.js --listen-api --run-e2e-tests",
"start:rest": "node clearTestingData && cd .. && env-cmd -f e2e-tests/env/rest/.env node e2e-tests/init/rest.js && env-cmd -f e2e-tests/env/rest/.env node packer/build/bundle.js --listen-api --run-e2e-tests",
"start:team": "node clearTestingData && cd .. && env-cmd -f e2e-tests/env/team/.env node e2e-tests/init/team.js && env-cmd -f e2e-tests/env/team/.env node packer/build/bundle.js --listen-api --run-e2e-tests",
"start:multi-sql": "node clearTestingData && cd .. && env-cmd -f e2e-tests/env/multi-sql/.env node e2e-tests/init/multi-sql.js && env-cmd -f e2e-tests/env/multi-sql/.env node packer/build/bundle.js --listen-api --run-e2e-tests",
"start:cloud": "node clearTestingData && cd .. && env-cmd -f e2e-tests/env/cloud/.env node packer/build/bundle.js --listen-api --run-e2e-tests",
@@ -37,12 +39,13 @@
"test:portal": "start-server-and-test start:portal http://localhost:3000 cy:run:portal",
"test:oauth": "start-server-and-test start:oauth http://localhost:3000 cy:run:oauth",
"test:browse-data": "start-server-and-test start:browse-data http://localhost:3000 cy:run:browse-data",
"test:rest": "start-server-and-test start:rest http://localhost:3000 cy:run:rest",
"test:team": "start-server-and-test start:team http://localhost:3000 cy:run:team",
"test:multi-sql": "start-server-and-test start:multi-sql http://localhost:3000 cy:run:multi-sql",
"test:cloud": "start-server-and-test start:cloud http://localhost:3000 cy:run:cloud",
"test:charts": "start-server-and-test start:charts http://localhost:3000 cy:run:charts",
"test:redis": "start-server-and-test start:redis http://localhost:3000 cy:run:redis",
"test": "yarn test:add-connection && yarn test:portal && yarn test:oauth && yarn test:browse-data && yarn test:team && yarn test:multi-sql && yarn test:cloud && yarn test:charts && yarn test:redis",
"test": "yarn test:add-connection && yarn test:portal && yarn test:oauth && yarn test:browse-data && yarn test:rest && yarn test:team && yarn test:multi-sql && yarn test:cloud && yarn test:charts && yarn test:redis",
"test:ci": "yarn test"
},
"dependencies": {}
+1
View File
@@ -0,0 +1 @@
test-api.pid
+13 -2
View File
@@ -25,8 +25,14 @@ function extractConnectionsFromEnv(env) {
socketPath: env[`SOCKET_PATH_${id}`],
serviceName: env[`SERVICE_NAME_${id}`],
authType: env[`AUTH_TYPE_${id}`] || (env[`SOCKET_PATH_${id}`] ? 'socket' : undefined),
defaultDatabase: env[`DATABASE_${id}`] || (env[`FILE_${id}`] ? getDatabaseFileLabel(env[`FILE_${id}`]) : null),
singleDatabase: !!env[`DATABASE_${id}`] || !!env[`FILE_${id}`],
defaultDatabase:
env[`DATABASE_${id}`] ||
(env[`FILE_${id}`]
? getDatabaseFileLabel(env[`FILE_${id}`])
: env[`APISERVERURL1_${id}`]
? '_api_database_'
: null),
singleDatabase: !!env[`DATABASE_${id}`] || !!env[`FILE_${id}`] || !!env[`APISERVERURL1_${id}`],
displayName: env[`LABEL_${id}`],
isReadOnly: env[`READONLY_${id}`],
databases: env[`DBCONFIG_${id}`] ? safeJsonParse(env[`DBCONFIG_${id}`]) : null,
@@ -54,6 +60,11 @@ function extractConnectionsFromEnv(env) {
sslKeyFile: env[`SSL_KEY_FILE_${id}`],
sslRejectUnauthorized: env[`SSL_REJECT_UNAUTHORIZED_${id}`],
trustServerCertificate: env[`SSL_TRUST_CERTIFICATE_${id}`],
apiServerUrl1: env[`APISERVERURL1_${id}`],
apiServerUrl2: env[`APISERVERURL2_${id}`],
apiKeyHeader: env[`APIKEYHEADER_${id}`],
apiKeyValue: env[`APIKEYVALUE_${id}`],
}));
return connections;
+139
View File
@@ -0,0 +1,139 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+139
View File
@@ -0,0 +1,139 @@
# dbgate-test-api
Testing API server with OpenAPI/Swagger and GraphQL support for E2E testing.
## Features
- 🚀 RESTful API with full CRUD operations
- 📚 Swagger UI for API documentation and testing
- 🔍 GraphQL API with custom query interface
- 💾 In-memory data storage
- 🎲 Pre-filled sample data for immediate testing
## Entities
The API provides 5 entities with comprehensive attributes:
1. **Users** (200 records) - firstName, lastName, email, age, active, createdAt
2. **Products** (200 records) - name, description, price, category, inStock, quantity, sku, createdAt
3. **Orders** (50 records) - customerId, orderNumber, totalAmount, status, orderDate
4. **Categories** (28 records) - name, slug, description, parentId, active, displayOrder
5. **Reviews** (100 records) - productId, userId, rating, title, comment, verified, helpfulCount, createdAt
## Installation
```bash
npm install
```
## Usage
Start the server:
```bash
npm start
```
The server will run on `http://localhost:3000` (or the port specified in PORT environment variable).
## API Endpoints
### REST API
- **Users**: `/api/users`
- **Products**: `/api/products`
- **Orders**: `/api/orders`
- **Categories**: `/api/categories`
- **Reviews**: `/api/reviews`
Each endpoint supports:
- `GET /api/{entity}` - Get all records
- `GET /api/{entity}/{id}` - Get single record
- `POST /api/{entity}` - Create new record
- `PUT /api/{entity}/{id}` - Update record
- `DELETE /api/{entity}/{id}` - Delete record
### Documentation & Testing
- **Swagger UI**: [http://localhost:3000/openapi/docs](http://localhost:3000/openapi/docs)
- **OpenAPI JSON**: [http://localhost:3000/openapi.json](http://localhost:3000/openapi.json)
- **GraphQL Endpoint**: [http://localhost:3000/graphql](http://localhost:3000/graphql)
- **GraphQL Interface**: [http://localhost:3000/graphiql](http://localhost:3000/graphiql)
## Example Requests
### REST API
```bash
# Get all users
curl http://localhost:3000/api/users
# Get user by ID
curl http://localhost:3000/api/users/1
# Create a new user
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"firstName":"John","lastName":"Doe","email":"john@example.com","age":30}'
# Update a user
curl -X PUT http://localhost:3000/api/users/1 \
-H "Content-Type: application/json" \
-d '{"age":31}'
# Delete a user
curl -X DELETE http://localhost:3000/api/users/1
```
### GraphQL API
```bash
# Query all users
curl -X POST http://localhost:3000/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ users { id firstName lastName email } }"}'
# Query single user
curl -X POST http://localhost:3000/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ user(id: 1) { id firstName lastName email age } }"}'
# Create user mutation
curl -X POST http://localhost:3000/graphql \
-H "Content-Type: application/json" \
-d '{"query":"mutation { createUser(input: {firstName: \"John\", lastName: \"Doe\", email: \"john@example.com\", age: 30}) { id firstName lastName } }"}'
```
## GraphQL Schema
### Queries
- `users` - Get all users
- `user(id: Int!)` - Get user by ID
- `products` - Get all products
- `product(id: Int!)` - Get product by ID
- `orders` - Get all orders
- `order(id: Int!)` - Get order by ID
- `categories` - Get all categories
- `category(id: Int!)` - Get category by ID
- `reviews` - Get all reviews
- `review(id: Int!)` - Get review by ID
### Mutations
- `createUser(input: UserInput!)` - Create user
- `updateUser(id: Int!, input: UserInput!)` - Update user
- `deleteUser(id: Int!)` - Delete user
- And similar mutations for products, orders, categories, and reviews
## Use in E2E Tests
This API is designed to be used in automated E2E tests:
```javascript
// Example using fetch in tests
const response = await fetch('http://localhost:3000/api/users');
const users = await response.json();
expect(users).toHaveLength(200);
```
## License
ISC
+41
View File
@@ -0,0 +1,41 @@
{
"name": "dbgate-tets-api",
"version": "1.0.0",
"description": "Testing API server (OpenAPI/swagger + graphQL)",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node src/server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/dbgate/dbgate-tets-api.git"
},
"keywords": [
"testing",
"api",
"rest",
"graphql",
"swagger",
"openapi",
"e2e"
],
"author": "",
"license": "ISC",
"type": "commonjs",
"bugs": {
"url": "https://github.com/dbgate/dbgate-tets-api/issues"
},
"homepage": "https://github.com/dbgate/dbgate-tets-api#readme",
"dependencies": {
"cors": "^2.8.6",
"express": "^5.2.1",
"graphiql": "^5.2.2",
"graphql": "^16.12.0",
"graphql-http": "^1.22.4",
"js-yaml": "^4.1.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1"
}
}
+53
View File
@@ -0,0 +1,53 @@
// Categories entity with in-memory storage
const categories = [];
let nextId = 1;
// Helper function to generate sample data
function generateCategories(count) {
const categoryNames = [
'Electronics', 'Computers', 'Mobile Phones', 'Cameras',
'Clothing', 'Men\'s Fashion', 'Women\'s Fashion', 'Kids Fashion',
'Books', 'Fiction', 'Non-Fiction', 'Educational',
'Home & Garden', 'Furniture', 'Kitchen', 'Decor',
'Sports', 'Fitness', 'Outdoor', 'Team Sports',
'Toys', 'Action Figures', 'Board Games', 'Puzzles',
'Automotive', 'Parts', 'Accessories', 'Tools'
];
for (let i = 0; i < Math.min(count, categoryNames.length); i++) {
categories.push({
id: nextId++,
name: categoryNames[i],
slug: categoryNames[i].toLowerCase().replace(/[^a-z0-9]+/g, '-'),
description: `Category for ${categoryNames[i]} products`,
parentId: i > 3 ? Math.floor(Math.random() * 4) + 1 : null,
active: Math.random() > 0.1,
displayOrder: i + 1
});
}
}
// Prefill with 28 categories
generateCategories(28);
module.exports = {
getAll: () => categories,
getById: (id) => categories.find(c => c.id === parseInt(id)),
create: (data) => {
const category = { id: nextId++, ...data };
categories.push(category);
return category;
},
update: (id, data) => {
const index = categories.findIndex(c => c.id === parseInt(id));
if (index === -1) return null;
categories[index] = { ...categories[index], ...data, id: parseInt(id) };
return categories[index];
},
delete: (id) => {
const index = categories.findIndex(c => c.id === parseInt(id));
if (index === -1) return false;
categories.splice(index, 1);
return true;
}
};
+49
View File
@@ -0,0 +1,49 @@
// Orders entity with in-memory storage
const orders = [];
let nextId = 1;
// Helper function to generate sample data
function generateOrders(count) {
const statuses = ['pending', 'processing', 'shipped', 'delivered', 'cancelled'];
for (let i = 0; i < count; i++) {
orders.push({
id: nextId++,
customerId: Math.floor(Math.random() * 200) + 1,
orderNumber: `ORD-${String(i + 1).padStart(8, '0')}`,
totalAmount: parseFloat((Math.random() * 5000 + 50).toFixed(2)),
status: statuses[Math.floor(Math.random() * statuses.length)],
orderDate: new Date(Date.now() - Math.floor(Math.random() * 180 * 24 * 60 * 60 * 1000)).toISOString()
});
}
}
// Prefill with 50 orders
generateOrders(50);
module.exports = {
getAll: () => orders,
getById: (id) => orders.find(o => o.id === parseInt(id)),
create: (data) => {
const order = {
id: nextId++,
...data,
orderDate: new Date().toISOString(),
orderNumber: `ORD-${String(nextId).padStart(8, '0')}`
};
orders.push(order);
return order;
},
update: (id, data) => {
const index = orders.findIndex(o => o.id === parseInt(id));
if (index === -1) return null;
orders[index] = { ...orders[index], ...data, id: parseInt(id) };
return orders[index];
},
delete: (id) => {
const index = orders.findIndex(o => o.id === parseInt(id));
if (index === -1) return false;
orders.splice(index, 1);
return true;
}
};
+51
View File
@@ -0,0 +1,51 @@
// Products entity with in-memory storage
const products = [];
let nextId = 1;
// Helper function to generate sample data
function generateProducts(count) {
const categories = ['Electronics', 'Clothing', 'Books', 'Home & Garden', 'Sports', 'Toys'];
const adjectives = ['Premium', 'Deluxe', 'Standard', 'Basic', 'Pro', 'Ultra'];
const nouns = ['Widget', 'Gadget', 'Tool', 'Device', 'Item', 'Product'];
for (let i = 0; i < count; i++) {
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
products.push({
id: nextId++,
name: `${adjective} ${noun} ${i + 1}`,
description: `This is a high-quality ${adjective.toLowerCase()} ${noun.toLowerCase()} for your needs.`,
price: parseFloat((Math.random() * 1000 + 10).toFixed(2)),
category: categories[Math.floor(Math.random() * categories.length)],
inStock: Math.random() > 0.2,
quantity: Math.floor(Math.random() * 500),
sku: `SKU-${String(i + 1).padStart(6, '0')}`,
createdAt: new Date(Date.now() - Math.floor(Math.random() * 365 * 24 * 60 * 60 * 1000)).toISOString()
});
}
}
// Prefill with 200 products
generateProducts(200);
module.exports = {
getAll: () => products,
getById: (id) => products.find(p => p.id === parseInt(id)),
create: (data) => {
const product = { id: nextId++, ...data, createdAt: new Date().toISOString() };
products.push(product);
return product;
},
update: (id, data) => {
const index = products.findIndex(p => p.id === parseInt(id));
if (index === -1) return null;
products[index] = { ...products[index], ...data, id: parseInt(id) };
return products[index];
},
delete: (id) => {
const index = products.findIndex(p => p.id === parseInt(id));
if (index === -1) return false;
products.splice(index, 1);
return true;
}
};
+58
View File
@@ -0,0 +1,58 @@
// Reviews entity with in-memory storage
const reviews = [];
let nextId = 1;
// Helper function to generate sample data
function generateReviews(count) {
const reviewTexts = [
'Great product, highly recommended!',
'Good quality for the price.',
'Not what I expected, but okay.',
'Excellent! Will buy again.',
'Poor quality, disappointed.',
'Amazing product! Exceeded expectations.',
'Average product, nothing special.',
'Very satisfied with this purchase.',
'Could be better, but it works.',
'Outstanding quality and service!'
];
for (let i = 0; i < count; i++) {
reviews.push({
id: nextId++,
productId: Math.floor(Math.random() * 200) + 1,
userId: Math.floor(Math.random() * 200) + 1,
rating: Math.floor(Math.random() * 5) + 1,
title: `Review ${i + 1}`,
comment: reviewTexts[Math.floor(Math.random() * reviewTexts.length)],
verified: Math.random() > 0.3,
helpfulCount: Math.floor(Math.random() * 100),
createdAt: new Date(Date.now() - Math.floor(Math.random() * 365 * 24 * 60 * 60 * 1000)).toISOString()
});
}
}
// Prefill with 100 reviews
generateReviews(100);
module.exports = {
getAll: () => reviews,
getById: (id) => reviews.find(r => r.id === parseInt(id)),
create: (data) => {
const review = { id: nextId++, ...data, createdAt: new Date().toISOString() };
reviews.push(review);
return review;
},
update: (id, data) => {
const index = reviews.findIndex(r => r.id === parseInt(id));
if (index === -1) return null;
reviews[index] = { ...reviews[index], ...data, id: parseInt(id) };
return reviews[index];
},
delete: (id) => {
const index = reviews.findIndex(r => r.id === parseInt(id));
if (index === -1) return false;
reviews.splice(index, 1);
return true;
}
};
+49
View File
@@ -0,0 +1,49 @@
// Users entity with in-memory storage
const users = [];
let nextId = 1;
// Helper function to generate sample data
function generateUsers(count) {
const firstNames = ['John', 'Jane', 'Bob', 'Alice', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace', 'Henry'];
const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez'];
const domains = ['example.com', 'test.com', 'demo.com', 'sample.org'];
for (let i = 0; i < count; i++) {
const firstName = firstNames[Math.floor(Math.random() * firstNames.length)];
const lastName = lastNames[Math.floor(Math.random() * lastNames.length)];
users.push({
id: nextId++,
firstName,
lastName,
email: `${firstName.toLowerCase()}.${lastName.toLowerCase()}${i}@${domains[Math.floor(Math.random() * domains.length)]}`,
age: Math.floor(Math.random() * 50) + 18,
active: Math.random() > 0.3,
createdAt: new Date(Date.now() - Math.floor(Math.random() * 365 * 24 * 60 * 60 * 1000)).toISOString()
});
}
}
// Prefill with 200 users
generateUsers(200);
module.exports = {
getAll: () => users,
getById: (id) => users.find(u => u.id === parseInt(id)),
create: (data) => {
const user = { id: nextId++, ...data, createdAt: new Date().toISOString() };
users.push(user);
return user;
},
update: (id, data) => {
const index = users.findIndex(u => u.id === parseInt(id));
if (index === -1) return null;
users[index] = { ...users[index], ...data, id: parseInt(id) };
return users[index];
},
delete: (id) => {
const index = users.findIndex(u => u.id === parseInt(id));
if (index === -1) return false;
users.splice(index, 1);
return true;
}
};
+132
View File
@@ -0,0 +1,132 @@
const users = require('../entities/users');
const products = require('../entities/products');
const orders = require('../entities/orders');
const categories = require('../entities/categories');
const reviews = require('../entities/reviews');
const { GraphQLError } = require('graphql');
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const resolvers = {
// User queries
users: () => users.getAll(),
user: ({ id }) => users.getById(id),
// Product queries
products: () => products.getAll(),
product: ({ id }) => products.getById(id),
// Order queries
orders: () => orders.getAll(),
order: ({ id }) => orders.getById(id),
// Category queries
categories: () => categories.getAll(),
category: ({ id }) => categories.getById(id),
// Review queries
reviews: () => reviews.getAll(),
review: ({ id }) => reviews.getById(id),
// Error testing queries
errorBadRequest: () => {
throw new GraphQLError('Bad request', {
extensions: {
code: 'BAD_USER_INPUT',
http: { status: 400 },
},
});
},
errorUnauthorized: () => {
throw new GraphQLError('Unauthorized', {
extensions: {
code: 'UNAUTHENTICATED',
http: { status: 401 },
},
});
},
errorForbidden: () => {
throw new GraphQLError('Forbidden', {
extensions: {
code: 'FORBIDDEN',
http: { status: 403 },
},
});
},
errorNotFound: () => {
throw new GraphQLError('Resource not found', {
extensions: {
code: 'NOT_FOUND',
http: { status: 404 },
},
});
},
errorInternal: () => {
throw new GraphQLError('Internal server error', {
extensions: {
code: 'INTERNAL_SERVER_ERROR',
http: { status: 500 },
},
});
},
delayed2s: async () => {
await wait(2000);
return 'Completed after 2 seconds';
},
delayed4s: async () => {
await wait(4000);
return 'Completed after 4 seconds';
},
delayed10s: async () => {
await wait(10000);
return 'Completed after 10 seconds';
},
delayed1m: async () => {
await wait(60000);
return 'Completed after 1 minute';
},
// User mutations
createUser: ({ input }) => users.create(input),
updateUser: ({ id, input }) => users.update(id, input),
deleteUser: ({ id }) => users.delete(id),
// Product mutations
createProduct: ({ input }) => products.create(input),
updateProduct: ({ id, input }) => products.update(id, input),
deleteProduct: ({ id }) => products.delete(id),
// Order mutations
createOrder: ({ input }) => orders.create(input),
updateOrder: ({ id, input }) => orders.update(id, input),
deleteOrder: ({ id }) => orders.delete(id),
// Category mutations
createCategory: ({ input }) => categories.create(input),
updateCategory: ({ id, input }) => categories.update(id, input),
deleteCategory: ({ id }) => categories.delete(id),
// Review mutations
createReview: ({ input }) => reviews.create(input),
updateReview: ({ id, input }) => reviews.update(id, input),
deleteReview: ({ id }) => reviews.delete(id),
delayedMutation2s: async () => {
await wait(2000);
return 'Mutation completed after 2 seconds';
},
delayedMutation4s: async () => {
await wait(4000);
return 'Mutation completed after 4 seconds';
},
delayedMutation10s: async () => {
await wait(10000);
return 'Mutation completed after 10 seconds';
},
delayedMutation1m: async () => {
await wait(60000);
return 'Mutation completed after 1 minute';
},
};
module.exports = resolvers;
+150
View File
@@ -0,0 +1,150 @@
const { buildSchema } = require('graphql');
const schema = buildSchema(`
type User {
id: Int!
firstName: String!
lastName: String!
email: String!
age: Int
active: Boolean
createdAt: String
}
type Product {
id: Int!
name: String!
description: String
price: Float!
category: String
inStock: Boolean
quantity: Int
sku: String
createdAt: String
}
type Order {
id: Int!
customerId: Int!
orderNumber: String!
totalAmount: Float!
status: String!
orderDate: String
}
type Category {
id: Int!
name: String!
slug: String!
description: String
parentId: Int
active: Boolean
displayOrder: Int
}
type Review {
id: Int!
productId: Int!
userId: Int!
rating: Int!
title: String
comment: String
verified: Boolean
helpfulCount: Int
createdAt: String
}
input UserInput {
firstName: String!
lastName: String!
email: String!
age: Int
active: Boolean
}
input ProductInput {
name: String!
description: String
price: Float!
category: String
inStock: Boolean
quantity: Int
sku: String
}
input OrderInput {
customerId: Int!
totalAmount: Float!
status: String!
}
input CategoryInput {
name: String!
slug: String!
description: String
parentId: Int
active: Boolean
displayOrder: Int
}
input ReviewInput {
productId: Int!
userId: Int!
rating: Int!
title: String
comment: String
verified: Boolean
helpfulCount: Int
}
type Query {
users: [User!]!
user(id: Int!): User
products: [Product!]!
product(id: Int!): Product
orders: [Order!]!
order(id: Int!): Order
categories: [Category!]!
category(id: Int!): Category
reviews: [Review!]!
review(id: Int!): Review
errorBadRequest: String
errorUnauthorized: String
errorForbidden: String
errorNotFound: String
errorInternal: String
delayed2s: String!
delayed4s: String!
delayed10s: String!
delayed1m: String!
}
type Mutation {
createUser(input: UserInput!): User!
updateUser(id: Int!, input: UserInput!): User
deleteUser(id: Int!): Boolean!
createProduct(input: ProductInput!): Product!
updateProduct(id: Int!, input: ProductInput!): Product
deleteProduct(id: Int!): Boolean!
createOrder(input: OrderInput!): Order!
updateOrder(id: Int!, input: OrderInput!): Order
deleteOrder(id: Int!): Boolean!
createCategory(input: CategoryInput!): Category!
updateCategory(id: Int!, input: CategoryInput!): Category
deleteCategory(id: Int!): Boolean!
createReview(input: ReviewInput!): Review!
updateReview(id: Int!, input: ReviewInput!): Review
deleteReview(id: Int!): Boolean!
delayedMutation2s: String!
delayedMutation4s: String!
delayedMutation10s: String!
delayedMutation1m: String!
}
`);
module.exports = schema;
+53
View File
@@ -0,0 +1,53 @@
// Authentication middleware functions
// Basic Authentication middleware
// Note: These are intentionally hard-coded test credentials for a test API
// Username: admin, Password: admin
const basicAuth = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Basic ')) {
res.setHeader('WWW-Authenticate', 'Basic realm="Access to test API"');
return res.status(401).json({ error: 'Authentication required' });
}
try {
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
const [username, password] = credentials.split(':');
// Simple test credentials - username: admin, password: admin
if (username === 'admin' && password === 'admin') {
next();
} else {
res.setHeader('WWW-Authenticate', 'Basic realm="Access to test API"');
return res.status(401).json({ error: 'Invalid credentials' });
}
} catch (error) {
res.setHeader('WWW-Authenticate', 'Basic realm="Access to test API"');
return res.status(400).json({ error: 'Malformed authorization header' });
}
};
// API Key Authentication middleware
// Note: This is an intentionally hard-coded test API key for a test API
// x-api-key: test-api-key-12345
const apiKeyAuth = (req, res, next) => {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
// Simple test API key - x-api-key: test-api-key-12345
if (apiKey === 'test-api-key-12345') {
next();
} else {
return res.status(401).json({ error: 'Invalid API key' });
}
};
module.exports = {
basicAuth,
apiKeyAuth,
};
+195
View File
@@ -0,0 +1,195 @@
const express = require('express');
const router = express.Router();
const categories = require('../entities/categories');
/**
* @swagger
* components:
* schemas:
* Category:
* type: object
* properties:
* id:
* type: integer
* name:
* type: string
* slug:
* type: string
* description:
* type: string
* parentId:
* type: integer
* nullable: true
* active:
* type: boolean
* displayOrder:
* type: integer
*/
/**
* @swagger
* /api/categories:
* get:
* summary: Get all categories
* tags: [Categories]
* responses:
* 200:
* description: List of categories
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Category'
*/
router.get('/', (req, res) => {
res.json(categories.getAll());
});
/**
* @swagger
* /api/categories/{id}:
* get:
* summary: Get category by ID
* tags: [Categories]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Category found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Category'
* 404:
* description: Category not found
*/
router.get('/:id', (req, res) => {
const category = categories.getById(req.params.id);
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
res.json(category);
});
/**
* @swagger
* /api/categories:
* post:
* summary: Create a new category
* tags: [Categories]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - name
* - slug
* properties:
* name:
* type: string
* slug:
* type: string
* description:
* type: string
* parentId:
* type: integer
* active:
* type: boolean
* displayOrder:
* type: integer
* responses:
* 201:
* description: Category created
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Category'
*/
router.post('/', (req, res) => {
const category = categories.create(req.body);
res.status(201).json(category);
});
/**
* @swagger
* /api/categories/{id}:
* put:
* summary: Update category by ID
* tags: [Categories]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* slug:
* type: string
* description:
* type: string
* parentId:
* type: integer
* active:
* type: boolean
* displayOrder:
* type: integer
* responses:
* 200:
* description: Category updated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Category'
* 404:
* description: Category not found
*/
router.put('/:id', (req, res) => {
const category = categories.update(req.params.id, req.body);
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
res.json(category);
});
/**
* @swagger
* /api/categories/{id}:
* delete:
* summary: Delete category by ID
* tags: [Categories]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Category deleted
* 404:
* description: Category not found
*/
router.delete('/:id', (req, res) => {
const deleted = categories.delete(req.params.id);
if (!deleted) {
return res.status(404).json({ error: 'Category not found' });
}
res.status(204).send();
});
module.exports = router;
+112
View File
@@ -0,0 +1,112 @@
const express = require('express');
const router = express.Router();
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function runDelay(req, res, ms, label) {
const startedAt = new Date().toISOString();
await wait(ms);
res.json({
endpoint: `delay-${label}`,
durationMs: ms,
startedAt,
finishedAt: new Date().toISOString(),
message: `Completed after ${label}`,
});
}
/**
* @swagger
* components:
* schemas:
* DelayResponse:
* type: object
* properties:
* endpoint:
* type: string
* durationMs:
* type: integer
* startedAt:
* type: string
* format: date-time
* finishedAt:
* type: string
* format: date-time
* message:
* type: string
*/
/**
* @swagger
* /api/delays/2s:
* get:
* summary: Delayed operation (2 seconds)
* tags: [Delays]
* responses:
* 200:
* description: Completed after 2 seconds
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/DelayResponse'
*/
router.get('/2s', async (req, res) => {
await runDelay(req, res, 2000, '2s');
});
/**
* @swagger
* /api/delays/4s:
* get:
* summary: Delayed operation (4 seconds)
* tags: [Delays]
* responses:
* 200:
* description: Completed after 4 seconds
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/DelayResponse'
*/
router.get('/4s', async (req, res) => {
await runDelay(req, res, 4000, '4s');
});
/**
* @swagger
* /api/delays/10s:
* get:
* summary: Delayed operation (10 seconds)
* tags: [Delays]
* responses:
* 200:
* description: Completed after 10 seconds
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/DelayResponse'
*/
router.get('/10s', async (req, res) => {
await runDelay(req, res, 10000, '10s');
});
/**
* @swagger
* /api/delays/1m:
* get:
* summary: Delayed operation (1 minute)
* tags: [Delays]
* responses:
* 200:
* description: Completed after 1 minute
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/DelayResponse'
*/
router.get('/1m', async (req, res) => {
await runDelay(req, res, 60000, '1m');
});
module.exports = router;
+175
View File
@@ -0,0 +1,175 @@
const express = require('express');
const router = express.Router();
/**
* @swagger
* components:
* schemas:
* ErrorResponse:
* type: object
* properties:
* error:
* type: string
* code:
* type: string
* details:
* type: object
*/
/**
* @swagger
* /api/errors/bad-request:
* get:
* summary: Return 400 Bad Request
* tags: [Error Testing]
* responses:
* 400:
* description: Bad request error
*/
router.get('/bad-request', (req, res) => {
res.status(400).json({
error: 'Bad request',
code: 'BAD_REQUEST',
details: { reason: 'Simulated invalid input' },
});
});
/**
* @swagger
* /api/errors/unauthorized:
* get:
* summary: Return 401 Unauthorized
* tags: [Error Testing]
* responses:
* 401:
* description: Unauthorized error
*/
router.get('/unauthorized', (req, res) => {
res.status(401).json({
error: 'Unauthorized',
code: 'UNAUTHORIZED',
});
});
/**
* @swagger
* /api/errors/forbidden:
* get:
* summary: Return 403 Forbidden
* tags: [Error Testing]
* responses:
* 403:
* description: Forbidden error
*/
router.get('/forbidden', (req, res) => {
res.status(403).json({
error: 'Forbidden',
code: 'FORBIDDEN',
});
});
/**
* @swagger
* /api/errors/not-found:
* get:
* summary: Return 404 Not Found
* tags: [Error Testing]
* responses:
* 404:
* description: Not found error
*/
router.get('/not-found', (req, res) => {
res.status(404).json({
error: 'Resource not found',
code: 'NOT_FOUND',
});
});
/**
* @swagger
* /api/errors/conflict:
* get:
* summary: Return 409 Conflict
* tags: [Error Testing]
* responses:
* 409:
* description: Conflict error
*/
router.get('/conflict', (req, res) => {
res.status(409).json({
error: 'Conflict',
code: 'CONFLICT',
});
});
/**
* @swagger
* /api/errors/unprocessable:
* get:
* summary: Return 422 Unprocessable Entity
* tags: [Error Testing]
* responses:
* 422:
* description: Validation error
*/
router.get('/unprocessable', (req, res) => {
res.status(422).json({
error: 'Validation failed',
code: 'UNPROCESSABLE_ENTITY',
details: { field: 'email', message: 'Invalid email format' },
});
});
/**
* @swagger
* /api/errors/rate-limit:
* get:
* summary: Return 429 Too Many Requests
* tags: [Error Testing]
* responses:
* 429:
* description: Rate limit error
*/
router.get('/rate-limit', (req, res) => {
res.setHeader('Retry-After', '60');
res.status(429).json({
error: 'Too many requests',
code: 'RATE_LIMITED',
});
});
/**
* @swagger
* /api/errors/internal:
* get:
* summary: Return 500 Internal Server Error
* tags: [Error Testing]
* responses:
* 500:
* description: Internal server error
*/
router.get('/internal', (req, res) => {
res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_SERVER_ERROR',
});
});
/**
* @swagger
* /api/errors/unavailable:
* get:
* summary: Return 503 Service Unavailable
* tags: [Error Testing]
* responses:
* 503:
* description: Service unavailable error
*/
router.get('/unavailable', (req, res) => {
res.status(503).json({
error: 'Service unavailable',
code: 'SERVICE_UNAVAILABLE',
});
});
module.exports = router;
+392
View File
@@ -0,0 +1,392 @@
const express = require('express');
const users = require('../entities/users');
const products = require('../entities/products');
const orders = require('../entities/orders');
const categories = require('../entities/categories');
const reviews = require('../entities/reviews');
const router = express.Router();
const entitySets = {
Users: users,
Products: products,
Orders: orders,
Categories: categories,
Reviews: reviews,
};
function parsePrimitive(rawValue) {
if (rawValue === null || rawValue === undefined) {
return null;
}
const trimmed = String(rawValue).trim();
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
return trimmed.slice(1, -1);
}
if (trimmed.toLowerCase() === 'true') {
return true;
}
if (trimmed.toLowerCase() === 'false') {
return false;
}
if (trimmed.toLowerCase() === 'null') {
return null;
}
if (!Number.isNaN(Number(trimmed)) && trimmed !== '') {
return Number(trimmed);
}
return trimmed;
}
function compareValues(left, right, operator) {
if (operator === 'eq') return left === right;
if (operator === 'ne') return left !== right;
if (operator === 'gt') return left > right;
if (operator === 'ge') return left >= right;
if (operator === 'lt') return left < right;
if (operator === 'le') return left <= right;
return false;
}
function buildFilterPredicate(filterExpression) {
if (!filterExpression) {
return () => true;
}
const functionMatch = filterExpression.match(/^(contains|startswith|endswith)\((\w+),\s*(.+)\)$/i);
if (functionMatch) {
const operator = functionMatch[1].toLowerCase();
const fieldName = functionMatch[2];
const lookupValue = String(parsePrimitive(functionMatch[3])).toLowerCase();
return (item) => {
const fieldValue = String(item[fieldName] ?? '').toLowerCase();
if (operator === 'contains') return fieldValue.includes(lookupValue);
if (operator === 'startswith') return fieldValue.startsWith(lookupValue);
if (operator === 'endswith') return fieldValue.endsWith(lookupValue);
return false;
};
}
const binaryMatch = filterExpression.match(/^(\w+)\s+(eq|ne|gt|ge|lt|le)\s+(.+)$/i);
if (binaryMatch) {
const fieldName = binaryMatch[1];
const operator = binaryMatch[2].toLowerCase();
const lookupValue = parsePrimitive(binaryMatch[3]);
return (item) => compareValues(item[fieldName], lookupValue, operator);
}
return () => true;
}
function applyOrderBy(items, orderByExpression) {
if (!orderByExpression) {
return items;
}
const fields = orderByExpression
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const [name, direction] = part.split(/\s+/);
return {
name,
descending: String(direction || '').toLowerCase() === 'desc',
};
});
return [...items].sort((a, b) => {
for (const field of fields) {
const aValue = a[field.name];
const bValue = b[field.name];
if (aValue === bValue) {
continue;
}
if (aValue === undefined || aValue === null) return field.descending ? 1 : -1;
if (bValue === undefined || bValue === null) return field.descending ? -1 : 1;
if (aValue > bValue) return field.descending ? -1 : 1;
if (aValue < bValue) return field.descending ? 1 : -1;
}
return 0;
});
}
function applySelect(items, selectExpression) {
if (!selectExpression) {
return items;
}
const fields = selectExpression
.split(',')
.map((part) => part.trim())
.filter(Boolean);
if (fields.length === 0) {
return items;
}
return items.map((item) => {
const selected = {};
fields.forEach((field) => {
if (Object.prototype.hasOwnProperty.call(item, field)) {
selected[field] = item[field];
}
});
if (!Object.prototype.hasOwnProperty.call(selected, 'id') && Object.prototype.hasOwnProperty.call(item, 'id')) {
selected.id = item.id;
}
return selected;
});
}
function escapeXml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function inferEdmType(sampleValue) {
if (typeof sampleValue === 'number') {
return Number.isInteger(sampleValue) ? 'Edm.Int32' : 'Edm.Decimal';
}
if (typeof sampleValue === 'boolean') {
return 'Edm.Boolean';
}
if (typeof sampleValue === 'string') {
const parsedDate = Date.parse(sampleValue);
if (!Number.isNaN(parsedDate) && sampleValue.includes('T')) {
return 'Edm.DateTimeOffset';
}
return 'Edm.String';
}
return 'Edm.String';
}
function createMetadataXml(baseUrl) {
const namespace = 'DBGate.TestApi';
const entityTypes = Object.entries(entitySets)
.map(([setName, entity]) => {
const first = entity.getAll()[0] || { id: 0 };
const fields = Object.keys(first);
const propertiesXml = fields
.map((field) => {
const type = inferEdmType(first[field]);
const nullable = field === 'id' ? 'false' : 'true';
return ` <Property Name="${escapeXml(field)}" Type="${type}" Nullable="${nullable}" />`;
})
.join('\n');
return ` <EntityType Name="${setName}">
<Key>
<PropertyRef Name="id" />
</Key>
${propertiesXml}
</EntityType>`;
})
.join('\n');
const entityContainerXml = Object.keys(entitySets)
.map((setName) => ` <EntitySet Name="${setName}" EntityType="${namespace}.${setName}" />`)
.join('\n');
return `<?xml version="1.0" encoding="utf-8"?>
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
<edmx:DataServices>
<Schema Namespace="${namespace}" xmlns="http://docs.oasis-open.org/odata/ns/edm">
${entityTypes}
<EntityContainer Name="Container">
${entityContainerXml}
</EntityContainer>
</Schema>
</edmx:DataServices>
</edmx:Edmx>`;
}
function getEntitySet(setName) {
return entitySets[setName];
}
function parseKey(rawKey) {
const keyWithoutName = String(rawKey).includes('=') ? String(rawKey).split('=')[1] : rawKey;
return parsePrimitive(keyWithoutName);
}
function formatCollectionResponse(req, setName, items, count) {
const response = {
'@odata.context': `${req.baseUrl}/$metadata#${setName}`,
value: items,
};
if (count !== null) {
response['@odata.count'] = count;
}
return response;
}
router.get('/', (req, res) => {
const serviceDocument = {
'@odata.context': `${req.baseUrl}/$metadata`,
value: Object.keys(entitySets).map((name) => ({
name,
kind: 'EntitySet',
url: name,
})),
};
res.json(serviceDocument);
});
router.get('/$metadata', (req, res) => {
res.type('application/xml');
res.send(createMetadataXml(req.baseUrl));
});
router.get('/:entitySet', (req, res) => {
const entity = getEntitySet(req.params.entitySet);
if (!entity) {
return res.status(404).json({ error: `Unknown entity set: ${req.params.entitySet}` });
}
const filterExpression = req.query.$filter;
const orderByExpression = req.query.$orderby;
const selectExpression = req.query.$select;
const skipValue = parseInt(req.query.$skip || '0', 10);
const topValue = req.query.$top !== undefined ? parseInt(req.query.$top, 10) : null;
const includeCount = String(req.query.$count || '').toLowerCase() === 'true';
let rows = entity.getAll();
rows = rows.filter(buildFilterPredicate(filterExpression));
const filteredCount = rows.length;
rows = applyOrderBy(rows, orderByExpression);
if (Number.isInteger(skipValue) && skipValue > 0) {
rows = rows.slice(skipValue);
}
if (Number.isInteger(topValue) && topValue >= 0) {
rows = rows.slice(0, topValue);
}
rows = applySelect(rows, selectExpression);
return res.json(formatCollectionResponse(req, req.params.entitySet, rows, includeCount ? filteredCount : null));
});
router.post('/:entitySet', (req, res) => {
const entity = getEntitySet(req.params.entitySet);
if (!entity) {
return res.status(404).json({ error: `Unknown entity set: ${req.params.entitySet}` });
}
const created = entity.create(req.body || {});
res.status(201).json({
'@odata.context': `${req.baseUrl}/$metadata#${req.params.entitySet}/$entity`,
...created,
});
});
router.get(/^\/([^/]+)\(([^)]+)\)$/, (req, res) => {
const setName = req.params[0];
const rawKey = req.params[1];
const entity = getEntitySet(setName);
if (!entity) {
return res.status(404).json({ error: `Unknown entity set: ${setName}` });
}
const row = entity.getById(parseKey(rawKey));
if (!row) {
return res.status(404).json({ error: `${setName} entity not found` });
}
return res.json({
'@odata.context': `${req.baseUrl}/$metadata#${setName}/$entity`,
...row,
});
});
router.patch(/^\/([^/]+)\(([^)]+)\)$/, (req, res) => {
const setName = req.params[0];
const rawKey = req.params[1];
const entity = getEntitySet(setName);
if (!entity) {
return res.status(404).json({ error: `Unknown entity set: ${setName}` });
}
const updated = entity.update(parseKey(rawKey), req.body || {});
if (!updated) {
return res.status(404).json({ error: `${setName} entity not found` });
}
return res.json({
'@odata.context': `${req.baseUrl}/$metadata#${setName}/$entity`,
...updated,
});
});
router.put(/^\/([^/]+)\(([^)]+)\)$/, (req, res) => {
const setName = req.params[0];
const rawKey = req.params[1];
const entity = getEntitySet(setName);
if (!entity) {
return res.status(404).json({ error: `Unknown entity set: ${setName}` });
}
const updated = entity.update(parseKey(rawKey), req.body || {});
if (!updated) {
return res.status(404).json({ error: `${setName} entity not found` });
}
return res.json({
'@odata.context': `${req.baseUrl}/$metadata#${setName}/$entity`,
...updated,
});
});
router.delete(/^\/([^/]+)\(([^)]+)\)$/, (req, res) => {
const setName = req.params[0];
const rawKey = req.params[1];
const entity = getEntitySet(setName);
if (!entity) {
return res.status(404).json({ error: `Unknown entity set: ${setName}` });
}
const deleted = entity.delete(parseKey(rawKey));
if (!deleted) {
return res.status(404).json({ error: `${setName} entity not found` });
}
return res.status(204).send();
});
module.exports = router;
+182
View File
@@ -0,0 +1,182 @@
const express = require('express');
const router = express.Router();
const orders = require('../entities/orders');
/**
* @swagger
* components:
* schemas:
* Order:
* type: object
* properties:
* id:
* type: integer
* customerId:
* type: integer
* orderNumber:
* type: string
* totalAmount:
* type: number
* status:
* type: string
* orderDate:
* type: string
* format: date-time
*/
/**
* @swagger
* /api/orders:
* get:
* summary: Get all orders
* tags: [Orders]
* responses:
* 200:
* description: List of orders
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Order'
*/
router.get('/', (req, res) => {
res.json(orders.getAll());
});
/**
* @swagger
* /api/orders/{id}:
* get:
* summary: Get order by ID
* tags: [Orders]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Order found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Order'
* 404:
* description: Order not found
*/
router.get('/:id', (req, res) => {
const order = orders.getById(req.params.id);
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
res.json(order);
});
/**
* @swagger
* /api/orders:
* post:
* summary: Create a new order
* tags: [Orders]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - customerId
* - totalAmount
* - status
* properties:
* customerId:
* type: integer
* totalAmount:
* type: number
* status:
* type: string
* responses:
* 201:
* description: Order created
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Order'
*/
router.post('/', (req, res) => {
const order = orders.create(req.body);
res.status(201).json(order);
});
/**
* @swagger
* /api/orders/{id}:
* put:
* summary: Update order by ID
* tags: [Orders]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* customerId:
* type: integer
* totalAmount:
* type: number
* status:
* type: string
* responses:
* 200:
* description: Order updated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Order'
* 404:
* description: Order not found
*/
router.put('/:id', (req, res) => {
const order = orders.update(req.params.id, req.body);
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
res.json(order);
});
/**
* @swagger
* /api/orders/{id}:
* delete:
* summary: Delete order by ID
* tags: [Orders]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Order deleted
* 404:
* description: Order not found
*/
router.delete('/:id', (req, res) => {
const deleted = orders.delete(req.params.id);
if (!deleted) {
return res.status(404).json({ error: 'Order not found' });
}
res.status(204).send();
});
module.exports = router;
+389
View File
@@ -0,0 +1,389 @@
const express = require('express');
const router = express.Router();
function normalizeArray(value) {
if (Array.isArray(value)) {
return value;
}
if (typeof value === 'string' && value.length > 0) {
return value.split(',');
}
return [];
}
function extractCookies(req) {
const cookieHeader = req.headers.cookie || '';
const result = {};
cookieHeader
.split(';')
.map((part) => part.trim())
.filter(Boolean)
.forEach((pair) => {
const [name, ...rest] = pair.split('=');
result[name] = rest.join('=');
});
return result;
}
/**
* @swagger
* components:
* schemas:
* SearchRequest:
* type: object
* required:
* - query
* properties:
* query:
* type: string
* tags:
* type: array
* items:
* type: string
* filters:
* type: object
* additionalProperties:
* type: string
* UploadMeta:
* type: object
* properties:
* description:
* type: string
* category:
* type: string
* ParameterEchoResponse:
* type: object
* properties:
* endpoint:
* type: string
* received:
* type: object
*/
/**
* @swagger
* /api/params/path/{entityId}/detail/{detailId}:
* get:
* summary: Path parameters example
* tags: [Parameters]
* parameters:
* - in: path
* name: entityId
* required: true
* description: Entity ID in path
* schema:
* type: integer
* - in: path
* name: detailId
* required: true
* description: Detail identifier in path
* schema:
* type: string
* responses:
* 200:
* description: Echo of path parameters
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ParameterEchoResponse'
*/
router.get('/path/:entityId/detail/:detailId', (req, res) => {
res.json({
endpoint: 'path',
received: {
entityId: Number(req.params.entityId),
detailId: req.params.detailId,
},
});
});
/**
* @swagger
* /api/params/query:
* get:
* summary: Query parameters example (primitive, array, object)
* tags: [Parameters]
* parameters:
* - in: query
* name: search
* description: Full-text search value
* schema:
* type: string
* - in: query
* name: page
* description: Page number
* schema:
* type: integer
* minimum: 1
* - in: query
* name: tags
* description: Repeated array style (?tags=a&tags=b)
* schema:
* type: array
* items:
* type: string
* style: form
* explode: true
* - in: query
* name: fields
* description: CSV array style (?fields=id,name)
* schema:
* type: array
* items:
* type: string
* style: form
* explode: false
* - in: query
* name: filter
* description: Deep object style (?filter[status]=active&filter[minPrice]=10)
* schema:
* type: object
* additionalProperties:
* type: string
* style: deepObject
* explode: true
* responses:
* 200:
* description: Echo of query parameters
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ParameterEchoResponse'
*/
router.get('/query', (req, res) => {
res.json({
endpoint: 'query',
received: {
search: req.query.search,
page: req.query.page ? Number(req.query.page) : undefined,
tags: normalizeArray(req.query.tags),
fields: normalizeArray(req.query.fields),
filter: req.query.filter || {},
rawQuery: req.query,
},
});
});
/**
* @swagger
* /api/params/header:
* get:
* summary: Header parameters example
* tags: [Parameters]
* parameters:
* - in: header
* name: x-trace-id
* required: false
* description: Correlation ID
* schema:
* type: string
* - in: header
* name: x-client-version
* required: false
* description: Client app version
* schema:
* type: string
* responses:
* 200:
* description: Echo of header parameters
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ParameterEchoResponse'
*/
router.get('/header', (req, res) => {
res.json({
endpoint: 'header',
received: {
traceId: req.headers['x-trace-id'],
clientVersion: req.headers['x-client-version'],
},
});
});
/**
* @swagger
* /api/params/cookie:
* get:
* summary: Cookie parameters example
* tags: [Parameters]
* parameters:
* - in: cookie
* name: sessionId
* required: false
* schema:
* type: string
* - in: cookie
* name: tenant
* required: false
* schema:
* type: string
* responses:
* 200:
* description: Echo of cookie parameters
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ParameterEchoResponse'
*/
router.get('/cookie', (req, res) => {
const cookies = extractCookies(req);
res.json({
endpoint: 'cookie',
received: {
sessionId: cookies.sessionId,
tenant: cookies.tenant,
allCookies: cookies,
},
});
});
/**
* @swagger
* /api/params/body/json:
* post:
* summary: JSON request body example
* tags: [Parameters]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SearchRequest'
* responses:
* 200:
* description: Echo of JSON body
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ParameterEchoResponse'
*/
router.post('/body/json', (req, res) => {
res.json({
endpoint: 'body-json',
received: req.body,
});
});
/**
* @swagger
* /api/params/body/form:
* post:
* summary: Form URL-encoded request body example
* tags: [Parameters]
* requestBody:
* required: true
* content:
* application/x-www-form-urlencoded:
* schema:
* type: object
* required:
* - title
* properties:
* title:
* type: string
* count:
* type: integer
* enabled:
* type: boolean
* responses:
* 200:
* description: Echo of form body
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ParameterEchoResponse'
*/
router.post('/body/form', (req, res) => {
res.json({
endpoint: 'body-form',
received: req.body,
});
});
/**
* @swagger
* /api/params/body/mixed/{id}:
* post:
* summary: Mixed parameters example (path + query + header + cookie + body)
* tags: [Parameters]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: query
* name: dryRun
* required: false
* schema:
* type: boolean
* - in: header
* name: x-correlation-id
* required: false
* schema:
* type: string
* - in: cookie
* name: locale
* required: false
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* note:
* type: string
* amount:
* type: number
* multipart/form-data:
* schema:
* type: object
* properties:
* metadata:
* $ref: '#/components/schemas/UploadMeta'
* file:
* type: string
* format: binary
* responses:
* 200:
* description: Echo of mixed inputs
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ParameterEchoResponse'
*/
router.post('/body/mixed/:id', (req, res) => {
const cookies = extractCookies(req);
res.json({
endpoint: 'body-mixed',
received: {
path: {
id: Number(req.params.id),
},
query: {
dryRun: req.query.dryRun,
},
header: {
correlationId: req.headers['x-correlation-id'],
},
cookie: {
locale: cookies.locale,
},
body: req.body,
contentType: req.headers['content-type'],
note: 'multipart/form-data shape is documented in OpenAPI; this test endpoint echoes parsed body when available.',
},
});
});
module.exports = router;
+203
View File
@@ -0,0 +1,203 @@
const express = require('express');
const router = express.Router();
const products = require('../entities/products');
/**
* @swagger
* components:
* schemas:
* Product:
* type: object
* properties:
* id:
* type: integer
* name:
* type: string
* description:
* type: string
* price:
* type: number
* category:
* type: string
* inStock:
* type: boolean
* quantity:
* type: integer
* sku:
* type: string
* createdAt:
* type: string
* format: date-time
*/
/**
* @swagger
* /api/products:
* get:
* summary: Get all products
* tags: [Products]
* responses:
* 200:
* description: List of products
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Product'
*/
router.get('/', (req, res) => {
res.json(products.getAll());
});
/**
* @swagger
* /api/products/{id}:
* get:
* summary: Get product by ID
* tags: [Products]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Product found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Product'
* 404:
* description: Product not found
*/
router.get('/:id', (req, res) => {
const product = products.getById(req.params.id);
if (!product) {
return res.status(404).json({ error: 'Product not found' });
}
res.json(product);
});
/**
* @swagger
* /api/products:
* post:
* summary: Create a new product
* tags: [Products]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - name
* - price
* properties:
* name:
* type: string
* description:
* type: string
* price:
* type: number
* category:
* type: string
* inStock:
* type: boolean
* quantity:
* type: integer
* sku:
* type: string
* responses:
* 201:
* description: Product created
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Product'
*/
router.post('/', (req, res) => {
const product = products.create(req.body);
res.status(201).json(product);
});
/**
* @swagger
* /api/products/{id}:
* put:
* summary: Update product by ID
* tags: [Products]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* description:
* type: string
* price:
* type: number
* category:
* type: string
* inStock:
* type: boolean
* quantity:
* type: integer
* sku:
* type: string
* responses:
* 200:
* description: Product updated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Product'
* 404:
* description: Product not found
*/
router.put('/:id', (req, res) => {
const product = products.update(req.params.id, req.body);
if (!product) {
return res.status(404).json({ error: 'Product not found' });
}
res.json(product);
});
/**
* @swagger
* /api/products/{id}:
* delete:
* summary: Delete product by ID
* tags: [Products]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Product deleted
* 404:
* description: Product not found
*/
router.delete('/:id', (req, res) => {
const deleted = products.delete(req.params.id);
if (!deleted) {
return res.status(404).json({ error: 'Product not found' });
}
res.status(204).send();
});
module.exports = router;
+204
View File
@@ -0,0 +1,204 @@
const express = require('express');
const router = express.Router();
const reviews = require('../entities/reviews');
/**
* @swagger
* components:
* schemas:
* Review:
* type: object
* properties:
* id:
* type: integer
* productId:
* type: integer
* userId:
* type: integer
* rating:
* type: integer
* title:
* type: string
* comment:
* type: string
* verified:
* type: boolean
* helpfulCount:
* type: integer
* createdAt:
* type: string
* format: date-time
*/
/**
* @swagger
* /api/reviews:
* get:
* summary: Get all reviews
* tags: [Reviews]
* responses:
* 200:
* description: List of reviews
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Review'
*/
router.get('/', (req, res) => {
res.json(reviews.getAll());
});
/**
* @swagger
* /api/reviews/{id}:
* get:
* summary: Get review by ID
* tags: [Reviews]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Review found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Review'
* 404:
* description: Review not found
*/
router.get('/:id', (req, res) => {
const review = reviews.getById(req.params.id);
if (!review) {
return res.status(404).json({ error: 'Review not found' });
}
res.json(review);
});
/**
* @swagger
* /api/reviews:
* post:
* summary: Create a new review
* tags: [Reviews]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - productId
* - userId
* - rating
* properties:
* productId:
* type: integer
* userId:
* type: integer
* rating:
* type: integer
* title:
* type: string
* comment:
* type: string
* verified:
* type: boolean
* helpfulCount:
* type: integer
* responses:
* 201:
* description: Review created
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Review'
*/
router.post('/', (req, res) => {
const review = reviews.create(req.body);
res.status(201).json(review);
});
/**
* @swagger
* /api/reviews/{id}:
* put:
* summary: Update review by ID
* tags: [Reviews]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* productId:
* type: integer
* userId:
* type: integer
* rating:
* type: integer
* title:
* type: string
* comment:
* type: string
* verified:
* type: boolean
* helpfulCount:
* type: integer
* responses:
* 200:
* description: Review updated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Review'
* 404:
* description: Review not found
*/
router.put('/:id', (req, res) => {
const review = reviews.update(req.params.id, req.body);
if (!review) {
return res.status(404).json({ error: 'Review not found' });
}
res.json(review);
});
/**
* @swagger
* /api/reviews/{id}:
* delete:
* summary: Delete review by ID
* tags: [Reviews]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Review deleted
* 404:
* description: Review not found
*/
router.delete('/:id', (req, res) => {
const deleted = reviews.delete(req.params.id);
if (!deleted) {
return res.status(404).json({ error: 'Review not found' });
}
res.status(204).send();
});
module.exports = router;
+192
View File
@@ -0,0 +1,192 @@
const express = require('express');
const router = express.Router();
const users = require('../entities/users');
/**
* @swagger
* components:
* schemas:
* User:
* type: object
* properties:
* id:
* type: integer
* firstName:
* type: string
* lastName:
* type: string
* email:
* type: string
* age:
* type: integer
* active:
* type: boolean
* createdAt:
* type: string
* format: date-time
*/
/**
* @swagger
* /api/users:
* get:
* summary: Get all users
* tags: [Users]
* responses:
* 200:
* description: List of users
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/User'
*/
router.get('/', (req, res) => {
res.json(users.getAll());
});
/**
* @swagger
* /api/users/{id}:
* get:
* summary: Get user by ID
* tags: [Users]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: User found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 404:
* description: User not found
*/
router.get('/:id', (req, res) => {
const user = users.getById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
/**
* @swagger
* /api/users:
* post:
* summary: Create a new user
* tags: [Users]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - firstName
* - lastName
* - email
* properties:
* firstName:
* type: string
* lastName:
* type: string
* email:
* type: string
* age:
* type: integer
* active:
* type: boolean
* responses:
* 201:
* description: User created
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
*/
router.post('/', (req, res) => {
const user = users.create(req.body);
res.status(201).json(user);
});
/**
* @swagger
* /api/users/{id}:
* put:
* summary: Update user by ID
* tags: [Users]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* firstName:
* type: string
* lastName:
* type: string
* email:
* type: string
* age:
* type: integer
* active:
* type: boolean
* responses:
* 200:
* description: User updated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 404:
* description: User not found
*/
router.put('/:id', (req, res) => {
const user = users.update(req.params.id, req.body);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
/**
* @swagger
* /api/users/{id}:
* delete:
* summary: Delete user by ID
* tags: [Users]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: User deleted
* 404:
* description: User not found
*/
router.delete('/:id', (req, res) => {
const deleted = users.delete(req.params.id);
if (!deleted) {
return res.status(404).json({ error: 'User not found' });
}
res.status(204).send();
});
module.exports = router;
+648
View File
@@ -0,0 +1,648 @@
const express = require('express');
const cors = require('cors');
const yaml = require('js-yaml');
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerUi = require('swagger-ui-express');
const { createHandler } = require('graphql-http/lib/use/express');
const schema = require('./graphql/schema');
const resolvers = require('./graphql/resolvers');
const { basicAuth, apiKeyAuth } = require('./middleware/auth');
// Import routes
const usersRoutes = require('./routes/users');
const productsRoutes = require('./routes/products');
const ordersRoutes = require('./routes/orders');
const categoriesRoutes = require('./routes/categories');
const reviewsRoutes = require('./routes/reviews');
const errorsRoutes = require('./routes/errors');
const parametersRoutes = require('./routes/parameters');
const delaysRoutes = require('./routes/delays');
const odataRoutes = require('./routes/odata');
const app = express();
const PORT = process.env.PORT || 4444;
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Swagger configuration (single UI with multiple server options)
const swaggerOptions = {
definition: {
openapi: '3.0.0',
info: {
title: 'DBGate Test API',
version: '1.0.0',
description: 'Testing API server with No Auth, Basic Auth, and API Key Auth modes',
},
servers: [
{
url: '/openapi/noauth',
description: 'No authentication',
},
{
url: '/openapi/baseauth',
description: 'Basic authentication',
},
{
url: '/openapi/keyauth',
description: 'API key authentication',
},
],
components: {
securitySchemes: {
basicAuth: {
type: 'http',
scheme: 'basic',
},
apiKeyAuth: {
type: 'apiKey',
in: 'header',
name: 'x-api-key',
},
},
},
},
apis: ['./src/routes/*.js'],
};
const swaggerSpec = swaggerJsdoc(swaggerOptions);
// OpenAPI/Swagger UI endpoint
app.use('/openapi/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
app.get('/openapi.json', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.send(swaggerSpec);
});
app.get('/openapi.yaml', (req, res) => {
res.setHeader('Content-Type', 'application/yaml');
res.send(yaml.dump(swaggerSpec, { noRefs: true }));
});
// REST API routes
app.use('/api/users', usersRoutes);
app.use('/api/products', productsRoutes);
app.use('/api/orders', ordersRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/reviews', reviewsRoutes);
app.use('/api/errors', errorsRoutes);
app.use('/api/params', parametersRoutes);
app.use('/api/delays', delaysRoutes);
// REST API routes for OpenAPI server options
app.use('/openapi/noauth/api/users', usersRoutes);
app.use('/openapi/noauth/api/products', productsRoutes);
app.use('/openapi/noauth/api/orders', ordersRoutes);
app.use('/openapi/noauth/api/categories', categoriesRoutes);
app.use('/openapi/noauth/api/reviews', reviewsRoutes);
app.use('/openapi/noauth/api/errors', errorsRoutes);
app.use('/openapi/noauth/api/params', parametersRoutes);
app.use('/openapi/noauth/api/delays', delaysRoutes);
app.use('/openapi/baseauth/api/users', basicAuth, usersRoutes);
app.use('/openapi/baseauth/api/products', basicAuth, productsRoutes);
app.use('/openapi/baseauth/api/orders', basicAuth, ordersRoutes);
app.use('/openapi/baseauth/api/categories', basicAuth, categoriesRoutes);
app.use('/openapi/baseauth/api/reviews', basicAuth, reviewsRoutes);
app.use('/openapi/baseauth/api/errors', basicAuth, errorsRoutes);
app.use('/openapi/baseauth/api/params', basicAuth, parametersRoutes);
app.use('/openapi/baseauth/api/delays', basicAuth, delaysRoutes);
app.use('/openapi/keyauth/api/users', apiKeyAuth, usersRoutes);
app.use('/openapi/keyauth/api/products', apiKeyAuth, productsRoutes);
app.use('/openapi/keyauth/api/orders', apiKeyAuth, ordersRoutes);
app.use('/openapi/keyauth/api/categories', apiKeyAuth, categoriesRoutes);
app.use('/openapi/keyauth/api/reviews', apiKeyAuth, reviewsRoutes);
app.use('/openapi/keyauth/api/errors', apiKeyAuth, errorsRoutes);
app.use('/openapi/keyauth/api/params', apiKeyAuth, parametersRoutes);
app.use('/openapi/keyauth/api/delays', apiKeyAuth, delaysRoutes);
// OData endpoints with different auth
app.use('/odata/noauth', odataRoutes);
app.use('/odata/baseauth', basicAuth, odataRoutes);
app.use('/odata/keyauth', apiKeyAuth, odataRoutes);
// GraphQL endpoints with different auth
// No auth
app.all('/graphql/noauth', createHandler({
schema: schema,
rootValue: resolvers,
}));
// Basic auth
app.all('/graphql/baseauth', basicAuth, createHandler({
schema: schema,
rootValue: resolvers,
}));
// API Key auth
app.all('/graphql/keyauth', apiKeyAuth, createHandler({
schema: schema,
rootValue: resolvers,
}));
// Legacy endpoint for backwards compatibility
app.all('/graphql', createHandler({
schema: schema,
rootValue: resolvers,
}));
// GraphiQL IDE with different auth
function createGraphiQLHTML(title, endpoint, authType) {
let authInfo = '';
let fetchOptions = '';
if (authType === 'none') {
authInfo = '<p class="info">No authentication required</p>';
fetchOptions = `headers: {
'Content-Type': 'application/json',
},`;
} else if (authType === 'basic') {
authInfo = `<p class="info">Basic Authentication: <code>username: admin, password: admin</code></p>`;
fetchOptions = `headers: {
'Content-Type': 'application/json',
'Authorization': 'Basic ' + btoa('admin:admin'),
},`;
} else if (authType === 'apikey') {
authInfo = `<p class="info">API Key Authentication: <code>x-api-key: test-api-key-12345</code></p>`;
fetchOptions = `headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-api-key-12345',
},`;
}
return `
<!DOCTYPE html>
<html lang="en">
<head>
<title>${title}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
margin: 0;
padding: 20px;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 20px;
}
h1 { color: #333; margin-top: 0; }
h2 { color: #666; font-size: 1.2em; margin-top: 30px; }
textarea, input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 14px;
box-sizing: border-box;
}
textarea { min-height: 150px; resize: vertical; }
button {
background: #4CAF50;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
margin-top: 10px;
}
button:hover { background: #45a049; }
#response {
background: #f9f9f9;
padding: 15px;
border-radius: 4px;
border: 1px solid #ddd;
margin-top: 20px;
white-space: pre-wrap;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 13px;
max-height: 400px;
overflow: auto;
}
.example {
background: #e8f5e9;
padding: 10px;
border-radius: 4px;
margin: 10px 0;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 13px;
cursor: pointer;
}
.example:hover { background: #c8e6c9; }
.info { color: #666; font-size: 14px; margin: 10px 0; }
.auth-info { background: #fff3cd; padding: 10px; border-radius: 4px; margin: 10px 0; border: 1px solid #ffc107; }
</style>
</head>
<body>
<div class="container">
<h1>🔍 ${title}</h1>
<p class="info">GraphQL endpoint: <code>${endpoint}</code></p>
<div class="auth-info">${authInfo}</div>
<h2>Query</h2>
<textarea id="query" placeholder="Enter your GraphQL query here...">{
users {
id
firstName
lastName
email
}
}</textarea>
<button onclick="executeQuery()">Execute Query</button>
<h2>Response</h2>
<div id="response">Results will appear here...</div>
<h2>Example Queries</h2>
<div class="example" onclick="setQuery(this.innerText)">{ users { id firstName lastName email } }</div>
<div class="example" onclick="setQuery(this.innerText)">{ products { id name price category inStock } }</div>
<div class="example" onclick="setQuery(this.innerText)">{ orders { id orderNumber totalAmount status } }</div>
<div class="example" onclick="setQuery(this.innerText)">{ user(id: 1) { id firstName lastName email age } }</div>
<div class="example" onclick="setQuery(this.innerText)">{ errorUnauthorized }</div>
<div class="example" onclick="setQuery(this.innerText)">{ errorInternal }</div>
<div class="example" onclick="setQuery(this.innerText)">{ delayed2s }</div>
<div class="example" onclick="setQuery(this.innerText)">mutation { delayedMutation4s }</div>
<div class="example" onclick="setQuery(this.innerText)">mutation {
createUser(input: {
firstName: "Test"
lastName: "User"
email: "test@example.com"
age: 25
active: true
}) {
id
firstName
lastName
email
}
}</div>
</div>
<script>
function setQuery(query) {
document.getElementById('query').value = query;
}
async function executeQuery() {
const query = document.getElementById('query').value;
const responseDiv = document.getElementById('response');
responseDiv.textContent = 'Loading...';
try {
const response = await fetch('${endpoint}', {
method: 'POST',
${fetchOptions}
body: JSON.stringify({ query }),
});
const data = await response.json();
responseDiv.textContent = JSON.stringify(data, null, 2);
} catch (error) {
responseDiv.textContent = 'Error: ' + error.message;
}
}
</script>
</body>
</html>
`;
}
// GraphiQL endpoints
app.get('/graphiql/noauth', (req, res) => {
res.type('html');
res.send(createGraphiQLHTML('GraphQL Interface - No Auth', '/graphql/noauth', 'none'));
});
app.get('/graphiql/baseauth', (req, res) => {
res.type('html');
res.send(createGraphiQLHTML('GraphQL Interface - Basic Auth', '/graphql/baseauth', 'basic'));
});
app.get('/graphiql/keyauth', (req, res) => {
res.type('html');
res.send(createGraphiQLHTML('GraphQL Interface - API Key Auth', '/graphql/keyauth', 'apikey'));
});
// Legacy GraphiQL endpoint for backwards compatibility
app.get('/graphiql', (req, res) => {
res.type('html');
res.send(createGraphiQLHTML('GraphQL Interface', '/graphql', 'none'));
});
// Root endpoint - Index page with all links
app.get('/', (req, res) => {
res.type('html');
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DBGate Test API</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 2.5em;
}
.subtitle {
color: #666;
margin-bottom: 40px;
font-size: 1.2em;
}
.section {
margin-bottom: 40px;
}
.section h2 {
color: #444;
margin-bottom: 20px;
font-size: 1.8em;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.card {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
border-left: 4px solid #667eea;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.card h3 {
color: #333;
margin-bottom: 10px;
font-size: 1.3em;
}
.card p {
color: #666;
margin-bottom: 15px;
font-size: 0.95em;
}
.card a {
display: inline-block;
background: #667eea;
color: white;
padding: 10px 20px;
text-decoration: none;
border-radius: 5px;
font-weight: 500;
transition: background 0.2s;
}
.card a:hover {
background: #5568d3;
}
.auth-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.85em;
font-weight: 600;
margin-top: 10px;
}
.auth-none {
background: #d4edda;
color: #155724;
}
.auth-basic {
background: #fff3cd;
color: #856404;
}
.auth-apikey {
background: #cce5ff;
color: #004085;
}
.credentials {
background: #fff3cd;
border: 1px solid #ffc107;
padding: 15px;
border-radius: 8px;
margin-top: 20px;
}
.credentials h3 {
color: #856404;
margin-bottom: 10px;
font-size: 1.1em;
}
.credentials code {
background: white;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
}
.rest-api {
background: #e8f5e9;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
}
.rest-api h3 {
color: #2e7d32;
margin-bottom: 15px;
}
.rest-api ul {
list-style: none;
padding-left: 0;
}
.rest-api li {
padding: 8px 0;
color: #333;
}
.rest-api a {
color: #2e7d32;
text-decoration: none;
font-weight: 500;
}
.rest-api a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<h1>🚀 DBGate Test API</h1>
<p class="subtitle">Testing API server with OpenAPI/Swagger and GraphQL support</p>
<div class="section">
<h2>📚 OpenAPI / Swagger UI</h2>
<div class="cards">
<div class="card">
<h3>Swagger Interface</h3>
<p>Use one Swagger URL, then choose Server and Authorization inside Swagger</p>
<span class="auth-badge auth-basic">Authorize: Basic Auth</span>
<span class="auth-badge auth-apikey" style="margin-left: 6px;">Authorize: API Key</span>
<br><br>
<a href="/openapi/docs" target="_blank">Open Swagger UI</a>
<a href="/openapi.yaml" target="_blank" style="margin-left: 10px; background: #5568d3;">OpenAPI YAML</a>
<small style="color: #666; margin-top: 10px; display: block;">Servers: /openapi/noauth, /openapi/baseauth, /openapi/keyauth</small>
</div>
</div>
</div>
<div class="section">
<h2>🔍 GraphQL Endpoints</h2>
<div class="cards">
<div class="card">
<h3>GraphQL - No Auth</h3>
<p>GraphQL endpoint without authentication</p>
<span class="auth-badge auth-none">No Auth Required</span>
<br><br>
<a href="/graphiql/noauth" target="_blank">Open GraphiQL</a>
<br>
<small style="color: #666; margin-top: 10px; display: block;">Endpoint: <code>/graphql/noauth</code></small>
</div>
<div class="card">
<h3>GraphQL - Basic Auth</h3>
<p>GraphQL endpoint with HTTP Basic Auth</p>
<span class="auth-badge auth-basic">Basic Auth</span>
<br><br>
<a href="/graphiql/baseauth" target="_blank">Open GraphiQL</a>
<br>
<small style="color: #666; margin-top: 10px; display: block;">Endpoint: <code>/graphql/baseauth</code></small>
</div>
<div class="card">
<h3>GraphQL - API Key</h3>
<p>GraphQL endpoint with API Key in header</p>
<span class="auth-badge auth-apikey">API Key Auth</span>
<br><br>
<a href="/graphiql/keyauth" target="_blank">Open GraphiQL</a>
<br>
<small style="color: #666; margin-top: 10px; display: block;">Endpoint: <code>/graphql/keyauth</code></small>
</div>
</div>
</div>
<div class="section">
<h2>🧭 OData Endpoints</h2>
<div class="cards">
<div class="card">
<h3>OData - No Auth</h3>
<p>OData endpoint without authentication</p>
<span class="auth-badge auth-none">No Auth Required</span>
<br><br>
<a href="/odata/noauth" target="_blank">Service Document</a>
<a href="/odata/noauth/$metadata" target="_blank" style="margin-left: 10px; background: #5568d3;">$metadata</a>
<small style="color: #666; margin-top: 10px; display: block;">Example: <code>/odata/noauth/Users?$top=5&$count=true</code></small>
</div>
<div class="card">
<h3>OData - Basic Auth</h3>
<p>OData endpoint with HTTP Basic Auth</p>
<span class="auth-badge auth-basic">Basic Auth</span>
<br><br>
<a href="/odata/baseauth" target="_blank">Service Document</a>
<a href="/odata/baseauth/$metadata" target="_blank" style="margin-left: 10px; background: #5568d3;">$metadata</a>
<small style="color: #666; margin-top: 10px; display: block;">Credentials: <code>admin/admin</code></small>
</div>
<div class="card">
<h3>OData - API Key</h3>
<p>OData endpoint with API Key in header</p>
<span class="auth-badge auth-apikey">API Key Auth</span>
<br><br>
<a href="/odata/keyauth" target="_blank">Service Document</a>
<a href="/odata/keyauth/$metadata" target="_blank" style="margin-left: 10px; background: #5568d3;">$metadata</a>
<small style="color: #666; margin-top: 10px; display: block;">Header: <code>x-api-key: test-api-key-12345</code></small>
</div>
</div>
</div>
<div class="credentials">
<h3>🔐 Test Credentials</h3>
<p><strong>Basic Authentication:</strong></p>
<ul>
<li>Username: <code>admin</code></li>
<li>Password: <code>admin</code></li>
</ul>
<br>
<p><strong>API Key Authentication:</strong></p>
<ul>
<li>Header: <code>x-api-key</code></li>
<li>Value: <code>test-api-key-12345</code></li>
</ul>
</div>
<div class="rest-api">
<h3>📡 REST API Endpoints</h3>
<ul>
<li>👥 Users: <a href="/api/users" target="_blank">/api/users</a></li>
<li>📦 Products: <a href="/api/products" target="_blank">/api/products</a></li>
<li>🛒 Orders: <a href="/api/orders" target="_blank">/api/orders</a></li>
<li>📂 Categories: <a href="/api/categories" target="_blank">/api/categories</a></li>
<li>⭐ Reviews: <a href="/api/reviews" target="_blank">/api/reviews</a></li>
<li>🧩 Parameters testing: <a href="/api/params/query?search=test&page=1&tags=a&tags=b&fields=id,name" target="_blank">/api/params/*</a></li>
<li>⚠️ Error testing: <a href="/api/errors/bad-request" target="_blank">/api/errors/*</a></li>
<li>⏱️ Delay testing: <a href="/api/delays/2s" target="_blank">/api/delays/*</a></li>
</ul>
<p style="margin-top: 15px; font-size: 0.9em;">Each endpoint supports: GET, POST, PUT, DELETE operations</p>
</div>
</div>
</body>
</html>
`);
});
// Start server
app.listen(PORT, () => {
console.log(`🚀 Server is running on http://localhost:${PORT}`);
console.log(`\n📚 OpenAPI/Swagger UI:`);
console.log(` - UI: http://localhost:${PORT}/openapi/docs`);
console.log(` - YAML: http://localhost:${PORT}/openapi.yaml`);
console.log(` - Servers: /openapi/noauth | /openapi/baseauth | /openapi/keyauth`);
console.log(` - Authorize: Basic (admin/admin) and API Key (x-api-key: test-api-key-12345)`);
console.log(`\n🔍 GraphiQL Interface:`);
console.log(` - No Auth: http://localhost:${PORT}/graphiql/noauth`);
console.log(` - Basic Auth: http://localhost:${PORT}/graphiql/baseauth (admin/admin)`);
console.log(` - API Key: http://localhost:${PORT}/graphiql/keyauth (x-api-key: test-api-key-12345)`);
console.log(`\n📡 GraphQL Endpoints:`);
console.log(` - No Auth: http://localhost:${PORT}/graphql/noauth`);
console.log(` - Basic Auth: http://localhost:${PORT}/graphql/baseauth`);
console.log(` - API Key: http://localhost:${PORT}/graphql/keyauth`);
console.log(`\n🧭 OData Endpoints:`);
console.log(` - No Auth: http://localhost:${PORT}/odata/noauth`);
console.log(` - Basic Auth: http://localhost:${PORT}/odata/baseauth (admin/admin)`);
console.log(` - API Key: http://localhost:${PORT}/odata/keyauth (x-api-key: test-api-key-12345)`);
console.log(` - Metadata: /odata/*/$metadata`);
console.log(`\n🏠 Index Page: http://localhost:${PORT}/`);
});
module.exports = app;