Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add file upload docs #1380

Merged
merged 11 commits into from
Sep 10, 2024
5 changes: 3 additions & 2 deletions docs/develop/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,12 @@ func AuthHandler(ctx context.Context, token string) (auth.UID, error) {

Note that for security reasons you may not want to reveal too much information about why a request did not pass your auth checks. There are many subtle security considerations when dealing with authentication and we don't have time to go into all of them here.

Whenever possible we recommend using a third-party auth provider.<br/>
See the guides for using [Firebase Authentication](/docs/how-to/firebase-auth) or [Auth0](/docs/how-to/auth0-auth) for examples of how to do that.
Whenever possible we recommend using a third-party auth provider instead of rolling your own authentication.

</Callout>

<RelatedDocsLink paths={["/docs/how-to/auth0-auth", "/docs/how-to/clerk-auth", "/docs/how-to/firebase-auth"]} />

## Using auth data

Once the user has been identified by the auth handler, the API handler is called
Expand Down
51 changes: 5 additions & 46 deletions docs/how-to/express-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -630,9 +630,7 @@ export const brokenWithErrorCode = api(
Express.js has a built-in middleware function to serve static files. You can use the `express.static` function to serve
files from a specific directory.

With Encore.ts you can use the `api.raw` function to serve static files, no third party library is needed. Use the
Node.js `fs` package to read the file from the file system and send it as a response.
Learn more in our [Raw Endpoints docs](/docs/ts/primitives/raw-endpoints)
Encore.ts also has built-in support for static file serving with the `api.static` method. The files are served directly from the Encore.ts Rust Runtime. This means that zero JavaScript code is executed to serve the files, freeing up the Node.js runtime to focus on executing business logic. This dramatically speeds up both the static file serving, as well as improving the latency of your API endpoints. Learn more in our [Static Assets docs](/docs/ts/primitives/static-assets).

**Express.js**

Expand All @@ -641,55 +639,16 @@ import express from "express";

const app: Express = express();

app.use("/assets", express.static("assets")); // Serve static files from the assets directory
app.use("/assets", express.static("assets"));
```

**Encore.ts**

```typescript
import {api} from "encore.dev/api";
import path from "node:path";
import fs from "node:fs";
import {APICallMeta, currentRequest} from "encore.dev";

export const serveAssets = api.raw(
{expose: true, path: "/assets/!rest", method: "GET"},
async (_, resp) => {
const meta = currentRequest() as APICallMeta;

// extract URL path
const fsPath = meta.pathParams.rest;
// based on the URL path, extract the file extension. e.g. .js, .doc, ...
const ext = path.parse(fsPath).ext;
// maps file extension to MIME typere
const map: Record<string, string> = {
".html": "text/html",
".js": "text/javascript",
".css": "text/css",
// Add the MIME types you need or use a library like mime
};

fs.stat(fsPath, (err) => {
if (err) {
// if the file is not found, return 404
resp.statusCode = 404;
resp.end(`File ${fsPath} not found!`);
return;
}
import { api } from "encore.dev/api";

// read file from file system
fs.readFile(fsPath, (err, data) => {
if (err) {
resp.statusCode = 500;
resp.end(`Error getting the file: ${err}.`);
} else {
// if the file is found, set Content-type and send data
resp.setHeader("Content-type", map[ext]);
resp.end(data);
}
});
});
},
export const assets = api.static(
{ expose: true, path: "/assets/*path", dir: "./assets" },
);
```

Expand Down
221 changes: 221 additions & 0 deletions docs/how-to/file-uploads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
---
seotitle: How to handle file uploads in you Encore.ts application
seodesc: Learn how to store file uploads as bytes in a database and serving them back to the client.
title: Handling file uploads
lang: ts
---

In this guide you will learn how to handle file uploads from a client in your Encore.ts backend.

<GitHubLink
href="https://github.com/encoredev/examples/tree/main/ts/file-upload"
desc="Handling file uploads and storing file data in a database"
/>

## Storing a single file in a database

Breakdown of the example:
* We have a [PostgreSQL database](/docs/ts/primitives/databases) table named `files` with columns `name` and `data` to store the file name and the file data.
* We have a [Raw Endpoint](/docs/ts/primitives/raw-endpoints) to handle file uploads. The endpoint has a `bodyLimit` set to `null` to allow for unlimited file size.
* We make use of the [busboy](https://www.npmjs.com/package/busboy) library to help with the file handling.
* We convert the file data to a `Buffer` and store the file as a `BYTEA` in the database.

```ts
-- upload.ts --
import { api } from "encore.dev/api";
import log from "encore.dev/log";
import busboy from "busboy";
import { SQLDatabase } from "encore.dev/storage/sqldb";

// Define a database named 'files', using the database migrations
// in the "./migrations" folder. Encore automatically provisions,
// migrates, and connects to the database.
export const DB = new SQLDatabase("files", {
migrations: "./migrations",
});

type FileEntry = { data: any[]; filename: string };

/**
* Raw endpoint for storing a single file to the database.
* Setting bodyLimit to null allows for unlimited file size.
*/
export const save = api.raw(
{ expose: true, method: "POST", path: "/upload", bodyLimit: null },
async (req, res) => {
const bb = busboy({
headers: req.headers,
limits: { files: 1 },
});
const entry: FileEntry = { filename: "", data: [] };

bb.on("file", (_, file, info) => {
entry.filename = info.filename;
file
.on("data", (data) => {
entry.data.push(data);
})
.on("close", () => {
log.info(`File ${entry.filename} uploaded`);
})
.on("error", (err) => {
bb.emit("error", err);
});
});

bb.on("close", async () => {
simon-johansson marked this conversation as resolved.
Show resolved Hide resolved
try {
const buf = Buffer.concat(entry.data);
await DB.exec`
INSERT INTO files (name, data)
VALUES (${entry.filename}, ${buf})
ON CONFLICT (name) DO UPDATE
SET data = ${buf}
`;
log.info(`File ${entry.filename} saved`);

// Redirect to the root page
res.writeHead(303, { Connection: "close", Location: "/" });
res.end();
} catch (err) {
bb.emit("error", err);
}
});

bb.on("error", async (err) => {
res.writeHead(500, { Connection: "close" });
res.end(`Error: ${(err as Error).message}`);
});

req.pipe(bb);
return;
},
);
-- migrations/1_create_tables.up.sql --
CREATE TABLE files (
name TEXT PRIMARY KEY,
data BYTEA NOT NULL
);
```

### Frontend

```html
<form method="POST" enctype="multipart/form-data" action="/upload">
<label for="filefield">Single file upload:</label><br>
<input type="file" name="filefield">
<input type="submit">
</form>
```

## Handling multiple file uploads

When handling multiple file uploads, we can use the same approach as above, but we need to handle multiple files in the busboy event listeners. When storing the files in the database, we loop through the files and save them one by one.

```ts
export const saveMultiple = api.raw(
{ expose: true, method: "POST", path: "/upload-multiple", bodyLimit: null },
async (req, res) => {
const bb = busboy({ headers: req.headers });
const entries: FileEntry[] = [];

bb.on("file", (_, file, info) => {
const entry: FileEntry = { filename: info.filename, data: [] };

file
.on("data", (data) => {
entry.data.push(data);
})
.on("close", () => {
entries.push(entry);
})
.on("error", (err) => {
bb.emit("error", err);
});
});

bb.on("close", async () => {
try {
for (const entry of entries) {
const buf = Buffer.concat(entry.data);
await DB.exec`
INSERT INTO files (name, data)
VALUES (${entry.filename}, ${buf})
ON CONFLICT (name) DO UPDATE
SET data = ${buf}
`;
log.info(`File ${entry.filename} saved`);
}

// Redirect to the root page
res.writeHead(303, { Connection: "close", Location: "/" });
res.end();
} catch (err) {
bb.emit("error", err);
}
});

bb.on("error", async (err) => {
res.writeHead(500, { Connection: "close" });
res.end(`Error: ${(err as Error).message}`);
});

req.pipe(bb);
return;
},
);
```

### Frontend

```html
<form method="POST" enctype="multipart/form-data" action="/upload-multiple">
<label for="filefield">Multiple files upload:</label><br>
<input type="file" name="filefield" multiple>
<input type="submit">
</form>
```

## Handling large files

In order to not run into a **Maximum request length exceeded**-error when uploading large files you might need to adjust the endpoints `bodyLimit`. You can also set the `bodyLimit` to `null` to allow for unlimited file size uploads. If unset it defaults to 2MiB.

## Retrieving files from the database

When retrieving files from the database, we can use a GET endpoint to fetch the file data by its name. We can then serve the file back to the client by creating a `Buffer` from the file data and sending it in the response.

```ts
import { api } from "encore.dev/api";
import { APICallMeta, currentRequest } from "encore.dev";

export const DB = new SQLDatabase("files", {
migrations: "./migrations",
});

export const get = api.raw(
{ expose: true, method: "GET", path: "/files/:name" },
async (req, resp) => {
try {
const { name } = (currentRequest() as APICallMeta).pathParams;
const row = await DB.queryRow`
SELECT data
FROM files
WHERE name = ${name}`;
if (!row) {
resp.writeHead(404);
resp.end("File not found");
return;
}

const chunk = Buffer.from(row.data);
resp.writeHead(200, { Connection: "close" });
resp.end(chunk);
} catch (err) {
resp.writeHead(500);
resp.end((err as Error).message);
}
},
);
```

You should now be able to retrieve a file from the database by making a GET request to `http://localhost:4000/files/name-of-file.ext`.
6 changes: 4 additions & 2 deletions docs/how-to/nestjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ loosely coupled and easily maintainable applications.
Encore is not opinionated when it comes to application architecture, so you can use it together with NestJS to structure
your business logic and Encore for creating backend primitives like APIs, Databases, and Cron Jobs.

Take a look at our **Encore + NestJS example** for
inspiration: https://github.com/encoredev/examples/tree/main/ts/nestjs
<GitHubLink
href="https://github.com/encoredev/examples/tree/main/ts/nestjs"
desc="Encore.ts + NestJS example"
/>

## Adding Encore to a NestJS project

Expand Down
5 changes: 5 additions & 0 deletions docs/menu.cue
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,11 @@
kind: "section"
text: "How to guides"
items: [{
kind: "basic"
text: "Handle file uploads"
path: "/ts/how-to/file-uploads"
file: "how-to/file-uploads"
}, {
kind: "basic"
text: "Use Vercel for frontend hosting"
path: "/ts/how-to/vercel"
Expand Down
Loading