Extending goa with Plugin DSLs
goa plugins make it possible to create new DSL and accompanying generators. Since DSLs are nothing more than Go functions their syntax is completely open. Plugins also make it possible to generate new kinds of outputs from any DSL. For example new generators that target different languages from the goa API design language.
The combination of these two dimensions make goa plugins very powerful allowing to create new DSLs and generators that target any use case where generating code, documentation, configuration or anything else is useful.
Another interesting aspect is that DSLs complete each other. It is not necessary to write a new DSL from scratch, one may add a few new keywords to the built-in API DSL for example. The new DSL implementation can create new artifacts or change how existing artifacts are created.
Extending the DSL
goa DSLs consist of Go package functions that construct recursive data structures called definitions. The roots of these definitions must be recorded in the goa dslengine package via the Register function. The actual content of the definitions is completely up to you. The generators use them to create the artifacts so they should contain all the required information to do the generation.
First Exposure
Here is an example of a function defining a new DSL function Model
that accepts a name and creates
a ModelDefinition
data structure:
package modeldsl
// ModelDefinition describes a model defined via DSL.
type ModelDefinition struct {
// Name is the model name
Name string
}
// The definition built by the DSL
var model *ModelDefinition
// Model creates a new model with the given name.
func Model(name string) {
model = &ModelDefinition{Name: name}
}
As you can see, nothing special - just a package function Model
that builds a data structure
ModelDefinition
and stores it in a package variable.
A common pattern used to implement expressive DSLs is to accept an anonymous function as last
argument of the DSL function. This function argument can then call other DSL functions to build
up the data structure. Expanding on the example above, let’s add a new field to the model - say
Type
which can be one of "TEXT"
or "INTEGER"
. Let’s introduce a new DSL function Type
that
makes it possible to specify the value for that field:
package modeldsl
// ModelDefinition describes a model.
type ModelDefinition struct {
// Name is the model name
Name string
// Type is the model type
Type string
}
// The definition built by the DSL
var model *ModelDefinition
// Model creates a new model with the given name.
func Model(name string, dsl func()) {
model = &ModelDefinition{Name: name}
dsl()
}
// Type sets the type of the model being built.
func Type(value string) {
if value != "TEXT" && value != "INTEGER" {
panic("invalid DSL!")
}
model.Type = value
}
With the above our users can now describe their models using something like:
var _ = Model("bottle", func() {
Type("TEXT")
}
Looking good! Another common pattern used to build DSLs are variadic functions. Such functions make
it possible to define optional arguments. Expanding again our DSL, we now want to allow users to
define models with type VARCHAR
however that type also requires an extra length value. We could
model this by allowing an extra - optional - argument to our Type
function:
// Type sets the type of the model being built.
func Type(value string, extra ...interface{}) {
if value != "TEXT" && value != "INTEGER" && value != "VARCHAR" {
panic("invalid DSL!")
}
if value == "VARCHAR" {
if len(extra) == 0 {
panic("invalid DSL!")
}
length, ok := extra[0].(int)
if !ok {
panic("invalid DSL!")
}
model.TypeLen = length // TypeLen is a int field on ModelDefinition
}
model.Type = value
}
OK, stepping back a bit: DSLs are nothing more than package functions that build package variables.
There are a few common patterns used to make the DSLs easier to use such as anonymous functions as
argument or variadic functions. The example above also used interface{}
as argument type making
it possible for the DSL user to pass in any value which can be also very helpful to build
powerful DSLs.
Plugging Into the goa DSL Engine
While the DSL above works it is clearly fairly limited, it builds only one data structure and doesn’t handle errors very gracefully. Errors and error reporting is crucial when writing a DSL as they are the first thing users experience. goa comes with a DSL engine that helps with building the recursive data structures and provide a standard framework for recording and reporting errors.
DSL Engine Execution Flow
Before jumping into the details of implementing a DSL on top of the goa engine it is helpful to understand the steps involved during execution at a high level. Backing off: what is executing a DSL? two things:
- The
Go
runtime evaluates the global variables when the generator program loads. This causes all the DSL functions called that way to get run and produce a first set of definitions. - Some of these functions may save anonymous functions in the definition data structures for deferred execution. The goa DSL engine runs these functions that may create and initialize additional definitions.
In the example above the Type
function ran the provided dsl
anonymous function right away. This
is not always possible as the code may rely on other definitions being created first. In this case
a common pattern consists of storing the dsl function inside the definition itself. The goa DSL
engine then takes care of running that function once all “top level” definitions have been created.
The flow of execution is thus:
- The
Go
runtime evaluates the design package variables which create the first set of definitions. - The goa DSL engine traverses this first set and runs embedded anonymous function (second pass).
- The goa DSL engine makes another pass to validate the definitions.
- The goa DSL engine makes a final pass to finalize the definitions.
Validation and finalization are discussed in more details below, they do what you would expect though which is validating that definitions are consistent, not missing information etc. and giving the opportunity to massage their content before the generators consume them.
A final note on the execution flow: if errors happen at any of the stages then the execution is stopped after that stage completes. This allows reporting multiple errors at once while avoiding to start the execution of a stage with invalid data.
DSL Definitions
The definitions built by a DSL executed by the goa engine must all implement the dslengine
Definition interface. This
interface defines a single method Context()
which returns a string used by the engine to build
error messages. The value of the string should help users understand where the error occurred. In
the example above the method could return the model name.
The dslengine
package defines 4 additional interfaces that definitions may optionally implement:
The Source interface must be
implemented by definitions that are built with a DSL function that accepts an anonymous function
as last argument. The DSL()
function returns this anonymous function so that the goa DSL engine
may execute it in phase 2.
The Validate
interface exposes the Validate()
method which is called by the goa DSL engine once
all the definitions have been built - in phase 3. The implementation must return a meaningful error
in case the definition is inconsistent or missing information (i.e. the user didn’t use the DSL
correctly). In the example above the check for whether a type has the right value could be done in
the Validate()
function.
The Finalize function is the function called in phase 4. It may set default values in definition fields for example or denormalize fields into data structures that are easier to consume by the generators.
Going back to the example, here is how the ModelDefinition
data structure could be implemented
to take advantage of the DSL engine:
// ModelDefinition describes a model.
type ModelDefinition struct {
// dsl that initializes the model
dsl func()
// Name is the model name
Name string
// Type is the model type
Type string
// TypeLen is the len of VARCHAR types
TypeLen int
// GenType is used by the code generator
GenType string
}
// Context returns the part of the error message used to identify the model.
func (m *ModelDefinition) Context() string {
return fmt.Sprintf("model %s", m.Name)
}
// Source returns the user defined DSL.
func (m *ModelDefinition) DSL() func() {
return m.dsl
}
// Validate makes sure the model type is correct.
func (m *ModelDefinition) Validate() error {
if m.Type != "TEXT" && m.Type != "INTEGER" && m.Type != "VARCHAR" {
return fmt.Errorf("invalid model type %#v, must be one of TEXT, INTEGER or VARCHAR", m.Type)
}
if m.Type == "VARCHAR" {
if m.TypeLen == 0 {
return fmt.Errorf("invalid VARCHAR length, must be greater than 0 and lesser than 256")
}
}
return nil
}
// Finalize computes the type name and stores it for the generator to use.
func (m *ModelDefinition) Finalize() {
if m.Type == "VARCHAR" {
m.GenType = fmt.Sprintf("VARCHAR(%d)", m.TypeLen)
} else {
m.GenType = m.Type
}
}
Given the data structure above the goa DSL engine takes care of calling each method in the right order and also of reporting errors in a consistent way to the user.
The DSL Roots
The last piece of the puzzle when creating a new DSL is to define the DSL roots that the DSL
engine executes. The roots must be registerd with the dslengine
package using the
Register
function. DSL roots implement the Root
interface which exposes the IterateSets
method that the engine uses to iterate through the
definitions for execution. IterateSets
returns a slice of definitions allowing you to control the order in
which different types of definitions should be executed.
Imagine that the DSL in the example above was extended to make it possible to define the model
types rather than having a hard coded list. Presumably the type definitions would have to be run
first before the model definitions. This could be done by having the DSL root IterateSets
method
first return all type definitions then all model definitions:
package modeldsl
// Root is the data structure built by the DSL.
type Root struct {
Models []*ModelDefinition
Types []*TypeDefinition
}
// dslRoot contains the instance of Root built by the DSL
var dslRoot = &Root{}
// IterateSets first returns the type definitions then the model definitions.
func (r *Root) IterateSets(it dslengine.SetIterator) {
err := it(r.Types)
if err != nil {
return // The errors will be reported to the user by the DSL engine.
}
it(r.Models)
}
Tying it all together the DSL functions would initialize the dslRoot
variable, for example:
// Model creates a new model with the given name.
func Model(name string, dsl func()) {
model = &ModelDefinition{Name: name, dsl: dsl}
dslRoot.Models = append(dslRoot.Models, model)
}
Implementing DSL Functions
This section covers additional information helpful to implement DSL functions. These include error reporting, details on the engine context stack and additional helper functions that are useful when implementing DSLs.
Reporting Errors
Real DSL implementations should not rely on panic
to report user error. Instead the dslengine
package exposes a few functions that take care of reporting the error message together with
contextual information including the line and column numbers of where the error occurred.
The main error reporting function is
ReportError which works very
much like fmt.Errorf
. Simply provide a error message optionally formatted a la fmt.Sprintf
.
Another useful function is
InvalidArgError which as its
name indicates is meant to be called when a DSL function is called with incorrect arguments. This
happens for example when the Go function parameters use interface{}
to allow for an expressive
DSL. The DSL function code validates the input and uses
InvalidArgError to report
invalid values.
Another function is IncompatibleDSL. The use case for this function is covered in the paragraph below.
All the error reporting functions build user friendly error messages and append them to the
dslengine
package Errors variable.
The Context Stack
While not strictly required to implement a DSL it may be useful to be aware of how the engine
works to take full advantage of it. A particularly interesting concept is the context stack. When
the engine executes it adds the currently executed definition to the context stack - that’s the
definition that implements the Source
interface and whose DSL is being executed. The current
definition at the top of the context stack can be retrieved via the CurrentDefinition
function.
This is useful to validate that a DSL function is being called in the right context,
for example in the Model DSL above you wouldn’t want Type
to be called anywhere else but when
the Model
function is executed. The Type
function could check as follows:
// Type sets the type of the model being built.
func Type(value string, extra ...interface{}) {
model, ok := dslengine.CurrentDefinition().(*ModelDefinition)
if !ok {
panic("invalid use of the Type function")
}
// ... initialize "model"
}
In fact this case is so common that dslengine
exposes an IncompatibleDSL
function that DSL implementations can call in this case:
// Type sets the type of the model being built.
func Type(value string, extra ...interface{}) {
model, ok := dslengine.CurrentDefinition().(*ModelDefinition)
if !ok {
dslengine.IncompatibleDSL("Type")
return
}
// ... initialize "model"
}
IncompatibleDSL
uses the value returned by the current definition Context
method to build a
nice error message.
Running DSLs
The Execute function takes an anonymous function and a definition as argument. It appends the definition to the context stack and executes the DSL. If the DSL reports errors it returns false otherwise returns true. This function is useful to run DSL that don’t depend on anything else and can be run when the Go runtime does the initial evaluation of the global variables rather than when the goa DSL engine runs the source definitions.
Integrating DSLs
DSL implementations make take advantage of definitions defined by other DSLs by simply importing the
corresponding package. For example a DSL that extends the goa API DSL can use the same definitions
used by goa itself. This makes it possible to easily add new keywords and extend any DSL. As an
example let’s add a Cluster
function to the existing
API function of the goa DSL. This
function takes a string and initializes a ClusterDefinition
:
package clusterdsl
import "github.com/goadesign/goa/design/apidsl"
// ClusterDefinition defines a cluster.
type ClusterDefinition struct {
// Name of cluster
Name string
// API is the goa API DSL definition that belongs to cluster.
API *apidsl.APIDefinition
}
// Root is the DSL root.
type Root struct {
Clusters []*ClusterDefinition
}
// DSL root
var dslRoot &Root{}
// Register the DSL root with the DSL engine.
func init() {
dslengine.Roots = append(dslengine.Roots, dslRoot)
}
// Cluster defines a cluster for an API.
func Cluster(name string) {
api, ok := dslengine.CurrentDefinition().(*apidsl.APIDefinition)
if !ok {
dslengine.InvalidDSL("Cluster")
return
}
c := &ClusterDefinition{Name: name, API: api}
dslRoot.Clusters = append(dslRoot.Clusters, c)
}
The generator will have access to the goa API definition to generate the artifacts.
Attributes
The goa API DSL defines an attribute
definition data structure
which represents a generic field. Attributes have a type and may contain other attributes (if the
type is Object). They also define validation
rules (required fields and for each field additional validations). Attributes are useful to many
DSLs and having the ability to define attributes inside plugin data structures is a common scenario.
This scenario is supported via the use of the ContainerDefinition
interface. Basically, the
Attribute
DSL checks whether the including parent is an attribute itself, a media type or a
generic/plugin Container definition.
Wrapping-up
Writing a DSL consists of writing public package functions that build up definitions. The goa dslengine package provides a simple framework for executing the DSL and reporting errors to users. Definition data structures may implement a number of interfaces to plug into the engine and take advantage of the execution lifecycle. The DSL must register its root objects in the DSL engine package Roots variable for the engine to execute the DSL.
Now that we have a DSL and that it creates definitions the next step is to actually use them to produce outputs. Enter generators.