2026-08-19 20:52:13 -07:00
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"net"
|
|
|
|
|
"os"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-08-26 16:39:30 -07:00
|
|
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
2026-08-19 20:52:13 -07:00
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// rawConfig represents the structure of the YAML configuration file.
|
|
|
|
|
type rawConfig struct {
|
2026-08-26 16:39:30 -07:00
|
|
|
Routes []rawRoute `yaml:"routes"`
|
|
|
|
|
Models []rawModel `yaml:"models"`
|
|
|
|
|
Adapters []rawAdapter `yaml:"adapters"`
|
2026-08-19 20:52:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// rawRoute represents a single route in the YAML configuration.
|
|
|
|
|
type rawRoute struct {
|
|
|
|
|
Name string `yaml:"name"`
|
|
|
|
|
Upstream rawUpstream `yaml:"upstream"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// rawModel represents a single model entry in the YAML configuration.
|
|
|
|
|
type rawModel struct {
|
|
|
|
|
Name string `yaml:"name"`
|
|
|
|
|
Address string `yaml:"address"`
|
|
|
|
|
Path string `yaml:"path"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 16:39:30 -07:00
|
|
|
// rawAdapter represents a service adapter in the YAML configuration.
|
|
|
|
|
type rawAdapter struct {
|
|
|
|
|
ServiceName string `yaml:"serviceName"`
|
|
|
|
|
Upstream struct {
|
|
|
|
|
URL string `yaml:"url"`
|
|
|
|
|
TimeoutSeconds int32 `yaml:"timeoutSeconds"`
|
|
|
|
|
} `yaml:"upstream"`
|
|
|
|
|
Auth struct {
|
|
|
|
|
Required bool `yaml:"required"`
|
|
|
|
|
Capability string `yaml:"capability"`
|
|
|
|
|
} `yaml:"auth"`
|
|
|
|
|
Retryable bool `yaml:"retryable"`
|
|
|
|
|
Resources []struct {
|
|
|
|
|
Name string `yaml:"name"`
|
|
|
|
|
Methods []struct {
|
|
|
|
|
Verb string `yaml:"verb"`
|
|
|
|
|
UpstreamPath string `yaml:"upstreamPath"`
|
|
|
|
|
RequestSchema string `yaml:"requestSchema"`
|
|
|
|
|
ResponseSchema string `yaml:"responseSchema"`
|
|
|
|
|
} `yaml:"methods"`
|
|
|
|
|
} `yaml:"resources"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 20:52:13 -07:00
|
|
|
// rawUpstream represents upstream configuration in YAML.
|
|
|
|
|
type rawUpstream struct {
|
|
|
|
|
Address string `yaml:"address"`
|
|
|
|
|
PathRewrite string `yaml:"pathRewrite"`
|
|
|
|
|
ConnectTimeout string `yaml:"connectTimeout"`
|
|
|
|
|
ReadTimeout string `yaml:"readTimeout"`
|
|
|
|
|
WriteTimeout string `yaml:"writeTimeout"`
|
|
|
|
|
MaxBodySize int64 `yaml:"maxBodySize"`
|
|
|
|
|
AuthRequired *bool `yaml:"authRequired"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 16:39:30 -07:00
|
|
|
// LoadRoutesAndModelsFromFile loads route, model, and adapter configuration from a YAML file.
|
|
|
|
|
func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*ModelUpstream, []*serviceadapter.ServiceAdapter, error) {
|
2026-08-19 20:52:13 -07:00
|
|
|
data, err := os.ReadFile(path)
|
|
|
|
|
if err != nil {
|
2026-08-26 16:39:30 -07:00
|
|
|
return nil, nil, nil, fmt.Errorf("failed to read config file %q: %w", path, err)
|
2026-08-19 20:52:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var raw rawConfig
|
|
|
|
|
if err := yaml.Unmarshal(data, &raw); err != nil {
|
2026-08-26 16:39:30 -07:00
|
|
|
return nil, nil, nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
|
2026-08-19 20:52:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Load routes
|
|
|
|
|
routes := make(map[string]*Route)
|
|
|
|
|
for _, rawRoute := range raw.Routes {
|
|
|
|
|
if rawRoute.Name == "" {
|
2026-08-26 16:39:30 -07:00
|
|
|
return nil, nil, nil, fmt.Errorf("route has empty name")
|
2026-08-19 20:52:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, exists := routes[rawRoute.Name]; exists {
|
2026-08-26 16:39:30 -07:00
|
|
|
return nil, nil, nil, fmt.Errorf("duplicate route: \"%s\"", rawRoute.Name)
|
2026-08-19 20:52:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
upstream, err := parseUpstream(rawRoute.Name, rawRoute.Upstream)
|
|
|
|
|
if err != nil {
|
2026-08-26 16:39:30 -07:00
|
|
|
return nil, nil, nil, err
|
2026-08-19 20:52:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
routes[rawRoute.Name] = &Route{
|
|
|
|
|
Name: rawRoute.Name,
|
|
|
|
|
Upstream: upstream,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Load models
|
|
|
|
|
models := make(map[string]*ModelUpstream)
|
|
|
|
|
for _, rawModel := range raw.Models {
|
|
|
|
|
if rawModel.Name == "" {
|
2026-08-26 16:39:30 -07:00
|
|
|
return nil, nil, nil, fmt.Errorf("model has empty name")
|
2026-08-19 20:52:13 -07:00
|
|
|
}
|
|
|
|
|
if _, exists := models[rawModel.Name]; exists {
|
2026-08-26 16:39:30 -07:00
|
|
|
return nil, nil, nil, fmt.Errorf("duplicate model: \"%s\"", rawModel.Name)
|
2026-08-19 20:52:13 -07:00
|
|
|
}
|
|
|
|
|
if rawModel.Address == "" {
|
2026-08-26 16:39:30 -07:00
|
|
|
return nil, nil, nil, fmt.Errorf("model \"%s\": field 'address' is required", rawModel.Name)
|
2026-08-19 20:52:13 -07:00
|
|
|
}
|
|
|
|
|
if _, _, err := net.SplitHostPort(rawModel.Address); err != nil {
|
2026-08-26 16:39:30 -07:00
|
|
|
return nil, nil, nil, fmt.Errorf("model \"%s\": invalid address \"%s\": %w", rawModel.Name, rawModel.Address, err)
|
2026-08-19 20:52:13 -07:00
|
|
|
}
|
|
|
|
|
models[rawModel.Name] = &ModelUpstream{
|
|
|
|
|
Name: rawModel.Name,
|
|
|
|
|
Address: rawModel.Address,
|
|
|
|
|
Path: rawModel.Path,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 16:39:30 -07:00
|
|
|
// Load adapters
|
|
|
|
|
adapters := make([]*serviceadapter.ServiceAdapter, 0, len(raw.Adapters))
|
|
|
|
|
for _, ra := range raw.Adapters {
|
|
|
|
|
if ra.ServiceName == "" {
|
|
|
|
|
return nil, nil, nil, fmt.Errorf("adapter has empty serviceName")
|
|
|
|
|
}
|
|
|
|
|
a := &serviceadapter.ServiceAdapter{
|
|
|
|
|
Name: ra.ServiceName,
|
|
|
|
|
ServiceName: ra.ServiceName,
|
|
|
|
|
CreatedAt: time.Now(),
|
|
|
|
|
}
|
|
|
|
|
a.Spec.ServiceName = ra.ServiceName
|
|
|
|
|
a.Spec.Upstream.URL = ra.Upstream.URL
|
|
|
|
|
a.Spec.Upstream.TimeoutSeconds = ra.Upstream.TimeoutSeconds
|
|
|
|
|
a.Spec.Auth.Required = ra.Auth.Required
|
|
|
|
|
a.Spec.Auth.Capability = ra.Auth.Capability
|
|
|
|
|
a.Spec.Retryable = ra.Retryable
|
|
|
|
|
for _, rr := range ra.Resources {
|
|
|
|
|
res := serviceadapter.Resource{Name: rr.Name}
|
|
|
|
|
for _, rm := range rr.Methods {
|
|
|
|
|
res.Methods = append(res.Methods, serviceadapter.Method{
|
|
|
|
|
Verb: rm.Verb,
|
|
|
|
|
UpstreamPath: rm.UpstreamPath,
|
|
|
|
|
RequestSchema: rm.RequestSchema,
|
|
|
|
|
ResponseSchema: rm.ResponseSchema,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
a.Spec.Resources = append(a.Spec.Resources, res)
|
|
|
|
|
}
|
|
|
|
|
adapters = append(adapters, a)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return routes, models, adapters, nil
|
2026-08-19 20:52:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// LoadRoutesFromFile loads route configuration from a YAML file.
|
|
|
|
|
// Deprecated: Use LoadRoutesAndModelsFromFile instead.
|
|
|
|
|
func LoadRoutesFromFile(path string) (map[string]*Route, error) {
|
2026-08-26 16:39:30 -07:00
|
|
|
routes, _, _, err := LoadRoutesAndModelsFromFile(path)
|
2026-08-19 20:52:13 -07:00
|
|
|
return routes, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parseUpstream validates and parses upstream configuration from raw YAML.
|
|
|
|
|
func parseUpstream(routeName string, raw rawUpstream) (Upstream, error) {
|
|
|
|
|
// Validate address is not empty
|
|
|
|
|
if raw.Address == "" {
|
|
|
|
|
return Upstream{}, fmt.Errorf("route \"%s\": field 'address' is required", routeName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate address format (host:port)
|
|
|
|
|
if _, _, err := net.SplitHostPort(raw.Address); err != nil {
|
|
|
|
|
return Upstream{}, fmt.Errorf("route \"%s\": invalid address \"%s\": %w", routeName, raw.Address, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate connectTimeout
|
|
|
|
|
if raw.ConnectTimeout == "" {
|
|
|
|
|
return Upstream{}, fmt.Errorf("route \"%s\": field 'connectTimeout' is required", routeName)
|
|
|
|
|
}
|
|
|
|
|
connectTimeout, err := time.ParseDuration(raw.ConnectTimeout)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Upstream{}, fmt.Errorf("route \"%s\": invalid connectTimeout \"%s\": %w", routeName, raw.ConnectTimeout, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate readTimeout
|
|
|
|
|
if raw.ReadTimeout == "" {
|
|
|
|
|
return Upstream{}, fmt.Errorf("route \"%s\": field 'readTimeout' is required", routeName)
|
|
|
|
|
}
|
|
|
|
|
readTimeout, err := time.ParseDuration(raw.ReadTimeout)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Upstream{}, fmt.Errorf("route \"%s\": invalid readTimeout \"%s\": %w", routeName, raw.ReadTimeout, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate writeTimeout
|
|
|
|
|
if raw.WriteTimeout == "" {
|
|
|
|
|
return Upstream{}, fmt.Errorf("route \"%s\": field 'writeTimeout' is required", routeName)
|
|
|
|
|
}
|
|
|
|
|
writeTimeout, err := time.ParseDuration(raw.WriteTimeout)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Upstream{}, fmt.Errorf("route \"%s\": invalid writeTimeout \"%s\": %w", routeName, raw.WriteTimeout, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate maxBodySize is not zero (it must be explicitly set)
|
|
|
|
|
if raw.MaxBodySize == 0 {
|
|
|
|
|
return Upstream{}, fmt.Errorf("route \"%s\": field 'maxBodySize' is required and must be > 0", routeName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate authRequired is not missing
|
|
|
|
|
if raw.AuthRequired == nil {
|
|
|
|
|
return Upstream{}, fmt.Errorf("route \"%s\": field 'authRequired' is required", routeName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Upstream{
|
|
|
|
|
Address: raw.Address,
|
|
|
|
|
PathRewrite: raw.PathRewrite,
|
|
|
|
|
ConnectTimeout: connectTimeout,
|
|
|
|
|
ReadTimeout: readTimeout,
|
|
|
|
|
WriteTimeout: writeTimeout,
|
|
|
|
|
MaxBodySize: raw.MaxBodySize,
|
|
|
|
|
AuthRequired: *raw.AuthRequired,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|