Skip to content

Instantly share code, notes, and snippets.

@gugadev
Created July 15, 2023 21:54
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gugadev/97cc3304ba3cd5984db13d8c4246dced to your computer and use it in GitHub Desktop.
Save gugadev/97cc3304ba3cd5984db13d8c4246dced to your computer and use it in GitHub Desktop.
Small utility to remote data fetching using polymorfism to allow distinct implementations.
/**
* @description Constituye un catálogo de errores de una API.
* Un catálogo es un mapa en donde se asocia un código de error
* con un mensaje describiendo el problema que se ha originado.
* Por ejemplo:
* {
* 24323: 'Ha ocurrido un error consumiendo el servicio externo ABC',
* 13424: 'No se encontraron coincidencias para la búsqueda'
* }
*/
export class ErrorCatalog {
constructor(public catalog: Record<number, string>) {}
getErrorMessage(errorCode: number): string {
return this.catalog[errorCode];
}
}
/**
* @description Este tipo solamente es utilizado para tipificar la respuesta
* del servicio cuando esta no es exitosa (diferente a 200).
* Está implícito que la respuesta del servidor debe retornar un
* campo 'code', el cual represente al código de error del catálogo.
*/
interface ResponseError {
code: number;
}
/**
* @description Representa la respuesta que se va a transmitir al
* repositorio. Esta puede contener una respuesta o un error.
* @field error: representa una instancia de UnknownRequestError,
* BadRequestError, NotFoundError o InternalServerError.
*/
class RemoteResponse<T> {
constructor(public status: number, public body?: T, public error?: Error) {}
}
/**
* @description Representa un error en la respuesta del servidor.
* Este error es usado en RemoteResponse para informar del error
* a capas exteriores.
*/
class RemoteResponseError<T> extends Error {
constructor(
// public message: string,
public statusCode: number,
public errorCode: number,
public response: T | undefined
) {
super("");
}
}
/**
* @description representa un error desconocido durante una
* petición, es decir, cuando el código HTTP es disinto
* a 400, 404 o 500.
*/
export class UnknownRequestError extends Error {
constructor(stackTrace?: string) {
super("Error desconocido");
this.stack = stackTrace;
}
}
/**
* @description representa un error HTTP 400.
*/
export class BadRequestError extends Error {
constructor(message?: string, stackTrace?: string) {
super(message ?? "Petición inválida o mal formada");
this.stack = stackTrace;
}
}
/**
* @description representa un error HTTP 404.
*/
export class NotFoundError extends Error {
constructor(stackTrace?: string) {
super("El recurso no fue encontrado");
this.stack = stackTrace;
}
}
/**
* @description representa un error HTTP 500.
*/
export class InternalServerError extends Error {
constructor(message?: string, stackTrace?: string) {
super(message ?? "Ocurrió un error en el servidor");
this.stack = stackTrace;
}
}
/**
* @description se utiliza cuando se espera una respuesta
* del servicio pero no se obtiene nada.
*/
export class NoResponseError extends Error {
constructor() {
super("No se obtuvo una respuesta del servidor");
}
}
export type RemoteConsumerRequestProps = Omit<
RequestInit,
"headers" | "body"
> & {
headers?: Record<string, unknown>;
body?: Record<string, unknown>;
method?: "get" | "post" | "put" | "patch" | "delete";
catalog?: ErrorCatalog;
};
export interface RemoteConsumer {
request<T>(
url: string,
props?: RemoteConsumerRequestProps
): Promise<RemoteResponse<T | undefined>>;
}
export class RestConsumer implements RemoteConsumer {
async request<T>(
endpoint: string,
{
headers,
catalog,
body = {},
method = "get",
}: RemoteConsumerRequestProps = {}
): Promise<RemoteResponse<T | undefined>> {
try {
const response = await fetch(endpoint, {
method,
headers: headers as unknown as HeadersInit | undefined,
...(method === "get"
? {}
: { body: body as unknown as BodyInit | undefined }),
});
const contentType = response.headers.get("Content-Type");
const errorResponseCodes = [400, 404, 500];
let data: unknown;
if (contentType?.toLowerCase().includes("application/json")) {
data = await response.json();
} else if (contentType?.toLowerCase().includes("arraybuffer")) {
data = await response.arrayBuffer();
} else {
data = await response.text();
}
if (errorResponseCodes.includes(response.status)) {
throw new RemoteResponseError<T>(
//"Ocurrió un error en la respuesta del servicio",
response.status,
(data as ResponseError).code,
data as T
);
}
return new RemoteResponse(200, data as T);
} catch (e) {
if (e instanceof RemoteResponseError) {
const error = e as RemoteResponseError<T>;
let customError: Error;
let errorMessage: string | undefined;
if (catalog && error.response) {
errorMessage = catalog.getErrorMessage(error.statusCode);
}
switch (error.errorCode) {
case 400: {
customError = new BadRequestError(errorMessage, error.stack);
break;
}
case 404: {
customError = new NotFoundError(error.stack);
break;
}
case 500: {
customError = new InternalServerError(errorMessage, error.stack);
break;
}
}
return new RemoteResponse(
error.statusCode,
error.response,
customError!
);
}
return new RemoteResponse(
0,
(e as Error).message as T,
new UnknownRequestError((e as Error).stack)
);
}
}
}
// TODO: implementar
class GraphQLConsumer implements RemoteConsumer {
request<T>(
url: string,
props?: RemoteConsumerRequestProps
): Promise<RemoteResponse<T | undefined>> {
throw new Error("Method not implemented.");
}
}
@gugadev
Copy link
Author

gugadev commented Jul 15, 2023

You should use as follows:

Entity

export class User {
  constructor(
    public name: string,
    public username: string,
    public company: string,
    public website?: string
  ) {}

  static fromJson(json: Record<string, unknown>): User {
    return new User(
      json["name"]! as string,
      json["username"]! as string,
      (json["company"] as Record<string, string>)["name"],
      json["website"] as string
    );
  }

  copyWith(
    name?: string,
    username?: string,
    company?: string,
    website?: string
  ): User {
    return new User(
      name ?? this.name,
      username ?? this.username,
      company ?? this.company,
      website ?? this.website
    );
  }
}

Repository

export class UsersRepository implements IUsersRepository {
  constructor(private remote: RemoteConsumer, private catalog?: ErrorCatalog) {}

  async getAll(): Promise<Array<Record<string, unknown>>> {
    const token = localStorage.getItem("auth_token");
    const url = "https://jsonplaceholder.typicode.com/users";
    const response = await this.remote.request<Array<Record<string, unknown>>>(
      url,
      {
        catalog: this.catalog,
      }
    );
    if (response.error) {
      throw response.error;
    }
    if (!response.body) {
      throw new NoResponseError();
    }
    console.log(response);
    return response.body;
  }
}

export interface IUsersRepository {
  getAll(): Promise<Array<Record<string, unknown>>>;
}

Use cases

export class GetAllUsersUseCase implements IGetAllUsersUseCase {
  constructor(private repository: IUsersRepository) {}

  async execute(): Promise<Array<User>> {
    const rawList = await this.repository.getAll();
    return rawList.map(User.fromJson);
  }
}

export interface IGetAllUsersUseCase {
  execute(): Promise<Array<User>>;
}

@gugadev
Copy link
Author

gugadev commented Jul 15, 2023

Finally you can use it with you framework of preference.

React

export function useGetUsers(useCase: IGetAllUsersUseCase) {
  return useQuery({
    queryKey: ["getAllUsers"],
    queryFn: () => useCase.execute(),
  });
}

function App() {
  // better: use DI
  // const { ... } = useGetAllUsers(di.resolve(GetAllUsersUseCase))
  const {
    data: users,
    error,
    isLoading,
  } = useGetUsers(
    new GetAllUsersUseCase(
      new UsersRepository(
        new RestConsumer(),
        new ErrorCatalog({
          1001: "Error de prueba",
          1002: "Otro error de prueba",
        })
      )
    )
  );

  const raiseError(message: string) {
    // raise a modal or something
  }

  useEffect(() => {
    if (error) {
      raiseModal(error.message)
    }
  }, [error]);

  return (
    <div className="App">
      {!users && isLoading && <h1>Cargando...</h1>}
      {users && (
        <table>
          <thead>
            <tr>
              <th>Nombre</th>
              <th>Email</th>
              <th>Compañía</th>
              <th>Sitio web</th>
            </tr>
          </thead>
          <tbody>
            {users.map((user) => (
              <tr key={user.username}>
                <td>{user.name}</td>
                <td>{user.username}</td>
                <td>{user.company}</td>
                <td>{user.website}</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </div>
  );
}

Angular

class UsersComponent implements OnInit {
    users: Array<User> = []

    constructor(private useCase: IGetAllUsersUseCase) {}

    private raiseError(message: string) {
      // raise modal or something
    }

    ngOnInit(): void {
        this.useCase.execute().subscribe({
            next(users) {
                this.users = users
            },
            error(error) {
                this.raiseError(error.message)   
            }
        })
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment