diff --git a/.changeset/moody-guests-smell.md b/.changeset/moody-guests-smell.md new file mode 100644 index 00000000..6e95508e --- /dev/null +++ b/.changeset/moody-guests-smell.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/api": patch +--- + +Adds openapidoc annotations for spec generation and swagger route for development diff --git a/.gitignore b/.gitignore index 17b4bfb0..9287305e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ packages/app/.vercel packages/app/coverage packages/app/out +# OpenAPI spec +packages/public/openapi.json + # optional npm cache directory **/.npm diff --git a/packages/api/.env.development b/packages/api/.env.development index d29428ca..7b29cd20 100644 --- a/packages/api/.env.development +++ b/packages/api/.env.development @@ -18,3 +18,4 @@ PORT=${HYPERDX_API_PORT} REDIS_URL=redis://localhost:6379 USAGE_STATS_ENABLED=false NODE_OPTIONS="--max-http-header-size=131072" +ENABLE_SWAGGER=true \ No newline at end of file diff --git a/packages/api/package.json b/packages/api/package.json index c9745b8b..0f8f8791 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -73,12 +73,15 @@ "@types/semver": "^7.3.12", "@types/sqlstring": "^2.3.0", "@types/supertest": "^2.0.12", + "@types/swagger-jsdoc": "^6", "@types/uuid": "^8.3.4", "jest": "^28.1.3", "migrate-mongo": "^11.0.0", "nodemon": "^2.0.20", "rimraf": "^4.4.1", "supertest": "^6.3.1", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "ts-jest": "^28.0.5", "ts-node": "^10.8.1", "tsc-alias": "^1.8.8", @@ -98,6 +101,7 @@ "dev:migrate-db-create": "ts-node node_modules/.bin/migrate-mongo create -f migrate-mongo-config.ts", "dev:migrate-db": "ts-node node_modules/.bin/migrate-mongo up -f migrate-mongo-config.ts", "dev:migrate-ch-create": "migrate create -ext sql -dir ./migrations/ch -seq", - "dev:migrate-ch": "migrate -database 'clickhouse://localhost:9000?database=default&x-multi-statement=true' -path ./migrations/ch up" + "dev:migrate-ch": "migrate -database 'clickhouse://localhost:9000?database=default&x-multi-statement=true' -path ./migrations/ch up", + "docgen": "ts-node scripts/generate-api-docs.ts" } } diff --git a/packages/api/scripts/generate-api-docs.ts b/packages/api/scripts/generate-api-docs.ts new file mode 100644 index 00000000..915b34c3 --- /dev/null +++ b/packages/api/scripts/generate-api-docs.ts @@ -0,0 +1,12 @@ +import fs from 'fs'; +import path from 'path'; +import swaggerJsdoc from 'swagger-jsdoc'; + +import { swaggerOptions } from '../src/utils/swagger'; + +const specs = swaggerJsdoc(swaggerOptions); +const outputPath = path.resolve(__dirname, '../../public/openapi.json'); +fs.mkdirSync(path.dirname(outputPath), { recursive: true }); +fs.writeFileSync(outputPath, JSON.stringify(specs, null, 2)); + +console.log(`OpenAPI specification written to ${outputPath}`); diff --git a/packages/api/src/api-app.ts b/packages/api/src/api-app.ts index 2ae6011d..cc539795 100644 --- a/packages/api/src/api-app.ts +++ b/packages/api/src/api-app.ts @@ -18,6 +18,7 @@ import externalRoutersV2 from './routers/external-api/v2'; import usageStats from './tasks/usageStats'; import { expressLogger } from './utils/logger'; import passport from './utils/passport'; +import { setupSwagger } from './utils/swagger'; const app: express.Application = express(); @@ -102,7 +103,15 @@ app.use('/clickhouse-proxy', isUserAuthenticated, clickhouseProxyRouter); // --------------------------------------------------------------------- // ----------------------- External Routers ---------------------------- // --------------------------------------------------------------------- -// API v1 +// API v2 +// Only initialize Swagger in development or if explicitly enabled +if ( + process.env.NODE_ENV !== 'production' || + process.env.ENABLE_SWAGGER === 'true' +) { + setupSwagger(app); +} + app.use('/api/v2', externalRoutersV2); // error handling diff --git a/packages/api/src/routers/external-api/v2/alerts.ts b/packages/api/src/routers/external-api/v2/alerts.ts index f116c9ff..c5c8d66d 100644 --- a/packages/api/src/routers/external-api/v2/alerts.ts +++ b/packages/api/src/routers/external-api/v2/alerts.ts @@ -13,8 +13,241 @@ import { import { translateAlertDocumentToExternalAlert } from '@/utils/externalApi'; import { alertSchema, objectIdSchema } from '@/utils/zod'; -const router = express.Router(); +/** + * @openapi + * components: + * schemas: + * Error: + * type: object + * properties: + * message: + * type: string + * Alert: + * type: object + * properties: + * id: + * type: string + * example: "65f5e4a3b9e77c001a123456" + * name: + * type: string + * example: "High Error Rate" + * message: + * type: string + * example: "Error rate exceeds threshold" + * threshold: + * type: number + * example: 100 + * interval: + * type: string + * example: "15m" + * thresholdType: + * type: string + * enum: [above, below] + * example: "above" + * source: + * type: string + * enum: [tile, search] + * example: "tile" + * state: + * type: string + * example: "inactive" + * channel: + * type: object + * properties: + * type: + * type: string + * example: "webhook" + * webhookId: + * type: string + * example: "65f5e4a3b9e77c001a789012" + * team: + * type: string + * example: "65f5e4a3b9e77c001a345678" + * tileId: + * type: string + * example: "65f5e4a3b9e77c001a901234" + * dashboard: + * type: string + * example: "65f5e4a3b9e77c001a567890" + * savedSearch: + * type: string + * nullable: true + * groupBy: + * type: string + * nullable: true + * silenced: + * type: boolean + * nullable: true + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * updatedAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * + * CreateAlertRequest: + * type: object + * required: + * - threshold + * - interval + * - source + * - thresholdType + * - channel + * properties: + * dashboardId: + * type: string + * example: "65f5e4a3b9e77c001a567890" + * tileId: + * type: string + * example: "65f5e4a3b9e77c001a901234" + * threshold: + * type: number + * example: 100 + * interval: + * type: string + * example: "1h" + * source: + * type: string + * enum: [tile, search] + * example: "tile" + * thresholdType: + * type: string + * enum: [above, below] + * example: "above" + * channel: + * type: object + * properties: + * type: + * type: string + * example: "webhook" + * webhookId: + * type: string + * example: "65f5e4a3b9e77c001a789012" + * name: + * type: string + * example: "Test Alert" + * message: + * type: string + * example: "Test Alert Message" + * + * UpdateAlertRequest: + * type: object + * properties: + * threshold: + * type: number + * example: 500 + * interval: + * type: string + * example: "1h" + * thresholdType: + * type: string + * enum: [above, below] + * example: "above" + * source: + * type: string + * enum: [tile, search] + * example: "tile" + * dashboardId: + * type: string + * example: "65f5e4a3b9e77c001a567890" + * tileId: + * type: string + * example: "65f5e4a3b9e77c001a901234" + * channel: + * type: object + * properties: + * type: + * type: string + * example: "webhook" + * webhookId: + * type: string + * example: "65f5e4a3b9e77c001a789012" + * name: + * type: string + * example: "Updated Alert Name" + * message: + * type: string + * example: "Updated message" + * + * AlertResponse: + * type: object + * properties: + * data: + * $ref: '#/components/schemas/Alert' + * + * AlertsListResponse: + * type: object + * properties: + * data: + * type: array + * items: + * $ref: '#/components/schemas/Alert' + * + * EmptyResponse: + * type: object + * properties: {} + */ +const router = express.Router(); +/** + * @openapi + * /api/v2/alerts/{id}: + * get: + * summary: Get Alert + * description: Retrieves a specific alert by ID + * operationId: getAlert + * tags: [Alerts] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * description: Alert ID + * example: "65f5e4a3b9e77c001a123456" + * responses: + * '200': + * description: Successfully retrieved alert + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AlertResponse' + * examples: + * alertResponse: + * summary: Single alert response + * value: + * data: + * id: "65f5e4a3b9e77c001a123456" + * name: "CPU Usage Alert" + * message: "CPU usage is above 80%" + * threshold: 80 + * interval: "5m" + * thresholdType: "above" + * source: "tile" + * state: "active" + * channel: + * type: "webhook" + * webhookId: "65f5e4a3b9e77c001a789012" + * team: "65f5e4a3b9e77c001a345678" + * tileId: "65f5e4a3b9e77c001a901234" + * dashboard: "65f5e4a3b9e77c001a567890" + * createdAt: "2023-03-15T10:20:30.000Z" + * updatedAt: "2023-03-15T14:25:10.000Z" + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * '404': + * description: Alert not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ router.get( '/:id', validateRequest({ @@ -44,6 +277,51 @@ router.get( }, ); +/** + * @openapi + * /api/v2/alerts: + * get: + * summary: List Alerts + * description: Retrieves a list of all alerts for the authenticated team + * operationId: listAlerts + * tags: [Alerts] + * responses: + * '200': + * description: Successfully retrieved alerts + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AlertsListResponse' + * examples: + * alertsList: + * summary: List of alerts + * value: + * data: + * - id: "65f5e4a3b9e77c001a123456" + * name: "High Error Rate" + * message: "Error rate exceeds threshold" + * threshold: 100 + * interval: "15m" + * thresholdType: "above" + * source: "tile" + * state: "inactive" + * channel: + * type: "webhook" + * webhookId: "65f5e4a3b9e77c001a789012" + * team: "65f5e4a3b9e77c001a345678" + * tileId: "65f5e4a3b9e77c001a901234" + * dashboard: "65f5e4a3b9e77c001a567890" + * createdAt: "2023-01-01T00:00:00.000Z" + * updatedAt: "2023-01-01T00:00:00.000Z" + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * message: "Unauthorized access. API key is missing or invalid." + */ router.get('/', async (req, res, next) => { try { const teamId = req.user?.team; @@ -61,6 +339,55 @@ router.get('/', async (req, res, next) => { } }); +/** + * @openapi + * /api/v2/alerts: + * post: + * summary: Create Alert + * description: Creates a new alert + * operationId: createAlert + * tags: [Alerts] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CreateAlertRequest' + * examples: + * tileAlert: + * summary: Create a tile-based alert + * value: + * dashboardId: "65f5e4a3b9e77c001a567890" + * tileId: "65f5e4a3b9e77c001a901234" + * threshold: 100 + * interval: "1h" + * source: "tile" + * thresholdType: "above" + * channel: + * type: "webhook" + * webhookId: "65f5e4a3b9e77c001a789012" + * name: "Error Spike Alert" + * message: "Error rate has exceeded 100 in the last hour" + * responses: + * '200': + * description: Successfully created alert + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AlertResponse' + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * '500': + * description: Server error or validation failure + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ router.post('/', async (req, res, next) => { const teamId = req.user?.team; if (teamId == null) { @@ -78,6 +405,69 @@ router.post('/', async (req, res, next) => { } }); +/** + * @openapi + * /api/v2/alerts/{id}: + * put: + * summary: Update Alert + * description: Updates an existing alert + * operationId: updateAlert + * tags: [Alerts] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * description: Alert ID + * example: "65f5e4a3b9e77c001a123456" + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UpdateAlertRequest' + * examples: + * updateAlert: + * summary: Update alert properties + * value: + * threshold: 500 + * interval: "1h" + * thresholdType: "above" + * source: "tile" + * dashboardId: "65f5e4a3b9e77c001a567890" + * tileId: "65f5e4a3b9e77c001a901234" + * channel: + * type: "webhook" + * webhookId: "65f5e4a3b9e77c001a789012" + * name: "Updated Alert Name" + * message: "Updated threshold and interval" + * responses: + * '200': + * description: Successfully updated alert + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AlertResponse' + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * '404': + * description: Alert not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * '500': + * description: Server error or validation failure + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ router.put( '/:id', validateRequest({ @@ -111,6 +501,43 @@ router.put( }, ); +/** + * @openapi + * /api/v2/alerts/{id}: + * delete: + * summary: Delete Alert + * description: Deletes an alert + * operationId: deleteAlert + * tags: [Alerts] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * description: Alert ID + * example: "65f5e4a3b9e77c001a123456" + * responses: + * '200': + * description: Successfully deleted alert + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/EmptyResponse' + * example: {} + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * '404': + * description: Alert not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ router.delete( '/:id', validateRequest({ diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index 7f02f0b8..264a3233 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -22,8 +22,249 @@ import { tagsSchema, } from '@/utils/zod'; +/** + * @openapi + * components: + * schemas: + * ChartSeries: + * type: object + * properties: + * type: + * type: string + * enum: [time, table, number, histogram, search, markdown] + * example: "time" + * dataSource: + * type: string + * enum: [events, metrics] + * example: "events" + * aggFn: + * type: string + * example: "count" + * where: + * type: string + * example: "level:error" + * groupBy: + * type: array + * items: + * type: string + * example: [] + * + * Tile: + * type: object + * properties: + * id: + * type: string + * example: "65f5e4a3b9e77c001a901234" + * name: + * type: string + * example: "Error Rate" + * x: + * type: integer + * example: 0 + * y: + * type: integer + * example: 0 + * w: + * type: integer + * example: 6 + * h: + * type: integer + * example: 3 + * asRatio: + * type: boolean + * example: false + * series: + * type: array + * items: + * $ref: '#/components/schemas/ChartSeries' + * + * Dashboard: + * type: object + * properties: + * id: + * type: string + * example: "65f5e4a3b9e77c001a567890" + * name: + * type: string + * example: "Service Overview" + * tiles: + * type: array + * items: + * $ref: '#/components/schemas/Tile' + * tags: + * type: array + * items: + * type: string + * example: ["production", "monitoring"] + * + * CreateDashboardRequest: + * type: object + * required: + * - name + * properties: + * name: + * type: string + * example: "New Dashboard" + * tiles: + * type: array + * items: + * $ref: '#/components/schemas/Tile' + * tags: + * type: array + * items: + * type: string + * example: ["development"] + * + * UpdateDashboardRequest: + * type: object + * properties: + * name: + * type: string + * example: "Updated Dashboard Name" + * tiles: + * type: array + * items: + * $ref: '#/components/schemas/Tile' + * tags: + * type: array + * items: + * type: string + * example: ["production", "updated"] + * + * DashboardResponse: + * type: object + * properties: + * data: + * $ref: '#/components/schemas/Dashboard' + * + * DashboardsListResponse: + * type: object + * properties: + * data: + * type: array + * items: + * $ref: '#/components/schemas/Dashboard' + */ + const router = express.Router(); +/** + * @openapi + * /api/v2/dashboards: + * get: + * summary: List Dashboards + * description: Retrieves a list of all dashboards for the authenticated team + * operationId: listDashboards + * tags: [Dashboards] + * responses: + * '200': + * description: Successfully retrieved dashboards + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DashboardsListResponse' + * '401': + * description: Unauthorized + */ +router.get('/', async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) { + return res.sendStatus(403); + } + + const dashboards = await Dashboard.find( + { team: teamId }, + { _id: 1, name: 1, tiles: 1, tags: 1 }, + ).sort({ name: -1 }); + + res.json({ + data: dashboards.map(d => + translateDashboardDocumentToExternalDashboard(d), + ), + }); + } catch (e) { + next(e); + } +}); + +/** + * @openapi + * /api/v2/dashboards/{id}: + * get: + * summary: Get Dashboard + * description: Retrieves a specific dashboard by ID + * operationId: getDashboard + * tags: [Dashboards] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * description: Dashboard ID + * example: "65f5e4a3b9e77c001a567890" + * responses: + * '200': + * description: Successfully retrieved dashboard + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DashboardResponse' + * examples: + * dashboard: + * summary: Single dashboard response + * value: + * data: + * id: "65f5e4a3b9e77c001a567890" + * name: "Infrastructure Monitoring" + * tiles: + * - id: "65f5e4a3b9e77c001a901234" + * name: "Server CPU" + * x: 0 + * y: 0 + * w: 6 + * h: 3 + * asRatio: false + * series: + * - type: "time" + * dataSource: "metrics" + * aggFn: "avg" + * field: "cpu.usage" + * where: "host:server-01" + * groupBy: [] + * - id: "65f5e4a3b9e77c001a901235" + * name: "Memory Usage" + * x: 6 + * y: 0 + * w: 6 + * h: 3 + * asRatio: false + * series: + * - type: "time" + * dataSource: "metrics" + * aggFn: "avg" + * field: "memory.usage" + * where: "host:server-01" + * groupBy: [] + * tags: ["infrastructure", "monitoring"] + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * message: "Unauthorized access. API key is missing or invalid." + * '404': + * description: Dashboard not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * message: "Dashboard not found" + */ router.get( '/:id', validateRequest({ @@ -56,28 +297,116 @@ router.get( }, ); -router.get('/', async (req, res, next) => { - try { - const teamId = req.user?.team; - if (teamId == null) { - return res.sendStatus(403); - } - - const dashboards = await Dashboard.find( - { team: teamId }, - { _id: 1, name: 1, tiles: 1, tags: 1 }, - ).sort({ name: -1 }); - - res.json({ - data: dashboards.map(d => - translateDashboardDocumentToExternalDashboard(d), - ), - }); - } catch (e) { - next(e); - } -}); - +/** + * @openapi + * /api/v2/dashboards: + * post: + * summary: Create Dashboard + * description: Creates a new dashboard + * operationId: createDashboard + * tags: [Dashboards] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CreateDashboardRequest' + * examples: + * simpleTimeSeriesDashboard: + * summary: Dashboard with time series chart + * value: + * name: "API Monitoring Dashboard" + * tiles: + * - name: "API Request Volume" + * x: 0 + * y: 0 + * w: 6 + * h: 3 + * asRatio: false + * series: + * - type: "time" + * dataSource: "events" + * aggFn: "count" + * where: "service:api" + * groupBy: [] + * tags: ["api", "monitoring"] + * complexDashboard: + * summary: Dashboard with multiple chart types + * value: + * name: "Service Health Overview" + * tiles: + * - name: "Request Count" + * x: 0 + * y: 0 + * w: 6 + * h: 3 + * asRatio: false + * series: + * - type: "time" + * dataSource: "events" + * aggFn: "count" + * where: "service:backend" + * groupBy: [] + * - name: "Error Distribution" + * x: 6 + * y: 0 + * w: 6 + * h: 3 + * asRatio: false + * series: + * - type: "table" + * dataSource: "events" + * aggFn: "count" + * where: "level:error" + * groupBy: ["errorType"] + * sortOrder: "desc" + * tags: ["service-health", "production"] + * responses: + * '200': + * description: Successfully created dashboard + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DashboardResponse' + * examples: + * createdDashboard: + * summary: Created dashboard response + * value: + * data: + * id: "65f5e4a3b9e77c001a567890" + * name: "API Monitoring Dashboard" + * tiles: + * - id: "65f5e4a3b9e77c001a901234" + * name: "API Request Volume" + * x: 0 + * y: 0 + * w: 6 + * h: 3 + * asRatio: false + * series: + * - type: "time" + * dataSource: "events" + * aggFn: "count" + * where: "service:api" + * groupBy: [] + * tags: ["api", "monitoring"] + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * message: "Unauthorized access. API key is missing or invalid." + * '500': + * description: Server error or validation failure + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * message: "Dashboard validation failed: name is required" + */ router.post( '/', validateRequest({ @@ -121,6 +450,125 @@ router.post( }, ); +/** + * @openapi + * /api/v2/dashboards/{id}: + * put: + * summary: Update Dashboard + * description: Updates an existing dashboard + * operationId: updateDashboard + * tags: [Dashboards] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * description: Dashboard ID + * example: "65f5e4a3b9e77c001a567890" + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UpdateDashboardRequest' + * examples: + * updateDashboard: + * summary: Update dashboard properties and tiles + * value: + * name: "Updated Dashboard Name" + * tiles: + * - id: "65f5e4a3b9e77c001a901234" + * name: "Updated Time Series Chart" + * x: 0 + * y: 0 + * w: 6 + * h: 3 + * asRatio: false + * series: + * - type: "time" + * dataSource: "events" + * aggFn: "count" + * where: "level:error" + * groupBy: [] + * - name: "New Number Chart" + * x: 6 + * y: 0 + * w: 6 + * h: 3 + * asRatio: false + * series: + * - type: "number" + * dataSource: "events" + * aggFn: "count" + * where: "level:info" + * tags: ["production", "updated"] + * responses: + * '200': + * description: Successfully updated dashboard + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DashboardResponse' + * examples: + * updatedDashboard: + * summary: Updated dashboard response + * value: + * data: + * id: "65f5e4a3b9e77c001a567890" + * name: "Updated Dashboard Name" + * tiles: + * - id: "65f5e4a3b9e77c001a901234" + * name: "Updated Time Series Chart" + * x: 0 + * y: 0 + * w: 6 + * h: 3 + * asRatio: false + * series: + * - type: "time" + * dataSource: "events" + * aggFn: "count" + * where: "level:error" + * groupBy: [] + * - id: "65f5e4a3b9e77c001a901236" + * name: "New Number Chart" + * x: 6 + * y: 0 + * w: 6 + * h: 3 + * asRatio: false + * series: + * - type: "number" + * dataSource: "events" + * aggFn: "count" + * where: "level:info" + * tags: ["production", "updated"] + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * message: "Unauthorized access. API key is missing or invalid." + * '404': + * description: Dashboard not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * message: "Dashboard not found" + * '500': + * description: Server error or validation failure + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * message: "Invalid dashboard configuration" + */ router.put( '/:id', validateRequest({ @@ -183,6 +631,47 @@ router.put( }, ); +/** + * @openapi + * /api/v2/dashboards/{id}: + * delete: + * summary: Delete Dashboard + * description: Deletes a dashboard + * operationId: deleteDashboard + * tags: [Dashboards] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * description: Dashboard ID + * example: "65f5e4a3b9e77c001a567890" + * responses: + * '200': + * description: Successfully deleted dashboard + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/EmptyResponse' + * example: {} + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * message: "Unauthorized access. API key is missing or invalid." + * '404': + * description: Dashboard not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * message: "Dashboard not found" + */ router.delete( '/:id', validateRequest({ diff --git a/packages/api/src/utils/swagger.ts b/packages/api/src/utils/swagger.ts new file mode 100644 index 00000000..87f58d24 --- /dev/null +++ b/packages/api/src/utils/swagger.ts @@ -0,0 +1,70 @@ +import { Application, Express } from 'express'; +import fs from 'fs'; +import path from 'path'; +import swaggerJsdoc from 'swagger-jsdoc'; +import swaggerUi from 'swagger-ui-express'; + +export const swaggerOptions = { + definition: { + openapi: '3.0.0', + info: { + title: 'HyperDX External API', + description: 'API for managing HyperDX alerts and dashboards', + version: '2.0.0', + }, + servers: [ + { + url: 'https://api.hyperdx.io', + description: 'Production API server', + }, + { + url: '/', + description: 'Current server', + }, + ], + tags: [ + { + name: 'Dashboards', + description: + 'Endpoints for managing dashboards and their visualizations', + }, + { + name: 'Alerts', + description: 'Endpoints for managing monitoring alerts', + }, + ], + components: { + securitySchemes: { + BearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'API Key', + }, + }, + }, + security: [ + { + BearerAuth: [], + }, + ], + }, + apis: ['./src/routers/external-api/**/*.ts'], // Path to the API routes files +}; + +export function setupSwagger(app: Application) { + const specs = swaggerJsdoc(swaggerOptions); + + // Serve swagger docs + app.use('/api/v2/docs', swaggerUi.serve, swaggerUi.setup(specs)); + + // Serve OpenAPI spec as JSON (needed for ReDoc) + app.get('/api/v2/docs.json', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.send(specs); + }); + + // Optionally save the spec to a file + const outputPath = path.resolve(__dirname, '../../../public/openapi.json'); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, JSON.stringify(specs, null, 2)); +} diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index b5c35cd3..c864669e 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -24,6 +24,6 @@ "strict": true, "target": "ES2022" }, - "include": ["src", "migrations"], + "include": ["src", "migrations", "scripts"], "exclude": ["node_modules", "**/*.test.ts"] } diff --git a/yarn.lock b/yarn.lock index 7f4b43a0..0a088cd0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29,6 +29,48 @@ __metadata: languageName: node linkType: hard +"@apidevtools/json-schema-ref-parser@npm:^9.0.6": + version: 9.1.2 + resolution: "@apidevtools/json-schema-ref-parser@npm:9.1.2" + dependencies: + "@jsdevtools/ono": "npm:^7.1.3" + "@types/json-schema": "npm:^7.0.6" + call-me-maybe: "npm:^1.0.1" + js-yaml: "npm:^4.1.0" + checksum: 10c0/ebf952eb2e00bf0919f024e72897e047fd5012f0a9e47ac361873f6de0a733b9334513cdbc73205a6b43ac4a652b8c87f55e489c39b2d60bd0bc1cb2b411e218 + languageName: node + linkType: hard + +"@apidevtools/openapi-schemas@npm:^2.0.4": + version: 2.1.0 + resolution: "@apidevtools/openapi-schemas@npm:2.1.0" + checksum: 10c0/f4aa0f9df32e474d166c84ef91bceb18fa1c4f44b5593879529154ef340846811ea57dc2921560f157f692262827d28d988dd6e19fb21f00320e9961964176b4 + languageName: node + linkType: hard + +"@apidevtools/swagger-methods@npm:^3.0.2": + version: 3.0.2 + resolution: "@apidevtools/swagger-methods@npm:3.0.2" + checksum: 10c0/8c390e8e50c0be7787ba0ba4c3758488bde7c66c2d995209b4b48c1f8bc988faf393cbb24a4bd1cd2d42ce5167c26538e8adea5c85eb922761b927e4dab9fa1c + languageName: node + linkType: hard + +"@apidevtools/swagger-parser@npm:10.0.3": + version: 10.0.3 + resolution: "@apidevtools/swagger-parser@npm:10.0.3" + dependencies: + "@apidevtools/json-schema-ref-parser": "npm:^9.0.6" + "@apidevtools/openapi-schemas": "npm:^2.0.4" + "@apidevtools/swagger-methods": "npm:^3.0.2" + "@jsdevtools/ono": "npm:^7.1.3" + call-me-maybe: "npm:^1.0.1" + z-schema: "npm:^5.0.1" + peerDependencies: + openapi-types: ">=7" + checksum: 10c0/3b43f719c2d647ac8dcf30f132834d413ce21cbf7a8d9c3b35ec91149dd25d608c8fd892358fcd61a8edd8c5140a7fb13676f948e2d87067d081a47b8c7107e9 + languageName: node + linkType: hard + "@aw-web-design/x-default-browser@npm:1.4.126": version: 1.4.126 resolution: "@aw-web-design/x-default-browser@npm:1.4.126" @@ -4242,6 +4284,7 @@ __metadata: "@types/semver": "npm:^7.3.12" "@types/sqlstring": "npm:^2.3.0" "@types/supertest": "npm:^2.0.12" + "@types/swagger-jsdoc": "npm:^6" "@types/uuid": "npm:^8.3.4" axios: "npm:^1.6.2" compression: "npm:^1.7.4" @@ -4281,6 +4324,8 @@ __metadata: serialize-error: "npm:^8.1.0" sqlstring: "npm:^2.3.3" supertest: "npm:^6.3.1" + swagger-jsdoc: "npm:^6.2.8" + swagger-ui-express: "npm:^5.0.1" ts-jest: "npm:^28.0.5" ts-node: "npm:^10.8.1" tsc-alias: "npm:^1.8.8" @@ -5264,6 +5309,13 @@ __metadata: languageName: node linkType: hard +"@jsdevtools/ono@npm:^7.1.3": + version: 7.1.3 + resolution: "@jsdevtools/ono@npm:7.1.3" + checksum: 10c0/a9f7e3e8e3bc315a34959934a5e2f874c423cf4eae64377d3fc9de0400ed9f36cb5fd5ebce3300d2e8f4085f557c4a8b591427a583729a87841fda46e6c216b9 + languageName: node + linkType: hard + "@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0": version: 1.2.1 resolution: "@lezer/common@npm:1.2.1" @@ -7982,6 +8034,13 @@ __metadata: languageName: node linkType: hard +"@scarf/scarf@npm:=1.4.0": + version: 1.4.0 + resolution: "@scarf/scarf@npm:1.4.0" + checksum: 10c0/332118bb488e7a70eaad068fb1a33f016d30442fb0498b37a80cb425c1e741853a5de1a04dce03526ed6265481ecf744aa6e13f072178d19e6b94b19f623ae1c + languageName: node + linkType: hard + "@sentry/core@npm:^8.7.0": version: 8.13.0 resolution: "@sentry/core@npm:8.13.0" @@ -9823,7 +9882,7 @@ __metadata: languageName: node linkType: hard -"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.8": +"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.6, @types/json-schema@npm:^7.0.8": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db @@ -10382,6 +10441,13 @@ __metadata: languageName: node linkType: hard +"@types/swagger-jsdoc@npm:^6": + version: 6.0.4 + resolution: "@types/swagger-jsdoc@npm:6.0.4" + checksum: 10c0/fbe17d91a12e1e60a255b02e6def6877c81b356c75ffcd0e5167fbaf1476e2d6600cd7eea79e6b3e0ff7929dec33ade345147509ed3b98026f63c782b74514f6 + languageName: node + linkType: hard + "@types/tedious@npm:^4.0.10": version: 4.0.14 resolution: "@types/tedious@npm:4.0.14" @@ -12453,6 +12519,13 @@ __metadata: languageName: node linkType: hard +"call-me-maybe@npm:^1.0.1": + version: 1.0.2 + resolution: "call-me-maybe@npm:1.0.2" + checksum: 10c0/8eff5dbb61141ebb236ed71b4e9549e488bcb5451c48c11e5667d5c75b0532303788a1101e6978cafa2d0c8c1a727805599c2741e3e0982855c9f1d78cd06c9f + languageName: node + linkType: hard + "callsites@npm:^3.0.0": version: 3.1.0 resolution: "callsites@npm:3.1.0" @@ -13101,6 +13174,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:6.2.0": + version: 6.2.0 + resolution: "commander@npm:6.2.0" + checksum: 10c0/1b701c6726fc2b6c6a7d9ab017be9465153546a05767cdd0e15e9f9a11c07f88f64d47684b90b07e5fb103d173efb6afdf4a21f6d6c4c25f7376bd027d21062c + languageName: node + linkType: hard + "commander@npm:^2.19.0, commander@npm:^2.20.0": version: 2.20.3 resolution: "commander@npm:2.20.3" @@ -14201,6 +14281,15 @@ __metadata: languageName: node linkType: hard +"doctrine@npm:3.0.0, doctrine@npm:^3.0.0": + version: 3.0.0 + resolution: "doctrine@npm:3.0.0" + dependencies: + esutils: "npm:^2.0.2" + checksum: 10c0/c96bdccabe9d62ab6fea9399fdff04a66e6563c1d6fb3a3a063e8d53c3bb136ba63e84250bbf63d00086a769ad53aef92d2bd483f03f837fc97b71cbee6b2520 + languageName: node + linkType: hard + "doctrine@npm:^2.1.0": version: 2.1.0 resolution: "doctrine@npm:2.1.0" @@ -14210,15 +14299,6 @@ __metadata: languageName: node linkType: hard -"doctrine@npm:^3.0.0": - version: 3.0.0 - resolution: "doctrine@npm:3.0.0" - dependencies: - esutils: "npm:^2.0.2" - checksum: 10c0/c96bdccabe9d62ab6fea9399fdff04a66e6563c1d6fb3a3a063e8d53c3bb136ba63e84250bbf63d00086a769ad53aef92d2bd483f03f837fc97b71cbee6b2520 - languageName: node - linkType: hard - "dom-accessibility-api@npm:^0.5.9": version: 0.5.16 resolution: "dom-accessibility-api@npm:0.5.16" @@ -16784,6 +16864,20 @@ __metadata: languageName: node linkType: hard +"glob@npm:7.1.6": + version: 7.1.6 + resolution: "glob@npm:7.1.6" + dependencies: + fs.realpath: "npm:^1.0.0" + inflight: "npm:^1.0.4" + inherits: "npm:2" + minimatch: "npm:^3.0.4" + once: "npm:^1.3.0" + path-is-absolute: "npm:^1.0.0" + checksum: 10c0/2575cce9306ac534388db751f0aa3e78afedb6af8f3b529ac6b2354f66765545145dba8530abf7bff49fb399a047d3f9b6901c38ee4c9503f592960d9af67763 + languageName: node + linkType: hard + "glob@npm:7.1.7": version: 7.1.7 resolution: "glob@npm:7.1.7" @@ -19678,7 +19772,14 @@ __metadata: languageName: node linkType: hard -"lodash.isequal@npm:^4.0.0": +"lodash.get@npm:^4.4.2": + version: 4.4.2 + resolution: "lodash.get@npm:4.4.2" + checksum: 10c0/48f40d471a1654397ed41685495acb31498d5ed696185ac8973daef424a749ca0c7871bf7b665d5c14f5cc479394479e0307e781f61d5573831769593411be6e + languageName: node + linkType: hard + +"lodash.isequal@npm:^4.0.0, lodash.isequal@npm:^4.5.0": version: 4.5.0 resolution: "lodash.isequal@npm:4.5.0" checksum: 10c0/dfdb2356db19631a4b445d5f37868a095e2402292d59539a987f134a8778c62a2810c2452d11ae9e6dcac71fc9de40a6fedcb20e2952a15b431ad8b29e50e28f @@ -19720,6 +19821,13 @@ __metadata: languageName: node linkType: hard +"lodash.mergewith@npm:^4.6.2": + version: 4.6.2 + resolution: "lodash.mergewith@npm:4.6.2" + checksum: 10c0/4adbed65ff96fd65b0b3861f6899f98304f90fd71e7f1eb36c1270e05d500ee7f5ec44c02ef979b5ddbf75c0a0b9b99c35f0ad58f4011934c4d4e99e5200b3b5 + languageName: node + linkType: hard + "lodash.sortby@npm:^4.7.0": version: 4.7.0 resolution: "lodash.sortby@npm:4.7.0" @@ -26638,6 +26746,51 @@ __metadata: languageName: node linkType: hard +"swagger-jsdoc@npm:^6.2.8": + version: 6.2.8 + resolution: "swagger-jsdoc@npm:6.2.8" + dependencies: + commander: "npm:6.2.0" + doctrine: "npm:3.0.0" + glob: "npm:7.1.6" + lodash.mergewith: "npm:^4.6.2" + swagger-parser: "npm:^10.0.3" + yaml: "npm:2.0.0-1" + bin: + swagger-jsdoc: bin/swagger-jsdoc.js + checksum: 10c0/7e20f08e8d90cc1e787cd82c096291cf12533359f89c70fbe4295a01f7c4734f2e82a03ba94027127bcd3da04b817abfe979f00d00ef0cd8283e449250a66215 + languageName: node + linkType: hard + +"swagger-parser@npm:^10.0.3": + version: 10.0.3 + resolution: "swagger-parser@npm:10.0.3" + dependencies: + "@apidevtools/swagger-parser": "npm:10.0.3" + checksum: 10c0/d1a5c05f651f21a23508a36416071630b83e91dfffd52a6d44b06ca2cd1b86304c0dd2f4c04526c999b70062fa89bde3f5d54a1436626f4350590b6c6265a098 + languageName: node + linkType: hard + +"swagger-ui-dist@npm:>=5.0.0": + version: 5.21.0 + resolution: "swagger-ui-dist@npm:5.21.0" + dependencies: + "@scarf/scarf": "npm:=1.4.0" + checksum: 10c0/fed58b709cc956f5965cc3e2c522898365ecd43f5bb942252d7352e52039ef7b2106b915165295114009ce7e1b64c2b2cb97edf93e3f2a44adfed70bb8a4fac8 + languageName: node + linkType: hard + +"swagger-ui-express@npm:^5.0.1": + version: 5.0.1 + resolution: "swagger-ui-express@npm:5.0.1" + dependencies: + swagger-ui-dist: "npm:>=5.0.0" + peerDependencies: + express: ">=4.0.0 || >=5.0.0-beta" + checksum: 10c0/dbe9830caef7fe455241e44e74958bac62642997e4341c1b0f38a3d684d19a4a81b431217c656792d99f046a1b5f261abf7783ede0afe41098cd4450401f6fd1 + languageName: node + linkType: hard + "symbol-tree@npm:^3.2.4": version: 3.2.4 resolution: "symbol-tree@npm:3.2.4" @@ -28223,6 +28376,13 @@ __metadata: languageName: node linkType: hard +"validator@npm:^13.7.0": + version: 13.15.0 + resolution: "validator@npm:13.15.0" + checksum: 10c0/0f13fd7031ac575e8d7828431da8ef5859bac6a38ee65e1d7fdd367dbf1c3d94d95182aecc3183f7fa7a30ff4474bf864d1aff54707620227a2cdbfd36d894c2 + languageName: node + linkType: hard + "vary@npm:^1, vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" @@ -28879,6 +29039,13 @@ __metadata: languageName: node linkType: hard +"yaml@npm:2.0.0-1": + version: 2.0.0-1 + resolution: "yaml@npm:2.0.0-1" + checksum: 10c0/e76eba2fbae37cd3e5bff057184be7cdca849895149d2f5660386871a501d76d2e1ec5906c48269a9fe798f214df31d342675b37bcd9d09af7c12eb6fb46a740 + languageName: node + linkType: hard + "yaml@npm:^1.10.0": version: 1.10.2 resolution: "yaml@npm:1.10.2" @@ -28980,6 +29147,23 @@ __metadata: languageName: node linkType: hard +"z-schema@npm:^5.0.1": + version: 5.0.5 + resolution: "z-schema@npm:5.0.5" + dependencies: + commander: "npm:^9.4.1" + lodash.get: "npm:^4.4.2" + lodash.isequal: "npm:^4.5.0" + validator: "npm:^13.7.0" + dependenciesMeta: + commander: + optional: true + bin: + z-schema: bin/z-schema + checksum: 10c0/e4c812cfe6468c19b2a21d07d4ff8fb70359062d33400b45f89017eaa3efe9d51e85963f2b115eaaa99a16b451782249bf9b1fa8b31d35cc473e7becb3e44264 + languageName: node + linkType: hard + "zod-express-middleware@npm:^1.4.0": version: 1.4.0 resolution: "zod-express-middleware@npm:1.4.0"