Skip to content

Commit

Permalink
feat: make interop with Fetch API easier
Browse files Browse the repository at this point in the history
Adds `serve()` and `route()` middleware as well as convenience functions
on oak context.

Closes #533
  • Loading branch information
kitsonk committed Feb 4, 2024
1 parent 2e2f5da commit 4d4034b
Show file tree
Hide file tree
Showing 10 changed files with 641 additions and 25 deletions.
80 changes: 79 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,8 @@ several properties:

- `.originalRequest`

**DEPRECATED** this will be removed in a future release of oak.

The "raw" `NativeServer` request, which is an abstraction over the DOM
`Request` object. `.originalRequest.request` is the DOM `Request` instance
that is being processed. Users should generally avoid using these.
Expand All @@ -327,6 +329,11 @@ several properties:

A shortcut for `.protocol`, returning `true` if HTTPS otherwise `false`.

- `.source`

When running under Deno, `.source` will be set to the source web standard
`Request`. When running under NodeJS, this will be `undefined`.

- `.url`

An instance of [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL)
Expand Down Expand Up @@ -478,7 +485,7 @@ sent back to the requestor. It contains several properties:
A media type or extension to set the `Content-Type` header for the response.
For example, you can provide `txt` or `text/plain` to describe the body.

And a method:
And several methods:

- `.redirect(url?: string | URL | REDIRECT_BACK, alt?: string | URL)`

Expand All @@ -491,6 +498,19 @@ And a method:
occur to `/`. A basic HTML (if the requestor supports it) or a text body will
be set explaining they are being redirected.

- `.toDomResponse()`

This converts the information oak understands about the response to the Fetch
API `Response`. This will finalize the response, resulting in any further
attempt to modify the response to throw. This is intended to be used
internally within oak to be able to respond to requests.

- `.with(response: Response)` and `.with(body?: BodyInit, init?: ResponseInit)`

This sets the response to a web standard `Response`. Note that this will
ignore/override any other information set on the response by other middleware
including things like headers or cookies to be set.

### Automatic response body handling

When the response `Content-Type` is not set in the headers of the `.response`,
Expand Down Expand Up @@ -854,6 +874,64 @@ Would result in the return value being:
}
```

## Fetch API and `Deno.serve()` migration

If you are migrating from `Deno.serve()` or adapting code that is designed for
the web standard Fetch API `Request` and `Response`, there are a couple features
of oak to assist.

### `ctx.request.source`

When running under Deno, this will be set to a Fetch API `Request`, giving
direct access to the original request.

### `ctx.response.with()`

This method will accept a Fetch API `Response` or create a new response based
on the provided `BodyInit` and `ResponseInit`. This will also finalize the
response and ignores anything that may have been set on the oak `.response`.

### `middleware/serve#serve()` and `middelware/serve#route()`

These two middleware generators can be used to adapt code that operates more
like the `Deno.serve()` in that it provides a Fetch API `Request` and expects
the handler to resolve with a Fetch API `Response`.

An example of using `serve()` with `Application.prototype.use()`:

```ts
import { Application, serve } from "https://deno.land/x/oak/mod.ts";

const app = new Application();

app.use(serve((req, ctx) => {
console.log(req.url);
return new Response("Hello world!");
}));

app.listen();
```

And a similar solution works with `route()` where the context contains the
information about the router, like the params:

```ts
import { Application, route, Router } from "https://deno.land/x/oak/mod.ts";

const app = new Application;

const router = new Router();

router.get("/books/:id", route((req, ctx)) => {
console.log(ctx.params.id);
return Response.json({ title: "hello world", id: ctx.params.id });
});

app.use(router.routes());

app.listen();
```

## Testing

The `mod.ts` exports an object named `testing` which contains some utilities for
Expand Down
9 changes: 1 addition & 8 deletions context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,6 @@ function createMockNativeRequest(
respondWithStack = [];
upgradeWebSocketStack = [];
const request = new Request(url, requestInit);
const requestEvent = {
request,
respondWith(r: Response | Promise<Response>) {
respondWithStack.push(r);
return Promise.resolve();
},
};
const upgradeWebSocket: UpgradeWebSocketFn | undefined = upgradeUndefined
? undefined
: (request, options) => {
Expand Down Expand Up @@ -114,7 +107,7 @@ Deno.test({
assertStrictEquals(context.app, app);
assert(context.cookies instanceof SecureCookieMap);
assert(context.request instanceof OakRequest);
assert(isNativeRequest(context.request.originalRequest));
assert(context.request.source instanceof Request);
assert(context.response instanceof OakResponse);
},
});
Expand Down
17 changes: 5 additions & 12 deletions context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,7 @@ export class Context<
* ```
*/
assert(
// deno-lint-ignore no-explicit-any
condition: any,
condition: unknown,
errorStatus: ErrorStatus = 500,
message?: string,
props?: Record<string, unknown> & Omit<HttpErrorOptions, "status">,
Expand Down Expand Up @@ -298,17 +297,11 @@ export class Context<
* the a web standard `WebSocket` object. This will set `.respond` to
* `false`. If the socket cannot be upgraded, this method will throw. */
upgrade(options?: UpgradeWebSocketOptions): WebSocket {
if (this.#socket) {
return this.#socket;
}
if (!this.request.originalRequest.upgrade) {
throw new TypeError(
"Web socket upgrades not currently supported for this type of server.",
);
if (!this.#socket) {
this.#socket = this.request.upgrade(options);
this.app.addEventListener("close", () => this.#socket?.close());
this.respond = false;
}
this.#socket = this.request.originalRequest.upgrade(options);
this.app.addEventListener("close", () => this.#socket?.close());
this.respond = false;
return this.#socket;
}

Expand Down
145 changes: 145 additions & 0 deletions middleware/serve.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { type State } from "../application.ts";
import { Context } from "../context.ts";
import { NativeRequest } from "../http_server_native_request.ts";
import { type Next } from "../middleware.ts";
import { type RouteParams, Router, type RouterContext } from "../router.ts";
import { assert, assertEquals, assertStrictEquals } from "../test_deps.ts";
import { createMockApp, createMockNext } from "../testing.ts";
import { isNode } from "../util.ts";

import { route, serve } from "./serve.ts";

function setup<R extends string = "/", S extends State = State>(
request: Request,
remoteAddr: Deno.NetAddr = {
transport: "tcp",
hostname: "localhost",
port: 8080,
},
): [Context<S>, RouterContext<R, RouteParams<R>, S>, Next] {
const app = createMockApp();
const serverRequest = new NativeRequest(request, { remoteAddr });
const context = new Context<S>(app, serverRequest, app.state as S);
const routerContext = new Context(
app,
serverRequest,
app.state,
) as RouterContext<R, RouteParams<R>, S>;
Object.assign(routerContext, {
captures: [],
params: { "a": "b" },
router: {} as Router,
routeName: "c",
routePath: "d",
});
return [context, routerContext, createMockNext()];
}

Deno.test({
name: "serve - source request and response are strictly equal",
async fn() {
const request = new Request("http://localhost:8888/index.html");
const [context, , next] = setup(request);
let response: Response;
const mw = serve((req) => {
assertStrictEquals(req, request);
return response = new Response();
});
await mw(context, next);
assertStrictEquals(await context.response.toDomResponse(), response!);
},
});

Deno.test({
name: "serve - context is valid",
async fn() {
const request = new Request("http://localhost:8888/index.html");
const [context, , next] = setup(request);
const mw = serve((_req, ctx) => {
assert(ctx.app);
assert(ctx.state);
assertEquals(typeof ctx.assert, "function");
assertEquals(typeof ctx.throw, "function");
return new Response();
});
await mw(context, next);
},
});

Deno.test({
name: "serve - inspection is expected",
async fn() {
const request = new Request("http://localhost:8888/index.html");
const [context, , next] = setup(request);
const mw = serve((_req, ctx) => {
assertEquals(
Deno.inspect(ctx),
isNode()
? `ServeContext { app: MockApplication {}, ip: 'localhost', ips: [], state: {} }`
: `ServeContext { app: MockApplication {}, ip: "localhost", ips: [], state: {} }`,
);
return new Response();
});
await mw(context, next);
},
});

Deno.test({
name: "route - source request and response are strictly equal",
async fn() {
const request = new Request("http://localhost:8888/index.html");
const [, context, next] = setup(request);
let response: Response;
const mw = route<"/", RouteParams<"/">, State>((req) => {
assertStrictEquals(req, request);
return response = new Response();
});
await mw(context, next);
assertStrictEquals(await context.response.toDomResponse(), response!);
},
});

Deno.test({
name: "route - context is valid",
async fn() {
const request = new Request("http://localhost:8888/book/1234");
const [context, , next] = setup(request);
const router = new Router();
router.get(
"/book/:id",
route((_req, ctx) => {
assertEquals(ctx.captures, ["1234"]);
assertEquals(ctx.params, { id: "1234" });
assertEquals(ctx.routeName, undefined);
assertStrictEquals(ctx.router, router);
assertEquals(ctx.routerPath, undefined);
return new Response();
}),
);
const mw = router.routes();
await mw(context, next);
},
});

Deno.test({
name: "route - inspection is expected",
async fn() {
const request = new Request("http://localhost:8888/book/1234");
const [context, , next] = setup(request);
const router = new Router();
router.get(
"/book/:id",
route((_req, ctx) => {
assertEquals(
Deno.inspect(ctx),
isNode()
? `RouteContext {\n app: MockApplication {},\n captures: [ '1234' ],\n matched: [ [Layer] ],\n ip: 'localhost',\n ips: [],\n params: { id: '1234' },\n router: Router { '#params': {}, '#stack': [Array] },\n routeName: undefined,\n routerPath: undefined,\n state: {}\n}`
: `RouteContext {\n app: MockApplication {},\n captures: [ "1234" ],\n matched: [\n Layer {\n methods: [ "HEAD", "GET" ],\n middleware: [ [AsyncFunction (anonymous)] ],\n options: {\n end: undefined,\n sensitive: undefined,\n strict: undefined,\n ignoreCaptures: undefined\n },\n paramNames: [ "id" ],\n path: "/book/:id",\n regexp: /^\\/book(?:\\/([^\\/#\\?]+?))[\\/#\\?]?$/i\n}\n ],\n ip: "localhost",\n ips: [],\n params: { id: "1234" },\n router: Router {\n "#params": {},\n "#stack": [\n Layer {\n methods: [ "HEAD", "GET" ],\n middleware: [ [AsyncFunction (anonymous)] ],\n options: {\n end: undefined,\n sensitive: undefined,\n strict: undefined,\n ignoreCaptures: undefined\n },\n paramNames: [ "id" ],\n path: "/book/:id",\n regexp: /^\\/book(?:\\/([^\\/#\\?]+?))[\\/#\\?]?$/i\n}\n ]\n},\n routeName: undefined,\n routerPath: undefined,\n state: {}\n}`,
);
return new Response();
}),
);
const mw = router.routes();
await mw(context, next);
},
});
Loading

0 comments on commit 4d4034b

Please sign in to comment.