feat: end to end api type safety with redis rate limiting (#573)
* feat: support hono rpc * add wrangler file * update name of wrangler file * add wrangler as a dependency * add compatibility_flags * trigger ci * core: add health check to docker file * expose port 80 * update custom healthcheck * feat: implement openapi * install dependencies in root * debug yml * update yml * update port * update dockerfile with envs * completely move away from apiClient * rename backend to api * refactor: update server descriptions and comment out local spec saving * update lock file * use api.classroomio.com * add support for redis * fix: add missing line break in WelcomeModal.svelte * refactor: remove wrangler configuration and update Vite config for API integration
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { ZCertificateDownload, ZCourseDownloadContent } from '$src/types/course';
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { generateCertificate } from '$src/utils/certificate';
|
||||
import { generateCoursePdf } from '$src/utils/course';
|
||||
import { katexRouter } from '$src/routes/course/katex';
|
||||
import { lessonRouter } from '$src/routes/course/lesson';
|
||||
import { presignRouter } from '$src/routes/course/presign';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const courseRouter = new Hono()
|
||||
.post('/download/certificate', zValidator('json', ZCertificateDownload), async (c) => {
|
||||
const validatedData = c.req.valid('json');
|
||||
|
||||
const result = await generateCertificate(validatedData);
|
||||
|
||||
c.header('Content-Type', 'application/pdf');
|
||||
c.header(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="certificate-${validatedData.courseName}.pdf"`
|
||||
);
|
||||
|
||||
return c.body(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(result);
|
||||
controller.close();
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
.post('/download/content', zValidator('json', ZCourseDownloadContent), async (c) => {
|
||||
const validatedData = c.req.valid('json');
|
||||
|
||||
const pdfBuffer = await generateCoursePdf(validatedData);
|
||||
|
||||
c.header('Content-Type', 'application/pdf');
|
||||
|
||||
return c.body(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(pdfBuffer);
|
||||
controller.close();
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
.route('/katex', katexRouter)
|
||||
.route('/lesson', lessonRouter)
|
||||
.route('/presign', presignRouter);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Context, Hono } from 'hono';
|
||||
|
||||
import katex from 'katex';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const katexRouter = new Hono().get('/', async (c: Context) => {
|
||||
try {
|
||||
const original = c.req.raw.url;
|
||||
|
||||
const latexString = original.split('?')[1].replaceAll('+', '+').replaceAll('&space;', '');
|
||||
|
||||
const html = katex.renderToString(latexString, {
|
||||
output: 'mathml',
|
||||
throwOnError: false
|
||||
});
|
||||
|
||||
return c.html(html);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Validation error',
|
||||
details: error.message
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
console.error('Error rendering LaTeX:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Hono } from 'hono';
|
||||
import { ZLessonDownloadContent } from '$src/types/course/lesson';
|
||||
import { generateLessonPdf } from '$src/utils/lesson';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const lessonRouter = new Hono().post(
|
||||
'/download/pdf',
|
||||
zValidator('json', ZLessonDownloadContent),
|
||||
async (c) => {
|
||||
const validatedData = c.req.valid('json');
|
||||
|
||||
const pdfBuffer = await generateLessonPdf(validatedData);
|
||||
|
||||
c.header('Content-Type', 'application/pdf');
|
||||
return c.body(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(pdfBuffer);
|
||||
controller.close();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,253 @@
|
||||
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import {
|
||||
ZCourseDocumentPresignUrlUpload,
|
||||
ZCourseDownloadPresignedUrl,
|
||||
ZCoursePresignUrlUpload
|
||||
} from '$src/types/course';
|
||||
import { describeRoute, validator } from 'hono-openapi';
|
||||
|
||||
import { BUCKET_NAME } from '$src/constants/upload';
|
||||
import { CLOUDFLARE } from '$src/constants';
|
||||
import type { GetSignedUrlParameters } from '$src/utils/s3';
|
||||
import { Hono } from 'hono';
|
||||
import { authMiddleware } from '$src/middlewares/auth';
|
||||
import { generateFileKey } from '$src/utils/upload';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { s3Client } from '$src/utils/s3';
|
||||
|
||||
// Response schemas for OpenAPI documentation
|
||||
const PresignUploadResponse = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
success: { type: 'boolean' as const },
|
||||
url: { type: 'string' as const },
|
||||
fileKey: { type: 'string' as const },
|
||||
message: { type: 'string' as const }
|
||||
},
|
||||
required: ['success', 'url', 'fileKey', 'message']
|
||||
};
|
||||
|
||||
const PresignDownloadResponse = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
success: { type: 'boolean' as const },
|
||||
urls: {
|
||||
type: 'object' as const,
|
||||
additionalProperties: { type: 'string' as const }
|
||||
},
|
||||
message: { type: 'string' as const }
|
||||
},
|
||||
required: ['success', 'urls', 'message']
|
||||
};
|
||||
|
||||
export const presignRouter = new Hono()
|
||||
.post(
|
||||
'/video/upload',
|
||||
authMiddleware,
|
||||
describeRoute({
|
||||
description: 'Generate a pre-signed URL for video upload',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Pre-signed URL generated successfully',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: PresignUploadResponse
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid request body'
|
||||
},
|
||||
401: {
|
||||
description: 'Unauthorized'
|
||||
}
|
||||
},
|
||||
tags: ['Presign']
|
||||
}),
|
||||
validator('json', ZCoursePresignUrlUpload),
|
||||
async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
|
||||
const { fileName, fileType } = body;
|
||||
const fileKey = generateFileKey(fileName);
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: BUCKET_NAME.VIDEOS,
|
||||
Key: fileKey,
|
||||
ContentType: fileType
|
||||
}) as GetSignedUrlParameters[1];
|
||||
|
||||
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
|
||||
expiresIn: CLOUDFLARE.R2.PRESIGN_EXPIRATION_TIME
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
url: presignedUrl,
|
||||
fileKey,
|
||||
message: 'Pre-signed URL generated successfully'
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/document/upload',
|
||||
authMiddleware,
|
||||
describeRoute({
|
||||
description: 'Generate a pre-signed URL for document upload',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Document pre-signed URL generated successfully',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: PresignUploadResponse
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid request body'
|
||||
},
|
||||
401: {
|
||||
description: 'Unauthorized'
|
||||
}
|
||||
},
|
||||
tags: ['Presign']
|
||||
}),
|
||||
validator('json', ZCourseDocumentPresignUrlUpload),
|
||||
async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
|
||||
const { fileName, fileType } = body;
|
||||
const fileKey = generateFileKey(fileName);
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: BUCKET_NAME.DOCUMENTS,
|
||||
Key: fileKey,
|
||||
ContentType: fileType
|
||||
}) as GetSignedUrlParameters[1];
|
||||
|
||||
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
|
||||
expiresIn: CLOUDFLARE.R2.PRESIGN_EXPIRATION_TIME
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
url: presignedUrl,
|
||||
fileKey,
|
||||
message: 'Document pre-signed URL generated successfully'
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/video/download',
|
||||
authMiddleware,
|
||||
describeRoute({
|
||||
description: 'Generate pre-signed URLs for video download',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Video URLs retrieved successfully',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: PresignDownloadResponse
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid request body'
|
||||
},
|
||||
401: {
|
||||
description: 'Unauthorized'
|
||||
}
|
||||
},
|
||||
tags: ['Presign']
|
||||
}),
|
||||
validator('json', ZCourseDownloadPresignedUrl),
|
||||
async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
|
||||
const { keys } = body;
|
||||
|
||||
const signedUrls: Record<string, string> = {};
|
||||
|
||||
const urlPromises = keys.map(async (key) => {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: BUCKET_NAME.VIDEOS,
|
||||
Key: key
|
||||
}) as GetSignedUrlParameters[1];
|
||||
|
||||
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
|
||||
expiresIn: CLOUDFLARE.R2.DOWNLOAD_EXPIRATION_TIME
|
||||
});
|
||||
|
||||
return { key, presignedUrl };
|
||||
});
|
||||
|
||||
const results = await Promise.all(urlPromises);
|
||||
|
||||
results.forEach(({ key, presignedUrl }) => {
|
||||
signedUrls[key] = presignedUrl;
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
urls: signedUrls,
|
||||
message: 'Video URLs retrieved successfully'
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/document/download',
|
||||
authMiddleware,
|
||||
describeRoute({
|
||||
description: 'Generate pre-signed URLs for document download',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Document URLs retrieved successfully',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: PresignDownloadResponse
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid request body'
|
||||
},
|
||||
401: {
|
||||
description: 'Unauthorized'
|
||||
}
|
||||
},
|
||||
tags: ['Presign']
|
||||
}),
|
||||
validator('json', ZCourseDownloadPresignedUrl),
|
||||
async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
|
||||
const { keys } = body;
|
||||
|
||||
const signedUrls: Record<string, string> = {};
|
||||
|
||||
const urlPromises = keys.map(async (key) => {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: BUCKET_NAME.DOCUMENTS,
|
||||
Key: key
|
||||
}) as GetSignedUrlParameters[1];
|
||||
|
||||
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
|
||||
expiresIn: CLOUDFLARE.R2.DOWNLOAD_EXPIRATION_TIME
|
||||
});
|
||||
|
||||
return { key, presignedUrl };
|
||||
});
|
||||
|
||||
const results = await Promise.all(urlPromises);
|
||||
|
||||
results.forEach(({ key, presignedUrl }) => {
|
||||
signedUrls[key] = presignedUrl;
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
urls: signedUrls,
|
||||
message: 'Document URLs retrieved successfully'
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { sendWithNodemailer, sendWithZoho } from '$src/services/mail';
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { ZSendEmailValidation } from '$src/types/mail';
|
||||
import { env } from '$src/config/env';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const mailRouter = new Hono().post(
|
||||
'/send',
|
||||
zValidator('json', ZSendEmailValidation),
|
||||
async (c) => {
|
||||
const validatedData = c.req.valid('json');
|
||||
|
||||
const results = await Promise.all(
|
||||
validatedData.map(async (emailData) => {
|
||||
try {
|
||||
const res = env.ZOHO_TOKEN
|
||||
? await sendWithZoho(emailData)
|
||||
: await sendWithNodemailer(emailData);
|
||||
|
||||
console.log('Email status:', res);
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
details: error
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const hasErrors = results.some((result) => !result.success);
|
||||
if (hasErrors) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Some emails failed to send',
|
||||
details: results
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({ success: true, details: results });
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user