feat: add Swagger OpenAPI documentation endpoints
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
"build": "bun build src/index.ts --target=bun --outfile=dist/index.js",
|
"build": "bun build src/index.ts --target=bun --outfile=dist/index.js",
|
||||||
"start": "NODE_ENV=production bun dist/index.js",
|
"start": "NODE_ENV=production bun dist/index.js",
|
||||||
"db:migrate": "psql $DATABASE_URL -f schema.sql",
|
"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",
|
"lint": "bunx biome check src test",
|
||||||
"format": "bunx biome format --write src test"
|
"format": "bunx biome format --write src test"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { startBot } from './bot';
|
|||||||
import { config } from './env';
|
import { config } from './env';
|
||||||
import { handleFileInfo, handleFileRedirect } from './routes/files';
|
import { handleFileInfo, handleFileRedirect } from './routes/files';
|
||||||
import { handleHealth } from './routes/health';
|
import { handleHealth } from './routes/health';
|
||||||
|
import { handleSwaggerHtml, handleSwaggerJson } from './routes/swagger';
|
||||||
import { handleUpload } from './routes/upload';
|
import { handleUpload } from './routes/upload';
|
||||||
import logger from './utils/logger';
|
import logger from './utils/logger';
|
||||||
import { cleanupRateLimitCache } from './utils/rateLimit';
|
import { cleanupRateLimitCache } from './utils/rateLimit';
|
||||||
@@ -22,6 +23,12 @@ const server = serve({
|
|||||||
'/health': {
|
'/health': {
|
||||||
GET: handleHealth,
|
GET: handleHealth,
|
||||||
},
|
},
|
||||||
|
'/docs': {
|
||||||
|
GET: handleSwaggerHtml,
|
||||||
|
},
|
||||||
|
'/swagger.json': {
|
||||||
|
GET: handleSwaggerJson,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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<Response> => {
|
||||||
|
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<Response> => {
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>TeleUploader API Documentation</title>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui.css">
|
||||||
|
<style>
|
||||||
|
html { box-sizing: border-box; overflow-y: scroll; }
|
||||||
|
*, *::before, *::after { box-sizing: inherit; }
|
||||||
|
body { margin: 0; background: #fafafa; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui-bundle.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui-standalone-preset.js"></script>
|
||||||
|
<script>
|
||||||
|
window.onload = function() {
|
||||||
|
window.ui = SwaggerUIBundle({
|
||||||
|
url: '/swagger.json',
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: true,
|
||||||
|
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
||||||
|
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
|
||||||
|
layout: 'BaseLayout'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
return new Response(html, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'text/html; charset=utf-8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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('<!DOCTYPE html>');
|
||||||
|
expect(html).toContain('swagger-ui');
|
||||||
|
expect(html).toContain('/swagger.json');
|
||||||
|
expect(html).toContain('swagger-ui-bundle.js');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user