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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +17 -0
  3. data/lib/generators/zodra/install_generator.rb +20 -0
  4. data/lib/generators/zodra/templates/initializer.rb.tt +12 -0
  5. data/lib/zodra/action.rb +43 -0
  6. data/lib/zodra/action_builder.rb +38 -0
  7. data/lib/zodra/api_builder.rb +23 -0
  8. data/lib/zodra/api_definition.rb +24 -0
  9. data/lib/zodra/api_registry.rb +33 -0
  10. data/lib/zodra/attribute.rb +46 -0
  11. data/lib/zodra/configuration.rb +18 -0
  12. data/lib/zodra/contract.rb +33 -0
  13. data/lib/zodra/contract_builder.rb +37 -0
  14. data/lib/zodra/contract_registry.rb +42 -0
  15. data/lib/zodra/controller.rb +141 -0
  16. data/lib/zodra/definition.rb +36 -0
  17. data/lib/zodra/export/contract_mapper.rb +76 -0
  18. data/lib/zodra/export/surface_resolver.rb +76 -0
  19. data/lib/zodra/export/type_analysis.rb +133 -0
  20. data/lib/zodra/export/type_script_mapper.rb +143 -0
  21. data/lib/zodra/export/writer.rb +51 -0
  22. data/lib/zodra/export/zod_mapper.rb +200 -0
  23. data/lib/zodra/export.rb +35 -0
  24. data/lib/zodra/params_coercer.rb +100 -0
  25. data/lib/zodra/params_parser.rb +90 -0
  26. data/lib/zodra/params_validator.rb +74 -0
  27. data/lib/zodra/railtie.rb +13 -0
  28. data/lib/zodra/resource.rb +51 -0
  29. data/lib/zodra/resource_builder.rb +57 -0
  30. data/lib/zodra/response_serializer.rb +63 -0
  31. data/lib/zodra/route_helper.rb +9 -0
  32. data/lib/zodra/router.rb +55 -0
  33. data/lib/zodra/scalar_registry.rb +32 -0
  34. data/lib/zodra/scalar_type.rb +13 -0
  35. data/lib/zodra/tasks/zodra.rake +41 -0
  36. data/lib/zodra/type_builder.rb +57 -0
  37. data/lib/zodra/type_deriver.rb +55 -0
  38. data/lib/zodra/type_registry.rb +42 -0
  39. data/lib/zodra/union_builder.rb +15 -0
  40. data/lib/zodra/variant.rb +12 -0
  41. data/lib/zodra/variant_builder.rb +23 -0
  42. data/lib/zodra/version.rb +5 -0
  43. data/lib/zodra.rb +147 -0
  44. metadata +113 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3b3bb353dba60ee571c044f3a579258434d3d5109edc06d1ad616f83aba8a49a
4
+ data.tar.gz: 10f13e687eb9c91f14d4967729e06fce1c55a1d1bf782fb5fc9c5f2701de528f
5
+ SHA512:
6
+ metadata.gz: c9ef51b11dc4445fd9e257d941f2c443b4c61169b1658e29ebfccfe244874c9975d8180f997b6f107139768c28a5bf4cc12c91ba42b7a394ea5810bc0e5973e0
7
+ data.tar.gz: 0f8ba993a332ecc464f5fa50722a6ec39eab04634616ec93709a5dd490b1aedb03776d1dd047b2250c980d61db28bb865362569c8192e9e08bba0a93a6684bd3
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-03-11)
4
+
5
+ Initial release.
6
+
7
+ - Type DSL: objects, enums, unions with attributes (optional, nullable, defaults, constraints, enum)
8
+ - Type composition: `from:`, `pick:`, `omit:`, `partial:`
9
+ - Contracts: params and response definitions per action
10
+ - Resource routing: `Zodra.api` with nested resources and custom actions
11
+ - Params validation: strict by default, coercion, constraints
12
+ - Response serialization: `{ data: ... }` / `{ data: [...], meta: ... }` envelope
13
+ - Controller mixin: `zodra_params`, `zodra_respond`, error handling
14
+ - TypeScript export: interfaces from type definitions
15
+ - Zod export: schemas with constraints (min, max, enum)
16
+ - Auto-generated header in exported files
17
+ - Rails integration: Railtie, rake tasks, Zeitwerk
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module Zodra
6
+ class InstallGenerator < Rails::Generators::Base
7
+ source_root File.expand_path('templates', __dir__)
8
+
9
+ desc 'Creates a Zodra initializer and types directory'
10
+
11
+ def create_initializer
12
+ template 'initializer.rb.tt', 'config/initializers/zodra.rb'
13
+ end
14
+
15
+ def create_types_directory
16
+ empty_directory 'app/types'
17
+ create_file 'app/types/.keep'
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ Zodra.configure do |config|
4
+ # Directory where generated TypeScript/Zod files are written.
5
+ # config.output_path = "app/javascript/types"
6
+
7
+ # Key format for exported properties: :camel, :pascal, or :keep.
8
+ # config.key_format = :camel
9
+
10
+ # Zod import path used in generated schemas.
11
+ # config.zod_import = "zod"
12
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zodra
4
+ class Action
5
+ attr_reader :name, :params, :contract, :response_definition, :errors
6
+
7
+ attr_accessor :http_method, :path, :response_type
8
+
9
+ def initialize(name:, contract: nil)
10
+ @name = name
11
+ @contract = contract
12
+ @params = Definition.new(name: :"#{name}_params", kind: :object)
13
+ @response_definition = Definition.new(name: :"#{name}_response", kind: :object)
14
+ @response_type = nil
15
+ @collection = false
16
+ @errors = {}
17
+ end
18
+
19
+ def add_error(code, status:)
20
+ @errors[code.to_sym] = { code: code.to_sym, status: status }
21
+ end
22
+
23
+ def find_error(code)
24
+ @errors[code.to_sym]
25
+ end
26
+
27
+ def collection?
28
+ @collection
29
+ end
30
+
31
+ def collection!
32
+ @collection = true
33
+ end
34
+
35
+ def response_schema
36
+ if response_type
37
+ contract&.resolve_type(response_type) || TypeRegistry.global.find!(response_type)
38
+ else
39
+ response_definition
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zodra
4
+ class ActionBuilder
5
+ def initialize(action)
6
+ @action = action
7
+ end
8
+
9
+ def params(from: nil, pick: nil, omit: nil, partial: false, &block)
10
+ if from
11
+ source = resolve_type(from)
12
+ TypeDeriver.new(source, pick:, omit:, partial:).apply(@action.params)
13
+ end
14
+
15
+ TypeBuilder.new(@action.params).instance_eval(&block) if block
16
+ end
17
+
18
+ def response(type_name = nil, collection: false, &block)
19
+ @action.collection! if collection
20
+
21
+ if block
22
+ TypeBuilder.new(@action.response_definition).instance_eval(&block)
23
+ elsif type_name
24
+ @action.response_type = type_name.to_sym
25
+ end
26
+ end
27
+
28
+ def error(code, status:)
29
+ @action.add_error(code, status:)
30
+ end
31
+
32
+ private
33
+
34
+ def resolve_type(name)
35
+ @action.contract&.resolve_type(name) || TypeRegistry.global.find!(name)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zodra
4
+ class ApiBuilder
5
+ def initialize(api_definition)
6
+ @api_definition = api_definition
7
+ end
8
+
9
+ def resources(name, contract: nil, controller: nil, only: nil, except: nil, &block)
10
+ resource = Resource.new(name:, singular: false, contract:, controller:, only:, except:)
11
+ ResourceBuilder.new(resource).instance_eval(&block) if block
12
+ @api_definition.add_resource(resource)
13
+ resource
14
+ end
15
+
16
+ def resource(name, contract: nil, controller: nil, only: nil, except: nil, &block)
17
+ resource = Resource.new(name:, singular: true, contract:, controller:, only:, except:)
18
+ ResourceBuilder.new(resource).instance_eval(&block) if block
19
+ @api_definition.add_resource(resource)
20
+ resource
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zodra
4
+ class ApiDefinition
5
+ attr_reader :base_path, :resources
6
+
7
+ def initialize(base_path:)
8
+ @base_path = base_path
9
+ @resources = []
10
+ end
11
+
12
+ def add_resource(resource)
13
+ @resources << resource
14
+ end
15
+
16
+ def namespaces
17
+ @base_path.split('/').reject(&:empty?).map(&:to_sym)
18
+ end
19
+
20
+ def controller_namespace
21
+ namespaces.join('/')
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zodra
4
+ class ApiRegistry
5
+ include Enumerable
6
+
7
+ def self.global
8
+ @global ||= new
9
+ end
10
+
11
+ def initialize
12
+ @store = {}
13
+ end
14
+
15
+ def register(base_path)
16
+ raise DuplicateTypeError, "API '#{base_path}' is already registered" if @store.key?(base_path)
17
+
18
+ @store[base_path] = ApiDefinition.new(base_path:)
19
+ end
20
+
21
+ def find(base_path)
22
+ @store[base_path]
23
+ end
24
+
25
+ def each(&)
26
+ @store.each_value(&)
27
+ end
28
+
29
+ def clear!
30
+ @store.clear
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zodra
4
+ class Attribute
5
+ attr_reader :name, :type, :format, :default, :min, :max, :enum, :of, :reference_name
6
+
7
+ def initialize(name:, type:, optional: false, nullable: false, format: nil,
8
+ default: nil, min: nil, max: nil, enum: nil, of: nil, reference_name: nil)
9
+ @name = name.to_sym
10
+ @type = type.to_sym
11
+ @optional = optional
12
+ @nullable = nullable
13
+ @format = format
14
+ @default = default
15
+ @min = min
16
+ @max = max
17
+ @enum = enum
18
+ @of = of
19
+ @reference_name = reference_name
20
+ end
21
+
22
+ def optional?
23
+ @optional
24
+ end
25
+
26
+ def nullable?
27
+ @nullable
28
+ end
29
+
30
+ def reference?
31
+ type == :reference
32
+ end
33
+
34
+ def array?
35
+ type == :array
36
+ end
37
+
38
+ def dependency_name
39
+ if reference?
40
+ reference_name.to_sym
41
+ elsif array? && of
42
+ of.to_sym
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zodra
4
+ class Configuration
5
+ DEFAULTS = {
6
+ output_path: 'app/javascript/types',
7
+ key_format: :camel,
8
+ zod_import: 'zod',
9
+ strict_params: true
10
+ }.freeze
11
+
12
+ attr_accessor :output_path, :key_format, :zod_import, :strict_params
13
+
14
+ def initialize
15
+ DEFAULTS.each { |key, value| public_send(:"#{key}=", value) }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zodra
4
+ class Contract
5
+ attr_reader :name, :actions, :types
6
+
7
+ def initialize(name:)
8
+ @name = name
9
+ @actions = {}
10
+ @types = TypeRegistry.new
11
+ end
12
+
13
+ def add_action(action_name)
14
+ action = Action.new(name: action_name, contract: self)
15
+ @actions[action_name.to_sym] = action
16
+ action
17
+ end
18
+
19
+ def find_action(action_name)
20
+ @actions[action_name.to_sym]
21
+ end
22
+
23
+ def resolve_type(type_name)
24
+ types.find(type_name) || TypeRegistry.global.find!(type_name)
25
+ end
26
+
27
+ alias find! resolve_type
28
+
29
+ def find(type_name)
30
+ types.find(type_name) || TypeRegistry.global.find(type_name)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zodra
4
+ class ContractBuilder
5
+ def initialize(contract)
6
+ @contract = contract
7
+ end
8
+
9
+ def action(name, &block)
10
+ action = @contract.add_action(name)
11
+ ActionBuilder.new(action).instance_eval(&block) if block
12
+ action
13
+ end
14
+
15
+ def type(name, from: nil, pick: nil, omit: nil, partial: false, &block)
16
+ definition = @contract.types.register(name, kind: :object)
17
+
18
+ if from
19
+ source = @contract.resolve_type(from) || TypeRegistry.global.find!(from)
20
+ TypeDeriver.new(source, pick:, omit:, partial:).apply(definition)
21
+ end
22
+
23
+ TypeBuilder.new(definition).instance_eval(&block) if block
24
+ definition
25
+ end
26
+
27
+ def enum(name, values:)
28
+ @contract.types.register(name, kind: :enum, values:)
29
+ end
30
+
31
+ def union(name, discriminator:, &block)
32
+ definition = @contract.types.register(name, kind: :union, discriminator:)
33
+ UnionBuilder.new(definition).instance_eval(&block) if block
34
+ definition
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zodra
4
+ class ContractRegistry
5
+ include Enumerable
6
+
7
+ def self.global
8
+ @global ||= new
9
+ end
10
+
11
+ def initialize
12
+ @store = {}
13
+ end
14
+
15
+ def register(name)
16
+ name = name.to_sym
17
+ raise DuplicateTypeError, "Contract :#{name} is already registered" if @store.key?(name)
18
+
19
+ @store[name] = Contract.new(name:)
20
+ end
21
+
22
+ def find(name)
23
+ @store[name.to_sym]
24
+ end
25
+
26
+ def find!(name)
27
+ @store.fetch(name.to_sym) { raise KeyError, "Contract :#{name} is not registered" }
28
+ end
29
+
30
+ def exists?(name)
31
+ @store.key?(name.to_sym)
32
+ end
33
+
34
+ def each(&)
35
+ @store.each_value(&)
36
+ end
37
+
38
+ def clear!
39
+ @store.clear
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module Zodra
6
+ module Controller
7
+ extend ActiveSupport::Concern
8
+
9
+ RAILS_INTERNAL_KEYS = %w[controller action format].freeze
10
+
11
+ included do
12
+ wrap_parameters false
13
+
14
+ rescue_from Zodra::ParamsError do |error|
15
+ render json: { errors: transform_error_keys(error.errors) }, status: :unprocessable_entity
16
+ end
17
+ end
18
+
19
+ class_methods do
20
+ def zodra_contract(name)
21
+ @zodra_contract_name = name.to_sym
22
+ end
23
+
24
+ def zodra_contract_name
25
+ @zodra_contract_name
26
+ end
27
+
28
+ def zodra_rescue(action_name, exception_class, as:)
29
+ @zodra_rescue_mappings ||= []
30
+ @zodra_rescue_mappings << { action_name: action_name.to_sym, exception_class:, code: as.to_sym }
31
+
32
+ rescue_from exception_class do |exception|
33
+ handle_zodra_business_error(exception)
34
+ end
35
+ end
36
+
37
+ def zodra_rescue_mappings
38
+ @zodra_rescue_mappings || []
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def zodra_params
45
+ @zodra_params ||= begin
46
+ raw = request.parameters.except(*RAILS_INTERNAL_KEYS)
47
+ result = ParamsParser.call(raw, schema: zodra_action.params)
48
+ raise ParamsError, result.errors unless result.valid?
49
+
50
+ result.params
51
+ end
52
+ end
53
+
54
+ def zodra_respond(object, status: :ok)
55
+ schema = zodra_action.response_schema
56
+ serialized = serialize_response(object, schema)
57
+ render json: { data: serialized }, status:
58
+ end
59
+
60
+ def zodra_respond_collection(objects, status: :ok, meta: nil)
61
+ schema = zodra_action.response_schema
62
+ serialized = objects.map { |object| serialize_response(object, schema) }
63
+
64
+ response_body = { data: serialized }
65
+ response_body[:meta] = meta if meta
66
+ render json: response_body, status:
67
+ end
68
+
69
+ def zodra_errors(errors, status: :unprocessable_entity)
70
+ normalized = normalize_errors(errors)
71
+ validate_error_keys!(normalized)
72
+ render json: { errors: transform_error_keys(normalized) }, status:
73
+ end
74
+
75
+ def zodra_action
76
+ @zodra_action ||= zodra_contract.find_action(action_name) ||
77
+ raise(Zodra::Error,
78
+ "Action :#{action_name} not found in contract :#{self.class.zodra_contract_name}")
79
+ end
80
+
81
+ def zodra_contract
82
+ @zodra_contract ||= ContractRegistry.global.find!(self.class.zodra_contract_name)
83
+ end
84
+
85
+ def serialize_response(object, schema)
86
+ key_format = Zodra.configuration.key_format
87
+ ResponseSerializer.call(object, schema, key_format:, type_resolver: zodra_contract)
88
+ end
89
+
90
+ def handle_zodra_business_error(exception)
91
+ mapping = self.class.zodra_rescue_mappings.find do |m|
92
+ m[:action_name] == action_name.to_sym && exception.is_a?(m[:exception_class])
93
+ end
94
+
95
+ raise exception unless mapping
96
+
97
+ error_definition = zodra_action.find_error(mapping[:code])
98
+ status = error_definition ? error_definition[:status] : :internal_server_error
99
+
100
+ render json: { error: { code: mapping[:code].to_s, message: exception.message } }, status:
101
+ end
102
+
103
+ def normalize_errors(errors)
104
+ if errors.respond_to?(:to_hash)
105
+ errors.to_hash
106
+ elsif errors.respond_to?(:messages)
107
+ errors.messages
108
+ else
109
+ errors
110
+ end
111
+ end
112
+
113
+ def validate_error_keys!(errors)
114
+ return unless valid_error_keys_for_action
115
+
116
+ unknown_keys = errors.keys.map(&:to_sym) - valid_error_keys_for_action
117
+ return if unknown_keys.empty?
118
+
119
+ message = "Unknown error keys #{unknown_keys.inspect} for action :#{action_name}. " \
120
+ "Valid keys: #{valid_error_keys_for_action.inspect}"
121
+
122
+ raise Zodra::Error, message if defined?(Rails) && !Rails.env.production?
123
+
124
+ Zodra.logger&.warn("[Zodra] #{message}")
125
+ end
126
+
127
+ def valid_error_keys_for_action
128
+ return @valid_error_keys_for_action if defined?(@valid_error_keys_for_action)
129
+
130
+ param_keys = zodra_action.params.attributes.keys
131
+ @valid_error_keys_for_action = param_keys.empty? ? nil : param_keys + [:base]
132
+ end
133
+
134
+ def transform_error_keys(errors)
135
+ key_format = Zodra.configuration.key_format
136
+ return errors if key_format == :keep
137
+
138
+ errors.transform_keys { |key| ResponseSerializer.send(:transform_key, key, key_format) }
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zodra
4
+ class Definition
5
+ attr_reader :name, :kind, :discriminator, :values, :attributes, :variants
6
+
7
+ def initialize(name:, kind:, discriminator: nil, values: nil)
8
+ @name = name
9
+ @kind = kind
10
+ @discriminator = discriminator
11
+ @values = values
12
+ @attributes = {}
13
+ @variants = []
14
+ end
15
+
16
+ def object?
17
+ kind == :object
18
+ end
19
+
20
+ def enum?
21
+ kind == :enum
22
+ end
23
+
24
+ def union?
25
+ kind == :union
26
+ end
27
+
28
+ def add_attribute(attribute_name, **)
29
+ @attributes[attribute_name.to_sym] = Attribute.new(name: attribute_name, **)
30
+ end
31
+
32
+ def add_variant(tag, attributes: {})
33
+ @variants << Variant.new(tag:, attributes:)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zodra
4
+ module Export
5
+ class ContractMapper
6
+ def initialize(api_definitions, contracts)
7
+ @api_definitions = api_definitions
8
+ @contracts = contracts
9
+ end
10
+
11
+ def generate
12
+ return '' if @contracts.empty?
13
+
14
+ parts = []
15
+ parts << build_import
16
+ parts << build_contracts_map
17
+ parts << build_base_url if @api_definitions.any?
18
+ parts.join("\n\n")
19
+ end
20
+
21
+ private
22
+
23
+ def build_import
24
+ schema_names = contract_names.map { |name| "#{pascal_case(name)}Contract" }
25
+ "import { #{schema_names.join(', ')} } from './schemas';"
26
+ end
27
+
28
+ def build_contracts_map
29
+ entries = contract_names.map do |name|
30
+ " #{camel_case(name)}: #{pascal_case(name)}Contract"
31
+ end
32
+
33
+ "export const contracts = {\n#{entries.join(",\n")},\n} as const;"
34
+ end
35
+
36
+ def build_base_url
37
+ base_path = @api_definitions.first.base_path
38
+ "export const baseUrl = '#{base_path}';"
39
+ end
40
+
41
+ def contract_names
42
+ @contract_names ||= resolve_contract_names
43
+ end
44
+
45
+ def resolve_contract_names
46
+ if @api_definitions.any?
47
+ collect_resource_names(@api_definitions)
48
+ else
49
+ @contracts.map(&:name).map(&:to_s)
50
+ end
51
+ end
52
+
53
+ def collect_resource_names(api_definitions)
54
+ names = []
55
+ api_definitions.each do |api|
56
+ api.resources.each { |resource| collect_names_recursive(resource, names) }
57
+ end
58
+ names
59
+ end
60
+
61
+ def collect_names_recursive(resource, names)
62
+ names << resource.contract_name.to_s
63
+ resource.children.each { |child| collect_names_recursive(child, names) }
64
+ end
65
+
66
+ def pascal_case(name)
67
+ name.to_s.split('_').map(&:capitalize).join
68
+ end
69
+
70
+ def camel_case(name)
71
+ parts = name.to_s.split('_')
72
+ parts.first + parts[1..].map(&:capitalize).join
73
+ end
74
+ end
75
+ end
76
+ end