vogel

module
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 22, 2026 License: MIT

README

vogel

vogel es la base compartida en Go extraída de go-bluprint, el ancestro común de go-crucible y go-licencias. Contiene los paquetes que una auditoría de los tres repositorios estableció como comprobadamente compartibles: puertos/adaptadores puros y utilidades pequeñas sin lógica de negocio, pensados para ser consumidos por ambos sistemas en lugar de duplicados.

Los puertos (interfaces) viven en paquetes sin dependencias; los adaptadores viven en subpaquetes, de modo que importar un puerto nunca arrastra transitivamente el AWS SDK, SendGrid o go-mail.

Documentación

Documento Para qué
docs/MIGRATION_GUIDE.md Cómo go-crucible y go-licencias adoptan vogel: tabla de correspondencia archivo-local → paquete, orden de migración recomendado, y los cambios de firma que rompen.
docs/WIRING.md Cómo se componen los paquetes entre sí: quién escribe en reqctx, el orden de los middlewares, transacción y auditoría juntas, y los errores de montaje que el compilador no detecta.
examples/api/ Una API ejecutable que monta el módulo completo en un solo proceso. La referencia más confiable sobre cómo encajan las piezas.

Este README es el inventario: qué contiene cada paquete y por qué. Los tres documentos de arriba cubren lo que el inventario no puede — cómo se usa todo junto.

Mapa de paquetes

Paquete Qué es
stringutil Parseo flexible de fechas (ParseFlexibleDate) y normalización de cadenas de acentos/espacios en blanco (Normalize).
reqctx Metadatos con alcance de request transportados a través de context.Context, independientes de cualquier router HTTP o framework de logging: un ID de request/correlación (WithRequestID/RequestIDFromContext) y RequestInfo — IP del cliente y User-Agent (WithRequestInfo/RequestInfoFromContext). Sin más dependencia que el paquete context de la biblioteca estándar. Es el terreno neutral entre httpx/middleware (quien escribe) y logger/audit (quienes leen), de modo que ninguno de los dos tiene que importar al otro.
logger Un wrapper delgado de *slog.Logger (Logger). Lee el ID de request/correlación desde reqctx (vía Logger.WithContext) — no depende de ningún router HTTP, y no posee su propia clave de contexto para el ID de request (esa propiedad se trasladó a reqctx — ver el punto 16 más abajo).
storage El puerto Storage: Upload, Download, Delete, PresignedGetURL, PresignedPutURL. Sin más dependencia que la biblioteca estándar.
storage/s3 Adaptador compatible con S3 (S3Storage) que implementa storage.Storage mediante el AWS SDK v2. Funciona con AWS S3, MinIO, DigitalOcean Spaces y otros servicios compatibles con S3. Incluye StorageRegistry para mantener múltiples storages con nombre.
notification El puerto Notifier: Send(ctx, *Message) error, más Message.Validate(). Sin más dependencia que la biblioteca estándar.
notification/smtp Adaptador SMTP (SMTPNotifier) vía go-mail. Abre una conexión nueva por cada Send — ver el comentario de documentación de SMTPNotifier para el porqué.
notification/sendgrid Adaptador de SendGrid (SendGridNotifier) vía la API HTTP de SendGrid.
httpx/response Envoltorios estándar de respuesta JSON de éxito/error/lista (response.JSON, response.Error, response.ValidationError, response.JSONList, ...).
httpx/middleware Middleware HTTP: Recovery, RateLimitJSON, RequestContext (el único escritor que completa el ID de request, la IP del cliente y el User-Agent en reqctx para cada request entrante — reemplaza al viejo par RequestInfoMiddleware + LoggerRequestID), SecurityHeaders, StructuredLogger, Metrics (Prometheus), Authenticate (ejecuta un auth.Authenticator y guarda el principal resultante en el contexto de la request), RequirePermission (ejecuta un authz.Checker contra el principal autenticado y el resourceKind, sin atributos de instancia) y RequireAccess + WriteAccessError (la misma familia de chequeo, pero contra un access.Guard, para cuando la ruta también necesita un chequeo por instancia luego de cargar la entidad — ver el paquete access y docs/WIRING.md §2).
auth El puerto Authenticator: Authenticate(ctx, token string) (*Principal, error). Helpers de contexto (WithPrincipal/FromContext) y errores centinela tipados (ErrUnauthenticated, ErrForbidden, ErrServiceUnavailable) que distinguen 401/403/503. Recibe un token desnudo en lugar de una request: dónde vive la credencial en el cable de transmisión es una decisión de transporte, así que le corresponde a httpx/middleware, y un llamador que no tiene una request a mano — un worker que corre bajo una identidad de servicio, una CLI — igual puede resolver un principal. Solo importa context y errors.
auth/zitadel Adaptador de Zitadel (Authenticator) que envuelve el authorization.Authorizer[*oauth.IntrospectionContext] de zitadel-go/v3 — ese tipo genérico nunca aparece fuera de este paquete. Prefiere el claim estándar de OIDC preferred_username por sobre el campo legado Username.
authz El puerto Checker: IsAllowed(ctx, Principal, Resource, action) (bool, error). Un error no nulo significa que la decisión no pudo tomarse (mapearlo a 503); false significa una denegación genuina (mapearlo a 403). Sin más dependencia que la biblioteca estándar.
authz/cerbos Adaptador de Cerbos (Checker) vía el cliente gRPC de cerbos-sdk-go. Close() es un no-op documentado — cerbos.GRPCClient en la v0.3.17 no expone ningún método Close, así que no hay nada que liberar.
access El chequeo de autorización por instancia, para usarse desde la capa de aplicación (los casos de uso hexagonales) después de cargar la entidad: Guard (New(checker, opts...), Guard.Principal, Guard.Check), el resolutor opcional PrincipalAttributes (WithPrincipalAttributes) para atributos del principal resueltos en otro sistema (p. ej. sus asignaciones vigentes), memoizado por request vía WithRequestScope, y los centinelas propios ErrUnauthenticated/ErrForbidden/ErrUnavailable. Depende sólo de auth y authz — nunca de httpx, chi ni net/http (go list -deps ./access lo verifica, igual que el punto 17 para audit). Existe separado de authz (que se mantiene sin dependencias) y de httpx/middleware (que sólo puede chequear antes de cargar la entidad, con lo poco que trae la URL) precisamente para no forzar una segunda lectura de la misma fila que el handler ya va a cargar.
pgxtx Infraestructura de transacciones de pgx: PgxTxManager.WithTx, DBFromContext, TxFromContext, la interfaz DBTX. Se llama pgxtx (y no repository) porque contiene infraestructura de transacciones, no repositorios. Vive en la raíz del módulo, como hermano de postgres, porque WithTx funciona contra cualquier *pgxpool.Pool que el consumidor haya construido por su cuenta.
audit El puerto de rastro de auditoría: Entry, la interfaz Auditable (AuditRepr/AuditSnapshot), el Recorder (Record, más Options funcionales: WithSubject, WithChange, WithAggregate, WithAffectedResources, WithError, ...), y el puerto Repository (Create/GetByID/List). Lee al actor desde auth.FromContext y los metadatos de la request desde reqctx — nunca importa httpx ni un router (go list -deps ./audit no arrastra chi ni httpx; ver el punto 17). Recorder.Record recibe un Source (SourceHTTP/SourceWorker) como argumento obligatorio, no como una opción con valor por defecto — ver el punto 20.
audit/postgres Repository respaldado por PostgreSQL (NewRepository(pool)), portado desde go-licencias: una única consulta List que usa count(*) OVER() para la paginación (un solo round trip, no dos) y un Filters.ResourceID tipado como *uuid.UUID (no como string).
audit/migrations La migración embebida 001_create_audit_log.sql (idéntica byte a byte entre go-bluprint, go-crucible y go-licencias) expuesta como un fs.FS vía migrations.FS(), lista para pasarse a migrate.Up. Ver «Ejecutar las migraciones de la librería junto a las de la aplicación» más abajo.
audit/httpx La capa de consulta HTTP de audit: el DTO Response con ToResponse/ToResponseList, FiltersFromRequest (parsea audit.Filters desde los query params, resource_id incluido), Messages/DefaultMessages/WithMessages para la copia visible al usuario, y un Handler con List y GetByID listo para montar. Las piezas se exportan por separado a propósito: una aplicación que necesita sus propias anotaciones de Swagger escribe su handler reusando el parseo y el mapeo, en lugar de duplicarlos. Vive en un subpaquete y no en audit para que audit nunca alcance net/http — el CI lo verifica.
request Helpers de request HTTP: JSON / JSONWithLimit (decodificación JSON con límite de tamaño y rechazo de campos desconocidos, con respuestas mapeadas a 400/413) y Validator (acumula errores de validación por campo para query params y parámetros de URL de chi: UUIDParam, IntQuery, Int64Query, BoolQuery, TimeQuery, DateQuery, Enum, PublicIDParam, ...).
decimalx Parser acotado de decimales (Parse, ValidateBounds) anti-DoS para strings de dinero provenientes de una request: shopspring/decimal guarda un decimal como (coeficiente, exponente) de forma perezosa, así que cualquier operación que lo materialice (comparar, serializar, codificarlo para una columna NUMERIC) puede colgar un worker con una carga de pocos bytes si no se acota antes. La regla de dinero, con la escala como parámetro (ValidateAmount(d, scale), ParseAmount(s, scale)): rechaza — nunca redondea en silencio — lo que no sobrevive a d.Round(scale). Bounds/NewBounds/MaxExponentLimit permiten cotas propias sin poder desactivar la guarda. Query(v, r, param) vive acá (no en request, que no debe importar shopspring/decimal) y no rechaza negativos.
decimalx/decimalxtest AssertNoDirectNewFromString(t, root, dirs...): guardián estructural para el test suite de una app consumidora, que falla si algún archivo fuera de decimalx llama a los constructores de string de shopspring/decimal directamente en lugar de pasar por decimalx.Parse.
validation Traduce los errores de struct de go-playground/validator/v10 a request.FieldErrors (una clave por campo JSON, nunca el texto crudo de Go): New/Struct/Write con un RegisterTagNameFunc que nombra cada campo según su tag json, y Messages/WithMessages para localizar la redacción de required, min/max (conscientes del reflect.Kind del campo), oneof y cualquier otro tag. Write(w, r, v) escribe response.ValidationError (inválido) o response.Error con 500 (error de programación, sin exponer el texto de Go). Vive separado de request para que un consumidor que no valida structs no arrastre go-playground/validator/v10.
config Primitivas para leer y validar variables de entorno: lectores tipados con valores por defecto (String, Bool, Int, Int32, Duration, StringSlice), un lector de variable obligatoria (Require), un acumulador Errors para que una falla de arranque reporte todos los problemas de una sola vez, y validadores semánticos (ValidURL, IntRange, OneOf, MinMax). Deliberadamente no define structs de configuración de la aplicación — esos permanecen en la app consumidora.
postgres NewPool(ctx, Config, *slog.Logger) (*pgxpool.Pool, error): un constructor de pgxpool con tracing de queries lentas (SlowQueryTracer), métricas de pool para Prometheus (PoolMetricsCollector), statement_timeout / lock_timeout / idle_in_transaction_session_timeout del lado del servidor (configurables, con valores por defecto razonables), y un error duro de arranque cuando RequireTLS está activado pero el DSN deshabilita TLS.
migrate Acceso programático a migraciones de goose v3 propiedad de la aplicación consumidora (este paquete no embebe ninguna propia): Up, Down, UpTo, UpByOne, Status, todas recibiendo un fs.FS explícito. Cada corrida adquiere un advisory lock de PostgreSQL a nivel de sesión vía goose.WithSessionLocker, de modo que las invocaciones concurrentes de migrate up se serializan en lugar de competir por la carrera (race). Options.TableName selecciona una tabla de versión de goose distinta de la predeterminada, de modo que un conjunto de migraciones numerado de forma independiente (por ejemplo, audit/migrations) puede correr contra la misma base de datos que las propias migraciones de una aplicación sin colisionar — ver «Ejecutar las migraciones de la librería junto a las de la aplicación» más abajo.
worker Procesamiento de trabajos en segundo plano sobre River (github.com/riverqueue/river) respaldado por PostgreSQL: la interfaz Queue (Enqueue, EnqueueTx, Start, Stop), la implementación RiverQueue, Migrate y EnsureSchema. A diferencia del resto del módulo, la interfaz y su implementación viven en el MISMO paquete y River se importa abiertamente: un consumidor que define jobs ya importa River de todos modos (river.WorkerDefaults[T], river.Job[T]), así que esconderlo detrás de un puerto aparte sería ceremonia sin beneficio. Config es propio de vogel (Schema, DefaultMaxWorkers, Queues) porque el paquete no lee variables de entorno. Los trabajos periódicos se registran con la opción funcional WithPeriodicJobs — ver el punto 21 más abajo.
workflow Motor genérico de workflow/BPM: Definition (nodos, transiciones, guardas), Case, Event, el motor de transiciones y el puerto Repository. La restricción que lo define es que el motor no guarda dato de dominio alguno: un Case lleva sólo una referencia (Domain, ExternalID), nunca el objeto de negocio — sin form builder, sin tabla de datos clave/valor, sin blob JSON de campos. Cada Transition declara una CommentPolicy (none/optional/required) para el comentario u observación de ese paso; Engine.Move la valida contra MoveInput.Comment (recortado) ANTES de mutar el caso y lo guarda como Event.Comment en el EventMoved correspondiente — sigue siendo historial de proceso, nunca dato de dominio, y el EventClosed automático nunca lo repite. Un Node puede ser un nodo de decisión (NodeKind "decision", compuerta exclusiva al estilo BPMN): sus transiciones salientes son rutas evaluadas en el orden declarado, la primera cuya Guard calza gana, y exactamente una ruta sin Guard — la ruta por defecto — debe ir declarada al final; Definition.Validate lo exige, junto con que el nodo no tenga Start/Terminal/Eligible/Deadline y que ningún ciclo esté formado sólo por nodos de decisión. Engine.Move que aterriza en un nodo de decisión sigue enrutando automáticamente, dentro de la misma llamada y transacción, hasta descansar en un nodo de tarea o terminal — un caso nunca se persiste en un nodo de decisión — agregando un EventMoved por salto (el comentario sólo va en el salto humano). Una Transition puede además marcarse como retorno (Transition.Return), para que una etapa burocrática pueda devolver un caso a cualquier nodo de tarea anterior que haya ocupado de verdad; Engine.Available la ofrece y Engine.Move la acepta sólo si el caso ocupó ese destino alguna vez, según su propio historial de eventos — nunca por alcanzabilidad en el grafo, así que un caso que saltó una etapa nunca ve ofrecido volver a ella. Definition.Validate rechaza un retorno que apunte a un nodo de decisión, además de uno que apunte a un nodo terminal o que salga de un nodo de decisión: un caso nunca descansa en un nodo de decisión, así que un "retorno" hacia uno reactivaría el enrutamiento automático y podría empujar el caso hacia adelante en vez de hacia atrás. La asignación es por posición y unidad organizacional (Eligibility), nunca por ID de persona incrustado en la Definition; resolver quién ocupa hoy esa posición (incluida la subrogación) queda para un servicio de contexto organizacional del consumidor. Depende sólo de uuid y pgxtx.
workflow/postgres Repository respaldado por PostgreSQL para workflow: Create, GetByID, GetByExternalID, Update, AppendEvent, ListEvents, ListByEligibility. Todas las operaciones reciben un pgxtx.DBTX, de modo que el consumidor decide si corren dentro de su transacción.
workflow/migrations Migraciones embebidas expuestas como fs.FS vía migrations.FS(), con su propia tabla de versión (workflow_db_version) independiente de la de audit: 001_create_workflow.sql crea workflow_case/workflow_event, y 002_workflow_event_comment.sql agrega workflow_event.comment TEXT NOT NULL DEFAULT '' para el comentario/observación de cada paso.

Ejecutar las migraciones de la librería junto a las de la aplicación

audit/migrations incluye su propia 001_create_audit_log.sql, numerada de forma independiente de como estén numeradas las propias migraciones de la aplicación. Si ambos conjuntos registraran las versiones aplicadas en la tabla predeterminada de goose, goose_db_version, el 001 de la librería y el 001 de la aplicación colisionarían — el que corriera en segundo lugar se vería como "ya aplicado" y sería omitido silenciosamente, o fallaría directamente.

Ejecute los dos conjuntos contra la misma base de datos con dos llamadas separadas a migrate.Up, dándole al conjunto de la librería su propia tabla de versión mediante migrate.Options.TableName:

import (
	"github.com/kafeiih/vogel/audit/migrations"
	"github.com/kafeiih/vogel/migrate"
)

// Application migrations track in goose's default "goose_db_version" table.
if err := migrate.Up(ctx, dbURL, appMigrationsFS); err != nil {
	return err
}

// Library migrations track in their own table, independently versioned from
// the application's own 001, 002, ... — migrations.DefaultTableName is
// "vogel_db_version".
if err := migrate.Up(ctx, dbURL, migrations.FS(), migrate.Options{
	TableName: migrations.DefaultTableName,
}); err != nil {
	return err
}

Ambas llamadas adquieren el advisory lock de PostgreSQL a nivel de sesión de migrate de forma independiente (ver el punto 10 más abajo), de modo que ejecutarlas una tras otra en el arranque de la aplicación es seguro incluso con múltiples réplicas compitiendo por migrar al iniciar. migrate.Status y las demás funciones de migrate aceptan las mismas Options para consultar el estado del conjunto de la librería por separado del de la aplicación.

Correcciones aplicadas durante la extracción

Los paquetes de origen tenían veinte problemas conocidos; todos se corrigieron como parte de este port, en lugar de arrastrarse. El punto 21 no es un problema de origen sino una divergencia entre los dos consumidores que hubo que resolver al unificarlos, y el punto 24 es una capacidad agregada después del port inicial, no una corrección:

  1. logger ya no importa chi. El pkg/logger original leía el ID de request directamente desde github.com/go-chi/chi/v5/middleware, acoplando un paquete de logging a un router HTTP. logger ahora posee su propia clave de contexto (WithRequestID / RequestIDFromContext); httpx/middleware.LoggerRequestID es el puente que lee el ID de request de chi y lo alimenta a esa clave. Logger.WithContext fue reescrito para usar esta clave en lugar de ser código muerto.

  2. pgxtx.PgxTxManager.WithTx es seguro ante panics. Un panic dentro del callback ahora revierte la transacción (mediante un defer/recover) antes de volver a lanzar el panic, en lugar de dejar filtrada una transacción abierta hasta que el pool la desaloje. Cubierto por TestPgxTxManager_WithTx_PanicInCallback_RollsBackAndPropagates.

  3. notification/smtp documenta su comportamiento real en lugar de contradecirlo. El ADR original afirmaba un "connection pool"; el código abría una conexión SMTP nueva en cada Send. Este port mantiene el dial-per-send (más simple, siempre correcto, sin preocupaciones de concurrencia por cliente compartido) y lo declara explícitamente en el comentario de documentación de SMTPNotifier, incluyendo una referencia a lo que necesitaría una implementación con pool si el volumen de envíos llegara a justificarlo.

  4. httpx/middleware.Metrics ya no entra en panic ante un doble registro. El original llamaba a prometheus.MustRegister en init(), lo que entra en panic si se construye más de una vez. Ahora es NewMetrics(reg prometheus.Registerer) (*Metrics, error) (un reg nulo usa por defecto prometheus.DefaultRegisterer), que trata AlreadyRegisteredError como éxito reutilizando el colector existente. Cubierto por TestNewMetrics_DoubleConstruction_DoesNotPanic.

  5. El límite de bytes de request.JSON estaba mal por 2. Los tres repositorios de origen escribían maxBytes := 1_048_578 // 1mb; el valor real de 1 MiB es 1_048_576, y el error tipográfico ya se había copiado a un segundo sitio de llamada en uno de los repositorios. Corregido a 1_048_576 (request.JSONWithLimit también permite que los llamadores sobrescriban el límite por endpoint). Cubierto por TestJSON_DefaultLimitIsExactly1MiB.

  6. config ganó validación semántica, no solo chequeos de presencia. El config.Load() de origen solo verificaba "¿esta variable de entorno no está vacía?" — un error tipográfico en NOTIFICATION_PROVIDER (por ejemplo, sendgrdi) caía silenciosamente al valor por defecto de SMTP y deshabilitaba el envío de correo, sin nada más que un log Warn para advertirlo. config agrega ValidURL, IntRange, OneOf y MinMax, más un acumulador Errors para que una falla de arranque reporte todas las variables faltantes/inválidas de una sola vez en lugar de una por reinicio.

  7. postgres.NewPool falla rápido cuando no se puede honrar RequireTLS. El applyTLSConfig de origen solo elevaba la versión mínima de TLS si el DSN ya tenía TLS habilitado; un DSN con sslmode=disable producía silenciosamente una conexión en texto plano mientras el llamador creía que RequireTLS había surtido efecto — el código de origen incluso documentaba el hueco sin cerrarlo. Ahora, en cambio, devuelve un error de arranque que nombra la configuración culpable. Cubierto por TestApplyTLSConfig_RequireTLSTrueWithDisabledDSN_FailsFast y una prueba de integración que demuestra que el camino positivo sigue preservando ServerName (ver postgres_test.go).

  8. postgres agrega los tres timeouts del lado del servidor que ninguno de los tres repositorios configuraba: statement_timeout, lock_timeout e idle_in_transaction_session_timeout, aplicados mediante el hook AfterConnect de pgxpool con valores por defecto configurables y razonables. idle_in_transaction_session_timeout es el más importante: es el único backstop del lado del servidor contra un handler que se cuelga mientras mantiene abierta una transacción y sus locks — statement_timeout no ayuda ahí porque no hay ningún statement en ejecución. Verificado contra un contenedor real en TestNewPool_AppliesServerSideTimeouts.

  9. postgres.PoolMetricsCollector ya no entra en panic ante un doble registro — la misma clase de bug y la misma forma de corrección que httpx/middleware.Metrics (punto 4): NewPoolMetricsCollector(pool, reg) ahora devuelve (*PoolMetricsCollector, error) y reutiliza un colector ya registrado en lugar de llamar a MustRegister. Cubierto por TestNewPoolMetricsCollector_DoubleConstruction_DoesNotPanic.

  10. migrate ahora usa locks. Ninguno de los runners de migración de los tres repositorios tomaba ningún lock, así que dos invocaciones concurrentes de migrate up (dos jobs de CI, dos init containers, una corrida manual solapada con un pipeline) podían intercalar DDL contra la misma base de datos. migrate ahora construye su goose.Provider con goose.WithSessionLocker (un advisory lock de PostgreSQL a nivel de sesión), serializando las corridas concurrentes en lugar de dejarlas competir. Verificado contra un contenedor real con 5 llamadas concurrentes a migrate.Up en TestUp_ConcurrentInvocations_SerializeInsteadOfRacing. Una migración que necesite CREATE INDEX CONCURRENTLY igual debe usar la anotación de goose -- +goose NO TRANSACTION, dado que PostgreSQL rechaza ese statement dentro de una transacción — ver el comentario de documentación en migrate/runner.go.

  11. auth/authz eliminan por completo la multi-tenencia basada en org_id — nunca funcionó. Ambos sistemas de origen reflejaban un UserContext.OrgID en cada recurso de Cerbos para que los roles derivados pudieran comparar principal.attr.org_id == resource.attr.org_id, pero ResourceForUser copiaba ese org_id directamente del mismo principal, así que la comparación siempre era X == X — siempre verdadera. Incluso con ese bug corregido, la instancia de Zitadel del propietario tiene exactamente una organización ("intranet"), con la separación por sistema hecha en cambio a nivel de proyecto/aplicación, de modo que urn:zitadel:iam:user:resourceowner:id devuelve el mismo valor para cada usuario en cada sistema: nunca hubo un segundo valor contra el cual comparar. org_id tampoco aparece en ninguna consulta de repositorio ni en ninguna migración de ninguno de los dos sistemas. Principal.OrgID, extractOrgID, y el reflejo de org_id en ResourceForUser/principalAttr desaparecieron, junto con la rama de "tratar como no autenticado si falta org_id" que fallaba ruidosamente y que existía solo para proteger ese mecanismo muerto. La separación real entre sistemas es la validación de audience de OIDC, que el SDK de Zitadel ya realiza.

  12. auth/zitadel prefiere preferred_username por sobre el campo legado Username. go-crucible leía authCtx.Username directamente, que queda vacío para tokens provenientes de un flujo conforme al estándar; go-licencias ya llevaba esta corrección. Portada aquí para que el único adaptador de este módulo lo haga bien.

  13. Una caída del PDP/IdP ahora se mapea a HTTP 503, nunca a 401/403/500. go-crucible devolvía 500 cuando el chequeo de Cerbos fallaba; go-licencias devolvía deliberadamente 403 (su DEC-08, "para evitar un oráculo"); la base colapsaba también el ServiceUnavailableErr de Zitadel en 403. Los tres dejan al monitoreo ciego a la diferencia entre "el cliente hizo algo mal" y "nuestra infraestructura está caída", y ninguno de 401/403/500 es reintentable de forma significativa como sí lo es 503. Tanto httpx/middleware.Authenticate (proveedor de identidad) como httpx/middleware.RequirePermission (punto de decisión de política) ahora mapean un error del lado del proveedor a 503, una denegación genuina a 403, y credenciales faltantes/inválidas a 401 — ver los comentarios de documentación de ambos para el razonamiento completo. Esto reemplaza deliberadamente el DEC-08 de go-licencias.

  14. Los mensajes de auth/authz orientados al usuario ya no están fijados en español dentro del código. Los orígenes devolvían literales como "No tenés permisos para realizar esta acción" incrustados en una librería — algo que un paquete compartido no debería poseer en un único idioma. httpx/middleware.AuthMessages (con DefaultAuthMessages para valores por defecto neutrales en inglés y WithAuthMessages para sobrescribirlos) hace configurable el texto de 401/403/503; cada sistema consumidor establece su propio texto localizado.

  15. ZitadelAuthWithRole, IsGrantedRole y PrincipalFromUser no fueron portados. Los tres estaban exportados en cada repositorio de origen sin ningún sitio de llamada fuera de los tests. El comportamiento de wildcardResourceID = "*" de RequirePermission sí se mantuvo — es estructural, no cosmético: Cerbos rechaza cualquier recurso con un ID vacío antes de evaluar una política, lo cual de otro modo se manifiesta como un 500 en cada ruta a nivel de colección (list/create).

  16. audit_log.request_id finalmente se asigna. En los tres repositorios de origen, request_id estaba declarado en el DTO, mapeado desde la entidad y persistido por el repositorio — y nunca se asignaba en ningún lado. Cada fila de auditoría en producción tiene request_id = NULL, lo cual hace imposible vincular una entrada de auditoría con las líneas de log de su request, determinar que varias filas escritas por una operación fueron una sola acción, o seguir un cambio hasta el background job que generó. audit.Recorder.Record ahora lee el ID de request desde reqctx en el mismo paso incondicional en el que lee al principal actuante — no detrás de una opción que se pueda omitir por separado — de modo que un futuro sitio de llamada no pueda completar el actor mientras "olvida" el ID de request, como siempre hizo hasta ahora cada sitio de llamada. Cubierto por TestRecord_RequestIDInContext_IsPersistedOnEntry y TestRecord_NoRequestIDInContext_LeavesRequestIDNilWithoutError en audit/recorder_test.go, y verificado de punta a punta contra una base de datos real en audit/postgres/repository_integration_test.go.

  17. audit ya no importa la capa de transporte HTTP. El application/audit/recorder.go de origen llamaba a middleware.UserFromContext/middleware.RequestInfoFromContext — un puerto de la capa de aplicación importando interfaces/http/middleware, la dirección equivocada, y la razón por la que este paquete no pudo publicarse en la tanda 3. audit.Recorder ahora lee al actor vía vogel/auth.FromContext y los metadatos de la request vía vogel/reqctx, ambos paquetes con pocas dependencias orientados a la aplicación. go list -deps ./audit no arrastra chi, ni httpx, ni net/http.

    El último de esos puntos requirió un segundo paso. Authenticator originalmente recibía un *http.Request, y como Go resuelve dependencias por paquete y no por archivo, todo consumidor que importaba auth solo para leer un Principal heredaba net/http — audit entre ellos. Angostar el puerto a un token desnudo la eliminó desde la fuente.

  18. Los metadatos con alcance de request tienen un único dueño: reqctx. logger solía poseer su propia clave de contexto para el ID de request; httpx/middleware.RequestInfoMiddleware poseía por separado una clave no exportada para IP/User-Agent, con una vía de escape exportada RequestInfoContextKey() solo para que los tests pudieran inyectar valores. Ambos son ahora reqctx.WithRequestID/RequestIDFromContext y reqctx.WithRequestInfo/RequestInfoFromContext. logger.Logger.WithContext lee el ID de request desde reqctx en lugar de una clave que posee él mismo, de modo que el ID que llega a una línea de log y el que llega a una fila de audit_log son demostrablemente el mismo valor. httpx/middleware.RequestContext reemplaza al viejo par LoggerRequestID + RequestInfoMiddleware con un único middleware que escribe los tres valores en reqctx; el propio middleware.RequestID de chi sigue siendo la fuente del ID upstream. El RequestInfoContextKey() exportado desapareció — los tests usan reqctx.WithRequestInfo directamente.

  19. Las migraciones propiedad de la librería obtienen su propia tabla de versión de goose. audit/migrations comienza su propia numeración en 001, exactamente igual que las propias migraciones de cada aplicación consumidora, de modo que una tabla goose_db_version compartida vería a los dos 001 como la misma versión. migrate.Options ganó TableName (una opción provista por el llamador, no una constante fijada dentro de migrate, dado que ese paquete no posee migraciones propias y no tiene opinión sobre el esquema de nombres de ningún consumidor en particular), de modo que las migraciones de la librería se registran en su propia tabla. El nombre lo elige cada conjunto, no la librería: audit usa vogel_db_version (audit/migrations.DefaultTableName) por ser el primero que existió, y workflow usa workflow_db_version. Cada conjunto de migraciones de vogel numera desde 001, así que dos conjuntos que compartan tabla colisionan igual que la librería y la aplicación: un tercer conjunto necesita su propio nombre, no reusar el de audit. Verificado contra una base de datos real, con ambos conjuntos reutilizando la versión 1 y aplicándose ambos por completo, en TestUp_IndependentTableNames_DoNotCollide (migrate/runner_integration_test.go).

  20. Las entradas de auditoría de un background job declaran su origen explícitamente. El Recorder.Record de cada repositorio de origen fijaba Source en "http" por defecto dentro del struct literal, así que un worker o job programado que lo llamara — ninguno lo hizo jamás, precisamente porque no había ningún valor correcto al cual recurrir por defecto — habría producido una fila con forma de HTTP para un trabajo que nunca tocó una request. Recorder.Record ahora recibe Source (SourceHTTP o SourceWorker) como argumento posicional obligatorio, no como una Option, de modo que cada sitio de llamada declara su origen en lugar de heredar uno. Una entrada SourceWorker legítimamente no tiene ID de request a menos que su contexto haya sido a su vez derivado de la request que generó el job — ver TestRecord_SourceHTTP_And_SourceWorker y TestRecord_WorkerOrigin_RequestIDStillPropagatedWhenPresent.

  21. Los trabajos periódicos del worker pasaron de parámetro posicional a opción funcional. Los dos consumidores tenían la misma cola sobre River, con una única diferencia de firma: go-crucible había extendido NewRiverQueue(pool, workers, periodicJobs []*river.PeriodicJob, cfg, logger) para su sweep nocturno, mientras go-licencias seguía con NewRiverQueue(pool, workers, cfg, logger). Portar cualquiera de las dos tal cual rompía al otro consumidor. La firma unificada deja los parámetros obligatorios como estaban y mueve los trabajos periódicos a WithPeriodicJobs, de modo que go-licencias no cambia ninguna llamada y go-crucible sólo agrega la opción. TestBuildRiverConfig_WorkerMode_NoPeriodicJobsOption_MatchesLicenciasShape fija esa equivalencia: si algún día omitir la opción dejara de significar «sin trabajos periódicos», el test falla. Los trabajos periódicos siguen registrándose únicamente en modo binario worker — un cliente de sólo inserción no procesa nada, así que pasarlos junto a un *river.Workers nulo no los registra.

  22. La capa de consulta de audit dejó de duplicarse a mano, y eso cerró un agujero. El DTO, su mapeo y el parseo de filtros eran el mismo código en los dos consumidores — mismos campos, mismos json tags, misma paginación — salvo en un punto: go-licencias filtra por resource_id (*uuid.UUID) y go-crucible directamente no tiene ese campo en su Filters. No es una diferencia de diseño: es lo que pasa cuando dos copias del mismo código evolucionan por separado. audit/httpx lo unifica con el filtro incluido. Queries no se portó: era repo.GetByID seguido de toResponse, y vogel ya expone Repository. Los mensajes visibles al usuario, que en el origen estaban incrustados en español dentro de la librería, salieron a Messages/WithMessages por la misma razón que el punto 14. Y las listas de valores aceptados para operation_category y status se derivan de audit.Actions()/audit.Statuses() en vez de repetirse como literales, con un test que falla si se agrega una Action sin decidir si además es filtrable.

  23. StructuredLogger leía el request ID de chi, no de reqctx. El punto 18 movió la propiedad de los metadatos de request a reqctx y el doc comment de RequestContext promete que una línea de log y una fila de audit_log de la misma request quedan enlazadas de forma demostrable, porque ambas leen la misma clave. La línea de acceso de httpx/middleware.StructuredLogger quedó afuera de ese movimiento: seguía llamando a chimw.GetReqID. Coincidía con lo que veían logger y audit sólo porque hoy los tres valores derivan del mismo ID upstream de chi — un request ID que llegara al contexto por otra vía (un worker que lo deriva de la request que lo generó, un test que lo inyecta) terminaba en la fila de auditoría mientras el log de acceso imprimía vacío. Ahora las tres lecturas van a reqctx, y el enlace es una propiedad del diseño en vez de una coincidencia. El paquete no tenía ni un test; los dos que ahora existen (TestStructuredLogger_ReadsRequestIDFromReqctx, TestStructuredLogger_NoRequestID_LogsEmpty) son la razón por la que esto apareció.

  24. access y RequireAccess son una capacidad nueva, no una corrección heredada de los repositorios de origen — igual que el punto 21. Ninguno de los tres tenía un chequeo por instancia: RequirePermission (y su equivalente en cada sistema de origen) sólo podía comparar el principal contra el resourceKind y, a lo sumo, un ID sacado de la URL, porque corre antes de que el handler cargue nada. Un chequeo que dependa de un dato de la entidad misma — su dueño, su estado, si ya fue enviada — no era posible ahí, y la alternativa de que el propio middleware cargara la entidad habría significado leer la misma fila dos veces en cada ruta protegida (una para el chequeo, otra para el trabajo real del handler). access.Guard.Check, llamado desde el handler después de su propia carga, resuelve eso sin que authz deje de ser dependency-free ni que httpx/middleware tenga que adivinar qué cargar. RequirePermission ahora es un envoltorio delgado sobre access.New + RequireAccess, así que el mapeo de estados vive una sola vez. AuthOption — el tipo función que ya usaban Authenticate y RequirePermission — se dejó exactamente igual a propósito: convertirlo en un struct de configuración habría roto cualquier AuthOption escrito a mano como función literal en código consumidor ya existente, a cambio de nada que RequireAccess necesitara.

WithAffectedResources es la forma prevista de auditar una operación masiva/por lotes (bulk/batch): una única entrada sobre el recurso primario que nombra cada fila que tocó, en lugar de iterar y llamar a Record una vez por cada fila afectada dentro de la misma transacción (como hace hoy derecho_cobrar/commands.go de go-licencias) — esto último multiplica los INSERTs y extiende el tiempo de retención de los locks en proporción al tamaño del lote sin ningún beneficio, dado que AffectedResources es en sí misma una columna JSON consultable. Ver el comentario de documentación de WithAffectedResources y TestWithAffectedResources_SetsListOnSingleEntry.

Todavía no está acá

Esta es una cuarta tanda. Excluidos deliberadamente, decisiones pendientes:

  • El prefijo de URL /v1 / router.go — bloqueado por decisiones de convergencia entre los dos sistemas sobre convenciones de ruteo.
  • cmd/, migraciones (los archivos SQL en sí, más allá de las propias de audit), Dockerfile, docker-compose, swagger — fuera del alcance de una tanda de librería compartida; son asuntos propios de cada aplicación, no puertos/adaptadores compartibles. migrate/create.go (un scaffolder de archivos de migración) se dejó afuera de igual manera, por ser un asunto de CLI/plantilla y no de API de librería. Los jobs concretos de cada dominio (application/jobs/*) tampoco se portan: el andamiaje es compartible, el negocio que corre adentro no.
  • Las anotaciones de Swagger y el registro de rutas de audit — lo único que quedó afuera de audit/httpx (ver el punto 22). Las anotaciones de swaggo se leen de los comentarios sobre las funciones handler de cada aplicación, así que una app que quiera documentar estos endpoints escribe su propio handler con las piezas exportadas en lugar de montar el Handler de vogel; las rutas y su prefijo siguen siendo decisión de cada app.
  • null_helpers.go (nullInt/nullInt64) — dejado atrás deliberadamente. Devuelve nil cuando n == 0, confundiendo "sin establecer" con un cero legítimo. Además no se usaba en ninguno de los tres repositorios de origen.

Directories

Path Synopsis
Package access is the per-instance authorization check callable from the application layer (the hexagonal use cases, not the transport layer) once an entity has already been loaded.
Package access is the per-instance authorization check callable from the application layer (the hexagonal use cases, not the transport layer) once an entity has already been loaded.
Package audit defines the audit-trail port: the Entry record, the Auditable interface domain entities implement to be recorded automatically, and the Repository port a storage adapter implements (see audit/postgres).
Package audit defines the audit-trail port: the Entry record, the Auditable interface domain entities implement to be recorded automatically, and the Repository port a storage adapter implements (see audit/postgres).
httpx
Package httpx provides a ready-to-use HTTP handler for the audit log — List and GetByID — plus the composable pieces it is built from: DTO mapping (ToResponse, ToResponseList), filter parsing (FiltersFromRequest), and configurable user-facing messages (Messages).
Package httpx provides a ready-to-use HTTP handler for the audit log — List and GetByID — plus the composable pieces it is built from: DTO mapping (ToResponse, ToResponseList), filter parsing (FiltersFromRequest), and configurable user-facing messages (Messages).
migrations
Package migrations embeds the SQL migration(s) that create the audit_log table this library owns.
Package migrations embeds the SQL migration(s) that create the audit_log table this library owns.
postgres
Package postgres provides the PostgreSQL-backed implementation of the audit.Repository port, built on pgx.
Package postgres provides the PostgreSQL-backed implementation of the audit.Repository port, built on pgx.
Package auth defines the contracts for authenticating a caller from a bearer credential.
Package auth defines the contracts for authenticating a caller from a bearer credential.
zitadel
Package zitadel adapts github.com/zitadel/zitadel-go/v3's OIDC authorizer to the auth.Authenticator port.
Package zitadel adapts github.com/zitadel/zitadel-go/v3's OIDC authorizer to the auth.Authenticator port.
Package authz defines the contracts for authorization decisions.
Package authz defines the contracts for authorization decisions.
cerbos
Package cerbos adapts github.com/cerbos/cerbos-sdk-go's gRPC client to the authz.Checker port.
Package cerbos adapts github.com/cerbos/cerbos-sdk-go's gRPC client to the authz.Checker port.
Package config provides primitives for reading and validating configuration from environment variables: typed readers with defaults, a required-variable reader that fails with a clear message, an error accumulator so a boot failure reports every problem at once instead of one per restart, and semantic validators (well-formed URL, int range, allowed set, min<=max pairs) for cases where "non-empty" is not enough to catch a misconfiguration.
Package config provides primitives for reading and validating configuration from environment variables: typed readers with defaults, a required-variable reader that fails with a clear message, an error accumulator so a boot failure reports every problem at once instead of one per restart, and semantic validators (well-formed URL, int range, allowed set, min<=max pairs) for cases where "non-empty" is not enough to catch a misconfiguration.
Package decimalx provides a bounded parser for money-shaped strings coming from an HTTP request, plus the money-precision rule that goes with it.
Package decimalx provides a bounded parser for money-shaped strings coming from an HTTP request, plus the money-precision rule that goes with it.
decimalxtest
Package decimalxtest provides a structural test helper for consumers of decimalx: a guard that fails a test if any .go file outside the decimalx package itself calls one of shopspring/decimal's string constructors directly, bypassing the bounded parser decimalx.Parse provides.
Package decimalxtest provides a structural test helper for consumers of decimalx: a guard that fails a test if any .go file outside the decimalx package itself calls one of shopspring/decimal's string constructors directly, bypassing the bounded parser decimalx.Parse provides.
examples
api command
Command api is a runnable example that boots a real HTTP API wiring EVERY vogel package together, so a reader learns the composition root by reading this one directory.
Command api is a runnable example that boots a real HTTP API wiring EVERY vogel package together, so a reader learns the composition root by reading this one directory.
httpx
Package logger provides a thin, dependency-free wrapper around log/slog.
Package logger provides a thin, dependency-free wrapper around log/slog.
Package migrate provides programmatic access to SQL/Go migrations owned by the consuming application, wrapping pressly/goose v3's Provider API.
Package migrate provides programmatic access to SQL/Go migrations owned by the consuming application, wrapping pressly/goose v3's Provider API.
Package notification defines the contracts for email delivery.
Package notification defines the contracts for email delivery.
sendgrid
Package sendgrid implements the notification.Notifier port via the SendGrid HTTP API.
Package sendgrid implements the notification.Notifier port via the SendGrid HTTP API.
smtp
Package smtp implements the notification.Notifier port via SMTP using go-mail.
Package smtp implements the notification.Notifier port via SMTP using go-mail.
Package pgxtx provides pgx-based transaction plumbing: a context-scoped active transaction, and a WithTx helper that runs a callback inside one transaction, committing on success and rolling back on error or panic.
Package pgxtx provides pgx-based transaction plumbing: a context-scoped active transaction, and a WithTx helper that runs a callback inside one transaction, committing on success and rolling back on error or panic.
Package postgres provides a pgxpool.Pool constructor with the operational defaults the source applications lacked: slow-query tracing (see slow_query_tracer.go), Prometheus pool metrics (see metrics.go), a hard startup failure instead of a silent no-op when TLS is required but the DSN does not enable it, and server-side statement/lock/idle-in-transaction timeouts applied on every new connection.
Package postgres provides a pgxpool.Pool constructor with the operational defaults the source applications lacked: slow-query tracing (see slow_query_tracer.go), Prometheus pool metrics (see metrics.go), a hard startup failure instead of a silent no-op when TLS is required but the DSN does not enable it, and server-side statement/lock/idle-in-transaction timeouts applied on every new connection.
Package reqctx carries request-scoped metadata — a request/correlation ID, the client IP, and the User-Agent — through a context.Context, independent of any HTTP router or logging framework.
Package reqctx carries request-scoped metadata — a request/correlation ID, the client IP, and the User-Agent — through a context.Context, independent of any HTTP router or logging framework.
Package request provides HTTP request decoding and validation helpers shared across handlers: JSON body decoding with size limits and error mapping (json.go), localizable user-facing messages (messages.go), and query/URL- parameter validation (validate.go).
Package request provides HTTP request decoding and validation helpers shared across handlers: JSON body decoding with size limits and error mapping (json.go), localizable user-facing messages (messages.go), and query/URL- parameter validation (validate.go).
Package storage defines the contracts for object storage operations.
Package storage defines the contracts for object storage operations.
s3
Package s3 provides the S3-compatible implementation of the storage.Storage port.
Package s3 provides the S3-compatible implementation of the storage.Storage port.
Package validation turns go-playground/validator's struct-tag errors into the same shape request.Validator already uses: request.FieldErrors, one message per JSON field, ready for response.ValidationError.
Package validation turns go-playground/validator's struct-tag errors into the same shape request.Validator already uses: request.FieldErrors, one message per JSON field, ready for response.ValidationError.
Package worker provides background job processing for vogel-based applications, built on River (github.com/riverqueue/river) backed by PostgreSQL.
Package worker provides background job processing for vogel-based applications, built on River (github.com/riverqueue/river) backed by PostgreSQL.
Package workflow is a generic workflow/BPM engine: it owns case state, transitions, assignment, and history for any process a consumer chooses to model as a Definition.
Package workflow is a generic workflow/BPM engine: it owns case state, transitions, assignment, and history for any process a consumer chooses to model as a Definition.
migrations
Package migrations embeds the SQL migration(s) that create the workflow_case and workflow_event tables this library owns.
Package migrations embeds the SQL migration(s) that create the workflow_case and workflow_event tables this library owns.
postgres
Package postgres provides the PostgreSQL-backed implementation of the workflow.Repository port, built on native pgx.
Package postgres provides the PostgreSQL-backed implementation of the workflow.Repository port, built on native pgx.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL