add support for env variables to the DSL

This commit is contained in:
2025-09-02 00:54:38 -06:00
parent c6f14e1787
commit 69f507f176
12 changed files with 842 additions and 84 deletions

98
lang/debug_env_test.go Normal file
View File

@ -0,0 +1,98 @@
package lang
import (
"testing"
)
func TestSimpleEnvVar(t *testing.T) {
// Test the simplest possible environment variable syntax
input := `
server MyApp {
host env "HOST"
}
`
ast, err := ParseInput(input)
if err != nil {
t.Fatalf("Parse error: %v", err)
}
server := ast.Definitions[0].Server
if server.Settings[0].Host.EnvVar == nil {
t.Fatalf("Expected environment variable")
}
if server.Settings[0].Host.EnvVar.Name != "HOST" {
t.Errorf("Expected HOST, got %s", server.Settings[0].Host.EnvVar.Name)
}
}
func TestLiteralValue(t *testing.T) {
// Test that literal values still work
input := `
server MyApp {
host "localhost"
}
`
ast, err := ParseInput(input)
if err != nil {
t.Fatalf("Parse error: %v", err)
}
server := ast.Definitions[0].Server
if server.Settings[0].Host.Literal == nil {
t.Fatalf("Expected literal value")
}
if *server.Settings[0].Host.Literal != "localhost" {
t.Errorf("Expected localhost, got %s", *server.Settings[0].Host.Literal)
}
}
func TestEnvVarWithDefault(t *testing.T) {
// Test environment variable with default value
input := `
server MyApp {
host env "HOST" default "localhost"
}
`
ast, err := ParseInput(input)
if err != nil {
t.Fatalf("Parse error: %v", err)
}
server := ast.Definitions[0].Server
envVar := server.Settings[0].Host.EnvVar
if envVar == nil {
t.Fatalf("Expected environment variable")
}
if envVar.Name != "HOST" {
t.Errorf("Expected HOST, got %s", envVar.Name)
}
if envVar.Default == nil || *envVar.Default != "localhost" {
t.Errorf("Expected default localhost")
}
}
func TestEnvVarRequired(t *testing.T) {
// Test environment variable with required flag
input := `
server MyApp {
api_key env "API_KEY" required
}
`
ast, err := ParseInput(input)
if err != nil {
t.Fatalf("Parse error: %v", err)
}
server := ast.Definitions[0].Server
envVar := server.Settings[0].APIKey.EnvVar
if envVar == nil {
t.Fatalf("Expected environment variable")
}
if !envVar.Required {
t.Errorf("Expected required to be true")
}
}

View File

@ -4,12 +4,12 @@ import (
"github.com/alecthomas/participle/v2"
)
// Root AST node containing all definitions
// AST Root AST node containing all definitions
type AST struct {
Definitions []Definition `parser:"@@*"`
}
// Union type for top-level definitions
// Definition Union type for top-level definitions
type Definition struct {
Server *Server `parser:"@@"`
Entity *Entity `parser:"| @@"`
@ -17,25 +17,48 @@ type Definition struct {
Page *Page `parser:"| @@"`
}
// Clean server syntax
// ConfigValue Flexible value that can be literal or environment variable
type ConfigValue struct {
Literal *string `parser:"@String"`
EnvVar *EnvVar `parser:"| @@"`
}
// EnvVar Environment variable configuration
type EnvVar struct {
Name string `parser:"'env' @String"`
Default *string `parser:"('default' @String)?"`
Required bool `parser:"@'required'?"`
}
// Server Clean server syntax
type Server struct {
Name string `parser:"'server' @Ident"`
Settings []ServerSetting `parser:"('{' @@* '}')?"` // Block-delimited settings for consistency
}
type ServerSetting struct {
Host *string `parser:"('host' @String)"`
Port *int `parser:"| ('port' @Int)"`
Host *ConfigValue `parser:"('host' @@)"`
Port *IntValue `parser:"| ('port' @@)"`
DatabaseURL *ConfigValue `parser:"| ('database_url' @@)"`
APIKey *ConfigValue `parser:"| ('api_key' @@)"`
SSLCert *ConfigValue `parser:"| ('ssl_cert' @@)"`
SSLKey *ConfigValue `parser:"| ('ssl_key' @@)"`
}
// Clean entity syntax with better readability
// IntValue Similar to ConfigValue but for integers
type IntValue struct {
Literal *int `parser:"@Int"`
EnvVar *EnvVar `parser:"| @@"`
}
// Entity Clean entity syntax with better readability
type Entity struct {
Name string `parser:"'entity' @Ident"`
Description *string `parser:"('desc' @String)?"`
Fields []Field `parser:"('{' @@* '}')?"` // Block-delimited fields for consistency
}
// Much cleaner field syntax
// Field Detailed field syntax
type Field struct {
Name string `parser:"@Ident ':'"`
Type string `parser:"@Ident"`
@ -45,15 +68,24 @@ type Field struct {
Default *string `parser:"('default' @String)?"`
Validations []Validation `parser:"@@*"`
Relationship *Relationship `parser:"@@?"`
Endpoints []string `parser:"('endpoints' '[' @Ident (',' @Ident)* ']')?"` // with transforms this might not be needed
Transform []Transform `parser:"@@*"`
}
// Simple validation syntax
// Transform Field transformation specification
type Transform struct {
Type string `parser:"'transform' @Ident"`
Column *string `parser:"('to' @Ident)?"`
Direction *string `parser:"('on' @('input' | 'output' | 'both'))?"`
}
// Validation Simple validation syntax
type Validation struct {
Type string `parser:"'validate' @Ident"`
Value *string `parser:"@String?"`
}
// Clear relationship syntax
// Relationship Clear relationship syntax
type Relationship struct {
Type string `parser:"'relates' 'to' @Ident"`
Cardinality string `parser:"'as' @('one' | 'many')"`
@ -73,7 +105,7 @@ type Endpoint struct {
CustomLogic *string `parser:"('custom' @String)? '}')?"` // Close block after all content
}
// Clean parameter syntax
// EndpointParam Clean parameter syntax
type EndpointParam struct {
Name string `parser:"'param' @Ident ':'"`
Type string `parser:"@Ident"`
@ -81,14 +113,14 @@ type EndpointParam struct {
Source string `parser:"'from' @('path' | 'query' | 'body')"`
}
// Response specification
// ResponseSpec Response specification
type ResponseSpec struct {
Type string `parser:"'returns' @Ident"`
Format *string `parser:"('as' @String)?"`
Fields []string `parser:"('fields' '[' @Ident (',' @Ident)* ']')?"`
}
// Enhanced Page definitions with unified section model
// Page Enhanced Page definitions with unified section model
type Page struct {
Name string `parser:"'page' @Ident"`
Path string `parser:"'at' @String"`
@ -101,13 +133,13 @@ type Page struct {
Components []Component `parser:"@@* '}')?"` // Direct components within the block
}
// Meta tags for SEO
// MetaTag Meta tags for SEO
type MetaTag struct {
Name string `parser:"'meta' @Ident"`
Content string `parser:"@String"`
}
// Unified Section type that replaces Container, Tab, Panel, Modal, MasterDetail
// Section Unified Section type that replaces Container, Tab, Panel, Modal, MasterDetail
type Section struct {
Name string `parser:"'section' @Ident"`
Type *string `parser:"('type' @('container' | 'tab' | 'panel' | 'modal' | 'master' | 'detail'))?"`
@ -120,7 +152,7 @@ type Section struct {
Elements []SectionElement `parser:"('{' @@* '}')?"` // Block-delimited elements for unambiguous nesting
}
// New unified element type for sections
// SectionElement New unified element type for sections
type SectionElement struct {
Attribute *SectionAttribute `parser:"@@"`
Component *Component `parser:"| @@"`
@ -128,7 +160,7 @@ type SectionElement struct {
When *WhenCondition `parser:"| @@"`
}
// Flexible section attributes (replaces complex config types)
// SectionAttribute Flexible section attributes (replaces complex config types)
type SectionAttribute struct {
DataSource *string `parser:"('data' 'from' @String)"`
Style *string `parser:"| ('style' @String)"`
@ -137,14 +169,14 @@ type SectionAttribute struct {
Theme *string `parser:"| ('theme' @String)"`
}
// Simplified Component with unified attributes - reordered for better parsing
// Component Simplified Component with unified attributes - reordered for better parsing
type Component struct {
Type string `parser:"'component' @Ident"`
Entity *string `parser:"('for' @Ident)?"`
Elements []ComponentElement `parser:"('{' @@* '}')?"` // Parse everything inside the block
}
// Enhanced ComponentElement with recursive section support - now includes attributes
// ComponentElement Enhanced ComponentElement with recursive section support - now includes attributes
type ComponentElement struct {
Attribute *ComponentAttr `parser:"@@"` // Component attributes can be inside the block
Field *ComponentField `parser:"| @@"`
@ -153,7 +185,7 @@ type ComponentElement struct {
When *WhenCondition `parser:"| @@"`
}
// Simplified component attributes using key-value pattern - reordered for precedence
// ComponentAttr Simplified component attributes using key-value pattern - reordered for precedence
type ComponentAttr struct {
DataSource *string `parser:"('data' 'from' @String)"`
Fields []string `parser:"| ('fields' '[' @Ident (',' @Ident)* ']')"`
@ -164,14 +196,14 @@ type ComponentAttr struct {
Validate bool `parser:"| @'validate'"`
}
// Enhanced component field with detailed configuration using flexible attributes
// ComponentField Enhanced component field with detailed configuration using flexible attributes
type ComponentField struct {
Name string `parser:"'field' @Ident"`
Type string `parser:"'type' @Ident"`
Attributes []ComponentFieldAttribute `parser:"@@* ('{' @@* '}')?"` // Support both inline and block attributes
}
// Flexible field attribute system
// ComponentFieldAttribute Flexible field attribute system
type ComponentFieldAttribute struct {
Label *string `parser:"('label' @String)"`
Placeholder *string `parser:"| ('placeholder' @String)"`
@ -192,18 +224,18 @@ type ComponentFieldAttribute struct {
Validation *ComponentValidation `parser:"| @@"`
}
// Field relationship for autocomplete and select fields
// FieldRelation Field relationship for autocomplete and select fields
type FieldRelation struct {
Type string `parser:"'relates' 'to' @Ident"`
}
// Component validation
// ComponentValidation Component validation
type ComponentValidation struct {
Type string `parser:"'validate' @Ident"`
Value *string `parser:"@String?"`
}
// Enhanced WhenCondition with recursive support for both sections and components
// WhenCondition Enhanced WhenCondition with recursive support for both sections and components
type WhenCondition struct {
Field string `parser:"'when' @Ident"`
Operator string `parser:"@('equals' | 'not_equals' | 'contains')"`
@ -214,14 +246,14 @@ type WhenCondition struct {
Buttons []ComponentButton `parser:"@@* '}')?"` // Block-delimited for unambiguous nesting
}
// Simplified button with flexible attribute ordering
// ComponentButton Simplified button with flexible attribute ordering
type ComponentButton struct {
Name string `parser:"'button' @Ident"`
Label string `parser:"'label' @String"`
Attributes []ComponentButtonAttr `parser:"@@*"`
}
// Flexible button attribute system - each attribute is a separate alternative
// ComponentButtonAttr Flexible button attribute system - each attribute is a separate alternative
type ComponentButtonAttr struct {
Style *ComponentButtonStyle `parser:"@@"`
Icon *ComponentButtonIcon `parser:"| @@"`
@ -233,7 +265,7 @@ type ComponentButtonAttr struct {
Via *ComponentButtonVia `parser:"| @@"`
}
// Individual button attribute types
// ComponentButtonStyle Individual button attribute types
type ComponentButtonStyle struct {
Value string `parser:"'style' @String"`
}

342
lang/parser_env_test.go Normal file
View File

@ -0,0 +1,342 @@
package lang
import (
"testing"
)
func TestServerWithEnvironmentVariables(t *testing.T) {
input := `
server MyApp {
host env "HOST" default "localhost"
port env "PORT" default "8080"
database_url env "DATABASE_URL" required
api_key env "API_SECRET_KEY" required
}
`
ast, err := ParseInput(input)
if err != nil {
t.Fatalf("Parse error: %v", err)
}
if len(ast.Definitions) != 1 {
t.Fatalf("Expected 1 definition, got %d", len(ast.Definitions))
}
server := ast.Definitions[0].Server
if server == nil {
t.Fatalf("Expected server definition")
}
if server.Name != "MyApp" {
t.Errorf("Expected server name 'MyApp', got '%s'", server.Name)
}
if len(server.Settings) != 4 {
t.Fatalf("Expected 4 settings, got %d", len(server.Settings))
}
// Test host setting
hostSetting := server.Settings[0]
if hostSetting.Host == nil {
t.Fatalf("Expected host setting")
}
if hostSetting.Host.EnvVar == nil {
t.Fatalf("Expected host to be environment variable")
}
if hostSetting.Host.EnvVar.Name != "HOST" {
t.Errorf("Expected env var name 'HOST', got '%s'", hostSetting.Host.EnvVar.Name)
}
if hostSetting.Host.EnvVar.Default == nil || *hostSetting.Host.EnvVar.Default != "localhost" {
t.Errorf("Expected default 'localhost'")
}
// Test port setting
portSetting := server.Settings[1]
if portSetting.Port == nil {
t.Fatalf("Expected port setting")
}
if portSetting.Port.EnvVar == nil {
t.Fatalf("Expected port to be environment variable")
}
if portSetting.Port.EnvVar.Name != "PORT" {
t.Errorf("Expected env var name 'PORT', got '%s'", portSetting.Port.EnvVar.Name)
}
if portSetting.Port.EnvVar.Default == nil || *portSetting.Port.EnvVar.Default != "8080" {
t.Errorf("Expected default '8080'")
}
// Test required database_url
dbSetting := server.Settings[2]
if dbSetting.DatabaseURL == nil {
t.Fatalf("Expected database_url setting")
}
if dbSetting.DatabaseURL.EnvVar == nil {
t.Fatalf("Expected database_url to be environment variable")
}
if !dbSetting.DatabaseURL.EnvVar.Required {
t.Errorf("Expected database_url to be required")
}
// Test required api_key
apiSetting := server.Settings[3]
if apiSetting.APIKey == nil {
t.Fatalf("Expected api_key setting")
}
if apiSetting.APIKey.EnvVar == nil {
t.Fatalf("Expected api_key to be environment variable")
}
if !apiSetting.APIKey.EnvVar.Required {
t.Errorf("Expected api_key to be required")
}
}
func TestServerWithMixedValues(t *testing.T) {
input := `
server MyApp {
host "localhost"
port env "PORT" default "8080"
database_url env "DATABASE_URL" required
}
`
ast, err := ParseInput(input)
if err != nil {
t.Fatalf("Parse error: %v", err)
}
server := ast.Definitions[0].Server
if server == nil {
t.Fatalf("Expected server definition")
}
// Test literal host
hostSetting := server.Settings[0]
if hostSetting.Host == nil {
t.Fatalf("Expected host setting")
}
if hostSetting.Host.Literal == nil {
t.Fatalf("Expected host to be literal value")
}
if *hostSetting.Host.Literal != "localhost" {
t.Errorf("Expected literal value 'localhost', got '%s'", *hostSetting.Host.Literal)
}
// Test env port
portSetting := server.Settings[1]
if portSetting.Port == nil {
t.Fatalf("Expected port setting")
}
if portSetting.Port.EnvVar == nil {
t.Fatalf("Expected port to be environment variable")
}
}
func TestServerWithLiteralPort(t *testing.T) {
input := `
server MyApp {
host "localhost"
port 8080
}
`
ast, err := ParseInput(input)
if err != nil {
t.Fatalf("Parse error: %v", err)
}
server := ast.Definitions[0].Server
portSetting := server.Settings[1]
if portSetting.Port == nil {
t.Fatalf("Expected port setting")
}
if portSetting.Port.Literal == nil {
t.Fatalf("Expected port to be literal value")
}
if *portSetting.Port.Literal != 8080 {
t.Errorf("Expected literal port 8080, got %d", *portSetting.Port.Literal)
}
}
func TestServerConfigurationsInAnyOrder(t *testing.T) {
// Test that server settings can be defined in any order
tests := []struct {
name string
input string
}{
{
name: "host first, then port",
input: `
server MyApp {
host "localhost"
port 8080
}`,
},
{
name: "port first, then host",
input: `
server MyApp {
port 8080
host "localhost"
}`,
},
{
name: "mixed literal and env vars in random order",
input: `
server MyApp {
api_key env "API_KEY" required
host "localhost"
database_url env "DATABASE_URL" default "postgres://localhost:5432/myapp"
port 8080
ssl_cert env "SSL_CERT" required
}`,
},
{
name: "all env vars in different order",
input: `
server MyApp {
ssl_key env "SSL_KEY" required
port env "PORT" default "8080"
database_url env "DATABASE_URL" required
host env "HOST" default "localhost"
api_key env "API_KEY" required
ssl_cert env "SSL_CERT" required
}`,
},
{
name: "all literal values in different order",
input: `
server MyApp {
database_url "postgres://localhost:5432/myapp"
port 3000
ssl_key "/path/to/ssl.key"
host "0.0.0.0"
api_key "secret123"
ssl_cert "/path/to/ssl.crt"
}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ast, err := ParseInput(tt.input)
if err != nil {
t.Fatalf("Parse error for %s: %v", tt.name, err)
}
if len(ast.Definitions) != 1 {
t.Fatalf("Expected 1 definition, got %d", len(ast.Definitions))
}
server := ast.Definitions[0].Server
if server == nil {
t.Fatalf("Expected server definition")
}
if server.Name != "MyApp" {
t.Errorf("Expected server name 'MyApp', got '%s'", server.Name)
}
// Verify that we can parse any number of settings in any order
if len(server.Settings) < 1 {
t.Fatalf("Expected at least 1 setting, got %d", len(server.Settings))
}
// Test that we can access specific settings regardless of order
var hasHost, hasPort bool
for _, setting := range server.Settings {
if setting.Host != nil {
hasHost = true
}
if setting.Port != nil {
hasPort = true
}
}
// For the first two tests, verify both host and port are present
if tt.name == "host first, then port" || tt.name == "port first, then host" {
if !hasHost {
t.Errorf("Expected to find host setting")
}
if !hasPort {
t.Errorf("Expected to find port setting")
}
}
t.Logf("Successfully parsed %d settings for test '%s'", len(server.Settings), tt.name)
})
}
}
func TestServerConfigurationValidation(t *testing.T) {
// Test that we can properly validate different configurations
input := `
server ProductionApp {
ssl_cert env "SSL_CERT_PATH" required
database_url env "DATABASE_URL" required
host env "HOST" default "0.0.0.0"
api_key env "SECRET_API_KEY" required
port env "PORT" default "443"
ssl_key env "SSL_KEY_PATH" required
}
`
ast, err := ParseInput(input)
if err != nil {
t.Fatalf("Parse error: %v", err)
}
server := ast.Definitions[0].Server
if server.Name != "ProductionApp" {
t.Errorf("Expected server name 'ProductionApp', got '%s'", server.Name)
}
// Create a map to easily check for specific settings
settingsMap := make(map[string]interface{})
for _, setting := range server.Settings {
if setting.Host != nil {
settingsMap["host"] = setting.Host
}
if setting.Port != nil {
settingsMap["port"] = setting.Port
}
if setting.DatabaseURL != nil {
settingsMap["database_url"] = setting.DatabaseURL
}
if setting.APIKey != nil {
settingsMap["api_key"] = setting.APIKey
}
if setting.SSLCert != nil {
settingsMap["ssl_cert"] = setting.SSLCert
}
if setting.SSLKey != nil {
settingsMap["ssl_key"] = setting.SSLKey
}
}
// Verify all expected settings are present
expectedSettings := []string{"host", "port", "database_url", "api_key", "ssl_cert", "ssl_key"}
for _, expected := range expectedSettings {
if _, exists := settingsMap[expected]; !exists {
t.Errorf("Expected to find setting '%s'", expected)
}
}
// Verify host has default value
if hostSetting, ok := settingsMap["host"].(*ConfigValue); ok {
if hostSetting.EnvVar == nil {
t.Errorf("Expected host to be environment variable")
} else if hostSetting.EnvVar.Default == nil || *hostSetting.EnvVar.Default != "0.0.0.0" {
t.Errorf("Expected host default to be '0.0.0.0'")
}
}
// Verify ssl_cert is required
if sslCertSetting, ok := settingsMap["ssl_cert"].(*ConfigValue); ok {
if sslCertSetting.EnvVar == nil {
t.Errorf("Expected ssl_cert to be environment variable")
} else if !sslCertSetting.EnvVar.Required {
t.Errorf("Expected ssl_cert to be required")
}
}
}

View File

@ -4,6 +4,35 @@ import (
"testing"
)
// Helper functions for creating ConfigValue and IntValue instances
func literalConfigValue(value string) *ConfigValue {
return &ConfigValue{Literal: &value}
}
func literalIntValue(value int) *IntValue {
return &IntValue{Literal: &value}
}
func envConfigValue(name string, defaultValue *string, required bool) *ConfigValue {
return &ConfigValue{
EnvVar: &EnvVar{
Name: name,
Default: defaultValue,
Required: required,
},
}
}
func envIntValue(name string, defaultValue *string, required bool) *IntValue {
return &IntValue{
EnvVar: &EnvVar{
Name: name,
Default: defaultValue,
Required: required,
},
}
}
func TestParseServerDefinitions(t *testing.T) {
tests := []struct {
name string
@ -23,8 +52,8 @@ func TestParseServerDefinitions(t *testing.T) {
Server: &Server{
Name: "MyApp",
Settings: []ServerSetting{
{Host: stringPtr("localhost")},
{Port: intPtr(8080)},
{Host: literalConfigValue("localhost")},
{Port: literalIntValue(8080)},
},
},
},
@ -43,7 +72,7 @@ func TestParseServerDefinitions(t *testing.T) {
Server: &Server{
Name: "WebApp",
Settings: []ServerSetting{
{Host: stringPtr("0.0.0.0")},
{Host: literalConfigValue("0.0.0.0")},
},
},
},
@ -62,7 +91,7 @@ func TestParseServerDefinitions(t *testing.T) {
Server: &Server{
Name: "APIServer",
Settings: []ServerSetting{
{Port: intPtr(3000)},
{Port: literalIntValue(3000)},
},
},
},

View File

@ -21,7 +21,68 @@ func serverEqual(got, want Server) bool {
}
func serverSettingEqual(got, want ServerSetting) bool {
return stringPtrEqual(got.Host, want.Host) && intPtrEqual(got.Port, want.Port)
return configValueEqual(got.Host, want.Host) &&
intValueEqual(got.Port, want.Port) &&
configValueEqual(got.DatabaseURL, want.DatabaseURL) &&
configValueEqual(got.APIKey, want.APIKey) &&
configValueEqual(got.SSLCert, want.SSLCert) &&
configValueEqual(got.SSLKey, want.SSLKey)
}
func configValueEqual(got, want *ConfigValue) bool {
if got == nil && want == nil {
return true
}
if got == nil || want == nil {
return false
}
// Check literal values
if got.Literal != nil && want.Literal != nil {
return *got.Literal == *want.Literal
}
if got.Literal != nil || want.Literal != nil {
return false
}
// Check environment variables
if got.EnvVar != nil && want.EnvVar != nil {
return envVarEqual(*got.EnvVar, *want.EnvVar)
}
return got.EnvVar == nil && want.EnvVar == nil
}
func intValueEqual(got, want *IntValue) bool {
if got == nil && want == nil {
return true
}
if got == nil || want == nil {
return false
}
// Check literal values
if got.Literal != nil && want.Literal != nil {
return *got.Literal == *want.Literal
}
if got.Literal != nil || want.Literal != nil {
return false
}
// Check environment variables
if got.EnvVar != nil && want.EnvVar != nil {
return envVarEqual(*got.EnvVar, *want.EnvVar)
}
return got.EnvVar == nil && want.EnvVar == nil
}
func envVarEqual(got, want EnvVar) bool {
if got.Name != want.Name {
return false
}
if got.Required != want.Required {
return false
}
return stringPtrEqual(got.Default, want.Default)
}
func entityEqual(got, want Entity) bool {