Skip to content

Commit

Permalink
Created new custom functions code.
Browse files Browse the repository at this point in the history
Integrated into linting command, need to do the same for the other commands.

Now anyone can build and load custom functions without needing to modify vacuum source code.
  • Loading branch information
daveshanley committed Jul 14, 2022
1 parent 219a354 commit 26d8f7e
Show file tree
Hide file tree
Showing 13 changed files with 296 additions and 7 deletions.
10 changes: 8 additions & 2 deletions cmd/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ func GetLintCommand() *cobra.Command {
rulesetFlag, _ := cmd.Flags().GetString("ruleset")
silent, _ := cmd.Flags().GetBool("silent")

functionsFlag, _ := cmd.Flags().GetString("functions")

if !silent {
PrintBanner()
}
Expand Down Expand Up @@ -65,6 +67,8 @@ func GetLintCommand() *cobra.Command {
// default is recommended rules, based on spectral (for now anyway)
selectedRS := defaultRuleSets.GenerateOpenAPIRecommendedRuleSet()

customFunctions, _ := LoadCustomFunctions(functionsFlag)

// if ruleset has been supplied, lets make sure it exists, then load it in
// and see if it's valid. If so - let's go!
if rulesetFlag != "" {
Expand All @@ -75,6 +79,7 @@ func GetLintCommand() *cobra.Command {
pterm.Println()
return rsErr
}

selectedRS, rsErr = BuildRuleSetFromUserSuppliedSet(rsBytes, defaultRuleSets)
if rsErr != nil {
return rsErr
Expand All @@ -84,8 +89,9 @@ func GetLintCommand() *cobra.Command {
pterm.Info.Printf("Linting against %d rules: %s\n", len(selectedRS.Rules), selectedRS.DocumentationURI)
start := time.Now()
result := motor.ApplyRulesToRuleSet(&motor.RuleSetExecution{
RuleSet: selectedRS,
Spec: specBytes,
RuleSet: selectedRS,
Spec: specBytes,
CustomFunctions: customFunctions,
})

results := result.Results
Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().BoolP("time", "t", false, "Show how long vacuum took to run")
rootCmd.PersistentFlags().StringP("ruleset", "r", "", "Path to a spectral ruleset configuration")
rootCmd.PersistentFlags().StringP("functions", "f", "", "Path to custom functions")

rootCmd.AddCommand(GetLintCommand())
rootCmd.AddCommand(GetVacuumReportCommand())
Expand Down
18 changes: 18 additions & 0 deletions cmd/shared_functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ package cmd

import (
"fmt"
"github.com/daveshanley/vacuum/model"
"github.com/daveshanley/vacuum/plugin"
"github.com/daveshanley/vacuum/rulesets"
"github.com/pterm/pterm"
"github.com/pterm/pterm/putils"
Expand Down Expand Up @@ -43,3 +45,19 @@ func PrintBanner() {
pterm.Printf("version: %s | compiled: %s\n\n", Version, Date)
pterm.Println()
}

// LoadCustomFunctions will scan for (and load) custom functions defined as vacuum plugins.
func LoadCustomFunctions(functionsFlag string) (map[string]model.RuleFunction, error) {
// check custom functions
if functionsFlag != "" {
pm, err := plugin.LoadFunctions(functionsFlag)
if err != nil {
pterm.Error.Printf("Unable to open custom functions: %v\n", err)
pterm.Println()
return nil, err
}
pterm.Info.Printf("Loaded %d custom function(s) successfully.\n", pm.LoadedFunctionCount())
return pm.GetCustomFunctions(), nil
}
return nil, nil
}
19 changes: 17 additions & 2 deletions functions/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,18 @@ import (
"github.com/daveshanley/vacuum/functions/core"
openapi_functions "github.com/daveshanley/vacuum/functions/openapi"
"github.com/daveshanley/vacuum/model"
"github.com/daveshanley/vacuum/plugin"
"sync"
)

type customFunction struct {
functionHook plugin.FunctionHook
schemaHook plugin.FunctionSchema
}

type functionsModel struct {
functions map[string]model.RuleFunction
functions map[string]model.RuleFunction
customFunctions map[string]customFunction
}

// Functions is used to Query available functions loaded into vacuum
Expand All @@ -32,13 +39,17 @@ func MapBuiltinFunctions() Functions {

coreFunctionGrab.Do(func() {
var funcs map[string]model.RuleFunction
var customFuncs map[string]customFunction

if functionsSingleton != nil {
funcs = functionsSingleton.functions
customFuncs = functionsSingleton.customFunctions
} else {
funcs = make(map[string]model.RuleFunction)
customFuncs = make(map[string]customFunction)
functionsSingleton = &functionsModel{
functions: funcs,
functions: funcs,
customFunctions: customFuncs,
}
}

Expand Down Expand Up @@ -90,6 +101,10 @@ func MapBuiltinFunctions() Functions {
return functionsSingleton
}

func (fm functionsModel) RegisterCustomFunction(name string, function plugin.FunctionHook, schema plugin.FunctionSchema) {
fm.customFunctions[name] = customFunction{functionHook: function, schemaHook: schema}
}

func (fm functionsModel) GetAllFunctions() map[string]model.RuleFunction {
return fm.functions
}
Expand Down
16 changes: 13 additions & 3 deletions motor/rule_applicator.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ type ruleContext struct {
errors *[]error
index *model.SpecIndex
specInfo *model.SpecInfo
customFunctions map[string]model.RuleFunction
}

// RuleSetExecution is an instruction set for executing a ruleset. It's a convenience structure to allow the signature
// of ApplyRules to change, without a huge refactor. The ApplyRules function only returns a single error also.
type RuleSetExecution struct {
RuleSet *rulesets.RuleSet // The RuleSet in which to apply
Spec []byte // The raw bytes of the OpenAPI specification.

RuleSet *rulesets.RuleSet // The RuleSet in which to apply
Spec []byte // The raw bytes of the OpenAPI specification.
CustomFunctions map[string]model.RuleFunction // custom functions loaded from plugin.
}

// RuleSetExecutionResult returns the results of running the ruleset against the supplied spec.
Expand Down Expand Up @@ -136,6 +137,7 @@ func ApplyRulesToRuleSet(execution *RuleSetExecution) *RuleSetExecutionResult {
errors: &errors,
index: index,
specInfo: specInfo,
customFunctions: execution.CustomFunctions,
}
go runRule(ctx)
}
Expand Down Expand Up @@ -316,6 +318,14 @@ var lock sync.Mutex
func buildResults(ctx ruleContext, ruleAction model.RuleAction, nodes []*yaml.Node) *[]model.RuleFunctionResult {

ruleFunction := ctx.builtinFunctions.FindFunction(ruleAction.Function)
// not found, check if it's been registered as a custom function
if ruleFunction == nil {
if ctx.customFunctions != nil {
if ctx.customFunctions[ruleAction.Function] != nil {
ruleFunction = ctx.customFunctions[ruleAction.Function]
}
}
}

if ruleFunction != nil {

Expand Down
51 changes: 51 additions & 0 deletions plugin/plugin_loader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package plugin

import (
"github.com/pterm/pterm"
"io/ioutil"
"path/filepath"
"plugin"
"strings"
)

// LoadFunctions will load custom functions found in the supplied path
func LoadFunctions(path string) (*Manager, error) {

dirEntries, err := ioutil.ReadDir(path)
if err != nil {
return nil, err
}

pm := createPluginManager()

for _, entry := range dirEntries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".so") {
fPath := filepath.Join(path, entry.Name())

// found something
pterm.Info.Printf("Located custom function plugin: %s\n", fPath)

// let's try and open it.
p, e := plugin.Open(fPath)
if e != nil {
return nil, e
}

// look up the Boot function and store as a Symbol
var bootFunc plugin.Symbol
bootFunc, e = p.Lookup("Boot")
if err != nil {
return nil, err
}

// lets go pedro!
if bootFunc != nil {
bootFunc.(func(*Manager))(pm)
} else {
pterm.Error.Printf("Unable to boot plugin")
}
}
}

return pm, nil
}
29 changes: 29 additions & 0 deletions plugin/plugin_loader_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package plugin

import (
"github.com/stretchr/testify/assert"
"runtime"
"testing"
)

func TestLoadFunctions_Nowhere(t *testing.T) {
pm, err := LoadFunctions("nowhere")
assert.Nil(t, pm)
assert.Error(t, err)
}

func TestLoadFunctions(t *testing.T) {
pm, err := LoadFunctions("../model/test_files")
assert.NotNil(t, pm)
assert.NoError(t, err)
assert.Equal(t, 0, pm.LoadedFunctionCount())
}

func TestLoadFunctions_Sample(t *testing.T) {
pm, err := LoadFunctions("sample")
if runtime.GOOS != "windows" { // windows does not support this feature, at all.
assert.NotNil(t, pm)
assert.NoError(t, err)
assert.Equal(t, 0, pm.LoadedFunctionCount())
}
}
33 changes: 33 additions & 0 deletions plugin/plugin_manager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package plugin

import (
"github.com/daveshanley/vacuum/model"
"gopkg.in/yaml.v3"
)

type FunctionSchema func() model.RuleFunctionSchema
type FunctionHook func(nodes []*yaml.Node, context model.RuleFunctionContext) []model.RuleFunctionResult

type Manager struct {
customFunctions map[string]model.RuleFunction
}

func createPluginManager() *Manager {
return &Manager{
customFunctions: make(map[string]model.RuleFunction),
}
}

// RegisterFunction allows a custom function to be hooked in
func (pm *Manager) RegisterFunction(name string, ruleFunction model.RuleFunction) {
pm.customFunctions[name] = ruleFunction
}

// LoadedFunctionCount returns the number of available and ready to use functions.
func (pm *Manager) LoadedFunctionCount() int {
return len(pm.customFunctions)
}

func (pm *Manager) GetCustomFunctions() map[string]model.RuleFunction {
return pm.customFunctions
}
16 changes: 16 additions & 0 deletions plugin/plugin_manager_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package plugin

import (
"github.com/daveshanley/vacuum/functions/core"
"github.com/stretchr/testify/assert"
"testing"
)

func TestPluginManager_RegisterFunction(t *testing.T) {

pm := createPluginManager()

pm.RegisterFunction("defined", core.Defined{})
assert.Len(t, pm.GetCustomFunctions(), 1)

}
15 changes: 15 additions & 0 deletions plugin/sample/boot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package main

import "github.com/daveshanley/vacuum/plugin"

// Boot is called by the Manager when the module is located.
// all custom functions should be registered here.
func Boot(pm *plugin.Manager) {

sampleA := SampleRuleFunction_A{}
sampleB := SampleRuleFunction_B{}

// register custom functions with vacuum plugin manager.
pm.RegisterFunction(sampleA.GetSchema().Name, sampleA)
pm.RegisterFunction(sampleB.GetSchema().Name, sampleB)
}
32 changes: 32 additions & 0 deletions plugin/sample/sample_a.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package main

import (
"github.com/daveshanley/vacuum/model"
"gopkg.in/yaml.v3"
)

// SampleRuleFunction_A is an example custom rule that does nothing.
type SampleRuleFunction_A struct {
}

// GetSchema returns a model.RuleFunctionSchema defining the schema of the Defined rule.
func (s SampleRuleFunction_A) GetSchema() model.RuleFunctionSchema {
return model.RuleFunctionSchema{
Name: "uselessFunc",
}
}

// RunRule will execute the Sample rule, based on supplied context and a supplied []*yaml.Node slice.
func (s SampleRuleFunction_A) RunRule(nodes []*yaml.Node, context model.RuleFunctionContext) []model.RuleFunctionResult {

// return a single result, for a made up linting failure.
return []model.RuleFunctionResult{
{
Message: "this is a useless function that will always error out.",
StartNode: &yaml.Node{Line: 1, Column: 0},
EndNode: &yaml.Node{Line: 2, Column: 0},
Path: "$.i.do.not.exist",
Rule: context.Rule,
},
}
}
42 changes: 42 additions & 0 deletions plugin/sample/sample_b.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package main

import (
"fmt"
"github.com/daveshanley/vacuum/model"
"gopkg.in/yaml.v3"
)

// SampleRuleFunction_B is an example custom rule that checks only a single path exists.
type SampleRuleFunction_B struct {
}

// GetSchema returns a model.RuleFunctionSchema defining the schema of the Defined rule.
func (s SampleRuleFunction_B) GetSchema() model.RuleFunctionSchema {
return model.RuleFunctionSchema{
Name: "checkSinglePathExists",
}
}

// RunRule will execute the Sample rule, based on supplied context and a supplied []*yaml.Node slice.
func (s SampleRuleFunction_B) RunRule(nodes []*yaml.Node, context model.RuleFunctionContext) []model.RuleFunctionResult {

// get the index https://quobix.com/vacuum/api/spec-index/
index := context.Index

// get the paths node from the index.
paths := index.GetPathsNode()

// checks if there are more than two nodes present in the paths node, if so, more than one path is present.
if len(paths.Content) > 2 {
return []model.RuleFunctionResult{
{
Message: fmt.Sprintf("more than a single path exists, there are %v", len(paths.Content)/2),
StartNode: paths,
EndNode: paths,
Path: "$.paths",
Rule: context.Rule,
},
}
}
return nil
}
Loading

0 comments on commit 26d8f7e

Please sign in to comment.