diff --git a/package-lock.json b/package-lock.json index f66c860dc..5c40794f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10755,8 +10755,8 @@ "binary-extensions": "^2.2.0", "diff": "^5.0.0", "minimatch": "^3.0.4", - "npm-package-arg": "^8.1.4", - "pacote": "^11.3.4", + "npm-package-arg": "^8.1.1", + "pacote": "^11.3.0", "tar": "^6.1.0" } }, diff --git a/src/middleware/README.md b/src/middleware/README.md new file mode 100644 index 000000000..2a88ca78a --- /dev/null +++ b/src/middleware/README.md @@ -0,0 +1,89 @@ +# Middlewares for Common Environments + +If you want to use Node.js middleware, read main [README.md](../../README.md#createnodemiddlewareapp-options) instead. If you need to implement a handler/middleware for another environment, read this document. + +The `middleware` directory contains the generic HTTP handler. Each sub-directory (e.g., [`node`](node)) exposes an HTTP handler/middleware for a specific environment. + +``` +middleware +├── handle-request.ts +├── on-unhandled-request-default.ts +├── types.ts +├── node/ +├── cloudflare/ (to be implemented) +└── deno/ (to be implemented) +``` + +## Generic HTTP Handler + +[`handleRequest`](handle-request.ts) function is an abstract HTTP handler which accepts an `OctokitRequest` and returns an `OctokitResponse` if the request matches any predefined route. + +> Different environments (e.g., Node.js, Cloudflare, etc.) exposes different APIs when processing HTTP requests (e.g., [`IncomingMessage`](https://nodejs.org/api/http.html#http_class_http_incomingmessage) for Node.js, [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) for Cloudflare workers, etc.). Two HTTP-related types ([`OctokitRequest` and `OctokitResponse`](./types.ts)) are generalized to make an abstract HTTP handler possible. + +To share the behavior and capability with the existing Node.js middleware (and be compatible with [OAuth user authentication strategy in the browser](https://github.com/octokit/auth-oauth-user-client.js)), it is better to implement your HTTP handler/middleware based on `handleRequest` function. + +`handleRequest` function takes three parameters: + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ name + + type + + description +
+ app + + OAuthApp instance + + Required. +
+ options.pathPrefix + + string + + +All exposed paths will be prefixed with the provided prefix. Defaults to `"/api/github/oauth"` + +
+ request + + OctokitRequest + + Generalized HTTP request in `OctokitRequest` type. +
+ +## Adapt for an Environment + +Implementing an HTTP handler/middleware for a certain environment involves three steps: + +1. Write a function to parse the HTTP request (e.g., `IncomingMessage` in Node.js) into an `OctokitRequest` object. See [`node/parse-request.ts`](node/parse-request.ts) for reference. +2. Write a function to render an `OctokitResponse` object (e.g., as `ServerResponse` in Node.js). See [`node/send-response.ts`](node/send-response.ts) for reference. +3. Expose an HTTP handler/middleware in the dialect of the environment which performs three steps: + 1. Parse the HTTP request using (1). + 2. Process the `OctokitRequest` object using `handleRequest`. If the request is not handled by `handleRequest` (the request does not match any predefined route), [`onUnhandledRequestDefault`](on-unhandled-request-default.ts) can be used to generate a `404` response consistently. + 3. Render the `OctokitResponse` object using (2). diff --git a/src/middleware/node/middleware.ts b/src/middleware/handle-request.ts similarity index 53% rename from src/middleware/node/middleware.ts rename to src/middleware/handle-request.ts index 60ea9e234..3b576b41c 100644 --- a/src/middleware/node/middleware.ts +++ b/src/middleware/handle-request.ts @@ -1,75 +1,71 @@ -// remove type imports from http for Deno compatibility -// see https://github.com/octokit/octokit.js/issues/24#issuecomment-817361886 -// import { IncomingMessage, ServerResponse } from "http"; -type IncomingMessage = any; -type ServerResponse = any; +import { OAuthApp } from "../index"; +import { OctokitRequest, OctokitResponse, HandlerOptions } from "./types"; +import { ClientType, Options } from "../types"; +// @ts-ignore - requires esModuleInterop flag +import fromEntries from "fromentries"; -import { parseRequest } from "./parse-request"; - -import { OAuthApp } from "../../index"; -import { MiddlewareOptions } from "./types"; -import { Options, ClientType } from "../../types"; - -export async function middleware( +export async function handleRequest( app: OAuthApp>, - options: Required, - request: IncomingMessage, - response: ServerResponse, - next?: Function -) { - // request.url mayb include ?query parameters which we don't want for `route` + { pathPrefix = "/api/github/oauth" }: HandlerOptions, + request: OctokitRequest +): Promise { + // request.url may include ?query parameters which we don't want for `route` // hence the workaround using new URL() const { pathname } = new URL(request.url as string, "http://localhost"); const route = [request.method, pathname].join(" "); const routes = { - getLogin: `GET ${options.pathPrefix}/login`, - getCallback: `GET ${options.pathPrefix}/callback`, - createToken: `POST ${options.pathPrefix}/token`, - getToken: `GET ${options.pathPrefix}/token`, - patchToken: `PATCH ${options.pathPrefix}/token`, - patchRefreshToken: `PATCH ${options.pathPrefix}/refresh-token`, - scopeToken: `POST ${options.pathPrefix}/token/scoped`, - deleteToken: `DELETE ${options.pathPrefix}/token`, - deleteGrant: `DELETE ${options.pathPrefix}/grant`, + getLogin: `GET ${pathPrefix}/login`, + getCallback: `GET ${pathPrefix}/callback`, + createToken: `POST ${pathPrefix}/token`, + getToken: `GET ${pathPrefix}/token`, + patchToken: `PATCH ${pathPrefix}/token`, + patchRefreshToken: `PATCH ${pathPrefix}/refresh-token`, + scopeToken: `POST ${pathPrefix}/token/scoped`, + deleteToken: `DELETE ${pathPrefix}/token`, + deleteGrant: `DELETE ${pathPrefix}/grant`, }; // handle unknown routes if (!Object.values(routes).includes(route)) { - const isExpressMiddleware = typeof next === "function"; - if (isExpressMiddleware) { - // @ts-ignore `next` must be a function as we check two lines above - return next(); - } else { - return options.onUnhandledRequest(request, response); - } + return null; } - let parsedRequest; + let json: any; try { - parsedRequest = await parseRequest(request); + const text = await request.text(); + json = text ? JSON.parse(text) : {}; } catch (error) { - response.writeHead(400, { - "content-type": "application/json", - }); - return response.end( - JSON.stringify({ + return { + status: 400, + headers: { "content-type": "application/json" }, + text: JSON.stringify({ error: "[@octokit/oauth-app] request error", - }) - ); + }), + }; } - const { headers, query, body = {} } = parsedRequest; + const { searchParams } = new URL(request.url as string, "http://localhost"); + const query = fromEntries(searchParams) as { + state?: string; + scopes?: string; + code?: string; + redirectUrl?: string; + allowSignup?: string; + error?: string; + error_description?: string; + error_url?: string; + }; + const headers = request.headers as { authorization?: string }; try { if (route === routes.getLogin) { const { url } = app.getWebFlowAuthorizationUrl({ state: query.state, - scopes: query.scopes?.split(","), - allowSignup: query.allowSignup, + scopes: query.scopes ? query.scopes.split(",") : undefined, + allowSignup: query.allowSignup !== "false", redirectUrl: query.redirectUrl, }); - response.writeHead(302, { location: url }); - return response.end(); + return { status: 302, headers: { location: url } }; } if (route === routes.getCallback) { @@ -91,18 +87,19 @@ export async function middleware( code: query.code, }); - response.writeHead(200, { - "content-type": "text/html", - }); - response.write(`

Token created successfull

+ return { + status: 200, + headers: { + "content-type": "text/html", + }, + text: `

Token created successfull

-

Your token is: ${token}. Copy it now as it cannot be shown again.

`); - - return response.end(); +

Your token is: ${token}. Copy it now as it cannot be shown again.

`, + }; } if (route === routes.createToken) { - const { state: oauthState, code, redirectUrl } = body; + const { state: oauthState, code, redirectUrl } = json; if (!oauthState || !code) { throw new Error( @@ -118,11 +115,11 @@ export async function middleware( redirectUrl, }); - response.writeHead(201, { - "content-type": "application/json", - }); - - return response.end(JSON.stringify({ token, scopes })); + return { + status: 201, + headers: { "content-type": "application/json" }, + text: JSON.stringify({ token, scopes }), + }; } if (route === routes.getToken) { @@ -138,10 +135,11 @@ export async function middleware( token, }); - response.writeHead(200, { - "content-type": "application/json", - }); - return response.end(JSON.stringify(result)); + return { + status: 200, + headers: { "content-type": "application/json" }, + text: JSON.stringify(result), + }; } if (route === routes.patchToken) { @@ -157,10 +155,11 @@ export async function middleware( token, }); - response.writeHead(200, { - "content-type": "application/json", - }); - return response.end(JSON.stringify(result)); + return { + status: 200, + headers: { "content-type": "application/json" }, + text: JSON.stringify(result), + }; } if (route === routes.patchRefreshToken) { @@ -172,7 +171,7 @@ export async function middleware( ); } - const { refreshToken } = body; + const { refreshToken } = json; if (!refreshToken) { throw new Error( @@ -182,10 +181,11 @@ export async function middleware( const result = await app.refreshToken({ refreshToken }); - response.writeHead(200, { - "content-type": "application/json", - }); - return response.end(JSON.stringify(result)); + return { + status: 200, + headers: { "content-type": "application/json" }, + text: JSON.stringify(result), + }; } if (route === routes.scopeToken) { @@ -199,13 +199,14 @@ export async function middleware( const result = await app.scopeToken({ token, - ...body, + ...json, }); - response.writeHead(200, { - "content-type": "application/json", - }); - return response.end(JSON.stringify(result)); + return { + status: 200, + headers: { "content-type": "application/json" }, + text: JSON.stringify(result), + }; } if (route === routes.deleteToken) { @@ -221,8 +222,7 @@ export async function middleware( token, }); - response.writeHead(204); - return response.end(); + return { status: 204 }; } // route === routes.deleteGrant @@ -238,16 +238,12 @@ export async function middleware( token, }); - response.writeHead(204); - return response.end(); + return { status: 204 }; } catch (error) { - response.writeHead(400, { - "content-type": "application/json", - }); - response.end( - JSON.stringify({ - error: error.message, - }) - ); + return { + status: 400, + headers: { "content-type": "application/json" }, + text: JSON.stringify({ error: error.message }), + }; } } diff --git a/src/middleware/node/index.ts b/src/middleware/node/index.ts index 99d577eb8..9bc05f71f 100644 --- a/src/middleware/node/index.ts +++ b/src/middleware/node/index.ts @@ -1,19 +1,56 @@ -import { OAuthApp } from "../../index"; -import { middleware } from "./middleware"; +// remove type imports from http for Deno compatibility +// see https://github.com/octokit/octokit.js/issues/2075#issuecomment-817361886 +// import { IncomingMessage, ServerResponse } from "http"; +type IncomingMessage = any; +type ServerResponse = any; -import { MiddlewareOptions } from "./types"; -import { onUnhandledRequestDefault } from "./on-unhandled-request-default"; +import { parseRequest } from "./parse-request"; +import { sendResponse } from "./send-response"; +import { onUnhandledRequestDefault } from "../on-unhandled-request-default"; +import { handleRequest } from "../handle-request"; +import { OAuthApp } from "../../index"; +import { HandlerOptions } from "../types"; import { ClientType, Options } from "../../types"; +function onUnhandledRequestDefaultNode( + request: IncomingMessage, + response: ServerResponse +) { + const octokitRequest = parseRequest(request); + const octokitResponse = onUnhandledRequestDefault(octokitRequest); + sendResponse(octokitResponse, response); +} + export function createNodeMiddleware( app: OAuthApp>, { - pathPrefix = "/api/github/oauth", - onUnhandledRequest = onUnhandledRequestDefault, - }: MiddlewareOptions = {} -) { - return middleware.bind(null, app, { pathPrefix, - onUnhandledRequest, - } as Required); + onUnhandledRequest = onUnhandledRequestDefaultNode, + }: HandlerOptions & { + onUnhandledRequest?: ( + request: IncomingMessage, + response: ServerResponse + ) => void; + } = {} +) { + return async function ( + request: IncomingMessage, + response: ServerResponse, + next?: Function + ) { + const octokitRequest = parseRequest(request); + const octokitResponse = await handleRequest( + app, + { pathPrefix }, + octokitRequest + ); + + if (octokitResponse) { + sendResponse(octokitResponse, response); + } else if (typeof next === "function") { + next(); + } else { + onUnhandledRequest(request, response); + } + }; } diff --git a/src/middleware/node/on-unhandled-request-default.ts b/src/middleware/node/on-unhandled-request-default.ts deleted file mode 100644 index 3664190fc..000000000 --- a/src/middleware/node/on-unhandled-request-default.ts +++ /dev/null @@ -1,19 +0,0 @@ -// remove type imports from http for Deno compatibility -// see https://github.com/octokit/octokit.js/issues/24#issuecomment-817361886 -// import { IncomingMessage, ServerResponse } from "http"; -type IncomingMessage = any; -type ServerResponse = any; - -export function onUnhandledRequestDefault( - request: IncomingMessage, - response: ServerResponse -) { - response.writeHead(404, { - "content-type": "application/json", - }); - response.end( - JSON.stringify({ - error: `Unknown route: ${request.method} ${request.url}`, - }) - ); -} diff --git a/src/middleware/node/parse-request.ts b/src/middleware/node/parse-request.ts index da08fbeac..5df274a26 100644 --- a/src/middleware/node/parse-request.ts +++ b/src/middleware/node/parse-request.ts @@ -1,59 +1,21 @@ // remove type imports from http for Deno compatibility -// see https://github.com/octokit/octokit.js/issues/24#issuecomment-817361886 +// see https://github.com/octokit/octokit.js/issues/2075#issuecomment-817361886 // import { IncomingMessage } from "http"; type IncomingMessage = any; -// @ts-ignore remove once Node 10 is out maintenance. Replace with Object.fromEntries -import fromEntries from "fromentries"; +import { OctokitRequest } from "../types"; -type ParsedRequest = { - headers: { - authorization?: string; - }; - query: { - state?: string; - scopes?: string; - code?: string; - redirectUrl?: string; - allowSignup?: boolean; - error?: string; - error_description?: string; - error_url?: string; - }; - body?: { - code?: string; - state?: string; - redirectUrl?: string; - refreshToken?: string; - }; -}; - -export async function parseRequest( - request: IncomingMessage -): Promise { - const { searchParams } = new URL(request.url as string, "http://localhost"); - - const query = fromEntries(searchParams); - const headers = request.headers; - - if (!["POST", "PATCH"].includes(request.method as string)) { - return { headers, query }; +export function parseRequest(request: IncomingMessage): OctokitRequest { + const { method, url, headers } = request; + async function text() { + const text = await new Promise((resolve, reject) => { + let bodyChunks: Uint8Array[] = []; + request + .on("error", reject) + .on("data", (chunk: Uint8Array) => bodyChunks.push(chunk)) + .on("end", () => resolve(Buffer.concat(bodyChunks).toString())); + }); + return text; } - - return new Promise((resolve, reject) => { - let bodyChunks: Uint8Array[] = []; - request - .on("error", reject) - .on("data", (chunk: Uint8Array) => bodyChunks.push(chunk)) - .on("end", async () => { - const bodyString = Buffer.concat(bodyChunks).toString(); - if (!bodyString) return resolve({ headers, query }); - - try { - resolve({ headers, query, body: JSON.parse(bodyString) }); - } catch (error) { - reject(error); - } - }); - }); + return { method, url, headers, text }; } diff --git a/src/middleware/node/send-response.ts b/src/middleware/node/send-response.ts new file mode 100644 index 000000000..d753044b5 --- /dev/null +++ b/src/middleware/node/send-response.ts @@ -0,0 +1,13 @@ +// remove type imports from http for Deno compatibility +// see https://github.com/octokit/octokit.js/issues/2075#issuecomment-817361886 +// import { IncomingMessage, ServerResponse } from "http"; +type ServerResponse = any; +import { OctokitResponse } from "../types"; + +export function sendResponse( + octokitResponse: OctokitResponse, + response: ServerResponse +) { + response.writeHead(octokitResponse.status, octokitResponse.headers); + response.end(octokitResponse.text); +} diff --git a/src/middleware/node/types.ts b/src/middleware/node/types.ts deleted file mode 100644 index 6ff8c8ebd..000000000 --- a/src/middleware/node/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -// remove type imports from http for Deno compatibility -// see https://github.com/octokit/octokit.js/issues/24#issuecomment-817361886 -// import { IncomingMessage, ServerResponse } from "http"; -type IncomingMessage = any; -type ServerResponse = any; - -export type MiddlewareOptions = { - pathPrefix?: string; - onUnhandledRequest?: ( - request: IncomingMessage, - response: ServerResponse - ) => void; -}; diff --git a/src/middleware/on-unhandled-request-default.ts b/src/middleware/on-unhandled-request-default.ts new file mode 100644 index 000000000..14dd11757 --- /dev/null +++ b/src/middleware/on-unhandled-request-default.ts @@ -0,0 +1,13 @@ +import { OctokitRequest, OctokitResponse } from "./types"; + +export function onUnhandledRequestDefault( + request: OctokitRequest +): OctokitResponse { + return { + status: 404, + headers: { "content-type": "application/json" }, + text: JSON.stringify({ + error: `Unknown route: ${request.method} ${request.url}`, + }), + }; +} diff --git a/src/middleware/types.ts b/src/middleware/types.ts new file mode 100644 index 000000000..f259376d8 --- /dev/null +++ b/src/middleware/types.ts @@ -0,0 +1,16 @@ +export type OctokitRequest = { + method: string; + url: string; + headers: Record; + text: () => Promise; +}; + +export type OctokitResponse = { + status: number; + headers?: Record; + text?: string; +}; + +export type HandlerOptions = { + pathPrefix?: string; +};