No siempre hay un API lista para consumir del lado del servidor, así que vamos a explorar distintas opciones en Angular para poder sobrevivir del lado cliente sin tener que esperar a que los “otros” hagan su trabajo.
En un mundo ideal deberíamos usar la especificación OpenAPI y un enfoque API-first, pero para este post usaré código de servidor (que por supuesto no estará disponible para el front) para explicar el contrato:
app.MapGet("/todos", (string? title, string? description, bool? isCompleted) =>
{
return GetTodos(title, description, isCompleted, todos);
});
// ...
public class Todo(int id, string title, string? description, bool isCompleted)
{
public int Id { get; init; } = id;
public string Title { get; init; } = title;
public string? Description { get; init; } = description;
public bool IsCompleted { get; init; } = isCompleted;
}
Es resumen, un endpoint GET todos?title=&description=&isCompleted
que devolverá una lista de Todo
.
La versión de Angular que estoy usando es la 19.0.2.
todo.ts
será como sigue:
export interface Todo {
id: number;
title: string;
description: string | null;
isCompleted: boolean;
}
En cuando al componente, va a ser siempre el mismo con independencia del servicio, es decir, a él esto de mockear la API ni le va ni le viene.
import { Component } from '@angular/core';
import { TodoService } from '../todo.service';
import { CommonModule } from '@angular/common';
import { Observable, of } from 'rxjs';
import { Todo } from '../todo';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-todo-list',
imports: [CommonModule, ReactiveFormsModule],
template: `
<form [formGroup]="searchForm" (ngSubmit)="onSubmit()">
<div>
<label for="title">Title:</label>
<input id="title" type="text" formControlName="title">
</div>
<div>
<label for="description">Description:</label>
<input id="description" type="text" formControlName="description">
</div>
<div>
<label for="isCompleted">Completed:</label>
<select id="isCompleted" formControlName="isCompleted">
<option value=""></option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
<button type="submit">Search</button>
</form>
<ul>
@for (todo of (todos$ | async); track todo.id) {
<li>
{{ todo | json }}
</li>
}
</ul>
`,
styleUrl: './todo-list.component.scss'
})
export class TodoListComponent {
todos$: Observable<Todo[]> = of([]);
searchForm: FormGroup;
constructor(public todoService: TodoService, private fb: FormBuilder) {
this.searchForm = this.fb.group({
title: [''],
description: [''],
isCompleted: ['']
});
}
onSubmit() {
const { title, description, isCompleted } = this.searchForm.value;
const completedValue = isCompleted === '' ? undefined :
isCompleted === 'true' ? true : false;
this.todos$ = this.todoService.getTodos(title, description, completedValue);
}
}
Y es a partir de aquí donde tenemos que mockear la API, que se la espera pero no está.
Básicamente vamos a usar dos aproximaciones distintas, usando un servicio u usando un interceptor.
Con el servicio sería algo así:
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { Todo } from './todo';
@Injectable({
providedIn: 'root'
})
export class TodoService {
private readonly todos = [
{ id: 1, title: 'Todo 1', description: 'Description 1', isCompleted: true },
{ id: 2, title: 'Todo 2', description: 'Description 2', isCompleted: false },
{ id: 3, title: 'Todo 3', description: 'Description 3', isCompleted: true },
];
getTodos(title?: string, description?: string, isCompleted?: boolean): Observable<Todo[]> {
let filteredTodos = this.todos;
if (title) {
filteredTodos = filteredTodos.filter(todo => todo.title.includes(title));
}
if (description) {
filteredTodos = filteredTodos.filter(todo => todo.description?.includes(description));
}
if (isCompleted !== undefined) {
filteredTodos = filteredTodos.filter(todo => todo.isCompleted === isCompleted);
}
return of(filteredTodos);
}
}
Hay algunas variaciones que podría interesarnos, como guardar los datos en un fichero .json
o usar directamente HttpClient
.
Para usar un fichero .json
, se importa y ¡listo! easy peasy.
import todos from './todos.json';
@Injectable({
providedIn: 'root'
})
export class TodoService {
getTodos(title?: string, description?: string, isCompleted?: boolean): Observable<Todo[]> {
let filteredTodos = todos;
// ...
}
}
Si queremos usar HttpClient
, tendremos que guardar el fichero .json
en la carpeta public/ y usar rxjs para filtrar los datos.
import { Injectable } from '@angular/core';
import { map, Observable } from 'rxjs';
import { Todo } from './todo';
import { HttpClient } from '@angular/common/http';
@Injectable({
providedIn: 'root'
})
export class TodoService {
constructor(private httpClient: HttpClient) {
}
getTodos(title?: string, description?: string, isCompleted?: boolean): Observable<Todo[]> {
return this.httpClient.get<Todo[]>('/todos');
}
}
El problema de cualquiera de estas soluciones es que estamos escribiendo código en el servicio que no será el de producción.
Lo normal sería usar environments y, opcionalmente, un InjectionToken.
Con esto el servicio quedaría así:
export class TodoService {
private baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) {
}
getTodos(title?: string, description?: string, isCompleted?: boolean): Observable<Todo[]> {
let params = new HttpParams();
if (title) {
params = params.set('title', title);
}
if (description) {
params = params.set('description', description);
}
if (isCompleted !== undefined) {
params = params.set('isCompleted', isCompleted.toString());
}
return this.httpClient.get<Todo[]>(`${this.baseUrl}/todos`, { params });
}
}
O si usamos InjectionToken
(que nos dará más flexibilidad) así:
export class TodoService {
constructor(@Inject(API_BASE_URL) private baseUrl: string, private httpClient: HttpClient) {
}
En este momento, nuestro servicio ya no mockea la API (por un momento no hemos venido arriba y hemos preferido obviar que la API no está disponible todavía), así que tenemos dos opciones para seguir trabajando:
- Usar DI para inyectar un servicio fake en vez de la implementación real.
- Usar un
HttpInterceptor
y seguir teniendo sólo una implementación del servicio.
Para usar DI, crearemos un interfaz TodoService
(tomamos prestado el nombre para el contrato) y dos implementaciones: HttpTodoService
y FakeTodoService
.
import { Observable } from 'rxjs';
import { Todo } from './todo';
export interface TodoService {
getTodos(
title?: string,
description?: string,
isCompleted?: boolean): Observable<Todo[]>;
}
export class TodoService implements BaseTodoService {
constructor(@Inject(API_BASE_URL) private baseUrl: string, private httpClient: HttpClient) {
}
export class FakeTodoService implements BaseTodoService {
Ahora toca decidir que implementación resolverá DI (app.config.ts
) y actualizar la dependencia al tipo base en el componente:
export const TODO_SERVICE_TOKEN = new InjectionToken<TodoService>('TodoService');
// ...
{ provide: TODO_SERVICE_TOKEN, useClass: environment.production ? HttpTodoService : FakeTodoService }
constructor(@Inject(TODO_SERVICE_TOKEN) private todoService: TodoService, private fb: FormBuilder) {
Si optamos por el interceptor, existirá solamente de nuevo TodoService
y es un escalón más abajo en el framework donde mockearemos la API. Es decir, ahora que no esté la API disponible en el servidor no le importa ya sólo al componente, sino también al servicio.
El interceptor es como sigue:
import { Observable, of } from 'rxjs';
import todos from './todos.json';
import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest, HttpResponse } from '@angular/common/http';
export const todoInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, next: HttpHandlerFn): Observable<HttpEvent<unknown>> => {
if (req.url.endsWith('/todos') && req.method === 'GET') {
let filteredTodos = todos;
const title = req.params.get('title');
if (title) {
filteredTodos = filteredTodos.filter(todo => todo.title.includes(title));
}
const description = req.params.get('description');
if (description) {
filteredTodos = filteredTodos.filter(todo => todo.description?.includes(description));
}
const isCompleted = req.params.get('isCompleted');
if (isCompleted) {
filteredTodos = filteredTodos.filter(todo => todo.isCompleted === (isCompleted.toLowerCase() === 'true'));
}
return of(new HttpResponse({ status: 200, body: filteredTodos }));
}
return next(req);
};
Y hay que activarlo en app.config.ts
:
providers: [
// ...
provideHttpClient(withInterceptors([todoInterceptor])),
// ...
]
Hasta aquí hemos repasado las principales opciones que nos brinda Angular para salvar este match-ball, pero “No se vayan todavía, aún hay más” que diría Super Ratón.
Lo que vamos a probar ahora es a hacernos los suecos en Angular y delegar el mockear la API a un componente externo.
Hay bastantes recursos en línea para mockear una API:
Pero al final, de un modo u otro todos tiene un plan de pago y además depender de un tercero no es la mejor opción.
Para un endpoint rápido (para salir del paso, hacer una prueba, etc.), me gusta https://designer.mocky.io/. Si el endpoint te da igual, por supuesto siempre puedes usar https://jsonplaceholder.typicode.com/.
Sin embargo, hay otras opciones que nos van a permitir mockear una API como unos autenticos pro.
Mock Service Worker (mswjs) creará un Service Worker en el navegador de turno para interceptar las peticiones y devolver respuestas pre-enlatadas.
Para ponerlo en marcha tenemos que seguir la entrada de Getting started y Browser integration.
Los pasos a seguir son:
npm install msw@latest --save-dev
npx msw init .\public\ --save
, que creará el ficheropublic\mockServiceWorker.js
- Crear el fichero src\mocks\browser.ts
- Crear el fichero
src\mocks\handlers.ts
donde escribiremos la lógica del mock.
import { http, HttpResponse } from 'msw';
import todos from '../app/todos.json';
import { environment } from '../environments/environment';
const apiUrl = environment.apiUrl;
export const handlers = [
http.get(`${apiUrl}/todos`, ({ request, params, cookies }) => {
let filteredTodos = todos;
// https://mswjs.io/docs/recipes/query-parameters/#read-a-single-parameter
const url = new URL(request.url)
const title = url.searchParams.get('title')
if (title) {
filteredTodos = filteredTodos.filter(todo => todo.title.includes(title));
}
const description = url.searchParams.get('description')
if (description) {
filteredTodos = filteredTodos.filter(todo => todo.description?.includes(description));
}
const isCompleted = url.searchParams.get('isCompleted')
if (isCompleted) {
filteredTodos = filteredTodos.filter(todo => todo.isCompleted === (isCompleted.toString().toLowerCase() === 'true'));
}
return HttpResponse.json(filteredTodos);
}),
];
Y por último activar mswjs en main.ts
.
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
import { environment } from './environments/environment';
async function enableMocking() {
if (environment.production) {
return
}
const { worker } = await import("./mocks/browser")
return worker.start()
}
enableMocking().then(() => {
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));
})
mswjs mola mucho y es una opción. Sin embargo, reconozco que me sigue dando pereza escribir la API mockeada en handlers.ts
y es ahí donde entra mockoon.
mockoon es una aplicación de escritorio (con su correspondiente CLI para poder instalarlo en un entorno de CI/CD) que nos permitirá a golpe de click, crear la API y soportar múltiples casos de uso.
Hablo desde el hype, pero parece una herramienta muy flexible y versatil.
Tiene un modelo de pricing pero está relacionado con las características cloud. Como se dice en https://mockoon.com/features/, Offline first: No account, no sign-up, no cloud deployment required
Finalmente, creamos la API (que es guardada en un fichero .json
), arrancamos el servidor y ¡listo!.
También puede ser que no queramos elegir entre mswjs y mockoon… y me parece bien, ¡yo tampoco!. Es perfectamente viable usar ambas herramientas para cubrir casi cualquier escenario.
No te preocupes por que haya dos herramientas compitiendo por un mismo puerto, porque mockoon sí levanta un proceso en un puerto, pero mswjs no, simplemente intercepta peticiones y es un Service Worker.
Para combinar ambas, sólo tenemos que usar bypass en mswjs en aquellas peticiones que no queramos interceptar y que terminarán llegando a su destino final, en nuestro caso mockoon.
Por ejemplo, aunque terminamos creando un endpoint /todos
en mockoon, no tenía ninguna lógica de filtrado. Hagamos que esa lógica exista sólo para el campo title
y hagámoslo en mswjs.
import { bypass, http, HttpResponse } from 'msw';
import todos from '../app/todos.json';
import { environment } from '../environments/environment';
const apiUrl = environment.apiUrl;
export const handlers = [
http.get(`${apiUrl}/todos`, async ({ request, params, cookies }) => {
let filteredTodos = todos;
const url = new URL(request.url)
const title = url.searchParams.get('title')
if (title) {
filteredTodos = filteredTodos.filter(todo => todo.title.includes(title));
return HttpResponse.json(filteredTodos);
}
return await fetch(bypass(new Request(url)))
}),
];
Ahora sí, ¡combo güeno!.
Un saludo!