File Uploads
A complete worked example showing how to add file upload support to a Grove application. Includes a document module with a
Filefield, API routes, an Unpoly-powered upload form with direct-to-S3 presigned uploads, and a download page.
Project Structure
my-project/
grove.toml
modules/
documents/
module.grove
apps/
main/
layout.html
documents/
page.html
page.jq
[id]/
page.html
page.jq
api/
documents/
route.grove
[id]/
route.grove
Module
modules/documents/module.grove
module documents
record {
kind "doc"
title: String
notes: String = ""
attachment: File {
content_types: ["application/pdf", "image/png", "image/jpeg"]
max_size: 25mb
}
}
event created {
title: String
notes: String = ""
}
@delete
event deleted {}
validate title_not_empty {
params.title != ""
}
@create
action create_document {
title: String
notes: String = ""
validate title_not_empty
apply {
emit created {
title: params.title
notes: params.notes
}
}
}
action delete_document {
apply {
emit deleted {}
}
}
query list_documents {
args {
limit: Int = 50
offset: Int = 0
}
sql {
"SELECT *
FROM documents
ORDER BY created_at DESC
LIMIT @limit
OFFSET @offset"
}
}
API Routes
apps/main/api/documents/route.grove
Collection route -- list and create.
route {
GET {
data records {
query documents.list_documents {
limit: input.query.limit,
offset: input.query.offset
}
}
}
POST {
data result {
invoke documents.create_document {
title: input.body.title,
notes: input.body.notes
}
}
}
}
apps/main/api/documents/[id]/route.grove
Record route -- fetch and delete.
route {
GET {
data result {
fetch documents { id: input.params.id }
}
}
POST delete_document {
data result {
invoke documents.delete_document { id: input.params.id }
}
}
}
Layout
apps/main/layout.html
Reuse the standard layout shell from the Full-Stack Todo recipe, or any layout.html that includes Unpoly v3 from CDN.
Pages
apps/main/documents/page.jq
{
"records": .data.records
}
apps/main/documents/page.html
The upload form gets a presigned URL from /_file/presign-put, uploads the file directly to S3 via PUT, then creates the record through the API.
---
title: Documents
---
<h1>Documents</h1>
<section id="new-document">
<h2>Upload Document</h2>
<form id="upload-form">
<div class="form-row">
<label>
Title
<input type="text" name="title" required>
</label>
<label>
File
<input type="file" name="attachment"
accept=".pdf,.png,.jpg,.jpeg" required>
</label>
</div>
<div class="form-row">
<label style="flex:1">
Notes
<textarea name="notes" rows="2" style="width:100%"></textarea>
</label>
</div>
<div class="form-row">
<button type="submit">Upload</button>
</div>
</form>
</section>
<section id="document-list">
<table>
<thead>
<tr>
<th>Title</th>
<th>Notes</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{ "{{ .data.records[] | \"<tr><td><a href=\\\"/documents/\" + .id + \"\\\" up-follow>\" + .title + \"</a></td><td>\" + .notes + \"</td><td>\" + .created_at + \"</td></tr>\" }}" }}
</tbody>
</table>
</section>
<script>
document.getElementById("upload-form").addEventListener("submit", async (e) => {
e.preventDefault();
const form = e.target;
const title = form.title.value;
const notes = form.notes.value;
const file = form.attachment.files[0];
// Step 1: Get a presigned PUT URL
const presign = await fetch("/_file/presign-put", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
module: "documents",
field_name: "attachment",
filename: file.name,
content_type: file.type
})
});
const { url, key } = await presign.json();
// Step 2: Upload directly to S3
await fetch(url, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file
});
// Step 3: Create the record via the API
await fetch("/api/documents", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title,
notes: notes,
attachment: key
})
});
// Step 4: Refresh the document list
form.reset();
up.render("#document-list", { url: "/documents" });
});
</script>
apps/main/documents/[id]/page.jq
{
"doc": .data.result
}
apps/main/documents/[id]/page.html
The detail page fetches a presigned GET URL and opens it in a new tab for download.
---
title: Document Detail
---
<a href="/documents" up-follow>← Back to list</a>
<h1>{{ "{{ .data.doc.title }}" }}</h1>
<dl class="detail-grid">
<dt>Notes</dt>
<dd>{{ "{{ if .data.doc.notes then .data.doc.notes else \"-\" end }}" }}</dd>
<dt>Attachment</dt>
<dd>
{{ "{{ if .data.doc.attachment then \"<button id=\\\"download-btn\\\" class=\\\"secondary\\\" data-key=\\\"\" + .data.doc.attachment + \"\\\">Download</button>\" else \"<em>No file attached</em>\" end }}" }}
</dd>
<dt>Created</dt>
<dd>{{ "{{ .data.doc.created_at }}" }}</dd>
</dl>
<div class="actions">
<form action="/api/documents/{{ "{{ .data.doc.id }}" }}?handler=delete_document" method="POST"
up-submit up-target="main"
up-confirm="Delete this document?">
<button type="submit" class="danger">Delete</button>
</form>
</div>
<script>
const btn = document.getElementById("download-btn");
if (btn) {
btn.addEventListener("click", async () => {
const key = btn.dataset.key;
const resp = await fetch("/_file/presign-get", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key })
});
const { url } = await resp.json();
window.open(url, "_blank");
});
}
</script>
Running It
manzano dev
Open main.localhost:3000/documents. Upload a PDF or image, then click the title to view the detail page and download the file.
See Also
- File Uploads (Web Layer) -- Presigned URL endpoints, multipart upload, PII encryption
- Field Classifications -- How
@piiannotations encrypt file uploads - CRUD with State Machines -- Action-event CRUD patterns
- Full-Stack Todo App -- Complete app recipe with Unpoly UI