Extending PIDL¶
This guide explains how to extend PIDL with custom renderers, validation rules, and exporters.
Custom Renderers¶
PIDL uses a Renderer interface that you can implement to create custom diagram output formats.
The Renderer Interface¶
package render
import (
"io"
"github.com/grokify/pidl"
)
type Renderer interface {
// Render writes the diagram to the writer.
Render(w io.Writer, p *pidl.Protocol) error
// RenderString returns the diagram as a string.
RenderString(p *pidl.Protocol) (string, error)
// Format returns the output format name.
Format() Format
}
Implementing a Custom Renderer¶
Here's a complete example of a custom Markdown renderer:
package render
import (
"fmt"
"io"
"strings"
"github.com/grokify/pidl"
)
// MyMarkdownRenderer generates a custom Markdown format.
type MyMarkdownRenderer struct {
// Title includes the protocol name as header.
Title bool
// ShowDescriptions includes flow descriptions.
ShowDescriptions bool
}
// NewMyMarkdown creates a new renderer with defaults.
func NewMyMarkdown() *MyMarkdownRenderer {
return &MyMarkdownRenderer{
Title: true,
ShowDescriptions: true,
}
}
// Format returns the format identifier.
func (r *MyMarkdownRenderer) Format() Format {
return Format("my-markdown")
}
// Render writes the Markdown to the writer.
func (r *MyMarkdownRenderer) Render(w io.Writer, p *pidl.Protocol) error {
s, err := r.RenderString(p)
if err != nil {
return err
}
_, err = w.Write([]byte(s))
return err
}
// RenderString returns the Markdown as a string.
func (r *MyMarkdownRenderer) RenderString(p *pidl.Protocol) (string, error) {
var sb strings.Builder
// Write title
if r.Title {
sb.WriteString(fmt.Sprintf("# %s\n\n", p.ProtocolMeta.Name))
}
// Write entities
sb.WriteString("## Entities\n\n")
for _, e := range p.Entities {
sb.WriteString(fmt.Sprintf("- **%s** (%s)\n", e.Name, e.Type))
}
sb.WriteString("\n")
// Write flows
sb.WriteString("## Flows\n\n")
for i, f := range p.Flows {
sb.WriteString(fmt.Sprintf("%d. %s ā %s: %s\n",
i+1, f.From, f.To, f.Label))
if r.ShowDescriptions && f.Description != "" {
sb.WriteString(fmt.Sprintf(" - %s\n", f.Description))
}
}
return sb.String(), nil
}
Registering a Custom Renderer¶
To integrate your renderer with the render.New() factory:
// In your application code
func init() {
// Register with the format parser
render.RegisterFormat("my-markdown", func() render.Renderer {
return NewMyMarkdown()
})
}
Or use it directly:
Best Practices for Renderers¶
- Support configuration options - Use struct fields for customization
- Handle both io.Writer and string output - Implement both methods
- Check protocol kind - Process specs may need different handling
- Handle empty protocols gracefully - Don't panic on missing data
- Consider step types - For process specs, use
StepTypefor visual differentiation
Custom Validation Rules¶
PIDL's security analysis system uses rules that can be extended.
The SecurityRule Structure¶
type SecurityRule struct {
// ID is a unique identifier (e.g., "SEC001")
ID string
// Name is a human-readable name
Name string
// Description explains what the rule checks
Description string
// Category groups related rules
Category string
// Severity indicates the risk level
Severity Severity
// Check is the function that evaluates the rule
Check func(p *pidl.Protocol, opts *AnalysisOptions) []Risk
}
Implementing a Custom Rule¶
package analyze
import "github.com/grokify/pidl"
// MyCustomRule checks for a specific security pattern.
var MyCustomRule = SecurityRule{
ID: "CUSTOM001",
Name: "Unencrypted Sensitive Data",
Description: "Sensitive data should use encryption",
Category: CategoryDataProtection,
Severity: SeverityHigh,
Check: func(p *pidl.Protocol, opts *AnalysisOptions) []Risk {
var risks []Risk
for _, flow := range p.Flows {
// Check if flow has sensitive data
if flow.Annotation != nil && flow.Annotation.DataClassification == "confidential" {
// Check if encryption is required
if !hasSecurityRequirement(flow, "encryption") {
risks = append(risks, Risk{
RuleID: "CUSTOM001",
Severity: SeverityHigh,
Category: CategoryDataProtection,
Location: fmt.Sprintf("flow %sā%s", flow.From, flow.To),
Message: "Confidential data transmitted without encryption",
Remediation: "Add security requirement: encryption",
})
}
}
}
return risks
},
}
func hasSecurityRequirement(flow pidl.Flow, req string) bool {
if flow.Security == nil {
return false
}
for _, r := range flow.Security.Requires {
if r == req {
return true
}
}
return false
}
Registering Custom Rules¶
// Add your rule to the analyzer
func init() {
analyze.RegisterRule(MyCustomRule)
}
// Or create a custom analyzer
analyzer := analyze.NewAnalyzer()
analyzer.AddRule(MyCustomRule)
results := analyzer.Analyze(protocol)
Rule Categories¶
| Category | Description |
|---|---|
CategoryAuthentication |
Identity verification issues |
CategoryAuthorization |
Access control issues |
CategoryTokenSecurity |
Token handling issues |
CategoryDataProtection |
Data security issues |
CategoryNetworkSecurity |
Network boundary issues |
CategoryProcessSecurity |
Process spec issues |
Severity Levels¶
| Severity | When to Use |
|---|---|
SeverityCritical |
Immediate security threat |
SeverityHigh |
Significant security risk |
SeverityMedium |
Moderate security concern |
SeverityLow |
Minor issue or best practice |
SeverityInfo |
Informational finding |
Custom Exporters¶
Workflow exporters convert PIDL process specs to executable formats.
The Exporter Pattern¶
package export
import "github.com/grokify/pidl"
type MyExporter struct {
// Configuration options
OutputFormat string
}
func NewMyExporter() *MyExporter {
return &MyExporter{
OutputFormat: "default",
}
}
func (e *MyExporter) Export(p *pidl.Protocol) (string, error) {
// Validate it's a process spec
if p.ProtocolMeta.Kind != pidl.ProtocolKindProcess {
return "", fmt.Errorf("only process specs supported")
}
// Build your output format
var sb strings.Builder
for _, entity := range p.Entities {
if entity.StepType == "" {
continue
}
// Generate code for this step
sb.WriteString(e.generateStep(entity))
}
return sb.String(), nil
}
func (e *MyExporter) generateStep(entity pidl.Entity) string {
// Step-type-specific code generation
switch entity.StepType {
case pidl.StepTypeDeterministic:
return fmt.Sprintf("// Deterministic: %s\n", entity.Name)
case pidl.StepTypeLLM:
return fmt.Sprintf("// LLM: %s\n", entity.Name)
case pidl.StepTypeHuman:
return fmt.Sprintf("// Human: %s\n", entity.Name)
default:
return fmt.Sprintf("// Step: %s\n", entity.Name)
}
}
Using Parallel Block Detection¶
import "github.com/grokify/pidl"
func (e *MyExporter) Export(p *pidl.Protocol) (string, error) {
// Detect parallel execution blocks
blocks := pidl.DetectParallelBlocks(p)
for _, block := range blocks {
fmt.Printf("Parallel block: %s\n", block.ID)
fmt.Printf(" Join condition: %s\n", block.JoinCondition)
for _, branch := range block.Branches {
fmt.Printf(" Branch: %s\n", branch.EntityID)
}
}
// Use execution graph for ordering
graph := pidl.AnalyzeExecutionGraph(p)
for _, stage := range graph.Stages {
fmt.Printf("Stage %s: %v\n", stage.ID, stage.Steps)
}
return "", nil
}
Using Cost Information¶
import "github.com/grokify/pidl"
func (e *MyExporter) Export(p *pidl.Protocol) (string, error) {
// Analyze costs
analysis := pidl.AnalyzeProcessCosts(p)
fmt.Printf("Total estimated cost: $%.4f\n",
analysis.TotalEstimate.ExpectedCost)
for stepType, cost := range analysis.CostByType {
fmt.Printf(" %s: $%.4f\n", stepType, cost)
}
return "", nil
}
Schema Validation Extension¶
You can extend schema validation with custom validators.
Custom Port Validator¶
import "github.com/grokify/pidl"
type CustomValidator struct {
*pidl.PortSchemaValidator
customRules []func(port pidl.DataPort, data interface{}) error
}
func NewCustomValidator() *CustomValidator {
return &CustomValidator{
PortSchemaValidator: pidl.NewPortSchemaValidator(),
}
}
func (v *CustomValidator) AddRule(rule func(pidl.DataPort, interface{}) error) {
v.customRules = append(v.customRules, rule)
}
func (v *CustomValidator) ValidatePortData(port pidl.DataPort, data interface{}) (*pidl.SchemaValidationResult, error) {
// Run base validation
result, err := v.PortSchemaValidator.ValidatePortData(port, data)
if err != nil {
return nil, err
}
// Run custom rules
for _, rule := range v.customRules {
if err := rule(port, data); err != nil {
result.Valid = false
result.Errors = append(result.Errors, pidl.SchemaValidationError{
Path: port.Name,
Message: err.Error(),
})
}
}
return result, nil
}
Testing Extensions¶
Testing Renderers¶
func TestMyRenderer(t *testing.T) {
p := &pidl.Protocol{
ProtocolMeta: pidl.ProtocolMeta{
ID: "test",
Name: "Test Protocol",
},
Entities: []pidl.Entity{
{ID: "a", Name: "Service A"},
{ID: "b", Name: "Service B"},
},
Flows: []pidl.Flow{
{From: "a", To: "b", Label: "Request"},
},
}
r := NewMyMarkdown()
output, err := r.RenderString(p)
if err != nil {
t.Fatalf("Render failed: %v", err)
}
if !strings.Contains(output, "# Test Protocol") {
t.Error("expected title in output")
}
}
Testing Rules¶
func TestMyCustomRule(t *testing.T) {
p := &pidl.Protocol{
// ... protocol with security issue
}
opts := analyze.DefaultAnalysisOptions()
risks := MyCustomRule.Check(p, opts)
if len(risks) == 0 {
t.Error("expected rule to find risk")
}
}
See Also¶
- Process Specifications - Process spec fundamentals
- Workflow Exports - Built-in exporters
- Go Library Reference - Full API documentation