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:
-
Contrato completo + esqueleto da sua API interna de embeddings em Go (chi, Bearer token, rate limit).
-
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:
POST /v1/embeddings/product
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:
-
internal/domain/category/... -
internal/infra/... -
internal/http/...
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:
-
Tudo de “domínio ERP” vai para
internal/erp/productcategory. -
Tudo de “ML infra” (ONNX, client de embeddings) vai para
internal/ml/.... -
Tudo de “plataforma” (http, observabilidade) vai para
internal/platform/....
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:
-
mainmonta:-
OnnxCategoryPredictor(ml/onnx) -
HTTPEmbeddingsClient(ml/embeddingsclient) -
CategoryService(erp/productcategory) -
CategoryHandlers(platform/http)
-
Com isso você mantém o padrão:
-
contexto de negócio em
internal/erp/... -
capacidade técnica em
internal/ml/... -
camadas cross-cutting em
internal/platform/...
Ver depois:
08 - Diagramas Mermaid e visão geral de serviços + contextos