feat: add Swagger OpenAPI documentation endpoints

This commit is contained in:
MythEclipse
2026-05-18 07:39:27 +07:00
parent aae8ebfbcf
commit 6feca3564a
4 changed files with 318 additions and 1 deletions
+1 -1
View File
@@ -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"
},
+7
View File
@@ -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,
},
},
});
+274
View File
@@ -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',
},
});
};
+36
View File
@@ -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');
});
});