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
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
|
data/lib/zodra/action.rb
ADDED
|
@@ -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
|