Docly Developers
Knowledge base Misconception By design

Reading query and form fields in API calls

In shortDocly API endpoints (#/API/*.js) do not receive an Express-style request object — input arrives via auto-bound function parameters and the globals query (URL query string) and form (request body, JSON parsed for you).
Applies to APIJavaScript
  1. What you'll see

    A developer — or more often an AI code-generation tool — writes an API endpoint as if it were running under Express or Node.js:

    // #/API/hello.js — WRONG, all of it
    export default function (req, res) {
        var name = req.query.name;        // TypeError — req is null
        var body = JSON.parse(request.body); // request has no body property
        res.json({ message: "Hello " + name }); // there is no res — return the value instead
    }

    None of this works. There is no req/res pair in Docly. A parameter named req is looked up as an input field called "req" — which doesn't exist — so it is null, and the first property access throws. The global request object exists, but it holds request metadata (URL, Jwt, file/folder info) — not the query string and not the body.

    A quieter version of the same surprise: the parameters bind correctly, the endpoint works in testing, and then it throws for one particular user.

    // POST body: tags=news   (one checkbox ticked)
    export default (tags) => {
        return tags.map(t => t.toUpperCase());   // throws — a string has no map()
    };

    With two boxes ticked the same code works, because tags is then an array. With one ticked it is a string. See below.

  2. What's actually happening

    When a request hits an #/API/ endpoint, Docly prepares the input before your function runs:

    • query (global) — every URL query-string parameter as a key/value map. All values are strings, always. ?page=2 gives query.page === "2", so parse numbers yourself.
    • form (global) — the request body:
      • For form posts (application/x-www-form-urlencoded or multipart/form-data), a field sent once is a string, and a field sent more than once is an array of strings — the same types a JSON body gives. An empty field is "", which is falsy, so if (!title) catches it.
      • A field may legitimately appear more than once — a=1&a=2, and HTML forms do it routinely with checkboxes and multi-selects. So the type of such a field depends on what the user selected: one ticked box gives a string, two give an array, and none leaves the field out entirely. Normalise it when you need a list (see below).
      • For JSON posts (Content-Type: application/json), Docly deserializes the entire body into form. Values keep their JSON types — nested objects, arrays, numbers and booleans come through as-is.
    • Auto-bound parameters — if the file exports a default (or anonymous) function, each parameter is bound by name from the input: form is checked first, then query. export default function (email, page) { ... } receives form.email/query.email and form.page/query.page automatically — whichever is present.

    The same endpoint serves both GET and POST: on a GET there is simply no body, so form is empty and everything arrives in query.

    Two details worth knowing:

    • If both form and query contain the same key, form wins in parameter binding.
    • A JSON body must be an object at the top level ({ ... }). Posting a bare array or scalar leaves form null — wrap the payload: { "items": [...] }.
  3. What to do

    Pick the simplest mechanism that fits, and never parse the body yourself.

    Do — named parameters for simple endpoints (works for both GET and POST):

    // #/API/greet.js
    // GET  /API/greet?name=Ada           → name = "Ada" (from query)
    // POST /API/greet  {"name": "Ada"}    → name = "Ada" (from JSON body)
    export default function (name) {
        return { message: "Hello, " + (name || "stranger") + "!" };
    }

    Do — the query global for URL parameters (remember: values are strings):

    // #/API/articles.js — GET /API/articles?page=2&tag=news
    export default function () {
        var page = parseInt(query.page || "1");
        var tag = query.tag || null;
        return docly.getFiles("~/articles")
            .filter(a => !tag || a.Tag == tag)
            .slice((page - 1) * 20, page * 20);
    }

    Do — the form global for structured JSON bodies:

    // #/API/orders.js
    // POST /API/orders with Content-Type: application/json
    // body: { "customer": { "name": "Ada", "email": "ada@example.com" }, "lines": [ ... ] }
    export default function () {
        var customer = form.customer;     // nested object, types preserved
        var lines = form.lines || [];     // array comes through as an array
        if (!customer || !isEmail(customer.email)) throw "Invalid customer";
        return docly.saveFile("~/orders/" + guid() + ".json", { Content: JSON.stringify(form) });
    }

    Do — call it from the browser with ~/ paths:

    fetch('~/API/orders', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ customer: { name: 'Ada', email: 'ada@example.com' }, lines: [] })
    }).then(r => r.json()).then(result => console.log(result));

    Do — read a form field, and normalise the ones that can repeat:

    // POST application/x-www-form-urlencoded
    // title=Hello&tags=news&tags=sport
    export default function (title, tags) {
        if (!title) throw "Title is required";     // one value: a string, "" when left empty
        title = title.trim();
    
        // A checkbox group: one ticked box is a string, two are an array, none is no field at all.
        var list = Array.isArray(tags) ? Array.from(tags) : (tags ? [tags] : []);
        return { title: title, tags: list, count: list.length };
    }

    Turn a list into text yourself with list.join(", "). Writing "Tags: " + tags gives Tags: ["news","sport"] when there are two values.

    Older code. Earlier Docly versions passed every form field as a .NET StringValues collection, and this article used to recommend Array.from(form.title)[0] and form.title.Count. On a single value both now give the wrong answer without an error: Array.from splits a string into characters ("H"), and .Count is the length of the text. Replace them: String(form.title) for one value, and Array.isArray(v) ? Array.from(v) : [v] for a list. Both work with the old and the new behaviour.

    Don't:

    export default function (req) { ... }      // no Express-style request argument exists
    var body = JSON.parse(request.body);       // request has no body — JSON is already in form
    var name = request.query.name;             // request has no query — use the query global
    form.tags.map(...);                        // throws when only one box is ticked — normalise first
    Array.from(form.title)[0];                 // "H" — Array.from splits a string into characters
    form.title.Count;                          // the length of the text, not the number of values

    File uploads are the one exception: files posted as multipart/form-data do not appear in form. Use docly.getUploads() to list them and docly.getUploadBase64(fieldName) to read one. Ordinary text fields in the same multipart post still arrive in form.

    query and form exist only in #/API/ scripts. Hash pages (.hash) are rendered once and cached, so they cannot read per-request input at all — route dynamic input through an API endpoint instead.

Last reviewed 23 Oct 2026