field_struct 0.15.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/AGENTS.md +58 -0
- data/CHANGELOG.md +653 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/README.md +645 -0
- data/UPGRADING.md +743 -0
- data/USAGE.md +820 -0
- data/docs/getting_started.md +337 -0
- data/lib/field_struct/base.rb +1316 -0
- data/lib/field_struct/error.rb +48 -0
- data/lib/field_struct/errors.rb +103 -0
- data/lib/field_struct/field.rb +179 -0
- data/lib/field_struct/metadata.rb +331 -0
- data/lib/field_struct/model_name.rb +48 -0
- data/lib/field_struct/rbs.rb +299 -0
- data/lib/field_struct/registry.rb +85 -0
- data/lib/field_struct/scaffold.rb +331 -0
- data/lib/field_struct/types/array.rb +156 -0
- data/lib/field_struct/types/base.rb +162 -0
- data/lib/field_struct/types/big_decimal.rb +78 -0
- data/lib/field_struct/types/binary.rb +38 -0
- data/lib/field_struct/types/boolean.rb +126 -0
- data/lib/field_struct/types/date.rb +158 -0
- data/lib/field_struct/types/datetime.rb +163 -0
- data/lib/field_struct/types/email.rb +49 -0
- data/lib/field_struct/types/float.rb +80 -0
- data/lib/field_struct/types/immutable_string.rb +18 -0
- data/lib/field_struct/types/integer.rb +133 -0
- data/lib/field_struct/types/nested.rb +82 -0
- data/lib/field_struct/types/preset_resolver.rb +37 -0
- data/lib/field_struct/types/rfc3339_format.rb +197 -0
- data/lib/field_struct/types/string.rb +73 -0
- data/lib/field_struct/types/symbol.rb +38 -0
- data/lib/field_struct/types/temporal_parser.rb +93 -0
- data/lib/field_struct/types/time.rb +168 -0
- data/lib/field_struct/types/time_format_resolver.rb +54 -0
- data/lib/field_struct/types/union.rb +96 -0
- data/lib/field_struct/types/url.rb +46 -0
- data/lib/field_struct/types/uuid.rb +46 -0
- data/lib/field_struct/types/value.rb +24 -0
- data/lib/field_struct/types/zone_option.rb +267 -0
- data/lib/field_struct/version.rb +7 -0
- data/lib/field_struct.rb +159 -0
- data/sig/field_struct.rbs +2845 -0
- data/skills/field-struct/SKILL.md +183 -0
- metadata +139 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module FieldStruct
|
|
6
|
+
module Types
|
|
7
|
+
# Float type. Coerces via +Kernel#Float+, which parses numeric strings
|
|
8
|
+
# strictly and rejects anything non-numeric. Strings are base 10, as for
|
|
9
|
+
# {Types::Integer}: a hex string like "0x1A", which +Kernel#Float+ would
|
|
10
|
+
# read as 26.0, is refused.
|
|
11
|
+
class Float < Base
|
|
12
|
+
# A string +Kernel#Float+ would read as hex: optional whitespace and
|
|
13
|
+
# sign, then +0x+. (Binary and octal prefixes it already refuses.)
|
|
14
|
+
# @return [::Regexp]
|
|
15
|
+
HEX_PREFIX = /\A\s*[+-]?0x/i
|
|
16
|
+
# Native options: a +round:+ Integer precision and an +in:+ Array or
|
|
17
|
+
# Range of allowed values.
|
|
18
|
+
#
|
|
19
|
+
# @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
|
|
20
|
+
def self.own_option_schema
|
|
21
|
+
{
|
|
22
|
+
round: option(type: [::Integer]),
|
|
23
|
+
in: option(type: [::Array, ::Range])
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [::Integer, nil] field-level rounding precision; +nil+ disables.
|
|
28
|
+
def self.default_round
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param value [Numeric, ::String, nil] +nil+ → +nil+; everything
|
|
33
|
+
# else flows through +Kernel#Float+ — strict on string parsing,
|
|
34
|
+
# rejects non-numeric strings, Symbols, Booleans, Arrays, and
|
|
35
|
+
# hex strings ("0x1A").
|
|
36
|
+
# @param round [::Integer, nil] decimal places, rounding half up (ties
|
|
37
|
+
# away from zero); +nil+ leaves the value unrounded. The result is a
|
|
38
|
+
# Float even at +round: 0+.
|
|
39
|
+
# @return [::Float, nil] +nil+ for +nil+ input, otherwise the parsed float
|
|
40
|
+
# @raise [ArgumentError, TypeError] when the value cannot be coerced
|
|
41
|
+
# (a String that is not a number, a Complex), or coerces to NaN or
|
|
42
|
+
# ±Infinity (including a String like "1e400")
|
|
43
|
+
def coerce(value, round: self.class.default_round, **)
|
|
44
|
+
return nil if value.nil?
|
|
45
|
+
|
|
46
|
+
result = value.is_a?(::Float) ? value : parse(value)
|
|
47
|
+
# The class, never the value — see Types::Time#coerce.
|
|
48
|
+
raise ArgumentError, "Float fields expect a finite number, got a non-finite #{value.class}" unless result.finite?
|
|
49
|
+
|
|
50
|
+
# Float#round returns an Integer at round: 0; .to_f keeps the
|
|
51
|
+
# field's type. half: :up pins ties away from zero.
|
|
52
|
+
round ? result.round(round, half: :up).to_f : result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Class] the top-level +::Float+ class
|
|
56
|
+
def ruby_type
|
|
57
|
+
::Float
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# @param value [Object] a non-Float value
|
|
63
|
+
# @return [::Float]
|
|
64
|
+
# @raise [ArgumentError] when it is a hex string or +Kernel#Float+
|
|
65
|
+
# refuses it; the message names the class, never the value
|
|
66
|
+
# (Kernel's own message echoes it)
|
|
67
|
+
def parse(value)
|
|
68
|
+
if value.is_a?(::String) && HEX_PREFIX.match?(value)
|
|
69
|
+
raise ArgumentError, 'Float fields expect a base-10 number, got a String that is not one'
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
begin
|
|
73
|
+
::Kernel.Float(value)
|
|
74
|
+
rescue ::ArgumentError, ::RangeError
|
|
75
|
+
raise ArgumentError, "Float fields could not read a #{value.class} as a number", cause: nil
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'string'
|
|
4
|
+
|
|
5
|
+
module FieldStruct
|
|
6
|
+
module Types
|
|
7
|
+
# Like {Types::String}, but the coerced value is frozen. Useful when
|
|
8
|
+
# downstream code must not mutate a stored string.
|
|
9
|
+
class ImmutableString < FieldStruct::Types::String
|
|
10
|
+
# @param value [#to_s, nil] anything stringable; +nil+ stays +nil+
|
|
11
|
+
# @return [::String, nil] frozen string, or +nil+ if input is +nil+
|
|
12
|
+
def coerce(value, **)
|
|
13
|
+
result = super
|
|
14
|
+
result.nil? ? nil : result.dup.freeze
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module FieldStruct
|
|
6
|
+
module Types
|
|
7
|
+
# Integer type. Strings parse in base 10 unless the field declares a
|
|
8
|
+
# +base:+, so "010" is ten (not octal eight) and "0x1A" is refused rather
|
|
9
|
+
# than read as hex. Float-shaped strings like "3.14" raise rather than
|
|
10
|
+
# silently truncate. Float numerics, by contrast, are truncated — that
|
|
11
|
+
# matches +Kernel#Integer+'s own behavior on numeric input — unless the
|
|
12
|
+
# field declares +strict: true+, which refuses any non-integral value.
|
|
13
|
+
class Integer < Base
|
|
14
|
+
# The radixes +Kernel#Integer+ accepts for a +base:+ option.
|
|
15
|
+
# @return [::Range<::Integer>]
|
|
16
|
+
BASES = (2..36)
|
|
17
|
+
|
|
18
|
+
# Native options: an +in:+ Array or Range of allowed values, a
|
|
19
|
+
# +base:+ Integer radix (2..36) for string input, and a +strict:+
|
|
20
|
+
# Boolean.
|
|
21
|
+
#
|
|
22
|
+
# @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
|
|
23
|
+
def self.own_option_schema
|
|
24
|
+
{
|
|
25
|
+
in: option(type: [::Array, ::Range]),
|
|
26
|
+
base: option(type: [::Integer]),
|
|
27
|
+
strict: option(type: [::TrueClass, ::FalseClass])
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Refuse a +base:+ outside 2..36 at field-declaration time. A
|
|
32
|
+
# non-Integer +base:+ is left for the option-shape check to report.
|
|
33
|
+
#
|
|
34
|
+
# @param options [::Hash{::Symbol => Object, nil}] the per-field options hash
|
|
35
|
+
# @return [::Hash{::Symbol => Object, nil}] the options, unchanged
|
|
36
|
+
# @raise [ArgumentError] when +base:+ is an Integer outside 2..36
|
|
37
|
+
def self.resolve_options(options)
|
|
38
|
+
base = options[:base]
|
|
39
|
+
if base.is_a?(::Integer) && !BASES.cover?(base)
|
|
40
|
+
raise ArgumentError, "base: on Integer expects an Integer from 2 to 36, got #{base}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
options
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param value [::Integer, Numeric, ::String, nil] +nil+ → +nil+;
|
|
47
|
+
# +Integer+ passes through; a String parses in +base+ (surrounding
|
|
48
|
+
# whitespace and digit underscores allowed; float-shaped or
|
|
49
|
+
# out-of-base strings raise ArgumentError); other numerics flow
|
|
50
|
+
# through +Kernel#Integer+ — Floats are truncated;
|
|
51
|
+
# Symbols/Booleans/Arrays raise TypeError.
|
|
52
|
+
# @param base [::Integer, nil] radix (2..36) for String input; +nil+
|
|
53
|
+
# means 10
|
|
54
|
+
# @param strict [Boolean, nil] when true, refuse a value that is not a
|
|
55
|
+
# whole number (+12.7+, a +Time+) instead of truncating it; an
|
|
56
|
+
# integral one (+12.0+) still becomes its Integer
|
|
57
|
+
# @return [::Integer, nil] +nil+ for +nil+ input, otherwise the parsed integer
|
|
58
|
+
# @raise [ArgumentError, TypeError] when the value cannot be coerced,
|
|
59
|
+
# including a NaN or ±Infinity Numeric (Float, BigDecimal) and a
|
|
60
|
+
# Complex with an imaginary part
|
|
61
|
+
def coerce(value, base: 10, strict: false, **)
|
|
62
|
+
return nil if value.nil?
|
|
63
|
+
return value if value.is_a?(::Integer)
|
|
64
|
+
return parse_string(value, base || 10) if value.is_a?(::String)
|
|
65
|
+
if value.is_a?(::Numeric) && !value.finite?
|
|
66
|
+
# The class, never the value — see Types::Time#coerce.
|
|
67
|
+
raise ArgumentError, "Integer fields expect a finite number, got a non-finite #{value.class}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
refuse_huge_decimal!(value)
|
|
71
|
+
|
|
72
|
+
whole = begin
|
|
73
|
+
::Kernel.Integer(value)
|
|
74
|
+
rescue ::RangeError
|
|
75
|
+
# A Complex with an imaginary part; Kernel's message echoes the value.
|
|
76
|
+
raise ArgumentError, "Integer fields could not read a #{value.class} as a number", cause: nil
|
|
77
|
+
end
|
|
78
|
+
refuse_inexact!(value, whole) if strict
|
|
79
|
+
whole
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The most integer digits a decimal may expand to. +BigDecimal('1e10000000')+
|
|
83
|
+
# is 11 bytes, but its Integer takes seconds to build, so untrusted JSON
|
|
84
|
+
# could burn CPU. The same limit as Python's int/str conversion
|
|
85
|
+
# (CVE-2020-10735).
|
|
86
|
+
#
|
|
87
|
+
# @return [::Integer]
|
|
88
|
+
MAX_DECIMAL_DIGITS = 4300
|
|
89
|
+
|
|
90
|
+
# @return [Class] the top-level +::Integer+ class
|
|
91
|
+
def ruby_type
|
|
92
|
+
::Integer
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# @param value [Object]
|
|
98
|
+
# @return [void]
|
|
99
|
+
# @raise [ArgumentError] when +value+ is a decimal whose integer part has
|
|
100
|
+
# more than {MAX_DECIMAL_DIGITS} digits
|
|
101
|
+
def refuse_huge_decimal!(value)
|
|
102
|
+
return unless defined?(::BigDecimal) && value.is_a?(::BigDecimal)
|
|
103
|
+
return if value.exponent <= MAX_DECIMAL_DIGITS
|
|
104
|
+
|
|
105
|
+
raise ArgumentError, "Integer fields read a decimal of at most #{MAX_DECIMAL_DIGITS} digits, " \
|
|
106
|
+
"got a larger #{value.class}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @param value [Object] the input, already read by +Kernel#Integer+
|
|
110
|
+
# @param whole [::Integer] what +Kernel#Integer+ made of it
|
|
111
|
+
# @return [void]
|
|
112
|
+
# @raise [ArgumentError] unless +whole+ equals +value+ — i.e. the read
|
|
113
|
+
# truncated a fraction, or went through +to_i+ on a non-number
|
|
114
|
+
def refuse_inexact!(value, whole)
|
|
115
|
+
return if whole == value
|
|
116
|
+
|
|
117
|
+
# The class, never the value — see Types::Time#coerce.
|
|
118
|
+
raise ArgumentError, "Integer fields with strict: true expect a whole number, got a #{value.class} that is not one"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @param value [::String]
|
|
122
|
+
# @param base [::Integer]
|
|
123
|
+
# @return [::Integer]
|
|
124
|
+
# @raise [ArgumentError] when the string is not an integer in +base+
|
|
125
|
+
def parse_string(value, base)
|
|
126
|
+
::Kernel.Integer(value, base)
|
|
127
|
+
rescue ::ArgumentError
|
|
128
|
+
# The class, never the value — see Types::Time#coerce.
|
|
129
|
+
raise ArgumentError, "Integer fields expect a base-#{base} integer, got a String that is not one", cause: nil
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module FieldStruct
|
|
6
|
+
module Types
|
|
7
|
+
# Type wrapper for nested FieldStruct fields.
|
|
8
|
+
#
|
|
9
|
+
# class Address < FieldStruct::Base
|
|
10
|
+
# required :street, :string
|
|
11
|
+
# required :city, :string
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# class Person < FieldStruct::Base
|
|
15
|
+
# required :address, Address
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Built and held per-Field by the DSL when it sees a class argument
|
|
19
|
+
# that descends from {FieldStruct::Base}. Each instance is
|
|
20
|
+
# parameterized by its target +struct_class+; instances are not
|
|
21
|
+
# interchangeable across different nested classes.
|
|
22
|
+
class Nested < Base
|
|
23
|
+
# @return [Class] the FieldStruct::Base subclass this type wraps
|
|
24
|
+
attr_reader :struct_class
|
|
25
|
+
|
|
26
|
+
# @param struct_class [Class] a FieldStruct::Base subclass
|
|
27
|
+
def initialize(struct_class)
|
|
28
|
+
super()
|
|
29
|
+
@struct_class = struct_class
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Coerce input into a {#struct_class} instance.
|
|
33
|
+
#
|
|
34
|
+
# - +nil+ → +nil+
|
|
35
|
+
# - already a {#struct_class} (or subclass) instance → passthrough
|
|
36
|
+
# - +Hash+ → +struct_class.new(hash)+
|
|
37
|
+
# - anything else → +TypeError+ (which the parent's coercion_policy
|
|
38
|
+
# handles like any shape-level coercion failure)
|
|
39
|
+
#
|
|
40
|
+
# Errors raised inside +struct_class.new+ (e.g.
|
|
41
|
+
# {UnknownAttributeError}, {CoercionError}) propagate to the caller
|
|
42
|
+
# rather than being caught by the parent's policy — those are
|
|
43
|
+
# structural rejections of the nested record's input, not parent-
|
|
44
|
+
# shape coercion failures.
|
|
45
|
+
#
|
|
46
|
+
# @param value [FieldStruct::Base, ::Hash{::Symbol, ::String => Object, nil}, nil] +nil+ → +nil+; an
|
|
47
|
+
# instance of {#struct_class} (or subclass) → passthrough; a
|
|
48
|
+
# +Hash+ → constructs via +struct_class.new(hash)+. Anything
|
|
49
|
+
# else raises TypeError.
|
|
50
|
+
# @return [FieldStruct::Base, nil]
|
|
51
|
+
# @raise [TypeError] when input is non-nil and neither a
|
|
52
|
+
# {#struct_class} instance nor a Hash
|
|
53
|
+
def coerce(value, **)
|
|
54
|
+
return nil if value.nil?
|
|
55
|
+
return value if value.is_a?(@struct_class)
|
|
56
|
+
return @struct_class.new(value) if value.is_a?(::Hash)
|
|
57
|
+
|
|
58
|
+
raise ::TypeError, "expected #{@struct_class} or Hash, got #{value.class}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param value [Object]
|
|
62
|
+
# @return [Boolean] true only when value is nil — an instance with
|
|
63
|
+
# all required fields missing is not "missing" here, it is just
|
|
64
|
+
# invalid. Invalidity is surfaced by the parent's validate_field.
|
|
65
|
+
def missing?(value)
|
|
66
|
+
value.nil?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Class] the +struct_class+ itself, so introspection sees
|
|
70
|
+
# the actual nested type rather than a generic FieldStruct::Base
|
|
71
|
+
def ruby_type
|
|
72
|
+
@struct_class
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [::String]
|
|
76
|
+
def inspect
|
|
77
|
+
name = @struct_class.name || 'AnonymousFieldStruct'
|
|
78
|
+
"#<#{FieldStruct.inspect_namespace}::Types::Nested struct_class=#{name}>"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FieldStruct
|
|
4
|
+
module Types
|
|
5
|
+
# Generic preset resolution for a single named option on a single
|
|
6
|
+
# type. Symbol values are looked up in the supplied presets hash;
|
|
7
|
+
# anything else passes through. Unknown preset names raise
|
|
8
|
+
# +ArgumentError+ at field-declaration time.
|
|
9
|
+
#
|
|
10
|
+
# Used by string-shaped types (Email/UUID/URL) for +format:+ and by
|
|
11
|
+
# time-shaped types (Date/Time/DateTime) via {TimeFormatResolver}
|
|
12
|
+
# (which adds a stricter set of allowed value types).
|
|
13
|
+
module PresetResolver
|
|
14
|
+
# Resolve +options[key]+ when it's a Symbol preset name, leaving other
|
|
15
|
+
# values untouched.
|
|
16
|
+
#
|
|
17
|
+
# @param options [::Hash{::Symbol => Object, nil}] the per-field options
|
|
18
|
+
# @param key [::Symbol] the option to resolve (e.g. +:format+)
|
|
19
|
+
# @param presets [::Hash{::Symbol => Object}] preset name → concrete value
|
|
20
|
+
# @param label [::String] used in the unknown-preset error message
|
|
21
|
+
# @return [::Hash{::Symbol => Object, nil}] options with +key+ resolved (a copy when changed)
|
|
22
|
+
# @raise [ArgumentError] when +options[key]+ names an unknown preset
|
|
23
|
+
def self.call(options, key, presets, label:)
|
|
24
|
+
return options unless options.key?(key)
|
|
25
|
+
|
|
26
|
+
value = options[key]
|
|
27
|
+
return options unless value.is_a?(::Symbol)
|
|
28
|
+
|
|
29
|
+
resolved = presets.fetch(value) do
|
|
30
|
+
raise ArgumentError,
|
|
31
|
+
"unknown #{label} preset #{value.inspect}. Available: #{presets.keys.inspect}"
|
|
32
|
+
end
|
|
33
|
+
options.merge(key => resolved)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module FieldStruct
|
|
7
|
+
module Types
|
|
8
|
+
# RFC 3339 — the profile of ISO 8601 that "ISO-8601" means in an API
|
|
9
|
+
# contract, and what JSON Schema's +date-time+ refers to. This is what the
|
|
10
|
+
# +:iso8601+ preset resolves to for {Date}, {Time} and {DateTime}.
|
|
11
|
+
#
|
|
12
|
+
# FieldStruct parses and renders it itself, for two reasons.
|
|
13
|
+
#
|
|
14
|
+
# **It cannot be written as a strftime string.** +%z+ renders +\+0000+,
|
|
15
|
+
# which RFC 3339 rejects — so the preset named +iso8601+ used to emit a
|
|
16
|
+
# value its own documented format would refuse. And no strftime string can
|
|
17
|
+
# express "optional fractional seconds", which most JSON APIs emit.
|
|
18
|
+
#
|
|
19
|
+
# **The stdlib helpers disagree with each other.** Routing +Time+ through
|
|
20
|
+
# +Time.iso8601+ and +DateTime+ through +DateTime.iso8601+ would give the
|
|
21
|
+
# same declared preset two different contracts:
|
|
22
|
+
#
|
|
23
|
+
# Time.iso8601('2026-02-30T10:30:00Z') # => 2026-03-02 10:30 UTC
|
|
24
|
+
# DateTime.iso8601('2026-02-30T10:30:00Z') # => refused
|
|
25
|
+
# Time.iso8601('2026-07-03T10:30Z') # => refused
|
|
26
|
+
# DateTime.iso8601('2026-07-03T10:30Z') # => accepted
|
|
27
|
+
# DateTime.iso8601('10:30:00Z') # => TODAY at 10:30
|
|
28
|
+
#
|
|
29
|
+
# +Time.iso8601+ reading 30 February as 2 March is the exact
|
|
30
|
+
# silently-wrong-day defect this release exists to remove, so it cannot be
|
|
31
|
+
# the thing that implements the strict preset.
|
|
32
|
+
#
|
|
33
|
+
# The pattern is deliberately narrow: fixed-width fields, a mandatory
|
|
34
|
+
# offset, and no week dates, ordinal dates or basic (unseparated) forms.
|
|
35
|
+
# That narrowness is the point — it is what lets a caller replace a
|
|
36
|
+
# hand-rolled +/\A\d{4}-\d{2}-\d{2}\z/+ with a declared format.
|
|
37
|
+
class Rfc3339Format
|
|
38
|
+
# Full-width calendar date. No sign, no week or ordinal forms.
|
|
39
|
+
# @return [::Regexp]
|
|
40
|
+
DATE = /\A(\d{4})-(\d{2})-(\d{2})\z/
|
|
41
|
+
|
|
42
|
+
# Date, +T+, time to whole seconds, optional fraction, mandatory offset.
|
|
43
|
+
# +±hhmm+ (no colon) is accepted on input because that is what FieldStruct
|
|
44
|
+
# v0.9.0 emitted, and its own output has to keep parsing.
|
|
45
|
+
#
|
|
46
|
+
# @return [::Regexp]
|
|
47
|
+
TIMESTAMP = /\A(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-]\d{2}:?\d{2})\z/
|
|
48
|
+
|
|
49
|
+
# @return [Rfc3339Format] the date-only form, for {Types::Date}
|
|
50
|
+
def self.date
|
|
51
|
+
@date ||= new(:date)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Rfc3339Format] the timestamp form, for {Types::Time} and {Types::DateTime}
|
|
55
|
+
def self.timestamp
|
|
56
|
+
@timestamp ||= new(:timestamp)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [::Symbol] +:date+ or +:timestamp+
|
|
60
|
+
attr_reader :kind
|
|
61
|
+
|
|
62
|
+
# @param kind [::Symbol] +:date+ or +:timestamp+
|
|
63
|
+
def initialize(kind)
|
|
64
|
+
@kind = kind
|
|
65
|
+
freeze
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [::Symbol] the preset name this format implements
|
|
69
|
+
def name
|
|
70
|
+
:iso8601
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The accepted shape, as a regular expression whose source is also valid
|
|
74
|
+
# as an ECMA pattern — so a schema or documentation generator can publish
|
|
75
|
+
# the enforced contract rather than a prose approximation of it.
|
|
76
|
+
#
|
|
77
|
+
# @return [::Regexp]
|
|
78
|
+
def pattern
|
|
79
|
+
kind == :date ? DATE : TIMESTAMP
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [::String] the format's name, for error messages and inspection
|
|
83
|
+
def to_s
|
|
84
|
+
'iso8601 (RFC 3339)'
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Parse a String into date/time fragments.
|
|
88
|
+
#
|
|
89
|
+
# @param value [::String] the raw string
|
|
90
|
+
# @return [::Hash{::Symbol => Object}] +:year+, +:mon+, +:mday+ and, for a
|
|
91
|
+
# timestamp, +:hour+, +:min+, +:sec+, +:sec_fraction+, +:offset+
|
|
92
|
+
# @raise [ArgumentError] when the string is not RFC 3339, or names a day,
|
|
93
|
+
# hour, minute or second that cannot exist
|
|
94
|
+
def parse(value)
|
|
95
|
+
match = pattern.match(value)
|
|
96
|
+
# The value is not echoed: FieldStruct wraps this as "could not be
|
|
97
|
+
# coerced: <message>" and that string reaches API responses.
|
|
98
|
+
raise ArgumentError, "expected an RFC 3339 #{kind}" if match.nil?
|
|
99
|
+
|
|
100
|
+
kind == :date ? date_parts(match) : timestamp_parts(match)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Render a coerced value back to RFC 3339.
|
|
104
|
+
#
|
|
105
|
+
# @param value [::Date, ::Time, ::DateTime] the coerced value
|
|
106
|
+
# @return [::String]
|
|
107
|
+
def render(value)
|
|
108
|
+
# Date#iso8601, never Date#xmlschema: ActiveSupport redefines the
|
|
109
|
+
# latter to emit a full timestamp, which would make output depend on
|
|
110
|
+
# whether the host loaded Rails.
|
|
111
|
+
return value.iso8601 if kind == :date
|
|
112
|
+
|
|
113
|
+
"#{value.strftime("%Y-%m-%dT%H:%M:%S")}#{fraction_of(value)}#{offset_of(value)}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# @param match [::MatchData] a {DATE} match
|
|
119
|
+
# @return [::Hash{::Symbol => ::Integer}]
|
|
120
|
+
def date_parts(match)
|
|
121
|
+
year, month, day = match.captures.map { |capture| Integer(capture, 10) }
|
|
122
|
+
TemporalParser.ensure_real_day!({year: year, mon: month, mday: day})
|
|
123
|
+
|
|
124
|
+
{year: year, mon: month, mday: day}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @param match [::MatchData] a {TIMESTAMP} match
|
|
128
|
+
# @return [::Hash{::Symbol => Object}]
|
|
129
|
+
def timestamp_parts(match)
|
|
130
|
+
year, month, day, hour, minute, second = match.captures[0, 6].map { |c| Integer(c, 10) }
|
|
131
|
+
TemporalParser.ensure_real_day!({year: year, mon: month, mday: day})
|
|
132
|
+
# RFC 3339 permits a leap second, but Time rolls :60 into the next
|
|
133
|
+
# minute and DateTime clamps it to :59 — the two stdlib classes give
|
|
134
|
+
# different answers, so the strict preset refuses it rather than
|
|
135
|
+
# picking one silently. Hour 24 is not RFC 3339 at all.
|
|
136
|
+
raise ArgumentError, 'invalid time of day' if hour > 23 || minute > 59 || second > 59
|
|
137
|
+
|
|
138
|
+
{
|
|
139
|
+
year: year, mon: month, mday: day, hour: hour, min: minute, sec: second,
|
|
140
|
+
sec_fraction: fraction_from(match.captures[6]),
|
|
141
|
+
offset: offset_from(match.captures[7])
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @param text [::String, nil] the +.digits+ capture, if any
|
|
146
|
+
# @return [::Rational] seconds after the whole second, exact
|
|
147
|
+
def fraction_from(text)
|
|
148
|
+
return 0r if text.nil?
|
|
149
|
+
|
|
150
|
+
::Kernel.Rational(text[1..], 10**(text.length - 1))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @return [::Integer] seconds east of UTC
|
|
154
|
+
# @raise [ArgumentError] when the offset names an hour or minute that
|
|
155
|
+
# cannot exist. Validated here rather than left to the constructors,
|
|
156
|
+
# because they disagree: +Time.new+ refuses a +25:00 offset while
|
|
157
|
+
# +DateTime.new+ accepts it and silently drops it to +00:00. A preset
|
|
158
|
+
# that means two different things depending on the field's type is the
|
|
159
|
+
# defect this class exists to avoid.
|
|
160
|
+
# @param text [::String] the offset capture: +Z+ or +±hh:mm+ / +±hhmm+
|
|
161
|
+
def offset_from(text)
|
|
162
|
+
return 0 if text.casecmp('Z').zero?
|
|
163
|
+
|
|
164
|
+
sign = text.start_with?('-') ? -1 : 1
|
|
165
|
+
digits = text[1..].delete(':')
|
|
166
|
+
hours = Integer(digits[0, 2], 10)
|
|
167
|
+
minutes = Integer(digits[2, 2], 10)
|
|
168
|
+
raise ArgumentError, 'invalid UTC offset' if hours > 23 || minutes > 59
|
|
169
|
+
|
|
170
|
+
sign * ((hours * 3600) + (minutes * 60))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# @param value [::Time, ::DateTime]
|
|
174
|
+
# @return [::String] +".123"+, or empty when the value is on a whole second
|
|
175
|
+
def fraction_of(value)
|
|
176
|
+
subsec = value.is_a?(::Time) ? value.subsec : value.sec_fraction
|
|
177
|
+
return '' if subsec.zero?
|
|
178
|
+
|
|
179
|
+
# Exact digits from the Rational; %f would round to six places and
|
|
180
|
+
# invent precision the value never carried.
|
|
181
|
+
digits = (subsec.to_r * 1_000_000_000).to_i.to_s.rjust(9, '0').sub(/0+\z/, '')
|
|
182
|
+
".#{digits}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# @param value [::Time, ::DateTime]
|
|
186
|
+
# @return [::String] +"Z"+ for UTC, otherwise +"+HH:MM"+
|
|
187
|
+
def offset_of(value)
|
|
188
|
+
seconds = value.is_a?(::Time) ? value.utc_offset : (value.offset * 86_400).to_i
|
|
189
|
+
return 'Z' if seconds.zero?
|
|
190
|
+
|
|
191
|
+
sign = seconds.negative? ? '-' : '+'
|
|
192
|
+
magnitude = seconds.abs
|
|
193
|
+
format('%s%02d:%02d', sign, magnitude / 3600, (magnitude % 3600) / 60)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module FieldStruct
|
|
6
|
+
module Types
|
|
7
|
+
# String type. Coerces any non-nil value via +to_s+; treats nil, empty,
|
|
8
|
+
# and whitespace-only strings as missing. A field declared +strict: true+
|
|
9
|
+
# accepts only scalars (String, Symbol, Integer) and objects
|
|
10
|
+
# responding to +to_str+, so a Hash or Array is refused rather than stored
|
|
11
|
+
# as its +inspect+ text.
|
|
12
|
+
class String < Base
|
|
13
|
+
# Native options: a +format:+ Regexp (or Symbol preset, used by the
|
|
14
|
+
# +:uuid+ / +:url+ / +:email+ subtypes), an +enum:+ Array of
|
|
15
|
+
# allowed values, and a +strict:+ Boolean.
|
|
16
|
+
#
|
|
17
|
+
# @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
|
|
18
|
+
def self.own_option_schema
|
|
19
|
+
{
|
|
20
|
+
format: option(type: [::Regexp, ::Symbol]),
|
|
21
|
+
enum: option(type: [::Array]),
|
|
22
|
+
strict: option(type: [::TrueClass, ::FalseClass])
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param value [#to_s, nil] anything stringable (everything in
|
|
27
|
+
# Ruby responds to +to_s+); +nil+ stays +nil+
|
|
28
|
+
# @param strict [Boolean, nil] when true, refuse anything but a String,
|
|
29
|
+
# Symbol, Integer or +to_str+ object (a Float, BigDecimal or Rational
|
|
30
|
+
# is refused: its +to_s+ is +"0.12e2"+ / +"1/2"+, not the caller's text). Strict never changes
|
|
31
|
+
# what an accepted value becomes — it is still +value.to_s+.
|
|
32
|
+
# @return [::String, nil] +nil+ when input is +nil+, otherwise +value.to_s+
|
|
33
|
+
# @raise [TypeError] under +strict:+, for any other value
|
|
34
|
+
def coerce(value, strict: false, **)
|
|
35
|
+
return nil if value.nil?
|
|
36
|
+
|
|
37
|
+
refuse_non_scalar!(value) if strict
|
|
38
|
+
value.to_s
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Missing if nil, empty, or whitespace-only.
|
|
42
|
+
#
|
|
43
|
+
# @param value [Object]
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def missing?(value)
|
|
46
|
+
return true if value.nil?
|
|
47
|
+
return true if value.respond_to?(:strip) && value.strip.empty?
|
|
48
|
+
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Class] the top-level +::String+ class
|
|
53
|
+
def ruby_type
|
|
54
|
+
::String
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# @param value [Object] a non-nil value
|
|
60
|
+
# @return [void]
|
|
61
|
+
# @raise [TypeError] unless +value+ is a String, Symbol, Integer, or
|
|
62
|
+
# responds to +to_str+; the message names the class, never the value
|
|
63
|
+
# (see Types::Time#coerce)
|
|
64
|
+
def refuse_non_scalar!(value)
|
|
65
|
+
return if value.is_a?(::String) || value.is_a?(::Symbol) || value.is_a?(::Integer)
|
|
66
|
+
return if value.respond_to?(:to_str)
|
|
67
|
+
|
|
68
|
+
raise ::TypeError, 'String fields with strict: true expect a String, Symbol, Integer ' \
|
|
69
|
+
"or an object responding to #to_str, got a #{value.class}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module FieldStruct
|
|
6
|
+
module Types
|
|
7
|
+
# Symbol type. Accepts an existing +Symbol+ or a +String+. Anything else
|
|
8
|
+
# raises +TypeError+, which the parent's +coercion_policy+ handles.
|
|
9
|
+
#
|
|
10
|
+
# Empty symbols (+:""+) are unusual; like other scalar non-string types,
|
|
11
|
+
# +missing?+ is nil-only — an empty symbol counts as present.
|
|
12
|
+
class Symbol < Base
|
|
13
|
+
# Native options: an +enum:+ Array of allowed values (symbols are
|
|
14
|
+
# string-like, so they share +enum:+ with {Types::String}).
|
|
15
|
+
#
|
|
16
|
+
# @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
|
|
17
|
+
def self.own_option_schema
|
|
18
|
+
{enum: option(type: [::Array])}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param value [::Symbol, ::String, nil] anything else raises TypeError
|
|
22
|
+
# @return [::Symbol, nil]
|
|
23
|
+
# @raise [TypeError] when input is not nil/Symbol/String
|
|
24
|
+
def coerce(value, **)
|
|
25
|
+
return nil if value.nil?
|
|
26
|
+
return value if value.is_a?(::Symbol)
|
|
27
|
+
return value.to_sym if value.is_a?(::String)
|
|
28
|
+
|
|
29
|
+
raise ::TypeError, "expected Symbol or String, got #{value.class}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Class] the top-level +::Symbol+ class
|
|
33
|
+
def ruby_type
|
|
34
|
+
::Symbol
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|