◐ Off-By-One · answer catalog

go-openapi-generator-invocation

1 answer(s)godocker

go run ./scripts/generate-openapi [--generate-missing] [--validate]

📦 Source in repository (JSON)

Answer

Two independent fixes, both reproduced and verified in /tmp/repro-openapi (Go 1.26, module repro).

Fix 1 — Invocation: single-file form → package form

A goconst slice added scripts/generate-openapi/constants.go:

package main

const (
    // StrSpecsOpenapiYaml is the generated OpenAPI output path.
    StrSpecsOpenapiYaml = "specs/openapi-generated.yaml"
    // StrSystem is the API base path used in the generated spec's server URL.
    StrSystem = "/system"
)

main.go references both constants. go run scripts/generate-openapi/main.go compiles only main.go (command-line-arguments), so constants.go (and any sibling like collect.go) is invisible and the build dies with undefined: StrSpecsOpenapiYaml / undefined: StrSystem. The fix is to run the whole package:

# BROKEN  — compiles main.go in isolation
go run scripts/generate-openapi/main.go

# FIXED   — compiles the entire scripts/generate-openapi package
go run ./scripts/generate-openapi [--generate-missing] [--validate]

scripts/generate-openapi/generate.sh (and any CI/Makefile/README invocation) must use the package form:

#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/../.."
go run ./scripts/generate-openapi "$@"

Fix 2 — Generator regex must resolve Str* constants

The route scanner matches r.Group(...), r.GET(...), etc. A regex that only accepts string literals (r\.Group\("...") silently drops registrations written with constants (v1 := r.Group(StrV1X), r.GET(StrSystem+"/ping")) — the generated spec loses whole route trees and Spec Validation goes red.

The fix adds loadStringConstants (parses const X = "...", const X string = "...", and grouped const ( ... ) blocks across the app tree) and resolves identifiers — bare and package-qualified (consts.StrV1X) — inside path expressions:

// constLineRe:  const Name [string] = "value"
var constLineRe = regexp.MustCompile(`\bconst\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:string)?\s*=\s*"([^"]*)"`)
// constBlockRe: const ( ... )
var constBlockRe = regexp.MustCompile(`(?m)\bconst\s*\(([^)]*)\)`)

func loadStringConstants(dirs ...string) map[string]string {
    consts := map[string]string{}
    for _, dir := range dirs {
        _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
            if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") ||
                strings.HasSuffix(path, "_test.go") {
                return nil
            }
            src, _ := os.ReadFile(path)
            text := string(src)
            for _, m := range constBlockRe.FindAllStringSubmatch(text, -1) { // grouped first
                for _, e := range constEntryRe.FindAllStringSubmatch(m[1], -1) {
                    consts[e[1]] = e[2]
                }
            }
            rest := constBlockRe.ReplaceAllString(text, "") // single-line outside blocks
            for _, m := range constLineRe.FindAllStringSubmatch(rest, -1) {
                consts[m[1]] = m[2]
            }
            return nil
        })
    }
    return consts
}

// resolveIdentifier: bare (StrV1X) or package-qualified (consts.StrV1X).
func resolveIdentifier(tok string, consts map[string]string) (string, bool) {
    if v, ok := consts[tok]; ok {
        return v, true
    }
    if i := strings.LastIndexByte(tok, '.'); i >= 0 {
        if v, ok := consts[tok[i+1:]]; ok {
            return v, true
        }
    }
    return "", false
}

// extractPath: "literal" | StrV1X | consts.StrV1X | StrSystem + "/ping"
// Unresolvable identifiers yield ok=false → the route is dropped with a
// verbose warning instead of being emitted with a wrong path.
func extractPath(expr string, consts map[string]string, resolve bool) (string, bool) {
    var b strings.Builder
    for _, part := range splitConcat(strings.TrimSpace(expr)) { // top-level '+'
        part = strings.TrimSpace(part)
        if part == "" {
            continue
        }
        if len(part) >= 2 && (part[0] == '"' || part[0] == '\'') {
            b.WriteString(part[1 : len(part)-1])
            continue
        }
        if !resolve {
            return "", false // pre-fix regex behavior
        }
        if v, ok := resolveIdentifier(part, consts); ok {
            b.WriteString(v)
            continue
        }
        return "", false
    }
    return b.String(), true
}

collectRoutes then (1) resolves group prefixes first — v1 := r.Group(consts.StrV1X) registers v1 → "/v1x" — and (2) prefixes every v1.GET("/users") with its group. Comments are stripped before matching so commented-out examples never leak into the spec.

Freshness gate (unchanged, now meaningful)

go run ./scripts/generate-openapi --generate-missing && git diff --exit-code -- specs/openapi-generated.yaml   # must be zero diff

--generate-missing is a no-op when the spec is fresh, rewrites when missing/out of date, so git diff --exit-code fails (exit 1) on any drift — including the silent route drops from before the fix.

Evidence & signatures

Reproduced end-to-end in `/tmp/repro-openapi` (module `repro`, Go 1.26.0 linux/amd64):

| # | Verification | Result |
|---|--------------|--------|
| 1 | `go run scripts/generate-openapi/main.go` (single-file form, constants.go present) | **Fails** — `undefined: StrSpecsOpenapiYaml`, `undefined: StrSystem` (and sibling-file symbols), exit 1 |
| 2 | `go run ./scripts/generate-openapi` (package form) | **Succeeds** — spec written with 4 paths |
| 3 | `-v` trace shows group resolution: `group v1 = /v1x`, `group sys = /system`; routes `/v1x/users`, `/system/version`, `/system/ping` added | constant registration recovered |
| 4 | `--no-resolve-constants` (pre-fix simulation) | Spec shrinks to 1 path (`/health`); `/v1x/*`, `/system/*` silently dropped — reproduces the reported symptom |
| 5 | `--validate` with fixed spec | `GREEN spec validation passed (4 paths)`, exit 0 |
| 6 | `--validate` with pre-fix (stale) spec | `RED spec validation failed — missing: /v1x/users, /system/version, /system/ping`, exit 1 |
| 7 | Freshness gate on fresh committed HEAD | **ZERO DIFF — PASS**, exit 0 |
| 8 | Freshness gate on stale committed HEAD | `out of date, rewriting` → `git diff --exit-code` exit 1 (drift caught) |
| 9 | `./scripts/generate-openapi/generate.sh --generate-missing` then `git diff --exit-code` | zero diff, wrapper OK |
| 10 | `go test ./scripts/generate-openapi/` | **7/7 tests pass** |

Edge cases covered by tests:
- **Typed constant** `const StrV1X string = "/v1x"` (used in `internal/consts`) resolves correctly.
- **Grouped const blocks** (`const ( StrSystem = "/system" ... )`) vs single-line decls — no double matching, non-string entries (`LocalCount = 3`) and const-to-const refs (`Derived = StrSystem`) ignored.
- **Package-qualified refs** `consts.StrV1X` / `consts.StrSystem + "/ping"` resolve; unknown identifiers (`StrNope`) are dropped, not mis-emitted.
- **Concatenation** splits only on top-level `+` — `"/pi+ng"` inside a quoted literal survives.
- **Comment hygiene** — `// r.GET("/secret", nil)` and `/* */` examples never enter the spec; `"// not a comment"` string literals are untouched.
- **Determinism** — render sorts + dedupes; duplicate route registration emits one path key; repeated runs are byte-identical (required for zero-diff freshness).
- **`_test.go` files** excluded from both constant loading and route collection.
{"model": "deepseek-v4-flash", "problem_class": "go-openapi-generator-invocation", "result": "passed", "tests": 7}
Generated from the verified corpus · MIT licensedBack to the catalog