|
| 1 | +// check-path-conflicts reads an OpenAPI v2 JSON file and detects HTTP path |
| 2 | +// conflicts where a literal path segment and a parameterized segment overlap at |
| 3 | +// the same position (e.g. /items/pause vs /items/{id}). |
| 4 | +package main |
| 5 | + |
| 6 | +import ( |
| 7 | + "encoding/json" |
| 8 | + "fmt" |
| 9 | + "os" |
| 10 | + "sort" |
| 11 | + "strings" |
| 12 | +) |
| 13 | + |
| 14 | +type openAPISpec struct { |
| 15 | + Paths map[string]map[string]json.RawMessage `json:"paths"` |
| 16 | +} |
| 17 | + |
| 18 | +// httpMethods are the valid HTTP method keys in an OpenAPI path item. |
| 19 | +var httpMethods = map[string]bool{ |
| 20 | + "get": true, "put": true, "post": true, "delete": true, |
| 21 | + "options": true, "head": true, "patch": true, |
| 22 | +} |
| 23 | + |
| 24 | +// segment represents one piece of a URL path. |
| 25 | +type segment struct { |
| 26 | + value string |
| 27 | + param bool // true when the segment is a path parameter like {id} |
| 28 | +} |
| 29 | + |
| 30 | +func parseSegments(path string) []segment { |
| 31 | + parts := strings.Split(strings.Trim(path, "/"), "/") |
| 32 | + segs := make([]segment, len(parts)) |
| 33 | + for i, p := range parts { |
| 34 | + segs[i] = segment{ |
| 35 | + value: p, |
| 36 | + param: strings.HasPrefix(p, "{") && strings.HasSuffix(p, "}"), |
| 37 | + } |
| 38 | + } |
| 39 | + return segs |
| 40 | +} |
| 41 | + |
| 42 | +// Two paths conflict when they have the same number of segments and at every |
| 43 | +// position, they either match literally or at least one of them is a parameter, |
| 44 | +// AND there is at least one position where one path has a literal and the other |
| 45 | +// has a parameter (otherwise they are the same path or differ only in parameter |
| 46 | +// names, which is a different issue). |
| 47 | +func conflicts(a, b []segment) bool { |
| 48 | + if len(a) != len(b) { |
| 49 | + return false |
| 50 | + } |
| 51 | + hasParamLiteralMismatch := false |
| 52 | + for i := range a { |
| 53 | + aParam := a[i].param |
| 54 | + bParam := b[i].param |
| 55 | + if !aParam && !bParam { |
| 56 | + // Both literals — must match exactly. |
| 57 | + if a[i].value != b[i].value { |
| 58 | + return false |
| 59 | + } |
| 60 | + } else if aParam != bParam { |
| 61 | + // One is a param, the other is a literal — potential conflict. |
| 62 | + hasParamLiteralMismatch = true |
| 63 | + } |
| 64 | + // Both params — always compatible at this position, continue. |
| 65 | + } |
| 66 | + return hasParamLiteralMismatch |
| 67 | +} |
| 68 | + |
| 69 | +func main() { |
| 70 | + if len(os.Args) < 2 { |
| 71 | + fmt.Fprintf(os.Stderr, "usage: %s <openapi-v2.json>\n", os.Args[0]) |
| 72 | + os.Exit(2) |
| 73 | + } |
| 74 | + |
| 75 | + data, err := os.ReadFile(os.Args[1]) |
| 76 | + if err != nil { |
| 77 | + fmt.Fprintf(os.Stderr, "error reading file: %v\n", err) |
| 78 | + os.Exit(2) |
| 79 | + } |
| 80 | + |
| 81 | + var spec openAPISpec |
| 82 | + if err := json.Unmarshal(data, &spec); err != nil { |
| 83 | + fmt.Fprintf(os.Stderr, "error parsing JSON: %v\n", err) |
| 84 | + os.Exit(2) |
| 85 | + } |
| 86 | + |
| 87 | + type parsedPath struct { |
| 88 | + raw string |
| 89 | + methods map[string]bool |
| 90 | + segments []segment |
| 91 | + } |
| 92 | + |
| 93 | + var parsed []parsedPath |
| 94 | + for p, item := range spec.Paths { |
| 95 | + methods := make(map[string]bool) |
| 96 | + for key := range item { |
| 97 | + if httpMethods[strings.ToLower(key)] { |
| 98 | + methods[strings.ToLower(key)] = true |
| 99 | + } |
| 100 | + } |
| 101 | + parsed = append(parsed, parsedPath{raw: p, methods: methods, segments: parseSegments(p)}) |
| 102 | + } |
| 103 | + sort.Slice(parsed, func(i, j int) bool { return parsed[i].raw < parsed[j].raw }) |
| 104 | + |
| 105 | + var found []string |
| 106 | + for i := 0; i < len(parsed); i++ { |
| 107 | + for j := i + 1; j < len(parsed); j++ { |
| 108 | + if !conflicts(parsed[i].segments, parsed[j].segments) { |
| 109 | + continue |
| 110 | + } |
| 111 | + // Find overlapping HTTP methods. |
| 112 | + var shared []string |
| 113 | + for m := range parsed[i].methods { |
| 114 | + if parsed[j].methods[m] { |
| 115 | + shared = append(shared, strings.ToUpper(m)) |
| 116 | + } |
| 117 | + } |
| 118 | + if len(shared) == 0 { |
| 119 | + continue |
| 120 | + } |
| 121 | + sort.Strings(shared) |
| 122 | + found = append(found, fmt.Sprintf(" %s\n %s\n methods: %s", |
| 123 | + parsed[i].raw, parsed[j].raw, strings.Join(shared, ", "))) |
| 124 | + } |
| 125 | + } |
| 126 | + |
| 127 | + if len(found) > 0 { |
| 128 | + fmt.Fprintf(os.Stderr, "found %d path conflict(s):\n\n", len(found)) |
| 129 | + for _, f := range found { |
| 130 | + fmt.Fprintln(os.Stderr, f) |
| 131 | + fmt.Fprintln(os.Stderr) |
| 132 | + } |
| 133 | + os.Exit(1) |
| 134 | + } |
| 135 | + |
| 136 | + fmt.Println("no path conflicts found") |
| 137 | +} |
0 commit comments