Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: infer type of the URLPattern context passed to the handler #12

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
10 changes: 10 additions & 0 deletions examples/typesafe_parameters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { router } from "https://deno.land/x/rutt/mod.ts";

await Deno.serve(
router({
"/hello/:andThisNot": (_req, _, { thisShouldBeUndefined, andThisNot }) =>
new Response(`Hello ${thisShouldBeUndefined} and ${andThisNot}`, {
status: 200,
}),
}),
).finished;
237 changes: 193 additions & 44 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,49 +18,141 @@
* @module
*/

// deno-lint-ignore no-explicit-any
export type TemplateWildcard = any;
type EmptyObject = Record<never, never>;
export type RoutePath = string; // number | symbol | ;
export type UnknownRouteNesting = Record<string, unknown>;

export type EnsureString<A extends string | symbol | number> = A extends string
? A
: "";
export type JoinStrings<A extends string, B extends string> = `${A}${B}`;

// deno-fmt-ignore formatting Split will make it unreadable
export type Split<S extends RoutePath, D extends string> =
string extends S
? string[]
: S extends ""
? []
: S extends `${infer T}${D}${infer U}`
? [T, ...Split<U, D>]
: [S];

// deno-fmt-ignore formatting FilterAndTrimPathParamsToStringUnion will make it unreadable
export type FilterAndTrimPathParamsToStringUnion<A extends RoutePath[], P extends string> =
{
// This "object" is an array of nevers and path segments
[K in keyof A]:
string extends A[K]
? never
: A[K] extends ""
? never
: A[K] extends `${P}` // when matched ":" alone - drop it
? P
: A[K] extends `${P}${infer R}`
? R
: never
}[number]; // if you get array[number] like this - TS will drop nevers and create union of strings

/**
* ```typescript
* type Path = "/api/items/:itemId/filter/:filterId"
* const extracted: ExtractPathMatch<Path> = {
* // TypeScript will expect itemId and filterId as defined strings here
* };
* ```
*/
export type ExtractPathMatch<
T extends RoutePath,
P extends string = string,
S extends string = ":",
> = Record<
FilterAndTrimPathParamsToStringUnion<
Split<T, "/">,
S
>,
P
>;

export type HandlerContextBase = Deno.ServeHandlerInfo;

/**
* Provides arbitrary context to {@link Handler} functions along with
* {@link ConnInfo connection information}.
*/
export type HandlerContext<T = unknown> = T & Deno.ServeHandlerInfo;
export type HandlerContext<
HandlerContextExtra extends Record<string, TemplateWildcard> = Record<
string,
unknown
>,
> = HandlerContextBase & HandlerContextExtra;

/**
* A handler for HTTP requests. Consumes a request and {@link HandlerContext}
* and returns an optionally async response.
*/
export type Handler<T = unknown> = (
export type Handler<HandlerContextExtra extends HandlerContextBase> = (
req: Request,
ctx: HandlerContext<T>,
ctx: HandlerContext<HandlerContextExtra>,
) => Response | Promise<Response>;

/**
* A handler type for anytime the `MatchHandler` or `other` parameter handler
* fails
*/
export type ErrorHandler<T = unknown> = (
export type ErrorHandler<HandlerContextExtra extends HandlerContextBase> = (
req: Request,
ctx: HandlerContext<T>,
ctx: HandlerContext<HandlerContextExtra>,
err: unknown,
) => Response | Promise<Response>;

/**
* A handler type for anytime a method is received that is not defined
*/
export type UnknownMethodHandler<T = unknown> = (
export type UnknownMethodHandler<
HandlerContextExtra extends HandlerContextBase,
> = (
req: Request,
ctx: HandlerContext<T>,
ctx: HandlerContext<HandlerContextExtra>,
knownMethods: KnownMethod[],
) => Response | Promise<Response>;

/**
* A handler type for a router path match which gets passed the matched values
*/
export type MatchHandler<T = unknown> = (
export type MatchHandler<
HandlerContextExtra extends HandlerContextBase,
_PatternMatch extends Record<string, string> = Record<string, string>,
> = <__PatternMatch extends _PatternMatch>(
req: Request,
ctx: HandlerContext<T>,
match: Record<string, string>,
ctx: HandlerContext<HandlerContextExtra>,
match: _PatternMatch,
) => Response | Promise<Response>;

export type RoutesBranch<
RoutesWithHandlers extends UnknownRouteNesting,
HandlerContextExtra extends HandlerContextBase,
Key extends RoutePath,
FullPath extends RoutePath,
> = Routes<
RoutesWithHandlers,
HandlerContextExtra,
Key,
FullPath
>;

// export type NestedRoutes<
// ParentKey extends string,
// HandlerContextExtra extends HandlerContextBase,
// RoutesDefinition extends UnknownRouteNesting,
// > = {
// [Key in keyof RoutesDefinition]: RoutesBranch<
// JoinStrings<ParentKey, EnsureString<Key>>,
// HandlerContextExtra
// >;
// };

/**
* A record of route paths and {@link MatchHandler}s which are called when a match is
* found along with it's values.
Expand All @@ -69,21 +161,38 @@ export type MatchHandler<T = unknown> = (
* being able to prefix a route with a method name and the `@` sign. For
* example a route only accepting `GET` requests would look like: `GET@/`.
*/
// deno-lint-ignore ban-types
export interface Routes<T = {}> {
[key: string]: Routes<T> | MatchHandler<T>;
}
export type Routes<
RoutesWithHandlers extends UnknownRouteNesting,
HandlerContextExtra extends HandlerContextBase,
ParentKey extends RoutePath,
FullPath extends RoutePath,
> = {
[Key in keyof RoutesWithHandlers]:
RoutesWithHandlers[EnsureString<Key>] extends UnknownRouteNesting
? RoutesBranch<
RoutesWithHandlers[EnsureString<Key>],
HandlerContextExtra,
EnsureString<Key>,
JoinStrings<FullPath, EnsureString<Key>>
>
: MatchHandler<
HandlerContextExtra,
ExtractPathMatch<JoinStrings<FullPath, EnsureString<Key>>>
>;
};

/**
* The internal route object contains either a {@link RegExp} pattern or
* {@link URLPattern} which is matched against the incoming request
* URL. If a match is found for both the pattern and method the associated
* {@link MatchHandler} is called.
*/
// deno-lint-ignore ban-types
export type InternalRoute<T = {}> = {
export type InternalRoute<
PatternMatch extends Record<string, string>,
HandlerContextExtra extends HandlerContextBase,
> = {
pattern: RegExp | URLPattern;
methods: Record<string, MatchHandler<T>>;
methods: Record<string, MatchHandler<HandlerContextExtra, PatternMatch>>;
};

/**
Expand All @@ -94,28 +203,33 @@ export type InternalRoute<T = {}> = {
* control over matches, for example by using a {@link RegExp} pattern instead
* of a {@link URLPattern}.
*/
// deno-lint-ignore ban-types
export type InternalRoutes<T = {}> = InternalRoute<T>[];
export type InternalRoutes<
PatternMatch extends Record<string, string>,
HandlerContextExtra extends HandlerContextBase,
> = InternalRoute<
PatternMatch,
HandlerContextExtra
>[];

/**
* Additional options for the {@link router} function.
*/
export interface RouterOptions<T> {
export interface RouterOptions<HandlerContextExtra extends HandlerContextBase> {
/**
* An optional property which contains a handler for anything that doesn't
* match the `routes` parameter
*/
otherHandler?: Handler<T>;
otherHandler?: Handler<HandlerContextExtra>;
/**
* An optional property which contains a handler for any time it fails to run
* the default request handling code
*/
errorHandler?: ErrorHandler<T>;
errorHandler?: ErrorHandler<HandlerContextExtra>;
/**
* An optional property which contains a handler for any time a method that
* is not defined is used
*/
unknownMethodHandler?: UnknownMethodHandler<T>;
unknownMethodHandler?: UnknownMethodHandler<HandlerContextExtra>;
}

/**
Expand Down Expand Up @@ -150,9 +264,11 @@ export function defaultOtherHandler(_req: Request): Response {
* The default error handler for the router. By default it responds with `null`
* body and a status of 500 along with `console.error` logging the caught error.
*/
export function defaultErrorHandler(
export function defaultErrorHandler<
HandlerContextExtra extends HandlerContextBase,
>(
_req: Request,
_ctx: HandlerContext,
_ctx: HandlerContextExtra,
err: unknown,
): Response {
console.error(err);
Expand All @@ -167,9 +283,11 @@ export function defaultErrorHandler(
* with `null` body, a status of 405 and the `Accept` header set to all
* {@link KnownMethod known methods}.
*/
export function defaultUnknownMethodHandler(
export function defaultUnknownMethodHandler<
HandlerContextExtra extends HandlerContextBase,
>(
_req: Request,
_ctx: HandlerContext,
_ctx: HandlerContextExtra,
knownMethods: KnownMethod[],
): Response {
return new Response(null, {
Expand All @@ -194,18 +312,34 @@ function joinPaths(a: string, b: string): string {
return a + b;
}

const isHandler = <
RoutePath extends string,
HandlerContextExtra extends HandlerContextBase,
>(
handler: unknown,
): handler is MatchHandler<HandlerContextExtra, ExtractPathMatch<RoutePath>> =>
typeof handler === "function";

/**
* Builds an {@link InternalRoutes} array from a {@link Routes} record.
*
* @param routes A {@link Routes} record
* @returns The built {@link InternalRoutes}
*/
export function buildInternalRoutes<T = unknown>(
routes: Routes<T>,
basePath = "/",
): InternalRoutes<T> {
const internalRoutesRecord: Record<string, InternalRoute<T>> = {};
for (const [route, handler] of Object.entries(routes)) {
export function buildInternalRoutes<
RoutePath extends string,
BaseRoutes extends Routes<BaseRoutes, HandlerContextExtra, RoutePath, "">,
HandlerContextExtra extends HandlerContextBase,
>(
routes: BaseRoutes,
basePath: RoutePath,
): InternalRoutes<ExtractPathMatch<RoutePath>, HandlerContextExtra> {
const internalRoutesRecord: Record<
string,
InternalRoute<ExtractPathMatch<RoutePath>, HandlerContextExtra>
> = {};
for (const [_route, handler] of Object.entries(routes)) {
const route = _route as keyof BaseRoutes & string;
let [methodOrPath, path] = route.split(knownMethodRegex);
let method = methodOrPath;
if (!path) {
Expand All @@ -215,15 +349,22 @@ export function buildInternalRoutes<T = unknown>(

path = joinPaths(basePath, path);

if (typeof handler === "function") {
if (isHandler<RoutePath, HandlerContextExtra>(handler)) {
const r = internalRoutesRecord[path] ?? {
pattern: new URLPattern({ pathname: path }),
methods: {},
};
r.methods[method] = handler;
internalRoutesRecord[path] = r;
} else {
const subroutes = buildInternalRoutes(handler, path);
const subroutes = buildInternalRoutes<
RoutePath,
BaseRoutes,
HandlerContextExtra
>(
handler as any,
path as RoutePath,
);
for (const subroute of subroutes) {
internalRoutesRecord[(subroute.pattern as URLPattern).pathname] ??=
subroute;
Expand Down Expand Up @@ -254,25 +395,33 @@ export function buildInternalRoutes<T = unknown>(
* @param options An object containing all of the possible configuration options
* @returns A deno std compatible request handler
*/
export function router<T = unknown>(
routes: Routes<T> | InternalRoutes<T>,
{ otherHandler, errorHandler, unknownMethodHandler }: RouterOptions<T> = {
export function router<
R extends Routes<R, HandlerContextExtra, "", "">,
HandlerContextExtra extends HandlerContextBase,
>(
routes: R | InternalRoutes<EmptyObject, HandlerContextExtra>,
{ otherHandler, errorHandler, unknownMethodHandler }: RouterOptions<
HandlerContextExtra
> = {
otherHandler: defaultOtherHandler,
errorHandler: defaultErrorHandler,
unknownMethodHandler: defaultUnknownMethodHandler,
},
): Handler<T> {
): Handler<HandlerContextExtra> {
otherHandler ??= defaultOtherHandler;
errorHandler ??= defaultErrorHandler;
unknownMethodHandler ??= defaultUnknownMethodHandler;

const internalRoutes = Array.isArray(routes)
? routes
: buildInternalRoutes(routes);
const internalRoutes = Array.isArray(routes) ? routes : buildInternalRoutes<
"/",
R,
HandlerContextExtra
>(routes, "/");

return async (req, ctx) => {
try {
for (const { pattern, methods } of internalRoutes) {
pattern;
const res = pattern.exec(req.url);
const groups = (pattern instanceof URLPattern
? ((res as URLPatternResult | null)?.pathname.groups as
Expand All @@ -287,12 +436,12 @@ export function router<T = unknown>(
if (res !== null) {
for (const [method, handler] of Object.entries(methods)) {
if (req.method === method) {
return await handler(req, ctx, groups);
return await handler(req, ctx, groups as never);
}
}

if (methods["any"]) {
return await methods["any"](req, ctx, groups);
return await methods["any"](req, ctx, groups as never);
} else {
return await unknownMethodHandler!(
req,
Expand Down
Loading