Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`apx client verify`** — a generate-and-compile release gate. It generates an
API client and **compiles** it, failing when any generated client does not
build, so a spec that is valid OpenAPI 3 and passes `apx lint`/`apx breaking`
but produces a non-compiling client (e.g. redundant `_limit`/`limit` query
params that normalize to the same Go field, or a path parameter named `url`
that shadows the `net/url` import) is caught before release. Each generator
runs in a throwaway directory; a generator whose toolchain is absent is skipped
(not failed), so the gate covers Go always and TypeScript where Node is
present. `--warn-only` (or `release.verify_clients.warn_only`) downgrades a
failure to a warning for staged rollout; `release.verify_clients.generators`
sets the matrix. The Builder-or-npm build dispatch is now centralized in
`internal/client` and shared by `generate --build`, `publish`, and `verify`.

### Fixed

- **Setup APX action** now installs `spectral` so the OpenAPI lint re-validation
Expand Down
5 changes: 5 additions & 0 deletions apx.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ policy:
release:
tag_format: "{subdir}/v{version}"
ci_only: true
# Generate-and-compile gate (apx client verify): generate a client and build
# it so a spec that cannot produce a buildable client is caught before release.
verify_clients:
generators: [go, typescript-angular] # empty => default matrix
warn_only: false # gate by default; --warn-only overrides
tools:
buf:
version: v1.66.1
Expand Down
158 changes: 116 additions & 42 deletions cmd/apx/commands/client.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package commands

import (
"context"
"fmt"
"os"
"os/exec"
Expand All @@ -26,6 +25,7 @@ func newClientCmd() *cobra.Command {
}
cmd.AddCommand(newClientGenerateCmd())
cmd.AddCommand(newClientPublishCmd())
cmd.AddCommand(newClientVerifyCmd())
return cmd
}

Expand Down Expand Up @@ -75,6 +75,119 @@ func newClientPublishCmd() *cobra.Command {
return cmd
}

func newClientVerifyCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "verify [target]",
Short: "Generate and compile a client to prove a spec is buildable",
Long: fmt.Sprintf("Generate an API client for one or more generators and compile it, failing the\n"+
"command if any client does not build. This is the release gate that stops a spec\n"+
"which cannot produce a buildable client from being published (a valid OpenAPI\n"+
"spec can still generate Go that does not compile).\n\n"+
"By default it verifies the %s generators; TypeScript is skipped where its Node\n"+
"toolchain is absent. Use --generator to scope, and --warn-only to downgrade a\n"+
"generate/compile failure to a warning (exit 0) instead of failing the gate.\n\n"+
"Available generators: %s",
strings.Join(client.DefaultVerifyGenerators, ", "), strings.Join(client.Names(), ", ")),
Args: cobra.MaximumNArgs(1),
RunE: clientVerifyAction,
}
cmd.Flags().String("input", "", "OpenAPI spec path (overrides config/auto-detect)")
cmd.Flags().String("from", "", "api-id of an apx.lock dependency to source the spec from (unreleased override)")
cmd.Flags().StringSlice("generator", nil, "generator(s) to verify (repeatable); default: release.verify_clients.generators, else go + typescript-angular")
cmd.Flags().String("package", "", "package/module name stamped into the generated client")
cmd.Flags().String("scope", "", "npm scope for the package (e.g. @example)")
cmd.Flags().Bool("warn-only", false, "downgrade a generate/compile failure to a warning and exit 0 (default: fail the gate)")
return cmd
}

// clientVerifyAction resolves a spec and generator matrix, generates+compiles a
// client per generator, and turns the aggregate result into an exit code: it
// fails when any verified client does not build, unless --warn-only (or the
// release.verify_clients.warn_only config default) downgrades that to a warning.
func clientVerifyAction(cmd *cobra.Command, args []string) error {
target := ""
if len(args) > 0 {
target = args[0]
}
input, _ := cmd.Flags().GetString("input")
from, _ := cmd.Flags().GetString("from")
pkg, _ := cmd.Flags().GetString("package")
scope, _ := cmd.Flags().GetString("scope")
generators, _ := cmd.Flags().GetStringSlice("generator")

// Best-effort config load; a valid apx.yaml is not required (e.g. with --input).
cfg, _ := config.LoadRaw("")

var ct *config.ClientTarget
if target != "" {
ct = findClientTarget(cfg, target)
if ct == nil {
return fmt.Errorf("no client target %q found in apx.yaml", target)
}
if scope == "" {
scope = ct.Scope
}
if pkg == "" {
pkg = ct.Package
}
}

specPath, err := resolveClientSpec(input, from, ct, target)
if err != nil {
return err
}

// Generator matrix precedence: flag > config > built-in default (the empty
// slice is resolved to client.DefaultVerifyGenerators inside Verify).
if len(generators) == 0 && cfg != nil {
generators = cfg.Release.VerifyClients.Generators
}

// warn-only precedence: an explicit flag wins; otherwise the config default.
warnOnly, _ := cmd.Flags().GetBool("warn-only")
if !cmd.Flags().Changed("warn-only") && cfg != nil {
warnOnly = cfg.Release.VerifyClients.WarnOnly
}

ui.Info("Verifying client build for %s ...", specPath)
report, err := client.Verify(cmd.Context(), client.VerifyOptions{
SpecPath: specPath,
Generators: generators,
PackageName: pkg,
Scope: scope,
})
if err != nil {
return fmt.Errorf("verifying client: %w", err)
}

for _, r := range report.Results {
switch {
case r.Skipped:
ui.Warning(" %s: skipped (%s)", r.Generator, r.Reason)
case r.OK:
ui.Success(" %s: ok", r.Generator)
default:
ui.Error(" %s: FAILED — %v", r.Generator, r.Err)
}
}

if report.Ran() == 0 {
ui.Warning("No generators were verified (all skipped). Install the required toolchain(s) to run the gate.")
return nil
}

if report.Failed() {
if warnOnly {
ui.Warning("Client verification failed, but --warn-only is set — not failing the gate.")
return nil
}
return fmt.Errorf("client verification failed: one or more generated clients did not build")
}

ui.Success("All verified clients built successfully")
return nil
}

// resolveClientContext applies the shared target/flag/spec resolution and
// returns the selected generator plus the fully-resolved GenerateContext.
//
Expand Down Expand Up @@ -175,7 +288,7 @@ func clientGenerateAction(cmd *cobra.Command, args []string) error {
}

if doBuild {
if err := buildClient(cmd.Context(), gen, res); err != nil {
if err := client.Build(cmd.Context(), gen, res); err != nil {
return fmt.Errorf("building client package: %w", err)
}
}
Expand Down Expand Up @@ -203,7 +316,7 @@ func clientPublishAction(cmd *cobra.Command, args []string) error {

// Build first: a Builder-aware generator (e.g. go) compile-verifies; the npm
// path builds dist/ which `npm publish` needs.
if err := buildClient(cmd.Context(), gen, res); err != nil {
if err := client.Build(cmd.Context(), gen, res); err != nil {
return fmt.Errorf("building client package: %w", err)
}

Expand Down Expand Up @@ -365,45 +478,6 @@ func defaultPackageName(specPath string) string {
return base + "-client"
}

// buildClient compile-verifies the generated package. A Builder-aware generator
// (e.g. the go generator: `go mod tidy` + `go build`) supplies its own build;
// generators without a Builder (e.g. typescript-angular) use the npm build path.
func buildClient(ctx context.Context, gen client.Generator, res client.Result) error {
if b, ok := gen.(client.Builder); ok {
return b.Build(ctx, res)
}
return buildClientPackage(res.PackageDir)
}

// buildClientPackage runs npm install then npm run build in dir to prove the
// generated package compiles.
func buildClientPackage(dir string) error {
if _, err := exec.LookPath("npm"); err != nil {
return fmt.Errorf("npm required for --build; install Node >=18: %w", err)
}

ui.Info("Running npm install in %s ...", dir)
install := exec.Command("npm", "install")
install.Dir = dir
install.Env = os.Environ()
install.Stdout = os.Stdout
install.Stderr = os.Stderr
if err := install.Run(); err != nil {
return fmt.Errorf("npm install failed: %w", err)
}

ui.Info("Running npm run build in %s ...", dir)
build := exec.Command("npm", "run", "build")
build.Dir = dir
build.Env = os.Environ()
build.Stdout = os.Stdout
build.Stderr = os.Stderr
if err := build.Run(); err != nil {
return fmt.Errorf("npm run build failed: %w", err)
}
return nil
}

// publishClientPackage runs `npm publish` (or `npm publish --dry-run`) in dir.
// The package's publishConfig selects the registry (GitHub Packages); auth for
// a real publish is supplied by the environment (e.g. GITHUB_TOKEN in CI).
Expand Down
27 changes: 27 additions & 0 deletions docs/cli-reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ version: 1
| `release` | struct | no | | | Release configuration |
| `release.tag_format` | string | no | `{subdir}/v{version}` | | Tag pattern; must contain {version} |
| `release.ci_only` | boolean | no | `true` | | Restrict releasing to CI environments |
| `release.verify_clients` | struct | no | | | Generate-and-compile gate (`apx client verify`) |
| `release.verify_clients.generators` | list | no | | | Generators to verify; empty means the default matrix (go, typescript-angular) |
| `release.verify_clients.warn_only` | boolean | no | `false` | | Downgrade a generate/compile failure to a warning instead of failing the gate (overridden by `--warn-only`) |
| `tools` | struct | no | | | Pinned tool versions |
| `tools.buf` | struct | no | | | Buf CLI settings |
| `tools.buf.version` | string | no | | | Buf CLI version |
Expand Down Expand Up @@ -383,6 +386,30 @@ Available generators:
- `typescript-angular` (default) — orchestrates `ng-openapi-gen`, emits an npm package; built/published with `npm`.
- `go` — orchestrates [`oapi-codegen`](https://github.com/oapi-codegen/oapi-codegen), emits a buildable Go module (typed client + models). For this generator `--package` is the Go **module path** (e.g. `github.com/you/your-client`) and `--scope` is ignored. `apx client generate --generator go --build` compile-verifies with `go build`; `apx client publish --generator go` records a `go-module` release artifact (Go modules are published by git tag in their own repo). The generated types honor the enriched OpenAPI contract from devedge-sdk (`enum`, `required`, `readOnly`/`writeOnly`); `x-aip-*` vendor extensions are ignored by the tool.

#### `apx client verify` — the generate-and-compile gate

`apx client verify` generates a client and **compiles** it, failing when any generated client does not build. A spec can be valid OpenAPI 3 and pass `apx lint`/`apx breaking` yet still produce a client that does not compile — e.g. redundant `_limit`/`limit` query params that normalize to the same Go field, or a path parameter named `url` that shadows the `net/url` import. This gate catches those before a module is released.

```bash
# Verify the auto-detected spec across the default matrix (go + typescript-angular):
apx client verify

# Scope to one generator (typical in release CI):
apx client verify --generator go --input openapi/notesd.openapi.yaml

# Soft-launch: report failures without failing the build:
apx client verify --warn-only
```

Each generator runs in a throwaway directory. A generator whose toolchain is absent is **skipped** (not failed), so a bare `apx client verify` covers Go always and TypeScript where Node is present. The command exits non-zero when any verified client fails to build, unless `--warn-only` (or [`release.verify_clients.warn_only`](#release)) downgrades that to a warning. Configure the matrix and default policy under `release.verify_clients`:

```yaml
release:
verify_clients:
generators: [go, typescript-angular] # empty => default matrix
warn_only: false # gate by default; --warn-only overrides
```

### `external_apis`

Registers third-party APIs for inclusion in the catalog and dependency system. See [External API Registration](../dependencies/external-apis.md) for full workflow documentation.
Expand Down
20 changes: 20 additions & 0 deletions docs/releasing/release-guardrails.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,26 @@ APX rejects backward transitions (e.g., `stable` → `beta`).
| Breaking change on `v1+` stable | Rejected — requires new major line |
| Breaking change on `deprecated` | Rejected — maintenance only |

## Client Build Verification

A spec can be valid OpenAPI 3 and pass `apx lint`/`apx breaking` yet still
produce a client that does not compile — for example redundant `_limit`/`limit`
query params that normalize to the same Go field, or a path parameter named
`url` that shadows the `net/url` import. `apx client verify` closes that gap: it
generates a client and **compiles** it, failing the release when any generated
client does not build.

| Scenario | APX behavior |
|----------|-------------|
| Generated client compiles | Passes |
| Generated client does not compile | Rejected — fails the gate |
| Generator toolchain absent (e.g. Node for TypeScript) | Skipped — not a failure |
| `--warn-only` (or `release.verify_clients.warn_only`) set | Failure downgraded to a warning (exit 0) |

Run it in release CI between `apx breaking` and `apx release prepare`. See
[`apx client verify`](../cli-reference/configuration.md#apx-client-verify--the-generate-and-compile-gate)
for configuration.

## See Also

- [Lifecycle Reference](lifecycle.md) — full lifecycle state definitions and policies
Expand Down
13 changes: 13 additions & 0 deletions internal/client/angular.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ type angularGenerator struct{}

func (g *angularGenerator) Name() string { return "typescript-angular" }

// ToolchainAvailable reports whether the Node toolchain (npx for ng-openapi-gen,
// npm for the build) is on PATH. Implements ToolchainChecker, so client
// verification skips this generator (rather than failing) where Node is absent.
func (g *angularGenerator) ToolchainAvailable() (bool, string) {
if _, err := exec.LookPath("npx"); err != nil {
return false, "npx not found on PATH (install Node >=18)"
}
if _, err := exec.LookPath("npm"); err != nil {
return false, "npm not found on PATH (install Node >=18)"
}
return true, ""
}

func (g *angularGenerator) Generate(ctx context.Context, gc GenerateContext) (Result, error) {
// Preflight: the generator shells out to npx.
if _, err := exec.LookPath("npx"); err != nil {
Expand Down
54 changes: 54 additions & 0 deletions internal/client/build.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package client

import (
"context"
"fmt"
"os"
"os/exec"

"github.com/infobloxopen/apx/internal/ui"
)

// Build compile-verifies the generated package described by res. A Builder-aware
// generator (e.g. the go generator: `go mod tidy` + `go build`) supplies its own
// build; generators without a Builder (e.g. typescript-angular) use the npm build
// path. The command layer MUST NOT publish a package whose Build failed.
//
// Centralized here (rather than in the command layer) so `generate --build`,
// `publish`, and `verify` all compile-verify identically.
func Build(ctx context.Context, gen Generator, res Result) error {
if b, ok := gen.(Builder); ok {
return b.Build(ctx, res)
}
return npmBuild(res.PackageDir)
}

// npmBuild runs `npm install` then `npm run build` in dir to prove the generated
// package compiles. Output streams to the process stdio so build failures are
// visible verbatim.
func npmBuild(dir string) error {
if _, err := exec.LookPath("npm"); err != nil {
return fmt.Errorf("npm required to build the client; install Node >=18: %w", err)
}

ui.Info("Running npm install in %s ...", dir)
install := exec.Command("npm", "install")
install.Dir = dir
install.Env = os.Environ()
install.Stdout = os.Stdout
install.Stderr = os.Stderr
if err := install.Run(); err != nil {
return fmt.Errorf("npm install failed: %w", err)
}

ui.Info("Running npm run build in %s ...", dir)
build := exec.Command("npm", "run", "build")
build.Dir = dir
build.Env = os.Environ()
build.Stdout = os.Stdout
build.Stderr = os.Stderr
if err := build.Run(); err != nil {
return fmt.Errorf("npm run build failed: %w", err)
}
return nil
}
9 changes: 9 additions & 0 deletions internal/client/goclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ type goGenerator struct{}

func (g *goGenerator) Name() string { return "go" }

// ToolchainAvailable reports whether the Go toolchain (needed for oapi-codegen
// via `go run` and for `go build`) is on PATH. Implements ToolchainChecker.
func (g *goGenerator) ToolchainAvailable() (bool, string) {
if _, err := exec.LookPath("go"); err != nil {
return false, "Go toolchain not found on PATH (install Go >=1.22)"
}
return true, ""
}

func (g *goGenerator) Generate(ctx context.Context, gc GenerateContext) (Result, error) {
// Preflight: the generator shells out to `go run` for oapi-codegen.
if _, err := exec.LookPath("go"); err != nil {
Expand Down
Loading
Loading