avro-gen-ruby 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +34 -0
  3. data/.github/workflows/release.yml +31 -0
  4. data/.gitignore +6 -0
  5. data/.rspec +1 -0
  6. data/.rubocop.yml +100 -0
  7. data/Gemfile +5 -0
  8. data/LICENSE +21 -0
  9. data/README.md +61 -0
  10. data/Rakefile +11 -0
  11. data/avro-gen-ruby.gemspec +32 -0
  12. data/lib/avro_gen/avro_parser.rb +64 -0
  13. data/lib/avro_gen/configuration.rb +60 -0
  14. data/lib/avro_gen/errors.rb +6 -0
  15. data/lib/avro_gen/generator/templates/schema_class.rb.tt +8 -0
  16. data/lib/avro_gen/generator/templates/schema_enum.rb.tt +13 -0
  17. data/lib/avro_gen/generator/templates/schema_record.rb.tt +102 -0
  18. data/lib/avro_gen/generator.rb +375 -0
  19. data/lib/avro_gen/railtie.rb +12 -0
  20. data/lib/avro_gen/schema_class/base.rb +62 -0
  21. data/lib/avro_gen/schema_class/enum.rb +48 -0
  22. data/lib/avro_gen/schema_class/record.rb +108 -0
  23. data/lib/avro_gen/schema_class.rb +62 -0
  24. data/lib/avro_gen/schema_field.rb +26 -0
  25. data/lib/avro_gen/schema_validator.rb +80 -0
  26. data/lib/avro_gen/upgrader.rb +43 -0
  27. data/lib/avro_gen/version.rb +5 -0
  28. data/lib/avro_gen.rb +18 -0
  29. data/lib/tasks/avro.rake +18 -0
  30. data/regenerate_test_schema_classes.rb +32 -0
  31. data/spec/generator_spec.rb +92 -0
  32. data/spec/my_schema_spec.rb +18 -0
  33. data/spec/my_schema_with_circular_reference_spec.rb +97 -0
  34. data/spec/my_schema_with_complex_types_spec.rb +235 -0
  35. data/spec/schema_validator_spec.rb +42 -0
  36. data/spec/schemas/com/my-namespace/Generated.avsc +77 -0
  37. data/spec/schemas/com/my-namespace/MyNestedSchema.avsc +62 -0
  38. data/spec/schemas/com/my-namespace/MySchema.avsc +18 -0
  39. data/spec/schemas/com/my-namespace/MySchemaCompound_key.avsc +18 -0
  40. data/spec/schemas/com/my-namespace/MySchemaId_key.avsc +12 -0
  41. data/spec/schemas/com/my-namespace/MySchemaWithBooleans.avsc +18 -0
  42. data/spec/schemas/com/my-namespace/MySchemaWithCircularReference.avsc +39 -0
  43. data/spec/schemas/com/my-namespace/MySchemaWithComplexTypes.avsc +120 -0
  44. data/spec/schemas/com/my-namespace/MySchemaWithDateTimes.avsc +33 -0
  45. data/spec/schemas/com/my-namespace/MySchemaWithId.avsc +28 -0
  46. data/spec/schemas/com/my-namespace/MySchemaWithTitle.avsc +22 -0
  47. data/spec/schemas/com/my-namespace/MySchemaWithUnionType.avsc +91 -0
  48. data/spec/schemas/com/my-namespace/MySchemaWithUniqueId.avsc +32 -0
  49. data/spec/schemas/com/my-namespace/MySchema_key.avsc +13 -0
  50. data/spec/schemas/com/my-namespace/Wibble.avsc +43 -0
  51. data/spec/schemas/com/my-namespace/Widget.avsc +27 -0
  52. data/spec/schemas/com/my-namespace/WidgetTheSecond.avsc +27 -0
  53. data/spec/schemas/com/my-namespace/WidgetTheThird.avsc +27 -0
  54. data/spec/schemas/com/my-namespace/my-suborg/MyLongNamespaceSchema.avsc +18 -0
  55. data/spec/schemas/com/my-namespace/request/CreateTopic.avsc +11 -0
  56. data/spec/schemas/com/my-namespace/request/Index.avsc +11 -0
  57. data/spec/schemas/com/my-namespace/request/UpdateRequest.avsc +11 -0
  58. data/spec/schemas/com/my-namespace/response/CreateTopic.avsc +11 -0
  59. data/spec/schemas/com/my-namespace/response/Index.avsc +11 -0
  60. data/spec/schemas/com/my-namespace/response/UpdateResponse.avsc +11 -0
  61. data/spec/schemas/my_namespace/generated.rb +164 -0
  62. data/spec/schemas/my_namespace/my_long_namespace_schema.rb +49 -0
  63. data/spec/schemas/my_namespace/my_nested_schema.rb +126 -0
  64. data/spec/schemas/my_namespace/my_schema.rb +62 -0
  65. data/spec/schemas/my_namespace/my_schema_compound_key.rb +42 -0
  66. data/spec/schemas/my_namespace/my_schema_id_key.rb +37 -0
  67. data/spec/schemas/my_namespace/my_schema_key.rb +37 -0
  68. data/spec/schemas/my_namespace/my_schema_with_boolean.rb +42 -0
  69. data/spec/schemas/my_namespace/my_schema_with_circular_reference.rb +84 -0
  70. data/spec/schemas/my_namespace/my_schema_with_complex_type.rb +241 -0
  71. data/spec/schemas/my_namespace/my_schema_with_date_time.rb +57 -0
  72. data/spec/schemas/my_namespace/my_schema_with_id.rb +52 -0
  73. data/spec/schemas/my_namespace/my_schema_with_title.rb +47 -0
  74. data/spec/schemas/my_namespace/my_schema_with_union_type.rb +205 -0
  75. data/spec/schemas/my_namespace/my_schema_with_unique_id.rb +57 -0
  76. data/spec/schemas/my_namespace/request/create_topic.rb +37 -0
  77. data/spec/schemas/my_namespace/request/index.rb +37 -0
  78. data/spec/schemas/my_namespace/request/update_request.rb +37 -0
  79. data/spec/schemas/my_namespace/response/create_topic.rb +37 -0
  80. data/spec/schemas/my_namespace/response/index.rb +37 -0
  81. data/spec/schemas/my_namespace/response/update_response.rb +37 -0
  82. data/spec/schemas/my_namespace/wibble.rb +77 -0
  83. data/spec/schemas/my_namespace/widget.rb +57 -0
  84. data/spec/schemas/my_namespace/widget_the_second.rb +57 -0
  85. data/spec/schemas/my_namespace/widget_the_third.rb +57 -0
  86. data/spec/snapshots/consumers-no-nest.snap +1740 -0
  87. data/spec/snapshots/consumers.snap +1720 -0
  88. data/spec/snapshots/my_nested_schema.snap +121 -0
  89. data/spec/snapshots/my_schema_with_boolean.snap +44 -0
  90. data/spec/snapshots/my_schema_with_circular_reference.snap +81 -0
  91. data/spec/snapshots/my_schema_with_complex_type.snap +236 -0
  92. data/spec/snapshots/my_schema_with_date_time.snap +59 -0
  93. data/spec/snapshots/my_schema_with_union_type.snap +207 -0
  94. data/spec/snapshots/namespace_folders.snap +1800 -0
  95. data/spec/snapshots/namespace_map.snap +1800 -0
  96. data/spec/spec_helper.rb +23 -0
  97. metadata +265 -0
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'avro'
4
+ require 'schema_registry_client'
5
+ require_relative 'schema_field'
6
+
7
+ module AvroGen
8
+ # Loads an Avro schema from the configured schema path and exposes its fields.
9
+ # Used at runtime by generated Record classes (for #schema_fields) and as the
10
+ # schema loader for the generator.
11
+ class SchemaValidator
12
+ # @return [String]
13
+ attr_reader :schema
14
+ # @return [String]
15
+ attr_reader :namespace
16
+
17
+ @stores = {}
18
+
19
+ class << self
20
+ # A schema store is an in-memory cache of parsed Avro schemas. Reuse one per
21
+ # path so repeated runtime lookups (e.g. Record#schema_fields across many
22
+ # records) don't re-read and re-parse the .avsc files each time.
23
+ # @param path [String]
24
+ # @return [SchemaRegistry::AvroSchemaStore]
25
+ def store_for(path)
26
+ (@stores ||= {})[path] ||= SchemaRegistry::AvroSchemaStore.new(path: path)
27
+ end
28
+
29
+ # Drop cached stores; call when the schemas on disk may have changed.
30
+ # @return [void]
31
+ def clear_store_cache!
32
+ @stores = {}
33
+ end
34
+ end
35
+
36
+ # @param schema [String]
37
+ # @param namespace [String]
38
+ # @param path [String] location of .avsc files; defaults to the configured schema_path
39
+ # @param store [SchemaRegistry::AvroSchemaStore] an existing store to reuse; when
40
+ # omitted a fresh, isolated store is created (the generator relies on this).
41
+ def initialize(schema:, namespace:, path: nil, store: nil)
42
+ @schema = schema
43
+ @namespace = namespace
44
+ @path = path || AvroGen.config.schema_path
45
+ @schema_store = store
46
+ end
47
+
48
+ # @return [SchemaRegistry::AvroSchemaStore]
49
+ def schema_store
50
+ @schema_store ||= SchemaRegistry::AvroSchemaStore.new(path: @path)
51
+ end
52
+
53
+ # Forcefully loads the schema into memory.
54
+ # @return [Avro::Schema]
55
+ def load_schema
56
+ avro_schema
57
+ end
58
+
59
+ # @return [Avro::Schema::NamedSchema]
60
+ def avro_schema(schema=nil)
61
+ schema ||= @schema
62
+ schema_store.find("#{@namespace}.#{schema}")
63
+ end
64
+
65
+ # @return [Array<AvroGen::SchemaField>]
66
+ def schema_fields
67
+ avro_schema.fields.map do |field|
68
+ enum_values = field.type.type == 'enum' ? field.type.symbols : []
69
+ AvroGen::SchemaField.new(field.name, field.type, enum_values, field.default)
70
+ end
71
+ end
72
+
73
+ # @return [void]
74
+ def validate(payload, schema: nil)
75
+ Avro::SchemaValidator.validate!(avro_schema(schema), payload,
76
+ recursive: true,
77
+ fail_on_extra_fields: true)
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'configuration'
4
+
5
+ module AvroGen
6
+ # Rewrites references in checked-in generated schema-class files from the old
7
+ # Deimos constants to their AvroGen equivalents, so users can migrate off the
8
+ # deprecated `const_missing` fallback in one shot.
9
+ module Upgrader
10
+ REPLACEMENTS = {
11
+ 'Deimos::SchemaClass::Record' => 'AvroGen::SchemaClass::Record',
12
+ 'Deimos::SchemaClass::Enum' => 'AvroGen::SchemaClass::Enum',
13
+ 'Deimos::SchemaClass::Base' => 'AvroGen::SchemaClass::Base',
14
+ 'Deimos::Utils::SchemaClass' => 'AvroGen::SchemaClass'
15
+ }.freeze
16
+
17
+ class << self
18
+ # @param path [String] directory containing generated classes; defaults to config
19
+ # @return [Array<String>] the list of files that were rewritten
20
+ def run(path: nil)
21
+ path ||= AvroGen.config.generated_class_path
22
+ changed = []
23
+ Dir["#{path}/**/*.rb"].each do |file|
24
+ original = File.read(file)
25
+ updated = rewrite(original)
26
+ next if updated == original
27
+
28
+ File.write(file, updated)
29
+ changed << file
30
+ end
31
+ changed
32
+ end
33
+
34
+ # @param contents [String]
35
+ # @return [String]
36
+ def rewrite(contents)
37
+ REPLACEMENTS.reduce(contents) do |text, (from, to)|
38
+ text.gsub(from, to)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AvroGen
4
+ VERSION = '0.1.0'
5
+ end
data/lib/avro_gen.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'avro_gen/version'
4
+ require 'avro_gen/errors'
5
+ require 'avro_gen/configuration'
6
+ require 'avro_gen/schema_field'
7
+ require 'avro_gen/avro_parser'
8
+ require 'avro_gen/schema_validator'
9
+ require 'avro_gen/schema_class'
10
+ require 'avro_gen/schema_class/base'
11
+ require 'avro_gen/schema_class/enum'
12
+ require 'avro_gen/schema_class/record'
13
+
14
+ require 'avro_gen/railtie' if defined?(Rails::Railtie)
15
+
16
+ # Top-level namespace for the Avro schema-class generator.
17
+ module AvroGen
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :avro do
4
+ desc 'Generate Ruby schema classes from Avro schemas'
5
+ task generate: :environment do
6
+ require 'avro_gen/generator'
7
+ Rails.logger&.info('Running avro:generate')
8
+ AvroGen::Generator.new.generate_from_path
9
+ end
10
+
11
+ desc 'Rewrite generated schema classes to use AvroGen instead of Deimos constants'
12
+ task upgrade: :environment do
13
+ require 'avro_gen/upgrader'
14
+ changed = AvroGen::Upgrader.run
15
+ puts "Upgraded #{changed.size} file(s):"
16
+ changed.each { |f| puts " #{f}" }
17
+ end
18
+ end
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Regenerates the committed runtime schema-class fixtures under spec/schemas/my_namespace.
5
+ # These are loaded by the behaviour specs (e.g. spec/my_schema_spec.rb).
6
+ require 'avro_gen'
7
+ require 'avro_gen/generator'
8
+
9
+ AvroGen.configure do |config|
10
+ config.schema_path = 'spec/schemas'
11
+ config.generated_class_path = 'spec/schemas'
12
+ config.nest_child_schemas = true
13
+ config.use_full_namespace = true
14
+ config.schema_namespace_map = {
15
+ 'com' => 'Schemas',
16
+ 'com.my-namespace.my-suborg' => %w(Schemas MyNamespace)
17
+ }
18
+ end
19
+
20
+ # Key configs mirror the Kafka topic configs Deimos used to generate these fixtures,
21
+ # so that keyed records get their tombstone/payload_key helpers.
22
+ ns = 'com.my-namespace'
23
+ configs = [
24
+ { schema: 'Generated', namespace: ns, key_config: { field: :a_string } },
25
+ { schema: 'MyNestedSchema', namespace: ns, key_config: { field: :test_id } },
26
+ { schema: 'MySchema', namespace: ns, key_config: { schema: 'MySchema_key' } },
27
+ { schema: 'MySchemaWithComplexTypes', namespace: ns, key_config: { field: :test_id } },
28
+ { schema: 'MySchemaWithCircularReference', namespace: ns, key_config: { none: true } },
29
+ { schema: 'MyLongNamespaceSchema', namespace: "#{ns}.my-suborg", key_config: { field: :test_id } }
30
+ ]
31
+
32
+ AvroGen::Generator.new.generate_from_configs(configs)
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ # Serializes a hash of { filename => contents } into a stable snapshot string.
6
+ class MultiFileSerializer
7
+ def process_string(str)
8
+ # Ruby 3.4 changes how hashes are printed
9
+ if Gem::Version.new(RUBY_VERSION) > Gem::Version.new('3.4.0')
10
+ str.gsub(/{"(.*)" => /, '{"\1"=>')
11
+ else
12
+ str
13
+ end
14
+ end
15
+
16
+ def dump(value)
17
+ value.keys.sort.map { |k| "#{k}:\n#{process_string(value[k])}\n" }.join("\n")
18
+ end
19
+ end
20
+
21
+ RSpec.describe AvroGen::Generator do
22
+ let(:schema_class_path) { 'spec/app/lib/schema_classes' }
23
+ let(:files) { Dir["#{schema_class_path}/**/*.rb"].to_h { |f| [f, File.read(f)] } }
24
+
25
+ # A schema with a field-based key, plus every other schema in the path
26
+ # generated as a plain class. This exercises records, enums, nested records,
27
+ # complex types (arrays/maps/unions) and circular references all at once.
28
+ let(:configs) do
29
+ [{ schema: 'Generated', namespace: 'com.my-namespace', key_config: { field: :a_string } }]
30
+ end
31
+
32
+ before(:each) do
33
+ AvroGen.config.schema_path = 'spec/schemas'
34
+ AvroGen.config.generated_class_path = schema_class_path
35
+ end
36
+
37
+ after(:each) do
38
+ FileUtils.rm_rf('spec/app')
39
+ end
40
+
41
+ it 'generates the correct classes with child schemas nested' do
42
+ AvroGen.config.nest_child_schemas = true
43
+ described_class.new.generate_from_configs(configs)
44
+ expect(files).to match_snapshot('consumers', snapshot_serializer: MultiFileSerializer)
45
+ end
46
+
47
+ it 'generates the correct classes with child schemas in their own files' do
48
+ AvroGen.config.nest_child_schemas = false
49
+ described_class.new.generate_from_configs(configs)
50
+ expect(files).to match_snapshot('consumers-no-nest', snapshot_serializer: MultiFileSerializer)
51
+ end
52
+
53
+ it 'generates folders matching the full namespace' do
54
+ AvroGen.config.use_full_namespace = true
55
+ described_class.new.generate_from_configs(configs)
56
+ expect(files).to match_snapshot('namespace_folders', snapshot_serializer: MultiFileSerializer)
57
+ end
58
+
59
+ it 'generates modules according to the namespace map' do
60
+ AvroGen.config.use_full_namespace = true
61
+ AvroGen.config.schema_namespace_map = {
62
+ 'com' => 'Schemas',
63
+ 'com.my-namespace.my-suborg' => %w(Schemas MyNamespace)
64
+ }
65
+ described_class.new.generate_from_configs(configs)
66
+ expect(files).to match_snapshot('namespace_map', snapshot_serializer: MultiFileSerializer)
67
+ end
68
+
69
+ # The snapshots above cover the full output, but these focused cases make it
70
+ # explicit that each notable Avro construct generates correctly (these mirror
71
+ # the per-schema scenarios that previously lived in Deimos).
72
+ describe 'specific Avro constructs' do
73
+ before(:each) do
74
+ AvroGen.config.nest_child_schemas = true
75
+ described_class.new.generate_from_path
76
+ end
77
+
78
+ {
79
+ 'records with complex types (arrays, maps, nested records and enums)' => 'my_schema_with_complex_type',
80
+ 'records with a circular reference' => 'my_schema_with_circular_reference',
81
+ 'records with union types' => 'my_schema_with_union_type',
82
+ 'records with nested child records' => 'my_nested_schema',
83
+ 'records with date/time logical types' => 'my_schema_with_date_time',
84
+ 'records with boolean fields' => 'my_schema_with_boolean'
85
+ }.each do |description, file|
86
+ it "generates #{description}" do
87
+ expect(files.slice("#{schema_class_path}/#{file}.rb")).
88
+ to match_snapshot(file, snapshot_serializer: MultiFileSerializer)
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Schemas::MyNamespace::MySchema do
4
+ let(:key) { Schemas::MyNamespace::MySchemaKey.new(test_id: 123) }
5
+
6
+ it 'should produce a tombstone with a hash' do
7
+ result = described_class.tombstone({ test_id: 123 })
8
+ expect(result.payload_key).to eq(key)
9
+ expect(result.to_h).to eq({ payload_key: { 'test_id' => 123 } })
10
+ end
11
+
12
+ it 'should work with a record' do
13
+ key = Schemas::MyNamespace::MySchemaKey.new(test_id: 123)
14
+ result = described_class.tombstone(key)
15
+ expect(result.payload_key).to eq(key)
16
+ expect(result.to_h).to eq({ payload_key: { 'test_id' => 123 } })
17
+ end
18
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ # For testing the generated class.
4
+ RSpec.describe Schemas::MyNamespace::MySchemaWithCircularReference do
5
+ let(:payload_hash) do
6
+ {
7
+ properties: {
8
+ a_boolean: {
9
+ 'property' => true
10
+ },
11
+ an_integer: {
12
+ 'property' => 1
13
+ },
14
+ a_float: {
15
+ 'property' => 4.5
16
+ },
17
+ a_string: {
18
+ 'property' => 'string'
19
+ },
20
+ an_array: {
21
+ 'property' => [1, 2, 3]
22
+ },
23
+ an_hash: {
24
+ 'property' => {
25
+ 'a_key' => 'a_value'
26
+ }
27
+ }
28
+ }
29
+ }
30
+ end
31
+
32
+ describe 'class initialization' do
33
+ it 'should initialize the class from keyword arguments' do
34
+ klass = described_class.new(properties: payload_hash[:properties])
35
+ expect(klass).to be_instance_of(described_class)
36
+ end
37
+
38
+ it 'should initialize the class from a hash with symbols as keys' do
39
+ klass = described_class.new(**payload_hash)
40
+ expect(klass).to be_instance_of(described_class)
41
+ end
42
+
43
+ it 'should initialize the class when missing attributes' do
44
+ payload_hash.delete(:properties)
45
+ klass = described_class.new(**payload_hash)
46
+ expect(klass).to be_instance_of(described_class)
47
+ end
48
+ end
49
+
50
+ describe 'base class methods' do
51
+ let(:klass) do
52
+ described_class.new(**payload_hash)
53
+ end
54
+
55
+ it 'should return the name of the schema and namespace' do
56
+ expect(klass.schema).to eq('MySchemaWithCircularReference')
57
+ expect(klass.namespace).to eq('com.my-namespace')
58
+ expect(klass.full_schema).to eq('com.my-namespace.MySchemaWithCircularReference')
59
+ end
60
+
61
+ it 'should return a json version of the payload' do
62
+ described_class.new(**payload_hash)
63
+ payload_h = {
64
+ 'properties' => {
65
+ a_boolean: {
66
+ 'property' => true
67
+ },
68
+ an_integer: {
69
+ 'property' => 1
70
+ },
71
+ a_float: {
72
+ 'property' => 4.5
73
+ },
74
+ a_string: {
75
+ 'property' => 'string'
76
+ },
77
+ an_array: {
78
+ 'property' => [1, 2, 3]
79
+ },
80
+ an_hash: {
81
+ 'property' => {
82
+ 'a_key' => 'a_value'
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ expect(klass.as_json).to eq(payload_h)
89
+ end
90
+
91
+ it 'should return a JSON string of the payload' do
92
+ s = '{"properties":{"a_boolean":{"property":true},"an_integer":{"property":1},"a_float":{"property":4.5},"a_str' \
93
+ 'ing":{"property":"string"},"an_array":{"property":[1,2,3]},"an_hash":{"property":{"a_key":"a_value"}}}}'
94
+ expect(klass.to_json).to eq(s)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ # For testing the generated class.
4
+ RSpec.describe Schemas::MyNamespace::MySchemaWithComplexType do
5
+ let(:payload_hash) do
6
+ {
7
+ test_id: 'test id',
8
+ test_float: 1.2,
9
+ test_string_array: %w(abc def),
10
+ test_int_array: [123, 456],
11
+ some_integer_map: { 'int_1' => 1, 'int_2' => 2 },
12
+ some_record: described_class::ARecord.new(a_record_field: 'field 1'),
13
+ some_optional_record: described_class::ARecord.new(a_record_field: 'field 2'),
14
+ some_record_array: [described_class::ARecord.new(a_record_field: 'field 3'),
15
+ described_class::ARecord.new(a_record_field: 'field 4')],
16
+ some_record_map: {
17
+ 'record_1' => described_class::ARecord.new(a_record_field: 'field 5'),
18
+ 'record_2' => described_class::ARecord.new(a_record_field: 'field 6')
19
+ },
20
+ some_enum_array: [described_class::AnEnum.new('sym1'),
21
+ described_class::AnEnum.new('sym2')]
22
+ }
23
+ end
24
+
25
+ describe 'class initialization' do
26
+ it 'should initialize the class from keyword arguments' do
27
+ klass = described_class.new(
28
+ test_id: payload_hash[:test_id],
29
+ test_float: payload_hash[:test_float],
30
+ test_string_array: payload_hash[:test_string_array],
31
+ some_record: payload_hash[:some_record],
32
+ some_optional_record: payload_hash[:some_optional_record],
33
+ some_record_array: payload_hash[:some_record_array],
34
+ some_record_map: payload_hash[:some_record_map],
35
+ some_enum_array: payload_hash[:some_enum_array]
36
+ )
37
+ expect(klass).to be_instance_of(described_class)
38
+ end
39
+
40
+ it 'should initialize the class from a hash with symbols as keys' do
41
+ klass = described_class.new(**payload_hash)
42
+ expect(klass).to be_instance_of(described_class)
43
+ end
44
+
45
+ it 'should initialize the class when missing attributes' do
46
+ payload_hash.delete(:test_id)
47
+ klass = described_class.new(**payload_hash)
48
+ expect(klass).to be_instance_of(described_class)
49
+ end
50
+ end
51
+
52
+ describe '.tombstone' do
53
+ it 'should return a tombstone' do
54
+ record = described_class.tombstone('foo')
55
+ expect(record.tombstone_key).to eq('foo')
56
+ expect(record.to_h).to eq({ payload_key: 'foo' })
57
+ end
58
+ end
59
+
60
+ describe 'base class methods' do
61
+ let(:klass) do
62
+ described_class.new(**payload_hash)
63
+ end
64
+
65
+ let(:schema_fields) do
66
+ %w(test_id test_float test_int_array test_optional_int test_string_array some_integer_map
67
+ some_record some_optional_record some_record_array some_record_map some_enum_array
68
+ some_optional_enum some_enum_with_default union_string)
69
+ end
70
+
71
+ it 'should return the name of the schema and namespace' do
72
+ expect(klass.schema).to eq('MySchemaWithComplexTypes')
73
+ expect(klass.namespace).to eq('com.my-namespace')
74
+ expect(klass.full_schema).to eq('com.my-namespace.MySchemaWithComplexTypes')
75
+ end
76
+
77
+ it 'should return an array of all fields in the schema' do
78
+ expect(klass.schema_fields).to match_array(schema_fields)
79
+ end
80
+
81
+ it 'should return a json version of the payload' do
82
+ described_class.new(**payload_hash)
83
+ payload_h = {
84
+ 'test_id' => 'test id',
85
+ 'test_float' => 1.2,
86
+ 'test_string_array' => %w(abc def),
87
+ 'test_int_array' => [123, 456],
88
+ 'some_optional_enum' => nil,
89
+ 'test_optional_int' => 123,
90
+ 'some_integer_map' => { 'int_1' => 1, 'int_2' => 2 },
91
+ 'some_record' => { 'a_record_field' => 'field 1' },
92
+ 'some_optional_record' => { 'a_record_field' => 'field 2' },
93
+ 'some_record_array' => [
94
+ { 'a_record_field' => 'field 3' },
95
+ { 'a_record_field' => 'field 4' }
96
+ ],
97
+ 'some_record_map' => {
98
+ 'record_1' => { 'a_record_field' => 'field 5' },
99
+ 'record_2' => { 'a_record_field' => 'field 6' }
100
+ },
101
+ 'some_enum_array' => %w(sym1 sym2),
102
+ 'some_enum_with_default' => 'sym6',
103
+ 'union_string' => ''
104
+ }
105
+
106
+ expect(klass.as_json).to eq(payload_h)
107
+ end
108
+
109
+ it 'should return a JSON string of the payload' do
110
+ s = '{"test_id":"test ' \
111
+ 'id","union_string":"","test_float":1.2,"test_string_array":["abc","def"],"test_int_array":[123,456],"test_' \
112
+ 'optional_i' \
113
+ 'nt":123,"some_integer_map":{"int_1":1,"int_2":2},"some_record":{"a_record_field":"field ' \
114
+ '1"},"some_optional_record":{"a_record_field":"field 2"},"some_record_array":[{"a_record_field":"field ' \
115
+ '3"},{"a_record_field":"field 4"}],"some_record_map":{"record_1":{"a_record_field":"field ' \
116
+ '5"},"record_2":{"a_record_field":"field ' \
117
+ '6"}},"some_enum_array":["sym1","sym2"],"some_optional_enum":null,"some_enum_with_default":"sym6"}'
118
+ expect(klass.to_json).to eq(s)
119
+ end
120
+ end
121
+
122
+ describe 'defaults' do
123
+ it 'should set an_optional_int if it is not provided' do
124
+ payload_hash.delete(:an_optional_int)
125
+ klass = described_class.new(**payload_hash)
126
+ expect(klass.test_optional_int).to eq(123)
127
+ end
128
+
129
+ it 'should set some_record if it is not provided' do
130
+ payload_hash.delete(:some_record)
131
+ klass = described_class.new(**payload_hash)
132
+ expect(klass.some_record).
133
+ to eq(described_class::ARecord.new(a_record_field: 'Test String'))
134
+ end
135
+
136
+ it 'should set some_record to nil' do
137
+ klass = described_class.new(**payload_hash, some_record: nil)
138
+ expect(klass.some_record).to be_nil
139
+ end
140
+ end
141
+
142
+ describe 'getters and setters' do
143
+ let(:klass) do
144
+ described_class.new(**payload_hash)
145
+ end
146
+
147
+ context 'when getting attributes' do
148
+ it 'should get of values of primitive types' do
149
+ expect(klass.test_id).to eq('test id')
150
+ expect(klass.test_float).to eq(1.2)
151
+ expect(klass.test_string_array).to eq(%w(abc def))
152
+ end
153
+
154
+ it 'should get the value of some_record_array' do
155
+ some_record_array = klass.some_record_array
156
+ expect(some_record_array.first).to be_instance_of(described_class::ARecord)
157
+ expect(some_record_array.first.a_record_field).to eq('field 3')
158
+ end
159
+
160
+ it 'should get the value of some_record_map' do
161
+ some_record_map = klass.some_record_map
162
+ expect(some_record_map['record_1']).
163
+ to be_instance_of(described_class::ARecord)
164
+ expect(some_record_map['record_1'].a_record_field).to eq('field 5')
165
+ end
166
+
167
+ it 'should get the value of some_enum_array' do
168
+ some_enum_array = klass.some_enum_array
169
+ expect(some_enum_array.first).to be_instance_of(described_class::AnEnum)
170
+ expect(some_enum_array.first.value).to eq('sym1')
171
+ end
172
+
173
+ it 'should get the value of some_record' do
174
+ record = klass.some_record
175
+ expect(record).to be_instance_of(described_class::ARecord)
176
+ expect(record.a_record_field).to eq('field 1')
177
+ expect(record.to_h).to eq({ 'a_record_field' => 'field 1' })
178
+ end
179
+
180
+ it 'should support Hash-style element access of values' do
181
+ expect(klass['test_id']).to eq('test id')
182
+ expect(klass['test_float']).to eq(1.2)
183
+ expect(klass['test_string_array']).to eq(%w(abc def))
184
+ end
185
+ end
186
+
187
+ context 'when setting attributes' do
188
+ it 'should modify the value of test_id' do
189
+ expect(klass.test_id).to eq('test id')
190
+
191
+ klass.test_id = 'something different'
192
+ expect(klass.test_id).to eq('something different')
193
+ end
194
+
195
+ it 'should modify the value of some_optional_record' do
196
+ expect(klass.some_optional_record).
197
+ to eq(described_class::ARecord.new(a_record_field: 'field 2'))
198
+ klass.some_optional_record = described_class::ARecord.
199
+ new(a_record_field: 'new field')
200
+
201
+ expect(klass.some_optional_record).to eq(described_class::ARecord.
202
+ new(a_record_field: 'new field'))
203
+ expect(klass.some_optional_record.as_json).to eq({ 'a_record_field' => 'new field' })
204
+ end
205
+
206
+ it 'should accept a hash object inner records' do
207
+ klass.some_optional_record = { a_record_field: 'new field' }
208
+ expect(klass.some_optional_record).
209
+ to eq(described_class::ARecord.new(a_record_field: 'new field'))
210
+ expect(klass.some_optional_record.as_json).to eq({ 'a_record_field' => 'new field' })
211
+ end
212
+
213
+ it 'should modify the value of some_enum_array' do
214
+ klass.some_enum_array.first.value = 'new_sym'
215
+ expect(klass.some_enum_array.first).
216
+ to eq(described_class::AnEnum.new('new_sym'))
217
+
218
+ klass.some_enum_array.second.value = described_class::AnEnum.
219
+ new('other_sym')
220
+ expect(klass.some_enum_array.second.value).to eq('other_sym')
221
+ end
222
+
223
+ it 'should modify the value of some_record_map' do
224
+ klass.some_record_map['record_1'].a_record_field = 'new field'
225
+ expect(klass.some_record_map['record_1']).to eq(described_class::ARecord.
226
+ new(a_record_field: 'new field'))
227
+
228
+ klass.some_record_map['record_2'] = described_class::ARecord.
229
+ new(a_record_field: 'other field')
230
+ expect(klass.some_record_map['record_2']).to eq(described_class::ARecord.
231
+ new(a_record_field: 'other field'))
232
+ end
233
+ end
234
+ end
235
+ end