lex-transformer 0.1.4 → 0.3.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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +16 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +37 -21
- data/CHANGELOG.md +54 -0
- data/CLAUDE.md +135 -0
- data/Dockerfile +1 -1
- data/Gemfile +13 -0
- data/README.md +121 -19
- data/Rakefile +2 -0
- data/docker_deploy.rb +1 -0
- data/lex-transformer.gemspec +13 -17
- data/lib/legion/extensions/transformer/actors/transform.rb +20 -14
- data/lib/legion/extensions/transformer/client.rb +104 -0
- data/lib/legion/extensions/transformer/definitions.rb +56 -0
- data/lib/legion/extensions/transformer/engines/base.rb +19 -0
- data/lib/legion/extensions/transformer/engines/erb.rb +37 -0
- data/lib/legion/extensions/transformer/engines/jsonpath.rb +41 -0
- data/lib/legion/extensions/transformer/engines/liquid.rb +31 -0
- data/lib/legion/extensions/transformer/engines/llm.rb +153 -0
- data/lib/legion/extensions/transformer/engines/registry.rb +51 -0
- data/lib/legion/extensions/transformer/engines/static.rb +21 -0
- data/lib/legion/extensions/transformer/helpers/schema_validator.rb +48 -0
- data/lib/legion/extensions/transformer/runners/transform.rb +79 -52
- data/lib/legion/extensions/transformer/transport/exchanges/task.rb +10 -4
- data/lib/legion/extensions/transformer/transport/messages/message.rb +27 -17
- data/lib/legion/extensions/transformer/transport/queues/transform.rb +12 -6
- data/lib/legion/extensions/transformer/transport.rb +19 -13
- data/lib/legion/extensions/transformer/version.rb +3 -1
- data/lib/legion/extensions/transformer.rb +3 -0
- metadata +29 -76
- data/CODE_OF_CONDUCT.md +0 -74
- data/Gemfile.lock +0 -58
- data/bitbucket-pipelines.yml +0 -19
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'engines/registry'
|
|
4
|
+
require_relative 'helpers/schema_validator'
|
|
5
|
+
require_relative 'definitions'
|
|
6
|
+
|
|
7
|
+
module Legion
|
|
8
|
+
module Extensions
|
|
9
|
+
module Transformer
|
|
10
|
+
class Client
|
|
11
|
+
def transform(payload:, transformation: nil, engine: nil, schema: nil, engine_options: {}, name: nil)
|
|
12
|
+
return transform_by_name(name: name, payload: payload, engine_options: engine_options) if transformation.nil? && name
|
|
13
|
+
|
|
14
|
+
eng = resolve_engine(engine, transformation)
|
|
15
|
+
rendered = eng.render(transformation, payload, **engine_options)
|
|
16
|
+
rendered = parse_rendered(rendered)
|
|
17
|
+
|
|
18
|
+
return rendered if rendered.is_a?(Hash) && rendered[:success] == false
|
|
19
|
+
|
|
20
|
+
if schema
|
|
21
|
+
validation = Helpers::SchemaValidator.validate(schema: schema, data: rendered)
|
|
22
|
+
return { success: false, status: 'transformer.validation_failed', errors: validation[:errors] } unless validation[:valid]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
{ success: true, result: rendered }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def transform_chain(steps:, payload:)
|
|
29
|
+
result = payload.dup
|
|
30
|
+
steps.each do |step|
|
|
31
|
+
eng = resolve_engine(step[:engine], step[:transformation])
|
|
32
|
+
step_opts = step[:engine_options] || {}
|
|
33
|
+
rendered = eng.render(step[:transformation], result, **step_opts)
|
|
34
|
+
rendered = parse_rendered(rendered)
|
|
35
|
+
|
|
36
|
+
return rendered if rendered.is_a?(Hash) && rendered[:success] == false
|
|
37
|
+
|
|
38
|
+
if step[:schema]
|
|
39
|
+
validation = Helpers::SchemaValidator.validate(schema: step[:schema], data: rendered)
|
|
40
|
+
return { success: false, status: 'transformer.validation_failed', errors: validation[:errors] } unless validation[:valid]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if rendered.is_a?(Hash)
|
|
44
|
+
result = result.merge({ args: rendered }.merge(rendered))
|
|
45
|
+
else
|
|
46
|
+
result[:args] = rendered
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
{ success: true, result: result }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def transform_by_name(name:, payload:, engine_options: {})
|
|
55
|
+
definition = Definitions.fetch(name)
|
|
56
|
+
return { success: false, error: 'definition_not_found' } unless definition
|
|
57
|
+
|
|
58
|
+
if definition[:conditions] && conditioner_available?
|
|
59
|
+
cond_result = evaluate_conditions(definition[:conditions], payload)
|
|
60
|
+
return { success: false, reason: 'conditions_not_met' } unless cond_result
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
merged_opts = Definitions.merge_options(definition, **engine_options)
|
|
64
|
+
|
|
65
|
+
transform(
|
|
66
|
+
transformation: definition[:transformation],
|
|
67
|
+
payload: payload,
|
|
68
|
+
engine: definition[:engine],
|
|
69
|
+
schema: definition[:schema],
|
|
70
|
+
engine_options: merged_opts
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def conditioner_available?
|
|
75
|
+
defined?(Legion::Extensions::Conditioner::Client)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def evaluate_conditions(conditions, payload)
|
|
79
|
+
client = Legion::Extensions::Conditioner::Client.new
|
|
80
|
+
result = client.evaluate(conditions: conditions, values: payload)
|
|
81
|
+
result[:passed]
|
|
82
|
+
rescue StandardError
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def resolve_engine(engine_name, transformation)
|
|
87
|
+
if engine_name
|
|
88
|
+
Engines::Registry.fetch(engine_name)
|
|
89
|
+
else
|
|
90
|
+
Engines::Registry.detect(transformation)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def parse_rendered(rendered)
|
|
95
|
+
return rendered unless rendered.is_a?(String)
|
|
96
|
+
|
|
97
|
+
Legion::JSON.load(rendered)
|
|
98
|
+
rescue StandardError
|
|
99
|
+
rendered
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Transformer
|
|
6
|
+
class Definitions
|
|
7
|
+
class << self
|
|
8
|
+
def fetch(name)
|
|
9
|
+
defns = load_definitions
|
|
10
|
+
return nil unless defns&.key?(name)
|
|
11
|
+
|
|
12
|
+
symbolize_definition(defns[name])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def names
|
|
16
|
+
defns = load_definitions
|
|
17
|
+
return [] unless defns
|
|
18
|
+
|
|
19
|
+
defns.keys
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def merge_options(definition, **overrides)
|
|
23
|
+
base = definition[:engine_options] || {}
|
|
24
|
+
base.merge(overrides)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def load_definitions
|
|
30
|
+
return nil unless defined?(Legion::Settings)
|
|
31
|
+
|
|
32
|
+
Legion::Settings.dig('lex-transformer', 'definitions')
|
|
33
|
+
rescue StandardError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def symbolize_definition(raw)
|
|
38
|
+
defn = {}
|
|
39
|
+
defn[:transformation] = raw['transformation'] || raw[:transformation]
|
|
40
|
+
defn[:engine] = (raw['engine'] || raw[:engine])&.to_sym
|
|
41
|
+
defn[:engine_options] = symbolize_hash(raw['engine_options'] || raw[:engine_options] || {})
|
|
42
|
+
defn[:schema] = raw['schema'] || raw[:schema]
|
|
43
|
+
defn[:conditions] = raw['conditions'] || raw[:conditions]
|
|
44
|
+
defn
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def symbolize_hash(hash)
|
|
48
|
+
return {} unless hash.is_a?(Hash)
|
|
49
|
+
|
|
50
|
+
hash.transform_keys(&:to_sym)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Transformer
|
|
6
|
+
module Engines
|
|
7
|
+
class Base
|
|
8
|
+
def render(template, payload, **_opts)
|
|
9
|
+
raise NotImplementedError, "#{self.class}#render must be implemented"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def name
|
|
13
|
+
raise NotImplementedError, "#{self.class}#name must be implemented"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tilt'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Transformer
|
|
9
|
+
module Engines
|
|
10
|
+
class Erb < Base
|
|
11
|
+
def name
|
|
12
|
+
:erb
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render(template, payload, **_opts)
|
|
16
|
+
tilt_template = Tilt['erb'].new { template }
|
|
17
|
+
variables = build_variables(template, payload)
|
|
18
|
+
tilt_template.render(Object.new, variables)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def build_variables(template, payload)
|
|
24
|
+
variables = { **payload }
|
|
25
|
+
variables[:crypt] = Legion::Crypt if defined?(Legion::Crypt) && template.include?('crypt')
|
|
26
|
+
variables[:settings] = Legion::Settings if defined?(Legion::Settings) && template.include?('settings')
|
|
27
|
+
variables[:cache] = Legion::Cache if defined?(Legion::Cache) && template.include?('cache')
|
|
28
|
+
if payload.key?(:task_id) && template.include?('task') && defined?(Legion::Data::Model::Task)
|
|
29
|
+
variables[:task] = Legion::Data::Model::Task[payload[:task_id]]
|
|
30
|
+
end
|
|
31
|
+
variables
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Transformer
|
|
8
|
+
module Engines
|
|
9
|
+
class Jsonpath < Base
|
|
10
|
+
def name
|
|
11
|
+
:jsonpath
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render(expression, payload, **_opts)
|
|
15
|
+
extract(expression, payload)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def extract(path, data)
|
|
21
|
+
path = path.delete_prefix('$.') if path.start_with?('$.')
|
|
22
|
+
segments = path.split('.')
|
|
23
|
+
result = data
|
|
24
|
+
segments.each do |segment|
|
|
25
|
+
case result
|
|
26
|
+
when Hash
|
|
27
|
+
key = result.key?(segment.to_sym) ? segment.to_sym : segment
|
|
28
|
+
result = result[key]
|
|
29
|
+
when Array
|
|
30
|
+
result = result[segment.to_i]
|
|
31
|
+
else
|
|
32
|
+
return nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Transformer
|
|
8
|
+
module Engines
|
|
9
|
+
class Liquid < Base
|
|
10
|
+
def name
|
|
11
|
+
:liquid
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render(template, payload, **_opts)
|
|
15
|
+
require 'liquid'
|
|
16
|
+
liquid_template = ::Liquid::Template.parse(template)
|
|
17
|
+
liquid_template.render(stringify_keys(payload))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def stringify_keys(hash)
|
|
23
|
+
hash.each_with_object({}) do |(k, v), result|
|
|
24
|
+
result[k.to_s] = v.is_a?(Hash) ? stringify_keys(v) : v
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'timeout'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Transformer
|
|
9
|
+
module Engines
|
|
10
|
+
class Llm < Base
|
|
11
|
+
AUTH_ERRORS = /auth|credentials|forbidden|api.key|unauthorized/i
|
|
12
|
+
RETRY_SLEEP = 1
|
|
13
|
+
|
|
14
|
+
def name
|
|
15
|
+
:llm
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def render(prompt, payload, **opts)
|
|
19
|
+
raise 'Legion::LLM is not available or not started' unless llm_available?
|
|
20
|
+
|
|
21
|
+
@last_raw = nil
|
|
22
|
+
resolved = resolve_options(opts)
|
|
23
|
+
max_retries = resolved.delete(:max_retries)
|
|
24
|
+
attempts = 0
|
|
25
|
+
|
|
26
|
+
loop do
|
|
27
|
+
result = attempt_render(prompt, payload, resolved, attempts)
|
|
28
|
+
return result unless result == :retry
|
|
29
|
+
|
|
30
|
+
attempts += 1
|
|
31
|
+
return build_failure if attempts > max_retries
|
|
32
|
+
|
|
33
|
+
sleep(RETRY_SLEEP)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def llm_available?
|
|
40
|
+
defined?(Legion::LLM) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def resolve_options(opts)
|
|
44
|
+
defaults = settings_defaults
|
|
45
|
+
resolved = defaults.merge(opts)
|
|
46
|
+
resolved[:max_retries] = (resolved[:max_retries] || 1).to_i
|
|
47
|
+
resolved
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def settings_defaults
|
|
51
|
+
return {} unless defined?(Legion::Settings)
|
|
52
|
+
|
|
53
|
+
settings = begin
|
|
54
|
+
Legion::Settings.dig('lex-transformer', 'llm')
|
|
55
|
+
rescue StandardError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
return {} unless settings.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
settings.transform_keys(&:to_sym)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def attempt_render(prompt, payload, opts, attempt)
|
|
64
|
+
chat = call_llm(prompt, payload, opts, attempt)
|
|
65
|
+
content = extract_response(chat)
|
|
66
|
+
validate_json(content)
|
|
67
|
+
content
|
|
68
|
+
rescue Timeout::Error, IOError, Errno::ECONNREFUSED, Errno::ECONNRESET
|
|
69
|
+
:retry
|
|
70
|
+
rescue ::JSON::ParserError
|
|
71
|
+
@last_raw = content
|
|
72
|
+
:retry
|
|
73
|
+
rescue RuntimeError => e
|
|
74
|
+
raise if auth_error?(e)
|
|
75
|
+
|
|
76
|
+
{ success: false, error: e.class.to_s, message: e.message }
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
{ success: false, error: e.class.to_s, message: e.message }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def call_llm(prompt, payload, opts, attempt)
|
|
82
|
+
context = Legion::JSON.dump(payload)
|
|
83
|
+
|
|
84
|
+
return call_structured(prompt, context, opts) if opts[:structured] && opts[:schema] && structured_available?
|
|
85
|
+
|
|
86
|
+
full_prompt = build_prompt(prompt, context, attempt)
|
|
87
|
+
full_prompt = inject_schema_into_prompt(full_prompt, opts[:schema]) if opts[:structured] && opts[:schema]
|
|
88
|
+
|
|
89
|
+
llm_opts = build_llm_opts(opts)
|
|
90
|
+
Legion::LLM.chat(message: full_prompt, **llm_opts)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def structured_available?
|
|
94
|
+
Legion::LLM.respond_to?(:structured)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def call_structured(prompt, context, opts)
|
|
98
|
+
llm_opts = build_llm_opts(opts)
|
|
99
|
+
Legion::LLM.structured(
|
|
100
|
+
message: "#{prompt}\n\nPayload:\n```json\n#{context}\n```",
|
|
101
|
+
schema: opts[:schema],
|
|
102
|
+
**llm_opts
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def inject_schema_into_prompt(prompt, schema)
|
|
107
|
+
schema_json = Legion::JSON.dump(schema)
|
|
108
|
+
"#{prompt}\n\nYour response MUST conform to this JSON schema:\n```json\n#{schema_json}\n```"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_prompt(prompt, context, attempt)
|
|
112
|
+
base = "#{prompt}\n\nPayload:\n```json\n#{context}\n```\n\nRespond with valid JSON only. No explanation, no markdown fences."
|
|
113
|
+
return base unless attempt.positive?
|
|
114
|
+
|
|
115
|
+
"#{base}\n\nIMPORTANT: Your previous response was not valid JSON. Return ONLY a valid JSON object or array, nothing else."
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def extract_response(chat)
|
|
119
|
+
content = chat.respond_to?(:content) ? chat.content : chat.to_s
|
|
120
|
+
content = content.strip
|
|
121
|
+
content = content.sub(/\A```(?:json)?\n?/, '').sub(/\n?```\z/, '') if content.start_with?('```')
|
|
122
|
+
content
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def validate_json(content)
|
|
126
|
+
::JSON.parse(content)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def auth_error?(error)
|
|
130
|
+
AUTH_ERRORS.match?(error.message)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_llm_opts(opts)
|
|
134
|
+
llm_opts = {}
|
|
135
|
+
llm_opts[:model] = opts[:model] if opts[:model]
|
|
136
|
+
llm_opts[:provider] = opts[:provider] if opts[:provider]
|
|
137
|
+
llm_opts[:temperature] = opts[:temperature] if opts[:temperature]
|
|
138
|
+
llm_opts[:system_prompt] = opts[:system_prompt] if opts[:system_prompt]
|
|
139
|
+
llm_opts
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def build_failure
|
|
143
|
+
if @last_raw
|
|
144
|
+
{ success: false, error: 'invalid_json', message: 'max retries exhausted', raw: @last_raw }
|
|
145
|
+
else
|
|
146
|
+
{ success: false, error: 'timeout', message: 'max retries exhausted' }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'erb'
|
|
4
|
+
require_relative 'static'
|
|
5
|
+
require_relative 'liquid'
|
|
6
|
+
require_relative 'jsonpath'
|
|
7
|
+
require_relative 'llm'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module Transformer
|
|
12
|
+
module Engines
|
|
13
|
+
class Registry
|
|
14
|
+
ENGINES = {}.freeze
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def register(engine_class)
|
|
18
|
+
@engines ||= {}
|
|
19
|
+
instance = engine_class.new
|
|
20
|
+
@engines[instance.name] = instance
|
|
21
|
+
@engines[instance.name.to_s] = instance
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fetch(name)
|
|
25
|
+
@engines ||= {}
|
|
26
|
+
@engines[name.to_sym] || @engines[name.to_s] || raise(ArgumentError, "Unknown engine: #{name}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def detect(template)
|
|
30
|
+
if template.is_a?(String) && (template.include?('<%') || template.include?('%>'))
|
|
31
|
+
fetch(:erb)
|
|
32
|
+
else
|
|
33
|
+
fetch(:static)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def reset!
|
|
38
|
+
@engines = {}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
register(Erb)
|
|
43
|
+
register(Static)
|
|
44
|
+
register(Liquid)
|
|
45
|
+
register(Jsonpath)
|
|
46
|
+
register(Llm)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Transformer
|
|
8
|
+
module Engines
|
|
9
|
+
class Static < Base
|
|
10
|
+
def name
|
|
11
|
+
:static
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render(template, _payload, **_opts)
|
|
15
|
+
template
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Transformer
|
|
6
|
+
module Helpers
|
|
7
|
+
class SchemaValidator
|
|
8
|
+
class << self
|
|
9
|
+
def validate(schema:, data:)
|
|
10
|
+
return { valid: true } if schema.nil? || schema.empty?
|
|
11
|
+
|
|
12
|
+
errors = []
|
|
13
|
+
data ||= {}
|
|
14
|
+
|
|
15
|
+
check_required_keys(schema[:required_keys], data, errors)
|
|
16
|
+
check_types(schema[:types], data, errors)
|
|
17
|
+
|
|
18
|
+
errors.empty? ? { valid: true } : { valid: false, errors: errors }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def check_required_keys(required_keys, data, errors)
|
|
24
|
+
return if required_keys.nil? || required_keys.empty?
|
|
25
|
+
|
|
26
|
+
required_keys.each do |key|
|
|
27
|
+
errors << "missing required key: #{key}" unless data.key?(key)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def check_types(types, data, errors)
|
|
32
|
+
return if types.nil? || types.empty?
|
|
33
|
+
|
|
34
|
+
types.each do |key, expected_type|
|
|
35
|
+
next unless data.key?(key)
|
|
36
|
+
|
|
37
|
+
actual = data[key]
|
|
38
|
+
next if actual.is_a?(expected_type)
|
|
39
|
+
|
|
40
|
+
errors << "#{key} expected #{expected_type}, got #{actual.class}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|