paradocs 1.0.22 → 1.1.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,135 @@
1
+ # Structs
2
+ ## Overview
3
+ Structs turn schema definitions into objects graphs with attribute readers.
4
+
5
+ Add optional `Paradocs::Struct` module to define struct-like objects with schema definitions.
6
+
7
+ ```ruby
8
+ require 'parametric/struct'
9
+
10
+ class User
11
+ include Paradocs::Struct
12
+
13
+ schema do
14
+ field(:name).type(:string).present
15
+ field(:friends).type(:array).schema do
16
+ field(:name).type(:string).present
17
+ field(:age).type(:integer)
18
+ end
19
+ end
20
+ end
21
+ ```
22
+
23
+ `User` objects can be instantiated with hash data, which will be coerced and validated as per the schema definition.
24
+
25
+ ```ruby
26
+ user = User.new(
27
+ name: 'Joe',
28
+ friends: [
29
+ {name: 'Jane', age: 40},
30
+ {name: 'John', age: 30},
31
+ ]
32
+ )
33
+
34
+ # properties
35
+ user.name # => 'Joe'
36
+ user.friends.first.name # => 'Jane'
37
+ user.friends.last.age # => 30
38
+ ```
39
+
40
+ ## Errors
41
+
42
+ Both the top-level and nested instances contain error information:
43
+
44
+ ```ruby
45
+ user = User.new(
46
+ name: '', # invalid
47
+ friends: [
48
+ # friend name also invalid
49
+ {name: '', age: 40},
50
+ ]
51
+ )
52
+
53
+ user.valid? # false
54
+ user.errors['$.name'] # => "is required and must be present"
55
+ user.errors['$.friends[0].name'] # => "is required and must be present"
56
+
57
+ # also access error in nested instances directly
58
+ user.friends.first.valid? # false
59
+ user.friends.first.errors['$.name'] # "is required and must be valid"
60
+ ```
61
+
62
+ ## .new!(hash)
63
+
64
+ Instantiating structs with `.new!(hash)` will raise a `Paradocs::InvalidStructError` exception if the data is validations fail. It will return the struct instance otherwise.
65
+
66
+ `Paradocs::InvalidStructError` includes an `#errors` property to inspect the errors raised.
67
+
68
+ ```ruby
69
+ begin
70
+ user = User.new!(name: '')
71
+ rescue Paradocs::InvalidStructError => e
72
+ e.errors['$.name'] # "is required and must be present"
73
+ end
74
+ ```
75
+
76
+ ## Nested structs
77
+
78
+ You can also pass separate struct classes in a nested schema definition.
79
+
80
+ ```ruby
81
+ class Friend
82
+ include Paradocs::Struct
83
+
84
+ schema do
85
+ field(:name).type(:string).present
86
+ field(:age).type(:integer)
87
+ end
88
+ end
89
+
90
+ class User
91
+ include Paradocs::Struct
92
+
93
+ schema do
94
+ field(:name).type(:string).present
95
+ # here we use the Friend class
96
+ field(:friends).type(:array).schema Friend
97
+ end
98
+ end
99
+ ```
100
+
101
+ ## Inheritance
102
+
103
+ Struct subclasses can add to inherited schemas, or override fields defined in the parent.
104
+
105
+ ```ruby
106
+ class AdminUser < User
107
+ # inherits User schema, and can add stuff to its own schema
108
+ schema do
109
+ field(:permissions).type(:array)
110
+ end
111
+ end
112
+ ```
113
+
114
+ ## #to_h
115
+
116
+ `Struct#to_h` returns the ouput hash, with values coerced and any defaults populated.
117
+
118
+ ```ruby
119
+ class User
120
+ include Paradocs::Struct
121
+ schema do
122
+ field(:name).type(:string)
123
+ field(:age).type(:integer).default(30)
124
+ end
125
+ end
126
+
127
+ user = User.new(name: "Joe")
128
+ user.to_h # {name: "Joe", age: 30}
129
+ ```
130
+
131
+ ## Struct equality
132
+
133
+ `Paradocs::Struct` implements `#==()` to compare two structs Hash representation (same as `struct1.to_h.eql?(struct2.to_h)`.
134
+
135
+ Users can override `#==()` in their own classes to do whatever they need.
@@ -0,0 +1,29 @@
1
+ # Subchemas
2
+ > When your schema can change on-the-fly.
3
+
4
+ ## Subschemas and mutations.
5
+ Sometimes depending on the data the structure may vary. Most frequently used option is to use `:declared` policy (a.k.a. conditional, but below is another option:
6
+
7
+ - Mutations are blocks that are assigned to a field and called during the validation (in #resolve), block receives all the related data and should return a subschema name.
8
+ - Subschemas are conditional schemas declared inside schemas. They doesn't exist until mutation block is called and decides to invoke a subschema.
9
+ ```ruby
10
+ person_schema = Paradocs::Schema.new do
11
+ field(:role).type(:string).options(["admin", "user"]).mutates_schema! do |value, key, payload, env|
12
+ value == :admin ? :admin_schema : :user_schema
13
+ end
14
+
15
+ subschema(:admin_schema) do
16
+ field(:permissions).present.type(:string).options(["superuser"])
17
+ field(:admin_field)
18
+ end
19
+ subschema(:user_schema) do
20
+ field(:permissions).present.type(:string).options(["readonly"])
21
+ field(:user_field)
22
+ end
23
+ end
24
+
25
+ results = person_schema.resolve(name: "John", age: 20, role: :admin, permissions: "superuser")
26
+ results.output # => {name: "John", age: 20, role: :admin, permissions: "superuser", admin_field: nil}
27
+ results = person_schema.resolve(name: "John", age: 20, role: :admin, permissions: "readonly")
28
+ results.errors => {"$.permissions"=>["must be one of superuser, but got readonly"]}
29
+ ```
@@ -22,7 +22,8 @@ module Paradocs
22
22
  explicit_errors: false,
23
23
  whitelisted_keys: [],
24
24
  default_schema_name: :schema,
25
- meta_prefix: "_"
25
+ meta_prefix: "_",
26
+ whitelist_coercion: nil
26
27
  )
27
28
  end
28
29
 
@@ -0,0 +1,44 @@
1
+ module Paradocs
2
+ module Extensions
3
+ class PayloadBuilder
4
+ attr_reader :structure, :result
5
+ attr_accessor :skip_word
6
+ def initialize(schema, skip_word: :skip)
7
+ @structure = schema.structure
8
+ @skip_word = skip_word
9
+ end
10
+
11
+ def build!(sort_by_schema: false, &block)
12
+ result = structure.all_nested.map { |name, struct| [name, build_simple_structure(struct, &block)] }.to_h
13
+ sort_by_schema ? schema.resolve(result).output : result
14
+ end
15
+
16
+ private
17
+
18
+ def build_simple_structure(struct, &block)
19
+ struct.map do |key, value|
20
+ key = key.to_s
21
+ next if key.start_with?(Paradocs.config.meta_prefix) # skip all the meta fields
22
+ ex_value = restore_one(key, value, &block)
23
+ next if ex_value == @skip_word
24
+ [key, ex_value]
25
+ end.compact.to_h
26
+ end
27
+
28
+ def restore_one(key, value, &block)
29
+ default = value[:default]
30
+ ex_value = if value[:structure]
31
+ data = build_simple_structure(value[:structure], &block)
32
+ value[:type] == :array ? [data] : data
33
+ elsif default
34
+ default.is_a?(Proc) ? default.call : default
35
+ elsif value[:options] && !value[:options].empty?
36
+ options = value[:options]
37
+ value[:type] == :array ? options : options.sample
38
+ end
39
+ return ex_value unless block_given?
40
+ yield(key, value, ex_value, @skip_word)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,116 @@
1
+ module Paradocs
2
+ module Extensions
3
+ class Structure
4
+ DEFAULT = :generic
5
+ %w(errors subschemes).each do |key|
6
+ define_method(key) { "#{Paradocs.config.meta_prefix}#{key}".to_sym }
7
+ end
8
+
9
+ attr_reader :schema, :ignore_transparent, :root
10
+ attr_accessor :ignore_transparent
11
+ def initialize(schema, ignore_transparent=true, root="")
12
+ @schema = schema
13
+ @ignore_transparent = ignore_transparent
14
+ @root = root
15
+ end
16
+
17
+ def nested(&block)
18
+ schema.fields.each_with_object({errors => [], subschemes => {}}) do |(_, field), result|
19
+ meta, sc = collect_meta(field, root)
20
+ if sc
21
+ meta[:structure] = self.class.new(sc, ignore_transparent, meta[:json_path]).nested(&block)
22
+ result[errors] += meta[:structure].delete(errors)
23
+ else
24
+ result[errors] += field.possible_errors
25
+ end
26
+ result[field.key] = meta unless ignore_transparent && field.transparent?
27
+ yield(field.key, meta) if block_given?
28
+
29
+ next unless field.mutates_schema?
30
+ schema.subschemes.each do |name, subschema|
31
+ result[subschemes][name] = self.class.new(subschema, ignore_transparent, root).nested(&block)
32
+ result[errors] += result[subschemes][name][errors]
33
+ end
34
+ end
35
+ end
36
+
37
+ def all_nested(&block)
38
+ all_flatten(&block).each_with_object({}) do |(name, struct), obj|
39
+ obj[name] = {}
40
+ # sort the flatten struct to have iterated 1lvl keys before 2lvl and so on...
41
+ struct.sort_by { |k, v| k.to_s.count(".") }.each do |key, value|
42
+ target = obj[name]
43
+ key, value = key.to_s, value.clone # clone the values, because we do mutation below
44
+ value.merge!(nested_name: key) if value.respond_to?(:merge) # it can be array (_errors)
45
+ next target[key.to_sym] = value if key.start_with?(Paradocs.config.meta_prefix) # copy meta fields
46
+
47
+ parts = key.split(".")
48
+ next target[key] ||= value if parts.size == 1 # copy 1lvl key
49
+ parts.each.with_index do |subkey, index|
50
+ target[subkey] ||= value
51
+ next if parts.size == index + 1
52
+ target[subkey][:structure] ||= {}
53
+ target = target[subkey][:structure] # target goes deeper for each part
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ def all_flatten(schema_structure=nil, &block)
60
+ schema_structure ||= flatten(&block)
61
+ if schema_structure[subschemes].empty?
62
+ schema_structure.delete(subschemes) # don't include redundant key
63
+ return {DEFAULT => schema_structure}
64
+ end
65
+ schema_structure[subschemes].each_with_object({}) do |(name, subschema), result|
66
+ if subschema[subschemes].empty?
67
+ result[name] = schema_structure.merge(subschema)
68
+ result[name][errors] += schema_structure[errors]
69
+ result[name][errors].uniq!
70
+ result[name].delete(subschemes)
71
+ next result[name]
72
+ end
73
+
74
+ all_flatten(subschema).each do |sub_name, schema|
75
+ result["#{name}_#{sub_name}".to_sym] = schema_structure.merge(schema)
76
+ end
77
+ end
78
+ end
79
+
80
+ def flatten(&block)
81
+ schema.fields.each_with_object({errors => [], subschemes => {}}) do |(_, field), obj|
82
+ meta, sc = collect_meta(field, root)
83
+ humanized_name = meta.delete(:nested_name)
84
+ obj[humanized_name] = meta unless ignore_transparent && field.transparent?
85
+
86
+ if sc
87
+ deep_result = self.class.new(sc, ignore_transparent, meta[:json_path]).flatten(&block)
88
+ obj[errors] += deep_result.delete(errors)
89
+ obj[subschemes].merge!(deep_result.delete(subschemes))
90
+ obj.merge!(deep_result)
91
+ else
92
+ obj[errors] += field.possible_errors
93
+ end
94
+ yield(humanized_name, meta) if block_given?
95
+ next unless field.mutates_schema?
96
+ schema.subschemes.each do |name, subschema|
97
+ obj[subschemes][name] ||= self.class.new(subschema, ignore_transparent, root).flatten(&block)
98
+ obj[errors] += obj[subschemes][name][errors]
99
+ end
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def collect_meta(field, root)
106
+ json_path = root.empty? ? "$.#{field.key}" : "#{root}.#{field.key}"
107
+ meta = field.meta_data.merge(json_path: json_path)
108
+ sc = meta.delete(:schema)
109
+ meta[:mutates_schema] = true if meta.delete(:mutates_schema)
110
+ json_path << "[]" if meta[:type] == :array
111
+ meta[:nested_name] = json_path.gsub("[]", "")[2..-1]
112
+ [meta, sc]
113
+ end
114
+ end
115
+ end
116
+ end
@@ -107,7 +107,6 @@ module Paradocs
107
107
  define_method(:meta_data) do
108
108
  meta = super()
109
109
  meta[policy][:limit] = limit
110
- binding.pry unless meta.dig(policy, :limit)
111
110
  meta
112
111
  end
113
112
 
@@ -1,14 +1,13 @@
1
1
  require "paradocs/context"
2
2
  require "paradocs/results"
3
3
  require "paradocs/field"
4
- require "paradocs/extensions/insides"
4
+ require "paradocs/extensions/structure"
5
+ require "paradocs/extensions/payload_builder"
5
6
 
6
7
  module Paradocs
7
8
  class Schema
8
- include Extensions::Insides
9
-
10
9
  attr_accessor :environment
11
- attr_reader :subschemes
10
+ attr_reader :subschemes, :structure_builder
12
11
  def initialize(options={}, &block)
13
12
  @options = options
14
13
  @fields = {}
@@ -18,6 +17,7 @@ module Paradocs
18
17
  @default_field_policies = []
19
18
  @ignored_field_keys = []
20
19
  @expansions = {}
20
+ @structure_builder = Paradocs::Extensions::Structure.new(self)
21
21
  end
22
22
 
23
23
  def schema
@@ -29,6 +29,27 @@ module Paradocs
29
29
  f.mutates_schema!(&block)
30
30
  end
31
31
 
32
+ def structure(ignore_transparent: true)
33
+ flush!
34
+ structure_builder.ignore_transparent = ignore_transparent
35
+ structure_builder
36
+ end
37
+
38
+ def example_payloads(&block)
39
+ @example_payloads ||= Paradocs::Extensions::PayloadBuilder.new(self).build!(&block)
40
+ end
41
+
42
+ def walk(meta_key = nil, &visitor)
43
+ r = visit(meta_key, &visitor)
44
+ Results.new(r, {}, {})
45
+ end
46
+
47
+ def visit(meta_key = nil, &visitor)
48
+ fields.each_with_object({}) do |(_, field), m|
49
+ m[field.key] = field.visit(meta_key, &visitor)
50
+ end
51
+ end
52
+
32
53
  def subschema(*args, &block)
33
54
  options = args.last.is_a?(Hash) ? args.last : {}
34
55
  name = args.first.is_a?(Symbol) ? args.shift : Paradocs.config.default_schema_name
@@ -143,6 +164,11 @@ module Paradocs
143
164
  end
144
165
  end
145
166
 
167
+ def flush!
168
+ @fields = {}
169
+ @applied = false
170
+ end
171
+
146
172
  protected
147
173
 
148
174
  attr_reader :definitions, :options
@@ -205,10 +231,5 @@ module Paradocs
205
231
  end
206
232
  @applied = true
207
233
  end
208
-
209
- def flush!
210
- @fields = {}
211
- @applied = false
212
- end
213
234
  end
214
235
  end
@@ -18,8 +18,8 @@ module Paradocs
18
18
  base.extend ClassMethods
19
19
  end
20
20
 
21
- def initialize(attrs = {})
22
- @_results = self.class.schema.resolve(attrs)
21
+ def initialize(attrs = {}, environment = {})
22
+ @_results = self.class.schema.resolve(attrs, environment)
23
23
  @_graph = self.class.build(@_results.output)
24
24
  end
25
25
 
@@ -48,8 +48,8 @@ module Paradocs
48
48
  attr_reader :_graph, :_results
49
49
 
50
50
  module ClassMethods
51
- def new!(attrs = {})
52
- st = new(attrs)
51
+ def new!(attrs = {}, environment = {})
52
+ st = new(attrs, environment)
53
53
  raise InvalidStructError.new(st) unless st.valid?
54
54
  st
55
55
  end
@@ -1,3 +1,3 @@
1
1
  module Paradocs
2
- VERSION = "1.0.22"
2
+ VERSION = "1.1.2"
3
3
  end