merge: quick/frontend-setup-metabase-docs — frontend setup, metabase Go/Py, documentación en registry

This commit is contained in:
2026-03-28 20:33:20 +01:00
89 changed files with 7196 additions and 75 deletions
+20 -6
View File
@@ -10,13 +10,23 @@ Registry personal de codigo reutilizable con busqueda FTS. Diseñado para compos
---
## Explorar el registry (USAR SIEMPRE)
## Explorar el registry (OBLIGATORIO)
Antes de escribir codigo, SIEMPRE consulta registry.db para evitar duplicados y descubrir funciones reutilizables.
**SIEMPRE** consulta registry.db antes de escribir codigo, crear funciones, o responder sobre el registry. No uses grep/glob sobre archivos .go/.md — la BD es la fuente de verdad.
**La BD contiene el codigo y la documentacion completa** de cada funcion y tipo en los campos `code`, `documentation` y `notes`. Estos campos tambien estan indexados en FTS5, asi que puedes buscar dentro del codigo y la documentacion directamente. Para leer el codigo de una funcion: `SELECT code FROM functions WHERE id = '...'`. Para leer su documentacion: `SELECT documentation FROM functions WHERE id = '...'`.
**Busquedas FTS5 obligatorias:** Usa SIEMPRE la tabla FTS5 para buscar tanto por `name` como por `description`. Esto encuentra coincidencias parciales y similares que una busqueda exacta perderia. Usa operadores FTS5: `OR` para ampliar, `*` para prefijos, `NEAR` para proximidad.
```bash
# FTS5
sqlite3 registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'slice') ORDER BY name;"
# Busqueda FTS5 por nombre Y descripcion (USAR SIEMPRE ESTE PATRON)
sqlite3 registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:slice OR description:slice') ORDER BY name;"
# FTS5 con prefijo (encuentra slice, slicing, sliced...)
sqlite3 registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:slic* OR description:slic*') ORDER BY name;"
# FTS5 en tipos
sqlite3 registry.db "SELECT id, algebraic, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:result OR description:result') ORDER BY name;"
# Por dominio
sqlite3 registry.db "SELECT id, purity, signature FROM functions WHERE domain = 'finance' ORDER BY name;"
@@ -24,7 +34,7 @@ sqlite3 registry.db "SELECT id, purity, signature FROM functions WHERE domain =
# Puras de un dominio
sqlite3 registry.db "SELECT id, signature FROM functions WHERE domain = 'core' AND purity = 'pure' ORDER BY name;"
# Tipos
# Tipos por dominio
sqlite3 registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'cybersecurity';"
# Dependencias
@@ -37,6 +47,8 @@ sqlite3 registry.db "SELECT id, kind, status, title FROM proposals WHERE status
sqlite3 registry.db ".schema"
```
**Regla:** Si necesitas saber si algo existe o hay algo similar, haz la consulta FTS5 sobre la BD. No asumas que no existe sin consultar primero.
---
## Estructura
@@ -45,8 +57,10 @@ sqlite3 registry.db ".schema"
fn-registry/
functions/{domain}/ # .go + .md por funcion (core, finance, datascience, cybersecurity)
functions/pipelines/ # Composiciones, siempre impuras
functions/components/ # React (.tsx)
types/{domain}/ # .go + .md por tipo
frontend/ # pnpm + vite + react + tailwind + shadcn
frontend/functions/ # .tsx/.ts + .md (core para TS puro, ui para componentes React)
frontend/types/ # .ts + .md por tipo
registry/ # Paquete Go: modelos, SQLite, parser, indexer, validacion, migraciones
fn_operations/ # Paquete Go: operations database (libreria)
apps/ # Apps ejecutables (TUIs, CLIs) — modulos Go independientes, cada una con su operations.db
+21
View File
@@ -289,6 +289,15 @@ func printFunction(f *registry.Function) {
if f.Example != "" {
fmt.Printf("\nExample:\n%s\n", f.Example)
}
if f.Notes != "" {
fmt.Printf("\nNotes:\n%s\n", f.Notes)
}
if f.Documentation != "" {
fmt.Printf("\nDocumentation:\n%s\n", f.Documentation)
}
if f.Code != "" {
fmt.Printf("\nCode:\n%s\n", f.Code)
}
if f.Kind == registry.KindComponent {
fmt.Printf("Framework: %s\n", f.Framework)
if f.HasState != nil {
@@ -316,6 +325,18 @@ func printType(t *registry.Type) {
if t.Definition != "" {
fmt.Printf("\nDefinition:\n%s\n", t.Definition)
}
if t.Examples != "" {
fmt.Printf("\nExamples:\n%s\n", t.Examples)
}
if t.Notes != "" {
fmt.Printf("\nNotes:\n%s\n", t.Notes)
}
if t.Documentation != "" {
fmt.Printf("\nDocumentation:\n%s\n", t.Documentation)
}
if t.Code != "" {
fmt.Printf("\nCode:\n%s\n", t.Code)
}
}
// --- add ---
+1 -1
View File
@@ -17,7 +17,7 @@ imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "functions/components/DataTable.tsx"
file_path: "frontend/functions/ui/data_table.tsx"
props:
- name: data
type: "T[]"
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>fn-registry frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
{
"name": "frontend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"add": "pnpm dlx shadcn@latest add"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.17.0",
"dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"shadcn": "^4.1.1",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^8.0.3"
}
}
+3609
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+130
View File
@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+1
View File
@@ -0,0 +1 @@
import "./globals.css";
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"paths": {
"@/*": ["./src/*"]
},
"ignoreDeprecations": "6.0"
},
"include": ["src", "functions", "types"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
+49
View File
@@ -0,0 +1,49 @@
package infra
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
// MetabaseAuth autentica con email y password contra una instancia Metabase.
// Retorna un MetabaseClient con el session token listo para usar.
// baseURL es la URL base sin trailing slash (ej: "http://localhost:3000").
func MetabaseAuth(baseURL, email, password string) (MetabaseClient, error) {
payload, _ := json.Marshal(map[string]string{
"username": email,
"password": password,
})
resp, err := http.Post(baseURL+"/api/session", "application/json", bytes.NewReader(payload))
if err != nil {
return MetabaseClient{}, fmt.Errorf("metabase auth: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return MetabaseClient{}, fmt.Errorf("read auth response: %w", err)
}
if resp.StatusCode != 200 {
return MetabaseClient{}, fmt.Errorf("metabase auth: status %d: %s", resp.StatusCode, string(body))
}
var result struct {
ID string `json:"id"`
}
if err := json.Unmarshal(body, &result); err != nil {
return MetabaseClient{}, fmt.Errorf("parse auth response: %w", err)
}
return MetabaseClient{BaseURL: baseURL, Token: result.ID}, nil
}
// MetabaseNewClient crea un MetabaseClient usando una API key en lugar de session token.
// Las API keys se crean en Settings > Authentication > API Keys del admin de Metabase.
func MetabaseNewClient(baseURL, apiKey string) MetabaseClient {
return MetabaseClient{BaseURL: baseURL, Token: apiKey}
}
+50
View File
@@ -0,0 +1,50 @@
---
name: metabase_auth
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseAuth(baseURL, email, password string) (MetabaseClient, error)"
description: "Autentica contra la API de Metabase con email y password. Retorna un MetabaseClient con session token valido por 14 dias (configurable con MAX_SESSION_AGE en Metabase). Endpoint: POST /api/session."
tags: [metabase, auth, session, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: [MetabaseClient_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: [bytes, encoding/json, fmt, io, net/http]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_auth.go"
---
## Ejemplo
```go
// Autenticar con credenciales
client, err := MetabaseAuth("http://localhost:3000", "admin@example.com", "password123")
if err != nil {
log.Fatal(err)
}
// client.Token contiene el session token
// Alternativa: usar API key directamente
client := MetabaseNewClient("http://localhost:3000", "mb_api_key_xxxxx")
```
## Notas
Dos formas de obtener un MetabaseClient:
- `MetabaseAuth`: login con email/password, obtiene session token via POST /api/session. Token expira en 14 dias por defecto.
- `MetabaseNewClient`: usa una API key creada en el admin UI. No expira. Recomendado para automatizacion.
El token se envia como header `X-Metabase-Session` en todas las llamadas subsiguientes.
### Para un LLM que use estas funciones
1. Primero obtener un client con `MetabaseAuth()` o `MetabaseNewClient()`
2. Pasar el client a todas las funciones CRUD (usuarios, cards, dashboards)
3. Si recibes error 401, el token expiro — re-autenticar
4. Rate limiting: Metabase limita intentos de login fallidos
+35
View File
@@ -0,0 +1,35 @@
package infra
import "fmt"
// MetabaseCreateCard crea una nueva card/pregunta en Metabase.
// name: nombre de la pregunta (obligatorio).
// datasetQuery: query de la card (obligatorio). Estructura:
//
// SQL nativo: {"database": 1, "type": "native", "native": {"query": "SELECT ..."}}
// MBQL: {"database": 1, "type": "query", "query": {"source-table": 4, ...}}
//
// display: tipo de visualizacion ("table", "bar", "line", "pie", "scalar", etc.).
// collectionID: ID de la coleccion/carpeta (0 = root).
// description: descripcion opcional (vacio = sin descripcion).
func MetabaseCreateCard(client MetabaseClient, name string, datasetQuery map[string]any, display string, collectionID int, description string) (map[string]any, error) {
body := map[string]any{
"name": name,
"dataset_query": datasetQuery,
"display": display,
"visualization_settings": map[string]any{},
}
if collectionID > 0 {
body["collection_id"] = collectionID
}
if description != "" {
body["description"] = description
}
result, err := metabaseRequest("POST", client.BaseURL, client.Token, "/api/card", body)
if err != nil {
return nil, fmt.Errorf("metabase create card: %w", err)
}
return result, nil
}
+86
View File
@@ -0,0 +1,86 @@
---
name: metabase_create_card
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseCreateCard(client MetabaseClient, name string, datasetQuery map[string]any, display string, collectionID int, description string) (map[string]any, error)"
description: "Crea una nueva card/pregunta en Metabase con query SQL nativa o MBQL. Endpoint: POST /api/card."
tags: [metabase, card, question, create, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_create_card.go"
---
## Ejemplo
```go
// Crear pregunta con SQL nativo
card, err := MetabaseCreateCard(client, "Revenue by Month", map[string]any{
"database": 1,
"type": "native",
"native": map[string]any{
"query": "SELECT date_trunc('month', created_at) as month, SUM(total) as revenue FROM orders GROUP BY 1 ORDER BY 1",
},
}, "line", 5, "Monthly revenue trend")
// Crear pregunta con MBQL (structured query)
card, err := MetabaseCreateCard(client, "Order Count", map[string]any{
"database": 1,
"type": "query",
"query": map[string]any{
"source-table": 4,
"aggregation": []any{[]any{"count"}},
},
}, "scalar", 0, "Total number of orders")
```
## Notas
### Parametros para un LLM
| Parametro | Tipo | Requerido | Descripcion |
|-----------|------|-----------|-------------|
| client | MetabaseClient | si | Cliente autenticado |
| name | string | si | Nombre de la pregunta |
| datasetQuery | map[string]any | si | Query. Ver estructura abajo |
| display | string | si | Tipo de visualizacion |
| collectionID | int | no | ID de coleccion. 0 = root collection |
| description | string | no | Descripcion. Vacio = sin descripcion |
### Estructura de datasetQuery
**SQL nativo:**
```json
{
"database": <database_id>,
"type": "native",
"native": {"query": "SELECT ..."}
}
```
**MBQL (structured):**
```json
{
"database": <database_id>,
"type": "query",
"query": {
"source-table": <table_id>,
"aggregation": [["count"]],
"breakout": [["field", <field_id>, {"temporal-unit": "month"}]],
"filter": ["=", ["field", <field_id>, null], "value"]
}
}
```
### Valores de display
table, bar, line, pie, scalar, area, row, combo, funnel, map, scatter, waterfall, progress, gauge
@@ -0,0 +1,26 @@
package infra
import "fmt"
// MetabaseCreateDashboard crea un nuevo dashboard en Metabase.
// name: nombre del dashboard (obligatorio).
// description: descripcion opcional (vacio = sin descripcion).
// collectionID: ID de la coleccion/carpeta (0 = root).
func MetabaseCreateDashboard(client MetabaseClient, name, description string, collectionID int) (map[string]any, error) {
body := map[string]any{
"name": name,
}
if description != "" {
body["description"] = description
}
if collectionID > 0 {
body["collection_id"] = collectionID
}
result, err := metabaseRequest("POST", client.BaseURL, client.Token, "/api/dashboard", body)
if err != nil {
return nil, fmt.Errorf("metabase create dashboard: %w", err)
}
return result, nil
}
@@ -0,0 +1,53 @@
---
name: metabase_create_dashboard
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseCreateDashboard(client MetabaseClient, name, description string, collectionID int) (map[string]any, error)"
description: "Crea un nuevo dashboard vacio en Metabase. Para agregar cards usar MetabaseUpdateDashboard con el campo dashcards. Endpoint: POST /api/dashboard."
tags: [metabase, dashboard, create, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_create_dashboard.go"
---
## Ejemplo
```go
// Crear dashboard vacio
dashboard, err := MetabaseCreateDashboard(client, "Sales Overview", "KPIs de ventas", 5)
if err != nil {
log.Fatal(err)
}
dashboardID := int(dashboard["id"].(float64))
// Luego agregar cards con MetabaseUpdateDashboard
MetabaseUpdateDashboard(client, dashboardID, map[string]any{
"dashcards": []map[string]any{
{"id": -1, "card_id": 42, "size_x": 6, "size_y": 4, "col": 0, "row": 0},
},
})
```
## Notas
### Parametros para un LLM
| Parametro | Tipo | Requerido | Descripcion |
|-----------|------|-----------|-------------|
| client | MetabaseClient | si | Cliente autenticado |
| name | string | si | Nombre del dashboard |
| description | string | no | Descripcion. Vacio = sin descripcion |
| collectionID | int | no | Coleccion destino. 0 = root |
El dashboard se crea vacio. Para agregar cards, usar MetabaseUpdateDashboard con el array dashcards.
Retorna el objeto dashboard creado.
+28
View File
@@ -0,0 +1,28 @@
package infra
import "fmt"
// MetabaseCreateUser crea un nuevo usuario en Metabase.
// firstName, lastName y email son obligatorios.
// password es opcional: si esta vacio, Metabase envia email de invitacion.
// groupIDs es opcional: IDs de grupos a asignar (nil = solo grupo default).
func MetabaseCreateUser(client MetabaseClient, firstName, lastName, email, password string, groupIDs []int) (map[string]any, error) {
body := map[string]any{
"first_name": firstName,
"last_name": lastName,
"email": email,
}
if password != "" {
body["password"] = password
}
if len(groupIDs) > 0 {
body["group_ids"] = groupIDs
}
result, err := metabaseRequest("POST", client.BaseURL, client.Token, "/api/user", body)
if err != nil {
return nil, fmt.Errorf("metabase create user: %w", err)
}
return result, nil
}
+47
View File
@@ -0,0 +1,47 @@
---
name: metabase_create_user
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseCreateUser(client MetabaseClient, firstName, lastName, email, password string, groupIDs []int) (map[string]any, error)"
description: "Crea un nuevo usuario en Metabase. Si no se provee password, Metabase envia email de invitacion. Requiere permisos de superusuario. Endpoint: POST /api/user."
tags: [metabase, user, create, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_create_user.go"
---
## Ejemplo
```go
// Crear usuario con password
user, err := MetabaseCreateUser(client, "John", "Doe", "john@example.com", "securePass123", nil)
// Crear usuario sin password (envia invitacion por email)
user, err := MetabaseCreateUser(client, "Jane", "Smith", "jane@example.com", "", []int{1, 3})
```
## Notas
### Parametros para un LLM
| Parametro | Tipo | Requerido | Descripcion |
|-----------|------|-----------|-------------|
| client | MetabaseClient | si | Cliente autenticado con permisos admin |
| firstName | string | si | Nombre del usuario |
| lastName | string | si | Apellido del usuario |
| email | string | si | Email unico del usuario |
| password | string | no | Password. Vacio = Metabase envia invitacion |
| groupIDs | []int | no | IDs de grupos. nil = solo grupo default |
El email debe ser unico. Si ya existe, retorna error 400.
Retorna el objeto usuario creado como map (mismos campos que MetabaseGetUser).
@@ -0,0 +1,17 @@
package infra
import "fmt"
// MetabaseDeactivateUser desactiva (soft-delete) un usuario en Metabase.
// El usuario no se elimina permanentemente, solo se marca como inactivo.
// Requiere permisos de superusuario.
func MetabaseDeactivateUser(client MetabaseClient, userID int) error {
path := fmt.Sprintf("/api/user/%d", userID)
_, err := metabaseRequest("DELETE", client.BaseURL, client.Token, path, nil)
if err != nil {
return fmt.Errorf("metabase deactivate user %d: %w", userID, err)
}
return nil
}
@@ -0,0 +1,40 @@
---
name: metabase_deactivate_user
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseDeactivateUser(client MetabaseClient, userID int) error"
description: "Desactiva (soft-delete) un usuario en Metabase. El usuario no se elimina permanentemente, solo se marca como inactivo. Para reactivar, usar PUT /api/user/:id/reactivate. Endpoint: DELETE /api/user/:id."
tags: [metabase, user, delete, deactivate, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_deactivate_user.go"
---
## Ejemplo
```go
err := MetabaseDeactivateUser(client, 5)
if err != nil {
log.Fatal(err)
}
// Usuario 5 ahora esta inactivo
// Para ver desactivados: MetabaseListUsers(client, "deactivated", "", 0, 0)
```
## Notas
Es un soft-delete: el usuario se desactiva pero no se borra. Se puede reactivar con PUT /api/user/:id/reactivate.
Para listar usuarios desactivados, usar `MetabaseListUsers` con status "deactivated".
Requiere permisos de superusuario. Error 403 si no eres admin.
+16
View File
@@ -0,0 +1,16 @@
package infra
import "fmt"
// MetabaseDeleteCard elimina permanentemente una card/pregunta de Metabase.
// Para soft-delete, usar MetabaseUpdateCard con archived: true.
func MetabaseDeleteCard(client MetabaseClient, cardID int) error {
path := fmt.Sprintf("/api/card/%d", cardID)
_, err := metabaseRequest("DELETE", client.BaseURL, client.Token, path, nil)
if err != nil {
return fmt.Errorf("metabase delete card %d: %w", cardID, err)
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
---
name: metabase_delete_card
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseDeleteCard(client MetabaseClient, cardID int) error"
description: "Elimina permanentemente una card/pregunta de Metabase. Accion irreversible. Para soft-delete usar MetabaseUpdateCard con archived:true. Endpoint: DELETE /api/card/:id."
tags: [metabase, card, question, delete, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_delete_card.go"
---
## Ejemplo
```go
// Eliminar permanentemente
err := MetabaseDeleteCard(client, 42)
// Preferir soft-delete cuando sea posible:
// MetabaseUpdateCard(client, 42, map[string]any{"archived": true})
```
## Notas
**ATENCION**: Esta operacion es irreversible. La card se elimina permanentemente.
Para un borrado seguro, preferir archivar con `MetabaseUpdateCard(client, cardID, map[string]any{"archived": true})` que permite recuperar la card despues.
@@ -0,0 +1,16 @@
package infra
import "fmt"
// MetabaseDeleteDashboard elimina permanentemente un dashboard de Metabase.
// Para soft-delete, usar MetabaseUpdateDashboard con archived: true.
func MetabaseDeleteDashboard(client MetabaseClient, dashboardID int) error {
path := fmt.Sprintf("/api/dashboard/%d", dashboardID)
_, err := metabaseRequest("DELETE", client.BaseURL, client.Token, path, nil)
if err != nil {
return fmt.Errorf("metabase delete dashboard %d: %w", dashboardID, err)
}
return nil
}
@@ -0,0 +1,37 @@
---
name: metabase_delete_dashboard
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseDeleteDashboard(client MetabaseClient, dashboardID int) error"
description: "Elimina permanentemente un dashboard de Metabase. Accion irreversible. Para soft-delete usar MetabaseUpdateDashboard con archived:true. Endpoint: DELETE /api/dashboard/:id."
tags: [metabase, dashboard, delete, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_delete_dashboard.go"
---
## Ejemplo
```go
// Eliminar permanentemente
err := MetabaseDeleteDashboard(client, 1)
// Preferir soft-delete:
// MetabaseUpdateDashboard(client, 1, map[string]any{"archived": true})
```
## Notas
**ATENCION**: Esta operacion es irreversible. El dashboard y todas sus dashcards se eliminan permanentemente.
Para un borrado seguro, preferir archivar con `MetabaseUpdateDashboard(client, dashboardID, map[string]any{"archived": true})`.
+22
View File
@@ -0,0 +1,22 @@
package infra
import "fmt"
// MetabaseExecuteCard ejecuta la query de una card/pregunta guardada.
// parameters: parametros de la query (nil si no tiene parametros).
// Retorna los resultados con columnas y filas.
func MetabaseExecuteCard(client MetabaseClient, cardID int, parameters []map[string]any) (map[string]any, error) {
path := fmt.Sprintf("/api/card/%d/query", cardID)
var body map[string]any
if len(parameters) > 0 {
body = map[string]any{"parameters": parameters}
}
result, err := metabaseRequest("POST", client.BaseURL, client.Token, path, body)
if err != nil {
return nil, fmt.Errorf("metabase execute card %d: %w", cardID, err)
}
return result, nil
}
+71
View File
@@ -0,0 +1,71 @@
---
name: metabase_execute_card
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseExecuteCard(client MetabaseClient, cardID int, parameters []map[string]any) (map[string]any, error)"
description: "Ejecuta la query de una card/pregunta guardada en Metabase y retorna los resultados. Soporta parametros para queries parametrizadas. Endpoint: POST /api/card/:id/query."
tags: [metabase, card, question, execute, query, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_execute_card.go"
---
## Ejemplo
```go
// Ejecutar sin parametros
result, err := MetabaseExecuteCard(client, 42, nil)
if err != nil {
log.Fatal(err)
}
data := result["data"].(map[string]any)
rows := data["rows"].([]any)
fmt.Printf("Filas: %d\n", len(rows))
// Ejecutar con parametros
result, err := MetabaseExecuteCard(client, 42, []map[string]any{
{
"type": "category",
"target": []any{"variable", []any{"template-tag", "status"}},
"value": "active",
},
})
```
## Notas
### Estructura de la respuesta
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| status | string | "completed" o "failed" |
| row_count | float64 | Numero de filas |
| running_time | float64 | Tiempo de ejecucion en ms |
| data.columns | []string | Nombres de columnas |
| data.rows | [][]any | Filas de datos |
| data.cols | []map | Metadata de columnas (name, base_type, display_name) |
| data.native_form.query | string | SQL ejecutado |
### Parametros para queries parametrizadas
```go
[]map[string]any{
{
"type": "category", // tipo del parametro
"target": []any{"variable", []any{"template-tag", "tag"}}, // referencia al template-tag
"value": "valor", // valor a inyectar
},
}
```
Limite por defecto: 2000 filas. Para queries ad-hoc sin card, usar MetabaseExecuteQuery.
+28
View File
@@ -0,0 +1,28 @@
package infra
import "fmt"
// MetabaseExecuteQuery ejecuta una query ad-hoc (sin guardar como card) en Metabase.
// databaseID: ID de la base de datos en Metabase.
// sql: query SQL a ejecutar.
// maxResults: limite de filas (0 = default 2000 de Metabase).
func MetabaseExecuteQuery(client MetabaseClient, databaseID int, sql string, maxResults int) (map[string]any, error) {
body := map[string]any{
"database": databaseID,
"type": "native",
"native": map[string]any{"query": sql},
}
if maxResults > 0 {
body["constraints"] = map[string]any{
"max-results": maxResults,
"max-results-bare-rows": maxResults,
}
}
result, err := metabaseRequest("POST", client.BaseURL, client.Token, "/api/dataset", body)
if err != nil {
return nil, fmt.Errorf("metabase execute query: %w", err)
}
return result, nil
}
+63
View File
@@ -0,0 +1,63 @@
---
name: metabase_execute_query
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseExecuteQuery(client MetabaseClient, databaseID int, sql string, maxResults int) (map[string]any, error)"
description: "Ejecuta una query SQL ad-hoc contra una database de Metabase sin guardarla como card. Util para consultas rapidas y exploracion. Endpoint: POST /api/dataset."
tags: [metabase, query, execute, sql, dataset, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_execute_query.go"
---
## Ejemplo
```go
// Query simple
result, err := MetabaseExecuteQuery(client, 1, "SELECT * FROM users LIMIT 10", 0)
if err != nil {
log.Fatal(err)
}
data := result["data"].(map[string]any)
rows := data["rows"].([]any)
// Query con limite custom
result, err := MetabaseExecuteQuery(client, 1, "SELECT * FROM orders", 5000)
```
## Notas
### Parametros para un LLM
| Parametro | Tipo | Requerido | Descripcion |
|-----------|------|-----------|-------------|
| client | MetabaseClient | si | Cliente autenticado |
| databaseID | int | si | ID de la database en Metabase (obtener con GET /api/database) |
| sql | string | si | Query SQL a ejecutar |
| maxResults | int | no | Limite de filas. 0 = default 2000 |
### Diferencia con MetabaseExecuteCard
- `MetabaseExecuteQuery`: query ad-hoc, no se guarda. Usa POST /api/dataset.
- `MetabaseExecuteCard`: ejecuta una card ya guardada. Usa POST /api/card/:id/query.
Usar esta funcion para exploracion rapida. Si la query se va a reutilizar, crear una card con MetabaseCreateCard.
### Estructura de la respuesta
Misma estructura que MetabaseExecuteCard:
- `data.columns`: nombres de columnas
- `data.rows`: filas de datos
- `row_count`: numero de filas
- `running_time`: tiempo en ms
- `status`: "completed" o "failed"
+15
View File
@@ -0,0 +1,15 @@
package infra
import "fmt"
// MetabaseGetCard obtiene una card/pregunta de Metabase por su ID.
func MetabaseGetCard(client MetabaseClient, cardID int) (map[string]any, error) {
path := fmt.Sprintf("/api/card/%d", cardID)
result, err := metabaseRequest("GET", client.BaseURL, client.Token, path, nil)
if err != nil {
return nil, fmt.Errorf("metabase get card %d: %w", cardID, err)
}
return result, nil
}
+52
View File
@@ -0,0 +1,52 @@
---
name: metabase_get_card
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseGetCard(client MetabaseClient, cardID int) (map[string]any, error)"
description: "Obtiene los detalles completos de una card/pregunta de Metabase por su ID. Incluye la query, visualizacion y metadata. Endpoint: GET /api/card/:id."
tags: [metabase, card, question, get, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_get_card.go"
---
## Ejemplo
```go
card, err := MetabaseGetCard(client, 42)
if err != nil {
log.Fatal(err)
}
fmt.Println(card["name"], card["display"])
```
## Notas
Retorna el objeto card completo. Error 404 si no existe.
### Campos principales
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | float64 | ID de la card |
| name | string | Nombre |
| description | string | Descripcion |
| display | string | Tipo visualizacion |
| dataset_query | map | Query (native.query para SQL, query para MBQL) |
| visualization_settings | map | Config de visualizacion |
| collection_id | float64 | Coleccion contenedora |
| database_id | float64 | Database asociada |
| archived | bool | Archivada |
| creator | map | Objeto del usuario creador |
| created_at | string | Fecha creacion |
| updated_at | string | Fecha actualizacion |
+15
View File
@@ -0,0 +1,15 @@
package infra
import "fmt"
// MetabaseGetDashboard obtiene un dashboard completo de Metabase incluyendo sus cards.
func MetabaseGetDashboard(client MetabaseClient, dashboardID int) (map[string]any, error) {
path := fmt.Sprintf("/api/dashboard/%d", dashboardID)
result, err := metabaseRequest("GET", client.BaseURL, client.Token, path, nil)
if err != nil {
return nil, fmt.Errorf("metabase get dashboard %d: %w", dashboardID, err)
}
return result, nil
}
+71
View File
@@ -0,0 +1,71 @@
---
name: metabase_get_dashboard
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseGetDashboard(client MetabaseClient, dashboardID int) (map[string]any, error)"
description: "Obtiene un dashboard completo de Metabase incluyendo todas sus dashcards (cards posicionadas en el dashboard), tabs y parametros. Endpoint: GET /api/dashboard/:id."
tags: [metabase, dashboard, get, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_get_dashboard.go"
---
## Ejemplo
```go
dashboard, err := MetabaseGetDashboard(client, 1)
if err != nil {
log.Fatal(err)
}
fmt.Println(dashboard["name"])
// Acceder a las cards del dashboard
dashcards := dashboard["dashcards"].([]any)
for _, dc := range dashcards {
card := dc.(map[string]any)
fmt.Printf("Card ID: %v, Position: (%v, %v)\n",
card["card_id"], card["col"], card["row"])
}
```
## Notas
### Campos principales
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | float64 | ID del dashboard |
| name | string | Nombre |
| description | string | Descripcion |
| dashcards | []map | Array de dashcards (cards posicionadas) |
| parameters | []map | Filtros del dashboard |
| tabs | []map | Tabs del dashboard |
| collection_id | float64 | Coleccion contenedora |
| archived | bool | Archivado |
### Estructura de cada dashcard
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | float64 | ID del dashcard (positivo) |
| card_id | float64 | ID de la card/pregunta asociada |
| card | map | Objeto card completo |
| size_x | float64 | Ancho en grid (1-18) |
| size_y | float64 | Alto en grid |
| col | float64 | Columna en grid (0-based) |
| row | float64 | Fila en grid (0-based) |
| dashboard_tab_id | float64 | Tab al que pertenece (null = sin tabs) |
| parameter_mappings | []map | Mapeo de filtros a la card |
| visualization_settings | map | Settings de visualizacion |
Usar estos datos para construir el payload de MetabaseUpdateDashboard.
+15
View File
@@ -0,0 +1,15 @@
package infra
import "fmt"
// MetabaseGetUser obtiene un usuario de Metabase por su ID.
func MetabaseGetUser(client MetabaseClient, userID int) (map[string]any, error) {
path := fmt.Sprintf("/api/user/%d", userID)
result, err := metabaseRequest("GET", client.BaseURL, client.Token, path, nil)
if err != nil {
return nil, fmt.Errorf("metabase get user %d: %w", userID, err)
}
return result, nil
}
+51
View File
@@ -0,0 +1,51 @@
---
name: metabase_get_user
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseGetUser(client MetabaseClient, userID int) (map[string]any, error)"
description: "Obtiene los detalles de un usuario de Metabase por su ID numerico. Endpoint: GET /api/user/:id."
tags: [metabase, user, get, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_get_user.go"
---
## Ejemplo
```go
user, err := MetabaseGetUser(client, 1)
if err != nil {
log.Fatal(err)
}
fmt.Println(user["email"], user["first_name"])
```
## Notas
Retorna el objeto usuario completo como map. Error 404 si el ID no existe.
### Campos del usuario retornado
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | float64 | ID numerico |
| email | string | Email |
| first_name | string | Nombre |
| last_name | string | Apellido |
| is_superuser | bool | Es admin |
| is_active | bool | Esta activo |
| common_name | string | Nombre completo |
| date_joined | string | Fecha de creacion |
| last_login | string | Ultimo login |
| group_ids | []float64 | IDs de grupos |
| locale | string | Locale del usuario |
+107
View File
@@ -0,0 +1,107 @@
package infra
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
// metabaseRequest ejecuta una peticion HTTP contra la API de Metabase.
// method: GET, POST, PUT, DELETE
// baseURL: URL base sin trailing slash
// token: session token o API key
// path: ruta relativa (ej: "/api/user")
// body: payload JSON (nil para requests sin body)
// Retorna el body deserializado como map o nil si el body esta vacio.
func metabaseRequest(method, baseURL, token, path string, body map[string]any) (map[string]any, error) {
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal body: %w", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequest(method, baseURL+path, reqBody)
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Metabase-Session", token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("http %s %s: %w", method, path, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("metabase %s %s: status %d: %s", method, path, resp.StatusCode, string(respBody))
}
if len(respBody) == 0 {
return nil, nil
}
var result map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
return result, nil
}
// metabaseRequestList es como metabaseRequest pero para endpoints que retornan un array JSON.
func metabaseRequestList(method, baseURL, token, path string, body map[string]any) ([]map[string]any, error) {
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal body: %w", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequest(method, baseURL+path, reqBody)
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Metabase-Session", token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("http %s %s: %w", method, path, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("metabase %s %s: status %d: %s", method, path, resp.StatusCode, string(respBody))
}
if len(respBody) == 0 {
return nil, nil
}
var result []map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
return result, nil
}
+25
View File
@@ -0,0 +1,25 @@
package infra
import "fmt"
// MetabaseListCards lista preguntas/cards de Metabase.
// filter: "all", "mine", "fav", "archived", "recent", "popular", "database", "table" (vacio = todas).
// modelID: ID de database o tabla cuando filter es "database" o "table" (0 = ignorar).
func MetabaseListCards(client MetabaseClient, filter string, modelID int) ([]map[string]any, error) {
path := "/api/card"
sep := "?"
if filter != "" {
path += sep + "f=" + filter
sep = "&"
}
if modelID > 0 {
path += fmt.Sprintf("%smodel_id=%d", sep, modelID)
}
result, err := metabaseRequestList("GET", client.BaseURL, client.Token, path, nil)
if err != nil {
return nil, fmt.Errorf("metabase list cards: %w", err)
}
return result, nil
}
+63
View File
@@ -0,0 +1,63 @@
---
name: metabase_list_cards
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseListCards(client MetabaseClient, filter string, modelID int) ([]map[string]any, error)"
description: "Lista preguntas/cards de Metabase con filtro opcional. Retorna array de cards. Endpoint: GET /api/card."
tags: [metabase, card, question, list, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_list_cards.go"
---
## Ejemplo
```go
// Listar todas las cards
cards, err := MetabaseListCards(client, "all", 0)
// Solo mis preguntas
cards, err := MetabaseListCards(client, "mine", 0)
// Cards de una database especifica
cards, err := MetabaseListCards(client, "database", 1)
// Cards archivadas
cards, err := MetabaseListCards(client, "archived", 0)
```
## Notas
### Parametros para un LLM
| Parametro | Tipo | Requerido | Descripcion |
|-----------|------|-----------|-------------|
| client | MetabaseClient | si | Cliente autenticado |
| filter | string | no | "all", "mine", "fav", "archived", "recent", "popular", "database", "table". Vacio = todas |
| modelID | int | no | ID de database/tabla. Solo aplica con filter "database" o "table". 0 = ignorar |
No tiene paginacion con offset/limit. Retorna todas las cards que coinciden.
### Campos principales de cada card
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | float64 | ID numerico de la card |
| name | string | Nombre de la pregunta |
| description | string | Descripcion |
| display | string | Tipo de visualizacion (table, bar, line, pie, etc.) |
| collection_id | float64 | ID de la coleccion/carpeta |
| database_id | float64 | ID de la database |
| creator_id | float64 | ID del creador |
| archived | bool | Esta archivada |
| dataset_query | map | Query de la card (native o structured) |
@@ -0,0 +1,19 @@
package infra
import "fmt"
// MetabaseListDashboards lista dashboards de Metabase.
// filter: "all", "mine" o "archived" (vacio = todas).
func MetabaseListDashboards(client MetabaseClient, filter string) ([]map[string]any, error) {
path := "/api/dashboard"
if filter != "" {
path += "?f=" + filter
}
result, err := metabaseRequestList("GET", client.BaseURL, client.Token, path, nil)
if err != nil {
return nil, fmt.Errorf("metabase list dashboards: %w", err)
}
return result, nil
}
@@ -0,0 +1,57 @@
---
name: metabase_list_dashboards
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseListDashboards(client MetabaseClient, filter string) ([]map[string]any, error)"
description: "Lista dashboards de Metabase con filtro opcional. Retorna array de dashboards resumidos (sin dashcards). Endpoint: GET /api/dashboard."
tags: [metabase, dashboard, list, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_list_dashboards.go"
---
## Ejemplo
```go
// Listar todos los dashboards
dashboards, err := MetabaseListDashboards(client, "all")
// Solo mis dashboards
dashboards, err := MetabaseListDashboards(client, "mine")
// Dashboards archivados
dashboards, err := MetabaseListDashboards(client, "archived")
```
## Notas
### Parametros para un LLM
| Parametro | Tipo | Requerido | Descripcion |
|-----------|------|-----------|-------------|
| client | MetabaseClient | si | Cliente autenticado |
| filter | string | no | "all", "mine", "archived". Vacio = todas |
Retorna dashboards resumidos (sin cards). Para ver las cards de un dashboard, usar MetabaseGetDashboard.
### Campos principales de cada dashboard
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | float64 | ID del dashboard |
| name | string | Nombre |
| description | string | Descripcion |
| collection_id | float64 | Coleccion contenedora |
| creator_id | float64 | ID del creador |
| archived | bool | Archivado |
| created_at | string | Fecha creacion |
+30
View File
@@ -0,0 +1,30 @@
package infra
import "fmt"
// MetabaseListUsers lista usuarios de Metabase con filtros opcionales.
// status: "active", "deactivated" o "all" (vacio = "active").
// query: filtro por nombre o email (vacio = sin filtro).
// limit/offset: paginacion (0 = valores por defecto de Metabase).
func MetabaseListUsers(client MetabaseClient, status, query string, limit, offset int) (map[string]any, error) {
path := "/api/user?"
if status != "" {
path += "status=" + status + "&"
}
if query != "" {
path += "query=" + query + "&"
}
if limit > 0 {
path += fmt.Sprintf("limit=%d&", limit)
}
if offset > 0 {
path += fmt.Sprintf("offset=%d&", offset)
}
result, err := metabaseRequest("GET", client.BaseURL, client.Token, path, nil)
if err != nil {
return nil, fmt.Errorf("metabase list users: %w", err)
}
return result, nil
}
+67
View File
@@ -0,0 +1,67 @@
---
name: metabase_list_users
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseListUsers(client MetabaseClient, status, query string, limit, offset int) (map[string]any, error)"
description: "Lista usuarios de una instancia Metabase con filtros opcionales por estado, nombre/email y paginacion. Endpoint: GET /api/user. Requiere permisos de superusuario."
tags: [metabase, user, list, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_list_users.go"
---
## Ejemplo
```go
client, _ := MetabaseAuth("http://localhost:3000", "admin@example.com", "pass")
// Listar todos los usuarios activos
users, err := MetabaseListUsers(client, "active", "", 0, 0)
// Buscar usuario por email
users, err := MetabaseListUsers(client, "", "john@", 10, 0)
// Listar desactivados
users, err := MetabaseListUsers(client, "deactivated", "", 25, 0)
```
## Notas
Retorna un map con la estructura paginada de Metabase:
- `data`: array de objetos usuario (id, email, first_name, last_name, is_superuser, etc.)
- `total`: numero total de usuarios que coinciden
- `limit`: tamanio de pagina usado
- `offset`: offset usado
### Parametros para un LLM
| Parametro | Tipo | Requerido | Descripcion |
|-----------|------|-----------|-------------|
| client | MetabaseClient | si | Cliente autenticado |
| status | string | no | "active" (default), "deactivated", "all" |
| query | string | no | Filtro por nombre o email |
| limit | int | no | Tamanio de pagina (0 = default Metabase) |
| offset | int | no | Offset para paginacion (0 = inicio) |
### Campos del usuario retornado
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | float64 | ID numerico del usuario |
| email | string | Email unico |
| first_name | string | Nombre |
| last_name | string | Apellido |
| is_superuser | bool | Es admin |
| is_active | bool | Esta activo |
| common_name | string | Nombre completo |
| last_login | string | Fecha ultimo login |
+18
View File
@@ -0,0 +1,18 @@
package infra
import "fmt"
// MetabaseUpdateCard actualiza campos de una card/pregunta en Metabase.
// fields es un map con los campos a actualizar.
// Campos comunes: name, description, display, dataset_query, visualization_settings,
// collection_id, archived, enable_embedding.
func MetabaseUpdateCard(client MetabaseClient, cardID int, fields map[string]any) (map[string]any, error) {
path := fmt.Sprintf("/api/card/%d", cardID)
result, err := metabaseRequest("PUT", client.BaseURL, client.Token, path, fields)
if err != nil {
return nil, fmt.Errorf("metabase update card %d: %w", cardID, err)
}
return result, nil
}
+69
View File
@@ -0,0 +1,69 @@
---
name: metabase_update_card
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseUpdateCard(client MetabaseClient, cardID int, fields map[string]any) (map[string]any, error)"
description: "Actualiza campos de una card/pregunta en Metabase. Solo se modifican los campos incluidos en el map. Endpoint: PUT /api/card/:id."
tags: [metabase, card, question, update, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_update_card.go"
---
## Ejemplo
```go
// Cambiar nombre y descripcion
card, err := MetabaseUpdateCard(client, 42, map[string]any{
"name": "Updated Revenue Chart",
"description": "Now includes refunds",
})
// Archivar una card (soft-delete)
card, err := MetabaseUpdateCard(client, 42, map[string]any{
"archived": true,
})
// Mover a otra coleccion
card, err := MetabaseUpdateCard(client, 42, map[string]any{
"collection_id": 10,
})
// Cambiar la query SQL
card, err := MetabaseUpdateCard(client, 42, map[string]any{
"dataset_query": map[string]any{
"database": 1,
"type": "native",
"native": map[string]any{"query": "SELECT * FROM users LIMIT 100"},
},
})
```
## Notas
### Campos actualizables
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| name | string | Nombre de la pregunta |
| description | string | Descripcion |
| display | string | Tipo de visualizacion |
| dataset_query | map | Query SQL o MBQL |
| visualization_settings | map | Config de visualizacion |
| collection_id | int | Mover a otra coleccion |
| archived | bool | Archivar/desarchivar (soft-delete) |
| enable_embedding | bool | Habilitar embedding publico |
| embedding_params | map | Parametros de embedding |
Solo incluir los campos que se quieren cambiar.
Para eliminar permanentemente usar MetabaseDeleteCard. Para soft-delete usar archived: true.
@@ -0,0 +1,23 @@
package infra
import "fmt"
// MetabaseUpdateDashboard actualiza un dashboard en Metabase.
// fields puede incluir metadata del dashboard Y/O la lista completa de dashcards y tabs.
//
// Para gestionar cards en el dashboard, incluir "dashcards" en fields:
// - Agregar card: incluirla con ID negativo (ej: -1, -2)
// - Actualizar card: incluirla con su ID positivo existente
// - Eliminar card: omitirla del array (el array es el estado deseado completo)
//
// Campos comunes: name, description, archived, parameters, dashcards, tabs, collection_id.
func MetabaseUpdateDashboard(client MetabaseClient, dashboardID int, fields map[string]any) (map[string]any, error) {
path := fmt.Sprintf("/api/dashboard/%d", dashboardID)
result, err := metabaseRequest("PUT", client.BaseURL, client.Token, path, fields)
if err != nil {
return nil, fmt.Errorf("metabase update dashboard %d: %w", dashboardID, err)
}
return result, nil
}
@@ -0,0 +1,110 @@
---
name: metabase_update_dashboard
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseUpdateDashboard(client MetabaseClient, dashboardID int, fields map[string]any) (map[string]any, error)"
description: "Actualiza un dashboard en Metabase incluyendo metadata, cards y tabs. El campo dashcards representa el estado completo deseado: cards nuevas con ID negativo, existentes con ID positivo, omitidas se eliminan. Endpoint: PUT /api/dashboard/:id."
tags: [metabase, dashboard, update, cards, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_update_dashboard.go"
---
## Ejemplo
```go
// Cambiar nombre
MetabaseUpdateDashboard(client, 1, map[string]any{
"name": "Updated Dashboard",
})
// Agregar una card al dashboard
// Primero obtener las dashcards existentes
dash, _ := MetabaseGetDashboard(client, 1)
existingCards := dash["dashcards"].([]any)
// Construir nuevo array con las existentes + la nueva
dashcards := make([]map[string]any, 0)
for _, dc := range existingCards {
dashcards = append(dashcards, dc.(map[string]any))
}
// Agregar nueva card (ID negativo = nueva)
dashcards = append(dashcards, map[string]any{
"id": -1, "card_id": 55, "size_x": 6, "size_y": 4, "col": 0, "row": 0,
})
MetabaseUpdateDashboard(client, 1, map[string]any{
"dashcards": dashcards,
})
// Archivar dashboard (soft-delete)
MetabaseUpdateDashboard(client, 1, map[string]any{"archived": true})
```
## Notas
### Gestion de dashcards (IMPORTANTE)
El array `dashcards` representa el **estado completo deseado** del dashboard:
| Accion | Como hacerlo |
|--------|-------------|
| Agregar card | Incluir con **ID negativo** (-1, -2, etc.) |
| Actualizar card | Incluir con su **ID positivo** existente |
| Eliminar card | **Omitir** del array |
| No cambiar cards | No incluir el campo dashcards |
**Flujo tipico para agregar una card:**
1. `MetabaseGetDashboard` para obtener dashcards existentes
2. Copiar las existentes al nuevo array
3. Agregar la nueva con ID negativo
4. Enviar el array completo
### Estructura de una dashcard
```go
map[string]any{
"id": -1, // negativo = nueva, positivo = existente
"card_id": 42, // ID de la card/pregunta
"size_x": 6, // ancho (1-18)
"size_y": 4, // alto
"col": 0, // columna (0-based)
"row": 0, // fila (0-based)
"dashboard_tab_id": nil, // tab (nil = sin tabs)
"parameter_mappings": []map[string]any{}, // mapeo de filtros
"visualization_settings": map[string]any{}, // settings custom
}
```
### Gestion de tabs
```go
map[string]any{
"tabs": []map[string]any{
{"id": 1, "name": "Overview"}, // tab existente
{"id": -1, "name": "Details"}, // tab nuevo (ID negativo)
},
}
```
### Campos actualizables
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| name | string | Nombre del dashboard |
| description | string | Descripcion |
| archived | bool | Archivar/desarchivar |
| dashcards | []map | Estado completo de cards |
| tabs | []map | Tabs del dashboard |
| parameters | []map | Filtros del dashboard |
| collection_id | int | Mover a otra coleccion |
+17
View File
@@ -0,0 +1,17 @@
package infra
import "fmt"
// MetabaseUpdateUser actualiza campos de un usuario en Metabase.
// fields es un map con los campos a actualizar. Campos validos:
// first_name, last_name, email, is_superuser, group_ids, locale, login_attributes.
func MetabaseUpdateUser(client MetabaseClient, userID int, fields map[string]any) (map[string]any, error) {
path := fmt.Sprintf("/api/user/%d", userID)
result, err := metabaseRequest("PUT", client.BaseURL, client.Token, path, fields)
if err != nil {
return nil, fmt.Errorf("metabase update user %d: %w", userID, err)
}
return result, nil
}
+58
View File
@@ -0,0 +1,58 @@
---
name: metabase_update_user
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MetabaseUpdateUser(client MetabaseClient, userID int, fields map[string]any) (map[string]any, error)"
description: "Actualiza campos de un usuario en Metabase. Solo se modifican los campos incluidos en el map. Requiere permisos de superusuario. Endpoint: PUT /api/user/:id."
tags: [metabase, user, update, api]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/metabase_update_user.go"
---
## Ejemplo
```go
// Cambiar nombre
user, err := MetabaseUpdateUser(client, 5, map[string]any{
"first_name": "Jane",
"last_name": "Smith",
})
// Promover a admin
user, err := MetabaseUpdateUser(client, 5, map[string]any{
"is_superuser": true,
})
// Cambiar grupos
user, err := MetabaseUpdateUser(client, 5, map[string]any{
"group_ids": []int{1, 3, 5},
})
```
## Notas
### Campos actualizables
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| first_name | string | Nombre |
| last_name | string | Apellido |
| email | string | Email (debe ser unico) |
| is_superuser | bool | Permisos de admin |
| group_ids | []int | IDs de grupos del usuario |
| locale | string | Locale (ej: "es", "en") |
| login_attributes | map | Atributos para sandboxing |
Solo incluir los campos que se quieren cambiar. Los demas se mantienen sin modificar.
Retorna el objeto usuario actualizado.
+6
View File
@@ -20,3 +20,9 @@ type ImageInfo struct {
Size string
Created string
}
// MetabaseClient holds the connection details for a Metabase instance API.
type MetabaseClient struct {
BaseURL string // e.g. "http://localhost:3000"
Token string // session token or API key
}
+1
View File
@@ -0,0 +1 @@
__pycache__/
+1
View File
@@ -0,0 +1 @@
3.12
+11
View File
@@ -0,0 +1,11 @@
from .client import MetabaseClient
from .users import metabase_list_users, metabase_get_user, metabase_create_user, metabase_update_user, metabase_deactivate_user
from .cards import metabase_list_cards, metabase_get_card, metabase_create_card, metabase_update_card, metabase_delete_card, metabase_execute_card, metabase_execute_query
from .dashboards import metabase_list_dashboards, metabase_get_dashboard, metabase_create_dashboard, metabase_update_dashboard, metabase_delete_dashboard
__all__ = [
"MetabaseClient",
"metabase_list_users", "metabase_get_user", "metabase_create_user", "metabase_update_user", "metabase_deactivate_user",
"metabase_list_cards", "metabase_get_card", "metabase_create_card", "metabase_update_card", "metabase_delete_card", "metabase_execute_card", "metabase_execute_query",
"metabase_list_dashboards", "metabase_get_dashboard", "metabase_create_dashboard", "metabase_update_dashboard", "metabase_delete_dashboard",
]
+227
View File
@@ -0,0 +1,227 @@
"""CRUD de cards/preguntas de Metabase y ejecucion de queries."""
from .client import MetabaseClient
def metabase_list_cards(
client: MetabaseClient,
filter: str = "",
model_id: int = 0,
) -> list[dict]:
"""Lista preguntas/cards de Metabase con filtro opcional.
Endpoint: GET /api/card. No tiene paginacion offset/limit.
Args:
client: Cliente autenticado.
filter: "all", "mine", "fav", "archived", "recent", "popular",
"database", "table". Vacio = todas.
model_id: ID de database/tabla. Solo aplica con filter "database" o "table".
Returns:
Lista de dicts, cada uno con: id, name, description, display,
collection_id, database_id, creator_id, archived, dataset_query.
Example:
>>> cards = metabase_list_cards(client, filter="mine")
>>> for c in cards:
... print(c["id"], c["name"], c["display"])
"""
params = {}
if filter:
params["f"] = filter
if model_id > 0:
params["model_id"] = model_id
return client.request("GET", "/api/card", params=params)
def metabase_get_card(client: MetabaseClient, card_id: int) -> dict:
"""Obtiene los detalles completos de una card/pregunta.
Endpoint: GET /api/card/:id.
Args:
client: Cliente autenticado.
card_id: ID de la card.
Returns:
Dict con: id, name, description, display, dataset_query,
visualization_settings, collection_id, database_id, archived,
creator, created_at, updated_at.
Example:
>>> card = metabase_get_card(client, 42)
>>> print(card["name"], card["display"])
>>> print(card["dataset_query"]["native"]["query"]) # SQL
"""
return client.request("GET", f"/api/card/{card_id}")
def metabase_create_card(
client: MetabaseClient,
name: str,
dataset_query: dict,
display: str = "table",
collection_id: int = 0,
description: str = "",
) -> dict:
"""Crea una nueva card/pregunta en Metabase.
Endpoint: POST /api/card.
Args:
client: Cliente autenticado.
name: Nombre de la pregunta.
dataset_query: Query de la card. Estructura:
SQL nativo: {"database": 1, "type": "native", "native": {"query": "SELECT ..."}}
MBQL: {"database": 1, "type": "query", "query": {"source-table": 4, ...}}
display: Tipo de visualizacion: "table", "bar", "line", "pie", "scalar",
"area", "row", "combo", "funnel", "scatter", "waterfall", etc.
collection_id: ID de coleccion destino. 0 = root.
description: Descripcion opcional.
Returns:
Dict con la card creada.
Example:
>>> card = metabase_create_card(client, "Revenue by Month", {
... "database": 1,
... "type": "native",
... "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
... }, display="line", description="Monthly revenue trend")
"""
body: dict = {
"name": name,
"dataset_query": dataset_query,
"display": display,
"visualization_settings": {},
}
if collection_id > 0:
body["collection_id"] = collection_id
if description:
body["description"] = description
return client.request("POST", "/api/card", json=body)
def metabase_update_card(client: MetabaseClient, card_id: int, **fields) -> dict:
"""Actualiza campos de una card/pregunta en Metabase.
Endpoint: PUT /api/card/:id. Solo se modifican los campos pasados.
Args:
client: Cliente autenticado.
card_id: ID de la card.
**fields: Campos a actualizar. Validos:
name (str), description (str), display (str),
dataset_query (dict), visualization_settings (dict),
collection_id (int), archived (bool),
enable_embedding (bool), embedding_params (dict).
Returns:
Dict con la card actualizada.
Example:
>>> metabase_update_card(client, 42, name="Updated Name", archived=True)
>>> metabase_update_card(client, 42, dataset_query={
... "database": 1, "type": "native",
... "native": {"query": "SELECT * FROM users LIMIT 100"},
... })
"""
return client.request("PUT", f"/api/card/{card_id}", json=fields)
def metabase_delete_card(client: MetabaseClient, card_id: int) -> None:
"""Elimina permanentemente una card/pregunta.
Endpoint: DELETE /api/card/:id. IRREVERSIBLE.
Para soft-delete preferir: metabase_update_card(client, card_id, archived=True)
Args:
client: Cliente autenticado.
card_id: ID de la card a eliminar.
Example:
>>> metabase_delete_card(client, 42)
>>> # Preferir soft-delete: metabase_update_card(client, 42, archived=True)
"""
client.request("DELETE", f"/api/card/{card_id}")
def metabase_execute_card(
client: MetabaseClient,
card_id: int,
parameters: list[dict] | None = None,
) -> dict:
"""Ejecuta la query de una card/pregunta guardada.
Endpoint: POST /api/card/:id/query.
Args:
client: Cliente autenticado.
card_id: ID de la card a ejecutar.
parameters: Parametros para queries parametrizadas. Cada parametro:
{"type": "category", "target": ["variable", ["template-tag", "tag"]], "value": "val"}
Returns:
Dict con resultados:
- status: "completed" o "failed"
- row_count: numero de filas
- running_time: tiempo en ms
- data.columns: nombres de columnas
- data.rows: filas de datos (lista de listas)
- data.cols: metadata de columnas
- data.native_form.query: SQL ejecutado
Example:
>>> result = metabase_execute_card(client, 42)
>>> for row in result["data"]["rows"]:
... print(row)
>>> # Con parametros:
>>> result = metabase_execute_card(client, 42, parameters=[
... {"type": "category", "target": ["variable", ["template-tag", "status"]], "value": "active"},
... ])
"""
body = {}
if parameters:
body["parameters"] = parameters
return client.request("POST", f"/api/card/{card_id}/query", json=body or None)
def metabase_execute_query(
client: MetabaseClient,
database_id: int,
sql: str,
max_results: int = 0,
) -> dict:
"""Ejecuta una query SQL ad-hoc sin guardarla como card.
Endpoint: POST /api/dataset. Util para exploracion rapida y consultas
que no necesitan persistirse.
Args:
client: Cliente autenticado.
database_id: ID de la database en Metabase.
sql: Query SQL a ejecutar.
max_results: Limite de filas. 0 = default 2000.
Returns:
Dict con misma estructura que metabase_execute_card:
data.columns, data.rows, row_count, running_time, status.
Example:
>>> result = metabase_execute_query(client, 1, "SELECT * FROM users LIMIT 10")
>>> print(f"{result['row_count']} filas en {result['running_time']}ms")
>>> for row in result["data"]["rows"]:
... print(row)
"""
body: dict = {
"database": database_id,
"type": "native",
"native": {"query": sql},
}
if max_results > 0:
body["constraints"] = {
"max-results": max_results,
"max-results-bare-rows": max_results,
}
return client.request("POST", "/api/dataset", json=body)
+87
View File
@@ -0,0 +1,87 @@
"""Cliente base para la API REST de Metabase."""
import httpx
class MetabaseClient:
"""Cliente HTTP para una instancia Metabase.
Attributes:
base_url: URL base sin trailing slash (ej: "http://localhost:3000").
token: Session token o API key.
_http: Cliente httpx reutilizable con headers de auth.
"""
def __init__(self, base_url: str, token: str) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
self._http = httpx.Client(
base_url=self.base_url,
headers={
"Content-Type": "application/json",
"X-Metabase-Session": token,
},
timeout=30.0,
)
def request(self, method: str, path: str, **kwargs) -> dict | list | None:
"""Ejecuta una peticion HTTP contra la API de Metabase.
Args:
method: HTTP method (GET, POST, PUT, DELETE).
path: Ruta relativa (ej: "/api/user").
**kwargs: Argumentos extra para httpx (json, params, etc.).
Returns:
Respuesta deserializada como dict/list, o None si el body esta vacio.
Raises:
httpx.HTTPStatusError: Si el status code no es 2xx.
"""
resp = self._http.request(method, path, **kwargs)
resp.raise_for_status()
if not resp.content:
return None
return resp.json()
def close(self) -> None:
"""Cierra el cliente HTTP."""
self._http.close()
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
def metabase_auth(base_url: str, email: str, password: str) -> MetabaseClient:
"""Autentica contra Metabase con email y password.
Crea una sesion via POST /api/session y retorna un MetabaseClient
con el session token listo para usar. El token expira en 14 dias
por defecto (configurable con MAX_SESSION_AGE en Metabase).
Args:
base_url: URL base de la instancia (ej: "http://localhost:3000").
email: Email del usuario Metabase.
password: Password del usuario.
Returns:
MetabaseClient autenticado con session token.
Raises:
httpx.HTTPStatusError: Si las credenciales son invalidas (401)
o hay rate limiting.
Example:
>>> client = metabase_auth("http://localhost:3000", "admin@example.com", "pass")
>>> # client listo para usar con todas las funciones CRUD
"""
resp = httpx.post(
f"{base_url.rstrip('/')}/api/session",
json={"username": email, "password": password},
)
resp.raise_for_status()
token = resp.json()["id"]
return MetabaseClient(base_url, token)
+143
View File
@@ -0,0 +1,143 @@
"""CRUD de dashboards de Metabase."""
from .client import MetabaseClient
def metabase_list_dashboards(
client: MetabaseClient,
filter: str = "",
) -> list[dict]:
"""Lista dashboards de Metabase con filtro opcional.
Endpoint: GET /api/dashboard. Retorna dashboards resumidos (sin dashcards).
Args:
client: Cliente autenticado.
filter: "all", "mine" o "archived". Vacio = todas.
Returns:
Lista de dicts con: id, name, description, collection_id,
creator_id, archived, created_at.
Example:
>>> dashboards = metabase_list_dashboards(client, filter="mine")
>>> for d in dashboards:
... print(d["id"], d["name"])
"""
params = {}
if filter:
params["f"] = filter
return client.request("GET", "/api/dashboard", params=params)
def metabase_get_dashboard(client: MetabaseClient, dashboard_id: int) -> dict:
"""Obtiene un dashboard completo incluyendo sus cards.
Endpoint: GET /api/dashboard/:id.
Args:
client: Cliente autenticado.
dashboard_id: ID del dashboard.
Returns:
Dict con: id, name, description, dashcards (lista de cards posicionadas),
parameters (filtros), tabs, collection_id, archived.
Cada dashcard tiene: id, card_id, card (objeto completo), size_x, size_y,
col, row, dashboard_tab_id, parameter_mappings, visualization_settings.
Example:
>>> dash = metabase_get_dashboard(client, 1)
>>> for dc in dash["dashcards"]:
... print(f"Card {dc['card_id']} at ({dc['col']}, {dc['row']})")
"""
return client.request("GET", f"/api/dashboard/{dashboard_id}")
def metabase_create_dashboard(
client: MetabaseClient,
name: str,
description: str = "",
collection_id: int = 0,
) -> dict:
"""Crea un nuevo dashboard vacio en Metabase.
Endpoint: POST /api/dashboard.
Para agregar cards usar metabase_update_dashboard con dashcards.
Args:
client: Cliente autenticado.
name: Nombre del dashboard.
description: Descripcion opcional.
collection_id: Coleccion destino. 0 = root.
Returns:
Dict con el dashboard creado.
Example:
>>> dash = metabase_create_dashboard(client, "Sales Overview", "KPIs de ventas")
>>> # Agregar cards:
>>> metabase_update_dashboard(client, dash["id"], dashcards=[
... {"id": -1, "card_id": 42, "size_x": 6, "size_y": 4, "col": 0, "row": 0},
... ])
"""
body: dict = {"name": name}
if description:
body["description"] = description
if collection_id > 0:
body["collection_id"] = collection_id
return client.request("POST", "/api/dashboard", json=body)
def metabase_update_dashboard(client: MetabaseClient, dashboard_id: int, **fields) -> dict:
"""Actualiza un dashboard incluyendo metadata, cards y tabs.
Endpoint: PUT /api/dashboard/:id.
El campo dashcards representa el ESTADO COMPLETO DESEADO del dashboard:
- Agregar card: incluirla con ID negativo (-1, -2, etc.)
- Actualizar card existente: incluirla con su ID positivo
- Eliminar card: omitirla del array
Args:
client: Cliente autenticado.
dashboard_id: ID del dashboard.
**fields: Campos a actualizar. Validos:
name (str), description (str), archived (bool),
dashcards (list[dict]), tabs (list[dict]),
parameters (list[dict]), collection_id (int).
Returns:
Dict con el dashboard actualizado.
Example:
>>> # Cambiar nombre
>>> metabase_update_dashboard(client, 1, name="Updated Name")
>>>
>>> # Agregar card (primero obtener existentes)
>>> dash = metabase_get_dashboard(client, 1)
>>> cards = list(dash["dashcards"])
>>> cards.append({"id": -1, "card_id": 55, "size_x": 6, "size_y": 4, "col": 0, "row": 0})
>>> metabase_update_dashboard(client, 1, dashcards=cards)
>>>
>>> # Archivar (soft-delete)
>>> metabase_update_dashboard(client, 1, archived=True)
"""
return client.request("PUT", f"/api/dashboard/{dashboard_id}", json=fields)
def metabase_delete_dashboard(client: MetabaseClient, dashboard_id: int) -> None:
"""Elimina permanentemente un dashboard.
Endpoint: DELETE /api/dashboard/:id. IRREVERSIBLE.
Para soft-delete preferir: metabase_update_dashboard(client, id, archived=True)
Args:
client: Cliente autenticado.
dashboard_id: ID del dashboard a eliminar.
Example:
>>> metabase_delete_dashboard(client, 1)
>>> # Preferir: metabase_update_dashboard(client, 1, archived=True)
"""
client.request("DELETE", f"/api/dashboard/{dashboard_id}")
@@ -0,0 +1,46 @@
---
name: metabase_auth
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_auth(base_url: str, email: str, password: str) -> MetabaseClient"
description: "Autentica contra la API de Metabase con email y password. Retorna un MetabaseClient con session token valido por 14 dias. Endpoint: POST /api/session."
tags: [metabase, auth, session, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/client.py"
---
## Ejemplo
```python
from functions.metabase import metabase_auth
client = metabase_auth("http://localhost:3000", "admin@example.com", "pass")
# client listo para usar con todas las funciones CRUD
# Alternativa con API key:
from functions.metabase import MetabaseClient
client = MetabaseClient("http://localhost:3000", "mb_api_key_xxxxx")
```
## Notas
Dos formas de obtener un client:
- `metabase_auth()`: login con email/password, obtiene session token via POST /api/session
- `MetabaseClient(base_url, api_key)`: constructor directo con API key (recomendado para automatizacion)
El client es un context manager: `with metabase_auth(...) as client:`
Errores comunes:
- 401: credenciales invalidas
- Rate limiting en intentos fallidos de login
@@ -0,0 +1,35 @@
---
name: metabase_create_card
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_create_card(client: MetabaseClient, name: str, dataset_query: dict, display: str = 'table', collection_id: int = 0, description: str = '') -> dict"
description: "Crea una card/pregunta en Metabase con query SQL nativa o MBQL. Endpoint: POST /api/card."
tags: [metabase, card, question, create, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/cards.py"
---
## Ejemplo
```python
card = metabase_create_card(client, "Revenue", {
"database": 1, "type": "native",
"native": {"query": "SELECT SUM(total) FROM orders"},
}, display="scalar")
```
## Notas
dataset_query SQL nativo: `{"database": id, "type": "native", "native": {"query": "..."}}`
dataset_query MBQL: `{"database": id, "type": "query", "query": {"source-table": id, ...}}`
@@ -0,0 +1,32 @@
---
name: metabase_create_dashboard
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_create_dashboard(client: MetabaseClient, name: str, description: str = '', collection_id: int = 0) -> dict"
description: "Crea dashboard vacio en Metabase. Para agregar cards usar metabase_update_dashboard con dashcards. Endpoint: POST /api/dashboard."
tags: [metabase, dashboard, create, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/dashboards.py"
---
## Ejemplo
```python
dash = metabase_create_dashboard(client, "Sales Overview", "KPIs")
# Agregar cards con metabase_update_dashboard
```
## Notas
Se crea vacio. Agregar cards con metabase_update_dashboard(dashcards=[...]).
@@ -0,0 +1,32 @@
---
name: metabase_create_user
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_create_user(client: MetabaseClient, first_name: str, last_name: str, email: str, password: str = '', group_ids: list[int] | None = None) -> dict"
description: "Crea un nuevo usuario en Metabase. Sin password envia invitacion por email. Requiere superusuario. Endpoint: POST /api/user."
tags: [metabase, user, create, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/users.py"
---
## Ejemplo
```python
user = metabase_create_user(client, "John", "Doe", "john@example.com", "pass123")
user = metabase_create_user(client, "Jane", "Smith", "jane@example.com", group_ids=[1, 3])
```
## Notas
Email debe ser unico. Error 400 si ya existe.
@@ -0,0 +1,31 @@
---
name: metabase_deactivate_user
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_deactivate_user(client: MetabaseClient, user_id: int) -> None"
description: "Desactiva (soft-delete) un usuario en Metabase. Reactivar con PUT /api/user/:id/reactivate. Endpoint: DELETE /api/user/:id."
tags: [metabase, user, delete, deactivate, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/users.py"
---
## Ejemplo
```python
metabase_deactivate_user(client, 5)
```
## Notas
Soft-delete. El usuario se puede reactivar.
@@ -0,0 +1,32 @@
---
name: metabase_delete_card
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_delete_card(client: MetabaseClient, card_id: int) -> None"
description: "Elimina permanentemente una card/pregunta. IRREVERSIBLE. Preferir archived=True. Endpoint: DELETE /api/card/:id."
tags: [metabase, card, question, delete, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/cards.py"
---
## Ejemplo
```python
metabase_delete_card(client, 42)
# Preferir: metabase_update_card(client, 42, archived=True)
```
## Notas
IRREVERSIBLE. Preferir soft-delete con metabase_update_card(archived=True).
@@ -0,0 +1,32 @@
---
name: metabase_delete_dashboard
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_delete_dashboard(client: MetabaseClient, dashboard_id: int) -> None"
description: "Elimina permanentemente un dashboard. IRREVERSIBLE. Preferir archived=True. Endpoint: DELETE /api/dashboard/:id."
tags: [metabase, dashboard, delete, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/dashboards.py"
---
## Ejemplo
```python
metabase_delete_dashboard(client, 1)
# Preferir: metabase_update_dashboard(client, 1, archived=True)
```
## Notas
IRREVERSIBLE. Preferir soft-delete con metabase_update_dashboard(archived=True).
@@ -0,0 +1,34 @@
---
name: metabase_execute_card
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_execute_card(client: MetabaseClient, card_id: int, parameters: list[dict] | None = None) -> dict"
description: "Ejecuta la query de una card guardada y retorna resultados con columnas y filas. Soporta parametros. Endpoint: POST /api/card/:id/query."
tags: [metabase, card, question, execute, query, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/cards.py"
---
## Ejemplo
```python
result = metabase_execute_card(client, 42)
for row in result["data"]["rows"]:
print(row)
```
## Notas
Respuesta: status, row_count, running_time, data.columns, data.rows, data.cols.
Limite default: 2000 filas. Para ad-hoc sin card usar metabase_execute_query.
@@ -0,0 +1,32 @@
---
name: metabase_execute_query
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_execute_query(client: MetabaseClient, database_id: int, sql: str, max_results: int = 0) -> dict"
description: "Ejecuta query SQL ad-hoc contra Metabase sin guardarla como card. Util para exploracion rapida. Endpoint: POST /api/dataset."
tags: [metabase, query, execute, sql, dataset, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/cards.py"
---
## Ejemplo
```python
result = metabase_execute_query(client, 1, "SELECT * FROM users LIMIT 10")
print(f"{result['row_count']} filas en {result['running_time']}ms")
```
## Notas
Misma respuesta que metabase_execute_card. Default 2000 filas, override con max_results.
@@ -0,0 +1,32 @@
---
name: metabase_get_card
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_get_card(client: MetabaseClient, card_id: int) -> dict"
description: "Obtiene detalles completos de una card/pregunta de Metabase incluyendo query, visualizacion y metadata. Endpoint: GET /api/card/:id."
tags: [metabase, card, question, get, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/cards.py"
---
## Ejemplo
```python
card = metabase_get_card(client, 42)
print(card["name"], card["display"])
```
## Notas
Error 404 si no existe.
@@ -0,0 +1,33 @@
---
name: metabase_get_dashboard
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_get_dashboard(client: MetabaseClient, dashboard_id: int) -> dict"
description: "Obtiene dashboard completo con dashcards (cards posicionadas), tabs y parametros. Endpoint: GET /api/dashboard/:id."
tags: [metabase, dashboard, get, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/dashboards.py"
---
## Ejemplo
```python
dash = metabase_get_dashboard(client, 1)
for dc in dash["dashcards"]:
print(f"Card {dc['card_id']} at ({dc['col']}, {dc['row']})")
```
## Notas
Cada dashcard tiene: id, card_id, card, size_x, size_y, col, row, dashboard_tab_id, parameter_mappings.
@@ -0,0 +1,32 @@
---
name: metabase_get_user
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_get_user(client: MetabaseClient, user_id: int) -> dict"
description: "Obtiene los detalles de un usuario de Metabase por su ID. Endpoint: GET /api/user/:id."
tags: [metabase, user, get, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/users.py"
---
## Ejemplo
```python
user = metabase_get_user(client, 1)
print(user["email"], user["is_superuser"])
```
## Notas
Error 404 si el usuario no existe.
@@ -0,0 +1,32 @@
---
name: metabase_list_cards
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_list_cards(client: MetabaseClient, filter: str = '', model_id: int = 0) -> list[dict]"
description: "Lista preguntas/cards de Metabase. Filtros: all, mine, fav, archived, recent, popular, database, table. Endpoint: GET /api/card."
tags: [metabase, card, question, list, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/cards.py"
---
## Ejemplo
```python
cards = metabase_list_cards(client, filter="mine")
cards = metabase_list_cards(client, filter="database", model_id=1)
```
## Notas
No tiene paginacion offset/limit. Retorna todas las cards que coinciden.
@@ -0,0 +1,33 @@
---
name: metabase_list_dashboards
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_list_dashboards(client: MetabaseClient, filter: str = '') -> list[dict]"
description: "Lista dashboards de Metabase. Filtros: all, mine, archived. Retorna resumen sin dashcards. Endpoint: GET /api/dashboard."
tags: [metabase, dashboard, list, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/dashboards.py"
---
## Ejemplo
```python
dashboards = metabase_list_dashboards(client, filter="mine")
for d in dashboards:
print(d["id"], d["name"])
```
## Notas
Para ver cards de un dashboard usar metabase_get_dashboard.
@@ -0,0 +1,33 @@
---
name: metabase_list_users
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_list_users(client: MetabaseClient, status: str = '', query: str = '', limit: int = 0, offset: int = 0) -> dict"
description: "Lista usuarios de Metabase con filtros opcionales por estado, nombre/email y paginacion. Endpoint: GET /api/user."
tags: [metabase, user, list, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/users.py"
---
## Ejemplo
```python
users = metabase_list_users(client, status="active", query="john@")
for u in users["data"]:
print(u["email"], u["first_name"])
```
## Notas
Retorna dict paginado con data, total, limit, offset.
@@ -0,0 +1,31 @@
---
name: metabase_update_card
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_update_card(client: MetabaseClient, card_id: int, **fields) -> dict"
description: "Actualiza campos de una card/pregunta via kwargs. Campos: name, description, display, dataset_query, collection_id, archived. Endpoint: PUT /api/card/:id."
tags: [metabase, card, question, update, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/cards.py"
---
## Ejemplo
```python
metabase_update_card(client, 42, name="New Name", archived=True)
```
## Notas
Soft-delete con `archived=True`. Para delete permanente usar metabase_delete_card.
@@ -0,0 +1,39 @@
---
name: metabase_update_dashboard
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_update_dashboard(client: MetabaseClient, dashboard_id: int, **fields) -> dict"
description: "Actualiza dashboard incluyendo metadata, cards y tabs via kwargs. dashcards es el estado completo deseado: nuevas con ID negativo, existentes con positivo, omitidas se eliminan. Endpoint: PUT /api/dashboard/:id."
tags: [metabase, dashboard, update, cards, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/dashboards.py"
---
## Ejemplo
```python
# Agregar card
dash = metabase_get_dashboard(client, 1)
cards = list(dash["dashcards"])
cards.append({"id": -1, "card_id": 55, "size_x": 6, "size_y": 4, "col": 0, "row": 0})
metabase_update_dashboard(client, 1, dashcards=cards)
# Archivar
metabase_update_dashboard(client, 1, archived=True)
```
## Notas
dashcards = estado completo. ID negativo = nueva, positivo = existente, omitida = eliminada.
Campos: name, description, archived, dashcards, tabs, parameters, collection_id.
@@ -0,0 +1,32 @@
---
name: metabase_update_user
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_update_user(client: MetabaseClient, user_id: int, **fields) -> dict"
description: "Actualiza campos de un usuario en Metabase via keyword arguments. Campos: first_name, last_name, email, is_superuser, group_ids, locale. Endpoint: PUT /api/user/:id."
tags: [metabase, user, update, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/users.py"
---
## Ejemplo
```python
metabase_update_user(client, 5, first_name="Jane", is_superuser=True)
metabase_update_user(client, 5, group_ids=[1, 3, 5])
```
## Notas
Solo se modifican los campos pasados como kwargs.
+153
View File
@@ -0,0 +1,153 @@
"""CRUD de usuarios de Metabase."""
from .client import MetabaseClient
def metabase_list_users(
client: MetabaseClient,
status: str = "",
query: str = "",
limit: int = 0,
offset: int = 0,
) -> dict:
"""Lista usuarios de Metabase con filtros opcionales.
Endpoint: GET /api/user. Requiere permisos de superusuario.
Args:
client: Cliente autenticado.
status: "active" (default), "deactivated" o "all".
query: Filtro por nombre o email.
limit: Tamanio de pagina (0 = default Metabase).
offset: Offset para paginacion.
Returns:
Dict con estructura paginada:
- data: lista de usuarios (id, email, first_name, last_name, is_superuser, etc.)
- total: numero total de usuarios que coinciden
- limit: tamanio de pagina usado
- offset: offset usado
Example:
>>> users = metabase_list_users(client, status="active", query="john@")
>>> for u in users["data"]:
... print(u["email"], u["first_name"])
"""
params = {}
if status:
params["status"] = status
if query:
params["query"] = query
if limit > 0:
params["limit"] = limit
if offset > 0:
params["offset"] = offset
return client.request("GET", "/api/user", params=params)
def metabase_get_user(client: MetabaseClient, user_id: int) -> dict:
"""Obtiene un usuario de Metabase por su ID.
Endpoint: GET /api/user/:id.
Args:
client: Cliente autenticado.
user_id: ID numerico del usuario.
Returns:
Dict con datos del usuario: id, email, first_name, last_name,
is_superuser, is_active, common_name, date_joined, last_login,
group_ids, locale.
Raises:
httpx.HTTPStatusError: 404 si el usuario no existe.
Example:
>>> user = metabase_get_user(client, 1)
>>> print(user["email"], user["is_superuser"])
"""
return client.request("GET", f"/api/user/{user_id}")
def metabase_create_user(
client: MetabaseClient,
first_name: str,
last_name: str,
email: str,
password: str = "",
group_ids: list[int] | None = None,
) -> dict:
"""Crea un nuevo usuario en Metabase.
Endpoint: POST /api/user. Requiere permisos de superusuario.
Args:
client: Cliente autenticado con permisos admin.
first_name: Nombre del usuario.
last_name: Apellido del usuario.
email: Email unico del usuario.
password: Password. Vacio = Metabase envia invitacion por email.
group_ids: IDs de grupos a asignar. None = solo grupo default.
Returns:
Dict con el usuario creado (mismos campos que metabase_get_user).
Raises:
httpx.HTTPStatusError: 400 si el email ya existe.
Example:
>>> user = metabase_create_user(client, "John", "Doe", "john@example.com", "pass123")
>>> print(user["id"])
"""
body: dict = {
"first_name": first_name,
"last_name": last_name,
"email": email,
}
if password:
body["password"] = password
if group_ids:
body["group_ids"] = group_ids
return client.request("POST", "/api/user", json=body)
def metabase_update_user(client: MetabaseClient, user_id: int, **fields) -> dict:
"""Actualiza campos de un usuario en Metabase.
Endpoint: PUT /api/user/:id. Requiere permisos de superusuario.
Solo se modifican los campos pasados como keyword arguments.
Args:
client: Cliente autenticado con permisos admin.
user_id: ID del usuario a actualizar.
**fields: Campos a actualizar. Validos:
first_name (str), last_name (str), email (str),
is_superuser (bool), group_ids (list[int]),
locale (str), login_attributes (dict).
Returns:
Dict con el usuario actualizado.
Example:
>>> user = metabase_update_user(client, 5, first_name="Jane", is_superuser=True)
>>> user = metabase_update_user(client, 5, group_ids=[1, 3, 5])
"""
return client.request("PUT", f"/api/user/{user_id}", json=fields)
def metabase_deactivate_user(client: MetabaseClient, user_id: int) -> None:
"""Desactiva (soft-delete) un usuario en Metabase.
Endpoint: DELETE /api/user/:id. Requiere permisos de superusuario.
El usuario no se elimina permanentemente, solo se marca como inactivo.
Para reactivar: PUT /api/user/:id/reactivate.
Args:
client: Cliente autenticado con permisos admin.
user_id: ID del usuario a desactivar.
Example:
>>> metabase_deactivate_user(client, 5)
>>> # Para ver desactivados: metabase_list_users(client, status="deactivated")
"""
client.request("DELETE", f"/api/user/{user_id}")
+9
View File
@@ -0,0 +1,9 @@
[project]
name = "fn-registry-python"
version = "0.1.0"
description = "Funciones Python del fn-registry: Metabase API, ML, utilidades"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"httpx",
]
+91
View File
@@ -0,0 +1,91 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "fn-registry-python"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "httpx" },
]
[package.metadata]
requires-dist = [{ name = "httpx" }]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
+47 -19
View File
@@ -19,6 +19,9 @@ type IndexResult struct {
// and populates the database. It uses two passes:
// 1. Parse all entries and collect known IDs
// 2. Validate references against known IDs, then insert valid entries
//
// Scans functions/ and types/ at the root level, plus any language-specific
// directories (e.g. python/functions/, python/types/).
func Index(db *DB, root string) (*IndexResult, error) {
if err := db.Purge(); err != nil {
return nil, fmt.Errorf("purging database: %w", err)
@@ -26,39 +29,50 @@ func Index(db *DB, root string) (*IndexResult, error) {
result := &IndexResult{}
// Pass 1: parse everything
// Pass 1: parse everything from all source directories
var functions []*Function
var types []*Type
functionsDir := filepath.Join(root, "functions")
if _, err := os.Stat(functionsDir); err == nil {
filepath.Walk(functionsDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".md") {
return nil
}
f, err := ParseFunctionMD(path)
// Directories to scan for functions and types.
// Base dirs + language-specific dirs discovered automatically.
funcDirs := []string{filepath.Join(root, "functions")}
typeDirs := []string{filepath.Join(root, "types")}
// Discover language-specific directories (e.g. python/functions/, python/types/)
entries, _ := os.ReadDir(root)
for _, e := range entries {
if !e.IsDir() {
continue
}
langFuncs := filepath.Join(root, e.Name(), "functions")
if fi, err := os.Stat(langFuncs); err == nil && fi.IsDir() {
funcDirs = append(funcDirs, langFuncs)
}
langTypes := filepath.Join(root, e.Name(), "types")
if fi, err := os.Stat(langTypes); err == nil && fi.IsDir() {
typeDirs = append(typeDirs, langTypes)
}
}
for _, dir := range funcDirs {
walkMD(dir, func(path string) {
f, err := ParseFunctionMD(path, root)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("parse %s: %v", path, err))
return nil
return
}
functions = append(functions, f)
return nil
})
}
typesDir := filepath.Join(root, "types")
if _, err := os.Stat(typesDir); err == nil {
filepath.Walk(typesDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".md") {
return nil
}
t, err := ParseTypeMD(path)
for _, dir := range typeDirs {
walkMD(dir, func(path string) {
t, err := ParseTypeMD(path, root)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("parse %s: %v", path, err))
return nil
return
}
types = append(types, t)
return nil
})
}
@@ -99,3 +113,17 @@ func Index(db *DB, root string) (*IndexResult, error) {
return result, nil
}
// walkMD walks a directory recursively and calls fn for each .md file found.
func walkMD(dir string, fn func(path string)) {
if _, err := os.Stat(dir); err != nil {
return
}
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".md") {
return nil
}
fn(path)
return nil
})
}
+103
View File
@@ -0,0 +1,103 @@
-- Add documentation fields to functions and types.
-- examples: extracted code blocks from ## Ejemplo
-- notes: extracted text from ## Notas
-- documentation: remaining body text from .md
-- code: source code from the referenced .go/.py/.tsx file
ALTER TABLE functions ADD COLUMN notes TEXT NOT NULL DEFAULT '';
ALTER TABLE functions ADD COLUMN documentation TEXT NOT NULL DEFAULT '';
ALTER TABLE functions ADD COLUMN code TEXT NOT NULL DEFAULT '';
ALTER TABLE types ADD COLUMN examples TEXT NOT NULL DEFAULT '';
ALTER TABLE types ADD COLUMN notes TEXT NOT NULL DEFAULT '';
ALTER TABLE types ADD COLUMN documentation TEXT NOT NULL DEFAULT '';
ALTER TABLE types ADD COLUMN code TEXT NOT NULL DEFAULT '';
-- Rebuild FTS for functions: add examples, notes, documentation, code
DROP TRIGGER IF EXISTS functions_ai;
DROP TRIGGER IF EXISTS functions_ad;
DROP TRIGGER IF EXISTS functions_au;
INSERT INTO functions_fts(functions_fts) VALUES('rebuild');
DROP TABLE IF EXISTS functions_fts;
CREATE VIRTUAL TABLE functions_fts USING fts5(
id,
name,
description,
tags,
signature,
domain,
example,
notes,
documentation,
code,
content='functions',
content_rowid='rowid'
);
-- Populate FTS from existing data
INSERT INTO functions_fts(rowid, id, name, description, tags, signature, domain, example, notes, documentation, code)
SELECT rowid, id, name, description, tags, signature, domain, example, notes, documentation, code
FROM functions;
CREATE TRIGGER functions_ai AFTER INSERT ON functions BEGIN
INSERT INTO functions_fts(rowid, id, name, description, tags, signature, domain, example, notes, documentation, code)
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.signature, new.domain, new.example, new.notes, new.documentation, new.code);
END;
CREATE TRIGGER functions_ad AFTER DELETE ON functions BEGIN
INSERT INTO functions_fts(functions_fts, rowid, id, name, description, tags, signature, domain, example, notes, documentation, code)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.signature, old.domain, old.example, old.notes, old.documentation, old.code);
END;
CREATE TRIGGER functions_au AFTER UPDATE ON functions BEGIN
INSERT INTO functions_fts(functions_fts, rowid, id, name, description, tags, signature, domain, example, notes, documentation, code)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.signature, old.domain, old.example, old.notes, old.documentation, old.code);
INSERT INTO functions_fts(rowid, id, name, description, tags, signature, domain, example, notes, documentation, code)
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.signature, new.domain, new.example, new.notes, new.documentation, new.code);
END;
-- Rebuild FTS for types: add examples, notes, documentation, code
DROP TRIGGER IF EXISTS types_ai;
DROP TRIGGER IF EXISTS types_ad;
DROP TRIGGER IF EXISTS types_au;
INSERT INTO types_fts(types_fts) VALUES('rebuild');
DROP TABLE IF EXISTS types_fts;
CREATE VIRTUAL TABLE types_fts USING fts5(
id,
name,
description,
tags,
domain,
examples,
notes,
documentation,
code,
content='types',
content_rowid='rowid'
);
-- Populate FTS from existing data
INSERT INTO types_fts(rowid, id, name, description, tags, domain, examples, notes, documentation, code)
SELECT rowid, id, name, description, tags, domain, examples, notes, documentation, code
FROM types;
CREATE TRIGGER types_ai AFTER INSERT ON types BEGIN
INSERT INTO types_fts(rowid, id, name, description, tags, domain, examples, notes, documentation, code)
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain, new.examples, new.notes, new.documentation, new.code);
END;
CREATE TRIGGER types_ad AFTER DELETE ON types BEGIN
INSERT INTO types_fts(types_fts, rowid, id, name, description, tags, domain, examples, notes, documentation, code)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain, old.examples, old.notes, old.documentation, old.code);
END;
CREATE TRIGGER types_au AFTER UPDATE ON types BEGIN
INSERT INTO types_fts(types_fts, rowid, id, name, description, tags, domain, examples, notes, documentation, code)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain, old.examples, old.notes, old.documentation, old.code);
INSERT INTO types_fts(rowid, id, name, description, tags, domain, examples, notes, documentation, code)
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain, new.examples, new.notes, new.documentation, new.code);
END;
+10 -3
View File
@@ -47,6 +47,9 @@ type Function struct {
ErrorType string `json:"error_type"`
Imports []string `json:"imports"`
Example string `json:"example"`
Notes string `json:"notes"`
Documentation string `json:"documentation"`
Code string `json:"code"`
Tested bool `json:"tested"`
Tests []string `json:"tests"`
TestFilePath string `json:"test_file_path"`
@@ -82,9 +85,13 @@ type Type struct {
Description string `json:"description"`
Tags []string `json:"tags"`
UsesTypes []string `json:"uses_types"`
FilePath string `json:"file_path"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Examples string `json:"examples"`
Notes string `json:"notes"`
Documentation string `json:"documentation"`
Code string `json:"code"`
FilePath string `json:"file_path"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ProposalKind classifies a proposal.
+93 -37
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
@@ -73,7 +74,8 @@ func extractFrontmatter(data []byte) ([]byte, []byte, error) {
}
// ParseFunctionMD parses a function .md file into a Function.
func ParseFunctionMD(path string) (*Function, error) {
// root is the registry root directory, used to resolve file_path for code reading.
func ParseFunctionMD(path string, root string) (*Function, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
@@ -99,7 +101,7 @@ func ParseFunctionMD(path string) (*Function, error) {
return nil, fmt.Errorf("%s: description is required", path)
}
example := extractExample(body)
sections := extractSections(body)
f := &Function{
ID: GenerateID(raw.Name, raw.Lang, raw.Domain),
@@ -118,6 +120,9 @@ func ParseFunctionMD(path string) (*Function, error) {
ReturnsOptional: raw.ReturnsOptional,
ErrorType: raw.ErrorType,
Imports: raw.Imports,
Example: sections.example,
Notes: sections.notes,
Documentation: sections.documentation,
Tested: raw.Tested,
Tests: raw.Tests,
TestFilePath: raw.TestFilePath,
@@ -129,21 +134,25 @@ func ParseFunctionMD(path string) (*Function, error) {
Variant: raw.Variant,
}
if example != "" && f.Example == "" {
f.Example = example
if root != "" && raw.FilePath != "" {
codePath := filepath.Join(root, raw.FilePath)
if codeData, err := os.ReadFile(codePath); err == nil {
f.Code = string(codeData)
}
}
return f, nil
}
// ParseTypeMD parses a type .md file into a Type.
func ParseTypeMD(path string) (*Type, error) {
// root is the registry root directory, used to resolve file_path for code reading.
func ParseTypeMD(path string, root string) (*Type, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
fm, _, err := extractFrontmatter(data)
fm, body, err := extractFrontmatter(data)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
@@ -160,49 +169,96 @@ func ParseTypeMD(path string) (*Type, error) {
return nil, fmt.Errorf("%s: description is required", path)
}
sections := extractSections(body)
t := &Type{
ID: GenerateID(raw.Name, raw.Lang, raw.Domain),
Name: raw.Name,
Lang: raw.Lang,
Domain: raw.Domain,
Version: raw.Version,
Algebraic: Algebraic(raw.Algebraic),
Definition: strings.TrimSpace(raw.Definition),
Description: raw.Description,
Tags: raw.Tags,
UsesTypes: raw.UsesTypes,
FilePath: raw.FilePath,
ID: GenerateID(raw.Name, raw.Lang, raw.Domain),
Name: raw.Name,
Lang: raw.Lang,
Domain: raw.Domain,
Version: raw.Version,
Algebraic: Algebraic(raw.Algebraic),
Definition: strings.TrimSpace(raw.Definition),
Description: raw.Description,
Tags: raw.Tags,
UsesTypes: raw.UsesTypes,
Examples: sections.example,
Notes: sections.notes,
Documentation: sections.documentation,
FilePath: raw.FilePath,
}
if root != "" && raw.FilePath != "" {
codePath := filepath.Join(root, raw.FilePath)
if codeData, err := os.ReadFile(codePath); err == nil {
t.Code = string(codeData)
}
}
return t, nil
}
// extractExample pulls the first code block after an "## Ejemplo" heading.
func extractExample(body []byte) string {
// bodySections holds the extracted sections from a .md body.
type bodySections struct {
example string // content under ## Ejemplo
notes string // content under ## Notas
documentation string // everything else
}
// extractSections splits the markdown body into named sections.
// Known sections (## Ejemplo, ## Notas) are extracted separately.
// All other content (including unknown ## headings) goes into documentation.
func extractSections(body []byte) bodySections {
lines := strings.Split(string(body), "\n")
inExample := false
inCode := false
var code []string
var s bodySections
type section struct {
name string
lines []string
}
var current *section
var sections []section
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "## Ejemplo") {
inExample = true
continue
}
if inExample && !inCode && strings.HasPrefix(trimmed, "```") {
inCode = true
continue
}
if inCode {
if strings.HasPrefix(trimmed, "```") {
return strings.Join(code, "\n")
if strings.HasPrefix(trimmed, "## ") {
if current != nil {
sections = append(sections, *current)
}
code = append(code, line)
current = &section{name: trimmed}
continue
}
if inExample && !inCode && strings.HasPrefix(trimmed, "##") {
break
if current != nil {
current.lines = append(current.lines, line)
} else {
// Content before any ## heading goes to documentation
sections = append(sections, section{name: "_preamble", lines: []string{line}})
}
}
return ""
if current != nil {
sections = append(sections, *current)
}
var docParts []string
for _, sec := range sections {
content := strings.TrimSpace(strings.Join(sec.lines, "\n"))
if content == "" && sec.name == "_preamble" {
continue
}
switch {
case strings.HasPrefix(sec.name, "## Ejemplo"):
s.example = content
case strings.HasPrefix(sec.name, "## Notas"):
s.notes = content
case sec.name == "_preamble":
docParts = append(docParts, content)
default:
// Unknown sections go to documentation with their heading
docParts = append(docParts, sec.name+"\n\n"+content)
}
}
s.documentation = strings.TrimSpace(strings.Join(docParts, "\n\n"))
return s
}
+5 -5
View File
@@ -79,7 +79,7 @@ imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "functions/components/DataTable.tsx"
file_path: "frontend/functions/ui/data_table.tsx"
props:
- name: data
type: "T[]"
@@ -105,7 +105,7 @@ func writeTempFile(t *testing.T, dir, name, content string) string {
func TestParseFunctionMD(t *testing.T) {
path := writeTempFile(t, t.TempDir(), "filter_slice.md", functionMD)
f, err := ParseFunctionMD(path)
f, err := ParseFunctionMD(path, "")
if err != nil {
t.Fatal(err)
}
@@ -130,7 +130,7 @@ func TestParseFunctionMD(t *testing.T) {
func TestParseTypeMD(t *testing.T) {
path := writeTempFile(t, t.TempDir(), "ohlcv.md", typeMD)
typ, err := ParseTypeMD(path)
typ, err := ParseTypeMD(path, "")
if err != nil {
t.Fatal(err)
}
@@ -149,7 +149,7 @@ func TestParseTypeMD(t *testing.T) {
func TestParseComponentMD(t *testing.T) {
path := writeTempFile(t, t.TempDir(), "DataTable.md", componentMD)
f, err := ParseFunctionMD(path)
f, err := ParseFunctionMD(path, "")
if err != nil {
t.Fatal(err)
}
@@ -174,7 +174,7 @@ func TestParseComponentMD(t *testing.T) {
func TestParseMissingFrontmatter(t *testing.T) {
path := writeTempFile(t, t.TempDir(), "bad.md", "# No frontmatter here\n")
_, err := ParseFunctionMD(path)
_, err := ParseFunctionMD(path, "")
if err == nil {
t.Error("expected error for missing frontmatter")
}
+11 -4
View File
@@ -82,19 +82,22 @@ func (db *DB) InsertFunction(f *Function) error {
description, tags, uses_functions, uses_types, returns,
returns_optional, error_type, imports, example, tested,
tests, test_file_path, file_path, created_at, updated_at,
props, emits, has_state, framework, variant
props, emits, has_state, framework, variant,
notes, documentation, code
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?
?, ?, ?, ?, ?,
?, ?, ?
)`,
f.ID, f.Name, string(f.Kind), f.Lang, f.Domain, f.Version, string(f.Purity), f.Signature,
f.Description, marshalStrings(f.Tags), marshalStrings(f.UsesFunctions), marshalStrings(f.UsesTypes), marshalStrings(f.Returns),
f.ReturnsOptional, f.ErrorType, marshalStrings(f.Imports), f.Example, f.Tested,
marshalStrings(f.Tests), f.TestFilePath, f.FilePath, f.CreatedAt.Format(time.RFC3339), now,
marshalProps(f.Props), marshalStrings(f.Emits), hasState, f.Framework, marshalStrings(f.Variant),
f.Notes, f.Documentation, f.Code,
)
return err
}
@@ -115,11 +118,13 @@ func (db *DB) InsertType(t *Type) error {
INSERT OR REPLACE INTO types (
id, name, lang, domain, version, algebraic,
definition, description, tags, uses_types,
file_path, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
file_path, created_at, updated_at,
examples, notes, documentation, code
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Name, t.Lang, t.Domain, t.Version, string(t.Algebraic),
t.Definition, t.Description, marshalStrings(t.Tags), marshalStrings(t.UsesTypes),
t.FilePath, t.CreatedAt.Format(time.RFC3339), now,
t.Examples, t.Notes, t.Documentation, t.Code,
)
return err
}
@@ -270,6 +275,7 @@ func scanFunctions(rows interface{ Next() bool; Scan(...any) error }) ([]Functio
&f.ReturnsOptional, &f.ErrorType, &importsJSON, &f.Example, &f.Tested,
&testsJSON, &f.TestFilePath, &f.FilePath, &createdAt, &updatedAt,
&propsJSON, &emitsJSON, &hasState, &f.Framework, &variantJSON,
&f.Notes, &f.Documentation, &f.Code,
)
if err != nil {
return nil, fmt.Errorf("scanning function: %w", err)
@@ -308,6 +314,7 @@ func scanTypes(rows interface{ Next() bool; Scan(...any) error }) ([]Type, error
&t.ID, &t.Name, &t.Lang, &t.Domain, &t.Version, &t.Algebraic,
&t.Definition, &t.Description, &tagsJSON, &usesTypJSON,
&t.FilePath, &createdAt, &updatedAt,
&t.Examples, &t.Notes, &t.Documentation, &t.Code,
)
if err != nil {
return nil, fmt.Errorf("scanning type: %w", err)
+7
View File
@@ -0,0 +1,7 @@
package infra
// MetabaseClient holds the connection details for a Metabase instance API.
type MetabaseClient struct {
BaseURL string // e.g. "http://localhost:3000"
Token string // session token or API key
}
+24
View File
@@ -0,0 +1,24 @@
---
name: MetabaseClient
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type MetabaseClient struct {
BaseURL string
Token string
}
description: "Cliente para la API REST de Metabase. Contiene la URL base de la instancia y el token de autenticacion (session token o API key)."
tags: [metabase, api, client, infra]
uses_types: []
file_path: "types/infra/metabase_client.go"
---
## Notas
Tipo producto con dos campos obligatorios:
- `BaseURL`: URL base de la instancia Metabase sin trailing slash (ej: `http://localhost:3000`)
- `Token`: token de sesion obtenido con `MetabaseAuth()` o una API key creada en el admin UI de Metabase
El token se envia como header `X-Metabase-Session` en session tokens o `x-api-key` en API keys. Las funciones del registry usan `X-Metabase-Session` por defecto.