diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index b85057d66..074d7023d 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -6,13 +6,23 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - ruby: [ '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4', head] + ruby: ['2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4', '4.0', head] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 + - name: Setup Image + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update && sudo apt-get install -y libpcap-dev - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: Run Go erbrenderer tests + run: go test ./templatescompiler/erbrenderer/... + continue-on-error: ${{ matrix.ruby == 'head' }} - run: bundle install working-directory: templatescompiler/erbrenderer/ - run: bundle exec rake diff --git a/deployment/instance/state/builder.go b/deployment/instance/state/builder.go index 08c8ef439..26e7b12f7 100644 --- a/deployment/instance/state/builder.go +++ b/deployment/instance/state/builder.go @@ -225,7 +225,7 @@ func (b *builder) renderJobTemplates( func (b *builder) defaultAddress(networkRefs []NetworkRef, agentState agentclient.AgentState) (string, error) { - if (networkRefs == nil) || (len(networkRefs) == 0) { + if len(networkRefs) == 0 { return "", errors.New("Must specify network") //nolint:staticcheck } diff --git a/templatescompiler/erbrenderer/.gitignore b/templatescompiler/erbrenderer/.gitignore index 68feb7d26..72bcafc04 100644 --- a/templatescompiler/erbrenderer/.gitignore +++ b/templatescompiler/erbrenderer/.gitignore @@ -1 +1,3 @@ -Gemfile.lock \ No newline at end of file +.bundle/ +vendor/bundle/ +Gemfile.lock diff --git a/templatescompiler/erbrenderer/erb_renderer.rb b/templatescompiler/erbrenderer/erb_renderer.rb index 6bc7778f5..fe39e988a 100644 --- a/templatescompiler/erbrenderer/erb_renderer.rb +++ b/templatescompiler/erbrenderer/erb_renderer.rb @@ -1,9 +1,44 @@ # Based on common/properties/template_evaluation_context.rb -require "rubygems" -require "ostruct" -require "json" -require "erb" -require "yaml" +require 'rubygems' +require 'json' +require 'erb' +require 'yaml' + +# Simple struct-like class to replace OpenStruct dependency +# OpenStruct is being removed from Ruby standard library in Ruby 3.5+ +class PropertyStruct + def initialize(hash = {}) + @table = {} + hash.each do |key, value| + @table[key.to_sym] = wrap_value(value) + end + end + + def method_missing(method_name, *args) + if method_name.to_s.end_with?('=') + @table[method_name.to_s.chomp('=').to_sym] = wrap_value(args.first) + else + @table[method_name.to_sym] + end + end + + def respond_to_missing?(method_name, _include_private = false) + @table.key?(method_name.to_sym) || method_name.to_s.end_with?('=') + end + + private + + def wrap_value(value) + case value + when Hash + PropertyStruct.new(value) + when Array + value.map { |item| wrap_value(item) } + else + value + end + end +end class Hash def recursive_merge!(other) @@ -19,22 +54,20 @@ def recursive_merge!(other) end class TemplateEvaluationContext - attr_reader :name, :index - attr_reader :properties, :raw_properties - attr_reader :spec + attr_reader :name, :index, :properties, :raw_properties, :spec def initialize(spec) - @name = spec["job"]["name"] if spec["job"].is_a?(Hash) - @index = spec["index"] + @name = spec['job']['name'] if spec['job'].is_a?(Hash) + @index = spec['index'] - properties1 = if !spec["job_properties"].nil? - spec["job_properties"] - else - spec["global_properties"].recursive_merge!(spec["cluster_properties"]) - end + properties1 = if !spec['job_properties'].nil? + spec['job_properties'] + else + spec['global_properties'].recursive_merge!(spec['cluster_properties']) + end properties = {} - spec["default_properties"].each do |name, value| + spec['default_properties'].each do |name, value| copy_property(properties, properties1, name, value) end @@ -56,6 +89,7 @@ def p(*args) end return args[1] if args.length == 2 + raise UnknownProperty.new(names) end @@ -63,6 +97,7 @@ def if_p(*names) values = names.map do |name| value = lookup_property(@raw_properties, name) return ActiveElseBlock.new(self) if value.nil? + value end @@ -70,14 +105,14 @@ def if_p(*names) InactiveElseBlock.new end - def if_link(name) + def if_link(_name) false end private def copy_property(dst, src, name, default = nil) - keys = name.split(".") + keys = name.split('.') src_ref = src dst_ref = dst @@ -98,10 +133,10 @@ def copy_property(dst, src, name, default = nil) def openstruct(object) case object when Hash - mapped = object.each_with_object({}) { |(k, v), h| + mapped = object.each_with_object({}) do |(k, v), h| h[k] = openstruct(v) - } - OpenStruct.new(mapped) + end + PropertyStruct.new(mapped) when Array object.map { |item| openstruct(item) } else @@ -110,7 +145,7 @@ def openstruct(object) end def lookup_property(collection, name) - keys = name.split(".") + keys = name.split('.') ref = collection keys.each do |key| @@ -139,24 +174,23 @@ def else yield end - def else_if_p(*names, &block) # rubocop:disable Style/ArgumentsForwarding - @context.if_p(*names, &block) # rubocop:disable Style/ArgumentsForwarding + def else_if_p(*names, &block) + @context.if_p(*names, &block) end end class InactiveElseBlock - def else - end + def else; end - def else_if_p(*names) + def else_if_p(*_names) InactiveElseBlock.new end end end -# todo do not use JSON in releases +# TODO: do not use JSON in releases class << JSON - alias_method :dump_array_or_hash, :dump + alias dump_array_or_hash dump def dump(*args) arg = args[0] @@ -174,10 +208,10 @@ def initialize(json_context_path) end def render(src_path, dst_path) - erb = ERB.new(File.read(src_path), trim_mode: "-") + erb = ERB.new(File.read(src_path), trim_mode: '-') erb.filename = src_path - # Note: JSON.load_file was added in v2.3.1: https://github.com/ruby/json/blob/v2.3.1/lib/json/common.rb#L286 + # NOTE: JSON.load_file was added in v2.3.1: https://github.com/ruby/json/blob/v2.3.1/lib/json/common.rb#L286 context_hash = JSON.parse(File.read(@json_context_path)) template_evaluation_context = TemplateEvaluationContext.new(context_hash) @@ -186,7 +220,7 @@ def render(src_path, dst_path) name = "#{template_evaluation_context&.name}/#{template_evaluation_context&.index}" line_i = e.backtrace.index { |l| l.include?(erb&.filename.to_s) } - line_num = line_i ? e.backtrace[line_i].split(":")[1] : "unknown" + line_num = line_i ? e.backtrace[line_i].split(':')[1] : 'unknown' location = "(line #{line_num}: #{e.inspect})" raise("Error filling in template '#{src_path}' for #{name} #{location}") diff --git a/templatescompiler/erbrenderer/erb_renderer_test.go b/templatescompiler/erbrenderer/erb_renderer_test.go index b52a37ed4..2398d1175 100644 --- a/templatescompiler/erbrenderer/erb_renderer_test.go +++ b/templatescompiler/erbrenderer/erb_renderer_test.go @@ -19,6 +19,7 @@ import ( type testTemplateEvaluationStruct struct { Index int `json:"index"` ID string `json:"id"` + IP string `json:"ip,omitempty"` GlobalProperties map[string]interface{} `json:"global_properties"` ClusterProperties map[string]interface{} `json:"cluster_properties"` DefaultProperties map[string]interface{} `json:"default_properties"` @@ -117,6 +118,1216 @@ property3: default_value3 }) }) + Context("with nested property access patterns", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "director": map[string]interface{}{ + "db": map[string]interface{}{ + "user": "admin", + "password": "secret", + "host": "localhost", + "port": 5432, + }, + "name": "test-director", + }, + "nats": map[string]interface{}{ + "address": "10.0.0.1", + "port": 4222, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "director.db.user": "default_user", + "director.db.password": "default_pass", + "director.db.host": "default_host", + "director.db.port": "default_port", + "director.name": "default_name", + "nats.address": "default_nats", + "nats.port": "default_port", + }, + }, + } + }) + + It("accesses deeply nested properties with dot notation", func() { + erbTemplateContent = `db_user: <%= p('director.db.user') %> +db_pass: <%= p('director.db.password') %> +db_host: <%= p('director.db.host') %> +nats_addr: <%= p('nats.address') %>` + expectedTemplateContents = `db_user: admin +db_pass: secret +db_host: localhost +nats_addr: 10.0.0.1` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with property defaults", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{}, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "existing_prop": "default_value", + }, + }, + } + }) + + It("uses default values for missing properties", func() { + erbTemplateContent = `has_default: <%= p('missing_prop', 'fallback_value') %> +no_default: <%= p('existing_prop') %>` + expectedTemplateContents = `has_default: fallback_value +no_default: default_value` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with if_p conditional property helper", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "enabled_feature": true, + "feature_config": map[string]interface{}{ + "host": "example.com", + "port": 8080, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "enabled_feature": "default", + "feature_config.host": "default", + "feature_config.port": "default", + "missing_feature.host": "default", + }, + }, + } + }) + + It("executes block when property exists", func() { + erbTemplateContent = `<% if_p('feature_config.host') do |host| %> +host_configured: <%= host %> +<% end -%>` + expectedTemplateContents = ` +host_configured: example.com +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("supports multiple properties in if_p", func() { + erbTemplateContent = `<% if_p('feature_config.host', 'feature_config.port') do |host, port| %> +config: <%= host %>:<%= port %> +<% end -%>` + expectedTemplateContents = ` +config: example.com:8080 +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("skips block when property is missing", func() { + erbTemplateContent = `before +<% if_p('completely_missing_prop') do |host| %> +should_not_appear: <%= host %> +<% end -%> +after` + expectedTemplateContents = `before +after` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with array of hashes property access", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "users": []interface{}{ + map[string]interface{}{ + "name": "alice", + "password": "secret1", + }, + map[string]interface{}{ + "name": "bob", + "password": "secret2", + }, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "users": "default", + }, + }, + } + }) + + It("iterates over array and accesses hash elements", func() { + erbTemplateContent = `<% p('users').each do |user| %> +user: <%= user['name'] %> pass: <%= user['password'] %> +<% end -%>` + expectedTemplateContents = ` +user: alice pass: secret1 + +user: bob pass: secret2 +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with spec object access", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 42, + ID: "uuid-123-456", + IP: "192.168.1.100", + GlobalProperties: map[string]interface{}{}, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{}, + }, + } + }) + + It("accesses spec properties via struct notation", func() { + erbTemplateContent = `index: <%= spec.index %> +id: <%= spec.id %> +ip: <%= spec.ip %>` + expectedTemplateContents = `index: 42 +id: uuid-123-456 +ip: 192.168.1.100` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with complex nested object creation", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "blobstore": map[string]interface{}{ + "provider": "s3", + "s3": map[string]interface{}{ + "bucket": "my-bucket", + "access_key": "AKIAIOSFODNN7EXAMPLE", + }, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "blobstore.provider": "default", + "blobstore.s3.bucket": "default", + "blobstore.s3.access_key": "default", + }, + }, + } + }) + + It("builds nested hash structures from properties", func() { + erbTemplateContent = `<%= +config = { + 'provider' => p('blobstore.provider'), + 'options' => { + 'bucket' => p('blobstore.s3.bucket'), + 'access_key' => p('blobstore.s3.access_key') + } +} +require 'json' +JSON.dump(config) +%>` + expectedTemplateContents = `{"provider":"s3","options":{"bucket":"my-bucket","access_key":"AKIAIOSFODNN7EXAMPLE"}}` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with boolean and numeric property types", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "features": map[string]interface{}{ + "enabled": true, + "max_count": 100, + "timeout": 30.5, + "debug_mode": false, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "features.enabled": "default", + "features.max_count": "default", + "features.timeout": "default", + "features.debug_mode": "default", + }, + }, + } + }) + + It("handles boolean and numeric property values", func() { + erbTemplateContent = `enabled: <%= p('features.enabled') %> +max_count: <%= p('features.max_count') %> +timeout: <%= p('features.timeout') %> +debug: <%= p('features.debug_mode') %>` + expectedTemplateContents = `enabled: true +max_count: 100 +timeout: 30.5 +debug: false` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("uses booleans in conditionals", func() { + erbTemplateContent = `<% if p('features.enabled') -%> +feature is enabled +<% end -%> +<% if !p('features.debug_mode') -%> +debug is disabled +<% end -%>` + expectedTemplateContents = `feature is enabled +debug is disabled +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with array map operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "ports": []interface{}{"8080", "8443", "9000"}, + "servers": []interface{}{ + map[string]interface{}{"host": "10.0.0.1", "port": 8080}, + map[string]interface{}{"host": "10.0.0.2", "port": 8081}, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "ports": "default", + "servers": "default", + }, + }, + } + }) + + It("converts array elements using map with symbol", func() { + erbTemplateContent = `<%= p('ports').map(&:to_i).inspect %>` + expectedTemplateContents = `[8080, 8443, 9000]` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("transforms array elements using map with block", func() { + erbTemplateContent = `<% p('servers').map { |s| s['host'] }.each do |host| %> +host: <%= host %> +<% end -%>` + expectedTemplateContents = ` +host: 10.0.0.1 + +host: 10.0.0.2 +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with array filtering operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "ca_certs": []interface{}{ + "", + " ", + nil, + "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", + "short", + "-----BEGIN CERTIFICATE-----\nMIID...\n-----END CERTIFICATE-----", + }, + "ports": []interface{}{8080, nil, 8443, nil, 9000}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "ca_certs": "default", + "ports": "default", + }, + }, + } + }) + + It("filters array elements using select", func() { + erbTemplateContent = `<%= p('ca_certs').select{ |v| !v.nil? && !v.strip.empty? && v.length > 50 }.length %>` + expectedTemplateContents = `2` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("removes nil values using compact", func() { + erbTemplateContent = `<%= p('ports').compact.inspect %>` + expectedTemplateContents = `[8080, 8443, 9000]` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with method chaining on property values", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "multiline_cert": "-----BEGIN CERTIFICATE-----\nline1\nline2\n-----END CERTIFICATE-----\n", + "url": "https://example.com:8443/path", + "yaml_data": map[string]interface{}{ + "key": "value", + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "multiline_cert": "default", + "url": "default", + "yaml_data": "default", + }, + }, + } + }) + + It("chains methods on string properties", func() { + erbTemplateContent = `<%= p('url').split(':')[0] %>` + expectedTemplateContents = `https` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("processes multiline strings with lines and map", func() { + erbTemplateContent = `<%= p('multiline_cert').lines.map { |line| " #{line.rstrip}" }.join("\n") %>` + expectedTemplateContents = ` -----BEGIN CERTIFICATE----- + line1 + line2 + -----END CERTIFICATE-----` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("chains to_yaml and gsub on hash properties", func() { + erbTemplateContent = `<%= p('yaml_data').to_yaml.gsub("---","").strip %>` + expectedTemplateContents = `key: value` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with each_with_index iteration", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "routes": []interface{}{ + map[string]interface{}{"uri": "api.example.com"}, + map[string]interface{}{"uri": "www.example.com"}, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "routes": "default", + }, + }, + } + }) + + It("iterates with index access", func() { + erbTemplateContent = `<% p('routes').each_with_index do |route, index| %> +route_<%= index %>: <%= route['uri'] %> +<% end -%>` + expectedTemplateContents = ` +route_0: api.example.com + +route_1: www.example.com +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with hash key access and membership testing", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "clients": map[string]interface{}{ + "client_a": map[string]interface{}{"secret": "secret_a"}, + "client_b": map[string]interface{}{"secret": "secret_b"}, + }, + "config": map[string]interface{}{ + "optional_key": "value", + "required_key": "required", + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "clients": "default", + "config": "default", + }, + }, + } + }) + + It("accesses hash keys and sorts them", func() { + erbTemplateContent = `<%= p('clients').keys.sort.first %>` + expectedTemplateContents = `client_a` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("checks for hash key membership", func() { + erbTemplateContent = `<% if p('config').key?('optional_key') %> +has_key: true +<% end -%>` + expectedTemplateContents = ` +has_key: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with conditional string operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "api_url": "https://api.example.com", + "cert": "", + "endpoint": "routing-api.service.cf.internal", + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "api_url": "default", + "cert": "default", + "endpoint": "default", + }, + }, + } + }) + + It("checks string prefix with start_with?", func() { + erbTemplateContent = `<% if p('api_url').start_with?('https') %> +secure: true +<% end -%>` + expectedTemplateContents = ` +secure: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("checks for empty strings", func() { + erbTemplateContent = `<% if p('cert') == "" %> +no_cert: true +<% end -%>` + expectedTemplateContents = ` +no_cert: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("performs string replacement with gsub", func() { + erbTemplateContent = `<%= p('endpoint').gsub('.internal', '.external') %>` + expectedTemplateContents = `routing-api.service.cf.external` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with array find operation", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "databases": []interface{}{ + map[string]interface{}{"tag": "uaa", "name": "uaadb"}, + map[string]interface{}{"tag": "admin", "name": "postgres"}, + }, + "providers": []interface{}{ + map[string]interface{}{"type": "internal", "name": "default"}, + map[string]interface{}{"type": "hsm", "name": "thales"}, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "databases": "default", + "providers": "default", + }, + }, + } + }) + + It("finds elements in array by condition", func() { + erbTemplateContent = `<% db = p('databases').find { |d| d['tag'] == 'uaa' } %> +db_name: <%= db['name'] %> +<% provider = p('providers').find { |p| p['type'] == 'hsm' } %> +provider_name: <%= provider['name'] %>` + expectedTemplateContents = ` +db_name: uaadb + +provider_name: thales` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with array flatten operation", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "nested_providers": []interface{}{ + []interface{}{ + map[string]interface{}{"type": "internal"}, + map[string]interface{}{"type": "hsm"}, + }, + []interface{}{ + map[string]interface{}{"type": "kms-plugin"}, + }, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "nested_providers": "default", + }, + }, + } + }) + + It("flattens nested arrays", func() { + erbTemplateContent = `<%= p('nested_providers').flatten.length %>` + expectedTemplateContents = `3` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with any? predicate", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "providers": []interface{}{ + map[string]interface{}{"type": "internal"}, + map[string]interface{}{"type": "hsm"}, + }, + "empty_list": []interface{}{}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "providers": "default", + "empty_list": "default", + }, + }, + } + }) + + It("checks if any element matches condition", func() { + erbTemplateContent = `<% if p('providers').any? { |p| p['type'] == 'hsm' } -%> +using_hsm: true +<% end -%> +<% if !p('empty_list').any? -%> +list_empty: true +<% end -%>` + expectedTemplateContents = `using_hsm: true +list_empty: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with nil? and empty? checks", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "optional_cert": "", + "required_key": "actual_value", + "empty_array": []interface{}{}, + "filled_array": []interface{}{"item"}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "optional_cert": "default", + "required_key": "default", + "empty_array": "default", + "filled_array": "default", + }, + }, + } + }) + + It("checks for empty strings and arrays", func() { + erbTemplateContent = `<% if p('optional_cert').empty? -%> +no_cert: true +<% end -%> +<% if !p('required_key').empty? -%> +has_key: true +<% end -%> +<% if p('empty_array').empty? -%> +array_empty: true +<% end -%> +<% if !p('filled_array').empty? -%> +array_filled: true +<% end -%>` + expectedTemplateContents = `no_cert: true +has_key: true +array_empty: true +array_filled: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with include? membership testing", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "valid_modes": []interface{}{"legacy", "exact"}, + "selected_mode": "exact", + "tls_modes": []interface{}{"enabled", "disabled"}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "valid_modes": "default", + "selected_mode": "default", + "tls_modes": "default", + }, + }, + } + }) + + It("checks array membership", func() { + erbTemplateContent = `<% if p('valid_modes').include?(p('selected_mode')) -%> +valid_selection: true +<% end -%> +<% if p('tls_modes').include?('enabled') -%> +supports_tls: true +<% end -%>` + expectedTemplateContents = `valid_selection: true +supports_tls: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with reject and uniq operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "providers": []interface{}{ + map[string]interface{}{"name": "p1", "enabled": true}, + map[string]interface{}{"name": "p2", "enabled": false}, + map[string]interface{}{"name": "p3", "enabled": true}, + }, + "types": []interface{}{"internal", "hsm", "internal", "kms-plugin"}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "providers": "default", + "types": "default", + }, + }, + } + }) + + It("rejects unwanted elements", func() { + erbTemplateContent = `<%= p('providers').reject { |p| !p['enabled'] }.length %>` + expectedTemplateContents = `2` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("removes duplicate values", func() { + erbTemplateContent = `<%= p('types').uniq.inspect %>` + expectedTemplateContents = `["internal", "hsm", "kms-plugin"]` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with hash values and merge operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "config": map[string]interface{}{ + "host": "localhost", + "port": 5432, + "timeout": 30, + }, + "defaults": map[string]interface{}{ + "timeout": 60, + "retries": 3, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "config": "default", + "defaults": "default", + }, + }, + } + }) + + It("extracts hash values", func() { + erbTemplateContent = `<%= p('config').values.sort_by(&:to_s).inspect %>` + expectedTemplateContents = `[30, 5432, "localhost"]` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("merges hashes", func() { + erbTemplateContent = `<% merged = p('defaults').merge(p('config')) %> +timeout: <%= merged['timeout'] %> +retries: <%= merged['retries'] %>` + expectedTemplateContents = ` +timeout: 30 +retries: 3` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with string index and type conversion operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "cert_with_newlines": "-----BEGIN CERTIFICATE-----\nMIIC...", + "cert_without_newlines": "-----BEGIN CERTIFICATE-----MIIC...", + "port_string": "8443", + "timeout_number": 30, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "cert_with_newlines": "default", + "cert_without_newlines": "default", + "port_string": "default", + "timeout_number": "default", + }, + }, + } + }) + + It("finds substring positions with index", func() { + erbTemplateContent = `<% if p('cert_with_newlines').index("\n").nil? %> +no_real_newline: true +<% else %> +has_real_newline: true +<% end -%> +<% if p('cert_without_newlines').index("\n").nil? %> +no_escaped_newline: true +<% end -%>` + expectedTemplateContents = ` +has_real_newline: true + +no_escaped_newline: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("converts types with to_i and to_s", func() { + erbTemplateContent = `port_number: <%= p('port_string').to_i %> +timeout_string: <%= p('timeout_number').to_s %>` + expectedTemplateContents = `port_number: 8443 +timeout_string: 30` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with first and last array accessors", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "servers": []interface{}{ + "server1.example.com", + "server2.example.com", + "server3.example.com", + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "servers": "default", + }, + }, + } + }) + + It("accesses first and last array elements", func() { + erbTemplateContent = `primary: <%= p('servers').first %> +backup: <%= p('servers').last %>` + expectedTemplateContents = `primary: server1.example.com +backup: server3.example.com` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with join operation on arrays", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "ciphers": []interface{}{ + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + }, + "scopes": []interface{}{"openid", "profile", "email"}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "ciphers": "default", + "scopes": "default", + }, + }, + } + }) + + It("joins array elements with delimiter", func() { + erbTemplateContent = `ciphers: <%= p('ciphers').join(',') %> +scopes: <%= p('scopes').join(' ') %>` + expectedTemplateContents = `ciphers: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 +scopes: openid profile email` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + Describe("error handling within Ruby", func() { var ( // see erb_renderer.rb diff --git a/templatescompiler/erbrenderer/spec/property_struct_spec.rb b/templatescompiler/erbrenderer/spec/property_struct_spec.rb new file mode 100644 index 000000000..c5953641d --- /dev/null +++ b/templatescompiler/erbrenderer/spec/property_struct_spec.rb @@ -0,0 +1,87 @@ +require 'spec_helper' +require 'erb_renderer' + +RSpec.describe 'PropertyStruct' do + describe 'initialization and attribute access' do + it 'provides dynamic attribute access for hash keys' do + ps = PropertyStruct.new(name: 'test', value: 42) + expect(ps.name).to eq('test') + expect(ps.value).to eq(42) + end + + it 'converts string keys to symbols' do + ps = PropertyStruct.new('name' => 'test', 'value' => 42) + expect(ps.name).to eq('test') + expect(ps.value).to eq(42) + end + + it 'supports nested attribute access' do + ps = PropertyStruct.new(config: { database: { host: 'localhost', port: 5432 } }) + nested = ps.config + expect(nested).to be_a(PropertyStruct) + expect(nested.database).to be_a(PropertyStruct) + expect(nested.database.host).to eq('localhost') + expect(nested.database.port).to eq(5432) + end + + it 'handles arrays of hashes' do + ps = PropertyStruct.new(servers: [{ name: 'web1', ip: '10.0.0.1' }, { name: 'web2', ip: '10.0.0.2' }]) + servers = ps.servers + expect(servers).to be_an(Array) + expect(servers.length).to eq(2) + expect(servers.first.name).to eq('web1') + expect(servers.last.ip).to eq('10.0.0.2') + end + + it 'responds to method queries correctly' do + ps = PropertyStruct.new(existing_key: 'value') + expect(ps.respond_to?(:existing_key)).to be true + expect(ps.respond_to?(:nonexistent_key)).to be false + end + end + + describe 'Ruby standard library method pass-through' do + it 'supports array operations like map' do + ps = PropertyStruct.new(ports: [8080, 8081, 8082]) + expect(ps.ports.map(&:to_s)).to eq(%w[8080 8081 8082]) + end + + it 'supports string operations' do + ps = PropertyStruct.new(url: 'https://example.com') + expect(ps.url.start_with?('https')).to be true + expect(ps.url.split('://')).to eq(['https', 'example.com']) + end + + it 'supports hash operations via direct access' do + ps = PropertyStruct.new(config: { a: 1, b: 2, c: 3 }) + expect(ps.config.a).to eq(1) + expect(ps.config.b).to eq(2) + expect(ps.config.c).to eq(3) + end + + it 'supports nil and empty checks' do + ps = PropertyStruct.new(empty_string: '', nil_value: nil, filled: 'data') + expect(ps.empty_string.empty?).to be true + expect(ps.nil_value.nil?).to be true + expect(ps.filled.nil?).to be false + end + end + + describe 'compatibility across Ruby versions' do + it 'works with ERB rendering' do + template = ERB.new("<%= ps.name.upcase %>: <%= ps.ports.join(',') %>") + ps = PropertyStruct.new(name: 'service', ports: [80, 443, 8080]) + result = template.result(binding) + expect(result).to eq('SERVICE: 80,443,8080') + end + + it 'maintains OpenStruct API compatibility' do + # Test that PropertyStruct can be used as a drop-in replacement for OpenStruct + ps = PropertyStruct.new(field1: 'value1', field2: 'value2') + expect(ps).to respond_to(:field1) + expect(ps).to respond_to(:field2) + expect(ps.field1).to eq('value1') + expect(ps.field2).to eq('value2') + end + end +end