NT

NutriTrack API — Hono RPC con Tipado Extremo a Extremo

SaaS B2B de nutrición • Cloudflare Workers • Generado con Hono RPC • Skill CULTIVA IA
TypeScript Cloudflare Workers Hono v4 Zod Validation End-to-End Types No Code Gen
server.ts
client.ts
types.ts
wrangler.toml
// src/server.ts — NutriTrack API • Cloudflare Workers + Hono RPC
import { Hono } from "hono"
import { zValidator } from "@hono/zod-validator"
import { cors } from "hono/cors"
import { bearerAuth } from "hono/bearer-auth"
import { logger } from "hono/logger"
import { HTTPException } from "hono/http-exception"
import { z } from "zod"

// ─── Zod Schemas ─────────────────────────────────────────────────────────────

const patientSchema = z.object({
  name:      z.string().min(2).max(120),
  email:     z.string().email(),
  weight_kg: z.number().positive().max(300),
  height_cm: z.number().positive().max(250),
  goal:      z.enum(["lose_weight", "gain_muscle", "maintain", "medical"]),
  notes:     z.string().max(1000).optional(),
})

const planSchema = z.object({
  title:        z.string().min(3),
  kcal_target:  z.number().int().min(800).max(5000),
  protein_g:    z.number().positive(),
  carbs_g:      z.number().positive(),
  fat_g:        z.number().positive(),
  duration_days: z.number().int().positive(),
})

const intakeSchema = z.object({
  date:     z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  kcal:     z.number().int().positive(),
  protein_g: z.number().positive(),
  carbs_g:   z.number().positive(),
  fat_g:     z.number().positive(),
  meal_log:  z.array(z.object({
    name: z.string(),
    kcal: z.number().positive(),
  })).optional(),
})

const listQuerySchema = z.object({
  page:   z.coerce.number().int().positive().default(1),
  limit:  z.coerce.number().int().min(1).max(100).default(20),
  search: z.string().optional(),
  goal:   z.enum(["lose_weight", "gain_muscle", "maintain", "medical"]).optional(),
})

// ─── App & Middleware ─────────────────────────────────────────────────────────

const app = new Hono<{ Bindings: CloudflareBindings }>()

app.use("*", logger())
app.use("/api/*", cors({
  origin: ["https://nutritrack.app", "https://app.nutritrack.app"],
  allowMethods: ["GET", "POST", "PATCH", "DELETE"],
}))
app.use("/api/*", bearerAuth({ token: (c) => c.env.API_SECRET }))

// ─── Patients Router ──────────────────────────────────────────────────────────

const patients = new Hono<{ Bindings: CloudflareBindings }>()

  .get("/",
    zValidator("query", listQuerySchema),
    async (c) => {
      const { page, limit, search, goal } = c.req.valid("query")  // ✅ typed
      const db = c.env.DB
      let query = "SELECT * FROM patients WHERE 1=1"
      const params: unknown[] = []
      if (search) { query += " AND name LIKE ?"; params.push(`%${search}%`) }
      if (goal)   { query += " AND goal = ?";       params.push(goal) }
      query += ` LIMIT ${limit} OFFSET ${(page - 1) * limit}`
      const result = await db.prepare(query).bind(...params).all()
      return c.json({ patients: result.results, page, limit })
    }
  )

  .post("/",
    zValidator("json", patientSchema),
    async (c) => {
      const data = c.req.valid("json")   // ✅ { name, email, weight_kg, height_cm, goal, notes? }
      const bmi = data.weight_kg / ((data.height_cm / 100) ** 2)
      const id  = crypto.randomUUID()
      await c.env.DB
        .prepare("INSERT INTO patients (id,name,email,weight_kg,height_cm,bmi,goal,notes) VALUES (?,?,?,?,?,?,?,?)")
        .bind(id, data.name, data.email, data.weight_kg, data.height_cm, bmi.toFixed(1), data.goal, data.notes ?? null)
        .run()
      return c.json({ patient: { id, ...data, bmi: +bmi.toFixed(1) } }, 201)
    }
  )

  .get("/:id", async (c) => {
    const id = c.req.param("id")
    const patient = await c.env.DB
      .prepare("SELECT * FROM patients WHERE id = ?").bind(id).first()
    if (!patient) throw new HTTPException(404, { message: "Patient not found" })
    return c.json({ patient })
  })

  .patch("/:id",
    zValidator("json", patientSchema.partial()),
    async (c) => {
      const { id } = c.req.param()
      const updates = c.req.valid("json")  // ✅ Partial<Patient>
      const sets    = Object.keys(updates).map((k) => `${k} = ?`).join(", ")
      const values  = [...Object.values(updates), id]
      await c.env.DB
        .prepare(`UPDATE patients SET ${sets}, updated_at = datetime('now') WHERE id = ?`)
        .bind(...values).run()
      return c.json({ ok: true, id, ...updates })
    }
  )

  .delete("/:id", async (c) => {
    const id = c.req.param("id")
    await c.env.DB.prepare("DELETE FROM patients WHERE id = ?").bind(id).run()
    return c.json({ deleted: id })
  })

  .get("/:id/plans", async (c) => {
    const id = c.req.param("id")
    const plans = await c.env.DB
      .prepare("SELECT * FROM plans WHERE patient_id = ? ORDER BY created_at DESC")
      .bind(id).all()
    return c.json({ plans: plans.results })
  })

  .post("/:id/plans",
    zValidator("json", planSchema),
    async (c) => {
      const patientId = c.req.param("id")
      const plan      = c.req.valid("json")  // ✅ typed plan data
      const planId    = crypto.randomUUID()
      await c.env.DB
        .prepare("INSERT INTO plans (id,patient_id,title,kcal_target,protein_g,carbs_g,fat_g,duration_days) VALUES (?,?,?,?,?,?,?,?)")
        .bind(planId, patientId, plan.title, plan.kcal_target, plan.protein_g, plan.carbs_g, plan.fat_g, plan.duration_days)
        .run()
      return c.json({ plan: { id: planId, patient_id: patientId, ...plan } }, 201)
    }
  )

  .post("/:id/intake",
    zValidator("json", intakeSchema),
    async (c) => {
      const patientId = c.req.param("id")
      const intake    = c.req.valid("json")  // ✅ typed intake
      const intakeId  = crypto.randomUUID()
      await c.env.DB
        .prepare("INSERT INTO intake_log (id,patient_id,date,kcal,protein_g,carbs_g,fat_g,meal_log_json) VALUES (?,?,?,?,?,?,?,?)")
        .bind(intakeId, patientId, intake.date, intake.kcal, intake.protein_g, intake.carbs_g, intake.fat_g, JSON.stringify(intake.meal_log ?? []))
        .run()
      return c.json({ intake: { id: intakeId, patient_id: patientId, ...intake } }, 201)
    }
  )

// ─── Mount & Global Error Handler ────────────────────────────────────────────

const api = app.route("/api/patients", patients)

app.onError((err, c) => {
  if (err instanceof HTTPException)
    return c.json({ error: err.message }, err.status)
  return c.json({ error: "Internal server error" }, 500)
})

// ─── KEY: Export AppType for RPC client ──────────────────────────────────────
export type AppType = typeof api   // 🔑 This is the only thing the client needs
export default app
server.ts
client.ts
types.ts
// src/client.ts — NutriTrack Frontend Client (React / Next.js)
import { hc } from "hono/client"
import type { InferRequestType, InferResponseType } from "hono/client"
import type { AppType } from "./server"  // ← solo el tipo, 0 bytes en bundle

// ─── Typed client ─────────────────────────────────────────────────────────────
export const api = hc<AppType>("https://api.nutritrack.app", {
  headers: () => ({
    Authorization: `Bearer ${localStorage.getItem("nt_token") ?? ""}`,
  }),
})

// ─── Infer types for forms and hooks ──────────────────────────────────────────
export type CreatePatientInput  = InferRequestType<typeof api.api.patients.$post>["json"]
export type PatientResponse     = InferResponseType<typeof api.api.patients.$post, 201>
export type CreatePlanInput     = InferRequestType<typeof api.api.patients[":id"].plans.$post>["json"]

// ─── Service layer ────────────────────────────────────────────────────────────

export const patientService = {
  /** Lista pacientes. Todos los tipos inferidos de AppType. */
  list: async (params?: { page?: string; search?: string }) => {
    const res = await api.api.patients.$get({
      query: { page: params?.page ?? "1", search: params?.search },
    })
    if (!res.ok) throw new Error("Failed to load patients")
    return res.json()  // → { patients: Patient[], page, limit }
  },

  /** Crea un paciente nuevo — cuerpo validado por Zod antes de enviar. */
  create: async (data: CreatePatientInput) => {
    const res = await api.api.patients.$post({ json: data })
    if (!res.ok) throw new Error("Failed to create patient")
    return res.json()  // → { patient: { id, name, email, bmi, ... } }
  },

  /** Obtiene detalle del paciente. */
  get: async (id: string) => {
    const res = await api.api.patients[":id"].$get({ param: { id } })
    if (!res.ok) throw new Error(`Patient ${id} not found`)
    return res.json()
  },

  /** Registra ingesta del día. */
  logIntake: async (patientId: string, intake: {
    date: string; kcal: number;
    protein_g: number; carbs_g: number; fat_g: number;
  }) => {
    const res = await api.api.patients[":id"].intake.$post({
      param: { id: patientId },
      json:  intake,
    })
    return res.json()
  },
}

// ─── Usage in React component ─────────────────────────────────────────────────
/*
  import { patientService, CreatePatientInput } from "./client"

  function NewPatientForm() {
    const handleSubmit = async (data: CreatePatientInput) => {
      const { patient } = await patientService.create(data)
      //        ^^ fully typed: { id, name, email, weight_kg, bmi, goal, ... }
      console.log("Created:", patient.id, "BMI:", patient.bmi)
    }
  }
*/
Por qué Hono RPC para NutriTrack
🔒
Cero errores de tipo en prod
Si el backend cambia un campo de weight_kg a weight, TypeScript falla en compilación, no en runtime.
Sin generación de código
No hay openapi-generator ni schemas extra. Solo export type AppType = typeof app.
🌍
Edge-first: <10ms latencia
Cloudflare Workers ejecuta el mismo código a 300+ nodos globales. Gratis en el plan Workers Paid.
🛡️
Validación Zod en ambos extremos
El servidor rechaza datos inválidos con 400. El cliente infiere los tipos exactos del schema Zod del servidor.