[management] switch to libopenapi for managing of openapi-based api (#8056)

* use libopenapi OpenAPI generator

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* added a handler for v1alpha1/peers

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* wire up request validator

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* wired up spec-based validation

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* use sync validation; set base url to v1alpha1

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* moved stuff around, extracted runtime libopenapu deps into runtime_tooling

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* testing /peers path params

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* fixed tests

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* added user schemas and paths

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* use api validation in tests

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* post-merge fixes

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* deleted tmp command used to test validator integration

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* require request body in post/put requests in order to force validation

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* making linter happy

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* switch to api/v1alpha1

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* go mod tidy

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* keep the original order of middleware: metrics, cors, then auth

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* return 422 on validation errors

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* cleanups

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* more cleanups

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* v1alpha1 spec cleanups

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* no need for a double-pointer

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* more linter fixes

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* removed more double pointers

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* use di to inject api_v0 and api_v1 http routers

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* do not re-add middleware on repeated call to ApiHandler

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* calling ApiRouter() now also calls ApiV1Router()

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

---------

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
This commit is contained in:
dmitri-netbird
2026-10-07 19:07:31 +02:00
committed by GitHub
parent 192d3d0344
commit 24cb7b75c2
25 changed files with 4509 additions and 64 deletions
@@ -0,0 +1,152 @@
package apiv1alpha1
import (
"log/slog"
"os"
"path/filepath"
"strings"
"unicode"
"github.com/pb33f/libopenapi"
"github.com/pb33f/libopenapi/bundler"
"github.com/pb33f/libopenapi/datamodel"
v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
"github.com/pb33f/libopenapi/generator/golang"
)
var ApiPath = filepath.Join("shared", "management", "http", "apiv1alpha1", "openapi.yaml")
func GenerateV1ApiBindings(openapipath string) ([]byte, *v3.Document, error) {
specFile, err := os.ReadFile(openapipath)
if err != nil {
return nil, nil, err
}
multiFileDoc, err := libopenapi.NewDocumentWithConfiguration(specFile, &datamodel.DocumentConfiguration{
AllowFileReferences: true,
BasePath: filepath.Dir(openapipath),
SpecFilePath: openapipath,
ExtractRefsSequentially: true,
Logger: slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelError,
})),
TransformSiblingRefs: true, // enable openapi 3.1 compliance by default
MergeReferencedProperties: true, // enable enhanced resolution by default
PropertyMergeStrategy: datamodel.PreserveLocal, // local properties take precedence
})
if err != nil {
return nil, nil, err
}
multiFileModel, err := multiFileDoc.BuildV3Model()
if err != nil {
return nil, nil, err
}
bundle, err := bundler.BundleDocumentComposed(&multiFileModel.Model, &bundler.BundleCompositionConfig{
StrictValidation: true,
})
if err != nil {
return nil, nil, err
}
doc, err := libopenapi.NewDocumentWithConfiguration(bundle, &datamodel.DocumentConfiguration{
Logger: slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})),
})
if err != nil {
return nil, nil, err
}
model, err := doc.BuildV3Model()
if err != nil {
return nil, nil, err
}
return bundle, &model.Model, nil
}
func GenerateV1Schema(model *v3.Document) (*golang.GeneratedFile, error) {
// render every schema in components.schemas into one file
gen := golang.NewGenerator(
golang.WithGeneratedComment(true),
golang.WithFormatMapping("date-time", "time.Time", "time"),
golang.WithOptionalFieldsAsPointers(true),
golang.WithEnumConstants(true),
golang.WithNestedTypeNameDelimiter(""),
golang.WithPackageName("apiv1alpha1"),
golang.WithFieldNameResolver(toPublicName))
return gen.RenderSchemas(model.Components.Schemas)
}
// this is to keep existing naming of fields like "id", "url", etc
// libopenapi by default converts them to all-uppercase, like "ID", "URL", etc
func toPublicName(name string) string {
parts := splitIdentifier(name)
if len(parts) == 0 {
return "Value"
}
var b strings.Builder
for _, p := range parts {
rs := []rune(strings.ToLower(p))
rs[0] = unicode.ToUpper(rs[0])
b.WriteString(string(rs))
}
out := b.String()
first := []rune(out)[0]
if unicode.IsDigit(first) {
return "Value" + out
}
return out
}
func splitIdentifier(name string) []string {
var raw []string
var b strings.Builder
flush := func() {
if b.Len() > 0 {
raw = append(raw, b.String())
b.Reset()
}
}
for _, r := range name {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(r)
default:
flush()
}
}
flush()
var parts []string
for _, part := range raw {
parts = append(parts, splitCamel(part)...)
}
return parts
}
func splitCamel(value string) []string {
rs := []rune(value)
if len(rs) == 0 {
return nil
}
var parts []string
start := 0
for i := 1; i < len(rs); i++ {
prev := rs[i-1]
cur := rs[i]
var next rune
if i+1 < len(rs) {
next = rs[i+1]
}
lowerToUpper := unicode.IsLower(prev) && unicode.IsUpper(cur)
acronymToWord := unicode.IsUpper(prev) && unicode.IsUpper(cur) && next != 0 && unicode.IsLower(next)
if lowerToUpper || acronymToWord {
parts = append(parts, string(rs[start:i]))
start = i
}
}
parts = append(parts, string(rs[start:]))
return parts
}