Module: Typstify::Data

Defined in:
lib/typstify/data.rb

Overview

Turns the data: you pass into the data.json a template reads.

The validation pass exists so that a bad value fails immediately, naming the exact key path, instead of surfacing later as a template that silently renders "#Invoice:0x00007f…" — or, under Rails, as an empty object.

That last case is the subtle one. ActiveSupport defines as_json on Object itself, returning the instance variables of anything at all, so a naive respond_to?(:as_json) check would happily serialise a Proc, an IO or a half-built value object into {} and render a blank invoice. We therefore accept as_json only from classes that actually define it — models, serializers, Hash, Array, Time — and reject the generic fallback.

Constant Summary collapse

PRIMITIVES =
[String, Integer, TrueClass, FalseClass, NilClass].freeze
GENERIC_AS_JSON_OWNERS =

as_json inherited from one of these is ActiveSupport's catch-all, not a deliberate serialization.

[Object, Kernel, BasicObject].freeze

Class Method Summary collapse

Class Method Details

.deliberate_as_json?(value) ⇒ Boolean

True when the object's class defines as_json itself, rather than picking up ActiveSupport's Object-level fallback.

Returns:

  • (Boolean)


82
83
84
85
86
87
88
# File 'lib/typstify/data.rb', line 82

def deliberate_as_json?(value)
  return false unless value.respond_to?(:as_json)

  !GENERIC_AS_JSON_OWNERS.include?(value.method(:as_json).owner)
rescue NameError
  false
end

.deliberate_to_h?(value) ⇒ Boolean

Returns:

  • (Boolean)


90
91
92
93
94
95
96
# File 'lib/typstify/data.rb', line 90

def deliberate_to_h?(value)
  return false unless value.respond_to?(:to_h)

  !GENERIC_AS_JSON_OWNERS.include?(value.method(:to_h).owner)
rescue NameError
  false
end

.dump(data) ⇒ String

Returns pretty JSON, ready to write as data.json.

Parameters:

  • data (Object)

    any JSON-serializable structure

Returns:

  • (String)

    pretty JSON, ready to write as data.json

Raises:

  • (ArgumentError)

    naming the key path of the first bad value



31
32
33
# File 'lib/typstify/data.rb', line 31

def dump(data)
  JSON.pretty_generate(normalize(data, ["data"]))
end

.join(path) ⇒ Object

["data", ".line_items", "[0]", ".amount"] => data.line_items.amount



99
100
101
# File 'lib/typstify/data.rb', line 99

def join(path)
  path.join
end

.normalize(value, path) ⇒ Object



35
36
37
38
39
40
41
42
43
44
45
# File 'lib/typstify/data.rb', line 35

def normalize(value, path)
  case value
  when *PRIMITIVES        then value
  when Symbol             then value.to_s
  when Float              then normalize_float(value, path)
  when Hash               then normalize_hash(value, path)
  when Array              then value.each_with_index.map { |v, i| normalize(v, path + ["[#{i}]"]) }
  when Time, Date         then value.iso8601
  else                         normalize_object(value, path)
  end
end

.normalize_float(float, path) ⇒ Object

Raises:

  • (ArgumentError)


58
59
60
61
62
# File 'lib/typstify/data.rb', line 58

def normalize_float(float, path)
  return float if float.finite?

  raise ArgumentError, "#{join(path)} is #{float}, which JSON cannot represent."
end

.normalize_hash(hash, path) ⇒ Object



47
48
49
50
51
52
53
54
55
56
# File 'lib/typstify/data.rb', line 47

def normalize_hash(hash, path)
  hash.each_with_object({}) do |(key, value), out|
    unless key.is_a?(String) || key.is_a?(Symbol) || key.is_a?(Numeric)
      raise ArgumentError, "#{join(path)} has a #{key.class} key (#{key.inspect}); " \
                           "JSON object keys must be strings, symbols or numbers."
    end

    out[key.to_s] = normalize(value, path + [".#{key}"])
  end
end

.normalize_object(value, path) ⇒ Object



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/typstify/data.rb', line 64

def normalize_object(value, path)
  if deliberate_as_json?(value)
    normalize(value.as_json, path)
  elsif deliberate_to_h?(value)
    normalize(value.to_h, path)
  else
    raise ArgumentError, <<~MSG.strip
      #{join(path)} is a #{value.class}, which is not JSON-serializable.

      Convert it first — a serializer, a class that defines #as_json, or a plain
      Hash of strings and numbers. Passing it through would put an empty object
      or an inspect string into your document.
    MSG
  end
end