This guide explains how to extend pdf-forge with custom injectors, mappers, template resolvers, and init functions for document generation.
The extensibility system allows you to:
- Injectors: Resolve dynamic values from external sources (CRM, databases, APIs)
- Mapper: Parse incoming request payloads into typed structures
- Template Resolver: Choose template version dynamically for document-type render
- Init Function: Load shared data once before all injectors run
flowchart LR
A[HTTP Request] --> B[Mapper]
B --> C[Payload]
C --> D[Init Function]
D --> E[InitializedData]
E --> F[Injectors]
F --> G[Resolved Values]
G --> H[Document Generation]
- Create your injector struct implementing
sdk.Injector - Register it with
engine.RegisterInjector(&MyInjector{}) - Run the engine
// core/extensions/register.go
package extensions
import "github.com/rendis/pdf-forge/core/sdk"
func Register(engine *sdk.Engine) {
engine.RegisterInjector(&CustomerNameInjector{})
engine.RegisterInjector(&InvoiceTotalInjector{})
engine.SetMapper(&MyMapper{})
engine.SetInitFunc(myInitFunc)
}// core/cmd/api/main.go
package main
import (
"log/slog"
"os"
"github.com/rendis/pdf-forge/core/cmd/api/bootstrap"
"github.com/rendis/pdf-forge/core/extensions"
)
func main() {
engine := bootstrap.NewWithConfig("settings/app.yaml").
SetI18nFilePath("settings/injectors.i18n.yaml")
extensions.Register(engine)
if err := engine.Run(); err != nil {
slog.Error("failed to run engine", slog.String("error", err.Error()))
os.Exit(1)
}
}your-app/
├── main.go # Engine setup + extension registration
├── config/
│ ├── app.yaml # Configuration
│ └── injectors.i18n.yaml # Injectable translations
├── extensions/
│ ├── injectors/ # Your custom injectors
│ │ ├── customer_name.go
│ │ └── invoice_total.go
│ ├── mapper.go # Request mapper
│ └── init.go # Init function
└── go.mod
Injectors resolve dynamic values inserted into document templates. Each injector has a unique code that maps to a template variable.
type Injector interface {
Code() string
Resolve() (sdk.ResolveFunc, []string) // func + dependency codes
IsCritical() bool
Timeout() time.Duration
DataType() sdk.ValueType
DefaultValue() *sdk.InjectableValue
Formats() *sdk.FormatConfig
}package injectors
import (
"context"
"time"
"github.com/rendis/pdf-forge/core/sdk"
)
type CustomerNameInjector struct{}
func (i *CustomerNameInjector) Code() string { return "customer_name" }
func (i *CustomerNameInjector) Resolve() (sdk.ResolveFunc, []string) {
return func(ctx context.Context, injCtx *sdk.InjectorContext) (*sdk.InjectorResult, error) {
payload := injCtx.RequestPayload().(*MyPayload)
return &sdk.InjectorResult{
Value: sdk.StringValue(payload.CustomerName),
}, nil
}, nil // no dependencies
}
func (i *CustomerNameInjector) IsCritical() bool { return true }
func (i *CustomerNameInjector) Timeout() time.Duration { return 10 * time.Second }
func (i *CustomerNameInjector) DataType() sdk.ValueType { return sdk.ValueTypeString }
func (i *CustomerNameInjector) DefaultValue() *sdk.InjectableValue { return nil }
func (i *CustomerNameInjector) Formats() *sdk.FormatConfig { return nil }sdk.StringValue("text") // string
sdk.NumberValue(123.45) // float64
sdk.BoolValue(true) // bool
sdk.TimeValue(time.Now()) // time.Time
sdk.TableValueData(table) // table
sdk.ImageValue("https://...") // image URL
sdk.ListValueData(list) // listInjectors can depend on other injectors. Dependencies are resolved using topological sort:
func (i *TotalPriceInjector) Resolve() (sdk.ResolveFunc, []string) {
return func(ctx context.Context, injCtx *sdk.InjectorContext) (*sdk.InjectorResult, error) {
unitPrice, _ := injCtx.GetResolved("unit_price")
quantity, _ := injCtx.GetResolved("quantity")
total := unitPrice.(float64) * quantity.(float64)
return &sdk.InjectorResult{Value: sdk.NumberValue(total)}, nil
}, []string{"unit_price", "quantity"} // dependencies
}Add translations in config/injectors.i18n.yaml:
groups:
- key: billing
name:
en: "Billing"
es: "Facturación"
icon: "receipt"
customer_name:
group: billing
name:
en: "Customer Name"
es: "Nombre del Cliente"
description:
en: "Full name of the customer"
es: "Nombre completo del cliente"Injectors can specify format options that appear in the template editor.
| Preset | Default Format | Description |
|---|---|---|
| Date | DD/MM/YYYY |
Date formats |
| Time | HH:mm |
Time formats |
| DateTime | DD/MM/YYYY HH:mm |
Combined date and time |
| Number | #,##0.00 |
Number formatting |
| Currency | $#,##0.00 |
Currency formatting |
| Percentage | #,##0.00% |
Percentage formatting |
| Phone | +## # #### #### |
Phone number formatting |
| RUT (Chile) | ##.###.###-# |
Chilean RUT formatting |
| Boolean | Yes/No |
Boolean display options |
func (i *InvoiceDateInjector) DataType() sdk.ValueType { return sdk.ValueTypeTime }
func (i *InvoiceDateInjector) Formats() *sdk.FormatConfig {
return &sdk.FormatConfig{
Default: "DD/MM/YYYY",
Options: []string{"DD/MM/YYYY", "MM/DD/YYYY", "YYYY-MM-DD", "D MMMM YYYY"},
}
}The mapper parses incoming HTTP request payloads. Only ONE mapper is allowed — if you need multiple document types, handle routing internally.
type RequestMapper interface {
Map(ctx context.Context, mapCtx *sdk.MapperContext) (any, error)
}MapperContext fields:
RawBody— unparsed HTTP request bodyHeaders— HTTP headersEnvironment— render environment (sdk.EnvironmentDevorsdk.EnvironmentProd)ExternalID,TemplateID,TransactionalID,Operation— request metadata
package extensions
import (
"context"
"encoding/json"
"github.com/rendis/pdf-forge/core/sdk"
)
type MyPayload struct {
CustomerName string `json:"customerName"`
ProductID string `json:"productId"`
Amount float64 `json:"amount"`
}
type MyMapper struct{}
func (m *MyMapper) Map(ctx context.Context, mapCtx *sdk.MapperContext) (any, error) {
var payload MyPayload
if err := json.Unmarshal(mapCtx.RawBody, &payload); err != nil {
return nil, err
}
return &payload, nil
}Handle multiple document types inside a single mapper:
func (m *MultiDocMapper) Map(ctx context.Context, mapCtx *sdk.MapperContext) (any, error) {
docType := mapCtx.Headers["X-Document-Type"]
switch docType {
case "contract":
return m.parseContract(mapCtx.RawBody)
case "invoice":
return m.parseInvoice(mapCtx.RawBody)
default:
return nil, fmt.Errorf("unknown document type: %s", docType)
}
}Use a custom resolver when you need to choose the template version dynamically before the default 3-level fallback in:
POST /api/v1/workspace/document-types/{code}/render
type TemplateResolver interface {
Resolve(
ctx context.Context,
req *sdk.TemplateResolverRequest,
adapter sdk.TemplateVersionSearchAdapter,
) (*string, error)
}Resolver contract:
- Return
versionID(*stringnon-nil): use that version. - Return
nil, nil: fallback to default resolver. - Return
error: abort request.
TemplateResolverRequest provides the original render request data:
| Field | Description |
|---|---|
TenantCode |
Tenant code from X-Tenant-Code header |
WorkspaceCode |
Workspace code from X-Workspace-Code header |
DocumentType |
Document type code from the URL path |
Headers |
HTTP headers from the original render request |
RawBody |
Unparsed HTTP request body |
Injectables |
Pre-resolved injectable values available at resolution time |
Environment |
Render environment from X-Environment header (sdk.EnvironmentDev or sdk.EnvironmentProd) |
req.Environment is an sdk.Environment enum derived from the X-Environment header. Use req.Environment.IsDev() to check for staging mode and decide whether to search for STAGING or PUBLISHED versions.
The default resolver uses Environment.IsDev() to automatically search for staging versions. In a custom resolver, you have full control — you can use Environment to replicate that behavior, ignore it, or implement custom logic like staging-with-fallback.
The resolver receives a read-only adapter for querying available template versions:
published := true
items, err := adapter.SearchTemplateVersions(ctx, sdk.TemplateVersionSearchParams{
TenantCode: req.TenantCode,
WorkspaceCodes: []string{req.WorkspaceCode, "SYS_WRKSP"},
DocumentType: req.DocumentType,
Published: &published,
})Each returned TemplateVersionSearchItem contains:
| Field | Description |
|---|---|
VersionID |
UUID of the template version — return this from Resolve() |
Published |
true if the version has PUBLISHED status |
TenantCode |
Tenant that owns the version |
WorkspaceCode |
Workspace that owns the version |
Staging takes precedence over Published. There is no automatic fallback from STAGING to PUBLISHED — if no staging versions exist, the result is empty.
Staging |
Published |
Versions returned |
|---|---|---|
| nil / false | nil / true | Only PUBLISHED (default) |
| true | (ignored) | Only STAGING |
| false | false | Only DRAFT |
WorkspaceCodes are searched in order and results are aggregated across all matching workspaces.
Search for a staging version first; if none found, fall back to published:
type StagingFallbackResolver struct{}
func (r *StagingFallbackResolver) Resolve(
ctx context.Context,
req *sdk.TemplateResolverRequest,
adapter sdk.TemplateVersionSearchAdapter,
) (*string, error) {
if !req.Environment.IsDev() {
return nil, nil // not dev — use default resolver
}
searchParams := sdk.TemplateVersionSearchParams{
TenantCode: req.TenantCode,
WorkspaceCodes: []string{req.WorkspaceCode},
DocumentType: req.DocumentType,
}
// 1. Try staging versions
staging := true
searchParams.Staging = &staging
items, err := adapter.SearchTemplateVersions(ctx, searchParams)
if err != nil {
return nil, err
}
if len(items) > 0 {
return &items[0].VersionID, nil
}
// 2. Fallback to published
searchParams.Staging = nil
published := true
searchParams.Published = &published
items, err = adapter.SearchTemplateVersions(ctx, searchParams)
if err != nil {
return nil, err
}
if len(items) > 0 {
return &items[0].VersionID, nil
}
return nil, nil // no versions found — let default resolver handle it
}engine.SetTemplateResolver(&resolvers.MyTemplateResolver{})The init function runs once before all injectors and loads shared data. Only ONE init function is allowed.
func myInitFunc(ctx context.Context, injCtx *sdk.InjectorContext) (any, error) {
payload := injCtx.RequestPayload().(*MyPayload)
customer, err := db.GetCustomer(ctx, payload.CustomerID)
if err != nil {
return nil, fmt.Errorf("failed to load customer: %w", err)
}
return &SharedData{Customer: customer}, nil
}Register it:
engine.SetInitFunc(myInitFunc)func (i *MyInjector) Resolve() (sdk.ResolveFunc, []string) {
return func(ctx context.Context, injCtx *sdk.InjectorContext) (*sdk.InjectorResult, error) {
initData := injCtx.InitData().(*SharedData)
return &sdk.InjectorResult{
Value: sdk.StringValue(initData.Customer.Name),
}, nil
}, nil
}| Use Init For | Use Injectors For |
|---|---|
| Data needed by multiple injectors | Single values |
| Expensive operations (API calls, DB queries) | Simple transformations |
| Authentication/authorization checks | Calculations |
| Loading configuration | Formatting |
Available from InjectorContext:
injCtx.ExternalID() // External identifier
injCtx.TemplateID() // Template being used
injCtx.TransactionalID() // For traceability
injCtx.Operation() // Operation type
injCtx.Environment() // Render environment (dev or prod)
injCtx.Header("key") // HTTP header value
injCtx.RequestPayload() // Parsed payload from mapper
injCtx.InitData() // Data from init function
injCtx.GetResolved("code") // Value from another injector
injCtx.SelectedFormat("code") // Selected format for an injectorfunc (i *MyInjector) Resolve() (sdk.ResolveFunc, []string) {
return func(ctx context.Context, injCtx *sdk.InjectorContext) (*sdk.InjectorResult, error) {
value, err := fetchValue(ctx)
if err != nil {
// If IsCritical() returns true, this stops document generation
// If false, the error is logged and the value is empty
return nil, fmt.Errorf("failed to fetch value: %w", err)
}
return &sdk.InjectorResult{Value: sdk.StringValue(value)}, nil
}, nil
}func (i *SlowInjector) Timeout() time.Duration {
return 60 * time.Second // Override default 30s
}
func (i *FastInjector) Timeout() time.Duration {
return 0 // Use default 30s
}ERROR: circular dependency detected: injector_a -> injector_b -> injector_a
Solution: Refactor injectors to break the cycle. Consider moving shared logic to the init function.
If an injector code has no translation, the code itself is displayed as the name.
Solution: Add the translation to config/injectors.i18n.yaml.
For dynamic, workspace-specific injectables that are defined at runtime (not at startup), implement WorkspaceInjectableProvider.
- Injectables that vary by workspace (different workspaces have different available variables)
- Injectables fetched from external systems at runtime
- Dynamic injectables that can't be registered at startup
type WorkspaceInjectableProvider interface {
// GetInjectables returns available injectables for a workspace.
// Called when editor opens. Use injCtx.TenantCode() and injCtx.WorkspaceCode().
GetInjectables(ctx context.Context, injCtx *entity.InjectorContext) (*GetInjectablesResult, error)
// ResolveInjectables resolves a batch of injectable codes.
// Return (nil, error) for CRITICAL failures that stop render.
// Return (result, nil) with result.Errors for non-critical failures.
ResolveInjectables(ctx context.Context, req *ResolveInjectablesRequest) (*ResolveInjectablesResult, error)
}type MyProvider struct{}
func (p *MyProvider) GetInjectables(ctx context.Context, injCtx *sdk.InjectorContext) (*sdk.GetInjectablesResult, error) {
// Fetch available injectables for this workspace from your system
// injCtx.TenantCode(), injCtx.WorkspaceCode() identify the workspace
return &sdk.GetInjectablesResult{
Injectables: []sdk.ProviderInjectable{
{
Code: "customer_name",
Label: map[string]string{"es": "Nombre", "en": "Customer Name"},
Description: map[string]string{"es": "Nombre del cliente", "en": "Full name of the customer"},
DataType: sdk.InjectableDataTypeText,
GroupKey: "customer_data",
},
},
Groups: []sdk.ProviderGroup{
{Key: "customer_data", Name: map[string]string{"es": "Datos", "en": "Customer Data"}, Icon: "user"},
},
}, nil
}
func (p *MyProvider) ResolveInjectables(ctx context.Context, req *sdk.ResolveInjectablesRequest) (*sdk.ResolveInjectablesResult, error) {
values := make(map[string]*sdk.InjectableValue)
for _, code := range req.Codes {
// Resolve each code from your external system
// Use req.Headers, req.Payload, req.InitData as needed
val := sdk.StringValue("resolved value")
values[code] = &val
}
return &sdk.ResolveInjectablesResult{Values: values}, nil
}engine.SetWorkspaceInjectableProvider(&MyProvider{})- i18n: Provider handles translations internally; return pre-translated
Label,Description, and groupName - Code Collisions: Provider codes must not conflict with registry injector codes (error on collision)
- Groups: Provider can define custom groups that merge with YAML-defined groups
- Error Handling: Return
(nil, error)for critical failures; useresult.Errorsfor non-critical
By default, render endpoints use OIDC authentication (panel provider + render_providers). For custom authentication (API keys, custom JWT, etc.), implement RenderAuthenticator.
- API key authentication for service-to-service calls
- Custom JWT validation (different from configured OIDC)
- Hybrid authentication (try OIDC, fallback to API key)
- Custom authorization logic before standard auth
type RenderAuthenticator interface {
// Authenticate validates the request and returns claims.
// Return (claims, nil) if valid.
// Return (nil, error) to reject with 401.
Authenticate(c *gin.Context) (*RenderAuthClaims, error)
}
type RenderAuthClaims struct {
UserID string // Caller identifier (required for audit/tracing)
Email string // Email (optional)
Name string // Name (optional)
Provider string // Name of the auth provider/method used
Extra map[string]any // Additional custom claims
}| Custom Auth Registered | Render Endpoints | Panel Endpoints |
|---|---|---|
| NO | Uses OIDC (panel + render_providers) | Uses panel OIDC |
| YES | Uses custom auth, ignores OIDC for render | Panel OIDC still works |
Panel OIDC always works for login/UI, independent of custom render auth.
type APIKeyAuthenticator struct {
validKeys map[string]string // key → userID
}
func (a *APIKeyAuthenticator) Authenticate(c *gin.Context) (*sdk.RenderAuthClaims, error) {
apiKey := c.GetHeader("X-API-Key")
if apiKey == "" {
return nil, errors.New("missing API key")
}
userID, ok := a.validKeys[apiKey]
if !ok {
return nil, errors.New("invalid API key")
}
return &sdk.RenderAuthClaims{
UserID: userID,
Provider: "api-key",
Extra: map[string]any{"api_key_prefix": apiKey[:8]},
}, nil
}
// Registration
engine.SetRenderAuthenticator(&APIKeyAuthenticator{
validKeys: map[string]string{
"sk_live_xxx": "service-account-1",
"sk_live_yyy": "service-account-2",
},
})type CustomJWTAuth struct {
secret []byte
}
func (a *CustomJWTAuth) Authenticate(c *gin.Context) (*sdk.RenderAuthClaims, error) {
tokenStr := extractBearerToken(c)
if tokenStr == "" {
return nil, errors.New("missing token")
}
claims, err := jwt.Parse(tokenStr, a.secret)
if err != nil {
return nil, err
}
return &sdk.RenderAuthClaims{
UserID: claims["sub"].(string),
Email: claims["email"].(string),
Provider: "custom-jwt",
}, nil
}Try Bearer token first, fallback to API key:
type HybridAuth struct {
oidcValidator *OIDCValidator
apiKeys map[string]string
}
func (a *HybridAuth) Authenticate(c *gin.Context) (*sdk.RenderAuthClaims, error) {
// Try Bearer token first
if token := c.GetHeader("Authorization"); strings.HasPrefix(token, "Bearer ") {
claims, err := a.oidcValidator.Validate(token[7:])
if err == nil {
return &sdk.RenderAuthClaims{
UserID: claims.Subject,
Email: claims.Email,
Provider: "oidc",
}, nil
}
}
// Fallback to API key
if key := c.GetHeader("X-API-Key"); key != "" {
if userID, ok := a.apiKeys[key]; ok {
return &sdk.RenderAuthClaims{
UserID: userID,
Provider: "api-key",
}, nil
}
}
return nil, errors.New("no valid credentials")
}Claims are stored in gin context with same keys as OIDC:
// In custom middleware or controller
userID, _ := c.Get("user_id")
email, _ := c.Get("user_email")
name, _ := c.Get("user_name")
provider, _ := c.Get("oidc_provider") // contains custom Provider name
// Extra claims from RenderAuthClaims.Extra
extra := middleware.GetRenderAuthExtra(c) // returns map[string]any or nilengine.SetRenderAuthenticator(&MyAuthenticator{})- Replaces OIDC for render: When registered, OIDC render_providers are ignored
- Panel unaffected: Panel OIDC always works for login/UI
- Same context keys: Claims stored using same keys as OIDC for compatibility
- Extra claims: Use
Extramap for custom claims, access viamiddleware.GetRenderAuthExtra(c)