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:
Rotimi Best
2025-09-21 19:35:41 +01:00
committed by GitHub
parent a6880f1b54
commit 6d68351a46
102 changed files with 2938 additions and 1633 deletions
+50
View File
@@ -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);
+39
View File
@@ -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
);
}
});
+24
View File
@@ -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();
}
})
);
}
);
+253
View File
@@ -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'
});
}
);
+48
View File
@@ -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 });
}
);