File Uploads

A complete worked example showing how to add file upload support to a Grove application. Includes a document module with a File field, 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>&larr; 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