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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
}
},
"dependencies": {
"@antelopejs/interface-api": ">=0.0.11 <1.0.0",
"@antelopejs/interface-api": ">=0.0.12 <1.0.0",
"@antelopejs/interface-api-util": "^0.1.1",
"@antelopejs/interface-core": ">=0.0.6 <1.0.0",
"reflect-metadata": "^0.2.2",
Expand Down
16 changes: 8 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

66 changes: 61 additions & 5 deletions src/implementations/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ type ParameterResolver = (
controllerInstance: UnknownRecord,
) => unknown;

type ThenCallback = (
onfulfilled: (value: unknown) => unknown,
onrejected: (reason: unknown) => unknown,
) => unknown;

interface PromiseLikeValue {
then?: unknown;
}

interface ComputedPropertyResolver {
key: string;
resolve: ParameterResolver;
Expand Down Expand Up @@ -74,13 +83,60 @@ function compileParameter(
return (context, controller) => provider.call(controller, context);
}

return async (context, controller) => {
let value = await provider.call(controller, context);
for (const modifier of modifiers) {
value = await modifier.call(controller, context, value);
return (context, controller) =>
applyModifiers(
provider.call(controller, context),
modifiers,
context,
controller,
);
}

function applyModifiers(
initialValue: unknown,
modifiers: ComputedParameter["modifiers"],
context: RequestContextDev,
controller: UnknownRecord,
startIndex = 0,
): unknown {
let value = initialValue;
for (let index = startIndex; index < modifiers.length; index += 1) {
const then = getThen(value);
if (then) {
return resolveThenable(value, then).then((resolved) =>
applyModifiers(resolved, modifiers, context, controller, index),
);
}
value = modifiers[index].call(controller, context, value);
}
const then = getThen(value);
return then ? resolveThenable(value, then) : value;
}

function getThen(value: unknown): ThenCallback | undefined {
if (
value === null ||
(typeof value !== "object" && typeof value !== "function")
) {
return;
}
const then = (value as PromiseLikeValue).then;
return typeof then === "function" ? (then as ThenCallback) : undefined;
}

function resolveThenable(value: unknown, then: ThenCallback): Promise<unknown> {
if (value instanceof Promise) {
return value;
};
}
return new Promise((resolve, reject) => {
queueMicrotask(() => {
try {
then.call(value, resolve, reject);
} catch (error) {
reject(error);
}
});
});
}

function compileController(
Expand Down
16 changes: 16 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface RequestContext {

interface DynamicRoute {
match: RegExp;
requiredPrefix: string;
requiredSuffix: string;
parameterName?: string;
parameterNames: string[];
sub: RouteLevel;
Expand Down Expand Up @@ -173,6 +175,12 @@ function findHandlers(
}

for (const route of level.dynamicRouteList) {
if (
(route.requiredPrefix && !part.startsWith(route.requiredPrefix)) ||
(route.requiredSuffix && !part.endsWith(route.requiredSuffix))
) {
continue;
}
const match = route.match.exec(part);
if (!match) {
continue;
Expand Down Expand Up @@ -436,8 +444,16 @@ function compileDynamicRoute(part: string): DynamicRoute {
pattern.push(`(.*)`);
}
pattern.push("$");
const firstParameter = part.indexOf(":");
const lastParameter = part.lastIndexOf(":");
let suffixStart = lastParameter + 1;
while (suffixStart < part.length && /[a-zA-Z0-9]/.test(part[suffixStart])) {
suffixStart += 1;
}
return {
match: new RegExp(pattern.join("")),
requiredPrefix: part.slice(0, firstParameter),
requiredSuffix: part.slice(suffixStart),
parameterName: mapping.length === 1 ? mapping[0] : undefined,
parameterNames: mapping,
sub: new RouteLevel(),
Expand Down
93 changes: 92 additions & 1 deletion src/test/controller-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { requestListener } from "../server";

const TEST_HOST = "127.0.0.1";
const TEST_ORIGIN = `http://${TEST_HOST}`;
const THEN_PROPERTY = ["th", "en"].join("");

interface TestController {
requestId?: string;
Expand All @@ -24,6 +25,11 @@ interface TestResponse {
body: string;
}

interface StatefulThenable {
readCount: () => number;
value: PromiseLike<string>;
}

type ControllerConstructor = (new () => TestController) & {
location: string;
};
Expand All @@ -42,6 +48,21 @@ function createController(): ControllerConstructor {
};
}

function statefulThenable(value: string): StatefulThenable {
let reads = 0;
const thenable = Object.create(null);
Object.defineProperty(thenable, THEN_PROPERTY, {
get: () => {
reads += 1;
if (reads > 1) {
throw new Error("then getter read more than once");
}
return (resolve: (resolved: string) => unknown) => resolve(value);
},
});
return { readCount: () => reads, value: thenable };
}

function createHandler(
Controller: ControllerConstructor,
callback: RouteHandler["callback"],
Expand Down Expand Up @@ -95,6 +116,7 @@ describe("Controller resolution", () => {
let server: Server;
let port: number;
let nextRouteId = 0;
let listenerResult: unknown;

function register(handler: RouteHandler): void {
const id = `controller-resolution-${nextRouteId++}`;
Expand All @@ -104,7 +126,7 @@ describe("Controller resolution", () => {

before(async () => {
server = createServer((request, response) => {
void requestListener(request, response, "http");
listenerResult = requestListener(request, response, "http");
});
port = await listen(server);
});
Expand Down Expand Up @@ -278,6 +300,75 @@ describe("Controller resolution", () => {
});
});

it("keeps synchronous modifier chains on the synchronous request path", async () => {
const Controller = createController();
const location = "/controller-resolution/synchronous-modifiers";
register(
createHandler(
Controller,
function (this: TestController, value) {
return `${value}:${this.sequence}`;
},
location,
[
computedParameter(
function (this: TestController) {
this.sequence += 1;
return "provider";
},
[
function (this: TestController, _context, value) {
this.sequence += 1;
return `${value}:first`;
},
function (this: TestController, _context, value) {
this.sequence += 1;
return `${value}:second`;
},
],
),
],
),
);

assert.deepEqual(await get(port, location), {
status: 200,
body: "provider:first:second:3",
});
assert.equal(listenerResult, undefined);
});

it("continues remaining modifiers after the first asynchronous value", async () => {
const Controller = createController();
const events: string[] = [];
const thenable = statefulThenable("value:thenable");
const location = "/controller-resolution/mixed-modifiers";
register(
createHandler(Controller, (value) => value, location, [
computedParameter(() => {
events.push("provider");
return thenable.value;
}, [
(_context, value) => {
events.push("async");
return Promise.resolve(`${value}:async`);
},
(_context, value) => {
events.push("remaining");
return `${value}:remaining`;
},
]),
]),
);

assert.deepEqual(await get(port, location), {
status: 200,
body: "value:thenable:async:remaining",
});
assert.deepEqual(events, ["provider", "async", "remaining"]);
assert.equal(thenable.readCount(), 1);
});

it("turns provider and modifier failures into request errors", async () => {
const ProviderController = createController();
const ModifierController = createController();
Expand Down
28 changes: 28 additions & 0 deletions src/test/routing-parameters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,34 @@ describe("Compiled route parameter extraction", () => {
});
});

it("preserves prefixed and suffixed dynamic segment matching", async () => {
register(
"compiled-filter-prefix",
"handler",
"get",
"/compiled/filter/pre:id.json",
({ routeParameters }) => `prefix:${routeParameters.id}`,
);
register(
"compiled-filter-suffix",
"handler",
"get",
"/compiled/filter/:first-:second.tail",
({ routeParameters }) =>
`suffix:${routeParameters.first}:${routeParameters.second}`,
);

assert.equal(
(await request("/compiled/filter/prevalue.json")).body,
"prefix:value",
);
assert.equal(
(await request("/compiled/filter/left-right.tail")).body,
"suffix:left:right",
);
assert.equal((await request("/compiled/filter/value.json")).status, 404);
});

it("isolates parameter objects for multiple handlers", async () => {
const observed: string[] = [];
register(
Expand Down
Loading