SYNC: Merge pull request #63 from dbgate/feature/test-api-e2e
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Vendored
+14
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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": {}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
test-api.pid
|
||||
@@ -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;
|
||||
|
||||
@@ -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-*
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user