Estructura del Proyecto
Maven layout estándar — sin framework de arranque, dispatcher como entry point
segmentflow-api/
src/main/java/com/segmentflow/
SegmentService.java — módulo principal @Action
SegmentStatsTool.java — MCP Tool para stats
model/
Segment.java — POJO extends AbstractData
src/main/resources/
application.properties — DB + server config
config/
Segment.xml — mapping SQL↔Java
src/test/java/com/segmentflow/
SegmentServiceTest.java — JUnit 5 + HTTP tests
pom.xml
bin/
dispatcher — entry point universal (CLI+HTTP)
application.properties
Toda la configuración en un fichero — sin XML de Spring, sin YAML
src/main/resources/application.properties
properties
# ── Base de datos ───────────────────────────────── driver=com.mysql.cj.jdbc.Driver database.url=jdbc:mysql://localhost:3306/segmentflow?useSSL=false&serverTimezone=UTC database.user=segmentflow_app database.password=s3cr3t_segflow_2024 # ── Servidor HTTP ───────────────────────────────── default.home.page=api/segments server.port=8090 server.host=0.0.0.0 # ── App ──────────────────────────────────────────── default.language=es_ES application.version=2.1.0 application.name=SegmentFlow API # ── Sesiones (Redis en prod) ─────────────────────── # default.session.repository=org.tinystruct.http.RedisSessionRepository # redis.host=redis.segmentflow.internal # redis.port=6379
Accede a cualquier valor desde tu aplicación con
this.getConfiguration("server.port"). No hay inyección de dependencias ni autowiring: todo es explícito.
SegmentService.java — Módulo Principal
Extiende AbstractApplication · todos los endpoints via @Action
GET
/api/segments
Lista todos los segmentos de una cuenta
CLI + HTTP
POST
/api/segments
Crea segmento nuevo con nombre y filtros
GET
/api/segments/{id}/stats
Métricas de usuarios y conversión
CLI + HTTP
CLI
export-csv/{id}
Exporta segmento a CSV desde terminal (backoffice)
CLI only
SSE
/sse/live-stats
Push de métricas en tiempo real
src/main/java/com/segmentflow/SegmentService.java
Java
package com.segmentflow; import org.tinystruct.AbstractApplication; import org.tinystruct.ApplicationException; import org.tinystruct.data.component.Builder; import org.tinystruct.data.component.Builders; import org.tinystruct.http.SSEPushManager; import org.tinystruct.system.annotation.Action; import org.tinystruct.system.annotation.Action.Mode; import com.segmentflow.model.Segment; import java.util.List; public class SegmentService extends AbstractApplication { @Override public void init() { // API-only: sin plantillas .view this.setTemplateRequired(false); } @Override public String version() { return "2.1.0"; } // ── GET /api/segments ───────────────────────────────────── // También: bin/dispatcher api/segments @Action("api/segments") public String listSegments() throws ApplicationException { Segment query = new Segment(); List<Segment> segments = query.findAll(); Builders list = new Builders(); for (Segment s : segments) { Builder item = new Builder(); item.put("id", s.getId()); item.put("name", s.getName()); item.put("filters", s.getFilters()); item.put("userCount", s.getUserCount()); item.put("active", s.isActive()); list.add(item); } Builder response = new Builder(); response.put("status", "ok"); response.put("count", segments.size()); response.put("data", list); return response.toString(); } // ── POST /api/segments ───────────────────────────────────── @Action(value = "api/segments", mode = Mode.HTTP_POST) public String createSegment() throws ApplicationException { String name = (String) getContext().getAttribute("name"); String filters = (String) getContext().getAttribute("filters"); if (name == null || name.isBlank()) { throw new ApplicationException("'name' is required"); } Segment seg = new Segment(); seg.setName(name); seg.setFilters(filters != null ? filters : "{}"); seg.setActive(true); seg.setUserCount(0); seg.save(); Builder res = new Builder(); res.put("status", "created"); res.put("id", seg.getId()); res.put("name", seg.getName()); return res.toString(); } // ── GET /api/segments/{id}/stats ─────────────────────────── // También: bin/dispatcher api/segments/42/stats @Action("api/segments/{id}/stats") public String segmentStats(int id) throws ApplicationException { Segment seg = new Segment(); seg.findById(id); if (seg.getId() == 0) { Builder err = new Builder(); err.put("status", "not_found"); err.put("id", id); return err.toString(); } // Calcula métricas en tiempo real double convRate = seg.getConversions() * 100.0 / Math.max(1, seg.getUserCount()); Builder stats = new Builder(); stats.put("segmentId", id); stats.put("name", seg.getName()); stats.put("totalUsers", seg.getUserCount()); stats.put("conversions", seg.getConversions()); stats.put("conversionRate", String.format("%.2f%%", convRate)); stats.put("lastUpdated", seg.getUpdatedAt()); return stats.toString(); } // ── CLI only: bin/dispatcher export-csv/42 ───────────────── @Action(value = "export-csv", mode = Mode.CLI) public String exportCsv(int id) throws ApplicationException { Segment seg = new Segment(); seg.findById(id); StringBuilder csv = new StringBuilder("id,name,filters,user_count,conversion_rate\n"); double rate = seg.getConversions() * 100.0 / Math.max(1, seg.getUserCount()); csv.append(seg.getId()).append(",") .append(seg.getName()).append(",") .append(seg.getFilters().replace(",", ";")).append(",") .append(seg.getUserCount()).append(",") .append(String.format("%.2f", rate)); // En CLI, stdout es el output natural System.out.println(csv); return "Exported segment " + id + " to stdout"; } // ── SSE: GET /sse/live-stats ──────────────────────────────── @Action("sse/live-stats") public String connectLiveStats() { String sessionId = getContext().getId(); return "{\"type\":\"connected\",\"session\":\"" + sessionId + "\"}"; } /** Llamado internamente (cron/evento) para hacer broadcast de métricas **/ public void broadcastStats(Segment seg) throws Exception { Builder msg = new Builder(); msg.put("type", "stats_update"); msg.put("segmentId", seg.getId()); msg.put("users", seg.getUserCount()); SSEPushManager.getInstance().broadcast(msg); } }
Segment.java — POJO con AbstractData
ORM mínimo sin Hibernate · CRUD nativo · mapping via XML
src/main/java/com/segmentflow/model/Segment.java
Java
package com.segmentflow.model; import org.tinystruct.data.component.AbstractData; import org.tinystruct.ApplicationException; import java.time.LocalDateTime; import java.util.List; public class Segment extends AbstractData { private int id; private String name; private String filters; // JSON string private int userCount; private int conversions; private boolean active; private LocalDateTime updatedAt; public Segment() { // AbstractData lee automáticamente config/Segment.xml super("Segment"); } // ── Operaciones CRUD heredadas de AbstractData ───────────── public List<Segment> findAll() throws ApplicationException { return this.query("SELECT * FROM segments WHERE 1=1 ORDER BY id DESC", Segment.class); } public void findById(int segId) throws ApplicationException { this.id = segId; this.findOne(); // mapea columnas → campos via Segment.xml } // ── Getters / Setters ────────────────────────────────────── public int getId() { return id; } public String getName() { return name; } public String getFilters() { return filters; } public int getUserCount() { return userCount; } public int getConversions() { return conversions; } public boolean isActive() { return active; } public LocalDateTime getUpdatedAt() { return updatedAt; } public void setName(String v) { this.name = v; } public void setFilters(String v) { this.filters = v; } public void setUserCount(int v) { this.userCount = v; } public void setActive(boolean v) { this.active = v; } }
src/main/resources/config/Segment.xml
XML
<?xml version="1.0" encoding="UTF-8"?> <mapping table="segments" class="com.segmentflow.model.Segment"> <field column="id" property="id" type="int" primary="true"/> <field column="name" property="name" type="String"/> <field column="filters" property="filters" type="String"/> <field column="user_count" property="userCount" type="int"/> <field column="conversions" property="conversions" type="int"/> <field column="active" property="active" type="boolean"/> <field column="updated_at" property="updatedAt" type="LocalDateTime"/> </mapping>
Comandos de Arranque y Prueba
Dispatcher como entry point — HTTP y CLI sobre el mismo binario
$ bin/dispatcher start --import org.tinystruct.system.HttpServer \
--import com.segmentflow.SegmentService
[ INFO] HttpServer started on port 8090
[ INFO] Registered action: api/segments (GET/POST)
[ INFO] Registered action: api/segments/{id}/stats (GET)
[ INFO] Registered action: sse/live-stats (GET)
[ INFO] Registered action: export-csv/{id} (CLI)
[ INFO] Registered action: api/segments (GET/POST)
[ INFO] Registered action: api/segments/{id}/stats (GET)
[ INFO] Registered action: sse/live-stats (GET)
[ INFO] Registered action: export-csv/{id} (CLI)
$ curl http://localhost:8090/?q=api/segments
{"status":"ok","count":3,"data":[{"id":1,"name":"High-Intent B2B","filters":"{\"industry\":\"SaaS\",\"employees\":\">50\"}","userCount":1842,"active":true},{"id":2,"name":"Churned 90d","filters":"{\"lastSeen\":\">90d\"}","userCount":567,"active":true}]}
$ bin/dispatcher api/segments/1/stats \
--import com.segmentflow.SegmentService
{"segmentId":1,"name":"High-Intent B2B","totalUsers":1842,"conversions":184,"conversionRate":"9.99%","lastUpdated":"2024-06-18T14:22:00"}
$ bin/dispatcher export-csv/1 --import com.segmentflow.SegmentService
id,name,filters,user_count,conversion_rate
1,High-Intent B2B,{industry:SaaS;employees:>50},1842,9.99
Exported segment 1 to stdout
1,High-Intent B2B,{industry:SaaS;employees:>50},1842,9.99
Exported segment 1 to stdout
Red Flags para SegmentFlow
Errores comunes en proyectos tinystruct — con la corrección exacta
| Síntoma | Causa | Patrón correcto |
|---|---|---|
template not found: api/segments |
Falta setTemplateRequired(false) |
Añadir en init() antes de registrar acciones |
import com.fasterxml.jackson |
Dependencia externa innecesaria | Usar Builder / Builders nativos de tinystruct |
Método @Action con visibilidad private |
El registro automático solo escanea public |
Todos los métodos @Action deben ser public |
| POST y GET con mismo path, se dispara el equivocado | Sin mode en la anotación |
@Action(value="api/segments", mode=Mode.HTTP_POST) |
List<Builder> para arrays JSON |
Erasure de genéricos en serialización | Usar siempre Builders (no List<Builder>) |
public static void main() en la clase |
Antipatrón tinystruct | El entry point siempre es bin/dispatcher |
| CLI arg no visible en el método | Arg no pasado con --key value |
getContext().getAttribute("--outputDir") |
MCP Security: Los valores de retorno de herramientas MCP se inyectan en el contexto del modelo. Siempre valida longitud, charset y nulidad de los argumentos antes de incluirlos en el string de respuesta para evitar Prompt Injection.
SegmentStatsTool — MCP Tool
Exponiendo el servicio como herramienta para agentes Claude/Cursor
src/main/java/com/segmentflow/SegmentStatsTool.java
Java
package com.segmentflow; import org.tinystruct.mcp.MCPTool; import org.tinystruct.mcp.MCPException; import org.tinystruct.system.annotation.Action; import org.tinystruct.system.annotation.Argument; import com.segmentflow.model.Segment; public class SegmentStatsTool extends MCPTool { public SegmentStatsTool() { super("segment_stats", "Get conversion stats for a SegmentFlow audience segment"); } @Action( value = "segment_stats/get", description = "Returns user count and conversion rate for a segment", arguments = { @Argument(key = "segmentId", description = "Numeric segment ID", type = "integer", optional = false) } ) public String getStats(String segmentId) throws MCPException { // SECURITY: Validar antes de usar en la respuesta (Prompt Injection) if (segmentId == null || !segmentId.matches("\\d{1,9}")) { throw new MCPException("Invalid segmentId: must be a positive integer"); } try { Segment seg = new Segment(); seg.findById(Integer.parseInt(segmentId)); double rate = seg.getConversions() * 100.0 / Math.max(1, seg.getUserCount()); return "Segment '" + seg.getName() + "': " + seg.getUserCount() + " users, " + String.format("%.1f%%", rate) + " conversion"; } catch (Exception e) { throw new MCPException("Failed to retrieve segment stats"); } } }
$ bin/dispatcher start \
--import org.tinystruct.system.HttpServer \
--import com.segmentflow.SegmentService \
--import com.segmentflow.SegmentStatsTool
[ INFO] MCP tool registered: segment_stats → getStats(segmentId)