zodra 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/lib/generators/zodra/install_generator.rb +20 -0
- data/lib/generators/zodra/templates/initializer.rb.tt +12 -0
- data/lib/zodra/action.rb +43 -0
- data/lib/zodra/action_builder.rb +38 -0
- data/lib/zodra/api_builder.rb +23 -0
- data/lib/zodra/api_definition.rb +24 -0
- data/lib/zodra/api_registry.rb +33 -0
- data/lib/zodra/attribute.rb +46 -0
- data/lib/zodra/configuration.rb +18 -0
- data/lib/zodra/contract.rb +33 -0
- data/lib/zodra/contract_builder.rb +37 -0
- data/lib/zodra/contract_registry.rb +42 -0
- data/lib/zodra/controller.rb +141 -0
- data/lib/zodra/definition.rb +36 -0
- data/lib/zodra/export/contract_mapper.rb +76 -0
- data/lib/zodra/export/surface_resolver.rb +76 -0
- data/lib/zodra/export/type_analysis.rb +133 -0
- data/lib/zodra/export/type_script_mapper.rb +143 -0
- data/lib/zodra/export/writer.rb +51 -0
- data/lib/zodra/export/zod_mapper.rb +200 -0
- data/lib/zodra/export.rb +35 -0
- data/lib/zodra/params_coercer.rb +100 -0
- data/lib/zodra/params_parser.rb +90 -0
- data/lib/zodra/params_validator.rb +74 -0
- data/lib/zodra/railtie.rb +13 -0
- data/lib/zodra/resource.rb +51 -0
- data/lib/zodra/resource_builder.rb +57 -0
- data/lib/zodra/response_serializer.rb +63 -0
- data/lib/zodra/route_helper.rb +9 -0
- data/lib/zodra/router.rb +55 -0
- data/lib/zodra/scalar_registry.rb +32 -0
- data/lib/zodra/scalar_type.rb +13 -0
- data/lib/zodra/tasks/zodra.rake +41 -0
- data/lib/zodra/type_builder.rb +57 -0
- data/lib/zodra/type_deriver.rb +55 -0
- data/lib/zodra/type_registry.rb +42 -0
- data/lib/zodra/union_builder.rb +15 -0
- data/lib/zodra/variant.rb +12 -0
- data/lib/zodra/variant_builder.rb +23 -0
- data/lib/zodra/version.rb +5 -0
- data/lib/zodra.rb +147 -0
- metadata +113 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zodra
|
|
4
|
+
module Export
|
|
5
|
+
class SurfaceResolver
|
|
6
|
+
def self.call(definitions, contracts)
|
|
7
|
+
return definitions if contracts.empty?
|
|
8
|
+
|
|
9
|
+
new(definitions, contracts).resolve
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(definitions, contracts)
|
|
13
|
+
@definitions_by_name = definitions.to_h { |d| [d.name, d] }
|
|
14
|
+
@contracts = contracts
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def resolve
|
|
18
|
+
reachable = expand_dependencies(collect_seed_types)
|
|
19
|
+
@definitions_by_name.values.select { |d| reachable.include?(d.name) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def collect_seed_types
|
|
25
|
+
names = Set.new
|
|
26
|
+
|
|
27
|
+
@contracts.each do |contract|
|
|
28
|
+
contract.actions.each_value do |action|
|
|
29
|
+
names << action.response_type if action.response_type
|
|
30
|
+
collect_attribute_references(action.params, names)
|
|
31
|
+
collect_attribute_references(action.response_definition, names) unless action.response_type
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
names
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def expand_dependencies(seed_names)
|
|
39
|
+
visited = Set.new
|
|
40
|
+
queue = seed_names.to_a
|
|
41
|
+
|
|
42
|
+
while (name = queue.shift)
|
|
43
|
+
next if visited.include?(name)
|
|
44
|
+
|
|
45
|
+
visited << name
|
|
46
|
+
definition = @definitions_by_name[name]
|
|
47
|
+
next unless definition
|
|
48
|
+
|
|
49
|
+
collect_definition_dependencies(definition).each do |dep|
|
|
50
|
+
queue << dep unless visited.include?(dep)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
visited
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def collect_definition_dependencies(definition)
|
|
58
|
+
deps = Set.new
|
|
59
|
+
definition.attributes.each_value { |attr| add_dependency(attr, deps) }
|
|
60
|
+
definition.variants.each do |variant|
|
|
61
|
+
variant.attributes.each_value { |attr| add_dependency(attr, deps) }
|
|
62
|
+
end
|
|
63
|
+
deps
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def collect_attribute_references(definition, names)
|
|
67
|
+
definition.attributes.each_value { |attr| add_dependency(attr, names) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def add_dependency(attribute, set)
|
|
71
|
+
dep = attribute.dependency_name
|
|
72
|
+
set << dep if dep
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zodra
|
|
4
|
+
module Export
|
|
5
|
+
class TypeAnalysis
|
|
6
|
+
Result = Data.define(:sorted, :cycles)
|
|
7
|
+
|
|
8
|
+
def self.call(definitions)
|
|
9
|
+
new(definitions).analyze
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(definitions)
|
|
13
|
+
@definitions_by_name = definitions.to_h { |d| [d.name, d] }
|
|
14
|
+
@graph = build_graph
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def analyze
|
|
18
|
+
cycles = detect_cycles
|
|
19
|
+
sorted = topological_sort(cycles)
|
|
20
|
+
Result.new(sorted:, cycles:)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def build_graph
|
|
26
|
+
@definitions_by_name.each_with_object({}) do |(name, definition), graph|
|
|
27
|
+
graph[name] = collect_dependencies(definition)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def collect_dependencies(definition)
|
|
32
|
+
deps = Set.new
|
|
33
|
+
|
|
34
|
+
definition.attributes.each_value do |attr|
|
|
35
|
+
dep = attr.dependency_name
|
|
36
|
+
deps << dep if dep && @definitions_by_name.key?(dep)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
definition.variants.each do |variant|
|
|
40
|
+
variant.attributes.each_value do |attr|
|
|
41
|
+
dep = attr.dependency_name
|
|
42
|
+
deps << dep if dep && @definitions_by_name.key?(dep)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
deps
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Tarjan's SCC algorithm — detects strongly connected components
|
|
50
|
+
def detect_cycles
|
|
51
|
+
@index_counter = 0
|
|
52
|
+
@stack = []
|
|
53
|
+
@indices = {}
|
|
54
|
+
@lowlinks = {}
|
|
55
|
+
@on_stack = Set.new
|
|
56
|
+
cycles = Set.new
|
|
57
|
+
|
|
58
|
+
@graph.each_key do |name|
|
|
59
|
+
tarjan(name, cycles) unless @indices.key?(name)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
cycles
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def tarjan(name, cycles)
|
|
66
|
+
@indices[name] = @index_counter
|
|
67
|
+
@lowlinks[name] = @index_counter
|
|
68
|
+
@index_counter += 1
|
|
69
|
+
@stack.push(name)
|
|
70
|
+
@on_stack << name
|
|
71
|
+
|
|
72
|
+
@graph[name].each do |dep|
|
|
73
|
+
if !@indices.key?(dep)
|
|
74
|
+
tarjan(dep, cycles)
|
|
75
|
+
@lowlinks[name] = [@lowlinks[name], @lowlinks[dep]].min
|
|
76
|
+
elsif @on_stack.include?(dep)
|
|
77
|
+
@lowlinks[name] = [@lowlinks[name], @indices[dep]].min
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
return unless @lowlinks[name] == @indices[name]
|
|
82
|
+
|
|
83
|
+
component = []
|
|
84
|
+
loop do
|
|
85
|
+
node = @stack.pop
|
|
86
|
+
@on_stack.delete(node)
|
|
87
|
+
component << node
|
|
88
|
+
break if node == name
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
return unless component.size > 1 || @graph[name].include?(name)
|
|
92
|
+
|
|
93
|
+
component.each { |n| cycles << n }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Kahn's algorithm with reversed edges — dependencies come before dependents
|
|
97
|
+
def topological_sort(cycles)
|
|
98
|
+
in_degree = Hash.new(0)
|
|
99
|
+
reverse_adjacency = {}
|
|
100
|
+
|
|
101
|
+
@graph.each_key do |name|
|
|
102
|
+
reverse_adjacency[name] ||= []
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
@graph.each do |name, deps|
|
|
106
|
+
deps.each do |dep|
|
|
107
|
+
next if cycles.include?(name) && cycles.include?(dep)
|
|
108
|
+
|
|
109
|
+
reverse_adjacency[dep] ||= []
|
|
110
|
+
reverse_adjacency[dep] << name
|
|
111
|
+
in_degree[name] += 1
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
queue = @graph.keys.select { |n| in_degree[n].zero? }
|
|
116
|
+
sorted = []
|
|
117
|
+
|
|
118
|
+
while (name = queue.shift)
|
|
119
|
+
sorted << name
|
|
120
|
+
reverse_adjacency[name]&.each do |dependent|
|
|
121
|
+
in_degree[dependent] -= 1
|
|
122
|
+
queue << dependent if in_degree[dependent].zero?
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
remaining = @graph.keys - sorted
|
|
127
|
+
sorted.concat(remaining)
|
|
128
|
+
|
|
129
|
+
sorted.filter_map { |name| @definitions_by_name[name] }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zodra
|
|
4
|
+
module Export
|
|
5
|
+
class TypeScriptMapper
|
|
6
|
+
PRIMITIVE_MAP = {
|
|
7
|
+
string: 'string',
|
|
8
|
+
integer: 'number',
|
|
9
|
+
decimal: 'number',
|
|
10
|
+
number: 'number',
|
|
11
|
+
boolean: 'boolean',
|
|
12
|
+
datetime: 'string',
|
|
13
|
+
date: 'string',
|
|
14
|
+
uuid: 'string',
|
|
15
|
+
binary: 'string'
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(key_format: :keep)
|
|
19
|
+
@key_format = key_format
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def map_definition(definition)
|
|
23
|
+
case definition.kind
|
|
24
|
+
when :object then map_object(definition)
|
|
25
|
+
when :enum then map_enum(definition)
|
|
26
|
+
when :union then map_union(definition)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def map_definitions(definitions, cycles: Set.new)
|
|
31
|
+
definitions.map { |definition| map_definition(definition) }.join("\n\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def map_contract(contract)
|
|
35
|
+
params_output = map_contract_params(contract)
|
|
36
|
+
descriptor_output = map_contract_descriptor(contract)
|
|
37
|
+
[params_output, descriptor_output].compact.reject(&:empty?).join("\n\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def map_contracts(contracts)
|
|
41
|
+
contracts.map { |contract| map_contract(contract) }.join("\n\n")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def map_object(definition)
|
|
47
|
+
properties = definition.attributes.values.map { |attr| map_property(attr) }
|
|
48
|
+
"export interface #{pascal_case(definition.name)} {\n#{properties.join("\n")}\n}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def map_enum(definition)
|
|
52
|
+
values = definition.values.map { |value| "'#{value}'" }.join(' | ')
|
|
53
|
+
"export type #{pascal_case(definition.name)} = #{values};"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def map_union(definition)
|
|
57
|
+
variants = definition.variants.map { |variant| map_variant(variant, definition.discriminator) }
|
|
58
|
+
"export type #{pascal_case(definition.name)} =\n#{variants.join("\n")};"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def map_variant(variant, discriminator)
|
|
62
|
+
discriminator_key = transform_key(discriminator)
|
|
63
|
+
properties = variant.attributes.values.map do |attr|
|
|
64
|
+
"#{transform_key(attr.name)}: #{map_type(attr)}"
|
|
65
|
+
end
|
|
66
|
+
all_properties = ["#{discriminator_key}: '#{variant.tag}'"] + properties
|
|
67
|
+
" | { #{all_properties.join('; ')} }"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def map_property(attribute)
|
|
71
|
+
key = transform_key(attribute.name)
|
|
72
|
+
optional_marker = attribute.optional? ? '?' : ''
|
|
73
|
+
type_string = map_type(attribute)
|
|
74
|
+
type_string = [type_string, 'null'].sort.join(' | ') if attribute.nullable?
|
|
75
|
+
" #{key}#{optional_marker}: #{type_string};"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def map_type(attribute)
|
|
79
|
+
if attribute.enum
|
|
80
|
+
attribute.enum.map { |v| "'#{v}'" }.join(' | ')
|
|
81
|
+
elsif attribute.reference?
|
|
82
|
+
pascal_case(attribute.reference_name)
|
|
83
|
+
elsif attribute.array?
|
|
84
|
+
"#{pascal_case(attribute.of)}[]"
|
|
85
|
+
else
|
|
86
|
+
resolve_primitive_type(attribute.type)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def transform_key(key)
|
|
91
|
+
key_string = key.to_s
|
|
92
|
+
case @key_format
|
|
93
|
+
when :camel then camel_case(key_string)
|
|
94
|
+
when :pascal then pascal_case_string(key_string)
|
|
95
|
+
else key_string
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def map_contract_params(contract)
|
|
100
|
+
contract.actions.values.map do |action|
|
|
101
|
+
params_name = :"#{action.name}_#{contract.name}_params"
|
|
102
|
+
renamed = Definition.new(name: params_name, kind: :object)
|
|
103
|
+
action.params.attributes.each { |key, attr| renamed.attributes[key] = attr }
|
|
104
|
+
map_object(renamed)
|
|
105
|
+
end.join("\n\n")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def map_contract_descriptor(contract)
|
|
109
|
+
name = pascal_case(contract.name)
|
|
110
|
+
return "export interface #{name}Contract {}" if contract.actions.empty?
|
|
111
|
+
|
|
112
|
+
entries = contract.actions.values.map do |action|
|
|
113
|
+
params_type = "#{pascal_case(action.name)}#{name}Params"
|
|
114
|
+
parts = ["method: '#{action.http_method.to_s.upcase}'", "path: '#{action.path}'", "params: #{params_type}"]
|
|
115
|
+
parts << "response: #{pascal_case(action.response_type)}" if action.response_type
|
|
116
|
+
" #{action.name}: { #{parts.join('; ')} };"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
"export interface #{name}Contract {\n#{entries.join("\n")}\n}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def resolve_primitive_type(type)
|
|
123
|
+
PRIMITIVE_MAP.fetch(type) do
|
|
124
|
+
scalar = ScalarRegistry.global.find(type)
|
|
125
|
+
scalar ? PRIMITIVE_MAP.fetch(scalar.base, 'unknown') : 'unknown'
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def pascal_case(name)
|
|
130
|
+
name.to_s.split('_').map(&:capitalize).join
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def pascal_case_string(string)
|
|
134
|
+
string.split('_').map(&:capitalize).join
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def camel_case(string)
|
|
138
|
+
parts = string.split('_')
|
|
139
|
+
parts.first + parts[1..].map(&:capitalize).join
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Zodra
|
|
6
|
+
module Export
|
|
7
|
+
class Writer
|
|
8
|
+
HEADER = '// Auto-generated by Zodra — DO NOT EDIT'
|
|
9
|
+
|
|
10
|
+
FORMATS = {
|
|
11
|
+
zod: 'schemas.ts',
|
|
12
|
+
typescript: 'types.ts'
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(configuration)
|
|
16
|
+
@configuration = configuration
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def write(format)
|
|
20
|
+
content = Export.generate(format,
|
|
21
|
+
key_format: @configuration.key_format,
|
|
22
|
+
zod_import: @configuration.zod_import)
|
|
23
|
+
|
|
24
|
+
write_file(FORMATS.fetch(format), content)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def write_contracts
|
|
28
|
+
content = Export.generate_contracts
|
|
29
|
+
return if content.empty?
|
|
30
|
+
|
|
31
|
+
write_file('contracts.ts', content)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def write_all
|
|
35
|
+
paths = FORMATS.keys.map { |format| write(format) }
|
|
36
|
+
contracts_path = write_contracts
|
|
37
|
+
paths << contracts_path if contracts_path
|
|
38
|
+
paths
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def write_file(filename, content)
|
|
44
|
+
FileUtils.mkdir_p(@configuration.output_path)
|
|
45
|
+
filepath = File.join(@configuration.output_path, filename)
|
|
46
|
+
File.write(filepath, "#{HEADER}\n#{content}\n")
|
|
47
|
+
filepath
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zodra
|
|
4
|
+
module Export
|
|
5
|
+
class ZodMapper
|
|
6
|
+
PRIMITIVE_MAP = {
|
|
7
|
+
string: 'z.string()',
|
|
8
|
+
integer: 'z.number().int()',
|
|
9
|
+
decimal: 'z.number()',
|
|
10
|
+
number: 'z.number()',
|
|
11
|
+
boolean: 'z.boolean()',
|
|
12
|
+
datetime: 'z.iso.datetime()',
|
|
13
|
+
date: 'z.iso.date()',
|
|
14
|
+
uuid: 'z.uuid()',
|
|
15
|
+
binary: 'z.string()'
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(key_format: :keep)
|
|
19
|
+
@key_format = key_format
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def map_definition(definition, lazy: false)
|
|
23
|
+
case definition.kind
|
|
24
|
+
when :object then map_object(definition, lazy:)
|
|
25
|
+
when :enum then map_enum(definition)
|
|
26
|
+
when :union then map_union(definition, lazy:)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def map_definitions(definitions, cycles: Set.new)
|
|
31
|
+
definitions.map { |definition| map_definition(definition, lazy: cycles.include?(definition.name)) }.join("\n\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def map_contract(contract)
|
|
35
|
+
params_output = map_contract_params(contract)
|
|
36
|
+
error_types_output = map_contract_error_types(contract)
|
|
37
|
+
descriptor_output = map_contract_descriptor(contract)
|
|
38
|
+
[params_output, error_types_output, descriptor_output].compact.reject(&:empty?).join("\n\n")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def map_contracts(contracts)
|
|
42
|
+
contracts.map { |contract| map_contract(contract) }.join("\n\n")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def map_object(definition, lazy: false)
|
|
48
|
+
name = pascal_case(definition.name)
|
|
49
|
+
properties = definition.attributes.values.map { |attr| " #{map_property(attr)}," }
|
|
50
|
+
body = "z.object({\n#{properties.join("\n")}\n})"
|
|
51
|
+
|
|
52
|
+
if lazy
|
|
53
|
+
"export const #{name}Schema: z.ZodType<#{name}> = z.lazy(() => #{body});"
|
|
54
|
+
else
|
|
55
|
+
"export const #{name}Schema = #{body};"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def map_enum(definition)
|
|
60
|
+
values = definition.values.map { |value| "'#{value}'" }.join(', ')
|
|
61
|
+
"export const #{pascal_case(definition.name)}Schema = z.enum([#{values}]);"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def map_union(definition, lazy: false)
|
|
65
|
+
name = pascal_case(definition.name)
|
|
66
|
+
discriminator_key = transform_key(definition.discriminator)
|
|
67
|
+
variants = definition.variants.map { |variant| map_variant(variant, discriminator_key) }
|
|
68
|
+
indent = ' '
|
|
69
|
+
body = "z.discriminatedUnion('#{discriminator_key}', [\n" \
|
|
70
|
+
"#{variants.map { |v| "#{indent}#{v}" }.join(",\n")},\n])"
|
|
71
|
+
|
|
72
|
+
if lazy
|
|
73
|
+
"export const #{name}Schema: z.ZodType<#{name}> = z.lazy(() => #{body});"
|
|
74
|
+
else
|
|
75
|
+
"export const #{name}Schema = #{body};"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def map_variant(variant, discriminator_key)
|
|
80
|
+
properties = variant.attributes.values.map { |attr| "#{transform_key(attr.name)}: #{map_zod_type(attr)}" }
|
|
81
|
+
all_properties = ["#{discriminator_key}: z.literal('#{variant.tag}')"] + properties
|
|
82
|
+
"z.object({ #{all_properties.join(', ')} })"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def map_property(attribute)
|
|
86
|
+
key = transform_key(attribute.name)
|
|
87
|
+
"#{key}: #{map_zod_type(attribute)}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def map_zod_type(attribute)
|
|
91
|
+
base = resolve_base_type(attribute)
|
|
92
|
+
base = apply_constraints(base, attribute)
|
|
93
|
+
apply_modifiers(base, attribute)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def resolve_base_type(attribute)
|
|
97
|
+
if attribute.enum
|
|
98
|
+
values = attribute.enum.map { |v| "'#{v}'" }.join(', ')
|
|
99
|
+
"z.enum([#{values}])"
|
|
100
|
+
elsif attribute.reference?
|
|
101
|
+
"#{pascal_case(attribute.reference_name)}Schema"
|
|
102
|
+
elsif attribute.array?
|
|
103
|
+
"z.array(#{pascal_case(attribute.of)}Schema)"
|
|
104
|
+
else
|
|
105
|
+
resolve_primitive_type(attribute.type)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def apply_constraints(base, attribute)
|
|
110
|
+
base = "#{base}.min(#{attribute.min})" if attribute.min
|
|
111
|
+
base = "#{base}.max(#{attribute.max})" if attribute.max
|
|
112
|
+
base
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def apply_modifiers(base, attribute)
|
|
116
|
+
base = "#{base}.default(#{format_default(attribute.default)})" unless attribute.default.nil?
|
|
117
|
+
base = "#{base}.nullable()" if attribute.nullable?
|
|
118
|
+
base = "#{base}.optional()" if attribute.optional?
|
|
119
|
+
base
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def format_default(value)
|
|
123
|
+
case value
|
|
124
|
+
when String then "'#{value}'"
|
|
125
|
+
when Symbol then "'#{value}'"
|
|
126
|
+
else value
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def transform_key(key)
|
|
131
|
+
key_string = key.to_s
|
|
132
|
+
case @key_format
|
|
133
|
+
when :camel then camel_case(key_string)
|
|
134
|
+
when :pascal then pascal_case(key_string)
|
|
135
|
+
else key_string
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def map_contract_params(contract)
|
|
140
|
+
contract.actions.values.map do |action|
|
|
141
|
+
params_name = :"#{action.name}_#{contract.name}_params"
|
|
142
|
+
renamed = Definition.new(name: params_name, kind: :object)
|
|
143
|
+
action.params.attributes.each { |key, attr| renamed.attributes[key] = attr }
|
|
144
|
+
map_object(renamed)
|
|
145
|
+
end.join("\n\n")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def map_contract_error_types(contract)
|
|
149
|
+
parts = contract.actions.values.filter_map do |action|
|
|
150
|
+
next if action.errors.empty?
|
|
151
|
+
|
|
152
|
+
action_name = pascal_case(action.name)
|
|
153
|
+
contract_name = pascal_case(contract.name)
|
|
154
|
+
|
|
155
|
+
codes = action.errors.values.map { |e| "'#{e[:code]}'" }.join(' | ')
|
|
156
|
+
"export type #{action_name}#{contract_name}BusinessError = { code: #{codes}; message: string };"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
parts.join("\n\n")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def map_contract_descriptor(contract)
|
|
163
|
+
name = pascal_case(contract.name)
|
|
164
|
+
return "export const #{name}Contract = {} as const;" if contract.actions.empty?
|
|
165
|
+
|
|
166
|
+
entries = contract.actions.values.map do |action|
|
|
167
|
+
params_schema = "#{pascal_case(action.name)}#{name}ParamsSchema"
|
|
168
|
+
parts = ["method: '#{action.http_method.to_s.upcase}' as const", "path: '#{action.path}' as const",
|
|
169
|
+
"params: #{params_schema}"]
|
|
170
|
+
parts << "response: #{pascal_case(action.response_type)}Schema" if action.response_type
|
|
171
|
+
if action.errors.any?
|
|
172
|
+
error_entries = action.errors.values.map do |e|
|
|
173
|
+
"{ code: '#{e[:code]}' as const, status: #{e[:status]} as const }"
|
|
174
|
+
end
|
|
175
|
+
parts << "errors: [#{error_entries.join(', ')}] as const"
|
|
176
|
+
end
|
|
177
|
+
" #{action.name}: { #{parts.join(', ')} }"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
"export const #{name}Contract = {\n#{entries.join(",\n")},\n} as const;"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def resolve_primitive_type(type)
|
|
184
|
+
PRIMITIVE_MAP.fetch(type) do
|
|
185
|
+
scalar = ScalarRegistry.global.find(type)
|
|
186
|
+
scalar ? PRIMITIVE_MAP.fetch(scalar.base, 'z.unknown()') : 'z.unknown()'
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def pascal_case(name)
|
|
191
|
+
name.to_s.split('_').map(&:capitalize).join
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def camel_case(string)
|
|
195
|
+
parts = string.split('_')
|
|
196
|
+
parts.first + parts[1..].map(&:capitalize).join
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
data/lib/zodra/export.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zodra
|
|
4
|
+
module Export
|
|
5
|
+
MAPPERS = {
|
|
6
|
+
typescript: TypeScriptMapper,
|
|
7
|
+
zod: ZodMapper
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
def self.generate(format, key_format: :camel, zod_import: 'zod')
|
|
11
|
+
mapper_class = MAPPERS.fetch(format) do
|
|
12
|
+
raise ConfigurationError, "Unknown export format: #{format}. Available: #{MAPPERS.keys.join(', ')}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
mapper = mapper_class.new(key_format:)
|
|
16
|
+
contracts = ContractRegistry.global.to_a
|
|
17
|
+
|
|
18
|
+
definitions = SurfaceResolver.call(TypeRegistry.global.to_a, contracts)
|
|
19
|
+
analysis = TypeAnalysis.call(definitions)
|
|
20
|
+
|
|
21
|
+
parts = []
|
|
22
|
+
parts << "import { z } from '#{zod_import}';" if format == :zod
|
|
23
|
+
parts << mapper.map_definitions(analysis.sorted, cycles: analysis.cycles)
|
|
24
|
+
parts << mapper.map_contracts(contracts) unless contracts.empty?
|
|
25
|
+
parts.reject(&:empty?).join("\n\n")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.generate_contracts
|
|
29
|
+
contracts = ContractRegistry.global.to_a
|
|
30
|
+
api_definitions = ApiRegistry.global.to_a
|
|
31
|
+
|
|
32
|
+
ContractMapper.new(api_definitions, contracts).generate
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|