Pollito Blog
March 24, 2024

Anime Poster Generator 4: Cómo un backend intenta hacer frontend

Posted on March 24, 2024  •  16 minutes  • 3289 words  • Other languages:  English

La imagen en miniatura no tiene ninguna relación con el contenido del blog. Es solo una foto que tomé del arte mural de la estación de metro Cais do Sodré .

Esta es una continuación de Anime Poster Generator 3: Puedo hacer frontend .

Todo el código mostrado aquí puedes encontrarlo en el repo de github anime-poster-generator-frontend .

¿Cómo abordé este desafío?

Creo que en cualquier situación es importante identificar primero las limitaciones y encontrar estrategias para solucionarlas. Aquí estaban los míos:

Objetivos

Hice este proyecto frontend con tres objetivos principales en mente:

anime-poster-generator-frontend repo

El repositorio se creó siguiendo shadcn/ui Next.js docs , por lo que su estructura de carpetas es similar a la mayoría de los proyectos de Next.js.

repo files

Ejemplo de un componente .tsx

No explicaré en profundidad cada archivo, porque hay muchos archivos. Pero principalmente, para ser honesto, la mayor parte de la explicación de los archivos tsx sería “Busqué en Google componente que necesitaba en tailwind, copié, pegué, cambié lo que se necesitaba para que encajara, y seguí adelante.

Ejemplo: el archivo src\components\animePosterGenerator\anime\mal_id\animeData.tsx es responsable de esta vista:

animeData

Una imagen a la izquierda, información a la derecha. En la información, podemos ver un texto similar a un título, algo de información debajo del título y un párrafo de descripción… Esto es lo mismo que el detalle de un producto en la página de una tienda.

Después de buscar “tailwind detalles del producto” y desplazarme por algunas opciones, elegí ésta :

product-detail

Puedes ver un poco el parecido, especialmente cuando miras las clases de tailwind en el código. Pero hay que darle crédito a quien lo merece (yo), me esforcé un poco para que se adaptara a lo que estaba buscando. Entonces, sí, el código se ve similar, pero no es un copy-paste.

Aquí está el código final del componente:

import Link from "next/link";
import { AnimeFull } from "../../../../../generated/rtk-query/jikanApi";
import { ExternalLink } from "lucide-react";
import { Fragment } from "react";

const Separator = () => (
  <div>
    <span className="text-5xl">·</span>
  </div>
);

const AnimeData = ({ data }: { data: AnimeFull }) => {
  const stats = [
    { stat: "Score", value: data.score },
    { stat: "Ranked", value: `#${data.rank}` },
    { stat: "Popularity", value: `#${data.popularity}` },
  ];

  return (
    <>
      <h2 className=" text-3xl title-font font-medium mb-1">{data.title}</h2>
      <div className="flex space-x-3 mb-4 items-center">
        {stats.map((item, index) => (
          <Fragment key={index}>
            {index !== 0 && <Separator />}
            <div className="flex flex-col items-center">
              <div>
                <span>{item.stat}</span>
              </div>
              <div>
                <span className="font-bold">{item.value ?? "-"}</span>
              </div>
            </div>
          </Fragment>
        ))}
        {data.url && (
          <>
            <Separator />
            <div className="flex flex-col items-center">
              <Link href={data.url} target="_blank">
                <ExternalLink strokeWidth={2} />
              </Link>
            </div>
          </>
        )}
      </div>
      <p className="leading-relaxed">{data.synopsis}</p>
    </>
  );
};

export default AnimeData;

¿Qué es /generated?

En el último fragmento de código, puedes encontrar un import interesante:

import { AnimeFull } from "../../../../../generated/rtk-query/jikanApi";

¿Qué podría ser? Por el contexto, podemos suponer que es un tipo de TypeScript generado, que tiene algo que ver con rtk-query y jikanApi. Sólo para estar seguros, pidamos a ChatGPT su opinión al respecto.

¿Cuál es el papel de AnimeFull en el código de este componente?

Aquí está la respuesta dada:

En el código del componente proporcionado, AnimeFull parece ser un tipo TypeScript importado de un directorio generado, probablemente el resultado de una herramienta de generación automática de código que interactúa con la API Jikan, una API no oficial de MyAnimeList. Este tipo se utiliza para escribir fuertemente el prop data en el componente AnimeData, asegurando que el objeto pasado como data se adhiera a la estructura y los tipos definidos por el tipo AnimeFull.

Esta tipificación fuerte proporciona varios beneficios:

  1. Seguridad de tipos: Garantiza que el componente AnimeData reciba datos en el formato esperado, que incluye campos específicos como score, rank, popularity, title, url, y sinopsis. Esto ayuda a evitar errores de tiempo de ejecución debido a estructuras o tipos de datos inesperados.
  1. Autocompletado e Intellisense: en un entorno de desarrollo que admita TypeScript, los desarrolladores que trabajan con el componente AnimeData pueden disfrutar del autocompletado y de la documentación en línea para el prop data, lo que facilita la comprensión de qué datos están disponibles y cómo se puede utilizar.
  1. Mantenibilidad: Al definir claramente la estructura esperada del prop “data”, el código se vuelve más fácil de mantener y actualizar. Si la estructura de “AnimeFull” cambia, TypeScript resaltará los errores en los que el componente “AnimeData” puede no ser compatible con la nueva estructura, guiando a los desarrolladores hacia las actualizaciones necesarias.
  1. Documentación: El uso de un tipo específico como AnimeFull sirve como una forma de documentación, aclarando el contenido esperado y el formato de los datos que el componente necesita para funcionar correctamente.

En resumen, AnimeFull en este contexto define la estructura y los tipos de datos esperados por el componente AnimeData, contribuyendo a la seguridad de tipos, un desarrollo más fácil y la mantenibilidad del código.

Incluso sabe qué es jikanApi sin que yo ni siquiera se lo diga ni se lo pida. Estas IA son cada vez más inteligentes.

Además, al observar la estructura de carpetas, hay algunos archivos y carpetas extraños que no tienen nada que ver con Next.js:

repo files w/red

RTK Query

Para implementar prácticas de desarrollo basado en contratos, quería encontrar una herramienta similar a lo que openapi-generator-maven-plugin es en Java Spring Boot.

openapi-generator-maven-plugin.png

Después de mirar algunas opciones, me decidí por RTK Query y sus capacidades de Code Generation .

Al acceder a la página principal de RTK Query, te encuentras con esto:

El conjunto de herramientas oficial, opinionado y con baterías incluidas para el desarrollo eficiente de Redux

No podría estar más feliz de leer eso, aunque no tenía idea de lo que significaba “desarrollo Redux”.

Intenté saltar al infierno de los tutoriales, pero decidí que no valía la pena y, en cambio, seguí ejemplos que encontré en Internet + ChatGPT + sentido común como desarrollador. Gran reconocimiento a Steven Lemon y su blog sobre Generación de código en React with RTK Query .

Entonces, ¿hace el trabajo esperado? ¡Sí! Pero necesita algunos ajustes.

El código generado debe ser consistente

¿Qué quiero decir con eso? Al seguir el blog de Steven y los documentos de Code Generation Redux, obtendrás un archivo con el código completo listo para usar:

outline

Genial, ¿no? Sí, pero se espera que el dev lo guarde y sea responsable de ello.

Quizás pienses “Eso no es gran cosa, git add . git commit, listo”. Pero aquí está el truco. ¿Qué pasaría si en el futuro algún desarrollador quisiera hacer trampa para evitar una advertencia o un error? (el clasico number|undefined can’t be assigned to a number const). La forma más sencilla sería cambiar el lugar donde está la definición.

Podemos llamar a esto una acción irresponsable y culpar al desarrollador de ser vago. Pero…

Aquí la culpa es más de los desarrolladores originales por no dejar clara la intención del archivo. Y no, nombrar el archivo algo_GENERADO.ts no es suficiente.

¿Y si cambia el contrato? Ahora tengo que regenerar el código, pero si alguien más escribió encima del código generado previamente, todo eso se sobrescribirá y desaparecerá.

Estos son mis criterios para lo que se genera el código:

El código generado debe ser de solo lectura, desechable y generado.

Este enfoque garantiza que se mantengan la integridad y la coherencia de las bases de código, evitando los peligros de las modificaciones manuales que podrían introducir errores o inconsistencias.

Garantizar la coherencia

Hacer que el código generado sea de solo lectura y desechable es extremadamente fácil: simplemente agregar al .gitignore

El desafío aquí es hacer que el código generado, bueno, se genere. Si se va a eliminar el código, necesito una forma confiable de generarlo una y otra vez.

En Java Spring Boot, openapi-generator-maven-plugin logra esto adjuntándose a la tarea de compilación de maven. Entonces tal vez podría recrear este comportamiento con un script package.json, pero ¿cómo? Una vez más, ChatGPT viene al rescate.

Después de algunos intercambios entre el código de ChatGPT y mi comportamiento deseado, nació generateRtkQuery.ts.

import {
  existsSync,
  mkdirSync,
  writeFileSync,
  readdirSync,
  unlinkSync,
} from "fs";
import { exec } from "child_process";
import { join } from "path";

const preProcessApi = () => {
  const directoryPath = "./generated/rtk-query";

  if (existsSync(directoryPath)) {
    const files = readdirSync(directoryPath);

    for (const file of files) {
      unlinkSync(join(directoryPath, file));
    }
    console.log(`All files cleared in: ${directoryPath}`);
  } else {
    mkdirSync(directoryPath, { recursive: true });
    console.log(`Directory created at: ${directoryPath}`);
  }
};

const processApi = (apiName: string) => {
  const directoryPath = "./generated/rtk-query";
  const filePath = `${directoryPath}/${apiName}Api.ts`;

  writeFileSync(filePath, "");

  const command = `npx @rtk-query/codegen-openapi ./src/schemas/openapi/${apiName}-config.ts`;

  exec(command, (error, stdout, stderr) => {
    if (error) {
      console.error(`exec error: ${error}`);
      return;
    }
    console.log(`stdout: ${stdout}`);
    console.error(`stderr: ${stderr}`);
  });
};

preProcessApi();
const apiNames = ["jikan", "animePosterGeneratorBackend"];
apiNames.forEach((apiName) => {
  processApi(apiName);
});

Este código TypeScript define dos funciones, preProcessApi y processApi, y luego ejecuta una secuencia de operaciones que involucran estas funciones para administrar y generar código API basado en las especificaciones OpenAPI para un conjunto de APIs. Aquí hay un desglose de lo que hace cada parte:

preProcessApi:

processApi:

Finalmente, creo un nuevo script npm:

"generate-apis": "tsc generateRtkQueryCode.ts && node generateRtkQueryCode.js"

No olvide combinar el reducer del cliente y concatenar el middleware en el store… sea lo que sea que eso signifique. No me molesté en aprender cómo funciona redux, solo seguí la documentación.

import {
  combineReducers,
  configureStore,
  Reducer,
  UnknownAction,
} from "@reduxjs/toolkit";
import { jikanClient } from "@/clients/jikanClient";
import { animePosterGeneratorBackendClient } from "@/clients/animePosterGeneratorBackendClient";

const combinedReducer = combineReducers({
  [jikanClient.reducerPath]: jikanClient.reducer,
  [animePosterGeneratorBackendClient.reducerPath]:
    animePosterGeneratorBackendClient.reducer,
});

const rootReducer: Reducer = (state: RootState, action: UnknownAction) => {
  if (action.type === "store/reset") {
    return {} as RootState;
  }
  return combinedReducer(state, action);
};

export const makeStore = () => {
  return configureStore({
    reducer: rootReducer,
    middleware: (getDefaultMiddleware) =>
      getDefaultMiddleware()
        .concat(jikanClient.middleware)
        .concat(animePosterGeneratorBackendClient.middleware),
  });
};

export type AppStore = ReturnType<typeof makeStore>;
export type RootState = ReturnType<AppStore["getState"]>;
export type AppDispatch = AppStore["dispatch"];

Aquí hay un diagrama rápido de cómo funciona todo. Algo similar al plugin openapi-generator-maven, pero con sus cosas propias de JavaScript.

redux generate diagram

Aquí hay un fragmento de un código generado que se está utilizando:

"use client";

import {
  DEFAULT_animeSearchQueryOrderbyValues,
  DEFAULT_searchQuerySortValues,
  SearchAnimeForm,
} from "@/components/animePosterGenerator/search/searchAnimeForm";
import { useSearchParams } from "next/navigation";
import {
  AnimeSearchQueryOrderby,
  SearchQuerySort,
  useGetAnimeSearchQuery,
} from "../../../generated/rtk-query/jikanApi";
import Loading from "@/components/animePosterGenerator/layout/loading";
import { AlertDestructive } from "@/components/animePosterGenerator/layout/alertDestructive";
import AnimeBentoGrid from "@/components/animePosterGenerator/search/animeBentoGrid";
import PaginationAnimeSearch from "@/components/animePosterGenerator/search/paginationAnimeSearch";

const SearchPage = () => {
  const searchParams = useSearchParams();

  const getAnimeSearchApiArg = {
    q: searchParams.get("q") || "",
    sort: (searchParams.get("sort") ||
      DEFAULT_searchQuerySortValues) as SearchQuerySort,
    orderBy: (searchParams.get("orderBy") ||
      DEFAULT_animeSearchQueryOrderbyValues) as AnimeSearchQueryOrderby,
    limit: 9,
    page: Number(searchParams.get("page")) || 1,
  };

  const { data, isLoading, isError, error } = useGetAnimeSearchQuery(
    getAnimeSearchApiArg,
    {
      skip:
        !getAnimeSearchApiArg.q ||
        !getAnimeSearchApiArg.sort ||
        !getAnimeSearchApiArg.orderBy ||
        !getAnimeSearchApiArg.page,
    }
  );

  if (isLoading) {
    return <Loading />;
  }

  if (isError || !data) {
    return (
      <AlertDestructive alertDescription={JSON.stringify(error, null, 2)} />
    );
  }

  return (
    <div className="grid gap-4">
      <div className="flex justify-center">
        <SearchAnimeForm getAnimeSearchApiArg={getAnimeSearchApiArg} />
      </div>
      <AnimeBentoGrid data={data.data} />
      {data.pagination && (
        <PaginationAnimeSearch
          paginationPlus={data}
          getAnimeSearchApiArg={getAnimeSearchApiArg}
        />
      )}
    </div>
  );
};

export default SearchPage;

Esto es mucho trabajo, ¿por qué no useEffect + useState?

Si vienes del infierno de los tutoriales de React (todos hemos estado allí), puedes pensar: “Puedo hacer fetch de datos con un useEffect y almacenar el resultado en un useState, no es tan complicado y parece requerir menos trabajo”.

react fetch data example

Permítanme responder eso con una cita del video “React for Haters”:

useEffect is specially fun and was originally going to be called useFootGun.

En React, especialmente cuando se trata de recuperar datos, es extremadamente fácil hacerlo mal. Entonces necesitaré que confies en mí: usa una biblioteca. Aquí hay un vídeo de Theo hablando más sobre ello:

¿Qué hacer cuando el código generado no hace lo que necesitas?

Tuve esta situación: cuando solicito un póster a anime-poster-generator-backend, responde un application/pdf (en el mundo javascript, un blob ).

Por alguna razón mágica de JavaScript, lamentablemente el endpoint generado no era lo suficientemente bueno: le faltaba un responseHandler.

La solución en sí es muy sencilla: agregue un responseHandler que abra el blob en una nueva pestaña. El problema es que eso implicaba modificar el código generado automáticamente… ¡Eso va totalmente en contra de lo que dije anteriormente en este blog!

¿Cómo proceder entonces? injectEndpoints en el cliente ! ¿Qué es inyectar un punto final? ¿Es esto Inyección de dependencia ? ¿Cómo funciona esto? Me encantaría responder todo eso, pero no tengo idea. Solo soy un desarrollador backend que hace lo que mejor sabe: hacer que las cosas funcionen.

Este es el resultado: una copia y pegado del endpoint generado + el responseHandler que faltaba.

import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import {
  MakePosterApiArg,
  MakePosterApiResponse,
} from "../../generated/rtk-query/animePosterGeneratorBackendApi";

export const animePosterGeneratorBackendClient = createApi({
  reducerPath: "animePosterGeneratorBackendClient",
  baseQuery: fetchBaseQuery({
    baseUrl: process.env.NEXT_PUBLIC_ANIME_POSTER_GENERATOR_BACKEND_BASE_URL,
  }),
  endpoints: () => ({}),
});

//created custom endpoint that is able to treat blobs
//based on https://github.com/reduxjs/redux-toolkit/issues/1522#issuecomment-1167482553
const injectedRtkApi = animePosterGeneratorBackendClient.injectEndpoints({
  endpoints: (build) => ({
    makePosterAsBlob: build.mutation<MakePosterApiResponse, MakePosterApiArg>({
      query(args) {
        return {
          url: `/poster`,
          method: "POST",
          body: args.posterContent,
          responseHandler: async (response) => {
            const url = window.URL.createObjectURL(await response.blob());
            window.open(url, "_blank");
          },
          cache: "no-cache",
        };
      },
    }),
  }),
  overrideExisting: false,
});

export const { useMakePosterAsBlobMutation } = injectedRtkApi;

Entonces, ahora, cuando necesito useMakePosterMutation, solo tengo que importar el que creé en lugar del generado automáticamente. Esto tiene un inconveniente: tuve que escribir un endpoint, en lugar de depender de uno generado automáticamente. Eso es tiempo perdido y será necesario mantenerlo si las cosas cambian.

import { SubmitHandler, useForm } from "react-hook-form";
import {
  MakePosterApiArg,
  PosterContent,
} from "../../../../../generated/rtk-query/animePosterGeneratorBackendApi";
import { useMakePosterAsBlobMutation } from "@/clients/animePosterGeneratorBackendClient";
import { Form } from "@/components/ui/form";
import { Button } from "@/components/ui/button";

const GenerateWithDefaultImage = ({
  posterContent,
}: {
  posterContent: PosterContent;
}) => {
  const form = useForm<MakePosterApiArg>({ defaultValues: { posterContent } });
  const [makePosterAsBlob] = useMakePosterAsBlobMutation();

  const onSubmit: SubmitHandler<MakePosterApiArg> = (makePosterApiArg) => {
    makePosterAsBlob(makePosterApiArg);
  };
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        <div className="grid w-full max-w-sm items-center gap-1.5">
          <Button type="submit">Generate Poster</Button>
        </div>
      </form>
    </Form>
  );
};

export default GenerateWithDefaultImage;

¿Que sigue?

Dejaré la serie aquí por un tiempo. Al regresar, quiero implementarlo en alguna parte. Ahora mismo estaré ocupado con:

Así que sí… al menos escribí 7 blogs durante marzo. Esta fue una buena racha productiva. ¡Nos vemos! <🐤/>

terreiro do paco

Hey, check me out!

You can find me here