Creada edge function wrapper para carbone.io del lci con un método (descargar reporte) #80

Merged
Guillermo.Arrieta merged 3 commits from issue/76-crear-edge-function-para-plantillas-de-carbone into main 2026-03-20 23:47:30 +00:00
7 changed files with 625 additions and 26 deletions
Showing only changes of commit 93c4e46e62 - Show all commits
+13 -21
View File
@@ -489,7 +489,7 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 6,
"id": "caf8bc20",
"metadata": {},
"outputs": [
@@ -500,13 +500,13 @@
" success: \u001b[33mtrue\u001b[39m,\n",
" data: {\n",
" category: \u001b[32m\"Planes de estudio\"\u001b[39m,\n",
" comment: \u001b[32m\"Plantilla de anexo 1 SEP RVOE Acuerdo 17-11-17\"\u001b[39m,\n",
" comment: \u001b[32m\"Plantillaaa de anexo 1 SEP RVOE Acuerdo 17-11-17\"\u001b[39m,\n",
" versionId: \u001b[32m\"1990f3351b4313779ae3ef3caeaea14dea51c3a98a691fb90a347dec53f875e6\"\u001b[39m\n",
" }\n",
"}"
]
},
"execution_count": 19,
"execution_count": 6,
"metadata": {},
"output_type": "execute_result"
}
@@ -514,13 +514,13 @@
"source": [
"await carbone.patchTemplate(templateID, {\n",
" category: \"Planes de estudio\",\n",
" comment: \"Plantilla de anexo 1 SEP RVOE Acuerdo 17-11-17\",\n",
" comment: \"Plantillaaa de anexo 1 SEP RVOE Acuerdo 17-11-17\",\n",
"});\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 7,
"id": "929883b9",
"metadata": {},
"outputs": [],
@@ -570,7 +570,7 @@
" { download: true },\n",
");\n",
"\n",
"await Deno.writeFile(\"../dist/anexo1.docx\", result.buffer);\n"
"await Deno.writeFile(\"../anexo1.docx\", result.buffer);\n"
]
},
{
@@ -628,16 +628,6 @@
"await Deno.writeFile(\"../dist/anexo1.pdf\", result.buffer);\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "4528a917",
"metadata": {},
"outputs": [],
"source": [
"await carbone.list"
]
},
{
"cell_type": "code",
"execution_count": null,
@@ -661,7 +651,7 @@
" type: \"docx\",\n",
" name: \"Anexo 1\",\n",
" category: \"Planes de estudio\",\n",
" comment: \"Plantilla de anexo 1 SEP RVOE Acuerdo 17-11-17\",\n",
" comment: \"Plantillaaa de anexo 1 SEP RVOE Acuerdo 17-11-17\",\n",
" tags: [],\n",
" origin: 1\n",
" }\n",
@@ -674,10 +664,10 @@
"source": [
"const versions = await carbone.listTemplates({\n",
" id: \"1373945625988937236\",\n",
" includeVersions: true\n",
" includeVersions: true,\n",
"});\n",
"\n",
"console.log(versions);"
"console.log(versions);\n"
]
},
{
@@ -746,8 +736,10 @@
"console.log(result);\n",
"\n",
"// const file = await carbone.downloadRender(render.data.renderId);\n",
"const file = await carbone.downloadRender(\"X1Bx6Stre95Sm54jHxIwpQYW5leG8xLnBkZg.pdf\");\n",
"await Deno.writeFile(\"dist/anexo1.pdf\", file.buffer);"
"const file = await carbone.downloadRender(\n",
" \"X1Bx6Stre95Sm54jHxIwpQYW5leG8xLnBkZg.pdf\",\n",
");\n",
"await Deno.writeFile(\"dist/anexo1.pdf\", file.buffer);\n"
]
},
{
+11
View File
@@ -76,3 +76,14 @@ entrypoint = "./functions/buscar-bibliografia/index.ts"
# Specifies static files to be bundled with the function. Supports glob patterns.
# For example, if you want to serve static HTML pages in your function:
# static_files = [ "./functions/buscar-bibliografia/*.html" ]
[functions.carbone-io-wrapper]
enabled = true
verify_jwt = true
import_map = "./functions/carbone-io-wrapper/deno.json"
# Uncomment to specify a custom file path to the entrypoint.
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
entrypoint = "./functions/carbone-io-wrapper/index.ts"
# Specifies static files to be bundled with the function. Supports glob patterns.
# For example, if you want to serve static HTML pages in your function:
# static_files = [ "./functions/carbone-io-wrapper/*.html" ]
+29 -5
View File
@@ -1,4 +1,4 @@
export type Json =
export type Json =
| string
| number
| boolean
@@ -7,10 +7,30 @@ export type Json =
| Json[]
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "14.1"
graphql_public: {
Tables: {
[_ in never]: never
}
Views: {
[_ in never]: never
}
Functions: {
graphql: {
Args: {
extensions?: Json
operationName?: string
query?: string
variables?: Json
}
Returns: Json
}
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
public: {
Tables: {
@@ -1548,6 +1568,9 @@ export type CompositeTypes<
: never
export const Constants = {
graphql_public: {
Enums: {},
},
public: {
Enums: {
estado_asignatura: ["borrador", "revisada", "aprobada", "generando"],
@@ -1607,3 +1630,4 @@ export const Constants = {
},
},
} as const
@@ -0,0 +1,3 @@
# Configuration for private npm package dependencies
# For more information on using private registries with Edge Functions, see:
# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries
@@ -0,0 +1,331 @@
const CARBONE_VERSION = "5";
export type CarboneSuccess<T> = {
success: true;
data: T;
hasMore?: boolean;
nextCursor?: string;
};
export type CarboneError = {
success: false;
error: string;
};
export type CarboneResponse<T> = CarboneSuccess<T> | CarboneError;
export type CarboneDownloadResult = {
buffer: Uint8Array;
contentType: string | null;
contentDisposition: string | null;
};
export class CarboneClient {
constructor(
private baseUrl: string,
private apiToken: string,
) {}
private buildHeaders(extra?: Record<string, string>) {
return {
Authorization: `Bearer ${this.apiToken}`,
"carbone-version": CARBONE_VERSION,
...extra,
};
}
private async parseResponse<T>(res: Response): Promise<T> {
const contentType = res.headers.get("content-type") || "";
if (!res.ok) {
let message = `Carbone request failed: ${res.status}`;
try {
const err = await res.json();
message = err?.error || err?.message || message;
} catch {
// ignore json parse errors
}
throw new Error(message);
}
if (contentType.includes("application/json")) {
return res.json() as Promise<T>;
}
return res as unknown as T;
}
private toQuery(params?: Record<string, unknown>) {
const qs = new URLSearchParams();
if (!params) return "";
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
qs.set(key, String(value));
}
const s = qs.toString();
return s ? `?${s}` : "";
}
async listTemplates(params?: {
id?: string;
versionId?: string;
category?: string;
origin?: 0 | 1;
includeVersions?: boolean;
search?: string;
limit?: number;
cursor?: string;
}) {
const res = await fetch(
`${this.baseUrl}/templates${this.toQuery(params)}`,
{
method: "GET",
headers: this.buildHeaders(),
},
);
return this.parseResponse<
CarboneResponse<
Array<{
id: string;
versionId: string;
deployedAt: number;
createdAt: number;
expireAt?: number;
size: number;
type: string;
name?: string;
category?: string;
comment?: string;
tags?: string[];
origin?: number;
}>
>
>(res);
}
async listCategories() {
const res = await fetch(`${this.baseUrl}/templates/categories`, {
method: "GET",
headers: this.buildHeaders(),
});
return this.parseResponse<CarboneResponse<Array<{ name: string }>>>(res);
}
async listTags() {
const res = await fetch(`${this.baseUrl}/templates/tags`, {
method: "GET",
headers: this.buildHeaders(),
});
return this.parseResponse<CarboneResponse<Array<{ name: string }>>>(res);
}
async uploadTemplateJson(body: {
template: string;
versioning?: boolean;
id?: string;
name?: string;
comment?: string;
tags?: string[];
category?: string;
sample?: unknown[];
deployedAt?: number;
expireAt?: number;
}) {
const res = await fetch(`${this.baseUrl}/template`, {
method: "POST",
headers: this.buildHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify(body),
});
return this.parseResponse<
CarboneResponse<{
id?: string;
versionId?: string;
templateId?: string;
type?: string;
size?: number;
createdAt?: number;
}>
>(res);
}
async uploadTemplateFile(input: {
file: Blob;
filename: string;
versioning?: boolean;
id?: string;
name?: string;
comment?: string;
tags?: string[];
category?: string;
sample?: unknown[];
deployedAt?: number;
expireAt?: number;
}) {
const form = new FormData();
if (input.versioning !== undefined) {
form.append("versioning", String(input.versioning));
}
if (input.id) form.append("id", input.id);
if (input.name) form.append("name", input.name);
if (input.comment) form.append("comment", input.comment);
if (input.category) form.append("category", input.category);
if (input.tags) {
for (const tag of input.tags) form.append("tags[]", tag);
}
if (input.sample) form.append("sample", JSON.stringify(input.sample));
if (input.deployedAt !== undefined) {
form.append("deployedAt", String(input.deployedAt));
}
if (input.expireAt !== undefined) {
form.append("expireAt", String(input.expireAt));
}
// template should be appended last
form.append("template", input.file, input.filename);
const res = await fetch(`${this.baseUrl}/template`, {
method: "POST",
headers: this.buildHeaders(),
body: form,
});
return this.parseResponse<
CarboneResponse<{
id?: string;
versionId?: string;
templateId?: string;
type?: string;
size?: number;
createdAt?: number;
}>
>(res);
}
async patchTemplate(
id: string,
body: {
name?: string;
comment?: string;
tags?: string[];
category?: string;
deployedAt?: number;
expireAt?: number;
},
) {
const res = await fetch(`${this.baseUrl}/template/${id}`, {
method: "PATCH",
headers: this.buildHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify(body),
});
return this.parseResponse<CarboneResponse<true>>(res);
}
async deleteTemplate(id: string) {
const res = await fetch(`${this.baseUrl}/template/${id}`, {
method: "DELETE",
headers: this.buildHeaders(),
});
return this.parseResponse<CarboneResponse<true>>(res);
}
async downloadTemplate(id: string): Promise<CarboneDownloadResult> {
const res = await fetch(`${this.baseUrl}/template/${id}`, {
method: "GET",
headers: this.buildHeaders(),
});
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(
err?.error || `Failed to download template: ${res.status}`,
);
}
return {
buffer: new Uint8Array(await res.arrayBuffer()),
contentType: res.headers.get("content-type"),
contentDisposition: res.headers.get("content-disposition"),
};
}
async render(
templateIdOrVersionId: string,
body: Record<string, unknown>,
opts?: { download?: boolean },
) {
const query = opts?.download ? "?download=true" : "";
const res = await fetch(
`${this.baseUrl}/render/${templateIdOrVersionId}${query}`,
{
method: "POST",
headers: this.buildHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify(body),
},
);
if (opts?.download) {
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error || `Render failed: ${res.status}`);
}
return {
buffer: new Uint8Array(await res.arrayBuffer()),
contentType: res.headers.get("content-type"),
contentDisposition: res.headers.get("content-disposition"),
} satisfies CarboneDownloadResult;
}
return this.parseResponse<CarboneResponse<{ renderId: string }>>(res);
}
async renderFromTemplate(
body: Record<string, unknown>,
opts?: { download?: boolean },
) {
const query = opts?.download ? "?download=true" : "";
const res = await fetch(`${this.baseUrl}/render/template${query}`, {
method: "POST",
headers: this.buildHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify(body),
});
if (opts?.download) {
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error || `Render failed: ${res.status}`);
}
return {
buffer: new Uint8Array(await res.arrayBuffer()),
contentType: res.headers.get("content-type"),
contentDisposition: res.headers.get("content-disposition"),
} satisfies CarboneDownloadResult;
}
return this.parseResponse<CarboneResponse<{ renderId: string }>>(res);
}
async downloadRender(renderId: string): Promise<CarboneDownloadResult> {
const res = await fetch(`${this.baseUrl}/render/${renderId}`, {
method: "GET",
});
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error || `Failed to download render: ${res.status}`);
}
return {
buffer: new Uint8Array(await res.arrayBuffer()),
contentType: res.headers.get("content-type"),
contentDisposition: res.headers.get("content-disposition"),
};
}
}
@@ -0,0 +1,8 @@
{
"imports": {
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts",
"@supabase/supabase-js": "npm:@supabase/supabase-js@2",
"@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts",
"@supabase/functions-js": "jsr:@supabase/functions-js@^2"
}
}
@@ -0,0 +1,230 @@
// Follow this setup guide to integrate the Deno language server with your editor:
// https://deno.land/manual/getting_started/setup_your_environment
// This enables autocomplete, go to definition, etc.
// Setup type definitions for built-in Supabase Runtime APIs
import "@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "@supabase/supabase-js";
import { z } from "zod";
import { corsHeaders } from "../_shared/cors.ts";
import type { Database, Json } from "../_shared/database.types.ts";
import { HttpError, sendError } from "../_shared/utils.ts";
import { CarboneClient } from "./carbone.ts";
const ActionSchema = z.object({
action: z.string().min(1),
});
const DownloadReportSchema = z.object({
action: z.literal("downloadReport"),
plan_estudio_id: z.string().min(1),
});
type DownloadReportInput = z.infer<typeof DownloadReportSchema>;
function getAuthHeader(req: Request): string | null {
return req.headers.get("Authorization") ?? req.headers.get("authorization");
}
async function loadPlanDatos(
supabase: ReturnType<typeof createClient<Database>>,
planEstudioId: string,
): Promise<Json> {
const { data, error } = await supabase
.from("planes_estudio")
.select("datos")
.eq("id", planEstudioId)
.maybeSingle();
if (error) {
throw new HttpError(
500,
"Error consultando el plan de estudios.",
"DB_ERROR",
{ message: error.message, details: error.details, hint: error.hint },
);
}
if (!data) {
throw new HttpError(
404,
"Plan de estudios no encontrado.",
"NOT_FOUND",
{ plan_estudio_id: planEstudioId },
);
}
return data.datos;
}
Deno.serve(async (req: Request): Promise<Response> => {
const url = new URL(req.url);
const functionName = url.pathname.split("/").pop();
console.log(
`[${new Date().toISOString()}][${functionName}]: Request received`,
);
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
try {
if (req.method !== "POST") {
throw new HttpError(
405,
"Método no permitido.",
"METHOD_NOT_ALLOWED",
{ method: req.method },
);
}
const authHeader = getAuthHeader(req);
if (!authHeader) {
throw new HttpError(401, "No autorizado.", "UNAUTHORIZED", {
reason: "missing_authorization_header",
});
}
const contentType = (req.headers.get("content-type") || "").toLowerCase();
if (!contentType.startsWith("application/json")) {
throw new HttpError(
415,
"Content-Type no soportado.",
"UNSUPPORTED_MEDIA_TYPE",
{ contentType },
);
}
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
throw new HttpError(
500,
"Configuración del servidor incompleta.",
"MISSING_ENV",
{
missing: [
!SUPABASE_URL ? "SUPABASE_URL" : null,
!SERVICE_ROLE_KEY ? "SUPABASE_SERVICE_ROLE_KEY" : null,
].filter(Boolean),
},
);
}
const CARBONE_API_TOKEN = Deno.env.get("CARBONE_API_TOKEN");
const CARBONE_TEMPLATE_ID = Deno.env.get("CARBONE_TEMPLATE_ID");
const CARBONE_BASE_URL = Deno.env.get("CARBONE_BASE_URL") ||
"https://carbone.lci.ulsa.mx";
if (!CARBONE_API_TOKEN || !CARBONE_TEMPLATE_ID) {
throw new HttpError(
500,
"Configuración del servidor incompleta.",
"MISSING_ENV",
{
missing: [
!CARBONE_API_TOKEN ? "CARBONE_API_TOKEN" : null,
!CARBONE_TEMPLATE_ID ? "CARBONE_TEMPLATE_ID" : null,
].filter(Boolean),
},
);
}
const bodyUnknown: unknown = await req.json();
const { action } = ActionSchema.parse(bodyUnknown);
const supabase = createClient<Database>(SUPABASE_URL, SERVICE_ROLE_KEY, {
auth: { persistSession: false },
});
switch (action) {
case "downloadReport": {
const input: DownloadReportInput = DownloadReportSchema.parse(
bodyUnknown,
);
const datos = await loadPlanDatos(supabase, input.plan_estudio_id);
const carbone = new CarboneClient(CARBONE_BASE_URL, CARBONE_API_TOKEN);
const result = await carbone.render(
CARBONE_TEMPLATE_ID,
{ data: datos, convertTo: "pdf" },
{ download: true },
);
if (!(result && typeof result === "object" && "buffer" in result)) {
throw new HttpError(
502,
"Respuesta inválida de Carbone.",
"UPSTREAM_INVALID_RESPONSE",
);
}
const download = result as {
buffer: Uint8Array;
contentType: string | null;
contentDisposition: string | null;
};
const bytes = download.buffer;
const arrayBuffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
const blob = new Blob([arrayBuffer], {
type: download.contentType ?? "application/octet-stream",
});
const headers = new Headers({
...corsHeaders,
"Content-Type": download.contentType ?? "application/octet-stream",
Connection: "keep-alive",
});
if (download.contentDisposition) {
headers.set("Content-Disposition", download.contentDisposition);
}
console.log(
`[${
new Date().toISOString()
}][${functionName}]: Request processed successfully`,
);
return new Response(blob, { status: 200, headers });
}
default:
throw new HttpError(
400,
"Acción no soportada.",
"UNSUPPORTED_ACTION",
{ action },
);
}
} catch (err) {
const error = err instanceof HttpError ? err : new HttpError(
500,
"Error interno del servidor.",
"INTERNAL_SERVER_ERROR",
{
original: err instanceof Error
? { message: err.message, stack: err.stack }
: err,
},
);
return sendError(error.status, error.message, error.code);
}
});
/* To invoke locally:
1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start)
2. Make an HTTP request:
# Requires secrets:
# - CARBONE_API_TOKEN
# - CARBONE_TEMPLATE_ID
# Optional:
# - CARBONE_BASE_URL (defaults to https://carbone.lci.ulsa.mx)
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/carbone-io-wrapper' \
--header 'Authorization: Bearer <JWT>' \
--header 'Content-Type: application/json' \
--data '{"action":"downloadReport","plan_estudio_id":"<uuid>"}'
*/