Class: Praxis::Blueprint

Inherits:
Object
  • Object
show all
Extended by:
Finalizable
Includes:
Attributor::Container, Attributor::Dumpable, Attributor::Type
Defined in:
lib/praxis/blueprint.rb

Direct Known Subclasses

BlueprintAttributeGroup, MediaType

Defined Under Namespace

Classes: DSLCompiler, FieldsetParser

Constant Summary collapse

@@caching_enabled =

rubocop:disable Style/ClassVars

false

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Finalizable

_finalize!, extended, finalizable, finalize!, finalized?, inherited

Constructor Details

#initialize(object) ⇒ Blueprint

Returns a new instance of Blueprint.



304
305
306
307
# File 'lib/praxis/blueprint.rb', line 304

def initialize(object)
  @object = object
  @validating = false
end

Class Attribute Details

.attributeObject (readonly)

Returns the value of attribute attribute.



65
66
67
# File 'lib/praxis/blueprint.rb', line 65

def attribute
  @attribute
end

.optionsObject (readonly)

Returns the value of attribute options.



65
66
67
# File 'lib/praxis/blueprint.rb', line 65

def options
  @options
end

Instance Attribute Details

#objectObject

Returns the value of attribute object.



62
63
64
# File 'lib/praxis/blueprint.rb', line 62

def object
  @object
end

#validatingObject (readonly)

Returns the value of attribute validating.



61
62
63
# File 'lib/praxis/blueprint.rb', line 61

def validating
  @validating
end

Class Method Details

._finalize!Object

Internal finalize! logic



227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/praxis/blueprint.rb', line 227

def self._finalize!
  if @block
    define_attribute!
    define_readers!
    # Don't blindly override a the default fieldset if the MediaType wants to define it on its own
    if @block_for_default_fieldset
      parse_default_fieldset(@block_for_default_fieldset)
    else
      generate_default_fieldset!
    end
    resolve_domain_model!
  end
  # Make sure to add the given defined description to the underlying type, so it can show up in the docs, etc
  # Blueprint groups do not have a description...
  if respond_to?(:description) && description
    options[:description] = description
    @attribute.type.options[:description] = description
  end
  super
end

.as_json_schema(**args) ⇒ Object

Delegates the json-schema methods to the underlying attribute/member_type



382
383
384
385
386
# File 'lib/praxis/blueprint.rb', line 382

def self.as_json_schema(**args)
  # TODO: Aren't we loosing the attribute options if we just call the type?? (e.g. description, etc)
  # Also, we might want to add a 'title' for MTs, to be the class name (without prefixing) ...
  @attribute.type.as_json_schema(args)
end

.attributes(opts = {}, &block) ⇒ Object



98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/praxis/blueprint.rb', line 98

def self.attributes(opts = {}, &block)
  if block_given?
    raise 'Redefining Blueprint attributes is not currently supported' if const_defined?(:InnerStruct, false)

    @options.merge!(opts.merge(dsl_compiler: DSLCompiler))
    @block = block

    return @attribute
  end

  raise "@attribute not defined yet for #{name}" unless @attribute

  @attribute.attributes
end

.cacheObject

Fetch current blueprint cache, scoped by this class



155
156
157
# File 'lib/praxis/blueprint.rb', line 155

def self.cache
  Thread.current[:praxis_blueprints_cache][self]
end

.cache=(cache) ⇒ Object



159
160
161
# File 'lib/praxis/blueprint.rb', line 159

def self.cache=(cache)
  Thread.current[:praxis_blueprints_cache] = cache
end

.caching_enabled=(caching_enabled) ⇒ Object



150
151
152
# File 'lib/praxis/blueprint.rb', line 150

def self.caching_enabled=(caching_enabled)
  @@caching_enabled = caching_enabled # rubocop:disable Style/ClassVars
end

.caching_enabled?Boolean

Returns:

  • (Boolean)


146
147
148
# File 'lib/praxis/blueprint.rb', line 146

def self.caching_enabled?
  @@caching_enabled
end

.check_option!(name, value) ⇒ Object



119
120
121
# File 'lib/praxis/blueprint.rb', line 119

def self.check_option!(name, value)
  Attributor::Struct.check_option!(name, value)
end

.default_fieldset(&block) ⇒ Object



190
191
192
193
194
# File 'lib/praxis/blueprint.rb', line 190

def self.default_fieldset(&block)
  return @default_fieldset unless block_given?

  @block_for_default_fieldset = block
end

.define_attribute!Object



254
255
256
257
258
259
# File 'lib/praxis/blueprint.rb', line 254

def self.define_attribute!
  @attribute = Attributor::Attribute.new(Attributor::Struct, @options, &@block)
  @block = nil
  @attribute.type.anonymous_type true
  const_set(:InnerStruct, @attribute.type)
end

.define_reader!(name) ⇒ Object



272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/praxis/blueprint.rb', line 272

def self.define_reader!(name)
  attribute = attributes[name]
  # TODO: profile and optimize
  # because we use the attribute in the reader,
  # it's likely faster to use define_method here
  # than module_eval, but we should make sure.
  define_method(name) do
    value = @object.__send__(name)
    return value if value.nil? || value.is_a?(attribute.type)

    attribute.load(value)
  end
end

.define_readers!Object



261
262
263
264
265
266
267
268
269
270
# File 'lib/praxis/blueprint.rb', line 261

def self.define_readers!
  attributes.each do |name, _attribute|
    name = name.to_sym

    # Don't redefine existing methods
    next if instance_methods.include? name

    define_reader! name
  end
end

.domain_model(klass = nil) ⇒ Object



113
114
115
116
117
# File 'lib/praxis/blueprint.rb', line 113

def self.domain_model(klass = nil)
  return @domain_model if klass.nil?

  @domain_model = klass
end

.dump(object, context: Attributor::DEFAULT_ROOT_CONTEXT, **opts) ⇒ Object Also known as: render

renders using the implicit default fieldset



215
216
217
218
219
220
# File 'lib/praxis/blueprint.rb', line 215

def self.dump(object, context: Attributor::DEFAULT_ROOT_CONTEXT, **opts)
  object = self.load(object, context, **opts)
  return nil if object.nil?

  object.render(context: context, **opts)
end

.example(context = nil, **values) ⇒ Object



167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/praxis/blueprint.rb', line 167

def self.example(context = nil, **values)
  context = case context
            when nil
              ["#{name}-#{values.object_id}"]
            when ::String
              [context]
            else
              context
            end

  new(attribute.example(context, values: values))
end

.familyObject



94
95
96
# File 'lib/praxis/blueprint.rb', line 94

def self.family
  'hash'
end

.generate_default_fieldset!Object



286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'lib/praxis/blueprint.rb', line 286

def self.generate_default_fieldset!
  attributes = self.attributes

  @default_fieldset = {}
  attributes.each do |name, attr|
    the_type = attr.type < Attributor::Collection ? attr.type.member_type : attr.type
    next if the_type < Blueprint

    # TODO: Allow groups in the default fieldset?? or perhaps better to make people explicitly define them?
    # next if (the_type < Blueprint && !(the_type < BlueprintAttributeGroup))

    # NOTE: we won't try to expand fields here, as we want to be lazy (and we're expanding)
    # every time a request comes in anyway. This could be an optimization we do at some point
    # or we can 'memoize it' to avoid trying to expand it over an over...
    @default_fieldset[name] = true
  end
end

.inherited(klass) ⇒ Object



68
69
70
71
72
73
74
75
76
# File 'lib/praxis/blueprint.rb', line 68

def self.inherited(klass)
  super

  klass.instance_eval do
    @options = {}
    @domain_model = Object
    @default_fieldset = {}
  end
end

.json_schema_typeObject



388
389
390
# File 'lib/praxis/blueprint.rb', line 388

def self.json_schema_type
  @attribute.type.json_schema_type
end

.load(value, context = Attributor::DEFAULT_ROOT_CONTEXT, **options) ⇒ Object Also known as: from



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/praxis/blueprint.rb', line 123

def self.load(value, context = Attributor::DEFAULT_ROOT_CONTEXT, **options)
  case value
  when self
    value
  when nil, Hash, String
    if (value = attribute.load(value, context, **options))
      new(value)
    end
  else
    if value.is_a?(domain_model) || value.is_a?(self::InnerStruct)
      # Wrap the value directly
      new(value)
    else
      # Wrap the object inside the domain_model
      new(domain_model.new(value))
    end
  end
end

.new(object) ⇒ Object

Override default new behavior to support memoized creation through an IdentityMap



79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/praxis/blueprint.rb', line 79

def self.new(object)
  # TODO: do we want to allow the identity map thing in the object?...maybe not.
  if @@caching_enabled
    return cache[object] ||= begin
      blueprint = allocate
      blueprint.send(:initialize, object)
      blueprint
    end
  end

  blueprint = allocate
  blueprint.send(:initialize, object)
  blueprint
end

.parse_default_fieldset(block) ⇒ Object



209
210
211
212
# File 'lib/praxis/blueprint.rb', line 209

def self.parse_default_fieldset(block)
  @default_fieldset = FieldsetParser.new(&block).fieldset
  @block_for_default_fieldset = nil
end

.resolve_domain_model!Object



248
249
250
251
252
# File 'lib/praxis/blueprint.rb', line 248

def self.resolve_domain_model!
  return unless domain_model.is_a?(String)

  @domain_model = domain_model.constantize
end

.valid_type?(value) ⇒ Boolean

Returns:

  • (Boolean)


163
164
165
# File 'lib/praxis/blueprint.rb', line 163

def self.valid_type?(value)
  value.is_a?(self) || value.is_a?(attribute.type)
end

.validate(value, context = Attributor::DEFAULT_ROOT_CONTEXT, _attribute = nil) ⇒ Object

Raises:

  • (ArgumentError)


180
181
182
183
184
185
186
187
188
# File 'lib/praxis/blueprint.rb', line 180

def self.validate(value, context = Attributor::DEFAULT_ROOT_CONTEXT, _attribute = nil)
  raise ArgumentError, "Invalid context received (nil) while validating value of type #{name}" if context.nil?

  context = [context] if context.is_a? ::String

  raise ArgumentError, "Error validating #{Attributor.humanize_context(context)} as #{name} for an object of type #{value.class.name}." unless value.is_a?(self)

  value.validate(context)
end

.view(name, **_options, &block) ⇒ Object



196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/praxis/blueprint.rb', line 196

def self.view(name, **_options, &block)
  unless name == :default
    raise "[ERROR] Views are no longer supported. Please use fully expanded fields when rendering.\n" \
          "NOTE that defining the :default view is deprecated, but still temporarily allowed, as an alias to define the default_fieldset.\n" \
          "A view for name #{name} is attempted to be defined in:\n#{Kernel.caller.first}"
  end
  raise 'Cannot define the default fieldset through the default view unless a block is passed' unless block_given?

  puts "[DEPRECATED] default fieldsets should be defined through `default_fieldset` instead of using the view :default block.\n" \
       "A default view is attempted to be defined in:\n#{Kernel.caller.first}"
  default_fieldset(&block)
end

Instance Method Details

#_cache_keyObject

By default we’ll use the object identity, to avoid rendering the same object twice Override, if there is a better way cache things up



311
312
313
# File 'lib/praxis/blueprint.rb', line 311

def _cache_key
  object
end

#_get_attr(name) ⇒ Object

generic semi-private getter used by Renderer



377
378
379
# File 'lib/praxis/blueprint.rb', line 377

def _get_attr(name)
  send(name)
end

#render(fields: self.class.default_fieldset, context: Attributor::DEFAULT_ROOT_CONTEXT, renderer: Renderer.new, **_opts) ⇒ Object Also known as: dump

Render the wrapped data with the given fields (or using the default fieldset otherwise)



316
317
318
319
320
321
# File 'lib/praxis/blueprint.rb', line 316

def render(fields: self.class.default_fieldset, context: Attributor::DEFAULT_ROOT_CONTEXT, renderer: Renderer.new, **_opts)
  # Accept a simple array of fields, and transform it to a 1-level hash with true values
  fields = fields.each_with_object({}) { |field, hash| hash[field] = true } if fields.is_a? Array

  renderer.render(self, fields, context: context)
end

#to_hObject



325
326
327
# File 'lib/praxis/blueprint.rb', line 325

def to_h
  Attributor.recursive_to_h(@object)
end

#validate(context = Attributor::DEFAULT_ROOT_CONTEXT) ⇒ Object



329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
# File 'lib/praxis/blueprint.rb', line 329

def validate(context = Attributor::DEFAULT_ROOT_CONTEXT)
  raise ArgumentError, "Invalid context received (nil) while validating value of type #{name}" if context.nil?

  context = [context] if context.is_a? ::String

  raise 'validation conflict' if @validating

  @validating = true

  errors = []
  keys_provided = []

  keys_provided = object.contents.keys

  keys_provided.each do |key|
    sub_context = self.class.generate_subcontext(context, key)
    attribute = self.class.attributes[key]

    if object.contents[key].nil?
      errors.concat ["Attribute #{Attributor.humanize_context(sub_context)} is not nullable."] if !Attributor::Attribute.nullable_attribute?(attribute.options) && object.contents.key?(key) # It is only nullable if there's an explicite null: true (undefined defaults to false)
      # No need to validate the attribute further if the key wasn't passed...(or we would get nullable errors etc..cause the attribute has no
      # context if its containing key was even passed (and there might not be a containing key for a top level attribute anyways))
    else
      value = _get_attr(key)
      next if value.respond_to?(:validating) && value.validating # really, it's a thing with sub-attributes

      errors.concat attribute.validate(value, sub_context)
    end
  end

  leftover = self.class.attributes.keys - keys_provided
  leftover.each do |key|
    sub_context = self.class.generate_subcontext(context, key)
    attribute = self.class.attributes[key]

    errors.concat ["Attribute #{Attributor.humanize_context(sub_context)} is required."] if attribute.options[:required]
  end

  self.class.attribute.type.requirements.each do |requirement|
    validation_errors = requirement.validate(keys_provided, context)
    errors.concat(validation_errors) unless validation_errors.empty?
  end
  errors
ensure
  @validating = false
end