Búsqueda de sitios web

typescript - ¿Cómo tipar fuertemente el objeto state del contexto de Oak en deno?


Quiero tipar fuertemente el objeto context.state proporcionado por oak en deno. Ya he visto enfoques de cómo esto podría funcionar (por ejemplo, context.cookies de Deno oak v10.5.1 nunca se establece), pero aún no he logrado implementarlo yo mismo en mi propio código.

Mi objetivo es acceder al context.state fuertemente tipado en cada uno de mis middlewares.

Esta es la interfaz para el context.state:

interface State {
    userID: number;
    sessionID: number;
}

Esta sería una de mis funciones de middleware para establecer el context.state en la que intenté acceder a las propiedades del estado del contexto:

const contextMiddleware = async (context: Context, next: () => Promise<unknown>): Promise<void> => {
    context.state.userID = 123;
    context.state.sessionID = 456;
    await next();
    delete context.state.userID;
    delete context.state.sessionID;
}

Mi problema es que en la función contextMiddleware estas dos propiedades son de tipo any y no del tipo esperado de number. Además, intellisense no reconoce estas dos propiedades para la autocompletación.

He visto que la solución podría ser pasar la interfaz IState como genéricos al objeto Application y Router que llama al middleware y luego establecer la función contextMiddleware al tipo RouterMiddleware o Middleware con los mismos genéricos del módulo oak import mod.ts.

Esto podría verse así, pero no funciona en este momento:

import {
  Application,
  type Context,
  type Middleware,
  Router,
} from "https://deno.land/x/[email /mod.ts";

interface State {
    userID: number;
    sessionID: number;
}

const contextMiddleware: Middleware<State, Context<State, State>> = async (context: Context, next: () => Promise<unknown>): Promise<void> => {
    context.state.userID = 123;
    context.state.sessionID= 456;
    await next();
    delete context.state.userID;
    delete context.state.sessionID;
}

const defaultRouter = new Router<State>();

defaultRouter
  .use(contextMiddleware)
  .get("/(.*)", (context: Context) => {
    context.response.status = 404;
    context.response.body = "Endpoint not available!";
  });

const app = new Application<State>();

app.use(defaultRouter.routes(), defaultRouter.allowedMethods());

app.addEventListener("listen", ({ hostname, port, secure }) => {
  console.log(
    `Listening on ${secure ? "https://" : "http://"}${
      hostname || "localhost"
    }:${port}`,
  );
});

await app.listen({ port: 8080 });

¿Qué me estoy perdiendo aquí?

¡Gracias por tu ayuda de antemano!

Respuesta #1

Debido a la cantidad de tipos de Oak que utilizan genéricos (a menudo con parámetros de tipo por defecto), y específicamente la manera en que usan esos tipos para crear nuevos tipos (en algunos casos, sin permitirte proporcionar tipos para usar en los espacios que tienen valores por defecto y utilizando los valores por defecto en su lugar), tipar fuertemente el middleware de Oak puede ser extremadamente complicado, especialmente si tu objetivo principal es la estricta seguridad de tipos.

Tenga en cuenta que Oak es una API de conveniencia para servidores web orientados a middleware. Su propósito principal es manejar el código repetitivo y ayudarle a concentrarse en su código de aplicación, y — para ser justos — ha hecho algunos compromisos increíbles que proporcionan tipos realmente buenos al hacerlo. Sin embargo, los valores predeterminados utilizados en el sistema de tipos de Oak parecen estar sesgados hacia la conveniencia más que hacia la seguridad en algunos lugares para lograr eso, por lo que es importante tenerlo en cuenta.

Los genéricos a veces pueden resultar frustrantes, y una parte potencialmente complicada de esto es que no hay un solo tipo de "contexto" en Oak (depende de dónde se esté utilizando el contexto), y — de manera no trivial — no hay un solo tipo de "estado" (Oak te permite potencialmente crear datos de estado únicos para cada ciclo de solicitud/respuesta, ofreciéndote opciones para la estrategia que utiliza para inicializarlo — más sobre esto a continuación).

Comencemos desde la base de los tipos dependientes involucrados y avancemos hacia arriba para obtener una comprensión completa. Comenzando con el app:

El tipo de Application se ve así:

class Application<AS extends State = Record<string, any>> extends EventTarget {
  constructor(options?: ApplicationOptions<AS, ServerRequest>);
  // --- snip ---
  state: AS;
  // --- snip ---
  use<S extends State = AS>(
    middleware: Middleware<S, Context<S, AS>>,
    ...middlewares: Middleware<S, Context<S, AS>>[]
  ): Application<S extends AS ? S : (S & AS)>;
  use<S extends State = AS>(
    ...middleware: Middleware<S, Context<S, AS>>[]
  ): Application<S extends AS ? S : (S & AS)>;
  // --- snip ---
}

El parámetro de tipo AS tiene una restricción de un alias de tipo State que se ve así:

type State = Record<string | number | symbol, any>;

(que básicamente es solo "cualquier objeto simple"). Ahora veamos las opciones utilizadas para crear la aplicación: ApplicationOptions, que se ve así:

interface ApplicationOptions <S, R extends ServerRequest> {
  contextState?:
    | "clone"
    | "prototype"
    | "alias"
    | "empty";
  keys?: KeyStack | Key[];
  logErrors?: boolean;
  proxy?: boolean;
  serverConstructor?: ServerConstructor<R>;
  state?: S;
}

Voy a omitir mostrar los tipos irrelevantes: ServerConstructor y KeyStack (que el módulo principal de Oak ni siquiera exporta, así que tendrías que ir a buscar el tipo tú mismo (¡malo!)).

La opción para definir la estrategia de cómo se crea el estado del contexto es ApplicationOptions#contextState, y la documentación en línea dice esto al respecto (también descrito usando un lenguaje ligeramente diferente aquí):

Determine cómo, al crear un nuevo contexto, debe aplicarse el estado de la aplicación. Un valor de "clone" establecerá el estado como un clon del estado de la aplicación. Cualquier propiedad no clonable o no enumerable no se copiará. Un valor de "prototype" significa que el estado de la aplicación se usará como el prototipo del estado del contexto, lo que significa que las propiedades superficiales en el estado del contexto no se reflejarán en el estado de la aplicación. Un valor de "alias" significa que .state de la aplicación y .state del contexto serán una referencia al mismo objeto. Un valor de "empty" inicializará el .state del contexto con un objeto vacío.

El valor predeterminado es "clone".

Vamos a dejar esto indefinido (lo que usa el valor predeterminado) al instanciar la aplicación. La discusión de estos algoritmos está fuera del alcance de la pregunta, pero es importante que seas consciente de cómo esto impacta el comportamiento de tu código porque cada configuración da lugar a un objeto de estado diferente para el contexto. Para revisar el código fuente sobre cómo se crea este valor, comienza aquí.

Debido a que no tienes ningún estado a nivel de aplicación, crear la aplicación (con estricta seguridad de tipos) se ve así:

// This means "an object with no property names and no values" (e.g. `{}`)
type EmptyObject = Record<never, never>;
type AppState = EmptyObject;

const app = new Application<AppState>({ state: {} });

Ten en cuenta que también puedes instanciarlo así:

const app = new Application<AppState>();

y Oak creará el objeto vacío para ti, pero prefiero que mi código sea explícito.

Si te refieres arriba al tipo por defecto que Oak usa para el parámetro de tipo genérico AS (en el caso de que no proporciones uno), verás que es Record<string, any>. Esta elección no es muy segura en cuanto a tipos, pero está ahí para hacer que el uso de datos de estado desconocidos o dinámicos sea más conveniente. La seguridad de tipos y la conveniencia no siempre están en conflicto, pero a menudo es el caso.

Lo anterior es importante para todo lo que sigue: significa que en cada enrutador y middleware, se debe usar el tipo AppState en lugar del parámetro genérico del tipo de estado de la aplicación (para el cual Oak usa el nombre de parámetro AS): de lo contrario, Oak proporciona el valor predeterminado menos seguro.

Ahora veamos el tipo de estado que planeas usar en cada uno de tus ciclos de solicitud-respuesta (que Oak llama el contexto):

type ContextState = {
  sessionID: number;
  userID: number;
};

Podrías notar que he renombrado el tipo de estado en tu ejemplo. Por supuesto, eres libre de nombrar las variables y tipos de tu programa como desees, pero quiero animarte a no anteponer cada tipo con I o T. Puede que hayas visto esto en el código de otras personas, pero en mi opinión, es solo ruido: es como anteponer cada variable de tu programa con v (por ejemplo, vDate, vName, vAmount, etc.): simplemente no es necesario. En su lugar, te animo a seguir la convención oficial de TypeScript de usar nombres significativos en PascalCase.

A continuación, veamos el Context de Oak. Se ve así:

class Context<S extends AS = State, AS extends State = Record<string, any>> {
  constructor(
    app: Application<AS>,
    serverRequest: ServerRequest,
    state: S,
    secure?,
  );
  // --- snip ---
  app: Application<AS>; // { state: AS }
  // --- snip ---
  state: S;
  // --- snip ---
}

Como puedes ver, el tipo de estado del contexto y el tipo de estado de la aplicación no son necesariamente los mismos.

y, a menos que dictes lo contrario, (quizás de manera insegura) Oak los establece de esa manera (o incluso menos seguros, como Record<string, any>).

Veamos también el otro tipo de contexto que proporciona Oak: RouterContext. Este tipo de contexto se utiliza dentro de los routers y es un tipo más específico que contiene información adicional sobre la información de la ruta: ruta, parámetros, etc. Se ve así:

interface RouterContext <
  R extends string,
  P extends RouteParams<R> = RouteParams<R>,
  S extends State = Record<string, any>,
> extends Context<S> {
  captures: string[];
  matched?: Layer<R, P, S>[];
  params: P;
  routeName?: string;
  router: Router;
  routerPath?: string;
}

No discutiré el tipo RouteParams aquí: es una utilidad de tipo algo compleja y recursiva que intenta construir un objeto de parámetros de ruta seguro en tipos a partir del parámetro literal de cadena de ruta R.

Como puedes ver arriba, este tipo extiende el tipo Context (extends Context<S>), pero solo le proporciona el tipo de estado del contexto (S), dejando el tipo de estado de la aplicación (AS) sin definir, lo que resulta en que se utilice el tipo por defecto (Record<string, any>). Este es el primer ejemplo que se ve aquí donde Oak elige no darte una manera de crear código seguro con tipos. Sin embargo, podemos crear nuestra propia versión que sí lo haga.

⚠️ Importante: La clase Layer no se exporta desde el módulo donde está definida en Oak, aunque se use en los tipos públicos (exportados) de Oak (¡de nuevo, muy mal!). Esto hace que sea imposible usarla en nuestro tipo de contexto personalizado más fuerte (a menos que recreemos manualmente el tipo), así que tendremos que hacer eso (¡qué dolor de cabeza!).

import {
  type Context,
  type RouteParams,
  Router,
  type RouterContext,
  type State as AnyOakState,
} from "https://deno.land/x/[email /mod.ts";

type EmptyObject = Record<never, never>;

interface RouterContextStrongerState<
  R extends string,
  AS extends AnyOakState = EmptyObject,
  S extends AS = AS,
  P extends RouteParams<R> = RouteParams<R>,
> extends Context<S, AS> {
  captures: string[];
  matched?: Exclude<RouterContext<R, P, S>["matched"], undefined>;
  params: P;
  routeName?: string;
  router: Router;
  routerPath?: string;
}

Este tipo personalizado será importante para crear funciones de middleware de enrutador seguras en cuanto a tipos porque las utilidades proporcionadas por Oak para crearlas (Middleware y RouterMiddleware) sufren los mismos problemas de uso de parámetros de tipo predeterminados que el tipo RouterContext, y se ven así:

interface Middleware<
  S extends State = Record<string, any>,
  T extends Context = Context<S>,
> {
  (context: T, next: () => Promise<unknown>): Promise<unknown> | unknown;
}

interface RouterMiddleware<
  R extends string,
  P extends RouteParams<R> = RouteParams<R>,
  S extends State = Record<string, any>,
> {
  (
    context: RouterContext<R, P, S>,
    next: () => Promise<unknown>,
  ): Promise<unknown> | unknown;
  param?: keyof P;
  router?: Router<any>;
}

Esta respuesta ya ha cubierto mucho (¡y aún no hemos terminado!), así que es un buen momento para resumir lo que tenemos hasta ahora:

import {
  Application,
  type Context,
  type RouteParams,
  Router,
  type RouterContext,
  type State as AnyOakState,
} from "https://deno.land/x/[email /mod.ts";

type EmptyObject = Record<never, never>;
type AppState = EmptyObject;

const app = new Application<AppState>({ state: {} });

type ContextState = {
  userID: number;
  sessionID: number;
};

interface RouterContextStrongerState<
  R extends string,
  AS extends AnyOakState = EmptyObject,
  S extends AS = AS,
  P extends RouteParams<R> = RouteParams<R>,
> extends Context<S, AS> {
  captures: string[];
  matched?: Exclude<RouterContext<R, P, S>["matched"], undefined>;
  params: P;
  routeName?: string;
  router: Router;
  routerPath?: string;
}

Con todo eso cubierto, finalmente podemos comenzar a abordar la respuesta a tu pregunta directa que fue "¿Cómo tipar fuertemente el objeto de estado del contexto de Oak en Deno?"

Ahora, la respuesta real es (y esto podría resultarte frustrante): "depende de lo que suceda en tus funciones middleware, y del orden en que se invocan (encadenadas entre sí)". Esto se debe a que cada middleware puede modificar el estado y ese nuevo estado será lo que reciba el siguiente middleware. Eso es lo que hace que esto sea tan complicado y probablemente la razón por la que Oak eligió usar Record<string, any> por defecto.

Entonces, veamos qué está pasando en tu ejemplo y creemos tipos para representarlo. Tienes dos funciones middleware, y ambas están en un router.

La primera función:

  • asigna algunos valores a las propiedades en el objeto de estado del contexto, luego
  • espera a que se completen todos los middleware coincidentes posteriores (eso es lo que hace await next()), luego
  • elimina esas propiedades del objeto de estado del contexto

Una versión fuertemente tipada de esto se vería así:

También creamos un alias de tipo NextFn para la función next para que no tengamos que seguir escribiendo toda la firma de la función para otros middleware.

type NextFn = () => Promise<unknown>;

async function assignContextStateValuesAtTheBeginningAndDeleteThemAtTheEnd(
  context: Context<Partial<ContextState>, AppState>,
  next: NextFn,
): Promise<void> {
  context.state.userID = 123;
  //            ^? number | undefined
  context.state.sessionID = 456;
  //            ^? number | undefined
  await next(); // Wait for subsequent middleware to finish
  delete context.state.userID;
  delete context.state.sessionID;
}

Tenga en cuenta que uso la utilidad de tipo Partial<Type> en el ContextState (lo que establece todos sus miembros como opcionales). Esto es por dos razones:

  1. Esas propiedades no existen en el estado del contexto al inicio de la función (el estado es solo un objeto vacío en ese momento)

  2. Se eliminan del estado del contexto al final de la función, y si intentas eliminar una propiedad no opcional, obtendrás este error de diagnóstico de TS: El operando de un operador 'delete' debe ser opcional. deno-ts(2790)

La otra función está en la misma cadena de middleware después de la primera función, pero solo coincide con las solicitudes GET y en rutas que coinciden con /(.*).

Aparte: no estoy seguro de cuál es tu intención al definir la ruta de esa manera (si solo quieres que el enrutador coincida con las solicitudes GET, entonces puedes configurarlo usando la opción de instanciación del enrutador RouterOptions#methods). En cualquier caso, podría ser útil que sepas que la documentación de Oak indica que utiliza la biblioteca path-to-regexp para analizar estas cadenas de ruta.

Eso podría verse así:

function setEndpointNotFound(
  context: RouterContextStrongerState<"/(.*)", AppState, Partial<ContextState>>,
): void {
  context.response.status = 404;
  context.response.body = "Endpoint not available!";
}

Si quieres crear una función middleware donde esperas que la anterior haya establecido las propiedades del estado del contexto, puedes usar una función predicado de guardia de tipo:

function idsAreSet<T extends { state: Partial<ContextState> }>(
  contextWithPartialState: T,
): contextWithPartialState is T & {
  state: T["state"] & Required<Pick<T["state"], "sessionID" | "userID">>;
} {
  return (
    typeof contextWithPartialState.state.sessionID === "number" &&
    typeof contextWithPartialState.state.userID === "number"
  );
}

async function someOtherMiddleware(
  context: Context<Partial<ContextState>, AppState>,
  next: NextFn,
): Promise<void> {
  // In the main scope of the function:
  context.state.userID;
  //            ^? number | undefined
  context.state.sessionID;
  //            ^? number | undefined

  if (idsAreSet(context)) {
    // After the type guard is used, in the `true` path scope:
    context.state.userID;
    //            ^? number
    context.state.sessionID;
    //            ^? number
  } else {
    // After the type guard is used, in the `false` path scope:
    context.state.userID;
    //            ^? number | undefined
    context.state.sessionID;
    //            ^? number | undefined
  }

  await next();
}

Es hora de crear el enrutador y usar nuestro middleware; es muy sencillo:

const router = new Router<Partial<ContextState>>();
router.use(assignContextStateValuesAtTheBeginningAndDeleteThemAtTheEnd);
router.get("/(.*)", setEndpointNotFound);

Y usar el router en la aplicación es igual de sencillo (directamente desde el código en tu pregunta):

app.use(router.routes(), router.allowedMethods());

Finalmente, terminemos comenzando el servidor.

El código en tu pregunta incluye una función de callback del evento listen de la aplicación que registra la dirección del servidor en la consola. Supongo que lo copiaste del ejemplo en la documentación. Es una función útil si quieres ver un mensaje en el stdout de tu consola cuando tu servidor se inicia. Aquí hay una ligera variación que utiliza el constructor URL para que, si llegas a escuchar en el puerto por defecto para el protocolo que estás usando (por ejemplo, 80 en http, 443 en https), esa parte se omitirá de la dirección en el mensaje (tal como en la dirección que te muestra tu navegador). También resulta ser compatible con el callback onListen usado por la función serve de la biblioteca std de Deno:

function printStartupMessage({ hostname, port, secure }: {
  hostname: string;
  port: number;
  secure?: boolean;
}): void {
  if (!hostname || hostname === "0.0.0.0") hostname = "localhost";
  const address =
    new URL(`http${secure ? "s" : ""}://${hostname}:${port}/`).href;
  console.log(`Listening at ${address}`);
  console.log("Use ctrl+c to stop");
}

app.addEventListener("listen", printStartupMessage);

Y... inicia el servidor:

await app.listen({ port: 8080 });

Aquí está el resultado completo del módulo de lo que se discutió arriba:

import {
  Application,
  type Context,
  type RouteParams,
  Router,
  type RouterContext,
  type State as AnyOakState,
} from "https://deno.land/x/[email /mod.ts";

type EmptyObject = Record<never, never>;
type AppState = EmptyObject;

const app = new Application<AppState>({ state: {} });

type ContextState = {
  userID: number;
  sessionID: number;
};

interface RouterContextStrongerState<
  R extends string,
  AS extends AnyOakState = EmptyObject,
  S extends AS = AS,
  P extends RouteParams<R> = RouteParams<R>,
> extends Context<S, AS> {
  captures: string[];
  matched?: Exclude<RouterContext<R, P, S>["matched"], undefined>;
  params: P;
  routeName?: string;
  router: Router;
  routerPath?: string;
}

type NextFn = () => Promise<unknown>;

async function assignContextStateValuesAtTheBeginningAndDeleteThemAtTheEnd(
  context: Context<Partial<ContextState>, AppState>,
  next: NextFn,
): Promise<void> {
  context.state.userID = 123;
  context.state.sessionID = 456;
  await next();
  delete context.state.userID;
  delete context.state.sessionID;
}

function setEndpointNotFound(
  context: RouterContextStrongerState<"/(.*)", AppState, Partial<ContextState>>,
): void {
  context.response.status = 404;
  context.response.body = "Endpoint not available!";
}

const router = new Router<Partial<ContextState>>();
router.use(assignContextStateValuesAtTheBeginningAndDeleteThemAtTheEnd);
router.get("/(.*)", setEndpointNotFound);

app.use(router.routes(), router.allowedMethods());

// This is not necessary, but is potentially helpful to see in the console
function printStartupMessage({ hostname, port, secure }: {
  hostname: string;
  port: number;
  secure?: boolean;
}): void {
  if (!hostname || hostname === "0.0.0.0") hostname = "localhost";
  const address =
    new URL(`http${secure ? "s" : ""}://${hostname}:${port}/`).href;
  console.log(`Listening at ${address}`);
  console.log("Use ctrl+c to stop");
}

app.addEventListener("listen", printStartupMessage);

await app.listen({ port: 8080 });

Es bastante información, pero sucede mucho dentro de un framework de servidor web, ¡y hacerlo de manera segura con tipos es aún más complicado!

Artículos relacionados