a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
220 lines
6.1 KiB
Go
220 lines
6.1 KiB
Go
package contracts
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// NestJS RouterModule cross-module prefixing. A NestJS app can mount whole
|
|
// modules under a path with `RouterModule.register([{ path, module, children }])`,
|
|
// so a controller's effective route prefix is the path its module is mounted at
|
|
// (walked through nested `children`) joined with the controller's own
|
|
// `@Controller('...')` prefix. Because the router config, the `@Module` that
|
|
// lists the controllers, and the controller itself routinely live in three
|
|
// different files, resolving this prefix is a cross-file graph walk — the
|
|
// multi-file reach a same-file regex scanner cannot do.
|
|
|
|
var (
|
|
nestControllerClassRE = regexp.MustCompile(`(?:export\s+)?class\s+([A-Za-z_]\w*)`)
|
|
nestObjStringRE = func(key string) *regexp.Regexp {
|
|
return regexp.MustCompile(key + `\s*:\s*['"` + "`" + `]([^'"` + "`" + `]+)['"` + "`" + `]`)
|
|
}
|
|
nestObjIdentRE = func(key string) *regexp.Regexp {
|
|
return regexp.MustCompile(key + `\s*:\s*([A-Za-z_]\w*)`)
|
|
}
|
|
nestControllersListRE = regexp.MustCompile(`controllers\s*:\s*\[([^\]]*)\]`)
|
|
)
|
|
|
|
// buildNestModulePrefixes resolves, across all scanned files, the mounted route
|
|
// prefix for every NestJS controller class: it walks the RouterModule route
|
|
// tree (module -> mounted prefix, inheriting parent paths through `children`),
|
|
// maps each `@Module` to the controllers it declares, and composes the two into
|
|
// controllerClass -> mounted prefix.
|
|
func buildNestModulePrefixes(files map[string]bool, srcFor func(string) []byte) map[string]string {
|
|
modulePrefix := map[string]string{} // module class -> mounted prefix
|
|
controllerModule := map[string]string{} // controller class -> module class
|
|
|
|
for path := range files {
|
|
src := srcFor(path)
|
|
if src == nil {
|
|
continue
|
|
}
|
|
text := string(src)
|
|
if strings.Contains(text, "RouterModule.") {
|
|
for _, call := range []string{"RouterModule.register(", "RouterModule.forRoot(", "RouterModule.forChild("} {
|
|
for _, idx := range indexAll(text, call) {
|
|
if arr, end := balancedSlice(text, idx, '[', ']'); end >= 0 {
|
|
walkNestRouterRoutes(arr, "", modulePrefix)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if strings.Contains(text, "@Module(") {
|
|
for _, mod := range extractNestModules(text) {
|
|
for _, ctrl := range mod.controllers {
|
|
if _, ok := controllerModule[ctrl]; !ok {
|
|
controllerModule[ctrl] = mod.name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
out := map[string]string{}
|
|
for ctrl, mod := range controllerModule {
|
|
if p, ok := modulePrefix[mod]; ok && p != "" {
|
|
out[ctrl] = p
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// walkNestRouterRoutes walks a RouterModule routes array, recording each
|
|
// module's full mounted prefix (parent paths joined through `children`).
|
|
func walkNestRouterRoutes(arr, parent string, out map[string]string) {
|
|
for _, obj := range splitTopLevelObjects(arr) {
|
|
head := obj
|
|
childrenArr := ""
|
|
if ci := strings.Index(obj, "children"); ci >= 0 {
|
|
head = obj[:ci]
|
|
if a, end := balancedSlice(obj[ci:], 0, '[', ']'); end >= 0 {
|
|
childrenArr = a
|
|
}
|
|
}
|
|
pathSeg := firstSubmatch(nestObjStringRE("path"), head)
|
|
module := firstSubmatch(nestObjIdentRE("module"), head)
|
|
prefix := parent
|
|
if pathSeg != "" {
|
|
prefix = joinPaths(parent, "/"+strings.Trim(pathSeg, "/"))
|
|
}
|
|
if module != "" {
|
|
out[module] = prefix
|
|
}
|
|
if childrenArr != "" {
|
|
walkNestRouterRoutes(childrenArr, prefix, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// nestModule is a parsed @Module: its class name and the controllers it lists.
|
|
type nestModule struct {
|
|
name string
|
|
controllers []string
|
|
}
|
|
|
|
// extractNestModules parses every @Module({ controllers: [...] }) and the class
|
|
// declared immediately after it.
|
|
func extractNestModules(text string) []nestModule {
|
|
var out []nestModule
|
|
for _, idx := range indexAll(text, "@Module(") {
|
|
args, end := balancedSlice(text, idx, '(', ')')
|
|
if end < 0 {
|
|
continue
|
|
}
|
|
name := firstSubmatch(nestControllerClassRE, text[end:])
|
|
if name == "" {
|
|
continue
|
|
}
|
|
var controllers []string
|
|
if cm := nestControllersListRE.FindStringSubmatch(args); cm != nil {
|
|
for _, id := range strings.Split(cm[1], ",") {
|
|
if id = strings.TrimSpace(id); id != "" {
|
|
controllers = append(controllers, id)
|
|
}
|
|
}
|
|
}
|
|
out = append(out, nestModule{name: name, controllers: controllers})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// nestControllerClass returns the @Controller-decorated class name in a
|
|
// contract's file — the key into the module-prefix map.
|
|
func nestControllerClass(c Contract, srcFor func(string) []byte) string {
|
|
src := srcFor(c.FilePath)
|
|
if src == nil {
|
|
return ""
|
|
}
|
|
text := string(src)
|
|
if idx := strings.Index(text, "@Controller"); idx >= 0 {
|
|
return firstSubmatch(nestControllerClassRE, text[idx:])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// --- small parsing helpers ----------------------------------------------
|
|
|
|
// balancedSlice finds the first `open` byte at or after fromIdx and returns the
|
|
// content up to its matching `close`, plus the index of that close (or -1).
|
|
// String/template literals are skipped so a brace inside a quote is ignored.
|
|
func balancedSlice(text string, fromIdx int, open, close byte) (string, int) {
|
|
i := fromIdx
|
|
for i < len(text) && text[i] != open {
|
|
i++
|
|
}
|
|
if i >= len(text) {
|
|
return "", -1
|
|
}
|
|
start := i
|
|
depth := 0
|
|
for i < len(text) {
|
|
switch text[i] {
|
|
case open:
|
|
depth++
|
|
case close:
|
|
depth--
|
|
if depth == 0 {
|
|
return text[start+1 : i], i
|
|
}
|
|
case '\'', '"', '`':
|
|
q := text[i]
|
|
i++
|
|
for i < len(text) && text[i] != q {
|
|
if text[i] == '\\' {
|
|
i++
|
|
}
|
|
i++
|
|
}
|
|
}
|
|
i++
|
|
}
|
|
return "", -1
|
|
}
|
|
|
|
// splitTopLevelObjects returns each `{...}` object at the top level of an array
|
|
// body, depth-aware so nested objects stay inside their parent.
|
|
func splitTopLevelObjects(arr string) []string {
|
|
var out []string
|
|
for i := 0; i < len(arr); {
|
|
if arr[i] == '{' {
|
|
if inner, end := balancedSlice(arr, i, '{', '}'); end >= 0 {
|
|
out = append(out, inner)
|
|
i = end + 1
|
|
continue
|
|
}
|
|
}
|
|
i++
|
|
}
|
|
return out
|
|
}
|
|
|
|
func indexAll(text, sub string) []int {
|
|
var out []int
|
|
for i := 0; ; {
|
|
j := strings.Index(text[i:], sub)
|
|
if j < 0 {
|
|
break
|
|
}
|
|
out = append(out, i+j)
|
|
i += j + len(sub)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func firstSubmatch(re *regexp.Regexp, s string) string {
|
|
if m := re.FindStringSubmatch(s); m != nil {
|
|
return m[1]
|
|
}
|
|
return ""
|
|
}
|