07 - Formatando a minha maneira

Continuação do artigo:
06 - Trocando o dummy embeddings por algo que consigamos plugar facilmente

Boa, vamos deixar isso com “cara de Farsoft” mesmo 😄

Vou dividir em duas partes:

  1. Contrato completo + esqueleto da sua API interna de embeddings em Go (chi, Bearer token, rate limit).

  2. Ajuste de organização do projeto para o padrão multi-contexto (internal/erp/..., internal/ml/..., internal/platform/...).


1) API interna de Embeddings em Go (contrato + esqueleto)

1.1. Contrato HTTP pro serviço de embeddings

Endpoint principal:

Request (JSON) – pensando em ERP real:

{
  "workspace_id": "3f3f30d6-1eb4-4b85-9f21-4a8a1af5abcd", // opcional (multi-tenant)
  "empresa_id": 123,                                      // opcional
  "produto_id": 98765,                                    // opcional
  "tipo": "descricao_produto",                            // enum simples
  "descricao": "Pastilha de freio dianteira Corolla 2018",
  "marca": "Bosch",
  "grupo_atual": "Freios",
  "metadata": {
    "origem": "erp_legacy",
    "usuario": "farnetani"
  }
}

Response (JSON) – compatível com o client que criamos:

{
  "embedding": [0.0123, -0.0456, 0.0789], // vetor numérico
  "dimensions": 3,                        // deve bater com len(embedding)
  "model": "farsoft-produto-v1",
  "workspace_id": "3f3f30d6-1eb4-4b85-9f21-4a8a1af5abcd",
  "empresa_id": 123,
  "produto_id": 98765,
  "tipo": "descricao_produto",
  "metadata": {
    "origem": "erp_legacy",
    "usuario": "farnetani"
  }
}

Para o Category API, o que importa mesmo é o campo embedding. O resto é ouro pra telemetria e auditoria.


1.2. Estrutura da API de embeddings

Um serviço separado, por exemplo:

embeddings-api/
  cmd/
    api/
      main.go
  internal/
    ml/
      embeddings/
        service.go      // orquestra geração de embeddings (chama modelo, OpenAI, etc.)
    erp/
      product/
        dto.go         // structs de request/response se quiser separar
    platform/
      http/
        router.go
        handlers.go
        middleware.go   // auth, rate limit, etc.
      observability/
        metrics.go
        tracing.go
  go.mod

1.3. go.mod

module github.com/farsoft-apps/embeddings-api

go 1.22

require (
    github.com/go-chi/chi/v5 v5.0.12
    github.com/go-chi/httprate v0.9.0
    github.com/prometheus/client_golang v1.19.0
    go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0
    go.opentelemetry.io/otel v1.27.0
    go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0
    go.opentelemetry.io/otel/sdk v1.27.0
)

1.4. Domínio ml/embeddings – service

internal/ml/embeddings/service.go – aqui você depois pluga o modelo real (OpenAI/LLM local/etc.):

package embeddings

import (
	"context"
	"fmt"
)

// Request de negócio (vindo do handler)
type ProductEmbeddingRequest struct {
	WorkspaceID string
	EmpresaID   int64
	ProdutoID   int64
	Tipo        string

	Descricao  string
	Marca      string
	GrupoAtual string

	Metadata map[string]string
}

// Response de negócio
type ProductEmbeddingResponse struct {
	Embedding  []float64
	Dimensions int
	Model      string

	WorkspaceID string
	EmpresaID   int64
	ProdutoID   int64
	Tipo        string

	Metadata map[string]string
}

type Provider interface {
	Generate(ctx context.Context, req ProductEmbeddingRequest) (ProductEmbeddingResponse, error)
}

// Service central
type Service struct {
	provider Provider
}

func NewService(p Provider) *Service {
	return &Service{provider: p}
}

func (s *Service) GenerateProductEmbedding(ctx context.Context, req ProductEmbeddingRequest) (ProductEmbeddingResponse, error) {
	if req.Descricao == "" {
		return ProductEmbeddingResponse{}, fmt.Errorf("descricao é obrigatória")
	}
	// aqui cabem mais regras (limpeza de texto, normalização etc.)
	return s.provider.Generate(ctx, req)
}

Provider de exemplo (OpenAI) – esqueleto

internal/ml/embeddings/openai_provider.go:

package embeddings

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"strings"
	"time"
)

type OpenAIConfig struct {
	BaseURL string
	APIKey  string
	Model   string
	Timeout time.Duration
}

type openAIProvider struct {
	client *http.Client
	cfg    OpenAIConfig
}

func NewOpenAIProvider(cfg OpenAIConfig) Provider {
	if cfg.Timeout == 0 {
		cfg.Timeout = 10 * time.Second
	}
	return &openAIProvider{
		client: &http.Client{Timeout: cfg.Timeout},
		cfg:    cfg,
	}
}

type openAIReq struct {
	Model string   `json:"model"`
	Input []string `json:"input"`
}

type openAIResp struct {
	Data []struct {
		Embedding []float64 `json:"embedding"`
	} `json:"data"`
}

func (p *openAIProvider) Generate(ctx context.Context, req ProductEmbeddingRequest) (ProductEmbeddingResponse, error) {
	url := strings.TrimRight(p.cfg.BaseURL, "/") + "/v1/embeddings"

	bodyReq := openAIReq{
		Model: p.cfg.Model,
		Input: []string{req.Descricao},
	}

	body, err := json.Marshal(bodyReq)
	if err != nil {
		return ProductEmbeddingResponse{}, fmt.Errorf("erro serializando request openai: %w", err)
	}

	httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body)))
	if err != nil {
		return ProductEmbeddingResponse{}, fmt.Errorf("erro criando request openai: %w", err)
	}

	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)

	resp, err := p.client.Do(httpReq)
	if err != nil {
		return ProductEmbeddingResponse{}, fmt.Errorf("erro chamando openai: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 300 {
		return ProductEmbeddingResponse{}, fmt.Errorf("openai retornou status %d", resp.StatusCode)
	}

	var apiResp openAIResp
	if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
		return ProductEmbeddingResponse{}, fmt.Errorf("erro decodificando resposta openai: %w", err)
	}

	if len(apiResp.Data) == 0 {
		return ProductEmbeddingResponse{}, fmt.Errorf("resposta openai vazia")
	}

	embedding := apiResp.Data[0].Embedding

	return ProductEmbeddingResponse{
		Embedding:   embedding,
		Dimensions:  len(embedding),
		Model:       p.cfg.Model,
		WorkspaceID: req.WorkspaceID,
		EmpresaID:   req.EmpresaID,
		ProdutoID:   req.ProdutoID,
		Tipo:        req.Tipo,
		Metadata:    req.Metadata,
	}, nil
}

1.5. HTTP handlers + auth + limiter

DTO do handler

internal/erp/product/dto.go:

package product

type EmbeddingRequestDTO struct {
	WorkspaceID string            `json:"workspace_id"`
	EmpresaID   int64             `json:"empresa_id"`
	ProdutoID   int64             `json:"produto_id"`
	Tipo        string            `json:"tipo"`

	Descricao  string `json:"descricao"`
	Marca      string `json:"marca"`
	GrupoAtual string `json:"grupo_atual"`

	Metadata map[string]string `json:"metadata"`
}

type EmbeddingResponseDTO struct {
	Embedding  []float64         `json:"embedding"`
	Dimensions int               `json:"dimensions"`
	Model      string            `json:"model"`

	WorkspaceID string            `json:"workspace_id"`
	EmpresaID   int64             `json:"empresa_id"`
	ProdutoID   int64             `json:"produto_id"`
	Tipo        string            `json:"tipo"`
	Metadata    map[string]string `json:"metadata"`
}

Handlers

internal/platform/http/handlers.go:

package http

import (
	"encoding/json"
	"net/http"

	"github.com/farsoft-apps/embeddings-api/internal/erp/product"
	"github.com/farsoft-apps/embeddings-api/internal/ml/embeddings"
)

type EmbeddingHandlers struct {
	svc *embeddings.Service
}

func NewEmbeddingHandlers(svc *embeddings.Service) *EmbeddingHandlers {
	return &EmbeddingHandlers{svc: svc}
}

func (h *EmbeddingHandlers) Healthz(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

func (h *EmbeddingHandlers) GenerateProductEmbedding(w http.ResponseWriter, r *http.Request) {
	var dtoReq product.EmbeddingRequestDTO
	if err := json.NewDecoder(r.Body).Decode(&dtoReq); err != nil {
		writeError(w, http.StatusBadRequest, "JSON inválido")
		return
	}

	req := embeddings.ProductEmbeddingRequest{
		WorkspaceID: dtoReq.WorkspaceID,
		EmpresaID:   dtoReq.EmpresaID,
		ProdutoID:   dtoReq.ProdutoID,
		Tipo:        dtoReq.Tipo,

		Descricao:  dtoReq.Descricao,
		Marca:      dtoReq.Marca,
		GrupoAtual: dtoReq.GrupoAtual,
		Metadata:   dtoReq.Metadata,
	}

	resp, err := h.svc.GenerateProductEmbedding(r.Context(), req)
	if err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}

	dtoResp := product.EmbeddingResponseDTO{
		Embedding:  resp.Embedding,
		Dimensions: resp.Dimensions,
		Model:      resp.Model,

		WorkspaceID: resp.WorkspaceID,
		EmpresaID:   resp.EmpresaID,
		ProdutoID:   resp.ProdutoID,
		Tipo:        resp.Tipo,
		Metadata:    resp.Metadata,
	}

	writeJSON(w, http.StatusOK, dtoResp)
}

func writeJSON(w http.ResponseWriter, status int, payload any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(payload)
}

func writeError(w http.ResponseWriter, status int, msg string) {
	writeJSON(w, status, map[string]string{"error": msg})
}

Middleware de auth + rate limit

internal/platform/http/middleware.go:

package http

import (
	"net/http"
	"os"
	"strings"

	"github.com/go-chi/httprate"
)

// Bearer token simples via env EMBEDDINGS_API_TOKEN
func AuthMiddleware(next http.Handler) http.Handler {
	expectedToken := os.Getenv("EMBEDDINGS_API_TOKEN")
	if expectedToken == "" {
		// sem token, não bloqueia (mas loga em produção)
		return next
	}

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		auth := r.Header.Get("Authorization")
		if !strings.HasPrefix(auth, "Bearer ") {
			w.WriteHeader(http.StatusUnauthorized)
			_, _ = w.Write([]byte(`{"error":"missing bearer token"}`))
			return
		}

		token := strings.TrimPrefix(auth, "Bearer ")
		if token != expectedToken {
			w.WriteHeader(http.StatusUnauthorized)
			_, _ = w.Write([]byte(`{"error":"invalid token"}`))
			return
		}

		next.ServeHTTP(w, r)
	})
}

// Rate limit: ex. 100 req/min por IP
func RateLimitMiddleware() func(http.Handler) http.Handler {
	return httprate.Limit(
		100,
		httprate.PerMinute(1),
		httprate.WithKeyFuncs(httprate.KeyByIP),
	)
}

Router

internal/platform/http/router.go:

package http

import (
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"

	"github.com/farsoft-apps/embeddings-api/internal/platform/observability"

	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

func NewRouter(h *EmbeddingHandlers) http.Handler {
	r := chi.NewRouter()

	r.Use(middleware.RequestID)
	r.Use(middleware.RealIP)
	r.Use(middleware.Logger)
	r.Use(middleware.Recoverer)

	// Rate limit global
	r.Use(RateLimitMiddleware())

	// Autenticação por token
	r.Use(AuthMiddleware)

	// Métricas Prometheus
	r.Use(observability.MetricsMiddleware)

	r.Get("/healthz", h.Healthz)
	r.Post("/v1/embeddings/product", h.GenerateProductEmbedding)

	// Metrics
	r.Handle("/metrics", observability.MetricsHandler())

	return otelhttp.NewHandler(r, "embeddings-api")
}

main.go

cmd/api/main.go:

package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/farsoft-apps/embeddings-api/internal/ml/embeddings"
	apihttp "github.com/farsoft-apps/embeddings-api/internal/platform/http"
	"github.com/farsoft-apps/embeddings-api/internal/platform/observability"
)

func main() {
	ctx := context.Background()

	observability.InitMetrics()

	shutdownTracer, err := observability.InitTracer(ctx, "embeddings-api")
	if err != nil {
		log.Printf("erro init tracer: %v", err)
	} else {
		defer func() {
			ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
			defer cancel()
			if err := shutdownTracer(ctx); err != nil {
				log.Printf("erro shutdown tracer: %v", err)
			}
		}()
	}

	// Config do provider OpenAI via env
	openAI := embeddings.OpenAIConfig{
		BaseURL: os.Getenv("OPENAI_BASE_URL"), // ex: https://api.openai.com
		APIKey:  os.Getenv("OPENAI_API_KEY"),
		Model:   os.Getenv("OPENAI_EMBEDDINGS_MODEL"), // ex: text-embedding-3-small
	}

	provider := embeddings.NewOpenAIProvider(openAI)
	svc := embeddings.NewService(provider)

	handlers := apihttp.NewEmbeddingHandlers(svc)
	router := apihttp.NewRouter(handlers)

	addr := ":8080"
	log.Printf("embeddings-api ouvindo em %s", addr)
	if err := http.ListenAndServe(addr, router); err != nil {
		log.Fatalf("erro servidor http: %v", err)
	}
}

2) Ajustar o projeto de categoria para seu padrão multi-contexto

Hoje a API de categorização está mais ou menos assim:

Adaptando para o seu estilo, sugiro algo nessa linha:

product-category-api/
  cmd/
    api/
      main.go
  internal/
    erp/
      productcategory/        // domínio + aplicação do caso de uso
        domain.go             // ProductCategory, PredictionInput, etc.
        ports.go              // CategoryPredictorPort, EmbeddingsPort
        service.go            // CategoryService
    ml/
      onnx/
        predictor.go          // OnnxCategoryPredictor (adapter modelo)
      embeddingsclient/
        client.go             // HTTPEmbeddingsClient (chama embeddings-api ou OpenAI)
    platform/
      http/
        handlers.go           // CategoryHandlers
        router.go
      observability/
        metrics.go
        tracing.go

Principais mudanças:

Os imports ficam mais semânticos, por exemplo no main.go da category API:

import (
	...

	"github.com/farsoft-apps/product-category-api/internal/erp/productcategory"
	"github.com/farsoft-apps/product-category-api/internal/ml/onnx"
	"github.com/farsoft-apps/product-category-api/internal/ml/embeddingsclient"
	apihttp "github.com/farsoft-apps/product-category-api/internal/platform/http"
	"github.com/farsoft-apps/product-category-api/internal/platform/observability"
)

E a montagem do grafo de dependências fica:

Com isso você mantém o padrão:


Ver depois:
08 - Diagramas Mermaid e visão geral de serviços + contextos