⚡ tinystruct · Java Framework · Guía de Implementación

SegmentFlow API Service

Módulo REST + CLI dual-mode para segmentación de audiencias — implementado sobre tinystruct 1.7.26 sin Spring ni dependencias externas.

Java 17
tinystruct 1.7.26
MySQL 8
MCP Ready
📁
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)
$ 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
🚫
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)