Skip to content

Commit

Permalink
feat: routing enhancements (#25)
Browse files Browse the repository at this point in the history
  • Loading branch information
LeeCheneler authored Nov 21, 2024
1 parent 6faa82a commit c0c6017
Show file tree
Hide file tree
Showing 8 changed files with 353 additions and 54 deletions.
123 changes: 98 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,36 @@ app.get("/", (context) => {
});
```

You can register middleware to execute on every route and method via the
`app.use` method. This is useful for middleware that should run on every
request.

```tsx
app.use(async (context, next) => {
console.log("Request received");

await next();
});
```

You can configure multiple middleware at a time:

```tsx
app.get(
"/",
(context) => {
context.text(StatusCode.OK, "One!");
},
(context) => {
context.text(StatusCode.OK, "Two!");
},
(context) => {
context.text(StatusCode.OK, "Three!");
},
// ... etc
);
```

### Available middleware

A collection of prebuilt middleware is available to use.
Expand Down Expand Up @@ -209,17 +239,7 @@ context.cookies.delete("name");

## Routing

You can register middleware to execute on every route and method via the
`app.use` method. This is useful for middleware that should run on every
request.

```tsx
app.use(async (context, next) => {
console.log("Request received");

await next();
});
```
### HTTP methods

Routes can be registered for each HTTP method against a route:

Expand Down Expand Up @@ -247,20 +267,73 @@ app.options((context) => {
});
```

You can configure multiple middleware at a time:
### Paths

Paths can be simple:

```tsx
app.get(
"/",
(context) => {
context.text(StatusCode.OK, "One!");
},
(context) => {
context.text(StatusCode.OK, "Two!");
},
(context) => {
context.text(StatusCode.OK, "Three!");
},
// ... etc
);
app.get("/one", (context) => {
context.text(StatusCode.OK, "Simple path");
});

app.get("/one/two", (context) => {
context.text(StatusCode.OK, "Simple path");
});

app.get("/one/two/three", (context) => {
context.text(StatusCode.OK, "Simple path");
});
```

### Parameters

Paths can contain parameters that will be passed to the context as strings:

```tsx
app.get("/user/:id", (context) => {
context.text(StatusCode.OK, `User ID: ${context.params.id}`);
});

app.get("/user/:id/post/:postId", (context) => {
context.text(
StatusCode.OK,
`User ID: ${context.params.id}, Post ID: ${context.params.postId}`,
);
});
```

### Wildcards

Paths can contain wildcards that will match any path. Wildcards must be at the
end of the path.

```tsx
app.get("/public/*", (context) => {
context.text(StatusCode.OK, "Wildcard path");
});
```

The path portion captured by the wildcard is available on the context:

```tsx
app.get("/public/*", (context) => {
context.text(StatusCode.OK, `Wildcard path: ${context.wildcard}`);
});
```

Wildcards are inclusive of the path its placed on. This means that the wildcard
will match any path that starts with the wildcard path.

```tsx
app.get("/public/*", (context) => {
context.text(StatusCode.OK, "Wildcard path");
});

/**
* matches:
*
* /public
* /public/one
* /public/one/two
*/
```
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mage/server",
"version": "0.6.4",
"version": "0.7.0",
"license": "MIT",
"exports": "./mod.ts",
"tasks": {
Expand Down
16 changes: 12 additions & 4 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,21 @@ export class MageApp {
};

return Deno.serve(serveOptions, async (req) => {
const context: MageContext = new MageContext(req);
const url = new URL(req.url);
const matchResult = this._router.match(url, req.method);

const matchResult = this._router.match(context);
const context: MageContext = new MageContext(
req,
url,
matchResult.params,
matchResult.wildcard,
);

const getAllowedMethods = () => this._router.getAvailableMethods(url);

const middleware = [
useOptions({
getAllowedMethods: () => this._router.getAvailableMethods(context),
getAllowedMethods,
}),
...matchResult.middleware,
];
Expand All @@ -185,7 +193,7 @@ export class MageApp {
if (!matchResult.matchedMethod) {
middleware.push(
useMethodNotAllowed({
getAllowedMethods: () => this._router.getAvailableMethods(context),
getAllowedMethods,
}),
);
}
Expand Down
39 changes: 25 additions & 14 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,6 @@ import { RedirectType, StatusCode, statusTextMap } from "./http.ts";
import { Cookies } from "./cookies.ts";
import type { CookieOptions } from "./cookies.ts";

/**
* Serializable JSON value
*/
type JSONValues = string | number | boolean | null | JSONValues[];

/**
* Serializable JSON object
*/
type JSON = { [key: string]: JSONValues } | JSONValues[];

/**
* Context object that is passed to each middleware. It persists throughout the
* request/response cycle and is used to interact with the request and response.
Expand All @@ -23,13 +13,23 @@ export class MageContext {
private _response: Response;
private _request: Request;
private _cookies: Cookies;
private _params: { [key: string]: string };
private _wildcard?: string;

/**
* The URL of the request
*/
public get url(): URL {
return this._url;
}

/**
* The URL parameters of the request matched by the router
*/
public get params(): { [key: string]: string } {
return this._params;
}

/**
* The response object that will be sent at the end of the request/response
* cycle.
Expand All @@ -45,11 +45,22 @@ export class MageContext {
return this._request;
}

public constructor(request: Request) {
this._url = new URL(request.url);
this._response = new Response();
public get wildcard(): string | undefined {
return this._wildcard;
}

public constructor(
request: Request,
url: URL,
params: { [key: string]: string },
wildcard?: string,
) {
this._request = request;
this._url = url;
this._params = params;
this._response = new Response();
this._cookies = new Cookies(this);
this._wildcard = wildcard;
}

/**
Expand All @@ -72,7 +83,7 @@ export class MageContext {
* @param status
* @param body
*/
public json(status: StatusCode, body: JSON) {
public json(status: StatusCode, body: { [key: string]: unknown }) {
this._response = new Response(JSON.stringify(body), {
status: status,
statusText: statusTextMap[status],
Expand Down
87 changes: 77 additions & 10 deletions src/router.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,62 @@
import type { MageContext } from "./context.ts";
import { HttpMethod } from "./http.ts";

interface MatchRoutenameResultMatch {
match: true;
params: { [key: string]: string };
wildcard?: string;
}

interface MatchRoutenameResultNoMatch {
match: false;
}

type MatchRoutenameResult =
| MatchRoutenameResultMatch
| MatchRoutenameResultNoMatch;

function matchRoutename(
routename: string,
pathname: string,
): MatchRoutenameResult {
const routeParts = routename.split("/");
const pathParts = pathname.split("/");

const params: { [key: string]: string } = {};

for (let i = 0; i < routeParts.length; i++) {
if (routeParts[i] === "*") {
const wildcard = pathParts.slice(i).join("/");

return { match: true, params, wildcard };
}

if (routeParts[i].startsWith(":")) {
const paramName = routeParts[i].substring(1);
params[paramName] = pathParts[i];
} else if (routeParts[i] !== pathParts[i]) {
return { match: false };
}
}

// Check for wildcard at the end of the route
if (
routeParts.length < pathParts.length &&
routeParts[routeParts.length - 1] === "*"
) {
const wildcard = pathParts.slice(routeParts.length - 1).join("/");

return { match: true, params, wildcard };
}

// Ensure all parts are matched
if (routeParts.length !== pathParts.length) {
return { match: false };
}

return { match: true, params };
}

/**
* Middleware function signature for Mage applications.
*/
Expand Down Expand Up @@ -40,6 +96,8 @@ interface MatchResult {
middleware: MageMiddleware[];
matchedRoutename: boolean;
matchedMethod: boolean;
params: { [key: string]: string };
wildcard?: string;
}

/**
Expand All @@ -50,26 +108,33 @@ export class MageRouter {
private _entries: RouterEntry[] = [];

/**
* Match middleware for a given context.
* Match middleware for a given request and extract parameters.
*
* @param context
* @param url
* @param method
* @returns
*/
public match(context: MageContext): MatchResult {
public match(url: URL, method: string): MatchResult {
let matchedRoutename = false;
let matchedMethod = false;
let params: { [key: string]: string } = {};
let wildcard: string | undefined;

const middleware = this._entries
.filter((entry) => {
if (entry.routename && entry.routename !== context.url.pathname) {
return false;
}

if (entry.routename) {
const result = matchRoutename(entry.routename, url.pathname);

if (!result.match) {
return false;
}

params = result.params;
wildcard = result.wildcard;
matchedRoutename = true;
}

if (entry.methods && !entry.methods.includes(context.request.method)) {
if (entry.methods && !entry.methods.includes(method)) {
return false;
}

Expand All @@ -85,6 +150,8 @@ export class MageRouter {
middleware,
matchedRoutename,
matchedMethod,
params,
wildcard,
};
}

Expand All @@ -94,9 +161,9 @@ export class MageRouter {
* @param pathname
* @returns
*/
public getAvailableMethods(context: MageContext): string[] {
public getAvailableMethods(url: URL): string[] {
const methods = this._entries
.filter((entry) => entry.routename === context.url.pathname)
.filter((entry) => entry.routename === url.pathname)
.flatMap((entry) => entry.methods ?? []);

return methods;
Expand Down
Loading

0 comments on commit c0c6017

Please sign in to comment.