[Short] Bun & Hono: Send and get files using AWS S3 client
I’m working on a project where I need an API that deals with files.
Since Bun is out, every time I need to bootstrap something I use it.
Now dealing with files is something else, a CRUD with files can be different from one API to another.
For my API, I use Bun and Hono.
Why ? Because it is simple, lightweight and fast.
I can’t stand those API where you need 30 files before having something ready anymore, I’m too old for this 💩.
Anyway, here was my requirements :
- Bun and Hono
- CRUD on files (or at least CRD)
- Using a S3 object storage (or compatible S3 object storage)
- Validating inputs
- Using the old
form
friend in HTTP
For testing purpose, I use MinIO in a docker env.
(Here, I won’t talk about user authentication, it would make the code more complex to read.)
Let’s do this !
Our journey to get things done in 5 steps:
- Dependencies
- Folder structure
- Schema validation
- File Service
- Routing
One more thing, it is not as easy as it seems 😅, but not as hard as it could have been.
Dependencies
Using Hono, I will use Zod for input validation and the official amazon S3 client.
bun add zod
for input validationbun add @hono/zod-validator
for easy to read input validationbun add @aws-sdk/client-s3
for communication with S3 like storagebun add uuid
(optional but for setting id to files in S3)- (also
bun add -D @types/uuid
)
Folder structure
I like simple things, in my src
folder, I have an index.ts
and a routes
folder.
In the routes
folder I create a documents
folder to handle the files route.
And in the documents
folder I have:
- a
documents.route.ts
for hono routing - a
documents.schema.ts
for zod validation - a
documents.service.ts
to deal with S3 without adding compexity to the routing file
So it looks like this:
- index.ts
- routes
-- documents
--- documents.route.ts
--- documents.schema.ts
--- documents.service.ts
Let’s begin with input validation
documents.schema.ts
is the easy part, I want to be able to give a file
and a custom name
in my body, so the validation will look like this.
import { z } from 'zod';
const createDocumentSchema = z.object({
name: z.string(),
file: z.instanceof(File),
});
export { createDocumentSchema };
And now let’s speaks with S3
documents.service.ts
is the the hard part, to do so, we have 5 environment variables in our .env
file (auto loaded by Bun).
We will consider that all files are in the same bucket, but you can do whatever you want.
S3_REGION=""
S3_URL=""
S3_ACCESS_KEY_ID=""
S3_ACCESS_KEY_SECRET=""
S3_BUCKET=""
And now the service with our 3 exposed functions createDocument
, getDocument
and deleteDocument
.
If you want to keep a track on what documents are on the S3, use a database.
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { v4 } from 'uuid';
// This is the client we will use to deal with our S3 like service
const s3 = new S3Client({
region: process.env.S3_REGION as string,
forcePathStyle: true,
endpoint: process.env.S3_URL as string,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID as string,
secretAccessKey: process.env.S3_ACCESS_KEY_SECRET as string,
},
});
// This function will upload our document to the storage on a specific bucket
// and returns the id of the file on the S3
// (if you want to keep a track on them)
const createDocument = async (
file: File,
): Promise<string> => {
// Create a specific uuid v4 to assign on the document on the s3 :)
const docId = v4();
const params = {
Body: Buffer.from(await file.arrayBuffer()),
Bucket: process.env.S3_BUCKET as string,
Key: docId,
// Important thing here, add the type or every file will have
// 'application/octet-stream'
ContentType: file.type,
};
// Upload file to S3
const uploadCommand = new PutObjectCommand(params);
const response = await s3.send(uploadCommand);
return docId;
};
// This function will fetch the document data on the S3 given an ID and
// return a byte array (file content) with a content type
// might throw an exception if the file does not exist.
const readDocument = async (
docId: string
): Promise<{
data: Uint8Array;
contentType: string;
}> => {
const readCommand = new GetObjectCommand({
Bucket: process.env.S3_BUCKET as string,
Key: docId,
});
const object = await s3.send(readCommand);
const byteArray = await object.Body?.transformToByteArray();
if (byteArray === undefined) {
throw new Error('File does not exist');
}
return {
data: byteArray,
contentType: object.ContentType ?? 'application/octet-stream',
};
};
// This function will delete the file on S3 given an ID
const deleteDocument = async (docId: string): Promise<void> => {
const deleteCommand = new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET as string,
Key: docId,
});
await s3.send(deleteCommand);
};
export { createDocument, deleteDocument, readDocument };
And to finish, expose our routes
With the routing file documents.route.ts
, is quite simple, also, we add our routes to our file.
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
const documentsRoute = new Hono();
// Create a document
documentsRoute.post(
'/',
zValidator('form', createDocumentSchema),
async => {
const fileData = c.req.valid('form');
try {
const docId = await createDocument(fileData.file);
return c.json({ id: docId }, 201);
} catch (error) {
console.error(error);
return c.json({ error: 'Failed to upload file', details: error }, 500);
}
}
);
// Get a document
documentsRoute.get(
'/:docId',
async => {
const docId = c.req.param('docId');
try {
const object = await readDocument(docId);
// our object contains the data and the content type
const byteArray = await object.data;
if (byteArray === undefined) {
return c.json({ error: 'Failed to read file' }, 500);
}
// Set the content type of the file in header to be understandable
// by clients
c.header('Content-Type', object.contentType);
// Just stream the byte array (with content type it will be interpreted
// in the good way)
return stream(c, async (stream) => {
// Write a process to be executed when aborted.
stream.onAbort(() => {
console.log('Aborted!');
});
await stream.write(byteArray);
});
} catch (error) {
return c.json({ error: 'Failed to read file' }, 500);
}
}
);
documentsRoute.delete(
'/:docId',
async (c) => {
const docId = c.req.param('docId');
try {
await deleteDocument(documentId);
return c.status(204);
} catch (error) {
return c.json({ error: 'Failed to delete file' }, 500);
}
});
export default documentsRoute;
Izipizi lemon squeezy ! You just have to add your documentsRoute
in your main router index.ts
.
import { Hono } from 'hono';
import documentsRoute from './routes/documents/documents.route';
const app = new Hono();
app.route('/documents', documentsRoute);
export default app;
Thank you for reading this post up to this.
If you liked it, please clap or share it, it’s always nice 😘