diff --git a/internal/cli/cli.go b/internal/cli/cli.go index c6573fe..795a08f 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -15,20 +15,10 @@ import ( "github.com/spf13/viper" ) -type ApiCatalogRule struct { - Options map[string]any - Disable bool -} - -type ApiCatalogUserPlugin struct { - File string - Options map[string]any -} - type ApiCatalogConfig struct { Title string - Rules map[string]ApiCatalogRule - Plugins map[string]ApiCatalogUserPlugin + Rules map[string]pluginmanager.PluginUserOverride + Plugins pluginmanager.PluginConfFile } func Run() { @@ -67,8 +57,16 @@ func Run() { } // loading up the plugins and corresponding rules - pluginManager := pluginmanager.New(fr) - if err := pluginManager.LoadBuiltinPlugin(); err != nil { + pManager := pluginmanager.New(fr, apiType) + if err := pManager.LoadBuiltinPlugin(); err != nil { + log.Fatal(err) + } + + if err := pManager.LoadUserPlugins(config.Plugins); err != nil { + log.Fatal(err) + } + + if err := pManager.OverrideRules(config.Rules); err != nil { log.Fatal(err) } @@ -96,49 +94,17 @@ func Run() { log.Fatal("Error api type not supported: ", apiType) } - for rule, p := range pluginManager.Rules { - userRuleCfg, ok := config.Rules[rule] - if ok && userRuleCfg.Disable { + for rule, opt := range pManager.Rules { + if opt.Disable { continue } // read original code - rawCode, err := pluginManager.ReadPluginCode(p.File) - if err != nil { - log.Fatal("Failed to : ", err) - } - // babel transpile - code, err := cmp.Transform(rawCode) + rawCode, err := pManager.ReadPluginCode(opt.File) if err != nil { log.Fatal("Failed to : ", err) } - // creating config for each rule because we also want rule name of each score and report setter - runCfg := &compiler.RunConfig{ - Type: apiType, - ApiSchema: apiSchemaFile, - SetScore: func(category string, score float32) { - rm.SetScore(rule, reportmanager.Score{Category: category, Value: score}) - }, - Report: func(body *reportmanager.ReportDef) { - rm.PushReport(rule, *body) - }, - } - - // execute the code - err = cmp.Run(code, runCfg, userRuleCfg.Options) - if err != nil { - log.Fatal("Error in program: ", err) - } - } - - // run user defined plugins - for rule, p := range config.Plugins { - // read original code - rawCode, err := pluginManager.ReadPluginCode(p.File) - if err != nil { - log.Fatal("Failed to : ", err) - } // babel transpile code, err := cmp.Transform(rawCode) if err != nil { @@ -158,7 +124,7 @@ func Run() { } // execute the code - err = cmp.Run(code, runCfg, p.Options) + err = cmp.Run(code, runCfg, opt.Options) if err != nil { log.Fatal("Error in program: ", err) } diff --git a/internal/cli/pluginmanager/plugin_manager.go b/internal/cli/pluginmanager/plugin_manager.go index c0c99a4..53dced3 100644 --- a/internal/cli/pluginmanager/plugin_manager.go +++ b/internal/cli/pluginmanager/plugin_manager.go @@ -18,23 +18,32 @@ type Reader interface { // Builtin will packaged as zip // Installed on runtime type PluginManager struct { - Rules map[string]struct{ File string } - Reader Reader + Rules map[string]*PluginRule + Reader Reader + ApiType string } -type PluginConfig struct { - Rules []PluginConfigRule `yaml:"rules" ` +// these are the +type PluginRule struct { + File string + Disable bool + Options map[string]any } -type PluginConfigRule struct { - Name string `yaml:"name"` - File string `yam:"file"` +type PluginUserOverride struct { + Disable *bool `json:"omitempty" yaml:"omitempty" toml:"omitempty"` + Options map[string]any `json:"omitempty" yaml:"omitempty" toml:"omitempty"` } -func New(fr Reader) *PluginManager { +type PluginConfFile struct { + Rules map[string]PluginRule +} + +func New(fr Reader, apiType string) *PluginManager { return &PluginManager{ - Rules: map[string]struct{ File string }{}, - Reader: fr, + Rules: make(map[string]*PluginRule), + Reader: fr, + ApiType: apiType, } } @@ -49,39 +58,66 @@ func getPluginConfFile(files []fs.DirEntry) (string, error) { func (p *PluginManager) LoadBuiltinPlugin() error { cwd, _ := os.Getwd() + // TODO(akhilmhdh): In prod mode this should point to "/.apic/plugins" - path := filepath.Clean(filepath.Join(cwd, "./plugins/builtin")) - pluginsDir, err := os.ReadDir(path) + path := filepath.Clean(filepath.Join(cwd, fmt.Sprintf("./plugins/builtin/%s", p.ApiType))) + builtInPlugin, err := os.ReadDir(path) if err != nil { log.Fatal("Failed to open builtin plugins dir: ", err) } - for _, pluginDir := range pluginsDir { - if pluginDir.IsDir() { - pluginName := pluginDir.Name() - pluginFolder := filepath.Join(path, fmt.Sprintf("/%s", pluginName)) - pluginFiles, err := os.ReadDir(pluginFolder) - if err != nil { - return err - } - // get plugin config file. - pluginCfgName, err := getPluginConfFile(pluginFiles) - if err != nil { - return err - } + // get plugin config file. + pluginCfgName, err := getPluginConfFile(builtInPlugin) + if err != nil { + return err + } - // load plugin config - cfgFilePath := filepath.Join(path, fmt.Sprintf("/%s/%s", pluginName, pluginCfgName)) - var pluginCfg PluginConfig - if err := p.Reader.ReadFile(cfgFilePath, &pluginCfg); err != nil { - return err - } + // load plugin config + cfgFilePath := filepath.Join(path, pluginCfgName) + var pluginCfg PluginConfFile + if err := p.Reader.ReadFile(cfgFilePath, &pluginCfg); err != nil { + return err + } + + // load up the rules + for rule, conf := range pluginCfg.Rules { + jsRuleFile := filepath.Join(path, fmt.Sprintf("/%s", conf.File)) + p.Rules[rule] = &PluginRule{Disable: conf.Disable, File: jsRuleFile, Options: conf.Options} + } - // load up the rules - for _, r := range pluginCfg.Rules { - jsRuleFile := filepath.Join(pluginFolder, fmt.Sprintf("/%s", r.File)) - p.Rules[r.Name] = struct{ File string }{File: jsRuleFile} + return nil +} + +func (p *PluginManager) LoadUserPlugins(userPlugins PluginConfFile) error { + // load up the rules + for rule, conf := range userPlugins.Rules { + if _, ok := p.Rules[rule]; ok { + fmt.Printf("Warning: %s is already defined. Overriding it.\n", rule) + } + + p.Rules[rule] = &PluginRule{Disable: conf.Disable, File: conf.File, Options: conf.Options} + } + + return nil +} + +func (p *PluginManager) OverrideRules(userOverrides map[string]PluginUserOverride) error { + for rule, conf := range userOverrides { + if val, ok := p.Rules[rule]; ok { + if conf.Disable != nil { + val.Disable = *conf.Disable + } + if conf.Options != nil { + for i, r := range conf.Options { + if val.Options == nil { + val.Options = make(map[string]any, 0) + } + val.Options[i] = r + } } + p.Rules[rule] = val + } else { + fmt.Printf("Overriding rule %s not found\n", rule) } } diff --git a/plugins/builtin/openapi/config.yaml b/plugins/builtin/openapi/config.yaml index b284faf..e746bd4 100644 --- a/plugins/builtin/openapi/config.yaml +++ b/plugins/builtin/openapi/config.yaml @@ -1,7 +1,13 @@ rules: - - name: "status_code_check" + status_code_check: file: "status_code_check.js" - - name: "body_in_get_req" + body_in_get_req: file: "body_in_get_req.js" - - name: "case_checker" - file: "case_checker.js" + url_case_checker: + file: "url_case_checker.js" + unsafe_url_character_check: + file: "unsafe_url_character_check.js" + url_length: + file: "url_length.js" + req_body_case_checker: + file: "req_body_case_checker.js" diff --git a/plugins/builtin/openapi/content_type_check.js b/plugins/builtin/openapi/content_type_check.js deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/builtin/openapi/req_body_case_checker.js b/plugins/builtin/openapi/req_body_case_checker.js new file mode 100644 index 0000000..0da29d7 --- /dev/null +++ b/plugins/builtin/openapi/req_body_case_checker.js @@ -0,0 +1,80 @@ +const snakeCaseRegex = /^[a-z0-9]+(?:_[a-z0-9]+)*$/; +const camelCaseRegex = /^[a-z]+(?:[A-Z0-9]+[a-z0-9]+[A-Za-z0-9]*)*$/; +const pascalCaseRegex = /^(?:[A-Z][a-z0-9]+)(?:[A-Z]+[a-z0-9]*)*$/; +const kebabCaseRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +function isCamelCase(word) { + return camelCaseRegex.test(word); +} + +function isPascalCase(word) { + return pascalCaseRegex.test(word); +} + +function isSnakeCase(word) { + return snakeCaseRegex.test(word); +} + +function isKebabCase(word) { + return kebabCaseRegex.test(word); +} + +function getCaseCheckerFn(type) { + switch (type) { + case "camelcase": + return isCamelCase; + case "snakecase": + return isSnakeCase; + case "pascalcase": + return isPascalCase; + case "kebabcase": + return isKebabCase; + default: + return isCamelCase; + } +} + +export default function (config, options = {}) { + let numberOfResponses = 0; + let numbnerOfFalseResponses = 0; + const checkerFn = getCaseCheckerFn(options?.casing); + + Object.keys(config.schema.paths).forEach((path) => { + Object.keys(config.schema.paths[path]).forEach((method) => { + (config.schema.paths[path][method].parameters || []).forEach((param) => { + numberOfResponses++; + if (!checkerFn(param.name)) { + numbnerOfFalseResponses++; + config.report({ + message: `Invalid casing for ${param.name} of ${param.in}`, + path: path, + method: method, + }); + } + }); + }); + }); + + Object.keys(config.schema.components.schemas).forEach((schema) => { + Object.keys(config.schema.components.schemas[schema].properties).forEach( + (property) => { + numberOfResponses++; + if (!checkerFn(property)) { + numbnerOfFalseResponses++; + config.report({ + message: `Invalid casing for ${property} of schema ${schema}`, + path: "Nil", + method: "Nil", + }); + } + } + ); + }); + + // if number goes to negative + const score = + (Math.max(numberOfResponses - numbnerOfFalseResponses, 0) / + numberOfResponses) * + 100; + config.setScore("quality", score); +} diff --git a/plugins/builtin/openapi/unsafe_url_character_check.js b/plugins/builtin/openapi/unsafe_url_character_check.js new file mode 100644 index 0000000..3f5b948 --- /dev/null +++ b/plugins/builtin/openapi/unsafe_url_character_check.js @@ -0,0 +1,31 @@ +// By this spec: https://perishablepress.com/stop-using-unsafe-characters-in-urls/ +const unsafeURLRegex = /^[a-zA-Z0-9{}\/~_-]*$/; + +export default function (config) { + let numberOfResponses = 0; + let numbnerOfFalseResponses = 0; + + Object.keys(config.schema.paths).forEach((path) => { + numberOfResponses++; + if (!unsafeURLRegex.test(path)) { + numbnerOfFalseResponses++; + + // get all methods + const methods = Object.keys(config.schema.paths[path]) + .join(", ") + .toUpperCase(); + config.report({ + message: `URL contains unsafe character`, + path: path, + method: methods, + }); + } + }); + + const score = + (Math.max(numberOfResponses - numbnerOfFalseResponses, 0) / + numberOfResponses) * + 100; + + config.setScore("quality", score); +} diff --git a/plugins/builtin/openapi/case_checker.js b/plugins/builtin/openapi/url_case_checker.js similarity index 69% rename from plugins/builtin/openapi/case_checker.js rename to plugins/builtin/openapi/url_case_checker.js index 471f7b3..c9b3717 100644 --- a/plugins/builtin/openapi/case_checker.js +++ b/plugins/builtin/openapi/url_case_checker.js @@ -49,26 +49,28 @@ export default function (config, options) { const checkerFn = getCaseCheckerFn(options.casing); - try { - Object.keys(config.schema.paths).forEach((path) => { - path - .split("/") - .filter(Boolean) - .forEach((pathFragment) => { - numberOfResponses++; - if (!checkerFn(removeParenthesis(pathFragment))) { - numbnerOfFalseResponses++; - config.report({ - message: `Invalid URL casing`, - path: path, - method: "Nil", - }); - } - }); - }); - } catch (err) { - console.log(err); - } + Object.keys(config.schema.paths).forEach((path) => { + path + .split("/") + .filter(Boolean) + .forEach((pathFragment) => { + numberOfResponses++; + if (!checkerFn(removeParenthesis(pathFragment))) { + numbnerOfFalseResponses++; + // get all methods + const methods = Object.keys(config.schema.paths[path]) + .join(", ") + .toUpperCase(); + + config.report({ + message: `Invalid URL casing`, + path: path, + method: methods, + }); + } + }); + }); + const score = (Math.max(numberOfResponses - numbnerOfFalseResponses, 0) / numberOfResponses) * diff --git a/plugins/builtin/openapi/url_length.js b/plugins/builtin/openapi/url_length.js new file mode 100644 index 0000000..f9b932c --- /dev/null +++ b/plugins/builtin/openapi/url_length.js @@ -0,0 +1,43 @@ +// for giving a score to {something} kinda paths in openapi +function isDynamicPathFragment(path) { + return path[0] === "{" && path[path.length - 1] === "}"; +} + +export default function (config) { + let numberOfResponses = 0; + let numbnerOfFalseResponses = 0; + + Object.keys(config.schema.paths).forEach((path) => { + numberOfResponses++; + const resources = path.split("/").filter(Boolean); + // the idea is for a given path /pets/{petid} + // we will ask user a weight for dynamic params then length of path fragment plus the dynamic x weigth + // gives total length + const resourceLength = resources.reduce( + (prev, curr) => + isDynamicPathFragment(curr) ? prev + 5 : prev + curr.length, + 0 + ); + + if (resourceLength > 75 || resources > 10) { + numbnerOfFalseResponses++; + + // get all methods + const methods = Object.keys(config.schema.paths[path]) + .join(", ") + .toUpperCase(); + config.report({ + message: `URL is too big, Resources: ${resources} Length: ${resourceLength} Weight: 75`, + path: path, + method: methods, + }); + } + }); + + const score = + (Math.max(numberOfResponses - numbnerOfFalseResponses, 0) / + numberOfResponses) * + 100; + + config.setScore("quality", score); +} diff --git a/test/apic.toml b/test/apic.toml index 6b2167a..606945a 100644 --- a/test/apic.toml +++ b/test/apic.toml @@ -1,8 +1,13 @@ title = "hello world" -[rules.body_in_get_req] +[rules.url_length] disable = true -# -# [plugins.body_in_get_req] -# file = "https://github.com/raw/akhilmhdh/api-catalog/feat/%234/plugins/builtin/openapi/body_in_get_req.js" +[rules.req_body_case_checker.options] +casing = "snakecase" + +[plugins.rules.test_plugin] +file = "./test/test_plugin.js" + +[plugins.rules.test_plugin.options] +test_data = "hello" diff --git a/test/petstore-v3.json b/test/petstore-v3.json index 1f1f3e2..2fd3e3e 100644 --- a/test/petstore-v3.json +++ b/test/petstore-v3.json @@ -73,7 +73,7 @@ "required": true }, "responses": { - "600": { + "200": { "description": "Successful operation", "content": { "application/xml": { @@ -396,7 +396,7 @@ ] } }, - "/pet/{petId}/uploadImage": { + "/pet/{petId}/uploa$dImage": { "post": { "tags": ["pet"], "summary": "uploads an image", @@ -452,7 +452,7 @@ ] } }, - "/store/inventory": { + "/store/inventory/store/inventory/store/inventory/store/inventory/store/inventory/store/inventory": { "get": { "tags": ["store"], "summary": "Returns pet inventories by status", diff --git a/test/test_plugin.js b/test/test_plugin.js new file mode 100644 index 0000000..f71a1ed --- /dev/null +++ b/test/test_plugin.js @@ -0,0 +1,3 @@ +export default function (cfg, opts) { + console.log("hello world", opts?.test_data); +}