Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions src/map/inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,20 @@ function requestMemberAccesses(
const out = new Map<string, InputSource>();
const p0 = params?.[0];
const reqName = p0 && ts.isIdentifier(p0.name) ? p0.name.text : undefined;
// Identifiers that ARE a request-input object (destructured `({ body })` param, `await req.json()`).
const sourceNames = new Set<string>();
// Identifiers that ARE a request-input object (destructured `({ body })` param, `await req.json()`),
// mapped to the NAMESPACE each one came from. It has to be a map, not a set of names: with
// `({ query: q })` the local is `q`, and matching the local against the literal 'query'/'params'
// discards the namespace — which silently mis-addresses the input (`post.doc` for a query-string
// field, and worse, a coordinate for a route param, which the resolver cannot address at all).
const sourceNames = new Map<string, InputSource>();
const payloadNames = new Set<string>();
if (opts.payloadParam && p0 && ts.isIdentifier(p0.name)) payloadNames.add(p0.name.text);
if (p0 && !reqName && ts.isObjectBindingPattern(p0.name)) {
for (const el of p0.name.elements) {
const key = bindingKey(el, ts);
if (key && REQ_SOURCES.includes(key) && ts.isIdentifier(el.name)) sourceNames.add(el.name.text);
if (key && REQ_SOURCES.includes(key) && ts.isIdentifier(el.name)) {
sourceNames.set(el.name.text, namespaceSource(key));
}
}
}
const unwrap = (e: any): any => {
Expand All @@ -156,7 +162,7 @@ function requestMemberAccesses(
if (ts.isVariableDeclaration(n) && n.initializer) {
const init = unwrap(n.initializer);
// const b = await request.json() → b is a request-input object from here on.
if (ts.isIdentifier(n.name) && isBodyReadCall(n.initializer)) sourceNames.add(n.name.text);
if (ts.isIdentifier(n.name) && isBodyReadCall(n.initializer)) sourceNames.set(n.name.text, bodyReadSource(n.initializer));
// const { a, b } = <source> | await request.json()
if (ts.isObjectBindingPattern(n.name) && (isReqSourceExpr(init) || isBodyReadCall(n.initializer))) {
const src = isBodyReadCall(n.initializer) ? bodyReadSource(n.initializer) : sourceOfExpr(init);
Expand All @@ -180,13 +186,20 @@ function requestMemberAccesses(
if (e.name.text === 'params') return 'route-param';
if (e.name.text === 'body') return 'body';
}
// The recorded namespace, so an ALIAS resolves correctly (`({ query: q }) => q.id` → query).
if (ts.isIdentifier(e)) {
const key = [...sourceNames].includes(e.text) ? e.text : undefined;
if (key === 'query') return 'query';
if (key === 'params') return 'route-param';
const recorded = sourceNames.get(e.text);
if (recorded) return recorded;
}
return 'body';
}

/** Map a request namespace key to the input source it implies. */
function namespaceSource(key: string): InputSource {
if (key === 'query') return 'query';
if (key === 'params') return 'route-param';
return 'body';
}
function bodyReadSource(init: any): InputSource {
const t = init?.getText?.() ?? '';
return /formData\s*\(/.test(t) ? 'form-body' : 'json-body';
Expand Down
73 changes: 73 additions & 0 deletions tests/map/aliased-namespace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildInputMap } from '../../src/map/index.js';

// The request NAMESPACE decides the runtime coordinate, so it has to survive renamed destructuring.
// `({ query: q })` binds the local `q`; matching that local against the literal 'query' discarded the
// namespace, which mis-addressed the input two ways:
// - a query-string field got `post.doc` → a rule that can never match
// - an aliased ROUTE PARAM got `post.id` → a coordinate for something the resolver cannot address,
// defeating the whole point of returning null for route params.
let dir: string;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'ps-alias-'));
mkdirSync(join(dir, 'src'), { recursive: true });
writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { express: '4' } }));
writeFileSync(join(dir, 'src', 'app.ts'), `
import express from "express";
import fs from "node:fs";
const app = express();
app.get("/plain", ({ query }, res) => { res.end(fs.readFileSync(query.doc)); });
app.get("/renamed", ({ query: q }, res) => { res.end(fs.readFileSync(q.doc)); });
app.get("/param/:id", ({ params: p }, res) => { res.end(fs.readFileSync(p.id)); });
app.post("/bodyalias", ({ body: b }, res) => { res.end(fs.readFileSync(b.file)); });
app.post("/nested", ({ query: q }, res) => { const { doc } = q; res.end(fs.readFileSync(doc)); });
`);
});
afterAll(() => rmSync(dir, { recursive: true, force: true }));

const input = async (route: string, name: string) => {
const { map } = await buildInputMap(dir);
const ep = map!.endpoints.find((e) => e.route === route)!;
return { ep, field: ep.inputs.find((i) => i.name === name)! };
};

describe('aliased request namespaces', () => {
it('keeps the namespace when the handler param is destructured plainly (control)', async () => {
const { field } = await input('/plain', 'doc');
expect(field).toMatchObject({ source: 'query', runtimeParameter: 'get.doc' });
});

it('keeps the namespace through a RENAMED destructuring', async () => {
const { field } = await input('/renamed', 'doc');
expect(field).toMatchObject({ source: 'query', runtimeParameter: 'get.doc' });
});

it('still refuses a coordinate for an aliased route param', async () => {
const { ep, field } = await input('/param/:id', 'id');
expect(field.source).toBe('route-param');
expect(field.runtimeParameter).toBeNull();
// …and therefore cannot become a candidate, however strong the flow evidence is.
expect(ep.flows.filter((f) => f.input === 'id' && f.ruleGeneratable)).toEqual([]);
});

it('keeps an aliased body namespace', async () => {
const { field } = await input('/bodyalias', 'file');
expect(field).toMatchObject({ source: 'body', runtimeParameter: 'post.file' });
});

it('keeps the namespace when destructuring again from the alias', async () => {
const { field } = await input('/nested', 'doc');
expect(field).toMatchObject({ source: 'query', runtimeParameter: 'get.doc' });
});

it('compiles candidates for the addressable ones only', async () => {
const { map } = await buildInputMap(dir);
const got = map!.endpoints
.flatMap((e) => e.flows.filter((f) => f.ruleGeneratable).map((f) => `${e.route}:${f.input}`))
.sort();
expect(got).toEqual(['/bodyalias:file', '/nested:doc', '/plain:doc', '/renamed:doc']);
});
});
Loading