Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,39 @@ The API listens on `http://localhost:3000` by default.

## Endpoints

| Method | Path | Description |
| ------ | --------- | ----------------------------------- |
| GET | `/` | API metadata (name, version, links) |
| GET | `/health` | Liveness probe with uptime |
| Method | Path | Description |
| ------ | ----------------------------- | ----------------------------------------------------------------- |
| GET | `/` | Static HTML UI with buttons to load/download cocktails |
| GET | `/api` | API metadata (name, version, endpoints) |
| GET | `/api/cocktails?letter={a-z}` | List of cocktails (name + ingredients) proxied from thecocktaildb |
| GET | `/health` | Liveness probe with uptime |

Unknown routes return `404` with a JSON body `{ "error": "Not Found", "path": "..." }`.

### `/api/cocktails`

Proxies the free [thecocktaildb](https://www.thecocktaildb.com/) API
(`search.php?f={letter}`) and returns a trimmed list:

```json
[
{
"id": "11007",
"name": "Margarita",
"category": "Ordinary Drink",
"glass": "Cocktail glass",
"thumbnail": "https://...",
"ingredients": [
{ "name": "Tequila", "measure": "1 1/2 oz" },
{ "name": "Triple sec", "measure": "1/2 oz" }
]
}
]
```

Responses are cached in memory per letter for 10 minutes. Invalid `letter` values return
`400`; upstream errors return `502`.

## Scripts

| Script | What it does |
Expand All @@ -57,20 +83,24 @@ Unknown routes return `404` with a JSON body `{ "error": "Not Found", "path": ".
## Project layout

```
public/
index.html # Static UI with "Cargar cócteles" and "Descargar JSON"
src/
app.js # Express app factory (testable, no listen)
server.js # Entrypoint: creates app and listens on PORT
config/
env.js # Typed env loader (dotenv)
routes/
index.js # GET /
index.js # GET /api (metadata)
health.js # GET /health
cocktails.js # GET /api/cocktails?letter={a-z}
middleware/
notFound.js # 404 JSON handler
errorHandler.js # Centralized error handler
tests/
root.test.js
health.test.js
cocktails.test.js
```

## Environment variables
Expand Down
216 changes: 216 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>api00 — Cocktails</title>
<style>
:root {
color-scheme: light;
}
body {
font-family:
system-ui,
-apple-system,
'Segoe UI',
sans-serif;
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
color: #1a1a1a;
background: #f6f6f8;
}
h1 {
margin-bottom: 0.25rem;
}
p.lead {
color: #555;
margin-top: 0;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin: 1.5rem 0;
}
button {
padding: 0.6rem 1rem;
border: none;
border-radius: 0.375rem;
background: #6334fb;
color: white;
font-weight: 600;
cursor: pointer;
font-size: 1rem;
}
button:hover:not(:disabled) {
background: #4b1fda;
}
button:disabled {
background: #aaa;
cursor: not-allowed;
}
button.secondary {
background: #444;
}
button.secondary:hover:not(:disabled) {
background: #222;
}
label {
font-size: 0.9rem;
color: #333;
}
input {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 0.25rem;
width: 3.5rem;
font-size: 1rem;
text-transform: lowercase;
}
#status {
padding: 0.5rem 0;
color: #555;
}
#status.error {
color: #b00020;
}
.cocktail {
background: white;
border: 1px solid #e3e3e8;
border-radius: 0.5rem;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.cocktail h2 {
margin: 0 0 0.5rem 0;
font-size: 1.15rem;
}
.cocktail .meta {
font-size: 0.8rem;
color: #777;
margin-bottom: 0.5rem;
}
.cocktail ul {
margin: 0;
padding-left: 1.25rem;
}
.cocktail li {
margin: 0.15rem 0;
}
</style>
</head>
<body>
<h1>api00 — Cocktails</h1>
<p class="lead">
Listado de cócteles obtenido de
<a href="https://www.thecocktaildb.com/" target="_blank" rel="noreferrer"
>thecocktaildb.com</a
>
a través del proxy <code>GET /api/cocktails?letter={a-z}</code>.
</p>

<div class="controls">
<label>
Letra inicial:
<input id="letter" type="text" value="a" maxlength="1" />
</label>
<button id="load">Cargar cócteles</button>
<button id="download" class="secondary" disabled>Descargar JSON</button>
</div>

<div id="status">Pulsa «Cargar cócteles» para empezar.</div>
<div id="list"></div>

<script>
(() => {
const loadBtn = document.getElementById('load');
const downloadBtn = document.getElementById('download');
const letterInput = document.getElementById('letter');
const statusEl = document.getElementById('status');
const listEl = document.getElementById('list');

let lastData = null;
let lastLetter = 'a';

async function loadCocktails() {
const letter = (letterInput.value || 'a').toLowerCase();
letterInput.value = letter;
lastLetter = letter;
loadBtn.disabled = true;
downloadBtn.disabled = true;
statusEl.className = '';
statusEl.textContent = `Cargando cócteles que empiezan por "${letter}"…`;
listEl.innerHTML = '';

try {
const res = await fetch(`/api/cocktails?letter=${encodeURIComponent(letter)}`);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
const data = await res.json();
lastData = data;

if (!data.length) {
statusEl.textContent = `No se encontraron cócteles para la letra "${letter}".`;
return;
}
statusEl.textContent = `${data.length} cóctel(es) encontrado(s).`;

for (const cocktail of data) {
const card = document.createElement('div');
card.className = 'cocktail';

const title = document.createElement('h2');
title.textContent = cocktail.name;
card.appendChild(title);

if (cocktail.category || cocktail.glass) {
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = [cocktail.category, cocktail.glass].filter(Boolean).join(' · ');
card.appendChild(meta);
}

const ul = document.createElement('ul');
for (const ing of cocktail.ingredients) {
const li = document.createElement('li');
li.textContent = ing.measure ? `${ing.measure} ${ing.name}` : ing.name;
ul.appendChild(li);
}
card.appendChild(ul);
listEl.appendChild(card);
}
downloadBtn.disabled = false;
} catch (err) {
statusEl.className = 'error';
statusEl.textContent = `Error: ${err.message}`;
} finally {
loadBtn.disabled = false;
}
}

function downloadJson() {
if (!lastData) return;
const blob = new Blob([JSON.stringify(lastData, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `cocktails-${lastLetter}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}

loadBtn.addEventListener('click', loadCocktails);
downloadBtn.addEventListener('click', downloadJson);
})();
</script>
</body>
</html>
11 changes: 10 additions & 1 deletion src/app.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import rootRouter from './routes/index.js';
import healthRouter from './routes/health.js';
import cocktailsRouter from './routes/cocktails.js';
import { notFound } from './middleware/notFound.js';
import { errorHandler } from './middleware/errorHandler.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const publicDir = path.resolve(__dirname, '..', 'public');

export function createApp() {
const app = express();

app.disable('x-powered-by');
app.use(express.json());

app.use('/', rootRouter);
app.use(express.static(publicDir));

app.use('/api', rootRouter);
app.use('/api/cocktails', cocktailsRouter);
app.use('/health', healthRouter);

app.use(notFound);
Expand Down
66 changes: 66 additions & 0 deletions src/routes/cocktails.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Router } from 'express';

const router = Router();

const BASE = 'https://www.thecocktaildb.com/api/json/v1/1';
const TTL_MS = 10 * 60 * 1000;
const cache = new Map();

export function _clearCocktailsCache() {
cache.clear();
}

function parseDrink(drink) {
const ingredients = [];
for (let i = 1; i <= 15; i += 1) {
const name = drink[`strIngredient${i}`];
const measure = drink[`strMeasure${i}`];
if (name && String(name).trim()) {
ingredients.push({
name: String(name).trim(),
measure: measure ? String(measure).trim() : null,
});
}
}
return {
id: drink.idDrink,
name: drink.strDrink,
category: drink.strCategory ?? null,
glass: drink.strGlass ?? null,
thumbnail: drink.strDrinkThumb ?? null,
ingredients,
};
}

router.get('/', async (req, res, next) => {
try {
const letter = String(req.query.letter ?? 'a').toLowerCase();
if (!/^[a-z]$/.test(letter)) {
return res.status(400).json({
error: 'Query parameter "letter" must be a single character a-z.',
});
}

const cached = cache.get(letter);
if (cached && Date.now() - cached.at < TTL_MS) {
return res.json(cached.data);
}

const upstream = await fetch(`${BASE}/search.php?f=${letter}`);
if (!upstream.ok) {
return res.status(502).json({
error: 'Upstream thecocktaildb error',
status: upstream.status,
});
}
const body = await upstream.json();
const drinks = Array.isArray(body?.drinks) ? body.drinks : [];
const data = drinks.map(parseDrink);
cache.set(letter, { at: Date.now(), data });
return res.json(data);
} catch (err) {
return next(err);
}
});

export default router;
2 changes: 1 addition & 1 deletion src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ router.get('/', (_req, res) => {
res.json({
name: 'api00',
version: '0.1.0',
endpoints: ['/health'],
endpoints: ['/health', '/api', '/api/cocktails?letter=a'],
});
});

Expand Down
Loading
Loading