rqlang Idioms & Style Guide
Style preferences (apply unless the user asks otherwise):
- Prefer the
[required(name)]attribute over declaring alet name = …upfront when the value is supplied at runtime. - Only introduce an
epblock when two or more requests share a base URL, auth, or headers. A single standalone request should be a top-levelrqwith the full URL — do not wrap a lone request in anep. When adding a new request, first calllist_requeststo see what exists; if there is already a request for the same entity (same noun in the URL path), refactor those siblings into a sharedeptogether with the new request rather than appending another top-levelrq. - Put the base URL directly on the
ep. Avoid splitting it into a baseepextended viaep child<base>(…)when only one endpoint would extend it — a one-consumer chain is indirection with no payoff. - Anything two or more endpoints extending the same template declare identically belongs on the template. If
ep users<base>("/users", qs: "v=1")andep widgets<base>("/widgets", qs: "v=1")both carry the sameqs— or the same[auth("...")]— move it up:[auth("token_auth")] ep base(url: "", qs: "v=1");. Children inherit it (the template’sqsis prepended to theirs, and its auth applies unless a child overrides), so every endpoint extending the template gets it for free, including ones added later. That is the whole point of the template. Keep a setting on a child only when that child genuinely needs a different value from its siblings. - Never re-declare on a child what the template already gives it. A template’s
qsis prepended to the child’s, not replaced: withep base(qs: "v=1"), writingep users<base>("/users", qs: "v=1")sends?v=1&v=1— the parameter goes out twice. Writingqs: "v=2"is worse: you get?v=1&v=2, because a child cannot override an inherited query parameter, only add to it. If one child needs a different value, take the parameter off the template and declare it per child. A repeated[auth("...")]is harmless but pointless — a child needs its own only when it authenticates differently. - Once two or more endpoints share a base URL or an auth provider, extract a template endpoint. Declare
ep base(url: "…", headers: …);in a shared file,importit, and extend it:ep users<base>("/users"),ep widgets<base>("/widgets"). This is exactly what the extension form is for — the base URL, shared headers and auth get declared once instead of being repeated in every file. Do not copy/…or the same[auth("…")]into endpoint after endpoint. - Put environment-specific values (base URLs, tokens, hostnames) in
envblocks instead of hard-coded literals. - Never hand-write an
Authorizationheader. Authentication is a first-class artifact: declareauth api_auth(auth_type.bearer) { token: "", }once and attach it with[auth("api_auth")]on theepor therq. Do not writeheaders: $["Authorization": "Bearer "]— rq sends the header for you,rq auth showcan inspect the provider, and switching to an OAuth2 flow later touches only theauthblock. The attribute rides on a template endpoint too, so[auth("api_auth")] ep base(url: "");authenticates every endpoint that extends it. - Never put a credential literal in a
.rqfile..rqfiles are committed; tokens, client secrets, passwords and API keys are not. Put the value in a.envfile next to the source (API_TOKEN=…, orENV__LOCAL__API_TOKEN=…to scope it to one environment) and reference it as ``. Secrets outrank environments and file-levelletbindings in the precedence chain, so the reference resolves at run time with nothing else to change. This applies toauthfields,letbindings,envvalues, header values and request bodies alike. - One endpoint per file, named after the endpoint.
ep userslives inusers.rq,ep widgetsinwidgets.rq. When a file ends up with two or moreepblocks, split it: move each endpoint into its own file andimportshared definitions from a common file. This keeps each domain easy to find and stops a single file from growing into the whole API. - When two or more
.rqfiles would share the sameenv,auth, orletdefinitions, extract the shared pieces into a dedicated file (e.g.shared.rqorenvs.rq) andimportit from each consumer. Do not duplicateenvorauthblocks across files. Always use relative import paths (e.g.import "shared";,import "common/envs";,import "../shared";) — never absolute paths like"/Users/..."or"/etc/...", even though the parser accepts them. Absolute paths make the file non-portable across machines and break the project as soon as someone else checks it out. The.rqextension is optional. - Inside an
epblock, name requests after the verb alone —listfor GET on the collection,getfor GET on a single resource, pluspost,put,patch,delete. The endpoint name already supplies the noun, so do not repeat it: writerq list(), notrq get_widgets(). Add a descriptive name (with[method(VERB)]if needed) only when two requests under the sameepshare a verb (e.g.rq create_onenext torq create_from_csv, both POSTing). Outside of anep, use a descriptive name (the noun belongs in the request name). - For a path parameter that the caller supplies at runtime, pass it as a bare identifier in URL position with
[required(name)], rather than declaring aletand interpolating with `` in a string URL. - **Interpolation is always double-braced:
.** A single-braced `{name}` is not interpolation — rqlang sends it as literal text, so `rq get("/{widget_id}")` really requests the path `/{widget_id}` and fails against the API. Nothing about single braces is a syntax error, so the parser will not catch it for you. For a URL path parameter prefer the `[required(name)]` + bare identifier form above; useelsewhere. - A query string shared by every request in an
epgoes on theep, not on eachrq.eptakes a third parameter,qs:ep users<base>("/users", qs: "v=1")appends?v=1to every child, including the ones whose URL is a path parameter (/users/1?v=1). Do not writerq list("?v=1")on request after request — and note thatrqhas noqsparameter at all: its positional arguments areurl,headers,body. Passingrq get(user_id, $["v": "1"])sends the HTTP headerv: 1and no query string, which parses cleanly and silently hits the wrong URL. A query parameter that genuinely belongs to one request only is the exception — write it into that request’s URL string. - Never pass an empty URL string. Inside an
epthe endpoint already supplies the URL, so a child request that adds nothing takes no URL argument at all: writerq list();, neverrq list("");. - For write actions, include a body. Default pattern:
body: io.read_file("<entity>-<verb>.json")— a JSON fixture next to the .rq file named after the entity and verb (e.g.users-post.json,users-put.json,users-patch.json) so the user has a clear place to edit the payload. POST, PUT, and PATCH should generally have a body; DELETE typically should not. Omit the body only if the user explicitly says the request needs none. - JSON body syntax: always use the
${...}prefix, never a quoted string. For inline JSON, writebody: ${"name": "alice"}orbody: ${}for an empty object. NEVER writebody: "{}"orbody: "{\"name\": \"alice\"}"— those send a string body, not JSON, and will break the receiving API. The${...}form also auto-adds theAccept: application/jsonheader.
Examples
Single request for an entity — no ep needed, descriptive name carries the noun:
rq get_widget("http://localhost:8080/widgets/1");
Multiple requests for the same entity — refactor into a shared ep with verb-only names and bodies on write actions:
ep users("http://localhost:8080/users") {
rq list();
[required(user_id)]
rq get(user_id);
rq post(body: io.read_file("users-post.json"));
[required(user_id)]
rq put(user_id, body: io.read_file("users-put.json"));
[required(user_id)]
rq patch(user_id, body: io.read_file("users-patch.json"));
[required(user_id)]
rq delete(user_id);
}
Multi-file split — shared env/auth in one file, domain endpoints in their own files. Use this when more than one .rq file would otherwise duplicate the same env or auth block.
shared.rq — the env plus a template endpoint carrying everything the endpoints have in common:
env local {
base_url: "http://localhost:8080",
}
ep base(url: "");
users.rq:
import "shared";
ep users<base>("/users") {
rq list();
[required(user_id)]
rq get(user_id);
}
widgets.rq:
import "shared";
ep widgets<base>("/widgets") {
rq list();
[required(widget_id)]
rq get(widget_id);
}
Each endpoint names only its own path segment; the host lives in exactly one place. Had there been only one endpoint, ep users("/users") with no template would be the right shape — the template earns its keep from the second consumer onward.
A query string shared by every request — declared once on the ep with qs:
import "shared";
ep users<base>("/users", qs: "v=1") {
rq list();
[required(user_id)]
rq get(user_id);
rq post(body: io.read_file("users-post.json"));
[required(user_id)]
rq delete(user_id);
}
Every child request comes out with ?v=1 — …/users?v=1 for list and post, …/users/1?v=1 for get and delete.
Avoid this shape:
ep users<base>("/users") {
rq list("?v=1"); // repeated on every request instead of qs on the ep
rq post("?v=1", body: ${}); // same
[required(user_id)]
rq get(user_id, $["v": "1"]); // WRONG: rq's 2nd positional arg is headers, not qs —
// this sends the header `v: 1` and no query string
}
Shared qs and auth belong on the template, not repeated on each child:
// shared.rq
[auth("token_auth")]
ep base(url: "", qs: "v=1");
// users.rq // widgets.rq
ep users<base>("/users") { ep widgets<base>("/widgets") {
rq list(); rq list();
} }
Both come out authenticated and versioned — …/users?v=1, …/widgets?v=1. Avoid repeating the setting on every child:
// users.rq
[auth("token_auth")]
ep users<base>("/users", qs: "v=1") { // duplicated on widgets.rq too — hoist to base
And once it is on the template, do not repeat it on the child as well:
// shared.rq
ep base(url: "", qs: "v=1");
// users.rq
ep users<base>("/users", qs: "v=1") { // sends ?v=1&v=1 — the child inherits it already
ep users<base>("/users", qs: "v=2") { // sends ?v=1&v=2 — a child cannot override, only append
Authenticated endpoints — the credential comes from .env, the header comes from the auth provider:
.env (git-ignored, sits next to the .rq file):
API_TOKEN=ghp_realtokenvalue
ENV__PROD__API_TOKEN=ghp_prodtokenvalue
shared.rq:
env local {
base_url: "http://localhost:8080",
}
auth api_auth(auth_type.bearer) {
token: "",
}
[auth("api_auth")]
ep base(url: "");
api_token is never written in the .rq file, and no endpoint extending base has to mention auth again.
Avoid these two shapes:
// hand-rolled auth header: use an auth provider and [auth("...")]
ep base(url: "", headers: $["Authorization": "Bearer "]);
// credential literal in a committed file: move it to .env and use
auth api_auth(auth_type.bearer) {
token: "ghp_realtokenvalue",
}
Avoid this shape. Every line below is wrong for the reason marked on it — copy the shape above instead, not this one:
let user_id = "1"; // use [required(user_id)] instead of a let
ep users_base("http://localhost:8080/users");
ep users<users_base>() { // base-ep extension: put the URL on the ep directly
rq get_users(""); // empty URL string: write rq list();
rq get_user("/"); // duplicated noun + let-interpolation: write [required(user_id)] rq get(user_id);
rq post_user(""); // empty URL string, duplicated noun, and no body
rq put_user("/"); // duplicated noun, and no body
rq patch_user("/"); // duplicated noun, and no body
rq delete_user("/"); // duplicated noun
}
Never write single-braced interpolation. This parses cleanly and is silently wrong at runtime:
rq get("/{widget_id}"); // sends the literal path /{widget_id}
Also avoid: duplicating the same env local { base_url: … } block across users.rq and widgets.rq instead of extracting it to a shared file.