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,375 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'rails/generators'
5
+ require 'schema_registry_client'
6
+ require 'active_support/core_ext'
7
+ require_relative 'configuration'
8
+ require_relative 'avro_parser'
9
+ require_relative 'schema_field'
10
+ require_relative 'schema_class'
11
+ require_relative 'schema_validator'
12
+
13
+ module AvroGen
14
+ # Generator for Schema Classes used for the IDE and consumer/producer interfaces.
15
+ class Generator < Rails::Generators::Base
16
+ # @return [Array<Symbol>]
17
+ SPECIAL_TYPES = %i(record enum).freeze
18
+ # @return [String]
19
+ INITIALIZE_WHITESPACE = "\n#{' ' * 19}".freeze
20
+ # @return [Array<String>]
21
+ IGNORE_DEFAULTS = %w(message_id timestamp).freeze
22
+ # @return [String]
23
+ SCHEMA_CLASS_FILE = 'schema_class.rb'
24
+ # @return [String]
25
+ SCHEMA_RECORD_PATH = File.expand_path('generator/templates/schema_record.rb.tt', __dir__).freeze
26
+ # @return [String]
27
+ SCHEMA_ENUM_PATH = File.expand_path('generator/templates/schema_enum.rb.tt', __dir__).freeze
28
+
29
+ source_root File.expand_path('generator/templates', __dir__)
30
+
31
+ no_commands do
32
+ # Retrieve the fields from this Avro Schema
33
+ # @param schema [Avro::Schema::NamedSchema]
34
+ # @return [Array<SchemaField>]
35
+ def fields(schema)
36
+ schema.fields.map do |field|
37
+ AvroGen::SchemaField.new(field.name, field.type, [], field.default)
38
+ end
39
+ end
40
+
41
+ # Converts AvroGen::SchemaField's to String form for generated YARD docs
42
+ # @param schema_field [AvroGen::SchemaField]
43
+ # @return [String] A string representation of the Type of this SchemaField
44
+ def deimos_field_type(schema_field)
45
+ _field_type(schema_field.type)
46
+ end
47
+
48
+ # Generate a Schema Model Class and all of its Nested Records from a
49
+ # schema name and namespace.
50
+ # @param schema_name [String]
51
+ # @param namespace [String]
52
+ # @param key_config [Hash,nil]
53
+ # @return [void]
54
+ def generate_classes(schema_name, namespace, key_config=nil)
55
+ schema_base = _schema_loader(schema_name, namespace)
56
+ schema_base.load_schema
57
+ if key_config&.dig(:schema)
58
+ key_schema_base = _schema_loader(key_config[:schema], namespace)
59
+ key_schema_base.load_schema
60
+ generate_class_from_schema_base(key_schema_base, key_config: nil)
61
+ end
62
+ generate_class_from_schema_base(schema_base, key_config: key_config)
63
+ end
64
+
65
+ # Generate classes for an explicit list of schema configs (each a Hash with
66
+ # :schema, :namespace and optional :key_config), then generate every other
67
+ # schema found in the configured path. This is the orchestration entry point
68
+ # used by Deimos (which derives configs from its Kafka topics).
69
+ # @param configs [Array<Hash>] [{ schema:, namespace:, key_config: }]
70
+ # @return [void]
71
+ def generate_from_configs(configs)
72
+ found_schemas = {}
73
+ configs.each do |config|
74
+ schema_name = config[:schema]
75
+ next if schema_name.nil?
76
+
77
+ namespace = config[:namespace]
78
+ key_config = config[:key_config] || {}
79
+ key_schema_name = key_config[:schema]
80
+
81
+ # don't regenerate if the schema was already found and had a payload key
82
+ next if found_schemas["#{namespace}.#{schema_name}"].present?
83
+
84
+ found_schemas["#{namespace}.#{schema_name}"] = key_schema_name
85
+ found_schemas["#{namespace}.#{key_schema_name}"] = nil
86
+ generate_classes(schema_name, namespace, key_config)
87
+ end
88
+
89
+ generate_from_path(skip: found_schemas.keys)
90
+ end
91
+
92
+ # Generate classes for every schema found in the configured schema path.
93
+ # @param skip [Array<String>] full schema names ("namespace.name") to skip
94
+ # @return [void]
95
+ def generate_from_path(skip: [])
96
+ path = AvroGen.config.schema_path
97
+ schema_store = SchemaRegistry::AvroSchemaStore.new(path: path)
98
+ schema_store.load_schemas!
99
+ schema_store.schemas.values.sort_by { |s| "#{s.namespace}#{s.name}" }.each do |schema|
100
+ name = "#{schema.namespace}.#{schema.name}"
101
+ next if skip.include?(name)
102
+
103
+ generate_classes(schema.name, schema.namespace, nil)
104
+ end
105
+ end
106
+
107
+ # @param schema [Avro::Schema::NamedSchema]
108
+ # @return [Array<Avro::Schema::NamedSchema]
109
+ def child_schemas(schema)
110
+ if schema.respond_to?(:fields)
111
+ schema.fields.map(&:type)
112
+ elsif schema.respond_to?(:values)
113
+ [schema.values]
114
+ elsif schema.respond_to?(:items)
115
+ [schema.items]
116
+ elsif schema.respond_to?(:schemas)
117
+ schema.schemas.reject { |s| s.instance_of?(Avro::Schema::PrimitiveSchema) }
118
+ else
119
+ []
120
+ end
121
+ end
122
+
123
+ # @param schemas [Array<Avro::Schema::NamedSchema>]
124
+ # @return [Array<Avro::Schema::NamedSchema>]
125
+ def collect_all_schemas(schemas)
126
+ schemas.dup.each do |schema|
127
+ next if @discovered_schemas.include?(schema)
128
+
129
+ @discovered_schemas << schema
130
+ schemas.concat(collect_all_schemas(child_schemas(schema)))
131
+ end
132
+
133
+ schemas.select { |s| s.respond_to?(:name) }.uniq
134
+ end
135
+
136
+ # @param schema_base [AvroGen::SchemaValidator]
137
+ # @param key_config [Hash,nil]
138
+ # @return [void]
139
+ def generate_class_from_schema_base(schema_base, key_config: nil)
140
+ @discovered_schemas = Set.new
141
+ @sub_schema_templates = []
142
+ schemas = collect_all_schemas(schema_base.schema_store.schemas.values)
143
+
144
+ main_schema = schemas.find { |s| s.name == schema_base.schema }
145
+ sub_schemas = schemas.reject { |s| s.name == schema_base.schema }.sort_by(&:name)
146
+ if AvroGen.config.nest_child_schemas
147
+ @sub_schema_templates = sub_schemas.map do |schema|
148
+ _generate_class_template_from_schema(schema, nil)
149
+ end
150
+ write_file(main_schema, key_config)
151
+ else
152
+ write_file(main_schema, key_config)
153
+ sub_schemas.each do |schema|
154
+ write_file(schema, nil)
155
+ end
156
+ end
157
+ end
158
+
159
+ # @param schema [Avro::Schema::NamedSchema]
160
+ # @param key_config [Hash,nil]
161
+ # @return [void]
162
+ def write_file(schema, key_config)
163
+ class_template = _generate_class_template_from_schema(schema, key_config)
164
+ @modules = AvroGen::SchemaClass.modules_for(schema.namespace)
165
+ @main_class_definition = class_template
166
+
167
+ file_prefix = schema.name.underscore.singularize
168
+ if AvroGen.config.use_full_namespace
169
+ # Use entire namespace for folders
170
+ # but don't add directories that are already in the path
171
+ directories = @modules.map(&:underscore).select do |m|
172
+ AvroGen.config.generated_class_path.exclude?(m)
173
+ end
174
+
175
+ file_prefix = "#{directories.join('/')}/#{file_prefix}"
176
+ end
177
+
178
+ filename = "#{AvroGen.config.generated_class_path}/#{file_prefix}.rb"
179
+ template(SCHEMA_CLASS_FILE, filename, force: true)
180
+ end
181
+
182
+ # Format a given field into its appropriate to_h representation.
183
+ # @param field[AvroGen::SchemaField]
184
+ # @return [String]
185
+ def field_as_json(field)
186
+ res = "'#{field.name}' => @#{field.name}"
187
+ field_base_type = _schema_base_class(field.type).type_sym
188
+
189
+ if %i(record enum).include?(field_base_type)
190
+ res += case field.type.type_sym
191
+ when :array
192
+ '.map { |v| v&.as_json }'
193
+ when :map
194
+ '.transform_values { |v| v&.as_json }'
195
+ else
196
+ '&.as_json'
197
+ end
198
+ end
199
+
200
+ res + (field.name == @fields.last.name ? '' : ',')
201
+ end
202
+ end
203
+
204
+ desc 'Generate schema classes from the configured schema path.'
205
+ # @return [void]
206
+ def generate
207
+ Rails.logger&.info("Generating schema classes to #{AvroGen.config.generated_class_path}")
208
+ generate_from_path
209
+ end
210
+
211
+ private
212
+
213
+ # @param schema_name [String]
214
+ # @param namespace [String]
215
+ # @return [AvroGen::SchemaValidator]
216
+ def _schema_loader(schema_name, namespace)
217
+ AvroGen::SchemaValidator.new(schema: schema_name, namespace: namespace)
218
+ end
219
+
220
+ # @param schema[Avro::Schema::NamedSchema]
221
+ # @param key_config[Hash,nil]
222
+ # @return [String]
223
+ def _generate_class_template_from_schema(schema, key_config)
224
+ _set_instance_variables(schema, key_config)
225
+
226
+ temp = schema.is_a?(Avro::Schema::RecordSchema) ? _record_class_template : _enum_class_template
227
+ res = ERB.new(temp, trim_mode: '-')
228
+ res.result(binding)
229
+ end
230
+
231
+ # @param schema[Avro::Schema::NamedSchema]
232
+ # @param key_config [Hash,nil]
233
+ def _set_instance_variables(schema, key_config)
234
+ schema_is_record = schema.is_a?(Avro::Schema::RecordSchema)
235
+ @current_schema = schema
236
+ return unless schema_is_record
237
+
238
+ @fields = fields(schema)
239
+ key_schema = nil
240
+ if key_config&.dig(:schema)
241
+ key_schema_base = _schema_loader(key_config[:schema], schema.namespace)
242
+ key_schema_base.load_schema
243
+ key_schema = key_schema_base.schema_store.schemas.values.first
244
+ @fields << AvroGen::SchemaField.new('payload_key', key_schema, [], nil)
245
+ end
246
+ @initialization_definition = _initialization_definition
247
+ @field_assignments = _field_assignments
248
+ @tombstone_assignment = _tombstone_assignment(key_config, key_schema)
249
+ end
250
+
251
+ def _tombstone_assignment(key_config, key_schema)
252
+ return nil unless key_config
253
+
254
+ if key_config[:plain]
255
+ 'record.tombstone_key = key'
256
+ elsif key_config[:field]
257
+ "record.tombstone_key = key\n record.#{key_config[:field]} = key"
258
+ elsif key_schema
259
+ field_base_type = _field_type(key_schema)
260
+ "record.tombstone_key = #{field_base_type}.initialize_from_value(key)\n record.payload_key = key"
261
+ else
262
+ ''
263
+ end
264
+ end
265
+
266
+ # Defines the initialization method for Schema Records with one keyword argument per line
267
+ # @return [String] A string which defines the method signature for the initialize method
268
+ def _initialization_definition
269
+ arguments = @fields.map do |schema_field|
270
+ arg = "#{schema_field.name}:"
271
+ arg += _field_default(schema_field)
272
+ arg.strip
273
+ end
274
+
275
+ result = "def initialize(_from_message: false, #{arguments.first}"
276
+ arguments[1..].each_with_index do |arg, _i|
277
+ result += ",#{INITIALIZE_WHITESPACE}#{arg}"
278
+ end
279
+ "#{result})"
280
+ end
281
+
282
+ # @param field [SchemaField]
283
+ # @return [String]
284
+ def _field_default(field)
285
+ default = field.default
286
+ return ' nil' if default == :no_default || default.nil? || IGNORE_DEFAULTS.include?(field.name)
287
+
288
+ type_sym = field.type.type_sym
289
+ if type_sym == :union
290
+ type_sym = field.type.schemas.find { |s| s.type_sym != :null }&.type_sym
291
+ end
292
+ case type_sym
293
+ when :string, :enum
294
+ " \"#{default}\""
295
+ when :record
296
+ schema_name = AvroGen::AvroParser.schema_classname(field.type)
297
+ class_instance = AvroGen::SchemaClass.instance(field.default, schema_name)
298
+ " #{class_instance.to_h}"
299
+ else
300
+ " #{default}"
301
+ end
302
+ end
303
+
304
+ # Overrides default attr accessor methods
305
+ # @return [Array<String>]
306
+ def _field_assignments
307
+ result = []
308
+ @fields.each do |field|
309
+ field_type = field.type.type_sym # Record, Union, Enum, Array or Map
310
+ schema_base_type = _schema_base_class(field.type)
311
+ field_base_type = _field_type(schema_base_type)
312
+ method_argument = %i(array map).include?(field_type) ? 'values' : 'value'
313
+ is_schema_class = %i(record enum).include?(schema_base_type.type_sym)
314
+
315
+ field_initialization = method_argument
316
+
317
+ if _is_complex_union?(field)
318
+ field_initialization = "initialize_#{field.name}_type(value, from_message: self._from_message)"
319
+ elsif is_schema_class
320
+ field_initialization = "#{field_base_type}.initialize_from_value(value, from_message: self._from_message)"
321
+ end
322
+
323
+ result << {
324
+ field: field,
325
+ field_type: field_type,
326
+ is_schema_class: is_schema_class,
327
+ method_argument: method_argument,
328
+ deimos_type: deimos_field_type(field),
329
+ field_initialization: field_initialization,
330
+ is_complex_union: _is_complex_union?(field)
331
+ }
332
+ end
333
+
334
+ result
335
+ end
336
+
337
+ # Helper method to detect if a field is a complex union type with multiple record schemas
338
+ # @param field [AvroGen::SchemaField]
339
+ # @return [Boolean]
340
+ def _is_complex_union?(field)
341
+ return false unless field.type.type_sym == :union
342
+
343
+ non_null_schemas = field.type.schemas.reject { |s| s.type_sym == :null }
344
+
345
+ record_schemas = non_null_schemas.select { |s| s.type_sym == :record }
346
+ record_schemas.length > 1
347
+ end
348
+
349
+ # Converts Avro::Schema::NamedSchema's to String form for generated YARD docs.
350
+ # @param avro_schema [Avro::Schema::NamedSchema]
351
+ # @return [String] A string representation of the Type of this SchemaField
352
+ def _field_type(avro_schema)
353
+ AvroGen::AvroParser.field_type(avro_schema)
354
+ end
355
+
356
+ # Returns the base class for this schema. Decodes Arrays, Maps and Unions
357
+ # @param avro_schema [Avro::Schema::NamedSchema]
358
+ # @return [Avro::Schema::NamedSchema]
359
+ def _schema_base_class(avro_schema)
360
+ AvroGen::AvroParser.schema_base_class(avro_schema)
361
+ end
362
+
363
+ # An ERB template for schema record classes
364
+ # @return [String]
365
+ def _record_class_template
366
+ File.read(SCHEMA_RECORD_PATH).strip
367
+ end
368
+
369
+ # An ERB template for schema enum classes
370
+ # @return [String]
371
+ def _enum_class_template
372
+ File.read(SCHEMA_ENUM_PATH).strip
373
+ end
374
+ end
375
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module AvroGen
6
+ # Exposes the avro:* rake tasks to a host Rails application.
7
+ class Railtie < Rails::Railtie
8
+ rake_tasks do
9
+ load File.expand_path('../tasks/avro.rake', __dir__)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../errors'
5
+
6
+ module AvroGen
7
+ module SchemaClass
8
+ # Base Class for Schema Classes generated from Avro.
9
+ class Base
10
+ # @param _args [Array<Object>]
11
+ def initialize(*_args)
12
+ end
13
+
14
+ # Converts the object to a hash which can be used for debugging or comparing objects.
15
+ # @param _opts [Hash]
16
+ # @return [Hash] a hash representation of the payload
17
+ def as_json(_opts={})
18
+ raise MissingImplementationError
19
+ end
20
+
21
+ # @param key [String,Symbol]
22
+ # @param val [Object]
23
+ # @return [void]
24
+ def []=(key, val)
25
+ self.send("#{key}=", val)
26
+ end
27
+
28
+ # @param other [SchemaClass::Base]
29
+ # @return [Boolean]
30
+ def ==(other)
31
+ comparison = other
32
+ if other.class == self.class
33
+ comparison = other.as_json
34
+ end
35
+
36
+ comparison == self.as_json
37
+ end
38
+
39
+ alias_method :eql?, :==
40
+
41
+ # @return [String]
42
+ def inspect
43
+ klass = self.class
44
+ "#{klass}(#{self.as_json})"
45
+ end
46
+
47
+ # Initializes this class from a given value
48
+ # @param value [Object]
49
+ # @return [SchemaClass::Base]
50
+ def self.initialize_from_value(_value)
51
+ raise MissingImplementationError
52
+ end
53
+
54
+ protected
55
+
56
+ # @return [Integer]
57
+ def hash
58
+ as_json.hash
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require 'json'
5
+
6
+ module AvroGen
7
+ module SchemaClass
8
+ # Base Class for Enum Classes generated from Avro.
9
+ class Enum < Base
10
+ # @return [String]
11
+ attr_accessor :value
12
+
13
+ # @param other [AvroGen::SchemaClass::Enum]
14
+ # @return [Boolean]
15
+ def ==(other)
16
+ other.is_a?(self.class) ? other.value == @value : other == @value
17
+ end
18
+
19
+ # @return [String]
20
+ def to_s
21
+ @value.to_s
22
+ end
23
+
24
+ # @param value [String]
25
+ def initialize(value)
26
+ @value = value
27
+ end
28
+
29
+ # Returns all the valid symbols for this enum.
30
+ # @return [Array<String>]
31
+ def symbols
32
+ raise MissingImplementationError
33
+ end
34
+
35
+ # @return [String]
36
+ def as_json(_opts={})
37
+ @value
38
+ end
39
+
40
+ # @return [SchemaClass::Enum]
41
+ def self.initialize_from_value(value, from_message: false)
42
+ return nil if value.nil?
43
+
44
+ value.is_a?(self) ? value : self.new(value)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../schema_validator'
5
+ require 'json'
6
+ require 'active_support/core_ext/hash/keys'
7
+ require 'active_support/core_ext/object/try'
8
+
9
+ module AvroGen
10
+ module SchemaClass
11
+ # Base Class of Record Classes generated from Avro.
12
+ class Record < Base
13
+ attr_accessor :tombstone_key, :_from_message
14
+
15
+ # Converts the object attributes to a hash which can be used for Kafka
16
+ # @return [Hash] the payload as a hash.
17
+ def to_h
18
+ if self.tombstone_key
19
+ { payload_key: self.tombstone_key&.as_json }
20
+ else
21
+ self.as_json
22
+ end
23
+ end
24
+
25
+ # Merge a hash or an identical schema object with this one and return a new object.
26
+ # @param other_hash [Hash,SchemaClass::Base]
27
+ # @return [SchemaClass::Base]
28
+ def merge(other_hash)
29
+ obj = self.class.new(**self.to_h.symbolize_keys)
30
+ other_hash.to_h.each do |k, v|
31
+ obj.send("#{k}=", v)
32
+ end
33
+ obj
34
+ end
35
+
36
+ # Element access method as if this Object were a hash
37
+ # @param key[String,Symbol]
38
+ # @return [Object] The value of the attribute if exists, nil otherwise
39
+ def [](key)
40
+ self.try(key.to_sym)
41
+ end
42
+
43
+ # @return [SchemaClass::Record]
44
+ def with_indifferent_access
45
+ self
46
+ end
47
+
48
+ # Returns the schema name of the inheriting class.
49
+ # @return [String]
50
+ def schema
51
+ raise MissingImplementationError
52
+ end
53
+
54
+ # Returns the namespace for the schema of the inheriting class.
55
+ # @return [String]
56
+ def namespace
57
+ raise MissingImplementationError
58
+ end
59
+
60
+ # Returns the full schema name of the inheriting class.
61
+ # @return [String]
62
+ def full_schema
63
+ "#{namespace}.#{schema}"
64
+ end
65
+
66
+ # Returns a validator that can load the schema and its fields. Reuses a
67
+ # shared, path-scoped schema store so repeated lookups don't re-parse the
68
+ # .avsc files.
69
+ # @return [AvroGen::SchemaValidator]
70
+ def validator
71
+ @validator ||= AvroGen::SchemaValidator.new(
72
+ schema: schema,
73
+ namespace: namespace,
74
+ store: AvroGen::SchemaValidator.store_for(AvroGen.config.schema_path)
75
+ )
76
+ end
77
+
78
+ # @return [Array<String>] an array of fields names in the schema.
79
+ def schema_fields
80
+ validator.schema_fields.map(&:name)
81
+ end
82
+
83
+ # Used internally so that we don't crash on unknown fields that come from
84
+ # a backwards compatible schema.
85
+ # @param kwargs [Hash] the attributes to set on the new object.
86
+ # @return [SchemaClass::Record]
87
+ def self.new_from_message(**kwargs)
88
+ record = self.new
89
+ attrs = kwargs.select { |k, _v| record.respond_to?("#{k}=") }
90
+ self.new(_from_message: true, **attrs)
91
+ end
92
+
93
+ # @return [SchemaClass::Record]
94
+ # @param from_message [Boolean] whether it's being initialized from a real Avro message.
95
+ def self.initialize_from_value(value, from_message: false)
96
+ return nil if value.nil?
97
+
98
+ return value if value.is_a?(self)
99
+
100
+ if from_message
101
+ self.new_from_message(**value&.symbolize_keys || {})
102
+ else
103
+ self.new(**value&.symbolize_keys || {})
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/string'
4
+ require 'active_support/core_ext/object/blank'
5
+ require 'active_support/core_ext/hash/keys'
6
+
7
+ module AvroGen
8
+ # Helpers used by the generator and by consumer/producer interfaces to
9
+ # locate and instantiate generated schema classes.
10
+ module SchemaClass
11
+ class << self
12
+ # @param namespace [String]
13
+ # @return [Array<String>]
14
+ def modules_for(namespace)
15
+ modules = [AvroGen.config.root_module]
16
+ namespace_override = nil
17
+ module_namespace = namespace
18
+
19
+ if AvroGen.config.use_full_namespace
20
+ if AvroGen.config.schema_namespace_map.present?
21
+ namespace_keys = AvroGen.config.schema_namespace_map.keys.sort_by { |k| -k.length }
22
+ namespace_override = namespace_keys.find { |k| module_namespace.include?(k) }
23
+ end
24
+
25
+ if namespace_override.present?
26
+ # override default module
27
+ modules = Array(AvroGen.config.schema_namespace_map[namespace_override])
28
+ module_namespace = module_namespace.gsub(/#{namespace_override}\.?/, '')
29
+ end
30
+
31
+ namespace_folders = module_namespace.split('.').map { |f| f.underscore.camelize }
32
+ modules.concat(namespace_folders) if namespace_folders.any?
33
+ end
34
+
35
+ modules
36
+ end
37
+
38
+ # Converts a raw payload into an instance of the Schema Class
39
+ # @param payload [Hash, AvroGen::SchemaClass::Base]
40
+ # @param schema [String]
41
+ # @param namespace [String]
42
+ # @return [AvroGen::SchemaClass::Record]
43
+ def instance(payload, schema, namespace='')
44
+ return payload if payload.is_a?(AvroGen::SchemaClass::Base)
45
+
46
+ klass = klass(schema, namespace)
47
+ return payload if klass.nil? || payload.nil?
48
+
49
+ klass.new_from_message(**payload.symbolize_keys)
50
+ end
51
+
52
+ # Determine and return the SchemaClass with the provided schema and namespace
53
+ # @param schema [String]
54
+ # @param namespace [String]
55
+ # @return [Class, nil]
56
+ def klass(schema, namespace)
57
+ constants = modules_for(namespace) + [schema.underscore.camelize.singularize]
58
+ constants.join('::').safe_constantize
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AvroGen
4
+ # Represents a field in the schema.
5
+ class SchemaField
6
+ # @return [String]
7
+ attr_accessor :name
8
+ # @return [Object]
9
+ attr_accessor :type
10
+ # @return [Array<String>]
11
+ attr_accessor :enum_values
12
+ # @return [Object]
13
+ attr_accessor :default
14
+
15
+ # @param name [String]
16
+ # @param type [Object]
17
+ # @param enum_values [Array<String>]
18
+ # @param default [Object]
19
+ def initialize(name, type, enum_values=[], default=:no_default)
20
+ @name = name
21
+ @type = type
22
+ @enum_values = enum_values
23
+ @default = default
24
+ end
25
+ end
26
+ end