Understanding Plugins
Goa plugins extend and customize the functionality of your APIs. Whether you need to add rate limiting, integrate monitoring tools, or generate code in different languages, plugins provide a flexible way to enhance Goa’s capabilities. This guide will walk you through understanding and creating plugins, starting with the fundamentals and building up to advanced usage.
Plugin Basics
Before diving into the technical details, let’s understand what plugins can do and how they work with Goa. A plugin typically provides three main capabilities:
First, plugins add new design functions to Goa’s DSL. These functions let users configure
additional features in their API designs. For example, a rate limiting plugin might add
functions like RateLimit() and Burst() that users can call to configure request
limits:
var _ = Service("calculator", func() {
// Configure rate limiting using the plugin's DSL functions
RateLimit(100, func() { // Allow 100 requests...
Period("1m") // ...per minute
Burst(20) // ...with bursts up to 20
})
Method("add", func() {
// Regular Goa DSL continues here
Payload(func() {
Field(1, "a", Int)
Field(2, "b", Int)
})
Result(Int)
})
})
Second, plugins create custom expressions that store and validate this configuration. These expressions integrate with Goa’s internal representation of your API design, ensuring that all settings are valid and consistent.
Third, plugins generate additional code based on their configuration. This might include middleware, helper functions, or configuration files. For example, our rate limiting plugin would generate middleware code that enforces the configured limits:
// Generated rate limiting middleware
type calculatorRateMiddleware struct {
limiter *rate.Limiter
next Service
}
func NewRateMiddleware() Middleware {
// Create a rate limiter: 100 requests per minute, burst of 20
limiter := rate.NewLimiter(rate.Every(time.Minute), 100)
limiter.SetBurst(20)
return func(next Service) Service {
return &calculatorRateMiddleware{
limiter: limiter,
next: next,
}
}
}
This generated code seamlessly integrates with Goa’s standard output, requiring minimal setup from users.
Foundation: The Goa Design Language
To create effective plugins, you need to understand how Goa’s design language works. While it looks like regular Go code, Goa’s DSL (Domain-Specific Language) provides a structured way to define your services, methods, and their properties.
Here’s a simple example of Goa’s design language:
var _ = Service("calculator", func() {
Description("A basic calculator service")
Method("add", func() {
// Define the input parameters
Payload(func() {
Field(1, "a", Int, "First number to add")
Field(2, "b", Int, "Second number to add")
})
// Define the output
Result(Int, "The sum of a and b")
})
})
This code defines a calculator service with an addition method. Each function like
Service(), Method(), and Field() is part of Goa’s DSL. When Goa processes this
design, it creates an internal representation called an “expression tree”:
Service("calculator")
└── Method("add")
├── Payload
│ ├── Field("a")
│ └── Field("b")
└── Result(Int)
Creating New DSL Functions
When building a plugin, you’ll need to create DSL functions that users can call in their API designs. These functions often need to store and validate configuration, which is done through custom expressions. Let’s understand this process step by step.
Understanding Expressions
An expression represents a piece of your API design in Goa. When users write DSL functions, these functions create and configure expressions. Here’s how it works:
var _ = Service("calculator", func() { // Creates a ServiceExpr
Method("add", func() { // Creates a MethodExpr
Payload(func() { // Creates a PayloadExpr
Field(1, "x", Int) // Configures the payload
})
})
})
For your plugin, you’ll define custom expressions to store configuration. For example, a rate limiting plugin might define:
// RateExpr stores rate limiting configuration for a service
type RateExpr struct {
Service *expr.ServiceExpr // The service this applies to
Requests int // Requests per period
Period string // Time period (e.g., "1m")
Burst int // Maximum burst size
}
Expression Interfaces
Your expressions must implement certain interfaces to work with Goa’s design processing.
The most basic requirement is the Expression interface, which provides identification:
// Required for all expressions
type Expression interface {
// EvalName returns a descriptive name for error messages
EvalName() string // e.g., "service calculator"
}
Depending on your needs, you can implement additional interfaces:
// Optional - implement if your expression has child DSL functions
type Source interface {
DSL() func() // Returns the DSL function to execute
}
// Optional - implement if you need to prepare data before validation
type Preparer interface {
Prepare() // Called in the preparation phase
}
// Optional - implement if your expression needs validation
type Validator interface {
Validate() error // Called in the validation phase
}
// Optional - implement if you need post-validation processing
type Finalizer interface {
Finalize() // Called in the finalization phase
}
Here’s a complete example showing how these interfaces work together in our rate limiting plugin:
// RateExpr represents rate limiting configuration
type RateExpr struct {
Service *expr.ServiceExpr
Requests int
Period string
Burst int
// Internal state
prepared bool
dsl func()
}
// Required: Implement Expression interface
func (r *RateExpr) EvalName() string {
return fmt.Sprintf("rate limit for service %q", r.Service.Name)
}
// Optional: Implement Source if your expression has child DSL
func (r *RateExpr) DSL() func() {
return r.dsl // Returns the DSL function to configure this expression
}
// Optional: Implement Preparer for setup
func (r *RateExpr) Prepare() {
if !r.prepared {
// Set sensible defaults
if r.Period == "" {
r.Period = "1m"
}
if r.Burst == 0 {
r.Burst = r.Requests
}
r.prepared = true
}
}
// Optional: Implement Validator for validation
func (r *RateExpr) Validate() error {
errors := new(eval.ValidationErrors)
if r.Requests <= 0 {
errors.Add(r, "requests must be positive, got %d", r.Requests)
}
if _, err := time.ParseDuration(r.Period); err != nil {
errors.Add(r, "invalid period %q: %s", r.Period, err)
}
if len(errors.Errors) > 0 {
return errors
}
return nil
}
// Optional: Implement Finalizer for post-processing
func (r *RateExpr) Finalize() {
// Perform any final processing after validation
}
Creating DSL Functions
With your expressions defined, you can create DSL functions that users will call in their designs. These functions create and configure your expressions:
// RateLimit is a DSL function that creates and configures a RateExpr
func RateLimit(requests int, fn func()) {
if current := eval.Current(); current != nil {
if svc, ok := current.(*expr.ServiceExpr); ok {
// Create our expression
rate := &RateExpr{
Service: svc,
Requests: requests,
dsl: fn,
}
// Execute the DSL function to configure it
if eval.Execute(fn, rate) {
// Store it in the service's metadata
svc.Meta = append(svc.Meta, rate)
}
}
}
}
This pattern provides several benefits:
- Type-safe configuration storage
- Validation during design processing
- Clear error messages when something goes wrong
- Integration with Goa’s expression tree
The Eval Package: Goa’s Plugin Engine
Now that we understand expressions and DSL functions, let’s explore how Goa processes
them. The eval package is the engine that powers Goa’s plugin system, processing
designs in four phases:
Initial Execution: First, it runs all the DSL functions you’ve written, building up the expression tree that represents your API design.
Preparation: Next, it prepares the expressions, handling tasks like resolving inheritance between types and flattening nested structures.
Validation: Then, it validates all expressions to ensure they follow the rules of the DSL and make logical sense.
Finalization: Finally, it performs any necessary cleanup, such as setting default values or resolving references between different parts of your design.
Let’s see this in action with our rate limiter plugin:
// In your design file:
var _ = Service("api", func() {
RateLimit(100, func() { // Creates a RateExpr
Period("1m") // Configures the period
Burst(20) // Sets burst size
})
})
// Behind the scenes, here's what happens:
// 1. Initial Execution
// - Creates a RateExpr with requests=100
// - Executes the DSL function, setting period="1m" and burst=20
// - Links the RateExpr to the ServiceExpr
// 2. Preparation
func (r *RateExpr) Prepare() {
if !r.prepared {
// Set default period if not specified
if r.Period == "" {
r.Period = "1m"
}
// Set default burst if not specified
if r.Burst == 0 {
r.Burst = r.Requests
}
r.prepared = true
}
}
// 3. Validation
func (r *RateExpr) Validate() error {
errors := new(eval.ValidationErrors)
// Validate requests
if r.Requests <= 0 {
errors.Add(r, "requests must be positive, got %d", r.Requests)
}
// Validate period format
if _, err := time.ParseDuration(r.Period); err != nil {
errors.Add(r, "invalid period %q: %s", r.Period, err)
}
// Validate burst size
if r.Burst < 0 {
errors.Add(r, "burst must be non-negative, got %d", r.Burst)
}
if len(errors.Errors) > 0 {
return errors
}
return nil
}
// 4. Finalization
func (r *RateExpr) Finalize() {
// Convert period to normalized form
if duration, err := time.ParseDuration(r.Period); err == nil {
r.normalizedPeriod = duration
}
// Ensure burst doesn't exceed requests
if r.Burst > r.Requests {
r.Burst = r.Requests
}
}
This example shows how the eval package orchestrates the design processing:
During Initial Execution, it processes the DSL functions in order:
- First
RateLimit(100)creates the base expression - Then
Period("1m")andBurst(20)configure it - The expression is attached to its parent service
- First
In the Preparation phase, it sets up defaults:
- Sets default period to “1m” if not specified
- Sets default burst to match requests if not specified
- Marks the expression as prepared to avoid duplicate work
During Validation, it checks all the rules:
- Ensures requests is positive
- Validates period format
- Checks burst is non-negative
- Collects all errors before reporting them
Finally, in Finalization, it:
- Normalizes the period to a duration
- Adjusts burst to not exceed requests
- Resolves any cross-references
This process ensures that by the time code generation begins:
- All expressions are fully configured
- All values are validated
- All cross-references are resolved
- All defaults are set appropriately
Essential Eval Package Functions
To work with this system effectively, you’ll use several essential functions from the
eval package. Let’s explore each one in detail:
1. Current() Expression
The Current() function returns the expression that’s currently being processed in the
DSL execution. This is crucial for context-aware DSL functions:
// Get the expression currently being processed
func Current() Expression
// Example usage in a DSL function:
func RateLimit(requests int) {
// Get the current expression (should be a Service)
if current := eval.Current(); current != nil {
// Check if we're in the right context
if svc, ok := current.(*expr.ServiceExpr); ok {
// We're inside a Service definition
// ... configure rate limiting for this service
} else {
// Wrong context - RateLimit must be used within a Service
eval.ReportError("RateLimit must be used within a Service")
}
}
}
This function is particularly useful when:
- Validating the context where your DSL function is used
- Accessing the parent expression that contains your configuration
- Mutating the parent expression (e.g. to attach sub-expressions)
2. Execute(fn func(), def Expression) bool
The Execute function runs a DSL function in the context of a specific expression. It
handles the setup and cleanup of the execution context:
// Execute a DSL function in the context of an expression
// Returns true if execution was successful
func Execute(fn func(), def Expression) bool
// Example usage:
func RateLimit(requests int, fn func()) {
if current := eval.Current(); current != nil {
if svc, ok := current.(*expr.ServiceExpr); ok {
// Create our configuration expression
rate := &RateExpr{
Service: svc,
Requests: requests,
}
// Execute the DSL function with our expression as context
if eval.Execute(fn, rate) {
// DSL executed successfully, store the configuration
svc.Meta = append(svc.Meta, rate)
}
// Note: if Execute returns false, an error was already reported
}
}
}
// Used like this:
var _ = Service("api", func() {
RateLimit(100, func() {
Period("1m")
Burst(20)
})
})
Key points about Execute:
- It temporarily sets the provided expression as the current expression
- It runs the DSL function in this context
- It restores the previous context when done
- It returns false if any errors occurred during execution
3. Error Reporting Functions
The eval package provides several functions for reporting errors during DSL execution:
ReportError(fm string, vals …any)
ReportError is used to report errors during DSL execution. It formats the error message
using the provided format string and values and automatically wraps it with the current
expression context:
// Report an error during DSL execution
func ReportError(fm string, vals ...any)
Example usage:
func Period(duration string) {
if rate, ok := eval.Current().(*RateExpr); ok {
if _, err := time.ParseDuration(duration); err != nil {
eval.ReportError(
"invalid duration %q: must be a valid duration (e.g., '1m', '1h')",
duration)
}
rate.Period = duration
}
}
When used in a design like this:
var _ = Service("orders", func() {
RateLimit(100, func() {
Period("2x") // Invalid duration
})
})
// The error output will be:
// /path/to/design/design.go:42: rate limit for service "orders": invalid duration "2x": must be a valid duration (e.g., '1m', '1h')
//
// The error message includes:
// - The file and line number where the error occurred
// - The expression context ("rate limit for service 'orders'")
// - The specific error message
// - Helpful guidance for fixing the issue
IncompatibleDSL()
IncompatibleDSL reports that a DSL function was used in the wrong context. This is a
convenience function for a common error case:
// IncompatibleDSL should be called by DSL functions when they are invoked in an
// incorrect context (e.g. "Params" in "Service").
func IncompatibleDSL() {
ReportError("invalid use of %s", caller())
}
Here’s how to use it in your DSL functions:
func Burst(n int) {
if rate, ok := eval.Current().(*RateExpr); ok {
rate.Burst = n
} else {
// Burst() was called outside of a RateLimit block
eval.IncompatibleDSL()
}
}
When used in an invalid context, like this:
var _ = Service("orders", func() {
Burst(20) // Error: called outside RateLimit
})
It produces an error message like:
/path/to/design/design.go:42: invalid use of Burst
The error indicates:
- The file and line where the DSL function was incorrectly used
- The name of the function that was used in the wrong context
This is particularly useful when:
- A DSL function must be used within a specific parent (e.g.,
BurstwithinRateLimit) - The current expression is not of the expected type
- A function requires specific context that isn’t present
4. Register(r Root) error
Register adds a new root expression to the DSL. Root expressions are the entry points
for your DSL and control the execution order:
// Register a new root expression
func Register(r Root) error
// Example of a root expression:
type RateLimitRoot struct {
*expr.RootExpr
// Additional fields specific to your plugin
}
// Implement the Root interface
func (r *RateLimitRoot) WalkSets(w eval.SetWalker) {
// Define the order of expression evaluation
w.Walk(r.Services)
}
func (r *RateLimitRoot) DependsOn() []eval.Root {
// Specify dependencies on other plugins
return []eval.Root{
&security.Root{},
}
}
func (r *RateLimitRoot) Packages() []string {
// Return import paths needed by generated code
return []string{
"golang.org/x/time/rate",
}
}
// Register the root in your plugin's init function
func init() {
root := &RateLimitRoot{
RootExpr: &expr.RootExpr{},
}
if err := eval.Register(root); err != nil {
panic(err) // or handle error appropriately
}
}
Important aspects of root expressions:
- They control the order of DSL execution through
WalkSets - They declare dependencies on other plugins
- They specify required packages for generated code
- They’re typically registered during package initialization
These functions work together to provide a robust framework for DSL execution:
Registersets up your plugin’s root expressionCurrentandExecutemanage the execution contextReportErrorandIncompatibleDSLhandle error cases- The root expression controls the overall execution flow
Creating Your First Plugin
Let’s put our knowledge into practice by creating a rate limiting plugin. We’ll build it step by step, explaining each component and its role in the plugin system.
Setting Up the Project
First, create a new directory for your plugin with this structure:
ratelimit/
├── dsl/
│ ├── dsl.go # Your DSL functions (RateLimit, Period, etc.)
│ └── types.go # Expression types for storing configuration
├── generate.go # Code generation logic
├── plugin.go # Plugin registration
└── templates/ # Code templates for generation
└── middleware.go.tmpl
This structure separates concerns:
- The
dslpackage contains the functions users will call in their designs - Expression types store and validate the configuration
- Code generation logic produces the actual middleware
- Templates define how the generated code will look
Step 1: Creating the DSL
Let’s start with the DSL functions in dsl/dsl.go. These are the functions that users
will call in their API designs:
package dsl
import (
"goa.design/goa/v3/eval"
"goa.design/goa/v3/expr"
)
// RateLimit defines rate limiting configuration for a service.
// Example:
//
// var _ = Service("calculator", func() {
// RateLimit(100, func() { // 100 requests...
// Period("1m") // ...per minute
// Burst(20) // ...with bursts up to 20
// })
// })
func RateLimit(requests int, fn func()) {
// Get the current expression being processed
if current := eval.Current(); current != nil {
// Check if we're in a Service context
if svc, ok := current.(*expr.ServiceExpr); ok {
// Create our rate limit configuration
rate := &RateExpr{
Service: svc,
Requests: requests,
}
// Execute the DSL function to configure the rate limit
if eval.Execute(fn, rate) {
// Store our configuration in the service's metadata
svc.Meta = append(svc.Meta, rate)
}
} else {
eval.ReportError("RateLimit must be used within a Service")
}
}
}
// Period sets the time window for the rate limit.
// Valid time units are "s", "m", "h" (seconds, minutes, hours).
func Period(duration string) {
// Get the current expression (should be our RateExpr)
if rate, ok := eval.Current().(*RateExpr); ok {
rate.Period = duration
} else {
eval.IncompatibleDSL()
}
}
// Burst sets the maximum number of requests allowed to exceed the rate limit.
func Burst(n int) {
if rate, ok := eval.Current().(*RateExpr); ok {
rate.Burst = n
} else {
eval.IncompatibleDSL()
}
}
Step 2: Defining Expression Types
Next, in dsl/types.go, we define the types that store our configuration:
package dsl
import (
"time"
"goa.design/goa/v3/eval"
"goa.design/goa/v3/expr"
)
// RateExpr stores rate limiting configuration for a service.
type RateExpr struct {
// The service this rate limit applies to
Service *expr.ServiceExpr
// Number of allowed requests
Requests int
// Time period (e.g., "1m", "1h")
Period string
// Maximum burst size
Burst int
}
// EvalName returns a descriptive name for error messages
func (r *RateExpr) EvalName() string {
return "Rate limit for " + r.Service.Name
}
// Validate ensures the configuration is valid
func (r *RateExpr) Validate() error {
errors := new(eval.ValidationErrors)
// Requests must be positive
if r.Requests <= 0 {
errors.Add(r, "requests must be positive, got %d", r.Requests)
}
// Period must be a valid duration
if _, err := time.ParseDuration(r.Period); err != nil {
errors.Add(r, "invalid period format %q, use 's', 'm', or 'h'", r.Period)
}
// Burst must be non-negative
if r.Burst < 0 {
errors.Add(r, "burst must be non-negative, got %d", r.Burst)
}
if len(errors.Errors) > 0 {
return errors
}
return nil
}
Step 3: Implementing Code Generation
The code generation function in generate.go creates the actual middleware. When you run goa gen, Goa calls each plugin’s Generate function to produce the necessary code files. Here’s how it works:
// Generate is called by Goa during code generation. It receives:
// - genpkg: The package path where generated code will be placed
// - roots: Array of root expressions containing the complete API design
// It must return ALL files that should exist after generation, including unmodified ones.
// Files not returned will be removed, allowing plugins to delete files from previous generations.
func Generate(genpkg string, roots []eval.Root) ([]*codegen.File, error) {
var files []*codegen.File
for _, root := range roots {
if r, ok := root.(*expr.RootExpr); ok {
// Generate middleware for each service with rate limiting
for _, svc := range r.Services {
if rate := findRateLimit(svc); rate != nil {
f := generateMiddleware(genpkg, svc, rate)
files = append(files, f)
}
}
}
}
return files, nil
}
// generateMiddleware creates the rate limiting middleware file
func generateMiddleware(genpkg string, svc *expr.ServiceExpr, rate *RateExpr) *codegen.File {
// Define where the generated file will go
path := filepath.Join(codegen.Gendir, "ratelimit",
codegen.SnakeCase(svc.Name)+".go")
// Prepare data for the template
data := map[string]interface{}{
"Service": svc,
"Rate": rate,
"Package": genpkg,
}
// Create a section from our template
section := &codegen.SectionTemplate{
Name: "ratelimit",
Source: middlewareT,
Data: data,
FuncMap: template.FuncMap{
"goifyName": codegen.Goify,
},
}
return &codegen.File{
Path: path,
SectionTemplates: []*codegen.SectionTemplate{section},
}
}
The code generation process follows these steps:
- When you run
goa gen, Goa processes all registered plugins in sequence - For each plugin, Goa calls its
Generatefunction with:- The target package path (
genpkg) - The complete API design (
roots)
- The target package path (
- Your plugin’s
Generatefunction:- Examines the design to find services using your plugin
- Creates appropriate code files using templates
- Must return ALL files that should exist, even unmodified ones
- Can remove files by not including them in the return value
- Goa manages the files:
- Creates or updates files returned by
Generate - Removes any previously generated files that weren’t returned
- Places all files in your project’s
gendirectory:
gen/ ├── calculator/ # Main service code ├── http/ # HTTP transport ├── cors/ # CORS plugin code └── ratelimit/ # Rate limiting code - Creates or updates files returned by
This process ensures that your plugin’s code generation integrates seamlessly with Goa’s standard output and provides complete control over file lifecycle, including the ability to remove files when they’re no longer needed.
Step 4: Creating the Template
The template in templates/middleware.go.tmpl defines how the generated code will look:
{{ define "ratelimit" }}
// Code generated by goa v3 ratelimit plugin; DO NOT EDIT.
package {{ .Package }}
import (
"context"
"time"
"golang.org/x/time/rate"
)
// {{ goifyName .Service.Name "middleware" }} implements rate limiting for the
// {{ .Service.Name }} service.
type {{ goifyName .Service.Name "middleware" }} struct {
limiter *rate.Limiter
next Service
}
// New{{ goifyName .Service.Name "middleware" }} creates a new rate limiting middleware.
func New{{ goifyName .Service.Name "middleware" }}() Middleware {
limiter := rate.NewLimiter(
rate.Every({{ .Rate.Period }}),
{{ .Rate.Requests }},
)
limiter.SetBurst({{ .Rate.Burst }})
return func(next Service) Service {
return &{{ goifyName .Service.Name "middleware" }}{
limiter: limiter,
next: next,
}
}
}
// Handle implements the middleware interface.
func (m *{{ goifyName .Service.Name "middleware" }}) Handle(ctx context.Context, next func(context.Context) error) error {
if err := m.limiter.Wait(ctx); err != nil {
return err
}
return next(ctx)
}
{{ end }}
Step 5: Registering the Plugin
Finally, register the plugin with Goa. There are three registration functions available, each affecting when your plugin runs relative to other plugins:
package ratelimit
import "goa.design/goa/v3/codegen"
// Option 1: Standard Registration (middle)
func init() {
// Registers the plugin to run in the middle, sorted alphabetically by name
codegen.RegisterPlugin("ratelimit", "gen", nil, Generate)
}
// Option 2: First Registration
func init() {
// Registers the plugin to run before other non-first plugins
codegen.RegisterPluginFirst("ratelimit", "gen", nil, Generate)
}
// Option 3: Last Registration (recommended for most plugins)
func init() {
// Registers the plugin to run after other non-last plugins
codegen.RegisterPluginLast("ratelimit", "gen", nil, Generate)
}
The registration functions take these parameters:
name: A unique identifier for your plugincmd: The Goa command this plugin works with (usually “gen”, can be “example”)pre: An optional preparation function (can be nil)p: The main generation function
Plugin Execution Order
Goa maintains three ordered lists of plugins:
- First plugins: Run before standard plugins (registered with
RegisterPluginFirst) - Standard plugins: Run in the middle (registered with
RegisterPlugin) - Last plugins: Run after standard plugins (registered with
RegisterPluginLast)
Within each list, plugins are sorted alphabetically by name. For example:
// These plugins run in this order:
codegen.RegisterPluginFirst("auth", "gen", nil, Generate) // 1. auth (first)
codegen.RegisterPluginFirst("cache", "gen", nil, Generate) // 2. cache (first)
codegen.RegisterPlugin("metrics", "gen", nil, Generate) // 3. metrics (standard)
codegen.RegisterPlugin("tracing", "gen", nil, Generate) // 4. tracing (standard)
codegen.RegisterPluginLast("cors", "gen", nil, Generate) // 5. cors (last)
codegen.RegisterPluginLast("ratelimit", "gen", nil, Generate) // 6. ratelimit (last)
Choosing the Right Registration Function
Choose your registration function based on your plugin’s dependencies and effects:
Use
RegisterPluginFirstwhen your plugin:- Needs to modify the design before other plugins see it
- Provides functionality that other plugins depend on
- Must run before specific built-in generators
Use
RegisterPlugin(standard) when your plugin:- Is independent of other plugins
- Doesn’t have specific ordering requirements
- Works with the default Goa-generated code
Use
RegisterPluginLastwhen your plugin:- Needs to see the final state after other plugins
- Modifies or wraps code generated by other plugins
- Adds cross-cutting concerns like middleware
For our rate limiting plugin, we should use RegisterPluginLast because:
- It generates middleware that wraps service endpoints
- It should run after the main service code is generated
- It doesn’t affect how other plugins generate their code
package ratelimit
import "goa.design/goa/v3/codegen"
func init() {
// Register as a last plugin since we're generating middleware
codegen.RegisterPluginLast("ratelimit", "gen", nil, Generate)
}
This ensures our rate limiting middleware can properly wrap any other middleware or handlers generated by other plugins.
Using Your Plugin
Now users can use your plugin in their designs:
package design
import (
. "goa.design/goa/v3/dsl"
. "path/to/ratelimit/dsl"
)
var _ = Service("calculator", func() {
RateLimit(100, func() {
Period("1m")
Burst(20)
})
Method("add", func() {
Payload(func() {
Field(1, "a", Int)
Field(2, "b", Int)
})
Result(Int)
})
})
When they run goa gen, your plugin will:
- Process the rate limit configuration
- Validate the settings
- Generate the middleware code
- Place it in the correct location in their project
Advanced Plugin Topics
Now that you understand the basics of creating plugins, let’s explore some advanced techniques that will help you build more sophisticated plugins.
Working with Expression Trees
When developing plugins, you often need to navigate and analyze the expression tree that Goa builds from the design. Here’s how to effectively work with expressions:
// Find methods that need validation in a service
func findMethodsToValidate(svc *expr.ServiceExpr) []*expr.MethodExpr {
var methods []*expr.MethodExpr
for _, method := range svc.Methods {
// Check if the method has a payload that needs validation
if method.Payload != nil && needsValidation(method.Payload) {
methods = append(methods, method)
}
// Check if the result needs validation
if method.Result != nil && needsValidation(method.Result) {
methods = append(methods, method)
}
}
return methods
}
// Check if an attribute needs validation
func needsValidation(attr *expr.AttributeExpr) bool {
// Check for validation rules in metadata
if meta := attr.Meta; meta != nil {
if _, ok := meta["validate"]; ok {
return true
}
}
// For objects, check each field
if obj, ok := attr.Type.(*expr.Object); ok {
for _, field := range *obj {
if needsValidation(field.Attribute) {
return true
}
}
}
return false
}
Context-Aware DSL Functions
Your DSL functions should be aware of their context and behave appropriately. Here’s how to create context-sensitive functions:
// MaxItems can be used in different contexts
func MaxItems(n int) {
switch current := eval.Current().(type) {
case *ArrayExpr:
// Used directly on an array type
current.MaxItems = n
case *ValidationExpr:
// Used within a validation block
if arr, ok := current.Target.Type.(*expr.Array); ok {
current.MaxItems = &n
} else {
eval.ReportError("MaxItems can only be used with array types")
}
default:
eval.IncompatibleDSL()
}
}
// Example usage:
var _ = Service("storage", func() {
Method("list", func() {
// Direct usage on an array
Payload(ArrayOf(String, func() {
MaxItems(100) // Limit array size
}))
// Usage in validation
Validate(func() {
MaxItems(50) // Different context, same function
})
})
})
Plugin Dependencies
Sometimes your plugin might depend on other plugins. Here’s how to handle dependencies:
// Root expression for a validation plugin
type ValidationRoot struct {
*RootExpr
}
// DependsOn indicates this plugin needs the security plugin
func (r *ValidationRoot) DependsOn() []eval.Root {
return []eval.Root{
// This plugin requires the security plugin
&security.Root{},
}
}
// Packages returns the import paths needed by this plugin
func (r *ValidationRoot) Packages() []string {
return []string{
"goa.design/plugins/v3/security",
"goa.design/plugins/v3/validation",
}
}
Advanced Error Handling
Error handling in plugins should be informative and helpful. Here’s how to create detailed error messages:
func (v *ValidationExpr) Validate() error {
errors := new(eval.ValidationErrors)
// Group related validations
if err := v.validateBasicRules(); err != nil {
if verr, ok := err.(*eval.ValidationErrors); ok {
errors.Merge(verr)
}
}
// Add context to errors
if v.Maximum != nil && v.Minimum != nil {
if *v.Maximum < *v.Minimum {
errors.Add(v,
"maximum (%d) cannot be less than minimum (%d)",
*v.Maximum, *v.Minimum)
}
}
// Validate nested expressions
for _, rule := range v.Rules {
if err := rule.Validate(); err != nil {
if verr, ok := err.(*eval.ValidationErrors); ok {
// Preserve error context when merging
errors.Merge(verr)
} else {
errors.Add(v, "invalid rule: %s", err)
}
}
}
if len(errors.Errors) > 0 {
return errors
}
return nil
}
// Helper for grouping related validations
func (v *ValidationExpr) validateBasicRules() error {
errors := new(eval.ValidationErrors)
// Check required fields
if v.Pattern != "" {
if _, err := regexp.Compile(v.Pattern); err != nil {
errors.Add(v, "invalid pattern %q: %s", v.Pattern, err)
}
}
return errors
}
Advanced Code Generation
For complex plugins, you might need to generate multiple files or handle different transport layers:
func Generate(genpkg string, roots []eval.Root) ([]*codegen.File, error) {
var files []*codegen.File
for _, root := range roots {
if r, ok := root.(*expr.RootExpr); ok {
// Generate service-specific files
for _, svc := range r.Services {
// Generate main service file
if f := generateService(genpkg, svc); f != nil {
files = append(files, f)
}
// Generate transport-specific code
if f := generateHTTP(genpkg, svc); f != nil {
files = append(files, f)
}
if f := generateGRPC(genpkg, svc); f != nil {
files = append(files, f)
}
// Generate documentation
if f := generateDocs(genpkg, svc); f != nil {
files = append(files, f)
}
}
}
}
return files, nil
}
// Generate transport-specific code
func generateHTTP(genpkg string, svc *expr.ServiceExpr) *codegen.File {
path := filepath.Join(codegen.Gendir, "http",
codegen.SnakeCase(svc.Name)+".go")
data := map[string]interface{}{
"Service": svc,
"Package": path.Base(genpkg),
}
sections := []*codegen.SectionTemplate{
{
Name: "http-handler",
Source: httpHandlerT,
Data: data,
FuncMap: template.FuncMap{
"routeName": func(m *expr.MethodExpr) string {
return codegen.Goify(m.Name, true) + "Handler"
},
},
},
{
Name: "http-client",
Source: httpClientT,
Data: data,
},
}
return &codegen.File{
Path: path,
SectionTemplates: sections,
}
}
These advanced techniques will help you create more sophisticated plugins that can:
- Navigate and analyze complex designs
- Provide context-aware DSL functions
- Handle dependencies between plugins
- Generate comprehensive error messages
- Produce multiple output files for different purposes
Best Practices for Plugin Development
Let’s explore best practices that will help you create high-quality, maintainable plugins. These guidelines are based on experience with real-world Goa plugins.
Design Principles
When designing your plugin’s interface, follow these principles:
Keep It Simple
- Focus on solving one problem well
- Make the most common use case the easiest to implement
- Provide sensible defaults for optional settings
Be Consistent with Goa
- Follow Goa’s DSL style and naming conventions
- Use similar patterns to Goa’s built-in functions
- Maintain consistency in error messages and documentation
Example of a well-designed DSL:
var _ = Service("orders", func() {
// Simple, common case
RateLimit(100)
// More complex case with options
RateLimit(100, func() {
Period("1m")
Burst(20)
})
})
Code Organization
Structure your plugin code for clarity and maintainability:
plugin-name/
├── dsl/
│ ├── dsl.go # Public DSL functions
│ ├── types.go # Expression types
│ └── internal.go # Internal helpers
├── generate/
│ ├── generate.go # Main generation logic
│ └── helpers.go # Generation helpers
├── templates/ # Code templates
│ ├── client.go.tmpl
│ └── server.go.tmpl
├── example/ # Example usage
│ └── design/
│ └── design.go
└── README.md # Clear documentation
Error Handling
Implement comprehensive error handling to help users fix issues quickly. Goa
provides a specialized ValidationErrors type for collecting and managing
validation errors:
// ValidationErrors collects multiple validation errors along with their contexts
type ValidationErrors struct {
Errors []error // The actual errors
Expressions []Expression // The expressions where errors occurred
}
// Example of using ValidationErrors in a validate function
func (r *RateExpr) Validate() error {
errors := new(eval.ValidationErrors)
// Add individual errors with context
if r.Requests <= 0 {
errors.Add(r, "requests must be positive, got %d", r.Requests)
}
// Validate nested configuration
if err := r.validatePeriod(); err != nil {
if verr, ok := err.(*eval.ValidationErrors); ok {
// Merge errors from nested validation
errors.Merge(verr)
} else {
// Add single error with context
errors.AddError(r, err)
}
}
if len(errors.Errors) > 0 {
return errors
}
return nil
}
// Helper function showing nested validation
func (r *RateExpr) validatePeriod() error {
errors := new(eval.ValidationErrors)
if r.Period != "" {
if _, err := time.ParseDuration(r.Period); err != nil {
// Add formats error with proper context
errors.Add(r,
"invalid period %q: must be a valid duration (e.g., '1m', '1h')",
r.Period)
}
}
return errors
}
The ValidationErrors type provides several key features:
Error Collection: Accumulates multiple errors during validation:
errors := new(eval.ValidationErrors) errors.Add(expr, "first error: %v", val1) errors.Add(expr, "second error: %v", val2)Context Preservation: Each error is associated with its expression:
// The error message includes the expression name: // "rate limit for service 'api': requests must be positive, got -1" errors.Add(rateExpr, "requests must be positive, got %d", requests)Error Merging: Combine errors from nested validations:
func (v *ValidationExpr) Validate() error { errors := new(eval.ValidationErrors) // Validate basic configuration if err := v.validateBasic(); err != nil { if verr, ok := err.(*eval.ValidationErrors); ok { errors.Merge(verr) // Merge nested validation errors } } // Validate each rule for _, rule := range v.Rules { if err := rule.Validate(); err != nil { if verr, ok := err.(*eval.ValidationErrors); ok { errors.Merge(verr) // Merge errors from each rule } else { errors.AddError(v, err) // Add single error } } } return errors }Flattened Error Messages: The
Error()method produces clear, structured output:// Output format: // rate limit for service 'api': requests must be positive, got -1 // rate limit for service 'api': invalid period "2x", use "s", "m", or "h"
Best practices for using ValidationErrors:
Create Early: Create the errors container at the start of validation
func (e *Expr) Validate() error { errors := new(eval.ValidationErrors) // ... validation logic ... }Add Context: Always provide the expression when adding errors
errors.Add(e, "value %v is invalid", value) // Good errors.AddError(e, fmt.Errorf("invalid")) // Also goodHandle Nested Validation: Properly merge errors from sub-validations
if err := subExpr.Validate(); err != nil { if verr, ok := err.(*eval.ValidationErrors); ok { errors.Merge(verr) } else { errors.AddError(e, err) } }Return Early: Return nil if no errors occurred
if len(errors.Errors) > 0 { return errors } return nil
This structured approach to error handling helps users understand and fix issues in their API designs by:
- Collecting all validation errors instead of stopping at the first one
- Providing clear context about where each error occurred
- Maintaining the relationship between errors and their expressions
- Producing well-formatted error messages
Code Generation
Follow these practices when generating code:
Use Templates Effectively
// Break down complex templates into smaller, focused sections sections := []*codegen.SectionTemplate{ { Name: "types", Source: typesT, Data: data, }, { Name: "encoders", Source: encodersT, Data: data, }, }Generate Clean Code
// Add clear comments in templates {{ define "types" }} // {{ .TypeName }} implements the rate limiting configuration. // It is safe for concurrent use. type {{ .TypeName }} struct { limiter *rate.Limiter config *Config } // Config stores the rate limiting parameters. type Config struct { Requests int // Maximum requests per period Period time.Duration // Time period for the limit Burst int // Maximum burst size } {{ end }}Include Documentation
// In templates, generate package documentation {{ define "header" }} // Package {{ .Package }} provides rate limiting functionality. // // It implements a token bucket algorithm to control request rates. // Usage: // limiter := New(100, time.Minute) // 100 requests per minute // if err := limiter.Wait(ctx); err != nil { // return err // } package {{ .Package }} {{ end }}
Testing
Implement comprehensive tests for your plugin:
func TestRateLimitDSL(t *testing.T) {
cases := []struct {
name string
design func()
wantErr bool
errMsg string
}{
{
name: "basic rate limit",
design: func() {
Service("test", func() {
RateLimit(100)
})
},
},
{
name: "invalid rate limit",
design: func() {
Service("test", func() {
RateLimit(-1)
})
},
wantErr: true,
errMsg: "requests must be positive",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Reset the design
eval.Reset()
// Run the test
err := eval.RunDSL(tc.design)
// Check results
if tc.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if !strings.Contains(err.Error(), tc.errMsg) {
t.Errorf("expected error containing %q, got %q",
tc.errMsg, err.Error())
}
} else if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
Documentation
Provide clear, comprehensive documentation:
README.md
- Clear description of the plugin’s purpose
- Installation instructions
- Basic usage examples
- Configuration options
- Common use cases
Code Comments
// RateLimit applies rate limiting to a service or method. // It allows specifying the maximum number of requests allowed per time period. // // Examples: // // var _ = Service("api", func() { // // Simple usage: 100 requests per minute // RateLimit(100) // // // Advanced usage: custom period and burst // RateLimit(100, func() { // Period("1m") // Burst(20) // }) func RateLimit(requests int, fn ...func()) { ... }Examples
- Provide working examples in the
exampledirectory - Include common use cases and advanced scenarios
- Add comments explaining key concepts
- Provide working examples in the
Conclusion
Plugins are a powerful way to extend Goa’s capabilities. By understanding the plugin architecture and following best practices, you can create robust plugins that enhance Goa’s code generation to meet your specific needs.
For real-world examples and inspiration, check out the official plugins repository.