From 6feca3564af2e2a58cdc567e601140c039ab81e8 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Mon, 18 May 2026 07:39:27 +0700 Subject: [PATCH] feat: add Swagger OpenAPI documentation endpoints --- package.json | 2 +- src/index.ts | 7 ++ src/routes/swagger.ts | 274 ++++++++++++++++++++++++++++++++++++++++++ test/swagger.test.ts | 36 ++++++ 4 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 src/routes/swagger.ts create mode 100644 test/swagger.test.ts diff --git a/package.json b/package.json index ab09882..06acf79 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "bun build src/index.ts --target=bun --outfile=dist/index.js", "start": "NODE_ENV=production bun dist/index.js", "db:migrate": "psql $DATABASE_URL -f schema.sql", - "test": "bun test test/rateLimit.test.ts && bun test test/file.test.ts && bun test test/telegram.test.ts && bun test test/upload.test.ts && bun test test/files.test.ts && bun test test/health.test.ts && bun test test/bot.test.ts && bun test test/bootstrap.test.ts", + "test": "bun test test/rateLimit.test.ts && bun test test/file.test.ts && bun test test/telegram.test.ts && bun test test/upload.test.ts && bun test test/files.test.ts && bun test test/health.test.ts && bun test test/bot.test.ts && bun test test/bootstrap.test.ts && bun test test/swagger.test.ts", "lint": "bunx biome check src test", "format": "bunx biome format --write src test" }, diff --git a/src/index.ts b/src/index.ts index 1d6d7b4..591d44b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { startBot } from './bot'; import { config } from './env'; import { handleFileInfo, handleFileRedirect } from './routes/files'; import { handleHealth } from './routes/health'; +import { handleSwaggerHtml, handleSwaggerJson } from './routes/swagger'; import { handleUpload } from './routes/upload'; import logger from './utils/logger'; import { cleanupRateLimitCache } from './utils/rateLimit'; @@ -22,6 +23,12 @@ const server = serve({ '/health': { GET: handleHealth, }, + '/docs': { + GET: handleSwaggerHtml, + }, + '/swagger.json': { + GET: handleSwaggerJson, + }, }, }); diff --git a/src/routes/swagger.ts b/src/routes/swagger.ts new file mode 100644 index 0000000..ec00940 --- /dev/null +++ b/src/routes/swagger.ts @@ -0,0 +1,274 @@ +const errorSchema = (example: string) => ({ + type: 'object', + properties: { + error: { type: 'string', example }, + }, +}); + +const jsonContent = (schema: object) => ({ + 'application/json': { schema }, +}); + +export const handleSwaggerJson = async (): Promise => { + const spec = { + openapi: '3.0.0', + info: { + title: 'TeleUploader API', + version: '1.0.0', + description: 'Telegram-backed file uploader API with redirect-based downloads.', + }, + servers: [ + { + url: '/', + description: 'Current environment', + }, + ], + paths: { + '/health': { + get: { + summary: 'Health Check', + description: 'Checks database connectivity status.', + responses: { + '200': { + description: 'Database is healthy', + content: jsonContent({ + type: 'object', + properties: { + status: { type: 'string', example: 'ok' }, + }, + }), + }, + '500': { + description: 'Database or server is unhealthy', + content: jsonContent({ + type: 'object', + properties: { + status: { type: 'string', example: 'error' }, + error: { type: 'string', example: 'DB Connection Failed' }, + }, + }), + }, + }, + }, + }, + '/api/upload': { + post: { + summary: 'Upload File', + description: 'Uploads a file to Telegram storage via multipart/form-data or JSON base64.', + requestBody: { + required: true, + content: { + 'multipart/form-data': { + schema: { + type: 'object', + required: ['file'], + properties: { + file: { + type: 'string', + format: 'binary', + description: 'File binary payload.', + }, + fileName: { + type: 'string', + description: 'Optional file name override.', + }, + }, + }, + }, + 'application/json': { + schema: { + type: 'object', + required: ['file'], + properties: { + file: { + type: 'string', + description: 'Base64 encoded file content.', + }, + fileName: { + type: 'string', + default: 'file', + description: 'Optional file name.', + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Successful upload metadata.', + content: jsonContent({ + type: 'object', + properties: { + public_id: { type: 'string', example: 'xYz123' }, + telegram_file_id: { type: 'string', example: 'BQACAgQAAxkBA...' }, + telegram_file_unique_id: { type: 'string', example: 'AgAD8w...' }, + storage_chat_id: { type: 'integer', example: -1001234567890 }, + storage_message_id: { type: 'integer', example: 42 }, + file_name: { type: 'string', example: 'document.pdf' }, + mime_type: { type: 'string', example: 'application/pdf' }, + size_bytes: { type: 'integer', example: 1048576 }, + file_type: { type: 'string', example: 'document' }, + uploader_id: { type: 'integer', example: 0 }, + created_at: { + type: 'string', + format: 'date-time', + example: '2026-05-18T10:00:00.000Z', + }, + download_url: { + type: 'string', + example: 'https://tele.asepharyana.tech/f/xYz123', + }, + }, + }), + }, + '400': { + description: 'Bad request.', + content: jsonContent(errorSchema('No file provided')), + }, + '500': { + description: 'Internal server error.', + content: jsonContent(errorSchema('Upload failed')), + }, + }, + }, + }, + '/f/{public_id}': { + get: { + summary: 'Redirect to Telegram File URL', + description: + 'Gets a fresh Telegram download URL and redirects with 302. Rate-limited by IP.', + parameters: [ + { + name: 'public_id', + in: 'path', + required: true, + description: 'Permanent public file ID.', + schema: { type: 'string' }, + }, + ], + responses: { + '302': { + description: 'Redirect to Telegram CDN URL.', + headers: { + Location: { + schema: { + type: 'string', + example: 'https://api.telegram.org/file/botTOKEN/documents/file_0.pdf', + }, + }, + }, + }, + '404': { + description: 'File not found.', + content: jsonContent(errorSchema('File not found')), + }, + '429': { + description: 'Rate limit exceeded.', + content: jsonContent(errorSchema('Rate limit exceeded')), + }, + '500': { + description: 'Internal server error.', + content: jsonContent(errorSchema('Server error')), + }, + }, + }, + }, + '/file/{public_id}/info': { + get: { + summary: 'Get File Info', + description: 'Gets saved file metadata by public ID.', + parameters: [ + { + name: 'public_id', + in: 'path', + required: true, + description: 'Permanent public file ID.', + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + description: 'File metadata.', + content: jsonContent({ + type: 'object', + properties: { + public_id: { type: 'string', example: 'xYz123' }, + file_name: { type: 'string', example: 'document.pdf' }, + mime_type: { type: 'string', example: 'application/pdf' }, + size_bytes: { type: 'integer', example: 1048576 }, + file_type: { type: 'string', example: 'document' }, + uploader_id: { type: 'integer', example: 0 }, + created_at: { + type: 'string', + format: 'date-time', + example: '2026-05-18T10:00:00.000Z', + }, + }, + }), + }, + '400': { + description: 'Missing public ID.', + content: jsonContent(errorSchema('Missing file id')), + }, + '404': { + description: 'File not found.', + content: jsonContent(errorSchema('File not found')), + }, + '500': { + description: 'Internal server error.', + content: jsonContent(errorSchema('Server error')), + }, + }, + }, + }, + }, + }; + + return Response.json(spec, { + status: 200, + headers: { + 'access-control-allow-origin': '*', + }, + }); +}; + +export const handleSwaggerHtml = async (): Promise => { + const html = ` + + + + TeleUploader API Documentation + + + + +
+ + + + +`; + + return new Response(html, { + status: 200, + headers: { + 'content-type': 'text/html; charset=utf-8', + }, + }); +}; diff --git a/test/swagger.test.ts b/test/swagger.test.ts new file mode 100644 index 0000000..54468c8 --- /dev/null +++ b/test/swagger.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'bun:test'; +import { handleSwaggerHtml, handleSwaggerJson } from '../src/routes/swagger'; + +describe('Swagger Documentation Endpoints', () => { + it('returns OpenAPI specification JSON', async () => { + const res = await handleSwaggerJson(); + + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + + const body = (await res.json()) as any; + expect(body.openapi).toBe('3.0.0'); + expect(body.info.title).toBe('TeleUploader API'); + expect(body.paths).toHaveProperty('/health'); + expect(body.paths).toHaveProperty('/api/upload'); + expect(body.paths).toHaveProperty('/f/{public_id}'); + expect(body.paths).toHaveProperty('/file/{public_id}/info'); + expect(body.paths['/api/upload'].post.requestBody.content).toHaveProperty( + 'multipart/form-data', + ); + expect(body.paths['/api/upload'].post.requestBody.content).toHaveProperty('application/json'); + }); + + it('returns Swagger UI HTML page', async () => { + const res = await handleSwaggerHtml(); + + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + + const html = await res.text(); + expect(html).toContain(''); + expect(html).toContain('swagger-ui'); + expect(html).toContain('/swagger.json'); + expect(html).toContain('swagger-ui-bundle.js'); + }); +});