Dynamic segments

[id] captures one segment, [...path] captures the rest.

Updated 2 min read

Two forms of dynamic segment exist, and both are read through the request context rather than through a positional argument list.

[name] — exactly one segment

// routes/posts/[id].chpl matching /posts/42
const id = ctx.pathParam("id");          // "42"
const numeric = ctx.paramInt("id", 0);   // 42, or 0 if unparsable

A [name] segment matches exactly one non-empty path segment. It never matches across a /.

[...name] — one or more segments

A catch-all captures the remainder of the path as a single string with the separators intact, and must be the last segment of the pattern.

// routes/docs/[...path].chpl matching /docs/guide/routing
const slug = ctx.pathParam("path");      // "guide/routing"

A catch-all does not match its own parent: /docs/[...path] does not answer /docs. Add routes/docs/index.chpl if that path needs an answer — which is exactly how this wiki redirects /docs to the introduction.

Validate what you capture

The runtime percent-decodes and normalises the request target before routing: . and .. are resolved against a virtual root, any residual escape is a 400, and control bytes, spaces and NUL in a decoded segment are rejected. Normalisation runs on the decoded path, so %2e%2e%2f cannot slip past a check for ../.

That is one guard, not a licence to skip your own. A captured parameter is still request data, and anything you do with it — a file lookup, a map key, a redirect target — deserves its own check:

proc page(ctx: Context, ref meta: PageMeta): string {
  const slug = ctx.pathParam("path");
  if !isSafeSlugPath(slug) then return notFound(meta);

  var doc: Document;
  if !ContentStore.find("/docs/" + slug, doc) then return notFound(meta);
  // ...
}

Resolving a parameter against an in-memory registry rather than against the filesystem removes the class of bug entirely: a map lookup cannot escape a directory.

Both forms together

routes/posts/new.chpl        ->  /posts/new
routes/posts/[id].chpl       ->  /posts/[id]
routes/posts/[...rest].chpl  ->  /posts/[...rest]

A request for /posts/new matches the literal route, not the parameter — see match order.